@vielzeug/codex 2.0.0 → 2.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/data/catalog.json +139 -130
  2. package/data/llms-full.txt +13091 -17593
  3. package/data/llms.txt +12 -11
  4. package/data/manifest.json +1 -1
  5. package/data/packages/arsenal.json +1 -1
  6. package/data/packages/assay.json +1 -1
  7. package/data/packages/clockwork.json +2 -2
  8. package/data/packages/codex.json +1 -1
  9. package/data/packages/coins.json +1 -1
  10. package/data/packages/conduit.json +1 -1
  11. package/data/packages/courier.json +1 -1
  12. package/data/packages/dnd.json +14 -12
  13. package/data/packages/familiar.json +26 -16
  14. package/data/packages/flux.json +1 -1
  15. package/data/packages/forge.json +1 -1
  16. package/data/packages/herald.json +19 -33
  17. package/data/packages/keymap.json +13 -19
  18. package/data/packages/ledger.json +28 -25
  19. package/data/packages/lingua.json +30 -28
  20. package/data/packages/necromancer.json +50 -0
  21. package/data/packages/orbit.json +34 -39
  22. package/data/packages/ore.json +1 -1
  23. package/data/packages/prism.json +37 -40
  24. package/data/packages/pulse.json +26 -24
  25. package/data/packages/refine.json +1 -1
  26. package/data/packages/ripple.json +1 -1
  27. package/data/packages/rune.json +6 -7
  28. package/data/packages/sandbox.json +7 -6
  29. package/data/packages/scout.json +10 -10
  30. package/data/packages/scroll.json +18 -17
  31. package/data/packages/sourcerer.json +1 -1
  32. package/data/packages/spell.json +1 -1
  33. package/data/packages/tempo.json +49 -81
  34. package/data/packages/vault.json +37 -40
  35. package/data/packages/ward.json +5 -17
  36. package/data/packages/wayfinder.json +9 -9
  37. package/data/refine.json +4914 -4914
  38. package/data/search.json +210 -211
  39. package/dist/cli.js +1 -1
  40. package/dist/cli.js.map +1 -1
  41. package/dist/http.js +46 -6
  42. package/dist/http.js.map +1 -1
  43. package/dist/server.js +1 -1
  44. package/dist/server.js.map +1 -1
  45. package/dist/tools/index.js +13 -5
  46. package/dist/tools/index.js.map +1 -1
  47. package/package.json +4 -4
@@ -1,10 +1,10 @@
1
1
  {
2
- "apiSource": "export { findShortcutConflicts } from './conflicts';\nexport { KeymapError, KeymapParseError } from './errors';\nexport { formatShortcut } from './format';\nexport { createKeymap } from './keymap';\nexport { createKeymapLayer } from './layer';\nexport { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';\nexport type { ConflictOptions } from './conflicts';\nexport type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';\nexport type { KeymapLayer } from './layer';\nexport type { ModifierKey, Shortcut, ShortcutStep } from './parser';\n",
2
+ "apiSource": "export { findShortcutConflicts } from './conflicts';\nexport { KeymapError, KeymapParseError } from './errors';\nexport { formatShortcut } from './format';\nexport { createKeymap } from './keymap';\nexport { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';\nexport type { ConflictOptions } from './conflicts';\nexport type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';\nexport type { ModifierKey, Shortcut, ShortcutStep } from './parser';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Keymap — Headless keyboard shortcut manager\ndescription: Chord-aware keyboard shortcut manager with context guards, modifier aliases, and disposable bindings — no DOM assumptions.\npackage: keymap\ncategory: app-infrastructure\nkeywords: [keyboard, shortcuts, hotkeys, chord, keybinding, headless, accessibility]\nexports:\n [\n canonicalizeShortcut,\n createKeymap,\n createKeymapLayer,\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 is error-prone: modifier key normalisation, platform differences (`ctrl` vs `meta`), chord sequences, and cleanup all require boilerplate. Keymap handles all of it in a headless, zero-dependency package.\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 save = () => console.log('save');\nconst map = createKeymap({ 'mod+s': save });\nconst unmount = map.mount(document);\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 | `when()` predicate per keymap |\n| Headless / SSR-safe | DOM required | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Disposable | Manual `removeEventListener` | `dispose()` + `using` |\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\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst save = () => console.log('save');\nconst openPalette = () => console.log('palette');\nconst goToTop = () => window.scrollTo({ top: 0 });\nconst closePanel = () => console.log('close');\n\nconst map = createKeymap({\n 'ctrl+k ctrl+s': () => save(),\n 'meta+shift+p': () => openPalette(),\n 'g g': () => goToTop(),\n escape: () => closePanel(),\n});\n\nconst unmount = map.mount(document);\n\n// Later:\nunmount(); // remove from this target only\nmap.dispose(); // or: using map = createKeymap(…)\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?, priority? }` 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- `createKeymapLayer()` — scoped keymap stack with `activate()` / `deactivate()`\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, trigger, priority) 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\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: Full API reference for @vielzeug/keymap — createKeymap, createKeymapLayer, formatShortcut, and all types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Export | Kind | Description |\n| ------ | ---- | ----------- |\n| `createKeymap` | function | Creates a headless keyboard shortcut manager |\n| `createKeymapLayer` | function | Creates a scoped keymap layer that stacks on a parent |\n| `formatShortcut` | function | Formats a shortcut string for display (Mac symbols or word labels) |\n| `findShortcutConflicts` | function | Finds registered bindings that would conflict with a proposed shortcut |\n| `KeymapError` | class | Base class for all keymap errors |\n| `KeymapParseError` | class | Thrown when a shortcut string cannot be parsed |\n| `Keymap` | interface | Object returned by `createKeymap` |\n| `KeymapLayer` | interface | Extends `Keymap` with `activate()`, `deactivate()`, `active` |\n| `KeymapOptions` | interface | Options for `createKeymap` and `createKeymapLayer` |\n| `BindingOptions` | type | Per-binding object: `{ handler, when?, trigger?, priority? }` |\n| `BindingValue` | type | `Handler \\| BindingOptions` — accepted wherever a handler is bound |\n| `Handler` | type | `(event: KeyboardEvent) => void` |\n| `parseShortcut` | function | Parses a shortcut string into `ShortcutStep[]` |\n| `parseStep` | function | Parses a single chord step into `ShortcutStep \\| null` |\n| `matchStep` | function | Tests whether a `KeyboardEvent` matches a `ShortcutStep` |\n| `canonicalizeShortcut` | function | Converts `ShortcutStep[]` into a stable canonical string |\n| `detectModKey` | function | Detects the platform modifier key (`'ctrl'` or `'meta'`) |\n| `ShortcutStep` | type | `{ key: string; modifiers: Set<ModifierKey> }` — one parsed step |\n| `Shortcut` | type | `ShortcutStep[]` — the result of `parseShortcut` |\n| `ModifierKey` | type | `'alt' \\| 'ctrl' \\| 'meta' \\| 'shift'` |\n| `BindingEntry` | type | Snapshot of a registered binding: `{ shortcut, trigger, priority }` |\n| `ConflictOptions` | type | Options for `findShortcutConflicts`: `{ modKey?, trigger? }` |\n\n## Package Entry Points\n\n```ts\nimport {\n canonicalizeShortcut, createKeymap, createKeymapLayer, detectModKey,\n findShortcutConflicts, formatShortcut, KeymapError, KeymapParseError,\n matchStep, parseShortcut, parseStep,\n} from '@vielzeug/keymap';\nimport type {\n BindingEntry, BindingOptions, BindingValue, ConflictOptions, Handler,\n Keymap, KeymapLayer, KeymapOptions, ModifierKey, Shortcut, ShortcutStep,\n} from '@vielzeug/keymap';\n```\n\n## `createKeymap(bindings?, options?)`\n\nCreates a headless keyboard shortcut manager.\n\n```ts\nfunction createKeymap(\n bindings?: Record<string, BindingValue>,\n options?: KeymapOptions,\n): Keymap\n```\n\n**Parameters**\n\n- `bindings` — Optional record mapping shortcut strings to `BindingValue`. Shortcut strings support chord sequences (space-separated steps), modifier aliases (`mod`, `cmd`, `ctrl`, `alt`, `shift`), special key aliases (`esc`, `space`, `up`, etc.), and are case-insensitive.\n- `options` — Optional configuration (see `KeymapOptions`).\n\n**Returns** a `Keymap` object.\n\n**Example**\n\n```ts\nconst map = createKeymap({\n 'mod+k mod+s': () => save(),\n 'mod+shift+p': () => openPalette(),\n 'g g': () => goToTop(),\n esc: { handler: closePanel, when: () => isPanelOpen() },\n space: { handler: togglePlay, trigger: 'keyup' },\n}, { modKey: 'ctrl' });\n\nconst unmount = map.mount(document);\n```\n\n## `Keymap`\n\n```ts\ninterface Keymap {\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 [Symbol.dispose](): void;\n}\n```\n\n### `mount(target)`\n\nAttaches `keydown` and `keyup` listeners to `target`. Returns an unmount function that removes only the listeners added by this call.\n\n```ts\nconst unmount = map.mount(document);\nunmount(); // detach\n```\n\nOne keymap can be mounted to multiple targets simultaneously. Each call returns an independent unmount function. Mounting the **same** target a second time without unmounting first still attaches a second listener (handlers fire twice) — this emits a dev warning rather than throwing, since remounting the same target is occasionally intentional.\n\n### `bind(shortcut, value)`\n\nAdds or replaces a binding at runtime. Returns an unbind function.\n\n```ts\nconst unbind = map.bind('ctrl+shift+f', () => openSearch());\nunbind(); // remove just this binding\n```\n\nThrows if `shortcut` is invalid (modifier-only, empty, or ambiguous).\n\n### `unbind(shortcut)`\n\nRemoves the binding for the given shortcut string. Emits a dev warning if the shortcut is not registered; never throws.\n\n```ts\nmap.unbind('ctrl+k');\n```\n\n### `dispose()`\n\nRemoves all mounted listeners and resets chord state. Idempotent.\n\n```ts\nmap.dispose();\n// or:\nusing map = createKeymap({ ... });\n```\n\n### `listBindings()`\n\nReturns a snapshot of all currently registered bindings. Does not include `handler` or `when` — only the shortcut shape, trigger, and priority.\n\n```ts\nconst entries = map.listBindings();\n// [\n// { shortcut: [{ key: 'k', modifiers: Set { 'ctrl' } }], trigger: 'keydown', priority: 0 },\n// ]\n```\n\nUseful for building shortcut palette UIs, conflict detection, and accessibility overlays.\n\n## `KeymapOptions`\n\n```ts\ninterface KeymapOptions {\n chordTimeout?: number; // default: 1000ms\n modKey?: 'ctrl' | 'meta'; // default: platform-detected\n preventDefault?: boolean; // default: true\n stopPropagation?: boolean; // default: false\n when?: () => boolean;\n}\n```\n\n| Option | Default | Description |\n| ------ | ------- | ----------- |\n| `chordTimeout` | `1000` | Milliseconds before a partial chord sequence resets. A non-finite or non-positive value falls back to `1000` with a dev warning. |\n| `modKey` | platform | Override `mod` alias resolution: `'meta'` (Mac ⌘) or `'ctrl'` (Windows/Linux). Auto-detected from `navigator` when omitted. |\n| `preventDefault` | `true` | Call `event.preventDefault()` on a matched binding |\n| `stopPropagation` | `false` | Call `event.stopPropagation()` on a matched binding |\n| `when` | — | Global guard predicate; all bindings are suppressed when `when()` returns `false` |\n\n## `createKeymapLayer(parent, bindings?, options?)`\n\nCreates a scoped keymap layer that stacks on top of a parent keymap. The caller is responsible for mounting both the parent and the layer independently — each manages its own event listeners.\n\n```ts\nfunction createKeymapLayer(\n parent: Keymap,\n bindings?: Record<string, BindingValue>,\n options?: KeymapOptions,\n): KeymapLayer\n```\n\n**Example**\n\n```ts\nconst base = createKeymap({ 'ctrl+z': undo });\nconst modal = createKeymapLayer(base, {\n esc: { handler: closeModal, when: () => isModalOpen() },\n});\n\n// Mount parent and layer independently — each manages its own listeners.\nconst unmountBase = base.mount(document);\nconst unmountModal = modal.mount(document);\n\nmodal.deactivate(); // base handles everything; layer is suspended\nmodal.activate(); // layer resumes\n\nunmountModal();\nunmountBase();\n```\n\nDisposing the layer does **not** dispose the parent — the caller owns the parent lifecycle.\n\n## `KeymapLayer`\n\n```ts\ninterface KeymapLayer extends Keymap {\n activate(): void;\n deactivate(): void;\n readonly active: boolean;\n readonly parent: Keymap;\n}\n```\n\n| Member | Description |\n| ------ | ----------- |\n| `activate()` | Re-enables the layer (default: active) |\n| `deactivate()` | Suspends the layer; the parent keymap continues to fire normally |\n| `active` | `true` when the layer is currently active |\n| `parent` | Returns the parent `Keymap` passed to `createKeymapLayer` |\n| `listBindings()` | Returns the layer's own bindings (not the parent's) |\n\n## `formatShortcut(shortcut, modKey?)`\n\nFormats a shortcut string into a human-readable display string. Resolves `mod` using `modKey`.\n\n```ts\nfunction formatShortcut(\n shortcut: string,\n modKey?: 'ctrl' | 'meta',\n): string\n```\n\nOn Mac (`modKey: 'meta'`), uses standard Mac symbols. On other platforms, uses word labels.\n\n```ts\nformatShortcut('mod+shift+p', 'meta') // '⇧⌘P'\nformatShortcut('mod+shift+p', 'ctrl') // 'Ctrl+Shift+P'\nformatShortcut('ctrl+k ctrl+s', 'meta') // '⌃K ⌃S'\nformatShortcut('escape', 'meta') // 'Esc'\n```\n\n## `findShortcutConflicts(shortcut, entries, options?)`\n\nFinds registered bindings that would conflict with a proposed shortcut — an exact duplicate, a\nshorter binding that would be shadowed as a chord prefix, or a longer binding the proposed\nshortcut would itself shadow. Only compares against entries sharing the same `trigger`.\n\n```ts\nfunction findShortcutConflicts(\n shortcut: string,\n entries: readonly BindingEntry[],\n options?: ConflictOptions,\n): BindingEntry[]\n```\n\n**Parameters**\n\n- `shortcut` — The shortcut string being considered for a new binding.\n- `entries` — Existing bindings to check against — typically `map.listBindings()`.\n- `options` — See `ConflictOptions`. `trigger` defaults to `'keydown'`.\n\n**Returns** the subset of `entries` that conflict; `[]` if there's no relationship (or `shortcut` is\nempty/whitespace-only).\n\n**Example**\n\n```ts\nconst map = createKeymap({ g: () => scrollToTop() });\n\nfindShortcutConflicts('g g', map.listBindings());\n// → [{ shortcut: [{ key: 'g', modifiers: Set {} }], trigger: 'keydown', priority: 0 }]\n// binding 'g g' would be shadowed: 'g' fires immediately before the second step is ever read\n```\n\nUseful when building a shortcut-customization UI — check `findShortcutConflicts()` before calling\n`bind()` to warn the user instead of silently creating an unreachable binding.\n\n### `ConflictOptions`\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n| Field | Default | Description |\n| ----- | ------- | ----------- |\n| `modKey` | platform | Resolves `mod` in the proposed `shortcut` string |\n| `trigger` | `'keydown'` | Which entries to compare against — `'keydown'` and `'keyup'` never conflict with each other |\n\n## Errors\n\n### `KeymapError`\n\nBase class for all keymap errors. Use `instanceof KeymapError` (or `KeymapError.is()`) to catch\nany keymap-originated error.\n\n```ts\nclass KeymapError extends Error {\n static is(err: unknown): err is KeymapError;\n}\n```\n\n### `KeymapParseError`\n\nThrown when a shortcut string cannot be parsed — an ambiguous multi-key step (e.g. `'ctrl+k+j'`)\nor an invalid step (modifier-only, with no key). Extends `KeymapError`.\n\n```ts\nclass KeymapParseError extends KeymapError {}\n```\n\n```ts\nimport { KeymapError, KeymapParseError } from '@vielzeug/keymap';\n\ntry {\n map.bind('ctrl+k+j', handler);\n} catch (err) {\n if (KeymapError.is(err)) {\n console.error(err.message); // 'Ambiguous shortcut step: \"ctrl+k+j\" — multiple non-modifier keys found'\n }\n}\n```\n\n## Types\n\n### `BindingOptions`\n\nPer-binding configuration object.\n\n```ts\ntype BindingOptions = {\n handler: Handler;\n priority?: number; // default: 0\n trigger?: 'keydown' | 'keyup'; // default: 'keydown'\n when?: () => boolean;\n};\n```\n\n| Field | Default | Description |\n| ----- | ------- | ----------- |\n| `handler` | — | The function to call when the shortcut fires |\n| `priority` | `0` | Reserved for future conflict resolution — see note below. A non-finite value falls back to `0` with a dev warning. |\n| `trigger` | `'keydown'` | Which keyboard event phase fires the handler |\n| `when` | — | Per-binding guard; handler suppressed when `when()` returns `false` at event time |\n\n> **Note on `priority`:** because bindings are keyed by their canonical shortcut string, two *live* bindings can never share an identical step sequence — the moment they would, the second `bind()` call simply replaces the first (see `bind(shortcut, value)` above). There is currently no scenario where two distinct bindings compete to fire the same event, so `priority` has no observable effect on which handler runs. It's kept as a documented, validated field for forward compatibility rather than removed outright.\n\n### `BindingValue`\n\n```ts\ntype BindingValue = Handler | BindingOptions;\n```\n\nA plain function is treated as `{ handler: fn, priority: 0, trigger: 'keydown' }`. Use `BindingOptions` for any per-binding customisation.\n\n```ts\nconst map = createKeymap({\n 'ctrl+k': () => quickAction(),\n esc: { handler: closePanel, when: () => isPanelOpen() },\n space: { handler: togglePlay, trigger: 'keyup' },\n});\n```\n\n### `Handler`\n\n```ts\ntype Handler = (event: KeyboardEvent) => void;\n```\n\n### `ShortcutStep`\n\nOne parsed step within a shortcut sequence.\n\n```ts\ntype ShortcutStep = {\n key: string; // lowercase, alias-resolved key name\n modifiers: Set<ModifierKey>; // required modifier keys\n};\n```\n\n### `Shortcut`\n\nAn alias for `ShortcutStep[]` — the direct return type of `parseShortcut`.\n\n```ts\ntype Shortcut = ShortcutStep[];\n```\n\n### `ModifierKey`\n\n```ts\ntype ModifierKey = 'alt' | 'ctrl' | 'meta' | 'shift';\n```\n\n### `BindingEntry`\n\nA read-only snapshot of a registered binding, returned by `listBindings()`. The `handler` and `when` guard are intentionally omitted.\n\n```ts\ntype BindingEntry = {\n readonly priority: number;\n readonly shortcut: readonly ShortcutStep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n| Field | Description |\n| ----- | ----------- |\n| `priority` | The binding's priority value |\n| `shortcut` | The parsed shortcut steps |\n| `trigger` | Which event phase fires the handler |\n\n---\n\n## Parser Utilities\n\n### `parseShortcut(raw, modKey?)`\n\nParses a shortcut string into an array of `ShortcutStep` objects. Useful for building custom matchers, testing, or integrating with other libraries.\n\n```ts\nfunction parseShortcut(\n raw: string,\n modKey?: 'ctrl' | 'meta', // default: auto-detected\n): Shortcut\n```\n\nThrows if any non-empty step is invalid (modifier-only with no key, or ambiguous multi-key step like `ctrl+k+j`). Extra whitespace between steps is silently ignored.\n\n```ts\nparseShortcut('ctrl+k ctrl+s', 'ctrl')\n// [\n// { key: 'k', modifiers: Set { 'ctrl' } },\n// { key: 's', modifiers: Set { 'ctrl' } },\n// ]\n```\n\n### `parseStep(raw, modKey?)`\n\nParses a **single** chord step (one keypress) into a `ShortcutStep`, or returns `null` if the step is empty or invalid. Does not throw.\n\n```ts\nfunction parseStep(\n raw: string,\n modKey?: 'ctrl' | 'meta',\n): ShortcutStep | null\n```\n\nUnlike `parseShortcut`, `parseStep` returns `null` instead of throwing on invalid input — useful for \"try\" patterns when parsing user-typed shortcut strings one step at a time.\n\n```ts\nparseStep('ctrl+k', 'ctrl') // { key: 'k', modifiers: Set { 'ctrl' } }\nparseStep('', 'ctrl') // null\n```\n\n### `matchStep(event, step)`\n\nTests whether a `KeyboardEvent` matches a `ShortcutStep`. Zero allocations — pure boolean comparisons.\n\n```ts\nfunction matchStep(event: KeyboardEvent, step: ShortcutStep): boolean\n```\n\nReturns `false` (never throws) for a malformed event missing a string `.key` — safe to call with hand-built event objects in headless/non-DOM usage.\n\n### `canonicalizeShortcut(steps)`\n\nConverts a `ShortcutStep[]` (i.e. the result of `parseShortcut`) into a stable canonical string. Modifiers are sorted alphabetically, steps are space-separated. Useful for conflict detection: two shortcuts resolve to the same canonical string if and only if they match the same key events.\n\n```ts\nfunction canonicalizeShortcut(steps: readonly ShortcutStep[]): string\n```\n\n```ts\ncanonicalizeShortcut(parseShortcut('cmd+k', 'ctrl')) // 'meta+k'\ncanonicalizeShortcut(parseShortcut('meta+k', 'ctrl')) // 'meta+k'\ncanonicalizeShortcut(parseShortcut('ctrl+k ctrl+s', 'ctrl')) // 'ctrl+k ctrl+s'\n```\n\n### `detectModKey()`\n\nDetects the platform modifier key. Returns `'meta'` on macOS, `'ctrl'` elsewhere.\n\n```ts\nfunction detectModKey(): 'ctrl' | 'meta'\n```\n\nUseful when you need a consistent `modKey` across multiple calls to `createKeymap`, `formatShortcut`, and `parseShortcut` without threading it manually.\n\n```ts\nconst modKey = detectModKey();\nconst map = createKeymap(bindings, { modKey });\nconst label = formatShortcut('mod+k', modKey);\n```\n\n---\n\n## Shortcut String Syntax\n\nShortcut strings are space-separated steps. Each step is `+`-joined modifier names and a single non-modifier key.\n\n### Modifier aliases\n\n| You write | Resolves to |\n| --------- | ----------- |\n| `mod` | `meta` on Mac, `ctrl` elsewhere (per `modKey`) |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `control` | `ctrl` |\n\n### Special key aliases\n\n| You write | `KeyboardEvent.key` |\n| --------- | ------------------- |\n| `esc` | `Escape` |\n| `space`, `spacebar` | ` ` (space character) |\n| `del` | `Delete` |\n| `up` | `ArrowUp` |\n| `down` | `ArrowDown` |\n| `left` | `ArrowLeft` |\n| `right` | `ArrowRight` |\n\n### Examples\n\n```\n'ctrl+k' → single step, Ctrl modifier\n'ctrl+k ctrl+s' → two-step chord (VS Code–style)\n'g g' → two-step key-key chord (Vim-style)\n'mod+shift+p' → ⌘⇧P on Mac, Ctrl+Shift+P elsewhere\n'escape' → Escape key, no modifiers\n'space' → Space key (alias for ' ')\n```\n",
6
- "usage": "---\ntitle: Keymap — Usage Guide\ndescription: How to use createKeymap for single shortcuts, chord sequences, context guards, and framework integration.\n---\n\n[[toc]]\n\n## Basic Usage\n\nPass a record of shortcut strings to handlers:\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({\n 'ctrl+s': () => save(),\n 'ctrl+z': () => undo(),\n 'ctrl+shift+z': () => redo(),\n escape: () => closeModal(),\n});\n\nconst unmount = map.mount(document);\n```\n\nThe returned `unmount` function detaches listeners from that target only. Call `map.dispose()` to remove from all mounted targets at once.\n\n## Modifier Aliases\n\nYou can write shortcuts in the style that feels natural Keymap normalises everything:\n\n| You write | Canonical form |\n| ----------------------- | -------------- |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `ctrl`, `control` | `ctrl` |\n| `shift` | `shift` |\n\n```ts\n// All three are equivalent:\ncreateKeymap({ 'cmd+k': handler });\ncreateKeymap({ 'command+k': handler });\ncreateKeymap({ 'meta+k': handler });\n```\n\n## Chord Sequences\n\nSeparate chord steps with a space. The default timeout between steps is 1 s:\n\n```ts\nconst map = createKeymap(\n {\n 'ctrl+k ctrl+s': () => save(), // VS Code–style\n 'g g': () => goToTop(), // Vim-style\n 'g G': () => goToBottom(),\n },\n {\n chordTimeout: 750, // ms — reset partial chord if exceeded\n },\n);\n```\n\n> **Tip:** A shorter binding always fires immediately when typed, even if a longer chord shares its prefix — binding order doesn't matter. `'g'` and `'g g'` together means `'g g'` can never be reached, because `'g'` fires the instant it's pressed. Use `findShortcutConflicts()` (see below) to detect this before it surprises a user.\n\n## `BindingOptions` — Per-binding Configuration\n\nPass a `BindingOptions` object instead of a plain handler to add guards, trigger control, or priority:\n\n```ts\nconst map = createKeymap({\n 'ctrl+s': () => save(), // plain handler\n escape: { handler: closePanel, when: () => isOpen() }, // per-binding guard\n space: { handler: togglePlay, trigger: 'keyup' }, // fires on keyup\n 'ctrl+z': { handler: undo, priority: 10 }, // wins over lower-priority bindings\n});\n```\n\n## Context Guards\n\nUse a global `when()` in `KeymapOptions` to disable an entire keymap conditionally:\n\n```ts\nconst map = createKeymap({ escape: () => closePanel() }, { when: () => panelIsOpen() });\n```\n\nFor per-binding guards, use `BindingOptions.when`:\n\n```ts\nconst map = createKeymap({\n escape: { handler: closePanel, when: () => isPanelOpen() },\n backspace: { handler: deleteLine, when: () => isEditorFocused() },\n});\n```\n\n## Trigger Control\n\nBindings default to `keydown`. Use `trigger: 'keyup'` for actions that should fire on release:\n\n```ts\nconst map = createKeymap({\n space: { handler: confirmAction, trigger: 'keyup' },\n});\n```\n\nKeydown and keyup chord trackers are independent — a `'g g'` chord on `keyup` does not interfere with a `'g g'` chord on `keydown`.\n\n## Replacing a Binding at Runtime\n\n`bind()` always replaces any existing binding for the same shortcut the most recent call wins, regardless of `priority`:\n\n```ts\nconst map = createKeymap({\n 'ctrl+k': defaultAction,\n});\n\n// A plugin registers an override at runtime — this replaces the default binding outright:\nmap.bind('ctrl+k', pluginOverride);\n```\n\n> `BindingOptions.priority` doesn't affect this because bindings are keyed by their canonical shortcut, two _live_ bindings can never actually compete for the same event, so there's no tie for `priority` to break. It's a validated, reserved field kept for forward compatibility — see the note in [`api.md`](./api.md#bindingoptions).\n\n## Display with `formatShortcut`\n\nFormat shortcut strings for display in tooltips, menus, or documentation:\n\n```ts\nimport { formatShortcut } from '@vielzeug/keymap';\n\nformatShortcut('mod+shift+p', 'meta'); // '⇧⌘P'\nformatShortcut('mod+shift+p', 'ctrl'); // 'Ctrl+Shift+P'\nformatShortcut('ctrl+k ctrl+s'); // platform-detected\n```\n\nReturns `''` and emits a dev warning for empty or invalid shortcuts.\n\n## Detecting Conflicts\n\nBefore binding a user-customized shortcut, check whether it would shadow (or be shadowed by) an\nexisting binding — most useful for a shortcut-customization UI where the shortcut string comes\nfrom user input:\n\n```ts\nimport { createKeymap, findShortcutConflicts } from '@vielzeug/keymap';\n\nconst map = createKeymap({ g: () => scrollToTop() });\n\nconst conflicts = findShortcutConflicts('g g', map.listBindings());\n\nif (conflicts.length > 0) {\n warnUser('This shortcut would never fire \"g\" already handles the first key.');\n} else {\n map.bind('g g', () => scrollToBottom());\n}\n```\n\n`findShortcutConflicts()` catches both directions: a shorter existing binding shadowing your\nproposal, and your proposal shadowing an existing longer chord.\n\n## Keymap Layers\n\nStack a scoped keymap on top of a base keymap for modal UIs. Mount each independently:\n\n```ts\nimport { createKeymap, createKeymapLayer } from '@vielzeug/keymap';\n\nconst base = createKeymap({ 'ctrl+z': undo, 'ctrl+s': save });\nconst modal = createKeymapLayer(base, {\n escape: { handler: closeModal, when: () => isModalOpen() },\n 'ctrl+enter': () => confirm(),\n});\n\nconst unmountBase = base.mount(document);\nconst unmountModal = modal.mount(document);\n\nmodal.deactivate(); // base handles everything; layer is suspended\nmodal.activate(); // layer resumes\n\nmodal.parent === base; // true\n\nunmountModal();\nunmountBase();\n```\n\n## Mounting to a Specific Element\n\nPass any `EventTarget` not just `document`:\n\n```ts\nconst editorEl = document.getElementById('editor')!;\nconst unmount = map.mount(editorEl); // only fires when focus is inside editor\n```\n\nOne keymap can be mounted to multiple targets simultaneously:\n\n```ts\nconst u1 = map.mount(editorA);\nconst u2 = map.mount(editorB);\n// u1() removes from editorA only\n// map.dispose() removes from both\n```\n\nMounting the _same_ target twice without unmounting first (e.g. a forgotten cleanup in an effect) still works handlers just fire twice and emits a dev warning to flag the likely mistake.\n\n## `preventDefault` and `stopPropagation`\n\n```ts\nconst map = createKeymap(\n { 'ctrl+s': () => save() },\n {\n preventDefault: true, // default: true prevents browser save dialog\n stopPropagation: false, // default: false\n },\n);\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useRef } from 'react';\nimport { createKeymap } from '@vielzeug/keymap';\n\nfunction App() {\n useEffect(() => {\n const map = createKeymap({\n 'ctrl+k': () => setOpen(true),\n escape: () => setOpen(false),\n });\n const unmount = map.mount(document);\n return () => unmount();\n }, []);\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onMounted, onUnmounted } from 'vue';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({\n 'ctrl+k': () => openPalette(),\n escape: () => closePalette(),\n});\n\nlet unmount: (() => void) | undefined;\nonMounted(() => {\n unmount = map.mount(document);\n});\nonUnmounted(() => unmount?.());\n</script>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\nimport { createKeymap } from '@vielzeug/keymap';\n\nconst map = createKeymap({\n 'ctrl+k': () => openPalette(),\n escape: () => closePalette(),\n});\n\nonMount(() => {\n const unmount = map.mount(document);\n return () => unmount();\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Keymap + Ledger\n\nWire undo/redo shortcuts to a `Ledger` instance:\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst map = createKeymap({\n 'ctrl+z': () => ledger.undo(),\n 'ctrl+shift+z': () => ledger.redo(),\n 'ctrl+y': () => ledger.redo(), // Windows alias\n});\nmap.mount(document);\n```\n\n### Keymap + Herald\n\nPublish shortcut events to a bus instead of calling handlers directly:\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createBus } from '@vielzeug/herald';\n\nconst bus = createBus<{ 'shortcut:save': void; 'shortcut:palette': void }>();\nconst map = createKeymap({\n 'ctrl+s': () => bus.emit('shortcut:save'),\n 'meta+shift+p': () => bus.emit('shortcut:palette'),\n});\nmap.mount(document);\n```\n\n## Best Practices\n\n- **One keymap per scope**: create separate keymaps for global shortcuts, panel shortcuts, and editor shortcuts — mount/unmount them as the relevant UI state changes.\n- **Dispose on teardown**: always call `unmount()` or `map.dispose()` when the component unmounts or the scope is destroyed.\n- **Avoid modifier-only shortcuts**: shortcuts like `shift` alone (no key) can't be reliably parsed — always include a non-modifier key.\n- **Use `when()` for toggleable scopes**: simpler than manually mounting and unmounting on every state change.\n",
7
- "examples": "---\ntitle: Keymap — Examples\ndescription: Worked examples for @vielzeug/keymap.\n---\n\n## Examples\n\n- [Global Shortcuts](./examples/global-shortcuts.md) — Register document-level hotkeys with a context guard\n- [Vim-style Navigation](./examples/vim-navigation.md) — Chord sequences for keyboard-driven navigation\n"
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| 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| `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| `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}\n```\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### `ConflictOptions`\n\nComparison options for `findShortcutConflicts()`.\n\n```ts\ninterface ConflictOptions {\n modKey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n### Shortcut Parser Types\n\nParsed shortcut data.\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## 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",
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### 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## 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
+ "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
  },
9
9
  "examples": [
10
10
  {
@@ -22,11 +22,6 @@
22
22
  "code": "import { createKeymap, findShortcutConflicts } from '@vielzeug/keymap'\n\n// findShortcutConflicts() catches unreachable bindings before you register them —\n// useful for a shortcut-customization UI driven by user input.\nconst map = createKeymap({\n g: () => console.log('go to top'),\n})\n\nconst proposed = 'g g'\nconst conflicts = findShortcutConflicts(proposed, map.listBindings())\n\nif (conflicts.length > 0) {\n console.log(`\"${proposed}\" would never fire — shadowed by an existing binding`)\n} else {\n map.bind(proposed, () => console.log('go to bottom'))\n}\n\n// A shortcut with no relationship to existing bindings reports no conflicts.\nconsole.log('ctrl+s conflicts:', findShortcutConflicts('ctrl+s', map.listBindings()).length)\n\n// keydown and keyup bindings never conflict — they're matched independently.\nconst withKeyup = createKeymap({ space: { handler: () => {}, trigger: 'keyup' } })\nconsole.log(\n 'space (keydown) vs space (keyup):',\n findShortcutConflicts('space', withKeyup.listBindings(), { trigger: 'keydown' }).length,\n)",
23
23
  "name": "Conflict Detection"
24
24
  },
25
- {
26
- "id": "keymap-layers",
27
- "code": "import { createKeymap, createKeymapLayer } from '@vielzeug/keymap'\n\n// createKeymapLayer() scopes a second set of bindings on top of a base keymap —\n// useful for modal UI. The caller mounts/disposes each independently.\nconst base = createKeymap({\n 'ctrl+z': () => console.log('undo'),\n})\n\nconst modal = createKeymapLayer(base, {\n escape: () => console.log('close modal'),\n})\n\nconst unmountBase = base.mount(document)\nconst unmountModal = modal.mount(document)\n\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))\n\n// deactivate() suspends the layer without touching the base keymap.\nmodal.deactivate()\nconsole.log('modal active:', modal.active)\ndocument.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) // no longer logs\n\nmodal.activate()\nconsole.log('modal.parent === base:', modal.parent === base)\n\nunmountModal()\nunmountBase()",
28
- "name": "Keymap Layers"
29
- },
30
25
  {
31
26
  "id": "parse-and-match",
32
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 (or KeymapError.is()) 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:', KeymapError.is(err), err instanceof KeymapParseError, err.message)\n}",
@@ -34,7 +29,7 @@
34
29
  },
35
30
  {
36
31
  "id": "shortcut-utilities",
37
- "code": "import {\n canonicalizeShortcut,\n createKeymap,\n detectModKey,\n parseShortcut,\n parseStep,\n} from '@vielzeug/keymap'\n\n// detectModKey() — platform modifier detection.\nconst modKey = detectModKey()\nconsole.log('Platform modifier:', modKey)\n\n// parseStep() — parse a single chord step (no throw on invalid input).\nconst step = parseStep('ctrl+k', modKey)\nconsole.log('parseStep ctrl+k:', step?.key, [...(step?.modifiers ?? [])])\n\nconst invalid = parseStep('', modKey)\nconsole.log('parseStep empty string:', invalid) // null\n\n// canonicalizeShortcut() — stable canonical key for conflict detection.\n// Different aliases for the same shortcut resolve to the same canonical key.\nconst a = canonicalizeShortcut(parseShortcut('cmd+k', modKey))\nconst b = canonicalizeShortcut(parseShortcut('meta+k', modKey))\nconsole.log('cmd+k canonical:', a)\nconsole.log('meta+k canonical:', b)\nconsole.log('Same canonical?', a === b)\n\n// listBindings() — inspect active bindings at runtime.\nconst map = createKeymap(\n {\n 'ctrl+k': () => console.log('ctrl+k fired'),\n 'ctrl+shift+s': { handler: () => console.log('save fired'), priority: 5, trigger: 'keyup' },\n },\n { modKey },\n)\n\nconst entries = map.listBindings()\nconsole.log('Bindings:', entries.length)\n\nfor (const entry of entries) {\n const canonical = canonicalizeShortcut(entry.shortcut)\n console.log(` ${canonical} — trigger: ${entry.trigger}, priority: ${entry.priority}`)\n}\n\n// bind() returns an unbind closure — uses canonical key internally.\nconst unbind = map.bind('ctrl+j', () => console.log('ctrl+j'))\nconsole.log('After bind:', map.listBindings().length)\n\nunbind()\nconsole.log('After unbind:', map.listBindings().length)",
32
+ "code": "import {\n canonicalizeShortcut,\n createKeymap,\n detectModKey,\n parseShortcut,\n parseStep,\n} from '@vielzeug/keymap'\n\n// detectModKey() — platform modifier detection.\nconst modKey = detectModKey()\nconsole.log('Platform modifier:', modKey)\n\n// parseStep() — parse a single chord step (no throw on invalid input).\nconst step = parseStep('ctrl+k', modKey)\nconsole.log('parseStep ctrl+k:', step?.key, [...(step?.modifiers ?? [])])\n\nconst invalid = parseStep('', modKey)\nconsole.log('parseStep empty string:', invalid) // null\n\n// canonicalizeShortcut() — stable canonical key for conflict detection.\n// Different aliases for the same shortcut resolve to the same canonical key.\nconst a = canonicalizeShortcut(parseShortcut('cmd+k', modKey))\nconst b = canonicalizeShortcut(parseShortcut('meta+k', modKey))\nconsole.log('cmd+k canonical:', a)\nconsole.log('meta+k canonical:', b)\nconsole.log('Same canonical?', a === b)\n\n// listBindings() — inspect active bindings at runtime.\nconst map = createKeymap(\n {\n 'ctrl+k': () => console.log('ctrl+k fired'),\n 'ctrl+shift+s': { handler: () => console.log('save fired'), trigger: 'keyup' },\n },\n { modKey },\n)\n\nconst entries = map.listBindings()\nconsole.log('Bindings:', entries.length)\n\nfor (const entry of entries) {\n const canonical = canonicalizeShortcut(entry.shortcut)\n console.log(` ${canonical} — trigger: ${entry.trigger}`)\n}\n\n// bind() returns an unbind closure — uses canonical key internally.\nconst unbind = map.bind('ctrl+j', () => console.log('ctrl+j'))\nconsole.log('After bind:', map.listBindings().length)\n\nunbind()\nconsole.log('After unbind:', map.listBindings().length)",
38
33
  "name": "Shortcut Utilities"
39
34
  }
40
35
  ],
@@ -44,20 +39,19 @@
44
39
  "KeymapParseError": "export { KeymapError, KeymapParseError } from './errors';",
45
40
  "formatShortcut": "export { formatShortcut } from './format';",
46
41
  "createKeymap": "export { createKeymap } from './keymap';",
47
- "createKeymapLayer": "export { createKeymapLayer } from './layer';",
48
42
  "canonicalizeShortcut": "export { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';",
49
43
  "detectModKey": "export { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';",
50
44
  "matchStep": "export { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';",
51
45
  "parseShortcut": "export { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';",
52
46
  "parseStep": "export { canonicalizeShortcut, detectModKey, matchStep, parseShortcut, parseStep } from './parser';",
53
47
  "ConflictOptions": "export type { ConflictOptions } from './conflicts';",
54
- "BindingEntry": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
55
- "BindingOptions": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
56
- "BindingValue": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
57
- "Handler": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
58
- "Keymap": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
59
- "KeymapOptions": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions } from './types';",
60
- "KeymapLayer": "export type { KeymapLayer } from './layer';",
48
+ "BindingEntry": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
49
+ "BindingOptions": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
50
+ "BindingValue": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
51
+ "Handler": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
52
+ "Keymap": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
53
+ "KeymapOptions": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
54
+ "When": "export type { BindingEntry, BindingOptions, BindingValue, Handler, Keymap, KeymapOptions, When } from './types';",
61
55
  "ModifierKey": "export type { ModifierKey, Shortcut, ShortcutStep } from './parser';",
62
56
  "Shortcut": "export type { ModifierKey, Shortcut, ShortcutStep } from './parser';",
63
57
  "ShortcutStep": "export type { ModifierKey, Shortcut, ShortcutStep } from './parser';"
@@ -1,54 +1,57 @@
1
1
  {
2
- "apiSource": "export { compose } from './compose';\nexport { LedgerDisposedError, LedgerError, LedgerExecutionError, LedgerRollbackError } from './errors';\nexport { createLedger } from './ledger';\nexport type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';\n",
2
+ "apiSource": "export { compose } from './compose';\nexport {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';\nexport { createLedger } from './ledger';\nexport type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';\n",
3
3
  "docs": {
4
- "index": "---\ntitle: Ledger — Async undo/redo command history\ndescription: Command-pattern undo/redo with async operations, Ripple signals for reactive state, and composable commands.\npackage: ledger\ncategory: utilities\nkeywords: [undo, redo, history, command-pattern, async, reactive, ripple]\nexports: [createLedger, compose]\nrelated: [ripple, keymap, forge, vault]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"ledger\" />\n\n## Why Ledger?\n\nUndo/redo is deceptively complex: you need to handle async side-effects, prevent concurrent mutations from racing, cap history size, and keep UI buttons reactive. Ledger solves all of this with a clean command-pattern API and Ripple signals.\n\n| Feature | Roll your own | Ledger |\n| ---------------------- | ------------------------------------- | --------------------------------------------------------- |\n| Bundle size | 0 B | <PackageInfo package=\"ledger\" type=\"size\" /> |\n| Async commands | Manual promise chaining | <ore-icon name=\"check\" size=\"16\"></ore-icon> serialised queue |\n| Race prevention | Manual locks | <ore-icon name=\"check\" size=\"16\"></ore-icon> built-in queue |\n| Reactive `canUndo` | Poll or manual events | `Computed<boolean>` from Ripple |\n| Composable commands | Custom wrapper | <ore-icon name=\"check\" size=\"16\"></ore-icon> `compose()` |\n| History cap | Array slice | `maxHistory` option |\n| Disposable | Manual | `dispose()` + `using` |\n\n<div class=\"decision-callout\">\n\n**Use Ledger when** you need undo/redo for editors, design tools, form state, or any app with reversible mutations — especially with async side-effects like server persistence.\n\n**Consider a simpler approach when** you only need one synchronous client-side mutation and do not need command semantics, async sequencing, or reactive history state.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/ledger\n```\n\n```sh [npm]\nnpm install @vielzeug/ledger\n```\n\n```sh [yarn]\nyarn add @vielzeug/ledger\n```\n\n:::\n\n## Quick Start\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger({ maxHistory: 50 });\n\n// Execute a reversible command\nawait ledger.do({\n execute: async () => { item.name = newName; },\n rollback: async () => { item.name = oldName; },\n label: 'Rename item',\n});\n\nawait ledger.undo(); // runs rollback\nawait ledger.redo(); // runs execute again\n\nledger.dispose(); // or: using ledger = createLedger()\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createLedger<TData>()` — Creates an async command stack; operations are serialised to prevent races\n- Reactive state — `canUndo`, `canRedo`, `historySize`, `isProcessing`, `pendingCount`, `historySnapshot` are Ripple `Computed` values\n- `compose()` — Group multiple commands into one atomic undo step; partial failure rolls back already-executed sub-commands; sub-rollback errors reach `onRollbackError`\n- `maxHistory` — Cap the undo stack; oldest entries evicted automatically\n- Async-safe — `execute()`, `rollback()`, and `clear()` are fully serialised through the queue\n- Typed history — `Command.data` stores custom metadata; `historySnapshot.value[n].data` is typed to `TData`\n- Error-safe rollback — failed `rollback()` warns via dev console; optional `onRollbackError` callback for UI integration\n- Cancellable `execute`/`rollback` receive an `AbortSignal`, merged from a caller-supplied signal and the ledger's own `disposalSignal`\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\n</div>\n\n## See Also\n\n<div class=\"see-also\">\n\n- [Ripple](/ripple/) — `canUndo`, `canRedo`, `isProcessing` are Ripple `Computed` values; use `effect()` or bind directly to templates\n- [Keymap](/keymap/) — Wire `ctrl+z` / `ctrl+shift+z` to `ledger.undo()` / `ledger.redo()` with zero boilerplate\n- [Forge](/forge/) — Combine Ledger with Forge for reversible form mutations\n- [Vault](/vault/) — Persist undo history across sessions by storing commands in IndexedDB\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
- "api": "---\ntitle: Ledger — API Reference\ndescription: Full API reference for @vielzeug/ledger — createLedger, Ledger interface, Command, and all types.\n---\n\n[[toc]]\n\n## API Overview\n\n| Export | Kind | Execution mode | Description |\n| ------ | ---- | -------------- | ----------- |\n| `createLedger` | function | async | Creates an undo/redo command stack |\n| `compose` | function | — | Combines multiple commands into one reversible command |\n| `Ledger` | interface | — | Object returned by `createLedger` |\n| `Command` | interface | — | A command: `{ execute, rollback?, label? }` |\n| `LedgerOptions` | interface | — | Options for `createLedger` |\n| `LedgerCallOptions` | interface | — | Options for `do()`/`undo()`/`redo()` — cancellation |\n| `CommandMeta` | interface | — | Metadata entry in `historySnapshot` |\n\n## Package Entry Points\n\n```ts\nimport { compose, createLedger } from '@vielzeug/ledger';\nimport type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from '@vielzeug/ledger';\n```\n\n## `createLedger(options?)`\n\nCreates an async undo/redo command history.\n\n```ts\nfunction createLedger<TData = unknown>(options?: LedgerOptions<TData>): Ledger<TData>\n```\n\n**Parameters**\n\n- `options.maxHistory` — Maximum number of entries in the undo stack (default: `100`). Oldest entries are evicted when exceeded.\n- `options.onRollbackError` — Optional callback invoked when `rollback()` throws. Receives the error and the `CommandMeta` of the failing command. The stack position is left unchanged regardless.\n\n**Returns** a `Ledger` object.\n\n```ts\nconst ledger = createLedger({ maxHistory: 50 });\n```\n\n## `Ledger`\n\n```ts\ninterface Ledger<TData = unknown> {\n readonly canRedo: Computed<boolean>;\n readonly canUndo: Computed<boolean>;\n readonly disposalSignal: AbortSignal;\n readonly disposed: boolean;\n readonly historySize: Computed<number>;\n readonly historySnapshot: Computed<readonly CommandMeta<TData>[]>;\n readonly isProcessing: Computed<boolean>;\n readonly pendingCount: Computed<number>;\n\n clear(): Promise<void>;\n dispose(): void;\n do(command: Command<TData>, options?: LedgerCallOptions): Promise<void>;\n redo(options?: LedgerCallOptions): Promise<void>;\n undo(options?: LedgerCallOptions): Promise<void>;\n [Symbol.dispose](): void;\n}\n```\n\n### Reactive signals\n\nAll signals are Ripple `Computed<T>` — read `.value` or call `.subscribe()`.\n\n| Signal | Type | Description |\n| ------ | ---- | ----------- |\n| `canUndo` | `Computed<boolean>` | `true` when the undo stack is non-empty |\n| `canRedo` | `Computed<boolean>` | `true` when the redo stack is non-empty |\n| `historySize` | `Computed<number>` | Number of undo steps available |\n| `historySnapshot` | `Computed<readonly CommandMeta<TData>[]>` | Metadata for each undo entry, newest first |\n| `isProcessing` | `Computed<boolean>` | `true` while a command's `execute` or `rollback` is running; `false` during a queued `clear()` |\n| `pendingCount` | `Computed<number>` | Number of operations currently in the queue (executing + waiting) |\n\n### `disposalSignal` / `disposed`\n\n```ts\nreadonly disposalSignal: AbortSignal\nreadonly disposed: boolean\n```\n\n`disposalSignal` aborts when `dispose()` runs — it's the same signal merged into the one\npassed to `execute`/`rollback` (see [`LedgerCallOptions`](#ledgercalloptions)). `disposed`\nflips to `true` at the same point.\n\n### `do(command, options?)`\n\nExecutes a command and pushes it onto the undo stack. Clears the redo stack.\n\n```ts\nledger.do(command: Command<TData>, options?: LedgerCallOptions): Promise<void>\n```\n\nIf `execute()` rejects, the command is not added to the stack. Rejects with\n`LedgerDisposedError` if the ledger is already disposed — `execute()` is never called.\n\n### `undo(options?)`\n\nPops the top entry from the undo stack and pushes it onto the redo stack. If the entry has a `rollback`, it is called first.\n\n```ts\nledger.undo(options?: LedgerCallOptions): Promise<void>\n```\n\nNo-op when `canUndo.value === false`. If `rollback()` throws, a dev warning is issued, `onRollbackError` is called with a `LedgerRollbackError` (if configured), and the stack position is left unchanged. Commands without a `rollback` are popped and moved to the redo stack without any reversal. Rejects with `LedgerDisposedError` if the ledger is already disposed.\n\n### `redo(options?)`\n\nPops the top entry from the redo stack, calls `execute()`, and pushes it back onto the undo stack.\n\n```ts\nledger.redo(options?: LedgerCallOptions): Promise<void>\n```\n\nNo-op when `canRedo.value === false`. Rejects with `LedgerExecutionError` if `execute()` throws, and with `LedgerDisposedError` if the ledger is already disposed.\n\n### `clear()`\n\nEnqueues a reset of both the undo and redo stacks. Returns a `Promise` that resolves once the reset has run (after any already-queued operations complete).\n\n```ts\nawait ledger.clear()\n```\n\nSafe to call while operations are in flight — the clear is serialised in the queue and runs after the current operation finishes. Rejects with `LedgerDisposedError` if the ledger is already disposed.\n\n### `dispose()`\n\nClears both stacks, aborts `disposalSignal`, and disposes all Ripple signals. After `dispose()`, reading `.value` on any signal returns `undefined`, and `do()`/`undo()`/`redo()`/`clear()` reject with `LedgerDisposedError`.\n\n```ts\nledger.dispose();\n// or:\nusing ledger = createLedger();\n```\n\n## `Command`\n\n```ts\ninterface Command<TData = unknown> {\n data?: TData;\n execute: (signal?: AbortSignal) => Promise<void> | void;\n rollback?: (signal?: AbortSignal) => Promise<void> | void;\n label?: string;\n}\n```\n\nBoth `execute` and `rollback` accept sync and async functions. Both receive an `AbortSignal` — see [`LedgerCallOptions`](#ledgercalloptions) — but the parameter is optional, so existing commands that ignore it (`execute: () => {...}`) still type-check.\n\n`rollback` is optional. Commands without one are still tracked in history; `undo()` moves them on the stack but performs no reversal.\n\n`label` is optional — it surfaces in `historySnapshot.value` for building undo history UI.\n\n`data` is an optional custom metadata payload, typed to the `TData` type parameter of `createLedger<TData>`. It is stored as-is in `historySnapshot.value[n].data`. Use it to attach context needed by undo-history UIs (e.g. before/after snapshots, affected IDs).\n\n## `LedgerOptions`\n\n```ts\ninterface LedgerOptions<TData = unknown> {\n maxHistory?: number; // default: 100\n onRollbackError?: (err: unknown, meta: CommandMeta<TData>) => void;\n}\n```\n\n| Option | Default | Description |\n| ------ | ------- | ----------- |\n| `maxHistory` | `100` | Maximum undo stack depth. Oldest entries evicted on overflow. |\n| `onRollbackError` | — | Called with a `LedgerRollbackError` when `rollback()` throws. Useful for surfacing undo failures to the UI without parsing console warnings. |\n\n## `LedgerCallOptions`\n\nOptions accepted by `do()`/`undo()`/`redo()`.\n\n```ts\ninterface LedgerCallOptions {\n signal?: AbortSignal;\n}\n```\n\n| Option | Default | Description |\n| ------ | ------- | ----------- |\n| `signal` | — | Merged with the ledger's own `disposalSignal` via `AbortSignal.any()` and passed to `execute`/`rollback`. Lets a long-running command observe caller-initiated cancellation, ledger disposal, or both. |\n\n`execute`/`rollback` always receive a live `AbortSignal`, even when `options.signal` is omitted — it's the ledger's own `disposalSignal` in that case, so every command can at least observe disposal.\n\n```ts\nconst controller = new AbortController();\n\nawait ledger.do(\n {\n execute: async (signal) => {\n await fetch('/api/save', { signal });\n },\n },\n { signal: controller.signal },\n);\n\ncontroller.abort(); // aborts the fetch above, if still in flight\n```\n\n## `compose(commands, label?)`\n\nCombines multiple commands into a single reversible command that counts as one undo step.\n\n```ts\nfunction compose<TData = unknown>(commands: Command<TData>[], label?: string): Command<TData>\n```\n\n`execute` runs all sub-commands in order and forwards its own `signal` argument to every sub-command's `execute`. **If any sub-command fails, already-executed sub-commands are rolled back automatically (best-effort) before the error is re-thrown** — making `compose()` atomic. `rollback` runs sub-commands in reverse (also forwarding `signal`), skipping any without a defined `rollback`. If a sub-command's `rollback` throws during `undo()`, the error is propagated to the ledger's `onRollbackError` callback (if configured). `rollback` is `undefined` when no sub-command defines one. Pass the result directly to `ledger.do()`:\n\n```ts\nawait ledger.do(compose([\n { execute: () => { node.x = newX; }, rollback: () => { node.x = oldX; } },\n { execute: () => { node.y = newY; }, rollback: () => { node.y = oldY; } },\n], 'Move node'));\n```\n\n## `CommandMeta`\n\nShape of entries in `historySnapshot.value`:\n\n```ts\ninterface CommandMeta<TData = unknown> {\n data: TData | undefined;\n label: string | undefined;\n}\n```\n\n`data` holds the value from `Command.data`. The type parameter is inferred from `createLedger<TData>()`; it defaults to `unknown` when no type argument is supplied.\n\n---\n\n## Errors\n\n### `LedgerError`\n\nBase class for all ledger errors. Use `instanceof LedgerError` or `LedgerError.is()` to catch any ledger-originated error.\n\n```ts\nclass LedgerError extends Error {\n static is(err: unknown): err is LedgerError;\n}\n```\n\n**Named subclasses**\n\n| Class | Thrown when |\n| ---------------------- | ------------------------------------------------------------------------------ |\n| `LedgerDisposedError` | `do()`/`undo()`/`redo()`/`clear()` is called on a disposed ledger instance |\n| `LedgerExecutionError` | A command's `execute()` function throws; original error available via `.cause` |\n| `LedgerRollbackError` | Passed to `onRollbackError` when a command's `rollback()` function throws during undo; original error via `.cause` |\n",
6
- "usage": "---\ntitle: Ledger — Usage Guide\ndescription: How to use createLedger for undo/redo, async commands, batch operations, and reactive UI binding.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDefine commands as `{ execute, rollback }` pairs and push them through `ledger.do()`:\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\n\nconst prev = item.name;\nconst next = 'New name';\n\nawait ledger.do({\n execute: async () => { item.name = next; },\n rollback: async () => { item.name = prev; },\n label: 'Rename item',\n});\n\nawait ledger.undo(); // item.name === prev\nawait ledger.redo(); // item.name === next\n```\n\nCommands can be sync or async both `() => void` and `() => Promise<void>` are accepted.\n\n## Reactive State\n\n`canUndo`, `canRedo`, `historySize`, `isProcessing`, and `historySnapshot` are Ripple `Computed` values. Read them directly in effects or templates:\n\n```ts\nimport { effect } from '@vielzeug/ripple';\n\neffect(() => {\n undoButton.disabled = !ledger.canUndo.value;\n redoButton.disabled = !ledger.canRedo.value;\n spinner.hidden = !ledger.isProcessing.value;\n});\n```\n\nOr read `.value` imperatively:\n\n```ts\nconsole.log(ledger.historySize.value); // number of undo steps\nconsole.log(ledger.historySnapshot.value); // readonly CommandMeta[]\n```\n\n## Composing Commands\n\nGroup multiple commands into a single undo step with `compose()`. Rollback runs all sub-commands in reverse:\n\n```ts\nimport { compose, createLedger } from '@vielzeug/ledger';\n\nawait ledger.do(compose(\n [\n { execute: () => { node.x = newX; }, rollback: () => { node.x = oldX; } },\n { execute: () => { node.y = newY; }, rollback: () => { node.y = oldY; } },\n { execute: () => { node.width = newW; }, rollback: () => { node.width = oldW; } },\n ],\n 'Move and resize',\n));\n\n// One undo step undoes all three:\nawait ledger.undo();\n```\n\n## Concurrent Safety\n\nAll operations — `do()`, `undo()`, and `redo()` — are serialised through an internal queue. Concurrent calls are queued, not rejected:\n\n```ts\n// Safe to call without awaiting each:\nledger.do(cmd1);\nledger.do(cmd2);\nledger.do(cmd3);\n// cmd1 → cmd2 → cmd3 execute in order\n```\n\n`isProcessing.value` is `true` while a command's `execute` or `rollback` is actively running. Use `pendingCount.value > 0` to check whether there are any operations in the queue (including those waiting to start).\n\n## History Cap\n\nLimit the undo stack size with `maxHistory` (default: `100`):\n\n```ts\nconst ledger = createLedger({ maxHistory: 30 });\n```\n\nWhen the limit is reached, the oldest undo entry is silently evicted. The redo stack is always cleared when a new `do()` is performed.\n\n## Custom Command Data\n\nAttach arbitrary metadata to a command with the `data` field. Use `createLedger<TData>()` to type it:\n\n```ts\ntype EditData = { before: string; after: string };\n\nconst ledger = createLedger<EditData>();\n\nawait ledger.do({\n data: { before: item.name, after: newName },\n execute: () => { item.name = newName; },\n rollback: () => { item.name = item.name; }, // captured in closure\n label: 'Rename item',\n});\n\nconst [latest] = ledger.historySnapshot.value;\nconsole.log(latest.data?.before); // string | undefined — fully typed\n```\n\n`data` is stored as-is and does not affect `execute` or `rollback` behaviour.\n\n## Error Handling\n\nIf `execute()` rejects, the command is **not** added to the undo stack:\n\n```ts\nawait ledger.do({\n execute: async () => {\n await api.save(item); // throws if server error\n },\n rollback: async () => { /* not reached */ },\n});\n// ledger.historySize.value unchanged\n```\n\nIf `rollback()` throws during `undo()`, a dev warning is issued and the stack position is left unchanged — the entry stays on the undo stack so the operation can be retried.\n\nTo receive rollback errors in your application code (for example, to show a notification), pass `onRollbackError` to `createLedger`:\n\n```ts\nconst ledger = createLedger({\n onRollbackError: (err, meta) => {\n notify(`Could not undo \"${meta.label ?? 'action'}\": ${String(err)}`);\n },\n});\n```\n\n## Cancellation\n\n`execute`/`rollback` receive an `AbortSignal` as their argument — pass your own via `{ signal }` on `do()`/`undo()`/`redo()` to cancel a specific in-flight command, or ignore it if the command has nothing to abort:\n\n```ts\nconst controller = new AbortController();\n\nconst save = ledger.do(\n {\n execute: async (signal) => {\n await fetch('/api/save', { body: JSON.stringify(item), method: 'POST', signal });\n },\n label: 'Save item',\n },\n { signal: controller.signal },\n);\n\ncancelButton.addEventListener('click', () => controller.abort());\n```\n\nThe signal you pass is merged with the ledger's own `disposalSignal`, so a command can bail out early on `dispose()` too without you having to wire that up yourself:\n\n```ts\nconst ledger = createLedger();\n\nconst polling = ledger.do({\n execute: async (signal) => {\n while (!signal?.aborted) {\n await pollServer();\n }\n },\n});\n\n// later, e.g. when the owning component unmounts:\nledger.dispose(); // the loop above sees signal.aborted === true and exits\nawait polling;\n```\n\n## Framework Integration\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useState } from 'react';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\n\nfunction UndoRedoButtons() {\n const [canUndo, setCanUndo] = useState(false);\n const [canRedo, setCanRedo] = useState(false);\n\n useEffect(() => {\n const unsub = ledger.canUndo.subscribe(({ newValue }) => setCanUndo(newValue));\n const unsub2 = ledger.canRedo.subscribe(({ newValue }) => setCanRedo(newValue));\n return () => { unsub(); unsub2(); };\n }, []);\n\n return (\n <>\n <button disabled={!canUndo} onClick={() => ledger.undo()}>Undo</button>\n <button disabled={!canRedo} onClick={() => ledger.redo()}>Redo</button>\n </>\n );\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onUnmounted, ref } from 'vue';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst canUndo = ref(false);\nconst canRedo = ref(false);\n\nconst u1 = ledger.canUndo.subscribe(({ newValue }) => { canUndo.value = newValue; });\nconst u2 = ledger.canRedo.subscribe(({ newValue }) => { canRedo.value = newValue; });\nonUnmounted(() => { u1(); u2(); });\n</script>\n\n<template>\n <button :disabled=\"!canUndo\" @click=\"ledger.undo()\">Undo</button>\n <button :disabled=\"!canRedo\" @click=\"ledger.redo()\">Redo</button>\n</template>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nlet canUndo = false;\nlet canRedo = false;\n\nonMount(() => {\n const u1 = ledger.canUndo.subscribe(({ newValue }) => { canUndo = newValue; });\n const u2 = ledger.canRedo.subscribe(({ newValue }) => { canRedo = newValue; });\n return () => { u1(); u2(); };\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Ledger + Keymap\n\n```ts\nimport { createKeymap } from '@vielzeug/keymap';\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst map = createKeymap({\n 'ctrl+z': () => ledger.undo(),\n 'ctrl+shift+z': () => ledger.redo(),\n 'ctrl+y': () => ledger.redo(), // Windows alias\n});\nmap.mount(document);\n```\n\n### Ledger + Ripple effect\n\n```ts\nimport { effect } from '@vielzeug/ripple';\n\neffect(() => {\n document.title = ledger.canUndo.value\n ? `● ${documentTitle}` // unsaved indicator\n : documentTitle;\n});\n```\n\n## Best Practices\n\n- **Capture state before mutation**: close over `prev` / `next` values at `do()` call time, not inside `execute`/`rollback`.\n- **Label meaningful operations**: `historySnapshot.value` exposes labels for undo history lists.\n- **Use `data` for rich history UIs**: store before/after snapshots or affected IDs in `Command.data`; retrieve them via `historySnapshot.value[n].data`.\n- **Await `clear()` when order matters**: `ledger.clear()` is serialised — it returns a `Promise` that resolves after any in-flight operation finishes.\n- **Dispose when done**: call `ledger.dispose()` when the owner component unmounts — it clears both stacks and disposes all signals.\n- **Avoid reading `.value` after `dispose()`**: the computed nodes are disposed; `.value` returns `undefined`.\n",
7
- "examples": "---\ntitle: Ledger — Examples\ndescription: Worked examples for @vielzeug/ledger.\n---\n\n# Examples\n\n- [Text Editor History](./examples/text-editor.md) — Per-keystroke undo with debouncing and Keymap integration\n- [Form History](./examples/form-history.md) — Reversible form field mutations with reactive undo/redo buttons\n"
4
+ "index": "---\ntitle: Ledger — Reversible async history\ndescription: Serialized reversible command history with cancellation ownership and atomic reactive snapshots.\npackage: ledger\ncategory: utilities\nkeywords: [undo, redo, history, commands, async, reactive, ripple]\nexports: [compose, createLedger]\nrelated: [ripple, keymap, forge, vault]\nenvironments: [browser, node, ssr, deno]\n---\n\n<!-- markdownlint-disable MD025 MD033 MD060 -->\n\n<PackageHero package=\"ledger\" />\n\n## Why Ledger?\n\nUndo and redo require more than array manipulation when operations are asynchronous, cancellable, and visible in a UI. Ledger serializes only reversible commands, owns queue lifecycle, and publishes one atomic state snapshot.\n\n```ts\n// Before\nconst undo = () => changes.pop()?.revert();\n\n// After\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nawait ledger.do({ apply: saveNext, revert: restorePrevious });\nawait ledger.undo();\n```\n\n| Feature | Roll your own | Ledger |\n| --- | --- | --- |\n| Bundle size | 0 B | <PackageInfo package=\"ledger\" type=\"size\" /> |\n| Reversible history | Manual arrays | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Serialized async work | Manual queue | <ore-icon name=\"check\" size=\"16\"></ore-icon> |\n| Queue cancellation | Manual ownership | Abort-aware lifecycle |\n| Reactive state | Manual events | `Readable<LedgerState>` |\n| Composition | Custom transaction code | `compose()` |\n\n<div class=\"decision-callout\">\n\n**Use Ledger when** you own reversible asynchronous state transitions and need undo, redo, or history UI.\n\n**Consider direct application code when** work is irreversible, fire-and-forget, or does not need history.\n\n</div>\n\n## Installation\n\n::: code-group\n\n```sh [pnpm]\npnpm add @vielzeug/ledger\n```\n\n```sh [npm]\nnpm install @vielzeug/ledger\n```\n\n```sh [yarn]\nyarn add @vielzeug/ledger\n```\n\n:::\n\n## Quick Start\n\nSubmit a reversible command, read state, then dispose its owner.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nlet value = 'before';\nconst ledger = createLedger();\n\nawait ledger.do({\n apply: () => { value = 'after'; },\n label: 'Rename value',\n revert: () => { value = 'before'; },\n});\n\nawait ledger.undo();\nconsole.log(ledger.state.value.undo.length); // 0\nledger.dispose();\n```\n\n## Features\n\n<div class=\"features-grid\">\n\n- `createLedger()` — Create serialized reversible command history\n- `state`Read atomic queue, undo, redo, and acceptance state\n- `compose()` — Combine reversible commands into one reversible command\n- `whenIdle()` — Await queued and active operation settlement\n- `LedgerCancelledError` Distinguish cancellation from execution failure\n- `maxHistory` Keep a non-negative safe-integer undo depth\n- `[Symbol.dispose]()` — Seal, abort, and clear a ledger owner\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/) — Consume Ledger `state` through effects or framework bindings.\n- [Keymap](/keymap/) — Route undo and redo shortcuts to a Ledger error boundary.\n- [Forge](/forge/) — Record reversible form transitions.\n- [Vault](/vault/) — Persist application snapshots outside transient undo history.\n\n</div>\n\n<!-- markdownlint-enable MD025 MD033 MD060 -->\n",
5
+ "api": "---\ntitle: Ledger — API Reference\ndescription: API reference for @vielzeug/ledger reversible commands, queue ownership, cancellation, and state snapshots.\n---\n\n[[toc]]\n\n## API Overview\n\n| Symbol | Purpose | Execution mode | Common gotcha |\n| --- | --- | --- | --- |\n| `createLedger()` | Create reversible async history | Sync | `dispose()` seals the owner |\n| `compose()` | Combine reversible commands | Sync | Every child must revert |\n| `Ledger` | History handle | Async methods | Catch operation failures |\n| `ReversibleCommand` | Apply/revert state transition | Sync or async | Irreversible work is outside Ledger |\n| `LedgerCancelledError` | Cancellation result | Sync | Different from execution failure |\n\n## Package Entry Point\n\n| Import | Purpose |\n| --- | --- |\n| `@vielzeug/ledger` | Root entry for Ledger functions, errors, and public types. |\n\n## Core Functions\n\n### `createLedger()`\n\n```ts\nfunction createLedger<TMeta = undefined>(options?: LedgerOptions): Ledger<TMeta>;\n```\n\nCreates a serialized owner for reversible commands.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `options` | `LedgerOptions` | History-cap configuration. |\n\n**Returns:** `Ledger<TMeta>`.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nlet value = 'before';\nconst ledger = createLedger();\n\nawait ledger.do({\n apply: () => { value = 'after'; },\n revert: () => { value = 'before'; },\n});\n\nawait ledger.undo();\nledger.dispose();\n```\n\n### `compose()`\n\n```ts\nfunction compose<TMeta = undefined>(\n commands: readonly ReversibleCommand<TMeta>[],\n label?: string,\n): ReversibleCommand<TMeta>;\n```\n\nSnapshots reversible children and returns one reversible command.\n\n| Parameter | Type | Description |\n| --- | --- | --- |\n| `commands` | `readonly ReversibleCommand<TMeta>[]` | Commands to apply in order and revert in reverse order. |\n| `label` | `string` | Optional history label. |\n\n**Returns:** `ReversibleCommand<TMeta>`.\n\n```ts\nimport { compose } from '@vielzeug/ledger';\n\nconst move = compose([\n { apply: moveX, revert: restoreX },\n { apply: moveY, revert: restoreY },\n], 'Move node');\n```\n\nIf apply and compensation both fail, the resulting `LedgerExecutionError.cause` is an `AggregateError` containing every failure.\n\n## `Ledger`\n\n```ts\ninterface Ledger<TMeta = undefined> {\n clear(): Promise<void>;\n readonly disposalSignal: AbortSignal;\n dispose(): void;\n readonly disposed: boolean;\n do(command: ReversibleCommand<TMeta>, options?: LedgerCallOptions): Promise<void>;\n redo(options?: LedgerCallOptions): Promise<void>;\n readonly state: Readable<LedgerState<TMeta>>;\n undo(options?: LedgerCallOptions): Promise<void>;\n whenIdle(): Promise<void>;\n [Symbol.dispose](): void;\n}\n```\n\n| Member | Return | Contract |\n| --- | --- | --- |\n| `do()` | `Promise<void>` | Applies and records a command. |\n| `undo()` | `Promise<void>` | Reverts latest undo entry. |\n| `redo()` | `Promise<void>` | Reapplies latest redo entry. |\n| `clear()` | `Promise<void>` | Clears retained undo and redo history. |\n| `whenIdle()` | `Promise<void>` | Resolves when queued and running counts are zero. |\n| `dispose()` | `void` | Seals owner, aborts active contexts, rejects unstarted work. |\n| `state` | `Readable<LedgerState<TMeta>>` | Atomic lifecycle and history snapshot. |\n\n## Types\n\n### `CommandContext`\n\n```ts\ninterface CommandContext {\n readonly signal: AbortSignal;\n}\n```\n\nContext passed to apply and revert. Active work must observe `signal` cooperatively.\n\n### `ReversibleCommand`\n\n```ts\ninterface ReversibleCommand<TMeta = undefined> {\n readonly apply: (context: CommandContext) => Promise<void> | void;\n readonly label?: string;\n readonly meta?: TMeta;\n readonly revert: (context: CommandContext) => Promise<void> | void;\n}\n```\n\n### `HistoryEntry`\n\n```ts\ninterface HistoryEntry<TMeta = undefined> {\n readonly label: string | undefined;\n readonly meta: TMeta | undefined;\n}\n```\n\n### `LedgerState`\n\n```ts\ninterface LedgerState<TMeta = undefined> {\n readonly accepting: boolean;\n readonly queued: number;\n readonly redo: readonly HistoryEntry<TMeta>[];\n readonly running: number;\n readonly undo: readonly HistoryEntry<TMeta>[];\n}\n```\n\n### `LedgerOptions`\n\n```ts\ninterface LedgerOptions {\n maxHistory?: number;\n}\n```\n\n`maxHistory` defaults to `100`, accepts non-negative safe integers, and uses `0` for no retained history.\n\n### `LedgerCallOptions`\n\n```ts\ninterface LedgerCallOptions {\n signal?: AbortSignal;\n}\n```\n\nAn already-aborted signal rejects before user code starts. Active commands receive a merged signal.\n\n## Errors\n\n| Error | Trigger | Notable properties |\n| --- | --- | --- |\n| `LedgerCancelledError` | Operation cancels before start or cooperatively stops | May carry original abort cause |\n| `LedgerDisposedError` | Operation submitted to sealed ledger | Queued work rejects without starting |\n| `LedgerExecutionError` | `apply()` fails | Original failure in `.cause` |\n| `LedgerRollbackError` | `revert()` fails | Entry remains in undo history |\n| `LedgerError` | Base class | `LedgerError.is(error)` narrows all Ledger errors |\n",
6
+ "usage": "---\ntitle: Ledger — Usage Guide\ndescription: Use reversible commands, atomic state snapshots, cancellation, and lifecycle ownership with @vielzeug/ledger.\n---\n\n[[toc]]\n\n## Basic Usage\n\nDefine both apply and revert before submitting a state transition.\n\n```ts\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst item = { name: 'Old name' };\nconst previous = item.name;\nconst next = 'New name';\n\nawait ledger.do({\n apply: () => { item.name = next; },\n label: 'Rename item',\n revert: () => { item.name = previous; },\n});\n\nawait ledger.undo();\nawait ledger.redo();\nledger.dispose();\n```\n\nIrreversible work belongs in application code, not Ledger commands.\n\n## Read State\n\nRead one atomic state object for history and queue status.\n\n```ts\nimport { effect } from '@vielzeug/ripple';\n\neffect(() => {\n const { redo, running, undo } = ledger.state.value;\n\n undoButton.disabled = undo.length === 0;\n redoButton.disabled = redo.length === 0;\n spinner.hidden = running === 0;\n});\n```\n\n`undo` and `redo` are chronological history arrays. The latest entry is the final array item.\n\n## Compose Reversible Commands\n\nCompose mutations only when each child can revert.\n\n```ts\nimport { compose } from '@vielzeug/ledger';\n\nawait ledger.do(\n compose([\n { apply: () => { node.x = nextX; }, revert: () => { node.x = previousX; } },\n { apply: () => { node.y = nextY; }, revert: () => { node.y = previousY; } },\n ], 'Move node'),\n);\n```\n\nIf an apply step fails, completed steps revert in reverse order. Ledger preserves apply and compensation failures through `LedgerExecutionError.cause`.\n\n## Handle Operation Failures\n\nCatch rejected operations at the application boundary.\n\n```ts\nimport { LedgerCancelledError, LedgerRollbackError } from '@vielzeug/ledger';\n\ntry {\n await ledger.undo();\n} catch (error) {\n if (error instanceof LedgerCancelledError) return;\n if (error instanceof LedgerRollbackError) showUndoError(error.message);\n else throw error;\n}\n```\n\nA failed revert remains in undo history for retry.\n\n## Cancel Work\n\nPass an abort signal to cancel work before it starts or cooperatively stop active work.\n\n```ts\nconst controller = new AbortController();\n\nconst save = ledger.do(\n {\n apply: async ({ signal }) => {\n await fetch('/api/save', { method: 'POST', signal });\n },\n revert: async () => {\n await fetch('/api/save', { method: 'DELETE' });\n },\n },\n { signal: controller.signal },\n);\n\ncontroller.abort();\nawait save.catch(reportHistoryError);\n```\n\nCommands that ignore an active abort signal continue until they settle. Use `whenIdle()` when an owner needs an awaitable drain boundary.\n\n## Limit History\n\nConfigure a non-negative safe-integer history cap.\n\n```ts\nconst ledger = createLedger({ maxHistory: 30 });\n```\n\nUse `maxHistory: 0` for serialized reversible commands without retained undo/redo history.\n\n## Dispose Owners\n\nDispose seals the ledger, aborts active contexts, clears retained history, and rejects queued work that has not started.\n\n```ts\nconst active = ledger.do({ apply: saveNext, revert: restorePrevious });\nconst idle = ledger.whenIdle();\n\nledger.dispose();\nawait active.catch(reportHistoryError);\nawait idle;\n```\n\n## Framework Integration\n\nCreate and dispose a ledger with framework ownership.\n\n::: code-group\n\n```tsx [React]\nimport { useEffect, useState } from 'react';\n\nimport { createLedger } from '@vielzeug/ledger';\n\nexport function UndoRedoButtons() {\n const [state, setState] = useState({ redo: 0, undo: 0 });\n\n useEffect(() => {\n const ledger = createLedger();\n const stop = ledger.state.subscribe(() => {\n const { redo, undo } = ledger.state.value;\n setState({ redo: redo.length, undo: undo.length });\n });\n\n return () => {\n stop();\n ledger.dispose();\n };\n }, []);\n\n return <span>{state.undo} undo / {state.redo} redo</span>;\n}\n```\n\n```vue [Vue 3]\n<script setup lang=\"ts\">\nimport { onUnmounted, ref } from 'vue';\n\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nconst undoCount = ref(0);\nconst stop = ledger.state.subscribe(() => { undoCount.value = ledger.state.value.undo.length; });\n\nonUnmounted(() => {\n stop();\n ledger.dispose();\n});\n</script>\n```\n\n```ts [Svelte]\nimport { onMount } from 'svelte';\n\nimport { createLedger } from '@vielzeug/ledger';\n\nconst ledger = createLedger();\nlet undoCount = 0;\n\nonMount(() => {\n const stop = ledger.state.subscribe(() => { undoCount = ledger.state.value.undo.length; });\n\n return () => {\n stop();\n ledger.dispose();\n };\n});\n```\n\n:::\n\n## Working with Other Vielzeug Libraries\n\n### Ledger + Keymap\n\nRoute key handlers through one error boundary.\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 'ctrl+z': () => void ledger.undo().catch(reportHistoryError),\n 'ctrl+shift+z': () => void ledger.redo().catch(reportHistoryError),\n});\n\nmap.mount(document);\n```\n\n## Best Practices\n\n- **Submit** only commands with real revert behavior.\n- **Snapshot** state before command submission.\n- **Catch** operation promises at application boundaries.\n- **Check** `state.value` for history and operation status.\n- **Use** `whenIdle()` before releasing owners that need a drain boundary.\n- **Keep** irreversible effects outside Ledger commands.\n- **Dispose** ledger owners during framework teardown.\n",
7
+ "examples": "---\ntitle: Ledger — Examples\ndescription: Worked examples for @vielzeug/ledger.\n---\n\n## Examples\n\n- [Text Editor History](./examples/text-editor.md)\n- [Form History](./examples/form-history.md)\n"
8
8
  },
9
9
  "examples": [
10
10
  {
11
11
  "id": "cancellation",
12
- "code": "import { createLedger } from '@vielzeug/ledger'\n\n// execute()/rollback() receive an AbortSignal — pass your own via { signal }\n// to cancel a specific in-flight command\nconst ledger = createLedger()\nconst controller = new AbortController()\nconst log = []\n\nconst save = ledger.do(\n {\n execute: async (signal) => {\n log.push('save started')\n // Check signal.aborted up front — an already-aborted signal never\n // fires a future 'abort' event, so a listener alone can miss it\n if (signal.aborted) throw new Error('save aborted')\n await new Promise((resolve, reject) => {\n const timer = setTimeout(resolve, 200)\n signal.addEventListener('abort', () => {\n clearTimeout(timer)\n reject(new Error('save aborted'))\n })\n })\n log.push('save finished') // never reached below\n },\n label: 'Save document',\n },\n { signal: controller.signal },\n)\n\ncontroller.abort()\n\ntry {\n await save\n} catch (err) {\n console.log('caught:', err.message) // 'save aborted'\n}\nconsole.log('log:', log) // ['save started'] — never got to 'save finished'\n\n// The signal is also merged with the ledger's own disposalSignal — no\n// external AbortController needed to react to dispose(). Poll signal.aborted\n// rather than relying on a future 'abort' event — the signal may already be\n// aborted by the time this command actually starts running its queue turn\nconst pending = ledger.do({\n execute: async (signal) => {\n while (!signal.aborted) {\n await new Promise((resolve) => setTimeout(resolve, 10))\n }\n },\n})\n\nledger.dispose()\nawait pending\nconsole.log('ledger disposed:', ledger.disposed) // true",
13
- "name": "Cancellation (AbortSignal)"
12
+ "code": "import { LedgerCancelledError, createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger()\nconst controller = new AbortController()\n\nconst save = ledger.do(\n {\n apply: async ({ signal }) => {\n if (signal.aborted) throw new Error('save aborted')\n await new Promise((resolve) => setTimeout(resolve, 50))\n },\n revert: () => {},\n },\n { signal: controller.signal },\n)\n\ncontroller.abort()\n\ntry {\n await save\n} catch (error) {\n console.log('cancelled:', error instanceof LedgerCancelledError)\n}\n\nconsole.log('undo entries:', ledger.state.value.undo.length)\nledger.dispose()",
13
+ "name": "Cancellation"
14
14
  },
15
15
  {
16
16
  "id": "command-data",
17
- "code": "import { createLedger } from '@vielzeug/ledger'\n\n// Store before/after snapshots with each command\nconst ledger = createLedger()\nconst doc = { title: 'Untitled', body: '' }\n\nasync function setTitle(next) {\n const prev = doc.title\n await ledger.do({\n data: { field: 'title', before: prev, after: next },\n execute: () => { doc.title = next },\n rollback: () => { doc.title = prev },\n label: 'Set title',\n })\n}\n\nasync function setBody(next) {\n const prev = doc.body\n await ledger.do({\n data: { field: 'body', before: prev, after: next },\n execute: () => { doc.body = next },\n rollback: () => { doc.body = prev },\n label: 'Set body',\n })\n}\n\n// Queue multiple operations (pendingCount tracks them)\nconst p1 = setTitle('Hello World')\nconst p2 = setBody('Lorem ipsum')\nconsole.log('queued ops:', ledger.pendingCount.value) // 2\n\nawait Promise.all([p1, p2])\nconsole.log('after edits — doc:', doc)\nconsole.log('pendingCount:', ledger.pendingCount.value) // 0\n\n// historySnapshot exposes data for each undo step (newest first)\nconst [latest, earlier] = ledger.historySnapshot.value\nconsole.log('latest data:', latest.data) // { field: 'body', before: '', after: 'Lorem ipsum' }\nconsole.log('earlier data:', earlier.data) // { field: 'title', before: 'Untitled', after: 'Hello World' }\n\nawait ledger.undo()\nconsole.log('after undo body:', doc.body) // ''\n\nawait ledger.undo()\nconsole.log('after undo title:', doc.title) // 'Untitled'\n\nledger.dispose()",
18
- "name": "command data & pendingCount"
17
+ "code": "import { createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger()\nconst documentState = { title: 'Untitled' }\nconst previous = documentState.title\n\nawait ledger.do({\n apply: () => { documentState.title = 'Hello World' },\n label: 'Set title',\n meta: { after: 'Hello World', before: previous, field: 'title' },\n revert: () => { documentState.title = previous },\n})\n\nconsole.log('state:', documentState)\nconsole.log('history meta:', ledger.state.value.undo.at(-1)?.meta)\nconsole.log('queued:', ledger.state.value.queued)\nconsole.log('running:', ledger.state.value.running)\n\nawait ledger.undo()\nconsole.log('after undo:', documentState)\nledger.dispose()",
18
+ "name": "History Metadata & State"
19
19
  },
20
20
  {
21
21
  "id": "compose-commands",
22
- "code": "import { compose, createLedger } from '@vielzeug/ledger'\n\n// compose() groups commands into one atomic undo step\nconst ledger = createLedger()\nconst node = { label: 'old', x: 0, y: 0 }\nconst original = { label: node.label, x: node.x, y: node.y }\n\nawait ledger.do(compose([\n {\n execute: () => { node.x = 100 },\n rollback: () => { node.x = original.x },\n },\n {\n execute: () => { node.y = 50 },\n rollback: () => { node.y = original.y },\n },\n {\n execute: () => { node.label = 'moved' },\n rollback: () => { node.label = original.label },\n },\n], 'Move and rename'))\n\nconsole.log('after compose:', node) // { label: 'moved', x: 100, y: 50 }\nconsole.log('historySize:', ledger.historySize.value) // 1 — one step for all three\n\nawait ledger.undo()\nconsole.log('after undo:', node) // { label: 'old', x: 0, y: 0 }\n\n// If a sub-command throws, already-executed ones roll back automatically\ntry {\n await ledger.do(compose([\n { execute: () => { node.x = 999 }, rollback: () => { node.x = 0 } },\n { execute: () => { throw new Error('server error') } },\n ]))\n} catch (err) {\n console.log('compose threw:', err.message) // 'server error'\n console.log('node.x rolled back:', node.x) // 0 — first sub-command rolled back\n}\n\nledger.dispose()",
23
- "name": "compose() Atomic Multi-step"
22
+ "code": "import { compose, createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger()\nconst node = { x: 0, y: 0 }\n\nawait ledger.do(compose([\n {\n apply: () => { node.x = 100 },\n revert: () => { node.x = 0 },\n },\n {\n apply: () => { node.y = 50 },\n revert: () => { node.y = 0 },\n },\n], 'Move node'))\n\nconsole.log('after apply:', node)\nconsole.log('undo entries:', ledger.state.value.undo.length)\n\nawait ledger.undo()\nconsole.log('after revert:', node)\nledger.dispose()",
23
+ "name": "Compose Reversible Commands"
24
24
  },
25
25
  {
26
26
  "id": "do-undo-redo",
27
- "code": "import { createLedger } from '@vielzeug/ledger'\n\n// An undo/redo stack for any async or sync mutations\nconst ledger = createLedger()\nlet counter = 0\n\nasync function increment() {\n const prev = counter\n const next = prev + 1\n await ledger.do({\n execute: () => { counter = next },\n rollback: () => { counter = prev },\n label: 'Increment',\n })\n}\n\nawait increment()\nawait increment()\nawait increment()\nconsole.log('after 3 increments:', counter) // 3\nconsole.log('historySize:', ledger.historySize.value) // 3\nconsole.log('canUndo:', ledger.canUndo.value) // true\n\nawait ledger.undo()\nconsole.log('after undo:', counter) // 2\n\nawait ledger.undo()\nconsole.log('after undo:', counter) // 1\n\nawait ledger.redo()\nconsole.log('after redo:', counter) // 2\n\n// A new do() discards the redo stack\nawait increment()\nconsole.log('historySize after new do:', ledger.historySize.value) // 3 (not 4)\n\nledger.dispose()",
27
+ "code": "import { createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger()\nlet counter = 0\n\nasync function increment() {\n const previous = counter\n const next = previous + 1\n\n await ledger.do({\n apply: () => { counter = next },\n label: 'Increment',\n revert: () => { counter = previous },\n })\n}\n\nawait increment()\nawait increment()\nawait increment()\nconsole.log('after increments:', counter)\nconsole.log('undo entries:', ledger.state.value.undo.length)\n\nawait ledger.undo()\nconsole.log('after undo:', counter)\n\nawait ledger.redo()\nconsole.log('after redo:', counter)\nledger.dispose()",
28
28
  "name": "do / undo / redo"
29
29
  },
30
30
  {
31
31
  "id": "reactive-signals",
32
- "code": "import { createLedger } from '@vielzeug/ledger'\n\n// historySnapshot exposes command labels — useful for undo history UI panels\nconst ledger = createLedger({ maxHistory: 5 })\n\nconst ops = [\n { label: 'Rename node', execute: () => {}, rollback: () => {} },\n { label: 'Move node', execute: () => {}, rollback: () => {} },\n { label: 'Resize node', execute: () => {}, rollback: () => {} },\n]\n\nfor (const op of ops) {\n await ledger.do(op)\n}\n\n// historySnapshot is newest-first\nconsole.log('labels:', ledger.historySnapshot.value.map(e => e.label))\n// ['Resize node', 'Move node', 'Rename node']\n\nconsole.log('historySize:', ledger.historySize.value) // 3\nconsole.log('canUndo:', ledger.canUndo.value) // true\nconsole.log('canRedo:', ledger.canRedo.value) // false\n\nawait ledger.undo()\nconsole.log('canRedo after undo:', ledger.canRedo.value) // true\nconsole.log('labels after undo:', ledger.historySnapshot.value.map(e => e.label))\n// ['Move node', 'Rename node']\n\nledger.clear()\nconsole.log('historySize after clear:', ledger.historySize.value) // 0\n\nledger.dispose()",
33
- "name": "Reactive Signals & historySnapshot"
32
+ "code": "import { createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger({ maxHistory: 5 })\nlet value = 0\n\nfor (const label of ['Increase', 'Increase again']) {\n const previous = value\n await ledger.do({\n apply: () => { value += 1 },\n label,\n revert: () => { value = previous },\n })\n}\n\nconsole.log('undo labels:', ledger.state.value.undo.map(entry => entry.label))\nconsole.log('queued/running:', ledger.state.value.queued, ledger.state.value.running)\n\nawait ledger.clear()\nconsole.log('undo entries after clear:', ledger.state.value.undo.length)\nledger.dispose()",
33
+ "name": "Reactive State"
34
34
  },
35
35
  {
36
36
  "id": "rollback-error",
37
- "code": "import { createLedger } from '@vielzeug/ledger'\n\n// onRollbackError surfaces undo failures without silently swallowing them\nconst errors = []\n\nconst ledger = createLedger({\n onRollbackError: (err, meta) => {\n errors.push({ label: meta.label, message: err.message })\n },\n})\n\nawait ledger.do({\n execute: async () => { console.log('executed') },\n rollback: async () => { throw new Error('server unreachable') },\n label: 'Save to server',\n})\n\nawait ledger.undo()\n// rollback threw stack position is unchanged, onRollbackError was called\n\nconsole.log('rollback errors:', errors)\n// [{ label: 'Save to server', message: 'server unreachable' }]\n\n// The entry stays on the undo stack so the operation can be retried\nconsole.log('canUndo (still true):', ledger.canUndo.value)\n\nledger.dispose()",
38
- "name": "onRollbackError Hook"
37
+ "code": "import { LedgerRollbackError, createLedger } from '@vielzeug/ledger'\n\nconst ledger = createLedger()\n\nawait ledger.do({\n apply: () => console.log('applied'),\n label: 'Save to server',\n revert: () => { throw new Error('server unreachable') },\n})\n\ntry {\n await ledger.undo()\n} catch (error) {\n if (error instanceof LedgerRollbackError) {\n console.log('revert failed:', error.message)\n }\n}\n\nconsole.log('undo entries:', ledger.state.value.undo.length)\nledger.dispose()",
38
+ "name": "Rollback Error"
39
39
  }
40
40
  ],
41
41
  "typeSignatures": {
42
42
  "compose": "export { compose } from './compose';",
43
- "LedgerDisposedError": "export { LedgerDisposedError, LedgerError, LedgerExecutionError, LedgerRollbackError } from './errors';",
44
- "LedgerError": "export { LedgerDisposedError, LedgerError, LedgerExecutionError, LedgerRollbackError } from './errors';",
45
- "LedgerExecutionError": "export { LedgerDisposedError, LedgerError, LedgerExecutionError, LedgerRollbackError } from './errors';",
46
- "LedgerRollbackError": "export { LedgerDisposedError, LedgerError, LedgerExecutionError, LedgerRollbackError } from './errors';",
43
+ "LedgerCancelledError": "export {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';",
44
+ "LedgerDisposedError": "export {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';",
45
+ "LedgerError": "export {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';",
46
+ "LedgerExecutionError": "export {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';",
47
+ "LedgerRollbackError": "export {\n LedgerCancelledError,\n LedgerDisposedError,\n LedgerError,\n LedgerExecutionError,\n LedgerRollbackError,\n} from './errors';",
47
48
  "createLedger": "export { createLedger } from './ledger';",
48
- "Command": "export type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';",
49
- "CommandMeta": "export type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';",
50
- "Ledger": "export type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';",
51
- "LedgerCallOptions": "export type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';",
52
- "LedgerOptions": "export type { Command, CommandMeta, Ledger, LedgerCallOptions, LedgerOptions } from './types';"
49
+ "CommandContext": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
50
+ "HistoryEntry": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
51
+ "Ledger": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
52
+ "LedgerCallOptions": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
53
+ "LedgerOptions": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
54
+ "LedgerState": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';",
55
+ "ReversibleCommand": "export type {\n CommandContext,\n HistoryEntry,\n Ledger,\n LedgerCallOptions,\n LedgerOptions,\n LedgerState,\n ReversibleCommand,\n} from './types';"
53
56
  }
54
57
  }