@vielzeug/codex 2.2.7 → 2.2.8

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 (39) hide show
  1. package/data/catalog.json +1693 -0
  2. package/data/llms-full.txt +27748 -0
  3. package/data/llms.txt +40 -0
  4. package/data/manifest.json +8 -0
  5. package/data/packages/arsenal.json +210 -0
  6. package/data/packages/assay.json +39 -0
  7. package/data/packages/clockwork.json +67 -0
  8. package/data/packages/codex.json +43 -0
  9. package/data/packages/coins.json +102 -0
  10. package/data/packages/conduit.json +60 -0
  11. package/data/packages/courier.json +58 -0
  12. package/data/packages/dnd.json +77 -0
  13. package/data/packages/familiar.json +40 -0
  14. package/data/packages/flux.json +93 -0
  15. package/data/packages/forge.json +84 -0
  16. package/data/packages/herald.json +108 -0
  17. package/data/packages/keymap.json +60 -0
  18. package/data/packages/ledger.json +57 -0
  19. package/data/packages/lingua.json +67 -0
  20. package/data/packages/necromancer.json +50 -0
  21. package/data/packages/orbit.json +99 -0
  22. package/data/packages/ore.json +73 -0
  23. package/data/packages/prism.json +66 -0
  24. package/data/packages/pulse.json +69 -0
  25. package/data/packages/refine.json +12 -0
  26. package/data/packages/ripple.json +83 -0
  27. package/data/packages/rune.json +79 -0
  28. package/data/packages/sandbox.json +40 -0
  29. package/data/packages/scout.json +60 -0
  30. package/data/packages/scroll.json +109 -0
  31. package/data/packages/sourcerer.json +72 -0
  32. package/data/packages/spell.json +133 -0
  33. package/data/packages/tempo.json +81 -0
  34. package/data/packages/vault.json +85 -0
  35. package/data/packages/ward.json +114 -0
  36. package/data/packages/wayfinder.json +110 -0
  37. package/data/refine.json +11926 -0
  38. package/data/search.json +1432 -0
  39. package/package.json +1 -1
@@ -0,0 +1,1432 @@
1
+ [
2
+ {
3
+ "category": "utilities",
4
+ "description": "tree shakeable typescript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.",
5
+ "docs": {
6
+ "index": " \ntitle: arsenal — utility library for typescript\ndescription: tree shakeable typescript utilities with focused category entry points for arrays, async work, caching, objects, strings, math, and guards.\npackage: arsenal\ncategory: utilities\nkeywords: [utility, array, string, object, math, async, debounce, throttle, cache]\nexports: [chunk, groupby, retry, debounce, clamp, isequal, taskpool, cache, fuzzyfilter, tryparsejson]\nrelated: [tempo, sourcerer, spell, coins]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"arsenal\" />\n\n## why arsenal?\n\narsenal keeps common utilities at package root and places specialized behavior behind category entry points. this keeps autocomplete focused while preserving one dependency and tree shakeable modules.\n\n```ts\n// before\nconst users = json.parse(raw).filter((user) => user.name.includes(query));\n\n// after\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst parsed = tryparsejson(raw);\nconst users = parsed.ok ? fuzzyfilter(parsed.value as user[], query, { select: (user) => user.name }) : [];\n```\n\n| feature | arsenal | lodash es | remeda |\n| | | | |\n| bundle size | <packageinfo package=\"arsenal\" type=\"size\" /> | ~72 kb | ~18 kb |\n| typed root utilities | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| category entry points | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| async task pool and cache | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use arsenal when** you need one typed utility dependency with focused subpaths for specialized behavior.\n\n**consider narrower alternatives when** you need only platform apis or a small functional subset.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/arsenal\n```\n\n```sh [npm]\nnpm install @vielzeug/arsenal\n```\n\n```sh [yarn]\nyarn add @vielzeug/arsenal\n```\n\n:::\n\n## quick start\n\n```ts\nimport { chunk, groupby, retry } from '@vielzeug/arsenal';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst pages = chunk([1, 2, 3, 4, 5], 2);\nconst byrole = groupby([{ role: 'admin' }, { role: 'user' }], (user) => user.role);\n\nconst pool = taskpool({ concurrency: 2 });\nconst health = await pool.run((signal) => retry(() => fetch('/health', { signal }).then((response) => response.json())));\n\nconst responses = cache<string, unknown>({ ttlms: 60_000 });\nconst profile = await responses.getorload('/profile', () => fetch('/profile').then((response) => response.json()));\n\npool.dispose();\nconsole.log(pages, byrole, health, profile);\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`chunk`**: common array/string chunking from package root\n **`retry`**: retry async work with cancellation support from package root\n **`taskpool`**: bounded, disposable concurrent work from `/async`\n **`cache`**: identity keyed ttl cache with async load deduplication from `/cache`\n **`fuzzyfilter`**: explicit field fuzzy filtering from `/array`\n **`tryparsejson`**: preserve json syntax failures from `/object`\n **`clamp`**: numeric bounds from package root\n **`isequal`**: structural equality from package root\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/) — validate `unknown` json data after `tryparsejson`.\n [vault](/vault/) — persistent storage; arsenal cache is in memory only.\n [tempo](/tempo/) — date/time utilities kept outside arsenal.\n [coins](/coins/) — money formatting and currency conversion.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
7
+ "api": " \ntitle: arsenal — api reference\ndescription: reference for arsenal root utilities and category entry points.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `chunk` | split arrays or strings | sync | root export |\n| `groupby` | group values by key | sync | root export |\n| `retry` | retry async work | async | rethrows final error |\n| `taskpool` | bound concurrent tasks | async | available from `/async` |\n| `cache` | in memory identity keyed cache | async | available from `/cache` |\n| `fuzzyfilter` | filter string or selected object fields | sync | object collections require `select` |\n| `fuzzyscore` | rank string or selected object fields | sync | object collections require `select` |\n| `tryparsejson` | preserve json syntax result | sync | returns `unknown` on success |\n| `getpath` | optional object lookup | sync | available from `/object` |\n| `clamp` | bound number to range | sync | root export |\n| `isequal` | structural equality | sync | root export |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/arsenal` | curated common utilities |\n| `@vielzeug/arsenal/array` | array transforms, sorting, fuzzy search |\n| `@vielzeug/arsenal/async` | retry, cancellation, task pool, timing |\n| `@vielzeug/arsenal/cache` | in memory cache and memoization |\n| `@vielzeug/arsenal/function` | composition, timing, assertions |\n| `@vielzeug/arsenal/guards` | predicate and type guard helpers |\n| `@vielzeug/arsenal/math` | numeric and statistical helpers |\n| `@vielzeug/arsenal/object` | paths, transforms, hash, json parse result |\n| `@vielzeug/arsenal/random` | random selection and uuid helpers |\n| `@vielzeug/arsenal/string` | text transforms and similarity |\n\n## array\n\n### fuzzyfilter / fuzzyscore\n\n```ts\nfuzzyfilter(strings: readonly string[], query: string, options?: fuzzyoptions): string[]\nfuzzyfilter<t>(items: readonly t[], query: string, options: fuzzyselection<t>): t[]\nfuzzyscore(strings: readonly string[], query: string, options?: fuzzyoptions): scoredresult<string>[]\nfuzzyscore<t>(items: readonly t[], query: string, options: fuzzyselection<t>): scoredresult<t>[]\n```\n\n`fuzzyfilter` preserves input order. `fuzzyscore` orders results by descending score.\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\n\nconst users = [{ email: 'alice@example.com', name: 'alice' }];\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\n```\n\n \n\n## async\n\n### taskpool\n\n```ts\ninterface taskpool {\n run<t>(task: (signal: abortsignal) => promise<t>): promise<t>;\n idle(): promise<void>;\n dispose(reason?: unknown): void;\n readonly active: number;\n readonly pending: number;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n\ntaskpool(options?: { concurrency?: number }): taskpool\n```\n\n`dispose()` aborts running cooperative tasks and rejects pending tasks.\n\n```ts\nimport { taskpool } from '@vielzeug/arsenal/async';\n\nconst pool = taskpool({ concurrency: 2 });\nconst user = await pool.run((signal) => fetch('/user', { signal }).then((response) => response.json()));\npool.dispose();\n```\n\n \n\n## cache\n\n### cache\n\n```ts\ninterface cache<k, t> {\n get(key: k): t | undefined;\n set(key: k, value: t, options?: { ttlms?: number }): void;\n getorload(key: k, load: () => promise<t>): promise<t>;\n delete(key: k): boolean;\n clear(): void;\n readonly size: number;\n}\n\ncache<k, t>(options?: cacheoptions): cache<k, t>\n```\n\nkeys use native `map` identity. expiry is lazy, evaluated by `get` and `getorload`. the `size` getter returns the live entry count without evicting.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\nconst profiles = cache<string, profile>({ ttlms: 60_000 });\nconst profile = await profiles.getorload('me', loadprofile);\n```\n\n \n\n## object\n\n### tryparsejson\n\n```ts\ntype jsonparseresult = { ok: true; value: unknown } | { error: syntaxerror; ok: false };\n\ntryparsejson(text: string): jsonparseresult\n```\n\nuse a schema validator after success to refine `unknown` data.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n\nconst result = tryparsejson(raw);\nif (!result.ok) throw result.error;\n```\n\n### getpath\n\n```ts\ngetpath<t extends record<string, unknown>, p extends string>(item: t, path: p): pathvalue<t, p> | undefined\ngetpathor<t extends record<string, unknown>, p extends string, f>(item: t, path: p, fallback: f): pathvalue<t, p> | f\nrequirepath<t extends record<string, unknown>, p extends string>(item: t, path: p): exclude<pathvalue<t, p>, undefined>\n```\n\n## types\n\n```ts\ntype fuzzyoptions = {\n normalize?: boolean;\n threshold?: number;\n};\n\ntype fuzzyselection<t> = fuzzyoptions & {\n select: (item: t) => string | readonly string[];\n};\n\ntype scoredresult<t> = { item: t; score: number };\n\ntype cacheoptions = {\n capacity?: number;\n now?: () => number;\n ttlms?: number;\n};\n```\n\n## errors\n\n `rangeerror` — invalid numeric bounds, capacity, concurrency, or retry count.\n `typeerror` — invalid value types, unsupported comparison, or required path missing.\n `arsenalserializationerror` — memo or hash cannot serialize supplied input.\n",
8
+ "usage": " \ntitle: arsenal — usage guide\ndescription: use arsenal root utilities for common work and category entry points for specialized collection, async, cache, object, and string behavior.\n \n\n[[toc]]\n\n## basic usage\n\nstart at package root for common transforms. move to a category entry point when code needs specialized behavior. this keeps imports readable and bundles focused.\n\n```ts\nimport { chunk, groupby, retry } from '@vielzeug/arsenal';\n\nconst users = [\n { id: 'a1', role: 'admin' },\n { id: 'u1', role: 'user' },\n { id: 'u2', role: 'user' },\n];\n\nconst pages = chunk(users, 2);\nconst byrole = groupby(users, (user) => user.role);\nconst health = await retry(() => fetch('/health').then((response) => response.json()));\n\nconsole.log(pages, byrole, health);\n```\n\nuse category imports for apis absent from root:\n\n```ts\nimport { fuzzyfilter } from '@vielzeug/arsenal/array';\nimport { taskpool } from '@vielzeug/arsenal/async';\nimport { cache } from '@vielzeug/arsenal/cache';\nimport { tryparsejson } from '@vielzeug/arsenal/object';\n```\n\n## transform collections\n\nuse `/array` for transforms that preserve input immutability. `filtermap` combines mapping and omission; `indexby` and `groupby` build lookup structures without mutation.\n\n```ts\nimport { filtermap, indexby, sort } from '@vielzeug/arsenal/array';\n\nconst products = [\n { id: 'p1', price: 20, published: true },\n { id: 'p2', price: 10, published: false },\n { id: 'p3', price: 15, published: true },\n];\n\nconst publishedlabels = filtermap(products, (product) => (product.published ? `${product.id}: ${product.price}` : undefined));\nconst byid = indexby(products, (product) => product.id);\nconst byprice = sort(products, (product) => product.price);\n\nconsole.log(publishedlabels, byid, byprice);\n```\n\n## search explicit fields\n\nsearch string arrays directly. object collections require `select`, so callers define exactly what can match.\n\n```ts\nimport { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array';\n\nconst users = [\n { email: 'alice@example.com', name: 'alice' },\n { email: 'bob@example.com', name: 'bob' },\n];\n\nconst matches = fuzzyfilter(users, 'alice', { select: (user) => [user.name, user.email] });\nconst ranked = fuzzyscore(users, 'ali', { select: (user) => user.name });\n```\n\n## work with object data\n\nuse `/object` for paths, key selection, stable cache keys, and object transforms.\n\n```ts\nimport { getpathor, hash, omit, pick } from '@vielzeug/arsenal/object';\n\nconst config = { api: { host: 'localhost', port: 3000 }, debug: true };\nconst port = getpathor(config, 'api.port', 8080);\nconst publicconfig = pick(config, ['api']);\nconst productionconfig = omit(config, ['debug']);\nconst key = hash({ port, productionconfig });\n\nconsole.log(publicconfig, key);\n```\n\n## parse and validate json\n\n`tryparsejson` distinguishes syntax failure from schema failure. treat successful values as `unknown`, then validate with spell or application code.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst user = s.object({ id: s.string(), name: s.string() });\nconst parsed = tryparsejson(raw);\n\nif (!parsed.ok) throw parsed.error;\n\nconst user = user.parse(parsed.value);\n```\n\n## bound concurrent work\n\nuse `parallel` for one finite collection. use `taskpool` when tasks arrive over time or need disposal.\n\n```ts\nimport { parallel, taskpool } from '@vielzeug/arsenal/async';\n\nconst metadata = await parallel(urls, (url) => fetch(url).then((response) => response.json()), { limit: 4 });\n\nconst pool = taskpool({ concurrency: 2 });\nconst profile = await pool.run((signal) => fetch('/profile', { signal }).then((response) => response.json()));\n\nawait pool.idle();\npool.dispose();\n\nconsole.log(metadata, profile);\n```\n\n## cache loaded values\n\nuse `cache` for process local values. keys retain native `map` identity. `getorload` deduplicates concurrent loads for one key.\n\n```ts\nimport { cache } from '@vielzeug/arsenal/cache';\n\ntype profile = { id: string; name: string };\n\nconst profiles = cache<string, profile>({ capacity: 100, ttlms: 60_000 });\nconst profile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nprofiles.delete('me');\nconst freshprofile = await profiles.getorload('me', () => fetch('/profile').then((response) => response.json()));\n\nconsole.log(profile, freshprofile);\n```\n\n## test deterministic randomness\n\nrandom helpers use cryptographic entropy by default. pass `randomsource` in tests when output must be deterministic.\n\n```ts\nimport { random, type randomsource } from '@vielzeug/arsenal/random';\n\nconst source: randomsource = { next: () => 0.5 };\n\nrandom(1, 4, source); // 3\n```\n\n## working with other vielzeug libraries\n\nuse spell after `tryparsejson` for typed external data. use vault instead of `cache` when data must survive reloads or process restart.\n\n```ts\nimport { tryparsejson } from '@vielzeug/arsenal/object';\nimport { s } from '@vielzeug/spell';\n\nconst settings = s.object({ theme: s.string() });\nconst parsed = tryparsejson(rawsettings);\nconst settings = parsed.ok ? settings.parse(parsed.value) : { theme: 'system' };\n```\n\n## best practices\n\n import common transforms from package root.\n import specialized apis from category entry points.\n pass `select` for every fuzzy search over objects.\n validate parsed json before using it as application data.\n use `parallel` for finite batches and `taskpool` for ongoing work.\n dispose task pools when their owner ends.\n use `cache` only for in memory data.\n inject `randomsource` in deterministic tests.\n",
9
+ "examples": " \ntitle: arsenal — examples\ndescription: practical examples and recipes for arsenal.\n \n\n## examples\n\n [array utilities](./examples/array.md)\n [async utilities](./examples/async.md)\n [cache utilities](./examples/cache.md)\n [function utilities](./examples/function.md)\n [guards / typed predicates](./examples/typed.md)\n [math utilities](./examples/math.md)\n [object utilities](./examples/object.md) — includes `getpath`, `tryparsejson`, `stringify`, `diff`, `deepmerge`\n [random utilities](./examples/random.md)\n [string utilities](./examples/string.md)\n"
10
+ },
11
+ "examples": [
12
+ {
13
+ "id": "array-chunk",
14
+ "text": "chunk split array into chunks import { chunk } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]\n\nconsole.log('original:', numbers)\nconsole.log('chunks of 3:', chunk(numbers, 3))\nconsole.log('chunks of 4:', chunk(numbers, 4))\n\n// practical use case: batch processing\nconst userids = [101, 102, 103, 104, 105, 106, 107, 108]\nconst batches = chunk(userids, 3)\nconsole.log('user id batches:', batches)"
15
+ },
16
+ {
17
+ "id": "array-filter",
18
+ "text": "filtermap filter and map array elements import { filtermap } from '@vielzeug/arsenal'\n\nconst users = [\n { name: 'alice', age: 25, active: true },\n { name: 'bob', age: 30, active: false },\n { name: 'charlie', age: 35, active: true },\n { name: 'david', age: 28, active: true }\n]\n\nconst activeusers = filtermap(users, user =>\n user.active ? user : undefined\n)\nconsole.log('active users:', activeusers)\n\nconst over30 = filtermap(users, user =>\n user.age > 30 ? user : undefined\n)\nconsole.log('users over 30:', over30)\n\nconst activenames = filtermap(users, user =>\n user.active ? user.name : undefined\n)\nconsole.log('active user names:', activenames)"
19
+ },
20
+ {
21
+ "id": "array-group",
22
+ "text": "groupby group array by key import { groupby } from '@vielzeug/arsenal'\n\nconst items = [\n { type: 'fruit', name: 'apple', price: 1.2 },\n { type: 'vegetable', name: 'carrot', price: 0.8 },\n { type: 'fruit', name: 'banana', price: 0.5 },\n { type: 'vegetable', name: 'broccoli', price: 1.5 },\n { type: 'fruit', name: 'orange', price: 0.9 }\n]\n\nconst bytype = groupby(items, item => item.type)\nconsole.log('grouped by type:', bytype)\n\nconst bypricerange = groupby(items, item =>\n item.price < 1 ? 'cheap' : 'expensive'\n)\nconsole.log('grouped by price:', bypricerange)"
23
+ },
24
+ {
25
+ "id": "array-map",
26
+ "text": "filtermap transform array elements import { filtermap } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 3, 4, 5]\n\nconst doubled = filtermap(numbers, number => number * 2)\nconsole.log('doubled:', doubled)\n\nconst strings = filtermap(numbers, number => `number: ${number}`)\nconsole.log('formatted:', strings)\n\nconst evendoubled = filtermap(numbers, number =>\n number % 2 === 0 ? number * 2 : undefined\n)\nconsole.log('even numbers doubled:', evendoubled)"
27
+ },
28
+ {
29
+ "id": "array-search",
30
+ "text": "fuzzyfilter explicit searchable fields import { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array'\n\nconst users = [\n { name: 'alice johnson', role: 'admin' },\n { name: 'bob smith', role: 'user' },\n { name: 'charlie brown', role: 'user' },\n]\n\nconst byname = fuzzyfilter(users, 'alice', { select: user => user.name })\nconsole.log('filtered:', byname)\n\nconst ranked = fuzzyscore(users, 'smith', { select: user => [user.name, user.role] })\nconsole.log('ranked:', ranked)"
31
+ },
32
+ {
33
+ "id": "array-search-normalize",
34
+ "text": "fuzzyfilter unicode normalization import { fuzzyfilter, fuzzyscore } from '@vielzeug/arsenal/array'\n\nconst names = ['josé', 'élise', 'café', 'naïve', 'resume']\n\nconst nonorm = fuzzyfilter(names, 'jose', { threshold: 0.9 })\nconsole.log('normalize:false:', nonorm)\n\nconst withnorm = fuzzyfilter(names, 'jose', { normalize: true, threshold: 0.9 })\nconsole.log('normalize:true:', withnorm)\n\nconst scored = fuzzyscore(names, 'elise', { normalize: true, threshold: 0.5 })\nconsole.log('scored:', scored)"
35
+ },
36
+ {
37
+ "id": "array-uniq",
38
+ "text": "uniq remove duplicates import { uniq } from '@vielzeug/arsenal'\n\nconst numbers = [1, 2, 2, 3, 3, 3, 4, 5, 5]\nconsole.log('unique numbers:', uniq(numbers))\n\nconst tags = ['javascript', 'react', 'vue', 'react', 'angular', 'vue']\nconsole.log('unique tags:', uniq(tags))\n\n// works with objects too (by reference)\nconst obj1 = { id: 1 }\nconst obj2 = { id: 2 }\nconst objects = [obj1, obj2, obj1, obj2]\nconsole.log('unique objects:', uniq(objects))"
39
+ },
40
+ {
41
+ "id": "async-attempt",
42
+ "text": "attempt safe async execution with isfail/isok helpers import { attempt, isfail, isok, retry } from '@vielzeug/arsenal/async'\n\n// attempt() wraps any async function — never throws, always returns { ok, value|error }\nconst ok = await attempt(async () => {\n await new promise(r => settimeout(r, 10))\n return { id: 1, title: 'buy groceries' }\n})\n\nif (isok(ok)) {\n console.log('success:', ok.value)\n}\n\n// failure path — errors are captured, not thrown\nconst fail = await attempt(async () => {\n throw new error('network timeout')\n})\n\nif (isfail(fail)) {\n console.log('caught:', fail.error.message) // 'network timeout'\n}\n\n// combine with retry() for resilient operations\nlet calls = 0\nconst result = await attempt(() =>\n retry(async () => {\n calls++\n if (calls < 3) throw new error('not ready')\n return 'ready'\n }, { times: 5, delay: 10 })\n)\n\nconsole.log('ok?', result.ok) // true\nconsole.log('value:', result.value) // 'ready'\nconsole.log('calls:', calls) // 3"
43
+ },
44
+ {
45
+ "id": "async-parallel",
46
+ "text": "parallel controlled parallel execution import { parallel } from '@vielzeug/arsenal/async'\n\nconst items = [1, 2, 3, 4, 5, 6, 7, 8]\n\nconst results = await parallel(\n items,\n async item => {\n console.log(`processing: ${item}`)\n await new promise(resolve => settimeout(resolve, 100))\n return item * 2\n },\n { limit: 2 }\n)\n\nconsole.log('results:', results)"
47
+ },
48
+ {
49
+ "id": "async-pool",
50
+ "text": "taskpool parallel execution with concurrency limit import { taskpool } from '@vielzeug/arsenal/async'\n\nconst pool = taskpool({ concurrency: 3 })\nconst tasks = array.from({ length: 6 }, (_, index) =>\n pool.run(async () => {\n console.log(`task ${index + 1} started`)\n await new promise(resolve => settimeout(resolve, 100))\n return `result ${index + 1}`\n }),\n)\n\nconsole.log('all results:', await promise.all(tasks))\nawait pool.idle()\npool.dispose()"
51
+ },
52
+ {
53
+ "id": "async-queue",
54
+ "text": "taskpool bounded concurrent work import { taskpool } from '@vielzeug/arsenal/async'\n\nconst pool = taskpool({ concurrency: 2 })\nconst tasks = [100, 50, 75, 30].map((delay, index) =>\n pool.run(async (signal) => {\n await new promise((resolve, reject) => {\n const timer = settimeout(resolve, delay)\n signal.addeventlistener('abort', () => {\n cleartimeout(timer)\n reject(signal.reason)\n }, { once: true })\n })\n return 'task ' + (index + 1)\n }),\n)\n\nconsole.log('after enqueue:', { active: pool.active, pending: pool.pending })\nconsole.log('results:', await promise.all(tasks))\nawait pool.idle()\npool.dispose()"
55
+ },
56
+ {
57
+ "id": "async-retry",
58
+ "text": "retry retry failed operations import { retry } from '@vielzeug/arsenal/async'\n\nlet attempts = 0\nconst unreliableoperation = async () => {\n attempts++\n console.log(`attempt #${attempts}`)\n\n if (attempts < 3) {\n throw new error('failed!')\n }\n\n return 'success!'\n}\n\ntry {\n const result = await retry(unreliableoperation, {\n times: 5,\n delay: 100\n })\n console.log('result:', result)\n console.log('total attempts:', attempts)\n} catch (err) {\n console.error('all retries failed:', err.message)\n}"
59
+ },
60
+ {
61
+ "id": "async-waitFor",
62
+ "text": "waitfor poll until condition is true or timeout/abort fires import { waitfor } from '@vielzeug/arsenal/async'\n\n// simulate a value that becomes ready after a short delay\nlet ready = false\nsettimeout(() => { ready = true }, 200)\n\nconsole.log('waiting for ready...')\nawait waitfor(() => ready, { interval: 50, timeout: 2000 })\nconsole.log('ready!')\n\n// abort early with an external signal\nconst ac = new abortcontroller()\nsettimeout(() => ac.abort(new error('user cancelled')), 100)\n\ntry {\n await waitfor(() => false, {\n interval: 50,\n signal: ac.signal,\n timeout: 5000,\n })\n} catch (err) {\n console.log('aborted:', err.message) // 'user cancelled'\n}"
63
+ },
64
+ {
65
+ "id": "function-debounce",
66
+ "text": "debounce trailing (default) and leading edge options import { debounce } from '@vielzeug/arsenal/function'\n\n// trailing (default) \nlet trailingcount = 0\nconst onsearch = debounce((q) => {\n trailingcount++\n console.log(`trailing #${trailingcount}: \"${q}\"`)\n}, 200)\n\nonsearch('c')\nonsearch('ca')\nonsearch('cat') // only this fires after 200ms\n\n// leading only \nlet leadingcount = 0\nconst onsubmit = debounce((q) => {\n leadingcount++\n console.log(`leading #${leadingcount}: \"${q}\"`)\n}, 200, { leading: true, trailing: false })\n\nonsubmit('first') // fires immediately\nonsubmit('second') // silenced (within 200ms window)\nonsubmit('third') // silenced\n\n// leading + trailing: fires on both edges \nlet bothcount = 0\nconst onboth = debounce(() => {\n bothcount++\n console.log(`both edge #${bothcount}`)\n}, 200, { leading: true, trailing: true })\n\nonboth() // fires immediately (leading)\n// trailing edge fires after 200ms (bothcount becomes 2)\n\nsettimeout(() => {\n console.log('trailing fires:', trailingcount, '| leading fires:', leadingcount, '| both fires:', bothcount)\n // trailing: 1 | leading: 1 | both: 2\n}, 400)"
67
+ },
68
+ {
69
+ "id": "function-memo",
70
+ "text": "memo lru cache with size tracking and invalidation import { memo } from '@vielzeug/arsenal/cache'\n\n// lru cache capped at 3 entries — oldest evicted when full\nlet callcount = 0\nconst compute = memo(\n (n) => { callcount++; return n * n },\n { maxsize: 3 }\n)\n\nconsole.log(compute(2)) // 4 — computed\nconsole.log(compute(3)) // 9 — computed\nconsole.log(compute(4)) // 16 — computed\nconsole.log(compute(2)) // 4 — cache hit\nconsole.log('calls so far:', callcount) // 3\nconsole.log('cached entries:', compute.size) // 3\n\n// adding a 4th entry evicts the oldest (key 2)\nconsole.log(compute(5)) // 25 — computed, evicts 2\nconsole.log('after 4th entry, size:', compute.size) // 3\n\n// invalidate a specific entry\ncompute.invalidate(3)\nconsole.log('after invalidate(3), size:', compute.size) // 2\nconsole.log(compute(3)) // 9 — recomputed\nconsole.log('total calls:', callcount) // 5"
71
+ },
72
+ {
73
+ "id": "function-pipe",
74
+ "text": "pipe left to right function composition import { pipe } from '@vielzeug/arsenal'\n\n// pipe: left to right function composition\nconst add5 = (n) => n + 5\nconst multiply2 = (n) => n * 2\nconst square = (n) => n * n\n\nconst transform = pipe(add5, multiply2, square)\nconsole.log('transform(3):', transform(3)) // (3+5)*2 = 16, 16^2 = 256\n\n// works with string transformations too\nconst normalise = pipe(\n (s) => s.trim(),\n (s) => s.tolowercase(),\n (s) => s.replace(/\\s+/g, ' '),\n)\nconsole.log('normalise result:', normalise(' hello world ')) // 'hello world'\n\n// zero args returns the identity function\nconst id = pipe()\nconsole.log('identity:', id(42)) // 42"
75
+ },
76
+ {
77
+ "id": "function-runAll",
78
+ "text": "runall run all callbacks, collect errors import { runall } from '@vielzeug/arsenal/function'\n\n// run every teardown function — collect errors instead of stopping on first failure\nconst log = []\n\nconst teardowns = [\n () => { log.push('cleanup a'); },\n () => { log.push('cleanup b'); throw new error('b failed'); },\n () => { log.push('cleanup c'); },\n]\n\ntry {\n runall(teardowns, { reverse: true }) // lifo order matches setup teardown semantics\n} catch (err) {\n console.log('errors collected:', err instanceof aggregateerror) // true\n console.log('error count:', err.errors.length) // 1\n console.log('still ran:', log) // ['cleanup c', 'cleanup b', 'cleanup a']\n}\n\n// without failures — just runs all in order\nconst steps = []\nrunall([() => steps.push(1), () => steps.push(2), () => steps.push(3)])\nconsole.log('steps:', steps) // [1, 2, 3]"
79
+ },
80
+ {
81
+ "id": "function-stash-async",
82
+ "text": "cache async load deduplication import { cache } from '@vielzeug/arsenal/cache'\n\nconst users = cache({ ttlms: 5000 })\nlet fetchcount = 0\n\nfunction fetchuser(id) {\n return users.getorload('user:' + id, async () => {\n fetchcount++\n await new promise(resolve => settimeout(resolve, 10))\n return { id, name: 'user ' + id }\n })\n}\n\nconst [first, second] = await promise.all([fetchuser(1), fetchuser(1)])\nconsole.log('fetch count:', fetchcount)\nconsole.log('same reference:', first === second)\n\nusers.delete('user:1')\nconsole.log('fresh:', await fetchuser(1))"
83
+ },
84
+ {
85
+ "id": "function-throttle",
86
+ "text": "throttle throttle function calls import { throttle } from '@vielzeug/arsenal/function'\n\nlet scrollcount = 0\nconst handlescroll = () => {\n scrollcount++\n console.log(`scroll event #${scrollcount}`)\n}\n\nconst throttledscroll = throttle(handlescroll, 200)\n\n// simulate rapid scroll events\nfor (let i = 0; i < 10; i++) {\n settimeout(() => throttledscroll(), i * 50)\n}\n\nsettimeout(() => {\n console.log('total throttled calls:', scrollcount)\n}, 1000)"
87
+ },
88
+ {
89
+ "id": "math-average",
90
+ "text": "average calculate average import { average, median, sum } from '@vielzeug/arsenal/math'\n\nconst numbers = [10, 20, 30, 40, 50]\nconsole.log('average:', average(numbers))\nconsole.log('sum:', sum(numbers))\nconsole.log('min:', math.min(...numbers))\nconsole.log('max:', math.max(...numbers))\nconsole.log('median:', median(numbers))"
91
+ },
92
+ {
93
+ "id": "object-diff",
94
+ "text": "diff compare objects import { diff } from '@vielzeug/arsenal/object'\n\nconst before = {\n name: 'alice',\n age: 25,\n email: 'alice@old.com',\n settings: { theme: 'light', lang: 'en' }\n}\n\nconst after = {\n name: 'alice',\n age: 26,\n email: 'alice@new.com',\n settings: { theme: 'dark', lang: 'en' }\n}\n\nconst changes = diff(after, before)\nconsole.log('changes detected:', changes)"
95
+ },
96
+ {
97
+ "id": "object-diffArrays",
98
+ "text": "diffarrays set and lcs strategies import { diffarrays } from '@vielzeug/arsenal/object'\n\n// default 'set' strategy — order independent\nconst v1 = [1, 2, 3]\nconst v2 = [2, 3, 4]\nconst setdiff = diffarrays(v1, v2)\nconsole.log('set diff:', setdiff) // { added: [4], removed: [1] }\n\n// 'lcs' strategy — ordered minimal diff\nconst before = [1, 2, 3, 4, 5]\nconst after = [1, 3, 4, 5, 6]\nconst lcsdiff = diffarrays(before, after, { strategy: 'lcs' })\nconsole.log('lcs diff:', lcsdiff) // { added: [6], removed: [2] }\n\n// with custom comparefn for objects\nconst oldusers = [{ id: 1, name: 'alice' }, { id: 2, name: 'bob' }]\nconst newusers = [{ id: 2, name: 'bob' }, { id: 3, name: 'charlie' }]\nconst userdiff = diffarrays(oldusers, newusers, { comparefn: (a, b) => a.id === b.id })\nconsole.log('users:', userdiff) // { added: [{id:3,...}], removed: [{id:1,...}] }"
99
+ },
100
+ {
101
+ "id": "object-getPath",
102
+ "text": "getpath dot notation access import { getpath, getpathor, requirepath } from '@vielzeug/arsenal/object'\n\nconst config = {\n server: { host: 'localhost', ports: [3000, 3001] },\n db: { name: 'mydb', pool: { min: 2, max: 10 } }\n}\n\n// standard dot notation\nconsole.log(getpath(config, 'server.host')) // 'localhost'\nconsole.log(getpath(config, 'db.pool.max')) // 10\nconsole.log(getpath(config, 'server.ports.0')) // 3000\nconsole.log(getpathor(config, 'missing', 'default')) // 'default'\nconsole.log(getpath(config, 'server.ports[1]')) // 3001\n\ntry {\n requirepath(config, 'db.pool.timeout')\n} catch (e) {\n console.log('threw:', e.message)\n}\n\nconsole.log(getpathor(config, '__proto__.polluted', 'safe')) // 'safe'"
103
+ },
104
+ {
105
+ "id": "object-hash",
106
+ "text": "hash deterministic cache key from any value import { hash } from '@vielzeug/arsenal/object'\n\n// stable cache key regardless of object key insertion order\nconst key1 = hash({ sort: 'asc', filter: { role: 'admin' } })\nconst key2 = hash({ filter: { role: 'admin' }, sort: 'asc' })\nconsole.log('same key?', key1 === key2) // true\nconsole.log('key:', key1) // '{\"filter\":{\"role\":\"admin\"},\"sort\":\"asc\"}'\n\n// handles date, regexp, set, map, bigint\nconsole.log(hash(new date('2024 01 01t00:00:00z'))) // '[date:2024 01 01t00:00:00.000z]'\nconsole.log(hash(new set([3, 1, 2]))) // '[set:1,2,3]' — sorted\nconsole.log(hash(new map([['b', 2], ['a', 1]]))) // '[map:\"a\"=>1,\"b\"=>2]' — sorted\nconsole.log(hash(42n)) // '42n'\nconsole.log(hash(/foo/gi)) // '[regexp:foo/gi]'\n\n// circular references produce a sentinel — no stack overflow\nconst obj = { x: 1 }\nobj.self = obj\nconsole.log(hash(obj)) // '{\"self\":[circular],\"x\":1}'\n\n// class instances coerce to string(instance) by default\nclass point {\n constructor(x, y) { this.x = x; this.y = y }\n tostring() { return `point(${this.x},${this.y})` }\n}\nconsole.log(hash(new point(1, 2))) // 'point(1,2)'"
107
+ },
108
+ {
109
+ "id": "object-merge",
110
+ "text": "deepmerge merge objects import { deepmerge, shallowmerge } from '@vielzeug/arsenal/object'\n\nconst obj1 = { a: 1, b: { c: 2 }, d: [1, 2] }\nconst obj2 = { b: { d: 3 }, e: 4, d: [3, 4] }\nconst obj3 = { a: 10, f: 5 }\n\nconst deeplymerged = deepmerge([obj1, obj2, obj3])\nconsole.log('deep merge:', deeplymerged)\n\nconst shallowlymerged = shallowmerge(obj1, obj2, obj3)\nconsole.log('shallow merge:', shallowlymerged)\n\nconst config1 = {\n api: { baseurl: 'https://api.dev', timeout: 5000 },\n features: { darkmode: true },\n}\nconst config2 = {\n api: { timeout: 10000, retries: 3 },\n features: { notifications: true },\n}\n\nconsole.log('merged configs:', deepmerge([config1, config2]))"
111
+ },
112
+ {
113
+ "id": "object-parseJSON",
114
+ "text": "tryparsejson preserve json syntax errors import { tryparsejson } from '@vielzeug/arsenal/object'\n\nconst valid = tryparsejson('{\"id\":1,\"name\":\"alice\"}')\nconst invalid = tryparsejson('{')\n\nif (valid.ok) console.log('parsed:', valid.value)\nif (!invalid.ok) console.log('syntax error:', invalid.error.message)"
115
+ },
116
+ {
117
+ "id": "object-prune",
118
+ "text": "prune remove empty values import { prune } from '@vielzeug/arsenal/object'\n\nconst data = {\n name: ' alice ',\n age: 30,\n tags: ['js', null, '', 'ts', undefined],\n settings: { theme: 'dark', extra: null, empty: {} }\n}\n\nconst cleaned = prune(data)\nconsole.log('pruned object:', cleaned)\n\n// prune array\nconst mixed = [1, null, 2, undefined, '', 3]\nconsole.log('pruned array:', prune(mixed))\n\n// prune string\nconsole.log('trimmed:', prune(' hello world '))\nconsole.log('empty string:', prune(' ')) // undefined"
119
+ },
120
+ {
121
+ "id": "object-stash",
122
+ "text": "cache identity keys, ttl, and load deduplication import { cache } from '@vielzeug/arsenal/cache'\n\nlet time = 0\nconst users = cache({ now: () => time, ttlms: 5000 })\n\nusers.set('greeting', 'hello')\nconsole.log('get:', users.get('greeting'))\n\nconst loaduser = (id) => new promise(resolve => settimeout(() => resolve({ id, name: 'alice' }), 50))\nconst [first, second] = await promise.all([\n users.getorload('user:1', () => loaduser(1)),\n users.getorload('user:1', () => loaduser(1)),\n])\nconsole.log('same value:', first === second)\n\ntime = 5000\nconsole.log('expired:', users.get('greeting'))"
123
+ },
124
+ {
125
+ "id": "string-camelcase",
126
+ "text": "camelcase convert to camelcase import { camelcase, pascalcase, kebabcase, snakecase } from '@vielzeug/arsenal/string'\n\nconst input = 'hello world example'\n\nconsole.log('camelcase:', camelcase(input))\nconsole.log('pascalcase:', pascalcase(input))\nconsole.log('kebab case:', kebabcase(input))\nconsole.log('snake_case:', snakecase(input))\n\n// different input formats\nconst formats = [\n 'hello world',\n 'hello_world',\n 'helloworld',\n 'helloworld'\n]\n\nformats.foreach(str => {\n console.log(`\"${str}\" → camelcase: ${camelcase(str)}`)\n})"
127
+ },
128
+ {
129
+ "id": "typed-is",
130
+ "text": "guards and platform type checks import { isdefined, isempty, isnil, isnumber, isplainobject } from '@vielzeug/arsenal/guards'\n\nconst values = ['hello', 42, true, [1, 2, 3], {}, null, undefined]\n\nvalues.foreach(value => {\n console.log({\n array: array.isarray(value),\n defined: isdefined(value),\n empty: isempty(value),\n nil: isnil(value),\n number: isnumber(value),\n plainobject: isplainobject(value),\n string: typeof value === 'string',\n })\n})"
131
+ }
132
+ ],
133
+ "exports": "chunk groupby retry debounce clamp isequal taskpool cache fuzzyfilter tryparsejson",
134
+ "keywords": "utility array string object math async debounce throttle cache",
135
+ "name": "@vielzeug/arsenal",
136
+ "related": "tempo sourcerer spell coins",
137
+ "slug": "arsenal",
138
+ "source": "export * from './array/chunk';\nexport * from './array/filtermap';\nexport * from './array/groupby';\nexport * from './array/indexby';\nexport * from './array/partition';\nexport * from './array/sort';\nexport * from './array/uniq';\nexport * from './async/attempt';\nexport * from './async/parallel';\nexport * from './async/retry';\nexport * from './async/sleep';\nexport * from './errors';\nexport * from './function/debounce';\nexport * from './function/once';\nexport * from './function/pipe';\nexport * from './function/throttle';\nexport * from './guards/combinators';\nexport * from './guards/isdefined';\nexport * from './guards/isequal';\nexport * from './guards/isnil';\nexport * from './guards/isplainobject';\nexport * from './math/clamp';\nexport * from './math/range';\nexport * from './object/getpath';\nexport * from './object/hash';\nexport * from './object/omit';\nexport * from './object/pick';\nexport * from './random/uuid';\nexport * from './string/camelcase';\n"
139
+ },
140
+ {
141
+ "category": "testing",
142
+ "description": "scoped dom queries, exact event dispatch, and cancellable async waiting for browser tests.",
143
+ "docs": {
144
+ "index": " \ntitle: assay — framework agnostic dom testing primitives\ndescription: scoped dom queries, exact event dispatch, and cancellable async waiting for browser tests.\npackage: assay\ncategory: testing\nkeywords: [testing, dom, events, queries, custom elements]\nrelated: [ore, refine]\nexports:\n [\n within,\n queryinshadow,\n queryallinshadow,\n querypart,\n getslotted,\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n waituntil,\n retry,\n waitforevent,\n delay,\n nexttick,\n assayerror,\n assayqueryerror,\n assaytimeouterror,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"assay\" />\n\n## why assay?\n\nassay provides focused dom test primitives that work with vanilla elements, custom elements, and framework rendered output. it scopes queries, dispatches exact browser event classes, and waits on explicit conditions without imposing a renderer or browser automation stack.\n\n```ts\n// before\nbutton.dispatchevent(new mouseevent('click', { bubbles: true }));\nawait new promise((resolve) => settimeout(resolve, 100));\n\n// after\nfireclick(view.get('button.submit'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n| feature | assay | testing library dom | browser automation |\n| | | | |\n| bundle size | <packageinfo package=\"assay\" type=\"size\" /> | larger query layer | browser runtime required |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| scoped dom queries | `within()` and shadow helpers | renderer oriented queries | manual selectors |\n| deterministic waits | `waituntil()` and `waitforevent()` | framework dependent | full browser timing |\n\n<div class=\"decision callout\">\n\n**use assay when** a dom unit test needs readable queries, dispatched events, or a bounded async wait without adopting a rendering framework.\n\n**consider browser integration tests when** correctness depends on browser default actions, focus behavior, pointer capture, layout, or accessibility tree behavior.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add d @vielzeug/assay\n```\n\n```sh [npm]\nnpm install d @vielzeug/assay\n```\n\n```sh [yarn]\nyarn add d @vielzeug/assay\n```\n\n:::\n\n## quick start\n\nscope a test fixture, dispatch an event, and wait for resulting dom state.\n\n```ts\nimport { fireclick, waituntil, within } from '@vielzeug/assay';\n\nconst panel = document.createelement('section');\npanel.innerhtml = '<button>save</button><output></output>';\npanel.queryselector('button')!.addeventlistener('click', () => {\n panel.queryselector('output')!.textcontent = 'saved';\n});\n\nconst view = within(panel);\nfireclick(view.get('button'));\nawait waituntil(() => view.querybytext('saved') !== null);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `within(root)` scopes nullable and required dom queries.\n `queryinshadow`, `querypart`, and `getslotted` cross custom element boundaries explicitly.\n `fireclick`, `fireinput`, `firekeydown`, and peers dispatch exact synchronous events.\n `waituntil`, `retry`, and `waitforevent` provide bounded, abortable async waiting.\n `delay` and `nexttick` model explicit timer and microtask scheduling.\n `assayerror`, `assayqueryerror`, and `assaytimeouterror` provide typed failures.\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 [ore](/ore/) — component authoring and test fixtures that pair with assay dom helpers.\n [refine](/refine/) — accessible components with component specific test assertions.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
145
+ "api": " \ntitle: assay — api reference\ndescription: api reference for @vielzeug/assay queries, event dispatch, and async waiting.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `within` | creates scoped query api | sync | required `get*` methods throw `assayqueryerror` |\n| `queryinshadow` / `querypart` / `getslotted` | crosses custom element boundaries | sync | open shadow roots are required |\n| `fire*` / `dispatch` | dispatches platform event instances | sync | does not reproduce browser default behavior |\n| `waituntil` / `retry` / `waitforevent` | waits for conditions, assertions, or events | async | use a signal or timeout for bounded waits |\n| `delay` / `nexttick` | schedules timers or microtasks | async | prefer `nexttick()` for microtask scheduled work |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/assay` | dom queries, events, wait helpers, errors, and types |\n\n## queries\n\n### `within(root)`\n\ncreates a `queryscope` for an `element`, `shadowroot`, `document`, or `documentfragment`.\n\n| method | returns | use |\n| | | |\n| `get(selector)` | `element` | required css match; throws `assayqueryerror` |\n| `query(selector)` | `element \\| null` | optional css match |\n| `queryall(selector)` | `element[]` | all css matches |\n| `getbytext(text, selector?)` | `element` | required exact trimmed text match |\n| `querybytext(text, selector?)` | `element \\| null` | optional exact trimmed text match |\n| `queryallbytext(text, selector?)` | `element[]` | all exact trimmed text matches |\n| `getbytestid(id)` | `element` | required `data testid` match |\n| `querybytestid(id)` | `element \\| null` | optional `data testid` match |\n| `queryallbytestid(id)` | `element[]` | all `data testid` matches |\n\ntext selectors default to `'*'`. required query failures include the lookup and a bounded view of the scoped dom.\n\n### shadow and slot helpers\n\n| function | returns | description |\n| | | |\n| `queryinshadow(host, selector)` | `element \\| null` | first match in an open shadow root |\n| `queryallinshadow(host, selector)` | `element[]` | all matches in an open shadow root |\n| `querypart(host, part)` | `element \\| null` | first shadow element whose `part` token matches |\n| `getslotted(host, slotname?)` | `element[]` | direct light dom children in a named or default slot |\n\nthese helpers return `null` or `[]` when there is no shadow root. dynamic test ids, parts, and slot names are matched\nas attribute values rather than interpolated into css selectors.\n\n## event dispatch\n\nall event helpers synchronously return `dispatchevent()`'s boolean result.\n\n```ts\nimport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from '@vielzeug/assay';\n\nfireclick(button, { clientx: 20 });\nfireinput(input);\nfirekeydown(input, { key: 'enter' });\nfirecustom(element, 'item added', { detail: { id: '42' } });\ndispatch(element, new event('ready'));\n```\n\n| function | event class | defaults |\n| | | |\n| `fireblur` / `firefocus` | `focusevent` | platform defaults (`bubbles: false`) |\n| `firechange` | `event` | `bubbles: true` |\n| `fireinput` | `inputevent` | `bubbles: true` |\n| `fireclick` | `mouseevent` | `bubbles: true`, `cancelable: true` |\n| `firekeydown` / `firekeyup` | `keyboardevent` | `bubbles: true`, `cancelable: true` |\n| `firesubmit` | `submitevent` | `bubbles: true`, `cancelable: true` |\n| `firecustom` | `customevent` | `bubbles: true`, `cancelable: true`, `composed: false` |\n\n`firecustom(target, type, init?)` dispatches a `customevent` with the given type. assay intentionally does not\nprovide browser default or fallback pointer/touch simulation.\n\n## async waiting\n\n```ts\nawait waituntil(() => ready, { interval: 20, signal, timeout: 1000 });\nawait retry(() => expect(spy).tohavebeencalled(), { signal, timeout: 1000 });\nawait waitforevent(target, 'ready', { signal, timeout: 1000 });\nawait delay(100, { signal });\nawait nexttick();\n```\n\n| function | success condition | options |\n| | | |\n| `waituntil(predicate, options?)` | predicate returns `true` | `timeout`, `interval`, `signal` |\n| `retry(assertion, options?)` | assertion stops throwing | `timeout`, `interval`, `signal`, `message` |\n| `waitforevent(target, type, options?)` | target emits `type` | `timeout`, `signal` |\n| `delay(ms?, options?)` | timer elapses | `signal` |\n| `nexttick()` | next microtask | none |\n\n`waituntil`, `retry`, and `waitforevent` reject with `assaytimeouterror` when their timeout expires. a supplied abort\nsignal rejects with its reason and removes timers and event listeners.\n\n## types\n\n```ts\nexport interface queryscope {\n get<e extends element = element>(selector: string): e;\n getbytestid<e extends element = element>(testid: string): e;\n getbytext<e extends element = element>(text: string, selector?: string): e;\n query<e extends element = element>(selector: string): e | null;\n queryall<e extends element = element>(selector: string): e[];\n queryallbytestid<e extends element = element>(testid: string): e[];\n queryallbytext<e extends element = element>(text: string, selector?: string): e[];\n querybytestid<e extends element = element>(testid: string): e | null;\n querybytext<e extends element = element>(text: string, selector?: string): e | null;\n}\n```\n\nscoped query helpers returned by `within(root)`.\n\n```ts\nexport interface waitoptions {\n /** polling interval in ms (default: 50). */\n interval?: number;\n /** cancel the pending wait. */\n signal?: abortsignal;\n /** maximum wait time in ms (default: 1000). */\n timeout?: number;\n}\n\nexport interface retryoptions extends waitoptions {\n /** context included in the timeout error. */\n message?: string;\n}\n\nexport interface delayoptions {\n /** cancel the pending delay. */\n signal?: abortsignal;\n}\n```\n\n`waitoptions` configures `waituntil`. `retryoptions` extends it for `retry`. `delayoptions` configures `delay`.\n\n## errors\n\n| error | meaning |\n| | |\n| `assayerror` | base class for assay originated errors |\n| `assayqueryerror` | a required `get*` query had no match |\n| `assaytimeouterror` | a wait operation reached its timeout |\n\nuse `instanceof assayerror` to narrow any value to the assay error hierarchy.\n",
146
+ "usage": " \ntitle: assay — usage guide\ndescription: scoped dom queries, exact event dispatch, and cancellable waiting with @vielzeug/assay.\n \n\n[[toc]]\n\n## basic usage\n\n`within(root)` is assay's query api. it accepts an element, document fragment, or shadow root and keeps every lookup\nin that scope. use `get*` when a match is required and nullable `query*` methods when absence is part of the assertion.\n\n```ts\nimport { within } from '@vielzeug/assay';\n\nconst view = within(panel.shadowroot!);\n\nconst save = view.get<htmlbuttonelement>('button.save');\nconst status = view.querybytext('saved');\n\nexpect(view.query('.error')).tobenull();\nexpect(view.getbytestid('summary').textcontent).tocontain('complete');\n```\n\n`get()`, `getbytext()`, and `getbytestid()` throw `assayqueryerror` with the lookup and a bounded rendering of the\nscoped dom. this keeps a failed required lookup diagnosable without non null assertions.\n\nuse the cross boundary helpers for custom elements:\n\n```ts\nimport { getslotted, queryallinshadow, querypart } from '@vielzeug/assay';\n\nconst trigger = querypart(menu, 'trigger');\nconst options = queryallinshadow(menu, '[role=\"option\"]');\nconst footeractions = getslotted(dialog, 'footer');\n```\n\n## exact event dispatch\n\nassay dispatches the platform event class named by each helper. it does not model browser activation, focus\nmanagement, or form defaults. `firefocus()` and `fireblur()` use their non bubbling platform defaults; use\n`focusin`/`focusout` events when testing delegated focus listeners. use `element.click()` when native activation is\nthe behavior under test; use assay when testing an event listener or a controlled state transition.\n\n```ts\nimport { fireclick, firecustom, fireinput, firekeydown } from '@vielzeug/assay';\n\ninput.value = 'ada';\nfireinput(input);\n\nfirekeydown(input, { key: 'enter' });\nfireclick(savebutton);\nfirecustom(panel, 'value change', { detail: { value: 42 } });\n```\n\nevery helper returns `dispatchevent()`'s boolean result. `dispatch(target, event)` is available when an existing\nevent instance is the clearest expression of the test.\n\n## waiting\n\nchoose the waiting primitive by the test's assertion shape:\n\n```ts\nimport { delay, retry, waitforevent, waituntil } from '@vielzeug/assay';\n\nawait waituntil(() => panel.queryselector('.status')?.textcontent === 'ready');\n\nawait retry(() => {\n expect(onsave).tohavebeencalledonce();\n});\n\nconst completed = waitforevent<customevent<{ id: string }>>(panel, 'save complete', {\n signal: abortsignal.timeout(1000),\n});\nfireclick(savebutton);\nexpect((await completed).detail.id).tobedefined();\n\nawait delay(100); // real timer dependency, such as a debounce\n```\n\n`waituntil()` retries only a boolean predicate. `retry()` retries only a callback that throws until it succeeds.\nboth, and `waitforevent()`, accept `timeout` and `signal`; `waituntil()` and `retry()` also accept `interval`.\ntimeouts reject with `assaytimeouterror`; aborts reject with the signal's reason.\n\n`nexttick()` resolves after one microtask. prefer it for microtask scheduled reactive work over a timer delay.\n\n## testing custom elements\n\nuse ore for component mounting and assay for generic dom concerns:\n\n```ts\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { mount } from '@vielzeug/ore/testing';\n\nconst fixture = await mount(\n () => html`\n <button @click=${onsave}>save</button>\n `,\n);\n\nfireclick(fixture.get('button'));\nawait fixture.flush();\n\nexpect(onsave).tohavebeencalledonce();\n```\n\nrefine's testing entry point contains only refine specific assertions and typed mount wrappers. import assay helpers\ndirectly instead of routing generic dom operations through another package.\n\n## best practices\n\n scope multiple assertions with `within()` rather than repeatedly querying the document.\n prefer `get*` for required controls and `query*` for intentional absence checks.\n make form state and event boundaries explicit: assign `.value`, then call `fireinput()` or `firechange()`.\n use browser integration tests for focus, disabled activation, pointer capture, and other browser default behavior.\n use `waitforevent()` for an emitted event, `waituntil()` for a condition, and `retry()` for assertions.\n",
147
+ "examples": " \ntitle: assay — examples\ndescription: practical examples and recipes for assay.\n \n\n## examples\n\n [custom element interaction](./examples/custom element interaction.md)\n [waiting for async updates](./examples/waiting for async updates.md)\n"
148
+ },
149
+ "examples": [],
150
+ "exports": "within queryinshadow queryallinshadow querypart getslotted dispatch fireblur firechange fireclick firecustom firefocus fireinput firekeydown firekeyup firesubmit waituntil retry waitforevent delay nexttick assayerror assayqueryerror assaytimeouterror",
151
+ "keywords": "testing dom events queries custom elements",
152
+ "name": "@vielzeug/assay",
153
+ "related": "ore refine",
154
+ "slug": "assay",
155
+ "source": "export { assayerror, assayqueryerror, assaytimeouterror } from './errors';\n\nexport {\n dispatch,\n fireblur,\n firechange,\n fireclick,\n firecustom,\n firefocus,\n fireinput,\n firekeydown,\n firekeyup,\n firesubmit,\n} from './events';\nexport { getslotted, type queryscope, queryallinshadow, queryinshadow, querypart, within } from './query';\nexport {\n type delayoptions,\n delay,\n nexttick,\n type retryoptions,\n retry,\n type waitoptions,\n waitforevent,\n waituntil,\n} from './wait';\n"
156
+ },
157
+ {
158
+ "category": "state",
159
+ "description": "framework neutral typed state machines with pure transitions, actor owned runtime work, timers, invokes, and explicit effects.",
160
+ "docs": {
161
+ "index": " \ntitle: clockwork — typed finite state machines for typescript\ndescription: framework neutral typed state machines with pure transitions, actor owned runtime work, timers, invokes, and explicit effects.\npackage: clockwork\ncategory: state\nkeywords: [state machine, finite state, typed, actor, async tasks]\nrelated: [herald, ripple, ward]\nexports: [definemachine, clockworkerror, machine, actor, machineconfig, machinesnapshot, transitionresult]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"clockwork\" />\n\n## why clockwork?\n\napplication workflows often mix state changes with timers, requests, rendering, and cleanup. clockwork keeps transition logic pure while each disposable actor owns runtime work. you can test state decisions without starting effects or invokes.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\n// before\nif (status === 'idle') status = 'loading';\nfetchitems().then((items) => {\n status = 'ready';\n data = items;\n});\n\n// after\ntype event = { type: 'fetch' } | { items: string[]; type: 'done' };\nconst machine = definemachine<{ items: string[] }, event>()({\n context: { items: [] },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: ({ signal }) => fetch('/api/items', { signal }).then((response) => response.json() as promise<string[]>),\n ondone: ({ result }) => ({ items: result, type: 'done' }),\n }],\n on: { done: { reduce: ({ event }) => ({ items: event.items }), target: 'ready' } },\n },\n ready: {},\n },\n});\n```\n\n| feature | clockwork | xstate | zustand |\n| | | | |\n| bundle size | <packageinfo package=\"clockwork\" type=\"size\" /> | larger actor/statechart runtime | smaller store runtime |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| pure transition api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> statechart focused | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| owned cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> actor disposal | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| framework coupling | <ore icon name=\"check\" size=\"16\"></ore icon> none | <ore icon name=\"check\" size=\"16\"></ore icon> none | <ore icon name=\"check\" size=\"16\"></ore icon> none |\n\n<div class=\"decision callout\">\n\n**use clockwork when** your feature has explicit workflow states, cancellable work, or effects that must run after a state commit.\n\n**consider xstate when** you need statecharts, visual tooling, or its broader actor ecosystem.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/clockwork\n```\n\n```sh [npm]\nnpm install @vielzeug/clockwork\n```\n\n```sh [yarn]\nyarn add @vielzeug/clockwork\n```\n\n:::\n\n## quick start\n\ndefine the context and event union, create an actor, observe its snapshot, then dispose it when its owner ends.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'dec' } | { type: 'inc' };\n\nconst counter = definemachine<{ count: number }, event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n dec: { reduce: ({ context }) => ({ count: context.count 1 }), target: 'idle' },\n inc: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n },\n },\n },\n});\n\nusing actor = counter.createactor();\nactor.subscribe((snapshot) => console.log(snapshot));\nactor.send({ type: 'inc' });\n// { context: { count: 1 }, state: 'idle' }\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`definemachine()`** — validates and compiles one flat machine definition.\n **`machine.transition()`** — evaluates a transition without actor runtime work.\n **`machine.createactor()`** — creates isolated, disposable runtime ownership.\n **`reduce`** — returns a replacement context from a transition.\n **`effects`** — run only after the actor commits and notifies subscribers.\n **`invoke`** — runs cancellable asynchronous work on state entry.\n **`after`** — schedules cancellable delayed transitions.\n **`actor.snapshot`** — exposes the current readonly state/context value.\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 [herald](/herald/) — publish events between independent actors without coupling machine definitions.\n [ripple](/ripple/) — bridge actor snapshots into a reactive graph when you need fine grained rendering.\n [ward](/ward/) — call authorization predicates from transition guards.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
162
+ "api": " \ntitle: clockwork — api reference\ndescription: reference for clockwork machine definitions, actors, devtools, and types.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `definemachine()` | compile a typed flat machine definition | sync | call the generic factory before supplying the definition |\n| `machine.transition()` | resolve a pure next snapshot | sync | does not run effects, invokes, or timers |\n| `machine.createactor()` | create a runtime owner | sync | fresh and restored actors have different entry behavior |\n| `actor.send()` | dispatch an event | sync | returns `void`; re entrant events queue internally |\n| `debugactor()` | observe committed snapshots | sync | observes only; it does not trace sends or errors |\n| `clockworkerror` | report definition and snapshot validation failures | sync | use `code`, not message text |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/clockwork` | machine compiler, actor runtime, errors, and types |\n| `@vielzeug/clockwork/devtools` | opt in snapshot observation through `debugactor()` |\n\n## core functions\n\n### `definemachine()`\n\n```ts\nfunction definemachine<\n context extends record<string, unknown> = record<string, never>,\n event extends machineevent = machineevent,\n>(): <state extends string>(definition: machineconfig<state, context, event>) => machine<state, context, event>;\n```\n\nreturns a factory that validates and compiles a typed flat machine definition. context must be a non array record. omit `context` only when the context type has no keys.\n\n**returns:** a definition function that returns `machine`.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'start' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'idle',\n states: { idle: { on: { start: { target: 'running' } } }, running: {} },\n});\n```\n\nthrows `clockworkerror` when a definition has an invalid context, initial state, target, transition, effect, invoke, or timer delay.\n\n \n\n### `debugactor()`\n\n```ts\nfunction debugactor<state extends string, context extends record<string, unknown>, event extends machineevent>(\n actor: actor<state, context, event>,\n options?: debugactoroptions<state, context>,\n): () => void;\n```\n\nsubscribes to committed actor snapshots and logs each one with `console.debug` by default. it does not modify actor behavior and does not observe dispatched events or runtime errors.\n\n**returns:** an unsubscribe cleanup function.\n\n**example:**\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\nimport { debugactor } from '@vielzeug/clockwork/devtools';\n\nconst machine = definemachine<record<string, never>, { type: 'next' }>()({\n initial: 'idle',\n states: { idle: { on: { next: { target: 'idle' } } } },\n});\n\nconst actor = machine.createactor();\nconst stopdebugging = debugactor(actor);\nactor.send({ type: 'next' });\nstopdebugging();\nactor.dispose();\n```\n\n## machine methods\n\n### `machine.transition()`\n\n```ts\ntransition(\n snapshot: machinesnapshot<state, context>,\n event: event,\n): transitionresult<state, context>;\n```\n\nresolves a snapshot for one user event without actor runtime work.\n\n| parameter | type | description |\n| | | |\n| `snapshot` | `machinesnapshot<state, context>` | input state and context |\n| `event` | `event` | user event to evaluate |\n\n**returns:** a `transitionresult` with `transition` or `ignored` type.\n\n**example:**\n\n```ts\nconst result = machine.transition(machine.initialsnapshot, { type: 'start' });\n```\n\n \n\n### `machine.can()`\n\n```ts\ncan(snapshot: machinesnapshot<state, context>, event: event): boolean;\n```\n\nreturns whether a transition exists and its guard passes.\n\n**returns:** `true` when the supplied snapshot accepts the event.\n\n \n\n### `machine.createactor()`\n\n```ts\ncreateactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n```\n\ncreates an independent actor for event dispatch, timers, invokes, effects, subscriptions, and disposal. a fresh actor starts the initial state's entry effects and resources. an actor restored with `options.snapshot` starts only the restored state's resources: invokes and timers, not entry effects.\n\n| parameter | type | description |\n| | | |\n| `options.snapshot` | `machinesnapshot<state, context>` | optional restored actor snapshot |\n| `options.maxtransitions` | `number` | positive queued transition limit for one synchronous flush |\n| `options.onerror` | `(error, context) => 'continue' \\| 'dispose'` | explicit disposition for runtime failures |\n\n**returns:** disposable `actor`.\n\n**example:**\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { phase, state }) {\n console.error(phase, state, error);\n return 'continue';\n },\n snapshot: { context: {}, state: 'idle' },\n});\n```\n\n## actor methods\n\n### `actor.send()`\n\n```ts\nsend(event: event): void;\n```\n\ndispatches a user event to the current actor state. events sent while the actor is processing queue and flush synchronously; sends to a disposed actor are ignored. for active actors, malformed events without a string `type` log a development warning and are ignored. valid but unhandled event types are ignored without a warning. use `actor.snapshot` after sending to read the current snapshot.\n\n**returns:** nothing.\n\n \n\n### `actor.can()`\n\n```ts\ncan(event: event): boolean;\n```\n\nreturns whether the current actor snapshot accepts an event. returns `false` after disposal.\n\n**returns:** boolean transition availability.\n\n \n\n### `actor.subscribe()`\n\n```ts\nsubscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n```\n\nregisters a listener for committed snapshots. the listener does not run immediately.\n\n**returns:** an unsubscribe function.\n\n \n\n### `actor.dispose()`\n\n```ts\ndispose(): void;\n[symbol.dispose](): void;\n```\n\ncancels timers and invokes, clears queued events and listeners, and aborts `disposalsignal`.\n\n**returns:** nothing. idempotent.\n\n## types\n\n### `machineevent`\n\n```ts\ntype machineevent = { readonly type: string };\n```\n\nbase constraint for event unions.\n\n### `eventtype<event>` and `eventbytype<event, type>`\n\n```ts\ntype eventtype<event extends machineevent> = event['type'] & string;\n\ntype eventbytype<event extends machineevent, type extends eventtype<event>> =\n extract<event, { type: type }>;\n```\n\nextract event type names and a matching event from an event union.\n\n### `machinesnapshot<state, context>`\n\n```ts\ntype machinesnapshot<state extends string, context extends record<string, unknown>> = {\n readonly context: readonly<context>;\n readonly state: state;\n};\n```\n\nthe plain readonly snapshot value used by machines and actors. readonly is a typescript contract; clockwork does not copy or freeze snapshots at runtime.\n\n### `guard<context, event>` and `reducer<context, event>`\n\n```ts\ntype guard<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => boolean;\n\ntype reducer<context extends record<string, unknown>, event> = (args: {\n readonly context: readonly<context>;\n readonly event: event;\n}) => context;\n```\n\na guard selects a transition. a reducer returns replacement context, which must be a non array record.\n\n### `effectargs<context, event>` and `effect<context, event>`\n\n```ts\ntype effectargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly send: (event: event) => void;\n readonly signal: abortsignal;\n};\n\ntype effect<context extends record<string, unknown>, event extends machineevent> =\n (args: effectargs<context, event>) => void;\n```\n\npost commit effects receive `undefined` for initial entry and actor timer transitions. they cannot update machine context directly.\n\n### `transition<state, context, event, type>` and `transitioninput`\n\n```ts\ntype transition<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = {\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, eventbytype<event, type>>;\n readonly reduce?: reducer<context, eventbytype<event, type>>;\n readonly target: state;\n};\n\ntype transitioninput<\n state extends string,\n context extends record<string, unknown>,\n event extends machineevent,\n type extends eventtype<event> = eventtype<event>,\n> = transition<state, context, event, type> | readonly transition<state, context, event, type>[];\n```\n\nan ordered transition array selects the first guard that passes.\n\n### `after<state, context, event>`\n\n```ts\ntype after<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly delay: number;\n readonly effects?: readonly effect<context, event>[];\n readonly guard?: guard<context, event | undefined>;\n readonly reduce?: reducer<context, event | undefined>;\n readonly target: state;\n};\n```\n\na delayed state transition. its guard and reducer receive `event: undefined`.\n\n### `invokeargs<context, event>` and `invoke<context, event, result>`\n\n```ts\ntype invokeargs<context extends record<string, unknown>, event extends machineevent> = {\n readonly context: readonly<context>;\n readonly event: event | undefined;\n readonly signal: abortsignal;\n};\n\ntype invoke<context extends record<string, unknown>, event extends machineevent, result = unknown> = {\n readonly ondone?: (args: { readonly context: readonly<context>; readonly result: result }) => event;\n readonly onerror?: (args: { readonly context: readonly<context>; readonly error: unknown }) => event;\n readonly src: (args: invokeargs<context, event>) => promise<result> | result;\n};\n```\n\nan actor owned task started on state entry. `event` is the triggering event or `undefined` for initial or restored resources.\n\n### `statenode<state, context, event>` and `machineconfig<state, context, event>`\n\n```ts\ntype statenode<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly after?: readonly after<state, context, event>[];\n readonly entry?: readonly effect<context, event>[];\n readonly exit?: readonly effect<context, event>[];\n readonly invoke?: readonly invoke<context, event>[];\n readonly on?: partial<{ [type in eventtype<event>]: transitioninput<state, context, event, type> }>;\n};\n\ntype machineconfig<state extends string, context extends record<string, unknown>, event extends machineevent> =\n (keyof context extends never ? { readonly context?: context } : { readonly context: context }) & {\n readonly initial: state;\n readonly states: record<state, statenode<state, context, event>>;\n };\n```\n\na flat machine definition. state nodes cannot contain child states.\n\n### `transitionresult<state, context>`\n\n```ts\ntype transitionresult<state extends string, context extends record<string, unknown>> = {\n readonly snapshot: machinesnapshot<state, context>;\n readonly type: 'ignored' | 'transition';\n};\n```\n\nresult of a pure user event transition. it contains no effect plan.\n\n### `actorerrorcontext<state, event>`, `actorerrordisposition`, and `actoroptions<state, context, event>`\n\n```ts\ntype actorerrorcontext<state extends string, event extends machineevent> = {\n readonly event?: event;\n readonly phase: 'effect' | 'invoke' | 'subscriber' | 'transition';\n readonly state: state;\n};\n\ntype actorerrordisposition = 'continue' | 'dispose';\n\ntype actoroptions<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n readonly maxtransitions?: number;\n readonly onerror?: (error: unknown, context: actorerrorcontext<state, event>) => actorerrordisposition;\n readonly snapshot?: machinesnapshot<state, context>;\n};\n```\n\n`onerror` must explicitly return `'continue'` to keep the actor alive or `'dispose'` to end it. without an error handler, clockwork disposes the actor silently.\n\n### `actor<state, context, event>`\n\n```ts\ntype actor<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n [symbol.dispose](): void;\n can(event: event): boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n send(event: event): void;\n readonly snapshot: machinesnapshot<state, context>;\n subscribe(listener: (snapshot: machinesnapshot<state, context>) => void): () => void;\n};\n```\n\nan actor's `snapshot` is the current plain readonly snapshot.\n\n### `machine<state, context, event>`\n\n```ts\ntype machine<state extends string, context extends record<string, unknown>, event extends machineevent> = {\n can(snapshot: machinesnapshot<state, context>, event: event): boolean;\n createactor(options?: actoroptions<state, context, event>): actor<state, context, event>;\n readonly initialsnapshot: machinesnapshot<state, context>;\n transition(snapshot: machinesnapshot<state, context>, event: event): transitionresult<state, context>;\n};\n```\n\na compiled, reusable machine. its transition lookup is map based, so unknown or poison event names such as `__proto__` are safely ignored when no transition exists.\n\n### `debugactoroptions<state, context>`\n\n```ts\ntype debugactoroptions<state extends string, context extends record<string, unknown>> = {\n readonly logger?: (snapshot: machinesnapshot<state, context>) => void;\n};\n```\n\noptional logger for `debugactor()`. logger failures are ignored so observation cannot affect the actor's error policy.\n\n## errors\n\n### `clockworkerrorcode`\n\n```ts\ntype clockworkerrorcode =\n | 'invalid_after_delay'\n | 'invalid_context'\n | 'invalid_definition'\n | 'invalid_effect'\n | 'invalid_initial_state'\n | 'invalid_invoke'\n | 'invalid_max_transitions'\n | 'invalid_snapshot_state'\n | 'invalid_transition'\n | 'invalid_transition_limit'\n | 'unknown_target';\n```\n\nstable machine readable code identifying a clockwork failure category.\n\n### `clockworkerror`\n\n`clockworkerror` reports invalid definitions, contexts, snapshots, and actor transition limits. it has `code`, `details`, and standard `error` fields. use `instanceof clockworkerror` to narrow an unknown error.\n\n```ts\nif (error instanceof clockworkerror) {\n console.error(error.code, error.details);\n}\n```\n",
163
+ "usage": " \ntitle: clockwork — usage guide\ndescription: build deterministic state machines with pure transitions and actor owned runtime work.\n \n\n[[toc]]\n\n## basic usage\n\ncall `definemachine<context, event>()` first to bind context and event types; the returned definition function infers state labels from `states`. context is optional only when its type has no keys. create one actor for each independently owned workflow.\n\n```ts\nimport { definemachine } from '@vielzeug/clockwork';\n\ntype event = { type: 'toggle' };\n\nconst machine = definemachine<record<string, never>, event>()({\n initial: 'on',\n states: {\n off: { on: { toggle: { target: 'on' } } },\n on: { on: { toggle: { target: 'off' } } },\n },\n});\n\nconst actor = machine.createactor();\nactor.send({ type: 'toggle' });\nconsole.log(actor.snapshot.state); // 'off'\nactor.dispose();\n```\n\ndispose actors when a feature, request, or test ends. you can use `using` when the surrounding runtime supports `symbol.dispose`.\n\n```ts\nusing actor = machine.createactor();\nactor.send({ type: 'toggle' });\n```\n\n## context reducers\n\na reducer receives readonly context and returns the next context. clockwork does not copy or freeze context at runtime, so do not mutate data that other code may retain.\n\n```ts\ntype event = { type: 'dec' } | { type: 'inc' } | { type: 'reset' };\n\nconst counter = definemachine<{ count: number }, event>()({\n context: { count: 0 },\n initial: 'idle',\n states: {\n idle: {\n on: {\n dec: { reduce: ({ context }) => ({ count: context.count 1 }), target: 'idle' },\n inc: { reduce: ({ context }) => ({ count: context.count + 1 }), target: 'idle' },\n reset: { reduce: () => ({ count: 0 }), target: 'idle' },\n },\n },\n },\n});\n```\n\nkeep reducers pure. make nested copies yourself when nested data changes.\n\n```ts\nsave: {\n reduce: ({ context, event }) => ({\n ...context,\n profile: { ...context.profile, name: event.name },\n }),\n target: 'editing',\n}\n```\n\n## guards\n\nguards decide whether a transition can run. they receive readonly context and the matching event. for several choices, use an ordered array; the first passing guard wins.\n\n```ts\npay: [\n {\n guard: ({ context }) => context.balance >= context.total,\n reduce: ({ context }) => ({ ...context, balance: context.balance context.total }),\n target: 'success',\n },\n { target: 'insufficientfunds' },\n]\n```\n\ncall `actor.can(event)` for the current actor snapshot or `machine.can(snapshot, event)` for an arbitrary snapshot.\n\n## pure transitions\n\n`machine.transition()` enables isolated unit tests and decision uis. it returns the unchanged snapshot with `type: 'ignored'` when no transition matches; it does not expose or run effects.\n\n```ts\nconst result = counter.transition(\n { context: { count: 3 }, state: 'idle' },\n { type: 'inc' },\n);\n\nif (result.type === 'transition') {\n console.log(result.snapshot.context.count); // 4\n}\n```\n\n## effects\n\nentry, exit, and transition effects run only through an actor. the actor commits, establishes the new state's timers and invokes, notifies subscribers, then runs exit, transition, and entry effects. effects cannot change context; send a regular event for another state change.\n\n```ts\ntype workflowevent = { type: 'submit' };\nconst workflow = definemachine<{ orderid: string }, workflowevent>()({\n context: { orderid: '' },\n initial: 'draft',\n states: {\n draft: {\n on: {\n submit: {\n effects: [({ context }) => console.debug('submitted', context)],\n target: 'submitted',\n },\n },\n },\n submitted: { entry: [({ context }) => console.log(`submitted ${context.orderid}`)] },\n },\n});\n```\n\neffects receive `context`, the triggering `event` (or `undefined` for initial entry), actor `send`, and the actor lifetime `signal`.\n\n## async invokes\n\ninvokes start on state entry. `src` gets readonly entry context, the triggering event or `undefined`, and an `abortsignal`. `ondone` or `onerror` map settlement to ordinary events. all invokes are cancelled when the actor exits the state or disposes.\n\n```ts\ntype loadevent =\n | { type: 'fetch' }\n | { items: string[]; type: 'success' }\n | { message: string; type: 'failure' }\n | { type: 'retry' };\n\nconst loader = definemachine<{ error: string; items: string[] }, loadevent>()({\n context: { error: '', items: [] },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n const response = await fetch('/api/items', { signal });\n if (!response.ok) throw new error(`http ${response.status}`);\n return response.json() as promise<string[]>;\n },\n ondone: ({ result }) => ({ items: result, type: 'success' }),\n onerror: ({ error }) => ({ message: string(error), type: 'failure' }),\n }],\n on: {\n failure: { reduce: ({ event }) => ({ error: event.message, items: [] }), target: 'error' },\n success: { reduce: ({ event }) => ({ error: '', items: event.items }), target: 'ready' },\n },\n },\n ready: {},\n error: { on: { retry: { target: 'loading' } } },\n },\n});\n```\n\n## delayed transitions\n\n`after` starts timers on state entry and cancels them on exit or disposal. its guard and reducer receive `event: undefined`; a user event with `type: '$after'` remains a normal user event.\n\n```ts\ntype notificationevent = { type: 'dismiss' } | { message: string; type: 'show' };\nconst notification = definemachine<{ message: string }, notificationevent>()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: { on: { show: { reduce: ({ event }) => ({ message: event.message }), target: 'visible' } } },\n visible: {\n after: [{ delay: 5_000, target: 'hidden' }],\n on: { dismiss: { target: 'hidden' } },\n },\n },\n});\n```\n\n## snapshot observation and persistence\n\n`actor.snapshot` is the current plain readonly snapshot; read it directly rather than calling a snapshot method. use `subscribe()` to integrate a state library or persist future committed snapshots. fresh actors run their initial entry effects and resources; restored actors start only the restored state's invokes and timers, not its entry effects.\n\n```ts\nconst stored = sessionstorage.getitem('wizard');\nconst actor = machine.createactor({\n snapshot: stored ? json.parse(stored) : undefined,\n});\n\nconst stopsaving = actor.subscribe((snapshot) => {\n sessionstorage.setitem('wizard', json.stringify(snapshot));\n});\n\nconsole.log(actor.snapshot);\nstopsaving();\nactor.dispose();\n```\n\nvalidate untrusted persisted data before passing it to `createactor()`. clockwork validates the restored state name but cannot validate application specific context fields.\n\n## error handling\n\nuse `onerror` to choose what happens after failures from transitions, effects, invokes, or subscribers. the context identifies the runtime phase and state; an event is present when one triggered the failure. return `'continue'` to keep the actor alive or `'dispose'` to end it.\n\n```ts\nconst actor = machine.createactor({\n onerror(error, { event, phase, state }) {\n console.error({ error, event, phase, state });\n return 'continue';\n },\n});\n```\n\nwithout `onerror`, an actor disposes silently. return `'dispose'` explicitly when an error handler logs an unrecoverable failure.\n\n## debugging\n\nuse opt in snapshot logging during development. `debugactor()` observes committed snapshots only; it does not trace dispatched events or runtime errors.\n\n```ts\nimport { debugactor } from '@vielzeug/clockwork/devtools';\n\nconst actor = machine.createactor();\nconst stopdebugging = debugactor(actor);\nactor.send({ type: 'next' });\nstopdebugging();\nactor.dispose();\n```\n\nfor richer inspection, subscribe to snapshots and record them in application devtools. clockwork intentionally has no internal trace buffer.\n\n## flat state maps\n\nclockwork has flat state ids. prefer explicit states such as `editingdraft` and `editingsaving`, or compose several actors when domains have independent lifecycles.\n\n## ssr\n\nreuse a compiled machine definition, but create and dispose an actor per request. never share an actor across concurrent requests.\n\n## testing\n\ntest deterministic state behavior through `machine.transition()`. create actors only for timers, invokes, effects, queueing, subscriptions, or disposal behavior.\n\n```ts\nimport { expect, test } from 'vitest';\n\ntest('increments without an actor', () => {\n const result = counter.transition(\n { context: { count: 2 }, state: 'idle' },\n { type: 'inc' },\n );\n\n expect(result).tomatchobject({\n snapshot: { context: { count: 3 }, state: 'idle' },\n type: 'transition',\n });\n});\n```\n\n## framework integration\n\nbridge the current actor snapshot into renderer state through one subscription. dispose that subscription with component lifecycle.\n\n::: code group\n\n```ts [react]\nimport { usesyncexternalstore } from 'react';\n\nfunction useactor<snapshot>(actor: { readonly snapshot: snapshot; subscribe(listener: (snapshot: snapshot) => void): () => void }) {\n return usesyncexternalstore(\n (notify) => actor.subscribe(() => notify()),\n () => actor.snapshot,\n );\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nconst snapshot = shallowref(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nonunmounted(stop);\n```\n\n```ts [svelte]\nimport { ondestroy } from 'svelte';\n\nlet snapshot = actor.snapshot;\nconst stop = actor.subscribe((next) => (snapshot = next));\nondestroy(stop);\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse herald when separate actors exchange application events. bridge clockwork snapshots into ripple only at a ui or application boundary.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\nconst bus = createbus<{ refresh: void }>();\nbus.on('refresh', () => actor.send({ type: 'fetch' }));\n```\n\n## best practices\n\n define context and event unions with `definemachine<context, event>()`.\n return replacement context from reducers; do not rely on runtime copying or freezing.\n keep guards and reducers pure.\n use actors for effects, timers, invokes, subscriptions, and cancellation.\n read the current snapshot from `actor.snapshot`, not a wrapper value.\n validate persisted context before restoring a snapshot.\n dispose every actor at its ownership boundary.\n route runtime failures through `onerror` when the owner can recover.\n",
164
+ "examples": " \ntitle: clockwork — examples\ndescription: practical state machine patterns with pure transitions and actors.\n \n\n [counter with reset](./examples/counter with reset.md)\n [form validation](./examples/form validation.md)\n [auto dismiss notification](./examples/auto dismiss notification.md)\n [model nested workflows with flat states](./examples/hierarchical states.md)\n [pure transition testing](./examples/unit testing.md)\n [auth flow with guards](./examples/auth flow.md)\n [data fetching with error recovery](./examples/data fetching.md)\n [fetch with retry](./examples/fetch retry.md)\n [paginated data loading](./examples/paginated data loading.md)\n [media player](./examples/media player.md)\n [persisted wizard](./examples/persisted wizard.md)\n [multi step wizard with routing](./examples/wizard with routing.md)\n [shopping cart checkout](./examples/checkout.md)\n [permission based access control](./examples/permission based access.md)\n [event boundaries](./examples/middleware pipeline.md)\n [multi machine coordination](./examples/multi machine coordination.md)\n [debugging transitions](./examples/debugging transitions.md)\n"
165
+ },
166
+ "examples": [
167
+ {
168
+ "id": "after-transitions",
169
+ "text": "delayed transitions import { definemachine } from '@vielzeug/clockwork'\n\n// timers begin on entry and cancel automatically on exit or disposal.\nconst machine = definemachine()({\n context: { message: '' },\n initial: 'hidden',\n states: {\n hidden: {\n on: {\n show: {\n reduce: ({ event }) => ({ message: event.message }),\n target: 'visible',\n },\n },\n },\n visible: {\n after: [{ delay: 500, target: 'hidden' }],\n on: { dismiss: { target: 'hidden' } },\n },\n },\n})\n\nconst actor = machine.createactor()\nactor.send({ type: 'show', message: 'saved' })\nconsole.log('visible:', actor.snapshot)\nsettimeout(() => console.log('after timer:', actor.snapshot), 700)"
170
+ },
171
+ {
172
+ "id": "async-invokes",
173
+ "text": "async invokes import { definemachine } from '@vielzeug/clockwork'\n\n// invokes get an abortsignal and send regular events when settled.\nconst machine = definemachine()({\n context: { error: '', user: null },\n initial: 'idle',\n states: {\n idle: { on: { fetch: { target: 'loading' } } },\n loading: {\n invoke: [{\n src: async ({ signal }) => {\n await new promise((resolve, reject) => {\n const timer = settimeout(resolve, 250)\n signal.addeventlistener('abort', () => { cleartimeout(timer); reject(new error('aborted')) })\n })\n return { name: 'alice' }\n },\n ondone: ({ result }) => ({ type: 'done', user: result }),\n onerror: ({ error }) => ({ type: 'failed', message: string(error) }),\n }],\n on: {\n done: { reduce: ({ event }) => ({ error: '', user: event.user }), target: 'ready' },\n failed: { reduce: ({ event }) => ({ error: event.message, user: null }), target: 'error' },\n },\n },\n ready: {},\n error: {},\n },\n})\n\nconst actor = machine.createactor()\nactor.send({ type: 'fetch' })\nconsole.log('loading:', actor.snapshot)\nsettimeout(() => console.log('resolved:', actor.snapshot), 400)"
174
+ },
175
+ {
176
+ "id": "basic-machine",
177
+ "text": "basic state machine import { definemachine } from '@vielzeug/clockwork'\n\n// compile one machine, then create an independent actor.\nconst machine = definemachine()({\n context: { cycles: 0 },\n initial: 'red',\n states: {\n red: { on: { next: { target: 'green' } } },\n green: { on: { next: { target: 'yellow' } } },\n yellow: {\n on: {\n next: {\n reduce: ({ context }) => ({ cycles: context.cycles + 1 }),\n target: 'red',\n },\n },\n },\n },\n})\n\nconst actor = machine.createactor()\nconsole.log('initial:', actor.snapshot)\nactor.send({ type: 'next' })\nactor.send({ type: 'next' })\nactor.send({ type: 'next' })\nconsole.log('after cycle:', actor.snapshot)\nconsole.log('can continue?', actor.can({ type: 'next' }))"
178
+ },
179
+ {
180
+ "id": "entry-exit-actions",
181
+ "text": "post commit effects import { definemachine } from '@vielzeug/clockwork'\n\nconst log = (message) => console.log(message)\n\n// effects run after the actor commits and notifies subscribers.\nconst machine = definemachine()({\n context: { reconnects: 0 },\n initial: 'disconnected',\n states: {\n disconnected: {\n entry: [() => log('[disconnected] socket closed')],\n on: { connect: { target: 'connected' } },\n },\n connected: {\n entry: [({ context }) => log('[connected] reconnects: ' + context.reconnects)],\n exit: [() => log('[connected] socket closing')],\n on: {\n disconnect: { target: 'disconnected' },\n error: {\n reduce: ({ context }) => ({ reconnects: context.reconnects + 1 }),\n target: 'disconnected',\n },\n },\n },\n },\n})\n\nconst actor = machine.createactor()\nactor.subscribe((snapshot) => console.log('committed:', snapshot))\nactor.send({ type: 'connect' })\nactor.send({ type: 'error' })"
182
+ },
183
+ {
184
+ "id": "guards-and-reducers",
185
+ "text": "guards & reducers import { definemachine } from '@vielzeug/clockwork'\n\nconst secret_key = 'vielzeug'\n\n// guards select a transition. reducers return replacement context.\nconst machine = definemachine()({\n context: { accessattempts: 0 },\n initial: 'locked',\n states: {\n locked: {\n on: {\n unlock: [\n {\n guard: ({ event }) => event.key === secret_key,\n reduce: () => ({ accessattempts: 0 }),\n target: 'unlocked',\n },\n {\n reduce: ({ context }) => ({ accessattempts: context.accessattempts + 1 }),\n target: 'locked',\n },\n ],\n },\n },\n unlocked: { on: { lock: { target: 'locked' } } },\n },\n})\n\nconst actor = machine.createactor()\nactor.send({ type: 'unlock', key: 'wrong' })\nconsole.log('wrong key:', actor.snapshot)\nactor.send({ type: 'unlock', key: secret_key })\nconsole.log('correct key:', actor.snapshot)"
186
+ },
187
+ {
188
+ "id": "pure-transitions-and-errors",
189
+ "text": "pure transitions & errors import { clockworkerror, definemachine } from '@vielzeug/clockwork'\n\nconst machine = definemachine()({\n context: { role: 'guest' },\n initial: 'locked',\n states: {\n locked: {\n on: {\n unlock: {\n guard: ({ context }) => context.role === 'admin',\n target: 'unlocked',\n },\n },\n },\n unlocked: { on: { lock: { target: 'locked' } } },\n },\n})\n\n// pure transition: no actor, effects, or mutation.\nconst result = machine.transition(\n { context: { role: 'guest' }, state: 'locked' },\n { type: 'unlock' },\n)\nconsole.log('guest result:', result.type)\n\nfor (const definition of [\n { initial: 'missing', states: { idle: {} } },\n { context: [], initial: 'idle', states: { idle: {} } },\n]) {\n try {\n definemachine()(definition)\n } catch (error) {\n if (error instanceof clockworkerror) {\n console.log('validation code:', error.code)\n console.log('details:', error.details)\n }\n }\n}"
190
+ }
191
+ ],
192
+ "exports": "definemachine clockworkerror machine actor machineconfig machinesnapshot transitionresult",
193
+ "keywords": "state machine finite state typed actor async tasks",
194
+ "name": "@vielzeug/clockwork",
195
+ "related": "herald ripple ward",
196
+ "slug": "clockwork",
197
+ "source": "export type { clockworkerrorcode } from './errors.js';\nexport { clockworkerror } from './errors.js';\nexport { definemachine } from './interpret.js';\nexport type {\n actor,\n actorerrorcontext,\n actorerrordisposition,\n actoroptions,\n after,\n effect,\n effectargs,\n eventbytype,\n eventtype,\n guard,\n invoke,\n invokeargs,\n machine,\n machineconfig,\n machineevent,\n machinesnapshot,\n reducer,\n statenode,\n transition,\n transitioninput,\n transitionresult,\n} from './types.js';\n"
198
+ },
199
+ {
200
+ "category": "ai",
201
+ "description": "local mcp access to vielzeug documentation and package metadata.",
202
+ "docs": {
203
+ "index": " \ntitle: codex\ndescription: local mcp access to vielzeug documentation and package metadata.\npackage: codex\ncategory: ai\nkeywords: [mcp, docs, ai]\nrelated: [refine]\nexports: [loadsnapshot, snapshotcatalog, createmcpserver, starthttphost]\nenvironments: [node]\n \n\n<packagehero package=\"codex\" />\n\n## why codex?\n\ncodex exposes current vielzeug catalog data through mcp without scanning source at request time.\n\n## installation\n\n```sh\npnpm add @vielzeug/codex\n```\n\n## quick start\n\n```sh\nnpx y @vielzeug/codex\n```\n\n## features\n\n `loadsnapshot` validates chunked snapshot metadata.\n `snapshotcatalog` loads package content only when requested.\n `createmcpserver` adapts catalog operations to mcp.\n\n## documentation\n\n [usage](./usage.md)\n [api](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n## see also\n\n [refine](../refine/) provides component metadata bundled by codex.\n",
204
+ "api": " \ntitle: codex api\ndescription: snapshot, catalog, mcp server, and local http host apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `loadsnapshot` | read validated snapshot metadata | sync | content chunks load lazily |\n| `validatesnapshot` | validate every content chunk | sync | throws on mismatch; use in tests, not startup |\n| `snapshotcatalog` | query package corpus | sync | construct from loaded snapshot |\n| `createmcpserver` | mcp adapter factory | sync | requires catalog and version |\n| `starthttphost` | loopback streamable http host | async | http remains local only |\n| `parsepointer` / `parsemanifest` / `parsecatalog` / `parsecontent` / `parsesearch` | pure snapshot parsers | sync | throw `codexerror` on malformed input |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/codex` | snapshot, catalog, mcp, and http apis |\n\n## snapshot\n\n### `loadsnapshot`\n\n```ts\nloadsnapshot(snapshotroot?: string, options?: { validatecontents?: boolean }): loadedsnapshot;\n```\n\nloads catalog/search metadata only. `snapshotroot` defaults to the bundled `data/` directory. pass `validatecontents: true` to verify every package content chunk; use `validatesnapshot()` as a shortcut for that. package chunks stay lazy at runtime.\n\n### `snapshotcatalog`\n\n```ts\nnew snapshotcatalog(snapshot: loadedsnapshot)\n```\n\nprovides package lookup, docs/source/example/signature access, deterministic search, and refine component lookup.\n\n \n\n### `validatesnapshot`\n\n```ts\nvalidatesnapshot(snapshotroot?: string): void;\n```\n\nloads and validates every package content chunk in the snapshot. use during generation, integration tests, or explicit artifact verification; throws `codexerror` on any mismatch.\n\n \n\n### snapshot parsers\n\n```ts\nparsepointer(value: unknown): snapshotpointer;\nparsemanifest(value: unknown): snapshotmanifest;\nparsecatalog(value: unknown): catalogfile;\nparsecontent(value: unknown, slug: string): packagecontent;\nparsesearch(value: unknown, catalog: catalogfile): searchrecord[];\n```\n\npure validation parsers used by `loadsnapshot`. each throws `codexerror` on malformed input.\n\n## mcp\n\n### `createmcpserver`\n\n```ts\ncreatemcpserver(catalog: catalog, options: { version: string; debug?: boolean }): server;\n```\n\nregisters mcp tools as an adapter over `catalog`.\n\n## http\n\n### `starthttphost`\n\n```ts\nstarthttphost(options: httphostoptions): promise<httphost>;\n```\n\nstarts streamable http on `127.0.0.1` by default. host accepts only loopback addresses.\n\n## types\n\n```ts\ninterface snapshotpointer {\n directory: string;\n}\n\ninterface snapshotmanifest {\n catalog: 'catalog.json';\n contentdirectory: 'packages';\n refine: 'refine.json' | null;\n schemaversion: typeof snapshot_schema_version;\n search: 'search.json';\n version: string;\n}\n```\n\ndev snapshots use `snapshotpointer` (via `current.json`); published snapshots are static directories.\n\n```ts\ninterface loadedsnapshot {\n catalog: catalogfile;\n contentdirectory: string;\n manifest: snapshotmanifest;\n refinecomponents: cemdeclaration[];\n search: searchrecord[];\n}\n```\n\n```ts\ninterface catalogfile {\n packages: packagemeta[];\n version: string;\n}\n\ninterface searchrecord {\n category: string;\n description: string;\n docs: partial<record<docpage, string>>;\n examples: array<{ id: string; text: string }>;\n exports: string;\n keywords: string;\n name: string;\n related: string;\n slug: string;\n source: string | null;\n}\n\ninterface packagemeta {\n availabledocpages: docpage[];\n category: string;\n description: string;\n exampleids: string[];\n exports: string[];\n hassource: boolean;\n keywords: string[];\n name: string;\n related: string[];\n slug: string;\n version: string;\n}\n\ninterface packagecontent {\n apisource: string | null;\n docs: partial<record<docpage, string>>;\n examples: example[];\n typesignatures: record<string, string>;\n}\n\ninterface example {\n code: string;\n id: string;\n name: string;\n}\n```\n\n```ts\ninterface catalog {\n getcomponent(tagname: string): cemdeclaration;\n getcontent(slug: string): packagecontent;\n getdocs(slug: string, page: docpage): string;\n getexample(slug: string, exampleid: string): example;\n getpackage(slug: string): packagemeta;\n getsource(slug: string): string;\n gettypesignature(slug: string, symbol: string): string;\n listcomponents(): cemdeclaration[];\n listexamples(slug: string): array<pick<example, 'id' | 'name'>>;\n listpackages(): packagemeta[];\n search(query: string): searchhit[];\n}\n\ninterface searchhit {\n matchedexamples?: string[];\n matchedin: array<'docs' | 'examples' | 'exports' | 'keywords' | 'metadata' | 'related' | 'source'>;\n matchedpages?: docpage[];\n name: string;\n slug: string;\n}\n```\n\n```ts\ninterface httphost {\n dispose(): promise<void>;\n readonly host: string;\n readonly port: number;\n [symbol.asyncdispose](): promise<void>;\n}\n\ninterface httphostoptions {\n catalog: catalog;\n debug?: boolean;\n host?: '127.0.0.1' | '::1';\n port: number;\n version: string;\n}\n```\n\n```ts\nconst doc_pages = ['index', 'api', 'usage', 'examples'] as const;\ntype docpage = (typeof doc_pages)[number];\n\nconst snapshot_schema_version = 1 as const;\n```\n\n```ts\ninterface cemdeclaration {\n attributes?: cemattribute[];\n cssparts?: cemcsspart[];\n cssproperties?: cemcssproperty[];\n description?: string;\n events?: cemevent[];\n members?: cemmember[];\n name?: string;\n slots?: cemslot[];\n superclass?: { name: string; package?: string };\n tagname?: string;\n [key: string]: unknown;\n}\n\ninterface cemattribute {\n default?: string;\n description?: string;\n fieldname?: string;\n name: string;\n type?: { text: string };\n}\n\ninterface cemcsspart {\n description?: string;\n name: string;\n}\n\ninterface cemcssproperty {\n default?: string;\n description?: string;\n name: string;\n}\n\ninterface cemevent {\n description?: string;\n name: string;\n type?: { text: string };\n}\n\ninterface cemmember {\n description?: string;\n kind?: 'field' | 'method';\n name: string;\n type?: { text: string };\n}\n\ninterface cemslot {\n description?: string;\n name: string;\n}\n```\n\n## errors\n\n`codexerror` signals malformed snapshots or host failures. `catalogerror` adds `invalid_arg`, `not_found`, or `unavailable` for expected tool failures.\n",
205
+ "usage": " \ntitle: codex — usage guide\ndescription: install, connect, develop, and debug the vielzeug mcp server.\n \n\n[[toc]]\n\n## basic usage\n\nrun local stdio server:\n\n```sh\nnpx y @vielzeug/codex\n```\n\nuse shipped `mcp setup.json` for machine readable generic configuration. client specific configuration must use its documented mcp format.\n\n## http mode\n\nhttp uses streamable http and binds loopback only:\n\n```sh\nnpx y @vielzeug/codex port=3100\ncurl http://127.0.0.1:3100/health\n```\n\nresponse includes snapshot version. no legacy sse endpoint, cors wildcard, or remote host mode exists.\n\n## local development\n\nrequires node 22+ and root setup:\n\n```sh\npnpm setup\ncd packages/codex\npnpm test:unit\npnpm test:integration\npnpm dev\n```\n\n`test:unit` uses fixtures only. `test:integration` regenerates a current snapshot then checks real monorepo inputs.\n\n`pnpm dev` watches documentation and package inputs, atomically publishes snapshots, then restarts server when snapshot changes.\n\n## debugging\n\n```sh\npnpm dev\nnode src/cli.ts port=3100 debug\ncurl http://127.0.0.1:3100/health\n```\n\n` debug` logs tool durations and expected catalog errors to stderr. build `@vielzeug/refine` before generating snapshot when component metadata changes.\n\n## programmatic usage\n\n```ts\nimport { snapshotcatalog, createmcpserver, loadsnapshot } from '@vielzeug/codex';\nimport { stdioservertransport } from '@modelcontextprotocol/server/stdio';\n\nconst snapshot = loadsnapshot();\nconst catalog = new snapshotcatalog(snapshot);\nawait createmcpserver(catalog, { version: snapshot.manifest.version }).connect(new stdioservertransport());\n```\n\n## best practices\n\n use `search packages` for capability discovery before loading broad source.\n use `get type signature` before loading full source.\n published package snapshots are static directories; local dev snapshots are immutable generations selected by `.dev/current.json`.\n run `validatesnapshot()` in artifact verification paths, not normal server startup.\n keep http local. use stdio for normal client integration.\n run `pnpm test:unit` before `pnpm test:integration`.\n",
206
+ "examples": " \ntitle: codex — examples\ndescription: practical mcp tool call examples for package discovery, docs lookup, and refine component queries.\n \n\n## examples\n\n [listing packages](./examples/listing packages.md)\n [searching packages](./examples/searching packages.md)\n [package metadata](./examples/package metadata.md)\n [reading docs](./examples/reading docs.md)\n [running repl examples](./examples/running repl examples.md)\n [looking up components](./examples/looking up components.md)\n [inspector](./examples/inspector.md)\n"
207
+ },
208
+ "examples": [],
209
+ "exports": "loadsnapshot snapshotcatalog createmcpserver starthttphost",
210
+ "keywords": "mcp docs ai",
211
+ "name": "@vielzeug/codex",
212
+ "related": "refine",
213
+ "slug": "codex",
214
+ "source": "export { type catalog, catalogerror, type searchhit, snapshotcatalog } from './catalog.js';\nexport { codexerror } from './errors.js';\nexport { type httphost, type httphostoptions, starthttphost } from './http.js';\nexport { createmcpserver } from './server.js';\nexport {\n loadsnapshot,\n parsecatalog,\n parsecontent,\n parsemanifest,\n parsepointer,\n parsesearch,\n validatesnapshot,\n} from './snapshot.js';\nexport {\n type cemattribute,\n type cemcsspart,\n type cemcssproperty,\n type cemdeclaration,\n type cemevent,\n type cemmember,\n type cemslot,\n doc_pages,\n type docpage,\n type example,\n type packagecontent,\n type packagemeta,\n snapshot_schema_version,\n type snapshotmanifest,\n type snapshotpointer,\n} from './types.js';\n"
215
+ },
216
+ {
217
+ "category": "finance",
218
+ "description": "exact bigint monetary arithmetic with explicit currency definitions, decimal strings, allocation, exchange, formatting, and json boundaries.",
219
+ "docs": {
220
+ "index": " \ntitle: coins — exact money for typescript\ndescription: exact bigint monetary arithmetic with explicit currency definitions, decimal strings, allocation, exchange, formatting, and json boundaries.\npackage: coins\ncategory: finance\nkeywords: [money, currency, bigint, decimal, exchange, formatting]\nexports: [money, currency, add, allocate, exchange, format]\nrelated: [vault, courier, spell]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"coins\" />\n\n## why coins?\n\ncoins keeps monetary values in bigint minor units, but makes units explicit at construction. currency scale comes from deterministic definitions; `intl` formats a known value without deciding its arithmetic representation.\n\n```ts\n// before\nconst total = (19.99 + 7.25) * 1.08;\n\n// after\nimport { usd, add, money, multiply } from '@vielzeug/coins';\n\nconst total = multiply(add(money('19.99', usd), money('7.25', usd)), '1.08');\n```\n\n| feature | coins | decimal.js | dinero.js |\n| | | | |\n| bundle size | <packageinfo package=\"coins\" type=\"size\" /> | external dependency | external dependency |\n| bigint minor units | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit currency scale | <ore icon name=\"check\" size=\"16\"></ore icon> | app defined | partial |\n| exact allocation | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use coins when** application values represent real money and every rounding boundary must be visible.\n\n**consider native numbers when** values are estimates, analytics, or display only approximations.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/coins\n```\n\n```sh [npm]\nnpm install @vielzeug/coins\n```\n\n```sh [yarn]\nyarn add @vielzeug/coins\n```\n\n:::\n\n## quick start\n\n```ts\nimport { usd, add, format, money, multiply } from '@vielzeug/coins';\n\nconst subtotal = add(money('12.50', usd), money('7.25', usd));\nconst total = multiply(subtotal, '1.08', { rounding: 'halfeven' });\n\nconsole.log(format(total));\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`money`**: one constructor for decimal and explicit minor unit values\n **`currency`**: deterministic built in currency definitions\n **`add`**: exact same currency arithmetic\n **`allocate`**: split every minor unit without loss\n **`exchange`**: typed source and target currency conversion\n **`format`**: locale presentation for bigint values\n **`parsemoneyjson`**: validate persisted money values\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 [vault](/vault/) — persist validated money json.\n [courier](/courier/) — retrieve exchange rate data.\n [spell](/spell/) — validate external monetary payloads.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
221
+ "api": " \ntitle: coins — api reference\ndescription: exact money, currency definitions, exchange, formatting, serialization, and errors.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `money` | construct validated money | sync | bigint requires `{ unit: 'minor' }` |\n| `currency` | resolve supported definition | sync | unknown codes throw |\n| `definecurrency` | define an explicit scale | sync | code must be three uppercase letters |\n| `add` / `subtract` | combine matching currencies | sync | mismatches throw |\n| `multiply` / `divide` | exact decimal scaling | sync | use decimal strings |\n| `sum` | aggregate with inferred currency | sync | empty iterable requires `{ currency }` |\n| `allocate` | split without losing minor units | sync | weights must be non negative |\n| `clamp` | bound to min/max range | sync | min must not exceed max |\n| `exchange` | convert through an exact rate | sync | rate source must match value currency |\n| `format` | present money with `intl` | sync | formatting does not define currency scale |\n| `tojson` / `parsemoneyjson` | cross json boundary | sync | persisted amount uses minor units |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/coins` | complete public coins api |\n\n## construction\n\n### `currency(code)`\n\n```ts\nfunction currency(code: string): currency;\n```\n\nresolves a registered currency definition by iso code. throws `invalidcurrencyerror` for unknown codes. built in definitions: `usd`, `eur`, `gbp`, `jpy`, `krw`, `bhd`, `kwd`.\n\n### `definecurrency({ code, minorunit })`\n\n```ts\nfunction definecurrency<c extends string>({ code, minorunit }: { code: c; minorunit: number }): currency<c>;\n```\n\ndefines an explicit scale for a custom currency. code must be three uppercase letters; `minorunit` must be an integer from 0 to 6. built in definitions are immutable and separate from custom definitions.\n\n### `iscurrency(value)`\n\n```ts\nfunction iscurrency(value: unknown): value is currency;\n```\n\ntype guard for registered currency definitions.\n\n### `money(amount, currency, options?)`\n\n```ts\nfunction money<c extends currency>(amount: string, currency: c): money<c>;\nfunction money<c extends currency>(amount: string, currency: c, options: { rounding: roundingmode }): money<c>;\nfunction money<c extends currency>(amount: bigint, currency: c, options: { unit: 'minor' }): money<c>;\n```\n\n```ts\nmoney('19.99', usd);\nmoney(1999n, usd, { unit: 'minor' });\n```\n\ndecimal strings that exceed the currency's precision require a `rounding` mode. bigint amounts require `{ unit: 'minor' }`.\n\n### `parsemoney(value)`\n\n```ts\nfunction parsemoney(value: unknown): money;\n```\n\nvalidates an unknown value as canonical money. requires a plain data object with a bigint `amount` and a registered currency.\n\n### `ismoney(value)`\n\n```ts\nfunction ismoney(value: unknown): value is money;\n```\n\ntype guard for canonical coins money values.\n\n## arithmetic\n\n```ts\nfunction add<c extends currency>(left: money<c>, right: money<noinfer<c>>): money<c>;\nfunction subtract<c extends currency>(left: money<c>, right: money<noinfer<c>>): money<c>;\nfunction multiply<c extends currency>(value: money<c>, factor: string, options?: { rounding?: roundingmode }): money<c>;\nfunction divide<c extends currency>(value: money<c>, divisor: string, options?: { rounding?: roundingmode }): money<c>;\nfunction compare<c extends currency>(left: money<c>, right: money<noinfer<c>>): 1 | 0 | 1;\nfunction clamp<c extends currency>(\n value: money<c>,\n options: { max: money<noinfer<c>>; min: money<noinfer<c>> },\n): money<c>;\nfunction abs<c extends currency>(value: money<c>): money<c>;\nfunction negate<c extends currency>(value: money<c>): money<c>;\nfunction round<c extends currency>(\n value: money<c>,\n options: { fractiondigits: number; rounding?: roundingmode },\n): money<c>;\nfunction todecimal(value: money): string;\n```\n\n`factor` and `divisor` are decimal strings. matching currency is required for binary money operations. `round`'s `fractiondigits` must be an integer from 0 to the currency's `minorunit`.\n\n## aggregation\n\n```ts\nfunction sum<c extends currency>(values: readonly money<c>[]): money<c>;\nfunction sum<c extends currency>(values: iterable<money<c>>, options: { currency: c }): money<c>;\nfunction allocate<c extends currency>(value: money<c>, count: number): money<c>[];\nfunction allocate<c extends currency>(value: money<c>, weights: readonly string[]): money<c>[];\n```\n\n`sum` infers currency from non empty values. `sum([], { currency: usd })` returns zero usd. `allocate` returns values whose minor unit total exactly equals input.\n\n## exchange\n\n```ts\nfunction exchangerate<from extends currency, to extends currency>({\n from,\n to,\n value,\n}: {\n from: from;\n to: to;\n value: string;\n}): exchangerate<from, to>;\n\nfunction exchange<from extends currency, to extends currency>(\n value: money<from>,\n rate: exchangerate<from, to>,\n options?: { rounding?: roundingmode },\n): money<to>;\n```\n\n```ts\nconst rate = exchangerate({ from: usd, to: eur, value: '0.9234' });\nexchange(money('100.00', usd), rate);\n```\n\n## formatting\n\n```ts\nfunction format(value: money, options?: formatoptions): string;\nfunction formatparts(value: money, options?: formatoptions): moneyformatpart[];\n```\n\n`formatoptions` uses `locale`, `style`, `rounding`, `minimumfractiondigits`, and `maximumfractiondigits`.\n\n## serialization\n\n```ts\nfunction tojson(value: money): moneyjson;\nfunction parsemoneyjson(value: unknown, options?: { currency?: (code: string) => currency }): money;\n```\n\n`tojson` produces a `{ amount, currency, unit: 'minor' }` shape. `parsemoneyjson` validates the shape, unit, and currency code; custom currencies require an explicit `currency` resolver.\n\n## types\n\n```ts\ntype currencycode<c extends string = string> = c & { readonly [currencybrand]: c };\n\ntype currency<c extends string = string> = readonly<{\n code: currencycode<c>;\n minorunit: number;\n}>;\n\ntype decimal = readonly<{\n readonly [decimalbrand]: true;\n denominator: bigint;\n numerator: bigint;\n}>;\n\ntype money<c extends currency = currency> = readonly<{\n amount: bigint;\n currency: c;\n readonly [moneybrand]: c;\n}>;\n\ntype exchangerate<from extends currency = currency, to extends currency = currency> = readonly<{\n from: from;\n to: to;\n value: decimal;\n}>;\n\ntype formatoptions = readonly<{\n locale?: string;\n maximumfractiondigits?: number;\n minimumfractiondigits?: number;\n rounding?: roundingmode;\n style?: 'code' | 'name' | 'narrowsymbol' | 'symbol';\n}>;\n\ntype moneyformatpart = readonly<{\n type: 'currency' | 'decimal' | 'fraction' | 'integer' | 'literal' | 'minussign' | 'plussign';\n value: string;\n}>;\n\ntype moneyjson = readonly<{\n amount: string;\n currency: string;\n unit: 'minor';\n}>;\n\ntype roundingmode = 'awayfromzero' | 'ceil' | 'floor' | 'halfawayfromzero' | 'halfeven' | 'towardzero';\n```\n\n`currencycode`, `decimal`, and `money` carry phantom brand symbols that prevent unbranded values from being assigned where a canonical value is required.\n\n## errors\n\n```ts\ntype coinserrorcode =\n | 'currency_mismatch'\n | 'division_by_zero'\n | 'format_error'\n | 'invalid_allocation'\n | 'invalid_currency'\n | 'invalid_decimal'\n | 'invalid_money'\n | 'invalid_rounding';\n```\n\nevery coins failure extends `coinserror` and exposes `code`.\n\n```ts\nclass coinserror extends error {\n readonly code: coinserrorcode;\n}\n\nclass currencymismatcherror extends coinserror {\n readonly expected: string;\n readonly received: string;\n}\n\nclass invalidcurrencyerror extends coinserror {\n readonly value: unknown;\n}\n```\n\n`currencymismatcherror` and `invalidcurrencyerror` are specialized `coinserror` subclasses. use `instanceof coinserror` to narrow any value to the coins error hierarchy.\n",
222
+ "usage": " \ntitle: coins — usage guide\ndescription: construct exact money, aggregate values, convert currencies, and format results with coins.\n \n\n[[toc]]\n\n## basic usage\n\nconstruct decimal values with a currency definition. coins stores minor units internally and never accepts implicit floating point input.\n\n```ts\nimport { usd, add, money, todecimal } from '@vielzeug/coins';\n\nconst subtotal = add(money('12.50', usd), money('7.25', usd));\n\nconsole.log(todecimal(subtotal)); // '19.75'\n```\n\nuse bigint only when data is already in minor units:\n\n```ts\nimport { usd, money } from '@vielzeug/coins';\n\nconst cents = money(1999n, usd, { unit: 'minor' });\n```\n\n## define currencies\n\nuse built in currency definitions for supported iso currencies. define a currency explicitly when your domain has a distinct scale.\n\n```ts\nimport { eur, usd, definecurrency, money } from '@vielzeug/coins';\n\nconst rewards = definecurrency({ code: 'pts', minorunit: 0 });\n\nmoney('10.00', usd);\nmoney('10.00', eur);\nmoney('500', rewards);\n```\n\n## apply exact arithmetic\n\npass decimal strings to scaling operations. use named rounding whenever an operation can produce fractional minor units.\n\n```ts\nimport { usd, divide, money, multiply, round, todecimal } from '@vielzeug/coins';\n\nconst subtotal = money('19.99', usd);\nconst taxed = multiply(subtotal, '1.08', { rounding: 'halfeven' });\n\n// extra currency precision must name its rounding policy.\nconst roundedinput = money('19.999', usd, { rounding: 'halfawayfromzero' });\nconst split = divide(taxed, '3', { rounding: 'floor' });\nconst displayed = round(taxed, { fractiondigits: 0, rounding: 'halfawayfromzero' });\n\nconsole.log(todecimal(split), todecimal(displayed));\n```\n\n## aggregate and allocate\n\n`sum` infers currency from non empty values. pass `{ currency }` only for possibly empty collections. `allocate` preserves every minor unit.\n\n```ts\nimport { usd, allocate, money, sum, todecimal } from '@vielzeug/coins';\n\nconst total = sum([money('10.00', usd), money('5.00', usd)]);\nconst zero = sum([], { currency: usd });\nconst weighted = allocate(money('10.00', usd), ['1', '2', '1']);\nconst even = allocate(money(5n, usd, { unit: 'minor' }), 2);\n\nconsole.log(todecimal(total));\nconsole.log(weighted.map(todecimal));\nconsole.log(even.map((value) => value.amount)); // [3n, 2n]\n```\n\n## convert currency\n\ncreate a typed rate from currency definitions and an exact decimal string.\n\n```ts\nimport { eur, usd, exchange, exchangerate, format, money } from '@vielzeug/coins';\n\nconst usdtoeur = exchangerate({ from: usd, to: eur, value: '0.9234' });\nconst euros = exchange(money('100.00', usd), usdtoeur, { rounding: 'halfeven' });\n\nconsole.log(format(euros, { locale: 'de de' }));\n```\n\n## serialize money\n\nuse json helpers at storage and transport boundaries. parsing validates the shape, unit, and currency code.\n\n```ts\nimport { usd, money, parsemoneyjson, tojson } from '@vielzeug/coins';\n\nconst encoded = tojson(money('19.99', usd));\nconst restored = parsemoneyjson(encoded);\n\n// custom currencies require an explicit resolver at restore time.\nconst custom = parsemoneyjson(customencoded, { currency: resolveappcurrency });\n```\n\n## handle errors\n\nuse `coinserror.code` for stable recovery branches.\n\n```ts\nimport { coinserror, usd, money } from '@vielzeug/coins';\n\ntry {\n money('19.999', usd);\n} catch (error) {\n if (error instanceof coinserror && error.code === 'invalid_money') {\n console.log('over precise decimal requires a rounding mode.');\n }\n}\n```\n\n## best practices\n\n use decimal strings for exact external inputs.\n use bigint only with `{ unit: 'minor' }`.\n pass named rounding options for division, scaling, and exchange.\n keep currency definitions at application boundaries.\n use `sum(values)` for non empty collections; `sum(values, { currency })` for possibly empty ones.\n serialize with `tojson` and validate with `parsemoneyjson`.\n format only at presentation boundaries.\n",
223
+ "examples": " \ntitle: coins — examples\ndescription: practical examples and recipes for @vielzeug/coins.\n \n\n## examples\n\n [formatting](./examples/formatting.md)\n [exchange rate conversion](./examples/exchange.md)\n [allocation](./examples/allocation.md)\n"
224
+ },
225
+ "examples": [
226
+ {
227
+ "id": "allocation-basic",
228
+ "text": "allocate preserve every minor unit import { usd, allocate, money, sum, todecimal } from '@vielzeug/coins'\n\nconst weighted = allocate(money('10.00', usd), ['1', '2', '1'])\nconst even = allocate(money(5n, usd, { unit: 'minor' }), 2)\n\nconsole.log(weighted.map(todecimal))\nconsole.log(todecimal(sum(weighted)))\nconsole.log(even.map(value => value.amount))"
229
+ },
230
+ {
231
+ "id": "arithmetic-basic",
232
+ "text": "arithmetic exact decimal scaling import { usd, add, divide, money, multiply, subtract, todecimal } from '@vielzeug/coins'\n\nconst subtotal = add(money('12.50', usd), money('7.25', usd))\nconst taxed = multiply(subtotal, '1.08', { rounding: 'halfeven' })\nconst split = divide(taxed, '3', { rounding: 'floor' })\n\nconsole.log(todecimal(subtract(subtotal, money('1.00', usd))) )\nconsole.log(todecimal(taxed))\nconsole.log(todecimal(split))"
233
+ },
234
+ {
235
+ "id": "errors-basic",
236
+ "text": "errors stable codes import { coinserror, usd, money } from '@vielzeug/coins'\n\ntry {\n money('19.999', usd)\n} catch (error) {\n if (error instanceof coinserror) {\n console.log(error.code)\n console.log(error.message)\n }\n}"
237
+ },
238
+ {
239
+ "id": "exchange-basic",
240
+ "text": "exchange exact currency conversion import { eur, usd, exchange, exchangerate, format, money } from '@vielzeug/coins'\n\nconst usdtoeur = exchangerate({ from: usd, to: eur, value: '0.9234' })\nconst euros = exchange(money('100.00', usd), usdtoeur, { rounding: 'halfeven' })\n\nconsole.log(format(euros, { locale: 'de de' }))"
241
+ },
242
+ {
243
+ "id": "format-basic",
244
+ "text": "format locale presentation import { eur, usd, format, formatparts, money } from '@vielzeug/coins'\n\nconst value = money('1234.56', usd)\n\nconsole.log(format(value))\nconsole.log(format(money('1234.56', eur), { locale: 'de de' }))\nconsole.log(formatparts(value))"
245
+ },
246
+ {
247
+ "id": "money-basic",
248
+ "text": "money decimal and minor unit construction import { usd, money, todecimal, tojson } from '@vielzeug/coins'\n\nconst price = money('19.99', usd)\nconst stored = money(1999n, usd, { unit: 'minor' })\n\nconsole.log(todecimal(price))\nconsole.log(stored.amount)\nconsole.log(tojson(price))"
249
+ },
250
+ {
251
+ "id": "rounding-basic",
252
+ "text": "round explicit rounding policy import { usd, money, round, todecimal } from '@vielzeug/coins'\n\nconst value = money('1.55', usd)\n\nconsole.log(todecimal(round(value, { fractiondigits: 1, rounding: 'halfeven' })))\nconsole.log(todecimal(round(value, { fractiondigits: 1, rounding: 'towardzero' })))"
253
+ },
254
+ {
255
+ "id": "serialization-basic",
256
+ "text": "serialization validate json boundaries import { usd, money, parsemoneyjson, tojson } from '@vielzeug/coins'\n\nconst encoded = tojson(money('19.99', usd))\nconst restored = parsemoneyjson(encoded)\n\nconsole.log(encoded)\nconsole.log(restored.amount, restored.currency.code)"
257
+ },
258
+ {
259
+ "id": "utilities-basic",
260
+ "text": "currency definitions and validation import { currency, definecurrency, iscurrency, ismoney, money } from '@vielzeug/coins'\n\nconst points = definecurrency({ code: 'pts', minorunit: 0 })\nconst balance = money('250', points)\n\nconsole.log(currency('usd').minorunit)\nconsole.log(iscurrency(points))\nconsole.log(ismoney(balance))"
261
+ }
262
+ ],
263
+ "exports": "money currency add allocate exchange format",
264
+ "keywords": "money currency bigint decimal exchange formatting",
265
+ "name": "@vielzeug/coins",
266
+ "related": "vault courier spell",
267
+ "slug": "coins",
268
+ "source": "export { allocate, sum } from './aggregate';\nexport { bhd, currency, definecurrency, eur, gbp, iscurrency, jpy, krw, kwd, usd } from './currency';\nexport type { coinserrorcode } from './errors';\nexport { coinserror, currencymismatcherror, invalidcurrencyerror } from './errors';\nexport { exchange, exchangerate } from './exchange';\nexport { format, formatparts } from './format';\nexport {\n abs,\n add,\n clamp,\n compare,\n divide,\n ismoney,\n money,\n multiply,\n negate,\n parsemoney,\n round,\n subtract,\n todecimal,\n} from './money';\nexport { parsemoneyjson, tojson } from './serialization';\nexport type {\n currency,\n currencycode,\n decimal,\n exchangerate,\n formatoptions,\n money,\n moneyformatpart,\n moneyjson,\n roundingmode,\n} from './types';\n"
269
+ },
270
+ {
271
+ "category": "infrastructure",
272
+ "description": "dependency first asynchronous dependency injection with typed tokens, lifecycle scopes, startup validation, and deterministic disposal.",
273
+ "docs": {
274
+ "index": " \ntitle: conduit — dependency injection for typescript\ndescription: dependency first asynchronous dependency injection with typed tokens, lifecycle scopes, startup validation, and deterministic disposal.\npackage: conduit\ncategory: infrastructure\nkeywords: [dependency injection, container, token, lifecycle, scope]\nexports: [createcontainer, token, scope]\nrelated: [courier, vault, rune]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"conduit\" />\n\n## why conduit?\n\nconduit makes service wiring explicit. factory dependency tuples are source of truth for creation, startup validation, and disposal order.\n\n```ts\n// before\nconst service = createservice(createapi(config), logger);\n\n// after\ncontainer.factory(service, [api, logger], (api, logger) => createservice(api, logger));\n```\n\n| feature | conduit | inversify | tsyringe |\n| | | | |\n| dependencies | explicit token tuples | decorators/runtime metadata | decorators/runtime metadata |\n| async factories | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| lifecycle scopes | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| runtime dependencies | 0 | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use conduit when** application services need explicit wiring and owned lifecycle cleanup.\n\n**consider direct imports when** dependencies are static, small, and need no replacement or disposal boundary.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/conduit\n```\n\n```sh [npm]\nnpm install @vielzeug/conduit\n```\n\n```sh [yarn]\nyarn add @vielzeug/conduit\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createcontainer, token } from '@vielzeug/conduit';\n\nconst config = token<{ baseurl: string }>('config');\nconst client = token<{ url: string }>('client');\nconst container = createcontainer();\n\ncontainer.value(config, { baseurl: '/api' });\ncontainer.factory(client, [config], (config) => ({ url: `${config.baseurl}/users` }));\n\nconsole.log(await container.resolve(client));\nawait container.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`token`**: typed dependency identity\n **`factory`**: static dependency first creation\n **`validate`**: startup graph validation\n **`scope`**: explicit request and job ownership\n **`dispose`**: in flight safe resource cleanup\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 [courier](/courier/) — inject http clients into application services.\n [vault](/vault/) — inject persistence adapters with scoped ownership.\n [rune](/rune/) — provide application logging services.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
275
+ "api": " \ntitle: conduit — api reference\ndescription: reference for conduit tokens, dependency first factories, scopes, validation, and lifecycle disposal.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | mode | common gotcha |\n| | | | |\n| `token` | create typed dependency identity | sync | same description does not mean same token |\n| `scope` | create named lifecycle identity | sync | must match factory lifetime |\n| `createcontainer` | create root registry | sync | dispose when application ends |\n| `value` | register an existing value | sync | one registration per token/container |\n| `factory` | register static dependency factory | sync | tuple is copied and authoritative |\n| `has` | check registration visibility | sync | walks parent containers |\n| `resolve` | resolve one dependency | async | missing provider throws |\n| `validate` | validate static graph | sync | run after registration |\n| `createscope` | create child owner | sync | named scope required for scoped factories |\n| `dispose` | release owned resources | async | may throw `conduitdisposeerror` after cleanup attempts |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/conduit` | complete conduit api |\n\n## tokens and scopes\n\n```ts\ntoken<t>(description: string): token<t>\nscope(name: string): scopetoken\n```\n\ntokens and scopes are unique symbols. descriptions exist only for diagnostics.\n\n## container\n\n```ts\ncreatecontainer(options?: { name?: string }): container\n```\n\n### value\n\n```ts\ncontainer.value(token, value, options?)\n```\n\n`options.dispose` runs during container disposal.\n\n### has\n\n```ts\ncontainer.has(token): boolean\n```\n\nchecks local and parent registrations without creating a factory result.\n\n### factory\n\n```ts\ncontainer.factory(token, dependencies, create, options?)\n```\n\n```ts\ncontainer.factory(service, [api, logger], (api, logger) => createservice(api, logger));\n```\n\n`dependencies` is copied at registration and drives creation, validation, cycle detection, and teardown order. factories may return a value or promise.\n\n`options.lifetime` accepts `'singleton'`, `'transient'`, or `scopetoken`. a singleton cannot depend on a scoped resource.\n\n```ts\ntype factoryoptions<t> = {\n dispose?: (value: t) => void | promise<void>;\n lifetime?: 'singleton' | 'transient' | scopetoken;\n};\n```\n\n### resolve\n\n```ts\ncontainer.resolve(token): promise<t>\n```\n\nsingleton resolutions deduplicate concurrent callers.\n\n### validate\n\n```ts\ncontainer.validate(): container\n```\n\nthrows for missing dependencies and circular factory tuples.\n\n### createscope\n\n```ts\ncontainer.createscope(scope?: scopetoken, options?: { name?: string }): container\n```\n\na matching scope owns resources registered with its `scopetoken` lifetime. disposing a parent also disposes its active child scopes.\n\n### dispose\n\n```ts\ncontainer.dispose(): promise<void>\ncontainer.disposalsignal: abortsignal\ncontainer.disposed: boolean\n```\n\ndisposal blocks new work, aborts `disposalsignal`, disposes active child scopes, waits for in flight creation, then disposes owned resources in reverse creation order. cleanup failures are aggregated in `conduitdisposeerror.errors`.\n\n## types\n\n```ts\ntype token<t = unknown> = symbol;\ntype scopetoken = symbol;\ntype lifetime = 'singleton' | 'transient' | scopetoken;\n\ntype valueoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n}>;\n\ntype factoryoptions<t> = readonly<{\n dispose?: (value: t) => promise<void> | void;\n lifetime?: lifetime;\n}>;\n\ntype infertokens<t extends readonly token<unknown>[]> = {\n [k in keyof t]: t[k] extends token<infer value> ? value : never;\n};\n\ninterface container {\n createscope(scope?: scopetoken, options?: { name?: string }): container;\n readonly disposalsignal: abortsignal;\n dispose(): promise<void>;\n readonly disposed: boolean;\n factory<t, dependencies extends readonly token<unknown>[]>(\n token: token<t>,\n dependencies: dependencies,\n create: (...values: infertokens<dependencies>) => promise<t> | t,\n options?: factoryoptions<t>,\n ): this;\n has<t>(token: token<t>): boolean;\n readonly name: string;\n resolve<t>(token: token<t>): promise<t>;\n validate(): this;\n value<t>(token: token<t>, value: t, options?: valueoptions<t>): this;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\n## errors\n\n `conduiterror` — base class; `conduiterror.is(error)` narrows package errors.\n `conduitprovidernotfounderror` — dependency has no registration.\n `conduitcirculardependencyerror` — static factory tuple graph contains a cycle.\n `conduitduplicateregistrationerror` — token registered twice in one container.\n `conduitscopedresolutionerror` — scoped factory resolved without matching scope.\n `conduitdisposederror` — operation attempted after disposal began.\n `conduitdisposeerror` — one or more cleanup hooks failed.\n",
276
+ "usage": " \ntitle: conduit — usage guide\ndescription: register static dependency tuples, resolve services asynchronously, create scopes, validate startup wiring, and dispose owned resources.\n \n\n[[toc]]\n\n## basic usage\n\ncreate tokens once, register values and factories, then resolve through one async api.\n\n```ts\nimport { createcontainer, token } from '@vielzeug/conduit';\n\nconst config = token<{ baseurl: string }>('config');\nconst client = token<{ url: string }>('client');\n\nconst container = createcontainer();\ncontainer.value(config, { baseurl: '/api' });\ncontainer.factory(client, [config], (config) => ({ url: `${config.baseurl}/users` }));\n\nconsole.log(await container.resolve(client));\nawait container.dispose();\n```\n\n## define dependencies\n\nfactory token tuples are authoritative. conduit resolves tuple values in order, validates every edge, and disposes created services in reverse dependency order.\n\n```ts\nconst logger = token<{ info(message: string): void }>('logger');\nconst api = token<{ get(path: string): promise<unknown> }>('api');\nconst service = token<{ load(): promise<unknown> }>('service');\n\ncontainer.factory(service, [api, logger], (api, logger) => ({\n async load() {\n logger.info('loading data');\n return api.get('/data');\n },\n}));\n```\n\n## choose lifetimes\n\nfactories are singletons by default. use transient lifetime for a new value on every resolution. conduit retains a transient only when its factory has a `dispose` hook.\n\n```ts\nconst requestid = token<{ id: string }>('requestid');\n\ncontainer.factory(requestid, [], () => ({ id: crypto.randomuuid() }), {\n lifetime: 'transient',\n});\n```\n\nconcurrent singleton resolutions share one in flight factory result. a singleton cannot depend on a scoped resource; give dependent factory equal or shorter lifetime instead. factory dependency tuples are copied at registration, so later caller mutation cannot change conduit's graph.\n\n## create named scopes\n\nuse a scope token when a resource belongs to a request, job, or test lifecycle.\n\n```ts\nimport { createcontainer, scope, token } from '@vielzeug/conduit';\n\nconst request = scope('request');\nconst session = token<{ id: string }>('session');\nconst root = createcontainer();\n\nroot.factory(session, [], () => ({ id: crypto.randomuuid() }), { lifetime: request });\n\nconst request = root.createscope(request);\nconst session = await request.resolve(session);\nawait request.dispose();\nawait root.dispose();\n```\n\n## validate startup wiring\n\ncall `validate()` after registration. it detects missing dependencies and cycles before service resolution. parent singleton factories validate dependencies from their registration owner; child overrides do not satisfy them.\n\n```ts\ncontainer.validate();\n```\n\n## dispose resources\n\n`dispose()` rejects new work, aborts `disposalsignal`, disposes child scopes, waits for in flight creation, then releases services in reverse creation order. a factory that finishes after disposal starts is immediately cleaned up and its resolver receives `conduitdisposederror`.\n\n```ts\nawait container.dispose();\n```\n\n`conduitdisposeerror.errors` contains every cleanup failure after conduit attempts all hooks, including cleanup from in flight factories and child scopes.\n\n## testing\n\ncreate a container per test and register explicit values for external dependencies.\n\n```ts\nconst clock = token<{ now(): number }>('clock');\nconst service = token<{ timestamp: number }>('service');\nconst container = createcontainer();\n\ncontainer.value(clock, { now: () => 123 });\ncontainer.factory(service, [clock], (clock) => ({ timestamp: clock.now() }));\n\nexpect(await container.resolve(service)).toequal({ timestamp: 123 });\nawait container.dispose();\n```\n\n## best practices\n\n create tokens at module scope.\n declare every factory dependency in its tuple.\n keep factories focused on one service.\n use scopes for request/job owned resources.\n call `validate()` during startup.\n dispose every scope and root container.\n keep optional application fallback policy outside conduit.\n use `await using container = createcontainer()` when lexical async disposal fits application lifetime.\n",
277
+ "examples": " \ntitle: conduit — examples\ndescription: dependency first container recipes.\n \n\n## examples\n\n [basic setup](./examples/basic setup.md)\n [static async providers](./examples/async providers.md)\n [lifetimes](./examples/lifetimes.md)\n [named scopes](./examples/named scopes.md)\n [disposal lifecycle](./examples/dispose lifecycle.md)\n [startup validation](./examples/startup hardening.md)\n"
278
+ },
279
+ "examples": [
280
+ {
281
+ "id": "basic-container",
282
+ "text": "dependency first factory import { createcontainer, token } from '@vielzeug/conduit'\n\nconst config = token<{ baseurl: string }>('config')\nconst client = token<{ url: string }>('client')\n\nconst container = createcontainer()\ncontainer.value(config, { baseurl: '/api' })\ncontainer.factory(client, [config], config => ({ url: config.baseurl + '/users' }))\n\nconsole.log(await container.resolve(client))\nawait container.dispose()"
283
+ },
284
+ {
285
+ "id": "dispose-lifecycle",
286
+ "text": "reverse dependency disposal import { createcontainer, token } from '@vielzeug/conduit'\n\nconst database = token('database')\nconst service = token('service')\nconst order = []\nconst container = createcontainer()\n\ncontainer.factory(database, [], () => ({ close() {} }), { dispose: () => { order.push('database') } })\ncontainer.factory(service, [database], database => ({ database }), { dispose: () => { order.push('service') } })\n\nawait container.resolve(service)\nawait container.dispose()\nconsole.log(order)"
287
+ },
288
+ {
289
+ "id": "lifetimes",
290
+ "text": "singleton and transient lifetimes import { createcontainer, token } from '@vielzeug/conduit'\n\nconst singleton = token('singleton')\nconst transient = token('transient')\nconst container = createcontainer()\n\ncontainer.factory(singleton, [], () => ({ id: crypto.randomuuid() }))\ncontainer.factory(transient, [], () => ({ id: crypto.randomuuid() }), { lifetime: 'transient' })\n\nconsole.log((await container.resolve(singleton)) === (await container.resolve(singleton)))\nconsole.log((await container.resolve(transient)) === (await container.resolve(transient)))\nawait container.dispose()"
291
+ },
292
+ {
293
+ "id": "scoped-execution",
294
+ "text": "named scope ownership import { createcontainer, scope, token } from '@vielzeug/conduit'\n\nconst request = scope('request')\nconst session = token('session')\nconst root = createcontainer()\n\nroot.factory(session, [], () => ({ id: crypto.randomuuid() }), { lifetime: request })\n\nconst request = root.createscope(request)\nconsole.log(await request.resolve(session))\nawait request.dispose()\nawait root.dispose()"
295
+ },
296
+ {
297
+ "id": "testing",
298
+ "text": "replace dependencies in tests import { createcontainer, token } from '@vielzeug/conduit'\n\nconst clock = token<{ now(): number }>('clock')\nconst service = token<{ timestamp: number }>('service')\nconst container = createcontainer()\n\ncontainer.value(clock, { now: () => 123 })\ncontainer.factory(service, [clock], clock => ({ timestamp: clock.now() }))\n\nconsole.log(await container.resolve(service))\nawait container.dispose()"
299
+ },
300
+ {
301
+ "id": "validate",
302
+ "text": "validate static dependencies import { createcontainer, token } from '@vielzeug/conduit'\n\nconst api = token('api')\nconst service = token('service')\nconst container = createcontainer()\n\ncontainer.factory(service, [api], api => ({ api }))\n\ntry {\n container.validate()\n} catch (error) {\n console.log(error.message)\n}\n\nawait container.dispose()"
303
+ }
304
+ ],
305
+ "exports": "createcontainer token scope",
306
+ "keywords": "dependency injection container token lifecycle scope",
307
+ "name": "@vielzeug/conduit",
308
+ "related": "courier vault rune",
309
+ "slug": "conduit",
310
+ "source": "export { createcontainer } from './container';\nexport {\n conduitcirculardependencyerror,\n conduitdisposederror,\n conduitdisposeerror,\n conduitduplicateregistrationerror,\n conduiterror,\n conduitprovidernotfounderror,\n conduitscopedresolutionerror,\n} from './errors';\nexport type { container, factoryoptions, infertokens, lifetime, scopetoken, token, valueoptions } from './types';\nexport { scope, token } from './types';\n"
311
+ },
312
+ {
313
+ "category": "http",
314
+ "description": "a framework neutral fetch client with explicit cache keys, direct mutations, and abortable streams.",
315
+ "docs": {
316
+ "index": " \ntitle: courier — http, queries, and streaming\ndescription: a framework neutral fetch client with explicit cache keys, direct mutations, and abortable streams.\npackage: courier\ncategory: http\nkeywords: [http client, fetch, caching, queries, mutations, sse, streaming, interceptors]\nrelated: [flux, ripple, spell]\nexports:\n [\n createcourier,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n couriertimeouterror,\n courieraborterror,\n courierschemavalidationerror,\n withbearerauth,\n withrequestid,\n withlogging,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"courier\" />\n\n## why courier?\n\nnative `fetch` leaves request policy, cached reads, and stream lifecycles to each application. courier keeps\nthose concerns in one client while making cache identity and fetch policy explicit at every cached read.\n\n```ts\n// before\nconst response = await fetch(`/api/users/${userid}`);\nif (!response.ok) throw new error(`http ${response.status}`);\nconst user = await response.json();\n\n// after\nawait courier.queries.fetch({\n key: ['users', userid],\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: userid }, signal }),\n});\n```\n\n| feature | courier | tanstack query | ky |\n| | | | |\n| bundle size | <packageinfo package=\"courier\" type=\"size\" /> | framework adapter required | separate package |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| native fetch transport | <ore icon name=\"check\" size=\"16\"></ore icon> | bring your own | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit cache keys | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| sse and ndjson iteration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| external runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use courier when** one application client should own typed http, explicit cached reads, direct writes, and\nabortable response streams.\n\n**consider tanstack query when** you need a maintained framework adapter or advanced cache features such as\ninfinite queries. **consider ky when** you only need a compact fetch wrapper without caching or streams.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/courier\n```\n\n```sh [npm]\nnpm install @vielzeug/courier\n```\n\n```sh [yarn]\nyarn add @vielzeug/courier\n```\n\n:::\n\n## quick start\n\ncreate one client for an application or request scope, then fetch a cache entry by its explicit key.\n\n```ts\nimport { courierhttperror, createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', query: { staletime: 30_000 } });\nconst key = ['users', 42] as const;\n\ntry {\n await courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/{id}', { params: { id: 42 }, signal }),\n });\n console.log(courier.queries.getsnapshot<user>(key)?.data);\n} catch (error) {\n if (courierhttperror.is(error, 404)) console.log('user not found');\n else throw error;\n} finally {\n courier.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createcourier()`** — one lifecycle, interceptor pipeline, header store, and cancellation boundary.\n **`get()` / `post()` / `put()` / `patch()` / `delete()`** — typed paths, query strings, request bodies, validation, and structured errors.\n **`queries.fetch()`** — key based cached reads, subscriptions, invalidation with refetch, and automatic garbage collection.\n **`mutate()`** — direct write operation with `invalidatekeys` for one step cache refetch, without hidden retries or a second state store.\n **`events()` / `read()`** — abortable sse, text, and ndjson iteration with normalized request errors.\n **`withbearerauth()` / `withrequestid()` / `withlogging()`** — composable transport policies.\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 [flux](/flux/) — adapts courier cache entries and event iterators into composable streams.\n [ripple](/ripple/) — stores courier snapshots in fine grained reactive state.\n [spell](/spell/) — validates parsed http payloads through courier's schema option.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
317
+ "api": " \ntitle: courier — api reference\ndescription: reference for courier http, cache, mutation, interceptor, and stream apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcourier()` | creates unified application client | sync | dispose only when whole scope ends |\n| `courier` http methods | sends and parses http requests | async | direct calls never deduplicate |\n| `queries.fetch()` | fetches one keyed cache entry | async | key must include all response identity inputs |\n| `mutate()` | runs one write operation | async | it never retries automatically |\n| `events()` / `read()` | opens abortable response iterators | async iteration | breaking iteration aborts request |\n| `withbearerauth()` | adds authorization interceptor | sync | token provider runs per request |\n| `withrequestid()` | adds request identifier interceptor | sync | default generator uses `uuid()` |\n| `withlogging()` | logs request result metadata | sync | requires explicit logger; urls may contain sensitive query values |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/courier` | client factory, errors, interceptors, and public types |\n\n## client\n\n### `createcourier()`\n\n```ts\ncreatecourier(options?: courieroptions): courier;\n```\n\nreturns client sharing transport configuration, headers, interceptors, cancellation, cache, mutations, and streams.\n\n| `courieroptions` field | type | default | description |\n| | | | |\n| `baseurl` | `string` | `''` | prefix for relative request paths |\n| `fetch` | `typeof globalthis.fetch` | `globalthis.fetch` | fetch implementation |\n| `headers` | `record<string, string>` | `{}` | global request headers |\n| `timeout` | `number` | `30_000` | default http timeout in milliseconds |\n| `query.staletime` | `number` | `0` | cache freshness duration |\n| `query.gctime` | `number` | `300_000` | garbage collect entries with no subscribers after this duration (ms); `infinity` disables |\n\n**returns:** `courier`.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\n```\n\n| `courier` member | signature | description |\n| | | |\n| `get` / `post` / `put` / `patch` / `delete` | `<t, p>(url: p, config?) => promise<t>` | sends one http request |\n| `setheaders` | `(updates) => void` | updates global headers |\n| `getheaders` | `() => readonly<record<string, string>>` | returns header snapshot |\n| `use` | `(interceptor) => () => void` | registers interceptor |\n| `cancelall` | `() => void` | aborts active http, cache, and mutation work |\n| `queries` | `querycache` | owns keyed cache entries |\n| `mutate` | `<t>(options) => promise<t>` | runs one write operation |\n| `events` | `<t, p>(url, options?) => asynciterableiterator<streamevent<t>>` | opens sse iterator |\n| `read` | `<t, p>(url, options?) => asynciterableiterator<t>` | opens text or ndjson iterator |\n| `dispose` | `() => void` | final disposal; aborts work and clears cache |\n| `disposed` | `boolean` | whether final disposal occurred |\n| `disposalsignal` | `abortsignal` | aborts on final disposal |\n\n \n\n## queries\n\n### `queries.fetch()`\n\n```ts\nfetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n```\n\nregisters latest definition for `definition.key`, then returns fresh cached data or runs its fetch function.\n\n| parameter | type | description |\n| | | |\n| `definition.key` | `querykey` | cache identity; include every response identity input |\n| `definition.fetch` | `(context: querycontext) => promise<t>` | request function for this key |\n| `definition.staletime` | `number` | per entry freshness duration |\n| `options.force` | `boolean` | fetch even when cached data is fresh |\n\n**returns:** cached or fetched data.\n\n```ts\nconst key = ['profile', 1] as const;\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n```\n\n| `querycache` method | returns | description |\n| | | |\n| `get(key)` | `t \\| undefined` | returns successful cached data |\n| `getsnapshot(key)` | `asyncstate<t> \\| null` | returns snapshot by key |\n| `set(key, data, options?)` | `void` | sets successful cache value |\n| `invalidate(prefix, options?)` | `void` | marks matching key prefixes stale; `options.refetch` triggers background refetch |\n| `keys()` | `querykey[]` | lists known keys |\n| `subscribe(key, listener)` | `unsubscribe` | subscribes to one key |\n| `clear()` | `void` | removes every cache entry |\n\n \n\n## mutations\n\n### `mutate()`\n\n```ts\nmutate<t>(options: mutationoptions<t>): promise<t>;\n```\n\nruns `options.request` once, then calls `onsuccess` after successful completion, then invalidates (and refetches) each key in `invalidatekeys`.\n\n| `mutationoptions<t>` field | type | description |\n| | | |\n| `request` | `(context: mutationcontext) => promise<t>` | write operation |\n| `onsuccess` | `(data, queries) => void \\| promise<void>` | cache update callback |\n| `invalidatekeys` | `readonly (readonly unknown[])[]` | key prefixes to invalidate and refetch after success |\n| `signal` | `abortsignal` | caller controlled cancellation |\n\n**returns:** request result.\n\n \n\n## streams\n\n### `events()` and `read()`\n\n```ts\nevents<t, p extends string>(url: p, options?: streamoptions<p>): asynciterableiterator<streamevent<t>>;\nread<t, p extends string>(url: p, options?: streamoptions<p> & { parse?: 'ndjson' | 'text' }): asynciterableiterator<t>;\n```\n\nboth iterators abort request when `return()` runs or `for await` loop exits. `events()` parses `event` and `data`\nfields; it does not retain event ids or reconnect.\n\n`streamoptions<p>` extends `requestconfig<p>` (typed path params) with an optional `method` field. it omits\n`responsetype` and `schema` (not applicable to streaming).\n\n**returns:** abortable async iterator.\n\n \n\n## interceptors\n\n### interceptor helpers\n\n```ts\nwithbearerauth(token: string | (() => string | promise<string>)): interceptor;\nwithrequestid(options?: { generate?: () => string; header?: string }): interceptor;\nwithlogging(options: {\n logger: (message: string, meta: { duration: number; method: string; status: number; url: string }) => void;\n}): interceptor;\n```\n\neach helper returns an `interceptor` accepted by `courier.use()`. `withlogging` requires an explicit `logger`\nfunction — no default console output.\n\n## types\n\n```ts\ntype transportoptions = {\n baseurl?: string;\n fetch?: typeof globalthis.fetch;\n headers?: record<string, string>;\n timeout?: number;\n};\n\ntype courieroptions = transportoptions & {\n query?: { gctime?: number; staletime?: number };\n};\n\ntype fetchcontext = {\n readonly headers: readonly<record<string, string>>;\n readonly init: readonly<omit<requestinit, 'headers'>>;\n readonly url: string;\n withheaders(updates: record<string, string>): fetchcontext;\n};\n\ntype interceptor = (ctx: fetchcontext, next: (ctx: fetchcontext) => promise<response>) => promise<response>;\n\ntype asyncstate<t> =\n | { data: undefined; error: null; isfetching: boolean; status: 'loading'; updatedat: undefined }\n | { data: t; error: null; isfetching: boolean; status: 'success'; updatedat: number }\n | { data: t | undefined; error: error; isfetching: false; status: 'error'; updatedat: number };\n\ntype querycontext = { readonly key: querykey; readonly signal: abortsignal };\ntype querydefinition<t> = { fetch: (context: querycontext) => promise<t>; key: querykey; staletime?: number };\ntype querykey = readonly [querykeyatom, ...querykeyatom[]];\ntype querykeyatom = string | number | boolean | null;\ntype querycache = {\n clear(): void;\n fetch<t>(definition: querydefinition<t>, options?: { force?: boolean }): promise<t>;\n get<t>(key: querykey): t | undefined;\n getsnapshot<t>(key: querykey): asyncstate<t> | null;\n invalidate(prefix: readonly unknown[], options?: { refetch?: boolean }): void;\n keys(): querykey[];\n set<t>(key: querykey, data: t, options?: { updatedat?: number }): void;\n subscribe(key: querykey, listener: () => void): unsubscribe;\n};\ntype mutationcontext = { readonly signal: abortsignal };\ntype mutationoptions<t> = {\n invalidatekeys?: readonly (readonly unknown[])[];\n onsuccess?: (data: t, queries: querycache) => void | promise<void>;\n request: (context: mutationcontext) => promise<t>;\n signal?: abortsignal;\n};\ntype streamevent<t = unknown> = { readonly data: t; readonly event: string };\ntype streamoptions<p extends string = string> = omit<requestconfig<p>, 'responsetype' | 'schema'> & {\n method?: string;\n};\ntype unsubscribe = () => void;\n```\n\n```ts\ntype paramvalue = string | number | boolean | null | readonly (string | number | boolean | null)[] | undefined;\ntype params = record<string, paramvalue>;\ntype requestconfig<p extends string = string, t = unknown> = {\n body?: unknown;\n fetchinit?: omit<requestinit, 'body' | 'headers' | 'method' | 'signal'>;\n headers?: record<string, string>;\n params?: record<string, string | number | boolean>;\n query?: params;\n responsetype?: 'auto' | 'json' | 'text' | 'blob' | 'arraybuffer' | 'raw';\n schema?: { parse(data: unknown): t };\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `couriererror` | base class for all courier errors | use `instanceof` to narrow |\n| `courierhttperror` | non 2xx http response | `status`, `data`, `headers`, `method`, `url`; `courierhttperror.is(e, status?)` narrows by status |\n| `couriernetworkerror` | request failure without response | `method`, `url`, `cause` |\n| `couriertimeouterror` | timeout signal aborts request | `method`, `url`, `cause` |\n| `courieraborterror` | caller, client, or iterator cancellation | `method`, `url`, `cause` |\n| `courierschemavalidationerror` | response schema fails | `data`, `cause` |\n| `courierparseerror` | response body cannot parse | — |\n| `courierdisposederror` | work starts after disposal | — |\n",
318
+ "usage": " \ntitle: courier — usage guide\ndescription: use one courier client for http, explicit cached reads, direct mutations, and abortable streams.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one courier client for an application or request scope. its transport policy and disposal lifecycle apply\nto every request, cache entry, mutation, and stream.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nconst courier = createcourier({ baseurl: 'https://api.example.com', query: { staletime: 30_000 } });\nconst key = ['users', 1] as const;\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get<user>('/users/{id}', { params: { id: 1 }, signal }),\n});\nconsole.log(courier.queries.get<user>(key)?.name);\n```\n\n## http requests\n\nuse root methods for rest requests. courier encodes path parameters, serializes plain object bodies, and parses\nsuccessful response bodies. each direct http call is independent; use a query key when concurrent cached reads\nshould share work.\n\n```ts\nconst posts = await courier.get<{ id: number; title: string }[]>('/users/{id}/posts', {\n params: { id: 1 },\n query: { limit: 20, status: 'published' },\n});\n\nawait courier.patch('/posts/{id}', {\n body: { title: 'updated title' },\n params: { id: posts[0].id },\n});\n```\n\ncall `courier.setheaders({ authorization: 'bearer token' })` to update subsequent calls.\n\n## interceptors\n\ninterceptors apply to http and streaming requests. register a policy once, then remove it when its containing\nscope ends.\n\n```ts\nimport { withbearerauth, withrequestid } from '@vielzeug/courier';\n\nconst removeauth = courier.use(withbearerauth(async () => sessionstorage.getitem('access token') ?? ''));\nconst removerequestid = courier.use(withrequestid());\n\nremoverequestid();\nremoveauth();\n```\n\nuse `withlogging()` with an explicit `logger` function to log requests during local development. `withlogging()`\nincludes full urls, so sanitize query values before persistent logging.\n\n```ts\nimport { withlogging } from '@vielzeug/courier';\n\ncourier.use(withlogging({ logger: (msg) => console.log(msg) }));\n```\n\n## cached queries\n\npass a stable key and fetch definition to `queries.fetch()`. the cache owns data, snapshots, subscriptions, and\nin flight deduplication for that key. entries with no subscribers are garbage collected after `gctime` (default\n5 min; `infinity` disables).\n\n```ts\nconst key = ['profile', 1] as const;\nconst definition = {\n key,\n fetch: ({ signal }) => courier.get<{ id: number; name: string }>('/profile/{id}', { params: { id: 1 }, signal }),\n staletime: 60_000,\n};\n\nconst stop = courier.queries.subscribe(key, () => {\n const state = courier.queries.getsnapshot<{ id: number; name: string }>(key);\n if (state?.status === 'success') console.log(state.data.name);\n if (state?.status === 'error') console.error(state.error);\n});\n\nawait courier.queries.fetch(definition);\nstop();\n```\n\n`queries.fetch(definition)` reuses fresh data. pass `{ force: true }` to fetch regardless of freshness.\n`invalidate(prefix, { refetch: true })` marks matching key prefixes stale and refetches them in the background\nin a single call.\n\n## direct mutations\n\nuse `mutate()` for a write operation. pass `invalidatekeys` to invalidate and refetch cache entries after a\nsuccessful write — no manual `invalidate()` + refetch boilerplate. use `onsuccess` for custom cache writes\n(e.g. seeding a created entity). courier never retries writes: retry only operations your application can prove\nidempotent.\n\n```ts\ntype user = { id: number; name: string };\n\nconst created = await courier.mutate<user>({\n request: ({ signal }) => courier.post<user>('/users', { body: { name: 'ada' }, signal }),\n onsuccess: (user, queries) => queries.set(['users', user.id], user),\n invalidatekeys: [['users']],\n});\n\nconsole.log(created.id);\n```\n\npass an external `signal` when caller owns cancellation. keep pending and error ui state in framework that owns\nthat ui.\n\n## server sent events\n\n`events()` returns an abortable `asynciterableiterator`. breaking loop, calling `return()`, aborting a provided\nsignal, or disposing client stops its request immediately. courier sends `accept: text/event stream` and\n`cache control: no cache` by default; pass headers to override either value.\n\n```ts\ntype notification = { text: string };\n\nfor await (const event of courier.events<notification>('/events')) {\n if (event.event !== 'message') continue;\n console.log(event.data.text);\n break;\n}\n```\n\ncourier parses valid json event data and otherwise returns text. it does not reconnect automatically or retain\nsse event ids; application owns reconnect policy.\n\n## http streaming\n\nuse `read()` for text chunks or ndjson records.\n\n```ts\ntype chatchunk = { done: boolean; delta: string };\n\nfor await (const chunk of courier.read<chatchunk>('/chat', {\n body: { prompt: 'explain cached queries.' },\n method: 'post',\n parse: 'ndjson',\n})) {\n console.log(chunk.delta);\n if (chunk.done) break;\n}\n```\n\nstreams have no timeout unless `timeout` is supplied. http, network, timeout, and cancellation failures use\ncourier error classes; starting a stream after disposal throws `courierdisposederror`.\n\n## framework integration\n\ncreate courier at application or route boundary. views read a key snapshot synchronously, subscribe during\ntheir lifecycle, and let framework own rendering state.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, usesyncexternalstore } from 'react';\nimport { createcourier } from '@vielzeug/courier';\nimport type { asyncstate, querydefinition } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nexport function profile({ courier, definition }: { courier: returntype<typeof createcourier>; definition: querydefinition<user> }) {\n const state = usesyncexternalstore(\n (listener) => courier.queries.subscribe(definition.key, listener),\n () => courier.queries.getsnapshot<user>(definition.key),\n () => courier.queries.getsnapshot<user>(definition.key),\n ) as asyncstate<user> | null;\n\n useeffect(() => void courier.queries.fetch(definition), [courier, definition]);\n\n if (!state || state.status === 'loading') return <p>loading...</p>;\n if (state.status === 'error') return <p role=\"alert\">{state.error.message}</p>;\n return <p>{state.data.name}</p>;\n}\n```\n\n```ts [vue 3]\nimport { onmounted, onunmounted, ref } from 'vue';\nimport { createcourier } from '@vielzeug/courier';\nimport type { asyncstate, querydefinition } from '@vielzeug/courier';\n\ntype user = { id: number; name: string };\n\nexport function useprofile(courier: returntype<typeof createcourier>, definition: querydefinition<user>) {\n const state = ref<asyncstate<user> | null>(courier.queries.getsnapshot(definition.key));\n const unsubscribe = courier.queries.subscribe(definition.key, () => {\n state.value = courier.queries.getsnapshot(definition.key);\n });\n\n onmounted(() => void courier.queries.fetch(definition));\n onunmounted(unsubscribe);\n\n return { state };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createcourier } from '@vielzeug/courier';\n import type { asyncstate, querydefinition } from '@vielzeug/courier';\n\n type user = { id: number; name: string };\n\n export let courier: returntype<typeof createcourier>;\n export let definition: querydefinition<user>;\n let state: asyncstate<user> | null = courier.queries.getsnapshot(definition.key);\n\n onmount(() => {\n const unsubscribe = courier.queries.subscribe(definition.key, () => (state = courier.queries.getsnapshot(definition.key)));\n void courier.queries.fetch(definition);\n return unsubscribe;\n });\n</script>\n\n{#if state?.status === 'success'}\n <p>{state.data.name}</p>\n{/if}\n```\n\n:::\n\ncourier exposes no framework specific loading or error store. render `asyncstate` in framework that owns view.\n\n## working with other vielzeug libraries\n\n### flux\n\nuse flux when cache snapshots or sse events need filtering, composition, or subscription lifecycle separate from\nui framework. pass cache and query definition to `fromquery()`.\n\n```ts\nimport { fromquery } from '@vielzeug/flux/courier';\n\nconst profile = {\n key: ['profile'] as const,\n fetch: ({ signal }: { signal: abortsignal }) => courier.get<{ id: number; name: string }>('/profile', { signal }),\n};\nconst profile$ = fromquery(courier.queries, profile);\n\nvoid courier.queries.fetch(profile);\n\nconst profilesubscription = profile$.subscribe((state) => console.log(state?.status));\n\nprofilesubscription.unsubscribe();\n```\n\n### ripple\n\nuse a ripple signal when courier data must participate in fine grained reactive state outside a component. mirror\nonly cache snapshot into signal.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst key = ['profile', 1] as const;\nconst profilestate = signal(courier.queries.getsnapshot<{ id: number; name: string }>(key));\nconst unsubscribe = courier.queries.subscribe(key, () => (profilestate.value = courier.queries.getsnapshot(key)));\n\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/profile/{id}', { params: { id: 1 }, signal }),\n});\n\nunsubscribe();\n```\n\n## best practices\n\n create one courier client per application or ssr request scope.\n use stable, complete cache keys for every cached response identity.\n fetch through `queries.fetch()` when work should deduplicate and cache.\n use `invalidatekeys` on mutations to refetch affected cache entries in one step.\n keep retries outside mutations until operation idempotency is proven.\n dispose only at final application or request boundary.\n keep credentials out of urls when using logging interceptors.\n",
319
+ "examples": " \ntitle: courier — examples\ndescription: practical examples and recipes for courier.\n \n\n## examples\n\n [authentication](./examples/authentication.md)\n [crud operations](./examples/crud operations.md)\n [disposal](./examples/disposal.md)\n [error handling patterns](./examples/error handling patterns.md)\n [file uploads](./examples/file uploads.md)\n [optimistic updates](./examples/optimistic updates.md)\n [polling](./examples/polling.md)\n [real time events](./examples/sse events.md)\n [ai token stream](./examples/ai token stream.md)\n"
320
+ },
321
+ "examples": [
322
+ {
323
+ "id": "create-courier",
324
+ "text": "createcourier unified client import { createcourier, withlogging } from '@vielzeug/courier'\n\nconst fetch: typeof globalthis.fetch = async (_url, init) =>\n new response(json.stringify(init?.method === 'post' ? { id: 3, name: 'courier' } : { id: 1, name: 'ada' }), {\n headers: { 'content type': 'application/json' },\n })\n\nconst courier = createcourier({\n baseurl: 'https://api.example.com',\n fetch,\n timeout: 8_000,\n query: { staletime: 10_000 },\n})\n\ncourier.use(withlogging({ logger: (msg) => console.log(msg) }))\n\nconst user = await courier.get('/users/1')\nconsole.log('user:', user.name)\n\nconst key = ['users', 1]\nawait courier.queries.fetch({\n key,\n fetch: ({ signal }) => courier.get('/users/1', { signal }),\n})\nconsole.log('cached user:', courier.queries.getsnapshot(key)?.data.name)\n\nconst created = await courier.mutate({\n request: ({ signal }) => courier.post('/users', { body: { name: 'courier' }, signal }),\n invalidatekeys: [['users']],\n})\n\nconsole.log('created id:', created.id)\ncourier.dispose()\nconsole.log('✓ client disposed')"
325
+ },
326
+ {
327
+ "id": "query-handle",
328
+ "text": "querycache cached async data import { createcourier } from '@vielzeug/courier'\n\nconst fetch: typeof globalthis.fetch = async () =>\n new response(json.stringify({ id: 1, name: 'ada' }), { headers: { 'content type': 'application/json' } })\nconst courier = createcourier({ baseurl: 'https://api.example.com', fetch })\nconst key = ['users', 1]\nconst user = {\n key,\n fetch: ({ signal }: { signal: abortsignal }) => courier.get('/users/1', { signal }),\n staletime: 30_000,\n}\n\ncourier.queries.subscribe(key, () => {\n const state = courier.queries.getsnapshot(key)\n console.log('state:', state?.status, '| fetching:', state?.isfetching)\n})\n\nawait courier.queries.fetch(user)\nconsole.log('name:', courier.queries.getsnapshot(key)?.data.name)\n\ncourier.queries.invalidate(key)\nawait courier.queries.fetch(user, { force: true })\n\nconsole.log('final snapshot:', courier.queries.getsnapshot(key))"
329
+ },
330
+ {
331
+ "id": "stream-cancellation",
332
+ "text": "streamcancellation abortable iteration import { createcourier } from '@vielzeug/courier'\n\n// breaking a stream loop aborts its active request immediately.\n const fetch: typeof globalthis.fetch = async () =>\n new response('{\"id\":1,\"message\":\"first record\"}\\n{\"id\":2,\"message\":\"second record\"}\\n')\n const courier = createcourier({ baseurl: 'https://api.example.com', fetch })\nconst iterator = courier.read('/chat', { body: { prompt: 'show one stream record.' }, parse: 'ndjson' })\nconst records = []\n\nfor await (const record of iterator) {\n records.push(record)\n console.log('first record:', record)\n break\n}\n\nconsole.log('stopped stream after', records.length, 'record')\nconsole.log('records:', records)"
333
+ }
334
+ ],
335
+ "exports": "createcourier couriererror courierhttperror couriernetworkerror couriertimeouterror courieraborterror courierschemavalidationerror withbearerauth withrequestid withlogging",
336
+ "keywords": "http client fetch caching queries mutations sse streaming interceptors",
337
+ "name": "@vielzeug/courier",
338
+ "related": "flux ripple spell",
339
+ "slug": "courier",
340
+ "source": "export { type courier, type courieroptions, createcourier } from './courier';\nexport {\n courieraborterror,\n courierdisposederror,\n couriererror,\n courierhttperror,\n couriernetworkerror,\n courierparseerror,\n courierschemavalidationerror,\n couriertimeouterror,\n} from './errors';\nexport { withbearerauth, withlogging, withrequestid } from './interceptors';\nexport type { streamevent, streamoptions } from './stream';\nexport type { fetchcontext, interceptor, transportoptions } from './transport';\nexport type {\n asyncstate,\n mutationcontext,\n mutationoptions,\n querycache,\n querycontext,\n querydefinition,\n querykey,\n querykeyatom,\n unsubscribe,\n} from './types';\nexport type { httprequestconfig as requestconfig, params } from './url';\n"
341
+ },
342
+ {
343
+ "category": "ui interaction",
344
+ "description": "framework agnostic drag and drop. drop zones with mime filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.",
345
+ "docs": {
346
+ "index": " \ntitle: dnd — drag and drop primitives for the dom\ndescription: framework agnostic drag and drop. drop zones with mime filtering, sortable lists with drag handles, and explicit connected scopes — zero dependencies.\npackage: dnd\ncategory: ui interaction\nkeywords: [drag drop, sortable, file upload, drop zone, dnd, reorder]\nrelated: [ore, scroll, refine]\nexports: [createdropzone, createsortable, createsortablescope, applyreorder, matchesaccept]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"dnd\" />\n\n## why dnd?\n\nthe html5 drag & drop api requires careful counter tracking to avoid hover state flicker, has no mime type pre filtering, and provides no sortable list abstraction.\n\n```ts\n// before — raw html5 drag & drop\nlet entercount = 0;\ndropzone.addeventlistener('dragenter', () => {\n entercount++;\n dropzone.classlist.add('over');\n});\ndropzone.addeventlistener('dragleave', () => {\n if ( entercount === 0) dropzone.classlist.remove('over');\n});\ndropzone.addeventlistener('dragover', (e) => e.preventdefault());\ndropzone.addeventlistener('drop', (e) => {\n e.preventdefault();\n entercount = 0;\n const files = [...e.datatransfer!.files];\n if (!files.every((f) => f.type.startswith('image/'))) return showerror('images only');\n uploadfiles(files);\n});\n\n// after — dnd\nimport { createdropzone } from '@vielzeug/dnd';\nconst zone = createdropzone({\n element: dropzone,\n accept: ['image/*'],\n ondrop: (files) => uploadfiles(files),\n ondroprejected: (files) => showerror(`${files.length} file(s) not accepted`),\n onhoverchange: (hovered) => dropzone.classlist.toggle('over', hovered),\n});\n```\n\n| feature | dnd | sortablejs | dnd kit |\n| | | | |\n| bundle size | <packageinfo package=\"dnd\" type=\"size\" /> | ~15 kb | ~30 kb |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| mime type filtering | <ore icon name=\"check\" size=\"16\"></ore icon> pre validated | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| counter based hover | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | n/a |\n| sortable lists | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| drag handles | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| `using` support | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| touch support | <ore icon name=\"check\" size=\"16\"></ore icon> scoped opt in | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use dnd when** you need reliable file drop zones with mime filtering or sortable lists in a framework agnostic environment.\n\n**consider dnd kit** if you are building a react app and need complex multi container drag interactions or accessibility first sortable trees.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/dnd\n```\n\n```sh [npm]\nnpm install @vielzeug/dnd\n```\n\n```sh [yarn]\nyarn add @vielzeug/dnd\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createdropzone, createsortable } from '@vielzeug/dnd';\n\n// file drop zone — with async validation and paste support\nconst dropzone = document.getelementbyid('dropzone')!;\n\nusing zone = createdropzone({\n element: dropzone,\n accept: ['image/*', '.pdf'],\n paste: true,\n onvalidate: (files) => files.every((file) => file.size <= 5_000_000),\n ondrop: (files) => console.log('upload', files),\n ondroprejected: (files) => {\n console.warn(`${files.length} file(s) rejected`);\n },\n onhoverchange: (hovered) => {\n dropzone.classlist.toggle('drag over', hovered);\n },\n});\n\n// sortable list — with revert support for optimistic updates\nlet currentorder = ['a', 'b', 'c'];\n\nusing sortable = createsortable({\n element: document.getelementbyid('list')!,\n keyboard: true,\n onbeforereorder: (from, to) => {\n // record positions here before the dom commits (for flip animations)\n },\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n currentorder = ids;\n setrevert(() => {\n currentorder = prev;\n });\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n **counter based hover state** — `onhoverchange` stays accurate when dragging over child elements; hover only activates when the drag payload passes the `accept` filter, with symmetric enter/leave pairing to prevent flicker\n **mime type pre validation** — queries `datatransfer.items` during drag to set `dropeffect='none'` before the drop; confirmed against `file.type` on drop\n **flexible accept patterns** — mime types (`image/png`), wildcards (`image/*`), and file extensions (`.pdf`)\n **`maxfiles` limit** — cap the number of accepted files per drop; excess files are forwarded to `ondroprejected`\n **`onvalidate` async gating** — optional cancellable async step after type filtering; `zone.validating` remains `true` until every pending validation settles\n **clipboard paste support** — `paste: true` routes pasted files through the same `accept`, `maxfiles`, and `onvalidate` pipeline; `onpaste` provides a separate callback; paste rejections are forwarded to `ondroprejected` with the same `(files: file[]) => void` signature as drop rejections\n **`ondroprejected`** — separate callback for files that didn't match `accept`, exceeded `maxfiles`, or were rejected by `onvalidate`; event type reflects whether the rejection came from a drop or a paste\n **sortable lists** — reorders dom children with a placeholder indicator; fires `onreorder` only when the order actually changes\n **drag handles** — scope dragging to a child selector via `handle`; whole item is draggable when omitted\n **custom drag preview** — pass an element or a `(id, item, event) => element | null` factory; control hotspot with `dragimageoffset`\n **`onbeforereorder` flip hook** — fires before commit for both drag and keyboard moves; pair it with [`capturelayout()`](/necromancer/api.md#capturelayout) for lifecycle owned flip animation\n **`sortable.revert()`** — register a revert function via `event.setrevert(fn)` inside `onreorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure\n **boundary safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventdefault`, so the browser can scroll the page normally\n **transactional connected scopes** — one `onmove` callback receives each cross list transfer with both final orders\n **scoped touch support** — `createsortablescope({ touch: true })` handles only items registered to that scope and uses an inert outline preview\n **explicit dom sync** — call `sortable.sync()` after dom mutations instead of relying on hidden observers\n **`[symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup\n **reactive friendly options** — `disabled` is re read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone\n **zero dependencies** — <packageinfo package=\"dnd\" type=\"size\" /> gzipped, <packageinfo package=\"dnd\" type=\"dependencies\" /> dependencies\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 [orbit](/orbit/) — floating element positioning; use alongside dnd to anchor drag previews and drop zone indicators to precise positions\n [ore](/ore/) — web component authoring framework; build draggable custom elements with dnd's pointer event primitives\n [refine](/refine/) — accessible web components; dnd powers the drag and drop inside refine's sortable list and kanban components\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
347
+ "api": " \ntitle: dnd — api reference\ndescription: complete api reference for dnd.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createdropzone()` | create a typed drop zone controller | sync | dispose the controller during teardown |\n| `createsortable()` | add sortable drag and drop behavior to lists | sync | provide stable item identity for reorder operations |\n| `createsortablescope()` | create a shared scope for connected lists | sync | each set of connected containers needs its own scope instance |\n| `applyreorder()` | apply ordered ids to data arrays | sync | unknown ids are skipped; non mentioned items are appended |\n| `dropzoneoptions.accept` | filter file types before processing | sync | mismatch between mime and extension can reject files unexpectedly |\n| `dropzoneoptions.maxfiles` | cap accepted files per drop | sync | excess accepted files become rejected; `ondroprejected` is called |\n| `matchesaccept()` | test a single `file` against an accept list | sync | extension patterns are case insensitive; empty list accepts all |\n| `dnderror` | base class for dnd errors | sync | use `dnderror.is()` to narrow unknown errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/dnd` | main exports and types |\n\n## types\n\n### `disposable`\n\n```ts\ninterface disposable {\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n [symbol.dispose](): void;\n}\n```\n\n### `dropzoneoptions`\n\n```ts\ninterface dropzoneoptions {\n element: htmlelement;\n accept?: string[];\n maxfiles?: number;\n onvalidate?: (files: file[], context: dropvalidationcontext) => boolean | promise<boolean>;\n disabled?: boolean;\n dropeffect?: datatransfer['dropeffect'];\n ondrop?: (files: file[]) => void;\n ondroprejected?: (files: file[]) => void;\n onhoverchange?: (hovered: boolean) => void;\n onvalidatingchange?: (validating: boolean) => void;\n paste?: boolean;\n onpaste?: (files: file[]) => void;\n}\n```\n\n### `dropzone`\n\n```ts\ninterface dropzone extends disposable {\n readonly hovered: boolean;\n readonly validating: boolean;\n}\n```\n\n### `dropvalidationcontext`\n\n```ts\ninterface dropvalidationcontext {\n readonly signal: abortsignal;\n}\n```\n\n### `sortableoptions`\n\n```ts\ninterface sortableoptions {\n element: htmlelement;\n getkey: (element: htmlelement) => string;\n scope?: sortablescope;\n handle?: string;\n keyboard?: boolean;\n axis?: 'vertical' | 'horizontal';\n autoscroll?: boolean | autoscrolloptions;\n dragimage?: htmlelement | ((id: string, item: htmlelement, event: dragevent) => htmlelement | null | undefined);\n dragimageoffset?: [number, number];\n placeholderclass?: string;\n disabled?: boolean;\n ondragstart?: (id: string, event: dragevent) => void;\n ondragend?: (id: string, event: dragevent) => void;\n onbeforereorder?: (from: string[], to: string[]) => void;\n onreorder?: (event: reorderevent) => void;\n}\n```\n\n### `autoscrolloptions`\n\n```ts\ninterface autoscrolloptions {\n edgethreshold?: number;\n speed?: number;\n container?: boolean;\n viewport?: boolean;\n}\n```\n\n### `reorderevent`\n\n```ts\ninterface reorderevent {\n ids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortable`\n\n```ts\ninterface sortable extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n sync(): void;\n}\n```\n\n### `sortablescope`\n\n```ts\ninterface sortablescope extends disposable {\n readonly isdragging: boolean;\n revert(): void;\n}\n```\n\n### `sortablescopeoptions`\n\n```ts\ninterface sortablescopeoptions {\n onmove?: (event: sortablemoveevent) => void;\n touch?: boolean | sortabletouchoptions;\n}\n```\n\n### `sortablemoveevent`\n\n```ts\ninterface sortablemoveevent {\n readonly itemid: string;\n readonly source: htmlelement;\n readonly sourceids: string[];\n readonly target: htmlelement;\n readonly targetids: string[];\n setrevert(fn: () => void): void;\n}\n```\n\n### `sortabletouchoptions`\n\n```ts\ninterface sortabletouchoptions {\n preview?: false | ((item: htmlelement) => htmlelement | null);\n}\n```\n\n`preview` returns a template that dnd clones before mounting it as a transient touch preview, so returning an element from the sortable item does not reparent or remove caller owned dom. return `false` to disable the preview.\n\n## `createdropzone()`\n\n```ts\ndeclare function createdropzone(options: dropzoneoptions): dropzone;\n```\n\nattaches drag and drop file handling to a dom element. returns a `dropzone` handle.\n\n| option | type | default | description |\n| | | | |\n| `element` | `htmlelement` | — | **required.** the element to attach drag listeners to. |\n| `accept` | `string[]` | `[]` | accepted file types. empty array accepts everything. each entry is a mime type (`'image/png'`), mime wildcard (`'image/*'`), or file extension (`'.pdf'`). |\n| `maxfiles` | `number` | — | maximum files accepted per drop. files beyond this limit are passed to `ondroprejected`. when omitted there is no limit. |\n| `onvalidate` | `(files, { signal }) => boolean \\| promise<boolean>` | — | optional async gating step. return or resolve `false` to reject all accepted files. `validating` remains true until every operation settles; `signal` aborts on disposal. |\n| `disabled` | `boolean` | — | when `true`, all drag and paste events are ignored. a disabled zone does not call `preventdefault` on `dragenter`, `dragover`, `drop`, or `paste`, so underlying elements (text editors, etc.) receive them normally. |\n| `dropeffect` | `'copy' \\| 'move' \\| 'link' \\| 'none'` | `'copy'` | the `dropeffect` set on `datatransfer` during `dragover`. controls the cursor indicator. |\n| `ondrop` | `(files: file[]) => void` | — | called with accepted files only. not called if all dropped files are rejected. also receives paste events when `paste: true` and `onpaste` is omitted. |\n| `ondroprejected` | `(files: file[]) => void` | — | called with files that did not match `accept`, exceeded `maxfiles`, or were rejected by `onvalidate`. |\n| `onhoverchange` | `(hovered: boolean) => void` | — | called when hover state toggles. use this callback for drag over styling. |\n| `onvalidatingchange` | `(validating: boolean) => void` | — | called whenever the aggregate async validation state changes. |\n| `paste` | `boolean` | `false` | when `true`, attaches a `paste` listener to `window`. pasted files run through the same `accept`, `maxfiles`, and `onvalidate` pipeline as dropped files. |\n| `onpaste` | `(files: file[]) => void` | — | called when files are pasted from the clipboard. falls back to `ondrop` when omitted. only active when `paste: true`. |\n\n**returns:** `dropzone`\n\nnotes:\n\n extension accept patterns are approximate during pre check (`datatransferitem` has no filename); exact filtering is applied at drop time.\n hover state (`hovered`) only becomes `true` when the dragged payload passes the `accept` filter. drags carrying rejected file types enter and leave the zone without triggering `onhoverchange`.\n hover state is reset on element drop and also global `window` `drop`/`dragend` to avoid stuck hover state when drags leave the viewport.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*', '.pdf'],\n ondrop: (files) => {\n upload(files);\n },\n ondroprejected: (files) => {\n showerror(`${files.length} rejected`);\n },\n onhoverchange: (hovered) => {\n dropel.classlist.toggle('drag over', hovered);\n },\n});\n```\n\n## `dropzone` interface\n\n### `zone.hovered`\n\n`readonly hovered: boolean`\n\n`true` when a drag is currently over the zone. updated synchronously by the internal counter — safe to read at any time.\n\n### `zone.validating`\n\n`readonly validating: boolean`\n\n`true` while an `onvalidate` promise is pending. use this to render a loading indicator between file selection and the acceptance/rejection callbacks firing.\n\n```ts\nconsole.log(zone.validating); // true between drop and onvalidate resolution\n```\n\n### `zone.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called. safe to read at any time.\n\n### `zone.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called. use it to cancel in flight requests tied to the zone's lifetime.\n\n### `zone.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the element, resets the drag counter and hover state, and clears the `hovered` flag. idempotent — safe to call multiple times.\n\n```ts\nzone.dispose();\n```\n\n### `zone[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`. called automatically when used with the `using` keyword.\n\n```ts\n{\n using zone = createdropzone({ element: dropel, ondrop: handlefiles });\n} // zone.dispose() runs here\n```\n\n## `createsortable()`\n\n```ts\ndeclare function createsortable(options: sortableoptions): sortable;\n```\n\nmakes the direct children of a container element reorderable via drag. returns a `sortable` handle.\n\n`createsortable` adds drag and keyboard defaults only when callers have not already supplied semantics. every changed attribute and inline style is restored to its prior value on disposal.\n\n `element`: `htmlelement`, required. the container whose children become sortable.\n `getkey`: `(element: htmlelement) => string`, required. maps each item element to its stable string identity. children for which `getkey` returns a falsy value are skipped.\n `scope`: `sortablescope`, default private scope. connects sortable lists explicitly; containers only exchange items when they share the same scope instance.\n `handle`: `string`. css selector for a drag handle inside each item. when omitted, the whole item is draggable.\n `keyboard`: `boolean`, default `true`. enables keyboard reordering with arrow keys plus `home` and `end`.\n `axis`: `'vertical' | 'horizontal'`, default `'vertical'`. controls midpoint calculation for placeholder insertion.\n `autoscroll`: `boolean | autoscrolloptions`, default `true`. scrolls the container near its edges; enable viewport scrolling with `autoscroll.viewport`.\n `dragimage`: `htmlelement | ((id, item, event) => htmlelement | null | undefined)`. custom native drag preview passed to `datatransfer.setdragimage()`. a `null` or `undefined` return skips `setdragimage` entirely.\n `dragimageoffset`: `[number, number]`, default `[0, 0]`. the `[x, y]` hotspot offset passed to `setdragimage`. controls which point of the preview image follows the cursor.\n `placeholderclass`: `string`, default `'dnd placeholder'`. css class applied to the generated placeholder element.\n `disabled`: `boolean`. blocks drag interactions. if a list becomes disabled mid drag, dnd cancels the drag and restores the original order.\n `ondragstart`: `(id: string, event: dragevent) => void`. called when a drag starts.\n `ondragend`: `(id: string, event: dragevent) => void`. called when a drag ends, whether completed or cancelled.\n `onbeforereorder`: `(from: string[], to: string[]) => void`. called with the before/after order snapshots just before a successful reorder commits — for both drag and keyboard. items are still in their pre commit positions at the time of the call, making it ideal for [`capturelayout()`](/necromancer/api.md#capturelayout) setup.\n `onreorder`: `(event: reorderevent) => void`. called after a successful reorder (drag or keyboard), only when the order changed. use `event.setrevert(fn)` to register a revert function that `sortable.revert()` will invoke.\n\n**returns:** `sortable`\n\n```ts\nconst boardscope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => savemove(itemid, sourceids, targetids),\n touch: true,\n});\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id!,\n handle: '.drag handle',\n ondragstart: (id) => {\n listel.classlist.add('sorting');\n },\n ondragend: (id) => {\n listel.classlist.remove('sorting');\n },\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n saveorder(ids);\n setrevert(() => saveorder(prev));\n },\n scope: boardscope,\n});\n```\n\n### `createsortablescope()`\n\n```ts\ndeclare function createsortablescope(options?: sortablescopeoptions): sortablescope;\n```\n\nuse one scope per connected set of containers. `onmove` fires once for cross list moves with both final orders; local reorders continue to call the sortable's `onreorder`.\n\n| parameter | type | description |\n| | | |\n| `options` | `sortablescopeoptions` | optional cross list move callback and scope owned touch configuration |\n\n**returns:** `sortablescope`.\n\n```ts\nimport { createsortablescope } from '@vielzeug/dnd';\n\nconst scope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => {\n persistmove(itemid, sourceids, targetids);\n },\n touch: true,\n});\n```\n\n## `sortable` interface\n\n### `sortable.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while an item drag is in progress.\n\n### `sortable.revert()`\n\n`revert(): void`\n\ncalls the revert function registered via `setrevert` in the last `onreorder` invocation (if any) and clears it. a no op when no revert function was registered or it has already been consumed. works for both drag based and keyboard based reorders.\n\nonly the most recent reorder can be reverted — a new reorder overwrites the stored function.\n\n```ts\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n setorder(ids);\n setrevert(() => setorder(prev)); // ← enable revert\n },\n});\n\n// on server error:\ntry {\n await api.saveorder(ids);\n} catch {\n sortable.revert();\n}\n```\n\n### `sortable.sync()`\n\n`sync(): void`\n\nre applies `draggable`, `role`, and handle attributes after dom mutations. call it after adding, removing, or replacing sortable children.\n\n### `sortable.disposed`\n\n`readonly disposed: boolean`\n\n`true` once `dispose()` has been called.\n\n### `sortable.disposalsignal`\n\n`readonly disposalsignal: abortsignal`\n\nan `abortsignal` that fires when `dispose()` is called.\n\n### `sortable.dispose()`\n\n`dispose(): void`\n\nremoves all event listeners from the container, strips sortable attributes from items and handles, and cancels any in progress drag by restoring the original order. idempotent — safe to call multiple times.\n\n### `sortable[symbol.dispose]()`\n\n`[symbol.dispose](): void`\n\nalias for `dispose()`.\n\n## `sortablescope` interface\n\n### `scope.isdragging`\n\n`readonly isdragging: boolean`\n\n`true` while any sortable registered to the scope is dragging.\n\n### `scope.revert()`\n\n`revert(): void`\n\ncalls and clears the rollback registered with `sortablemoveevent.setrevert()` for the latest cross list move. it is a no op when no rollback is registered.\n\n### `scope.dispose()`\n\n`dispose(): void`\n\ndisposes scope owned touch input and prevents registered lists from participating in future connected moves.\n\n## dom attributes\n\ndnd reads and writes the following dom attributes:\n\n `data dnd item`: internal marker applied by `createsortable` to children that return a truthy key from `getkey`. restored on `dispose()`.\n `draggable`, roles, tabindex, and `touchaction`: managed only as needed and restored to their exact prior values on `dispose()`.\n `data dragging`: set during drag, removed on `dragend` or `dispose()`. use it as your styling hook for drag state.\n `data dnd handle`: internal marker set by `createsortable` and `sortable.sync()`, removed by `dispose()`. lets dnd clean up only the handle attributes it applied.\n `aria hidden=\"true\"`: set on placeholder creation and removed with the placeholder. applied to the `.dnd placeholder` element.\n `style.touchaction = 'none'` (inline style): set by `createsortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), then restored on `dispose()`.\n\n## css classes\n\n| class | applied to | when |\n| | | |\n| `dnd placeholder` | `<div>` inserted by sortable | while an item is being dragged, in the placeholder's position |\n\n## `matchesaccept()`\n\n```ts\ndeclare function matchesaccept(file: file, accept: string[]): boolean;\n```\n\ntests whether a `file` matches an accept pattern list. each pattern can be:\n\n a mime type: `'image/png'`\n a mime wildcard: `'image/*'`\n a file extension: `'.pdf'`\n\nan empty list accepts everything. extension matching is case insensitive.\n\n**returns:** `true` when the file matches at least one pattern, or when `accept` is empty.\n\n```ts\nimport { matchesaccept } from '@vielzeug/dnd';\n\nmatchesaccept(file, ['image/*', '.pdf']); // true or false\n```\n\n## `applyreorder()`\n\n```ts\ndeclare function applyreorder<t>(items: t[], ids: string[], getkey: (item: t) => string): t[];\n```\n\napplies a dom reorder result (`orderedids`) to your backing array.\n\n ids missing from `items` are ignored.\n items not listed in `ids` are appended in original order.\n duplicate ids in `ids` — first occurrence wins, later occurrences are ignored.\n\n**returns:** a new array ordered by `ids`, with omitted items appended in their original order.\n\n```ts\nconst next = applyreorder(items, orderedids, (item) => item.id);\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `dnderror` | base class for package errors | `dnderror.is(error)` |\n| `dndscopeerror` | a sortable receives a scope not created by `createsortablescope()` | — |\n",
348
+ "usage": " \ntitle: dnd — usage guide\ndescription: drop zones, sortable lists, explicit connected scopes, keyboard sorting, and cleanup patterns with dnd.\n \n\n[[toc]]\n\n## basic usage\n\n`createdropzone` attaches drag and drop behavior to any dom element and keeps hover state stable with a counter.\n\n```ts\nimport { createdropzone } from '@vielzeug/dnd';\n\nconst dropzone = document.getelementbyid('dropzone')!;\n\nconst zone = createdropzone({\n element: dropzone,\n ondrop: (files) => {\n console.log('accepted files:', files);\n },\n});\n```\n\n### accept filtering\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*', '.pdf', 'application/json'],\n ondrop: (files) => {\n // accepted files only\n },\n ondroprejected: (files) => {\n showtoast(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nthe `accept` list is read at drop time, so mutating the array dynamically adjusts what is accepted for the next drop.\n\n### hover state\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n onhoverchange: (hovered) => {\n dropel.classlist.toggle('drag over', hovered);\n },\n});\n```\n\nread zone state imperatively:\n\n```ts\nconsole.log(zone.hovered);\nconsole.log(zone.validating);\n```\n\n### drop effect\n\n```ts\ncreatedropzone({\n element: dropel,\n dropeffect: 'move',\n ondrop: (files) => {\n // ...\n },\n});\n```\n\n### disabled state\n\n```ts\nconst options = { disabled: false, element: dropel, ondrop: handlefiles };\nconst zone = createdropzone(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = isreadonly;\n```\n\n### file limit\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*'],\n maxfiles: 5,\n ondrop: (files) => {\n // 1 5 accepted files\n },\n ondroprejected: (files) => {\n showtoast(`only 5 files at a time. ${files.length} were ignored.`);\n },\n});\n```\n\n### cleanup\n\n```ts\nzone.dispose();\n// or:\nusing zone = createdropzone({ element: dropel, ondrop: handlefiles });\n```\n\n### async validation\n\ngate drops behind an async check with `onvalidate`. the zone remains `validating: true` until every pending validation settles, and disposal aborts each validation signal.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*'],\n onvalidate: async (files, { signal }) => {\n const ok = await checkserverquota(files, { signal });\n return ok; // false → all files forwarded to ondroprejected\n },\n ondrop: (files) => uploadfiles(files),\n ondroprejected: (files) => showerror('quota exceeded'),\n});\n\n// show a spinner while checking\nconsole.log(zone.validating); // true during pending check\n```\n\na synchronous boolean return skips the microtask queue entirely:\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n onvalidate: (files) => files.every((f) => f.size < 5_000_000), // sync\n ondrop: handlefiles,\n});\n```\n\n### clipboard paste\n\nset `paste: true` to accept files pasted from the clipboard. the same `accept`, `maxfiles`, and `onvalidate` pipeline applies.\n\n```ts\nconst zone = createdropzone({\n element: dropel,\n paste: true,\n accept: ['image/*'],\n onpaste: (files) => {\n uploadfiles(files);\n },\n ondroprejected: (files) => {\n showerror(`${files.length} file(s) not accepted`);\n },\n});\n```\n\nwhen `onpaste` is omitted, accepted pasted files fall through to `ondrop`.\n\n## sortable\n\n`createsortable` makes direct children of a container reorderable via drag.\n\n### setup\n\n```html\n<ul id=\"task list\">\n <li data sort id=\"task 1\">design</li>\n <li data sort id=\"task 2\">develop</li>\n <li data sort id=\"task 3\">review</li>\n</ul>\n```\n\n```ts\nconst sortable = createsortable({\n element: document.getelementbyid('task list')!,\n getkey: (el) => el.dataset.sortid!,\n axis: 'vertical',\n onreorder: ({ ids }) => {\n savetaskorder(ids);\n },\n});\n```\n\ndnd automatically sets:\n\n `draggable=\"true\"` on sortable nodes (or handles)\n `role=\"listitem\"` on each item\n `role=\"list\"` on the container\n `tabindex=\"0\"` on each item for keyboard reordering\n\n### drag handles\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n handle: '.drag handle',\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### keyboard reordering\n\nfocus an item and use arrow keys to move it. `home` and `end` move to the boundary positions.\n\nwhen an item is already at the first or last position, the boundary key press is not consumed — the browser handles it normally (for example, scrolling the page). only keys that actually move an item call `preventdefault`.\n\n### connected lists\n\ncreate a shared scope when items should move between containers:\n\n```ts\nconst boardscope = createsortablescope({\n onmove: ({ itemid, sourceids, targetids }) => {\n persistmove(itemid, sourceids, targetids);\n },\n touch: true,\n});\n\ncreatesortable({\n element: todoel,\n getkey: (el) => el.dataset.sortid!,\n scope: boardscope,\n});\ncreatesortable({\n element: doneel,\n getkey: (el) => el.dataset.sortid!,\n scope: boardscope,\n});\n```\n\n### auto scroll and drag preview\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n autoscroll: { edgethreshold: 40, speed: 24, viewport: true },\n dragimage: (id, item) => item,\n dragimageoffset: [8, 8],\n});\n```\n\nviewport scrolling is opt in. container scrolling stays enabled by default.\n\n### lifecycle hooks\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n ondragstart: (id) => {\n listel.classlist.add('sorting');\n },\n ondragend: (id) => {\n listel.classlist.remove('sorting');\n },\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### custom identity function\n\n```ts\ncreatesortable({\n element: listel,\n getkey: (el) => el.getattribute('data id')!,\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### dynamic lists\n\ncall `sortable.sync()` after adding, removing, or replacing sortable items.\n\n```ts\nconst item = document.createelement('li');\nitem.dataset.sortid = 'task 4';\nitem.textcontent = 'deploy';\nlistel.appendchild(item);\nsortable.sync();\n```\n\n### disabled state\n\n```ts\nimport { createsortable, type sortableoptions } from '@vielzeug/dnd';\n\nconst options: sortableoptions = {\n disabled: false,\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => saveorder(ids),\n};\nconst sortable = createsortable(options);\n\n// options.disabled is read live on each event — mutate to toggle:\noptions.disabled = islocked;\n```\n\n### placeholder styling\n\n```css\n.dnd placeholder {\n background: var( color primary 50);\n border: 2px dashed var( color primary 300);\n border radius: 4px;\n box sizing: border box;\n}\n\n[data dragging] {\n opacity: 0.35;\n box shadow: 0 4px 12px rgba(0, 0, 0, 0.15);\n}\n```\n\n### mapping dom order back to data\n\n```ts\nimport { applyreorder, createsortable } from '@vielzeug/dnd';\n\nlet items = [\n { id: 'task 1', title: 'design' },\n { id: 'task 2', title: 'develop' },\n { id: 'task 3', title: 'review' },\n];\n\ncreatesortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n items = applyreorder(items, ids, (item) => item.id);\n },\n});\n```\n\n### cleanup\n\n```ts\nsortable.dispose();\n// or:\nusing sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => saveorder(ids),\n});\n```\n\n### flip animation hook\n\n`onbeforereorder` fires just before the dom reorder commits, for both drag and keyboard moves. pair it with [`capturelayout()`](/necromancer/api.md#capturelayout) to animate the resulting layout without managing rectangles, transforms, or animation frames yourself.\n\n```ts\nimport { capturelayout, type layouttransition } from '@vielzeug/necromancer';\n\nlet layout: layouttransition | undefined;\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onbeforereorder: () => {\n layout = capturelayout(listel.queryselectorall('[data sort id]'), {\n getkey: (el) => el.dataset.sortid!,\n });\n },\n onreorder: ({ ids }) => {\n saveorder(ids); // commit a framework render here when needed.\n layout?.animate({\n duration: 200,\n easing: 'ease out',\n elements: listel.queryselectorall('[data sort id]'),\n });\n layout = undefined;\n },\n});\n```\n\nif `saveorder()` triggers a render that replaces list items, call `layout?.animate({ elements: committeditems })` after that render commits. when dnd's own reordered elements remain in the dom, call `layout?.animate()` directly. dnd stays dependency free: the application chooses to install and import necromancer when it wants this integration.\n\n### optimistic updates and revert\n\ncall `sortable.revert()` to roll back the most recent reorder. register a revert function via `setrevert` inside `onreorder`.\n\n```ts\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids, setrevert }) => {\n const prev = currentorder;\n setorder(ids); // optimistic update\n setrevert(() => setorder(prev)); // registered for sortable.revert()\n },\n});\n\n// on server error:\ntry {\n await api.saveorder(currentorder);\n} catch {\n sortable.revert();\n}\n```\n\n## touch support\n\nhtml5 drag and drop has no native touch story. enable touch on a sortable scope; it only recognizes items registered to that scope, never unrelated `draggable` elements.\n\n```ts\nimport { createsortable, createsortablescope } from '@vielzeug/dnd';\n\nusing scope = createsortablescope({ touch: true });\nusing sortable = createsortable({ element: listel, getkey: (el) => el.dataset.id!, scope });\n```\n\n### touch preview\n\ntouch uses an inert outline by default, avoiding cloned application dom. provide a preview factory or opt out when your item styling supplies its own feedback.\n\n```ts\nconst scope = createsortablescope({\n touch: {\n // the returned element is cloned before dnd mounts it as a transient preview.\n preview: (item) => item.queryselector<htmlelement>('.drag preview'),\n },\n});\n```\n\n### why draggable items get `touch action: none`\n\n`createsortable` sets `touch action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set). this prevents a mobile browser from treating the initial movement as page scrolling before the scope controller can start the drag.\n\nthis has no effect on mouse/pointer input.\n\n## testing\n\ntest observable callbacks and controller state with your dom test runner. construct the zone in each test, dispatch a real `drop` event, then dispose it during teardown.\n\n```ts\nimport { aftereach, expect, it, vi } from 'vitest';\nimport { createdropzone } from '@vielzeug/dnd';\n\nconst zones: array<{ dispose(): void }> = [];\n\naftereach(() => zones.splice(0).foreach((zone) => zone.dispose()));\n\nit('forwards accepted files', async () => {\n const element = document.createelement('div');\n const ondrop = vi.fn();\n const zone = createdropzone({ element, ondrop });\n zones.push(zone);\n const file = new file(['content'], 'readme.txt', { type: 'text/plain' });\n const event = new event('drop') as dragevent;\n\n object.defineproperty(event, 'datatransfer', { value: { files: [file] } });\n element.dispatchevent(event);\n\n await promise.resolve();\n\n expect(ondrop).tohavebeencalledwith([file]);\n expect(zone.disposed).tobe(false);\n});\n```\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\nimport { createsortable, applyreorder } from '@vielzeug/dnd';\n\nfunction sortablelist({ initialitems }: { initialitems: { id: string; text: string }[] }) {\n const listref = useref<htmlulistelement>(null);\n const items = useref(initialitems);\n\n useeffect(() => {\n const sortable = createsortable({\n element: listref.current!,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n items.current = applyreorder(items.current, ids, (i) => i.id);\n },\n });\n return () => sortable.dispose();\n }, []);\n\n return (\n <ul ref={listref}>\n {initialitems.map((item) => (\n <li key={item.id} data sort id={item.id}>\n {item.text}\n </li>\n ))}\n </ul>\n );\n}\n```\n\n```ts [vue 3]\nimport { ref, onmounted, onunmounted } from 'vue';\nimport { createsortable, applyreorder, type sortable } from '@vielzeug/dnd';\n\nfunction usesortable(items: { id: string; text: string }[]) {\n const listref = ref<htmlelement | null>(null);\n const ordereditems = ref(items);\n let sortable: sortable | null = null;\n\n onmounted(() => {\n sortable = createsortable({\n element: listref.value!,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => {\n ordereditems.value = applyreorder(ordereditems.value, ids, (i) => i.id);\n },\n });\n });\n\n onunmounted(() => sortable?.dispose());\n return { listref, ordereditems };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createsortable, applyreorder } from '@vielzeug/dnd';\n\n export let initialitems: { id: string; text: string }[] = [];\n let items = initialitems;\n let listel: htmlulistelement;\n\n onmount(() => {\n const sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => { items = applyreorder(items, ids, (i) => i.id); },\n });\n return () => sortable.dispose();\n });\n</script>\n\n<ul bind:this={listel}>\n {#each items as item (item.id)}\n <li data sort id={item.id}>{item.text}</li>\n {/each}\n</ul>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ore\n\nuse dnd in custom web components by attaching behavior in component lifecycle hooks.\n\n```ts\nimport { createsortable } from '@vielzeug/dnd';\nimport { define, gethost, html, onmounted } from '@vielzeug/ore';\n\ndefine('task list', {\n setup(_props) {\n const el = gethost();\n\n onmounted(() => {\n const sortable = createsortable({\n element: el,\n getkey: (el) => el.dataset.sortid!,\n onreorder: ({ ids }) => save(ids),\n });\n return () => sortable.dispose();\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## best practices\n\n attach `createdropzone` and `createsortable` after the container element is in the dom — use `onmounted` in component frameworks.\n call `.dispose()` in the cleanup phase of your framework (useeffect return, onunmounted, ondestroy) to prevent memory leaks.\n use `data sort id` attributes that match your data's identity field — do not use dom index as an identifier.\n prefer `applyreorder()` over manual array splicing to keep your data array in sync with dom order.\n use `createsortablescope()` only when items should genuinely move between containers.\n use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.\n test keyboard reordering explicitly — dnd sets `tabindex` on items and supports arrow keys by default.\n enable `touch: true` only on scopes that own touch sortable lists.\n",
349
+ "examples": " \ntitle: dnd — examples\ndescription: practical examples and recipes for dnd.\n \n\n## examples\n\n [sortable list](./examples/sortable list.md)\n [touch enabled sortable list](./examples/touch enabled sortable list.md)\n [file upload drop zone](./examples/file upload drop zone.md)\n [optimistic reorder with revert and flip animation](./examples/optimistic reorder with revert.md)\n [combined sortable with inline editing](./examples/combined sortable with inline editing.md)\n [connected kanban keyboard sorting](./examples/connected kanban keyboard sorting.md)\n [web component with ore](./examples/web component with craft.md)\n [using `using` for scoped cleanup](./examples/using using for scoped cleanup.md)\n"
350
+ },
351
+ "examples": [
352
+ {
353
+ "id": "drop-zone-accept",
354
+ "text": "createdropzone accept filter import { createdropzone } from '@vielzeug/dnd'\n\nconst app = document.createelement('div')\napp.style.csstext = 'display:flex;flex direction:column;gap:12px;align items:flex start;'\ndocument.body.appendchild(app)\n\nconst button = document.createelement('button')\nbutton.type = 'button'\nbutton.style.csstext = 'padding:8px 12px;border:1px solid #d1d5db;border radius:8px;background:#fff;cursor:pointer;font:inherit;'\napp.appendchild(button)\n\nconst dropel = document.createelement('div')\ndropel.style.csstext = 'width:300px;height:200px;border:2px dashed #ccc;border radius:12px;display:flex;flex direction:column;align items:center;justify content:center;gap:8px;background:#fff;transition:border color 120ms ease, background 120ms ease, opacity 120ms ease;'\napp.appendchild(dropel)\n\nconst title = document.createelement('span')\nconst hint = document.createelement('small')\nhint.style.color = '#666'\ndropel.append(title, hint)\n\nconst options = {\n element: dropel,\n accept: ['image/*', '.pdf'],\n disabled: false,\n ondrop: (files) => {\n console.log('accepted files:')\n files.foreach(f => console.log(' ✓', f.name))\n },\n ondroprejected: (files) => {\n console.log('rejected files (wrong type):')\n files.foreach(f => console.log(' ✗', f.name, ' ', f.type || 'unknown'))\n },\n onhoverchange: (hovered) => {\n render(hovered)\n },\n}\n\nconst zone = createdropzone(options)\n\nconst render = (hovered = false) => {\n button.textcontent = options.disabled ? 'enable drop zone' : 'disable drop zone'\n title.textcontent = options.disabled ? 'drop zone disabled' : hovered ? 'release to drop files' : 'drop images or pdfs here'\n hint.textcontent = options.disabled ? 'drops are ignored while disabled' : 'accepted: image/* and .pdf'\n dropel.style.opacity = options.disabled ? '0.6' : '1'\n dropel.style.bordercolor = options.disabled ? '#94a3b8' : hovered ? '#10b981' : '#ccc'\n dropel.style.background = !options.disabled && hovered ? '#ecfdf5' : '#fff'\n}\n\nbutton.addeventlistener('click', () => {\n options.disabled = !options.disabled\n render()\n console.log('disabled:', options.disabled)\n})\n\nrender()\nconsole.log('drop zone ready. current hover state:', zone.hovered)"
355
+ },
356
+ {
357
+ "id": "drop-zone-basic",
358
+ "text": "createdropzone basic import { createdropzone } from '@vielzeug/dnd'\n\nconst dropel = document.createelement('div')\ndropel.id = 'drop zone'\ndropel.style.csstext = 'width:300px;height:200px;border:2px dashed #ccc;display:flex;align items:center;justify content:center;cursor:pointer;'\ndropel.textcontent = 'drop files here'\ndocument.body.appendchild(dropel)\n\nconst zone = createdropzone({\n element: dropel,\n ondrop: (files) => {\n console.log('dropped', files.length, 'file(s):')\n files.foreach(f => console.log(` ${f.name} (${f.type}) ${math.round(f.size / 1024)}kb`))\n },\n onhoverchange: (hovered) => {\n dropel.style.bordercolor = hovered ? '#3b82f6' : '#ccc'\n dropel.style.background = hovered ? '#eff6ff' : ''\n dropel.textcontent = hovered ? 'release to drop!' : 'drop files here'\n },\n})\n\nconsole.log('drop zone created and attached to #drop zone')\nconsole.log('api: zone.hovered =', zone.hovered)\nconsole.log('tip: try dragging files over the drop zone element')"
359
+ },
360
+ {
361
+ "id": "drop-zone-disposal",
362
+ "text": "dropzone — disposed & disposalsignal import { createdropzone } from '@vielzeug/dnd'\n\nconst dropel = document.createelement('div')\ndropel.style.csstext = 'width:300px;height:150px;border:2px dashed #ccc;display:flex;align items:center;justify content:center;'\ndropel.textcontent = 'drop files here'\ndocument.body.appendchild(dropel)\n\nconst zone = createdropzone({\n element: dropel,\n ondrop: (files) => console.log('dropped:', files.map(f => f.name)),\n onhoverchange: (hovered) => {\n dropel.style.bordercolor = hovered ? '#3b82f6' : '#ccc'\n },\n})\n\nconsole.log('zone.disposed:', zone.disposed) // false\nconsole.log('zone.disposalsignal.aborted:', zone.disposalsignal.aborted) // false\n\n// use disposalsignal to cancel an in flight request when the zone is torn down\nconst signal = zone.disposalsignal\nsignal.addeventlistener('abort', () => {\n console.log('disposalsignal fired — zone was disposed')\n})\n\n// dispose after 2 seconds to demonstrate\nsettimeout(() => {\n zone.dispose()\n console.log('zone.disposed:', zone.disposed) // true\n console.log('zone.disposalsignal.aborted:', zone.disposalsignal.aborted) // true\n\n // dispose() is idempotent — calling it again is safe\n zone.dispose()\n console.log('second dispose() call did not throw')\n}, 2000)"
363
+ },
364
+ {
365
+ "id": "drop-zone-matches-accept",
366
+ "text": "matchesaccept accept pattern testing import { matchesaccept } from '@vielzeug/dnd'\n\n// matchesaccept tests a file against an accept pattern list\nconst png = new file([''], 'photo.png', { type: 'image/png' })\nconst pdf = new file([''], 'report.pdf', { type: 'application/pdf' })\nconst txt = new file([''], 'readme.txt', { type: 'text/plain' })\n\nconsole.log(' mime wildcard ')\nconsole.log('image/* matches photo.png:', matchesaccept(png, ['image/*'])) // true\nconsole.log('image/* matches report.pdf:', matchesaccept(pdf, ['image/*'])) // false\n\nconsole.log(' file extension ')\nconsole.log('.pdf matches report.pdf:', matchesaccept(pdf, ['.pdf'])) // true\nconsole.log('.pdf matches report.pdf:', matchesaccept(pdf, ['.pdf'])) // true — case insensitive\nconsole.log('.pdf matches photo.png:', matchesaccept(png, ['.pdf'])) // false\n\nconsole.log(' exact mime type ')\nconsole.log('image/png matches photo.png:', matchesaccept(png, ['image/png'])) // true\nconsole.log('image/jpeg matches photo.png:', matchesaccept(png, ['image/jpeg'])) // false\n\nconsole.log(' empty list accepts everything ')\nconsole.log('[] matches readme.txt:', matchesaccept(txt, [])) // true\n\nconsole.log(' combined list ')\nconst accept = ['image/*', '.pdf']\nconsole.log('combined matches photo.png:', matchesaccept(png, accept)) // true\nconsole.log('combined matches report.pdf:', matchesaccept(pdf, accept)) // true\nconsole.log('combined matches readme.txt:', matchesaccept(txt, accept)) // false"
367
+ },
368
+ {
369
+ "id": "drop-zone-validate",
370
+ "text": "createdropzone async validate import { createdropzone } from '@vielzeug/dnd'\n\nconst app = document.createelement('div')\napp.style.csstext = 'display:flex;flex direction:column;gap:12px;width:320px;'\ndocument.body.appendchild(app)\n\nconst dropel = document.createelement('div')\ndropel.style.csstext = 'height:160px;border:2px dashed #d1d5db;border radius:12px;display:flex;flex direction:column;align items:center;justify content:center;gap:6px;background:#fff;transition:all 120ms ease;'\napp.appendchild(dropel)\n\nconst statusel = document.createelement('div')\nstatusel.style.csstext = 'font size:13px;color:#6b7280;min height:20px;'\napp.appendchild(statusel)\n\nconst title = document.createelement('span')\ntitle.style.csstext = 'font size:14px;color:#374151;'\ntitle.textcontent = 'drop images here'\n\nconst hint = document.createelement('small')\nhint.style.csstext = 'font size:12px;color:#9ca3af;'\nhint.textcontent = 'max 2 mb each — async size check via onvalidate'\n\ndropel.append(title, hint)\n\n// simulate async server side quota check\nconst simulatedvalidate = async (files) => {\n statusel.textcontent = 'checking file size…'\n await new promise(res => settimeout(res, 600))\n const allunder2mb = files.every(f => f.size < 2_097_152)\n return allunder2mb\n}\n\nconst zone = createdropzone({\n element: dropel,\n accept: ['image/*'],\n onvalidate: simulatedvalidate,\n ondrop: (files) => {\n statusel.textcontent = ''\n console.log('accepted:', files.map(f => `${f.name} (${math.round(f.size / 1024)}kb)`).join(', '))\n },\n ondroprejected: (files) => {\n statusel.textcontent = ''\n console.log('rejected:', files.map(f => f.name).join(', '))\n },\n onhoverchange: (hovered) => {\n dropel.style.bordercolor = hovered ? '#3b82f6' : '#d1d5db'\n dropel.style.background = hovered ? '#eff6ff' : '#fff'\n title.textcontent = hovered ? 'release to drop!' : 'drop images here'\n },\n})\n\nconsole.log('drop zone with onvalidate ready')\nconsole.log('zone.validating starts false:', zone.validating)"
371
+ },
372
+ {
373
+ "id": "sortable-connected",
374
+ "text": "createsortablescope connected lists import { applyreorder, createsortable, createsortablescope } from '@vielzeug/dnd'\n\nconst scope = createsortablescope({\n onmove: ({ source, sourceids, target, targetids }) => {\n if (source === todoel) todoitems = applyreorder(todoitems, sourceids, i => i.id)\n if (target === todoel) todoitems = applyreorder(todoitems, targetids, i => i.id)\n if (source === doneel) doneitems = applyreorder(doneitems, sourceids, i => i.id)\n if (target === doneel) doneitems = applyreorder(doneitems, targetids, i => i.id)\n console.log('moved item between lists')\n },\n})\n\nconst wrapper = document.createelement('div')\nwrapper.style.csstext = 'display:flex;gap:24px;align items:flex start;'\ndocument.body.appendchild(wrapper)\n\nlet todoitems = [\n { id: 'task a', title: 'design' },\n { id: 'task b', title: 'develop' },\n { id: 'task c', title: 'review' },\n]\nlet doneitems = [\n { id: 'task d', title: 'planning' },\n]\n\nconst itemstyle = 'padding:8px 12px;background:#fff;border:1px solid #e5e7eb;border radius:6px;cursor:grab;font size:14px;'\nconst liststyle = 'list style:none;padding:8px;margin:0;min height:48px;width:160px;background:#f9fafb;border:2px dashed #d1d5db;border radius:8px;display:flex;flex direction:column;gap:6px;'\n\nfunction makecolumn(label) {\n const col = document.createelement('div')\n col.style.csstext = 'display:flex;flex direction:column;gap:8px;'\n const heading = document.createelement('strong')\n heading.style.csstext = 'font size:13px;color:#374151;'\n heading.textcontent = label\n const ul = document.createelement('ul')\n ul.style.csstext = liststyle\n col.append(heading, ul)\n wrapper.appendchild(col)\n return ul\n}\n\nconst todoel = makecolumn('to do')\nconst doneel = makecolumn('done')\n\nfunction renderlist(ul, items) {\n ul.innerhtml = ''\n items.foreach(item => {\n const li = document.createelement('li')\n li.dataset.id = item.id\n li.style.csstext = itemstyle\n li.textcontent = item.title\n ul.appendchild(li)\n })\n}\n\nrenderlist(todoel, todoitems)\nrenderlist(doneel, doneitems)\n\nconst getkey = (el) => el.dataset.id ?? ''\n\nconst todosortable = createsortable({\n element: todoel,\n getkey,\n scope,\n})\n\nconst donesortable = createsortable({\n element: doneel,\n getkey,\n scope,\n})\n\nconsole.log('connected lists ready — drag items between columns')\nconsole.log('scope is shared:', typeof scope)"
375
+ },
376
+ {
377
+ "id": "sortable-list",
378
+ "text": "createsortable drag to reorder import { createsortable } from '@vielzeug/dnd'\n\nconst listel = document.createelement('ul')\nlistel.id = 'sortable list'\nlistel.style.csstext = 'list style: none; padding: 0; margin: 0; width: 200px;'\ndocument.body.appendchild(listel)\n\nconst items = [\n { id: 'item 1', label: 'item one' },\n { id: 'item 2', label: 'item two' },\n { id: 'item 3', label: 'item three' },\n { id: 'item 4', label: 'item four' },\n]\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id ?? '',\n onreorder: ({ ids }) => {\n console.log('reordered:', ids.join(' → '))\n render(ids)\n sortable.sync()\n },\n})\n\nfunction render(order) {\n listel.innerhtml = ''\n order.foreach(id => {\n const item = items.find(i => i.id === id)\n const li = document.createelement('li')\n li.dataset.id = item.id\n li.textcontent = item.label\n li.style.csstext = 'padding: 10px; margin: 4px 0; background: #f0f0f0; border radius: 4px; cursor: grab;'\n listel.appendchild(li)\n })\n}\n\nrender(items.map(i => i.id))\nsortable.sync()\n\nconsole.log('✓ sortable list created at #sortable list')"
379
+ },
380
+ {
381
+ "id": "sortable-revert",
382
+ "text": "createsortable optimistic revert import { applyreorder, createsortable } from '@vielzeug/dnd'\n\nconst app = document.createelement('div')\napp.style.csstext = 'display:flex;flex direction:column;gap:12px;width:220px;'\ndocument.body.appendchild(app)\n\nconst listel = document.createelement('ul')\nlistel.style.csstext = 'list style:none;padding:0;margin:0;display:flex;flex direction:column;gap:6px;'\napp.appendchild(listel)\n\nconst revertbtn = document.createelement('button')\nrevertbtn.type = 'button'\nrevertbtn.textcontent = 'revert last reorder'\nrevertbtn.style.csstext = 'padding:7px 14px;border:1px solid #d1d5db;border radius:6px;background:#fff;cursor:pointer;font:inherit;font size:13px;'\napp.appendchild(revertbtn)\n\nlet items = [\n { id: 'a', label: 'alpha' },\n { id: 'b', label: 'beta' },\n { id: 'c', label: 'gamma' },\n { id: 'd', label: 'delta' },\n]\n\nfunction render() {\n listel.innerhtml = ''\n items.foreach(item => {\n const li = document.createelement('li')\n li.dataset.id = item.id\n li.style.csstext = 'padding:9px 14px;background:#fff;border:1px solid #e5e7eb;border radius:6px;cursor:grab;font size:14px;'\n li.textcontent = item.label\n listel.appendchild(li)\n })\n sortable?.sync()\n}\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id ?? '',\n onreorder: ({ ids, setrevert }) => {\n const prev = items\n items = applyreorder(items, ids, i => i.id)\n render()\n console.log('reordered:', items.map(i => i.label).join(' → '))\n setrevert(() => {\n items = prev\n render()\n console.log('reverted to:', items.map(i => i.label).join(' → '))\n })\n },\n})\n\nrevertbtn.addeventlistener('click', () => sortable.revert())\n\nrender()\nconsole.log('drag to reorder, then click revert to undo the last move')"
383
+ },
384
+ {
385
+ "id": "sortable-with-handle",
386
+ "text": "createsortable drag handle import { createsortable } from '@vielzeug/dnd'\n\nconst listel = document.createelement('ul')\nlistel.style.csstext = 'list style:none;padding:0;width:250px;'\ndocument.body.appendchild(listel)\n\nconst tasks = [\n { id: 'task a', title: 'design ui' },\n { id: 'task b', title: 'write tests' },\n { id: 'task c', title: 'deploy to prod' },\n]\n\ntasks.foreach(task => {\n const li = document.createelement('li')\n li.dataset.id = task.id\n li.style.csstext = 'display:flex;align items:center;gap:8px;padding:8px;margin:4px 0;background:#fff;border:1px solid #e5e5e5;border radius:4px;'\n\n const handle = document.createelement('span')\n handle.classname = 'drag handle'\n handle.textcontent = '⬣'\n handle.style.csstext = 'cursor:grab;color:#888;font size:18px;'\n\n const label = document.createelement('span')\n label.textcontent = task.title\n\n li.appendchild(handle)\n li.appendchild(label)\n listel.appendchild(li)\n})\n\nconst sortable = createsortable({\n element: listel,\n getkey: (el) => el.dataset.id ?? '',\n handle: '.drag handle',\n onreorder: ({ ids }) => console.log('reordered:', ids),\n})\n\nconsole.log('handle based sortable created. isdragging:', sortable.isdragging)"
387
+ }
388
+ ],
389
+ "exports": "createdropzone createsortable createsortablescope applyreorder matchesaccept",
390
+ "keywords": "drag drop sortable file upload drop zone dnd reorder",
391
+ "name": "@vielzeug/dnd",
392
+ "related": "ore scroll refine",
393
+ "slug": "dnd",
394
+ "source": "export * from './drop zone';\nexport { dnderror, dndscopeerror } from './errors';\nexport * from './sortable';\nexport * from './types';\n"
395
+ },
396
+ {
397
+ "category": "workers",
398
+ "description": "typed es module worker pools with cancellation, priority scheduling, streaming, and test utilities.",
399
+ "docs": {
400
+ "index": " \ntitle: familiar — typed module worker pools\ndescription: typed es module worker pools with cancellation, priority scheduling, streaming, and test utilities.\npackage: familiar\ncategory: workers\nkeywords: [web workers, module workers, pool, concurrency, timeout, cancellation, streaming]\nrelated: [arsenal, ripple, herald]\nexports: [createworker, createstreamworker, batch, createtaskgroup, familiarerror, familiartimeouterror, familiartaskerror, familiarqueuefullerror, familiarterminatederror, familiarruntimeerror]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"familiar\" />\n\n## why familiar?\n\nraw workers force every application to maintain its own message contract, lifecycle, cancellation, and pool scheduler. familiar provides those boundaries while keeping worker code in normal typed es modules.\n\n```ts\n// before\nconst worker = new worker(new url('./sum.worker.ts', import.meta.url), { type: 'module' });\nworker.postmessage([1, 2, 3]);\n\n// after\nconst pool = createworker<number[], number>(new url('./sum.worker.ts', import.meta.url));\nawait pool.run([1, 2, 3]);\n```\n\n| feature | familiar | raw worker | comlink |\n| | | | |\n| bundle size | <packageinfo package=\"familiar\" type=\"size\" /> | built in | ~2 kb |\n| module worker contract | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| pool scheduling | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| abortsignal cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | manual |\n| versioned protocol | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | implementation specific |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use familiar when** worker jobs need bounded concurrency, typed errors, cancellation, or queue policy.\n\n**consider raw worker when** one isolated worker and custom messaging are enough.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/familiar\n```\n\n```sh [npm]\nnpm install @vielzeug/familiar\n```\n\n```sh [yarn]\nyarn add @vielzeug/familiar\n```\n\n:::\n\n## quick start\n\nregister task logic inside a worker module.\n\n```ts\n// double.worker.ts\nimport { exposetask } from '@vielzeug/familiar/protocol';\n\nexposetask((value: number) => value * 2);\n```\n\ncreate pool from module url and dispose it after use.\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst worker = createworker<number, number>(new url('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await worker.run(21));\n} finally {\n worker.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createworker()` — versioned task protocol over es module workers\n `createstreamworker()` — stream only worker capability\n `run()` — priority scheduling, transferables, timeout, and cancellation\n `batch()` — ordered task composition\n `createtaskgroup()` — shared cancellation and settlement tracking\n `stats` — active, queued, completed, and failed counters\n `createtestworker()` — faithful in process task pool testing\n `dispose()` and `drain()` — immediate or draining teardown, with `using` support\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [arsenal](/arsenal/) — async helpers for application coordination.\n [ripple](/ripple/) — expose worker results through reactive state.\n [herald](/herald/) — publish application events after worker jobs settle.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
401
+ "api": " \ntitle: familiar — api reference\ndescription: api reference for module worker pools and worker side protocol registration.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createworker()` | create single result module worker pool | sync | worker must call `exposetask()` |\n| `createstreamworker()` | create stream only module worker pool | sync | worker must call `exposestream()` |\n| `batch()` | yield ordered task pool results | async iterator | stops remaining work on first failure |\n| `createtaskgroup()` | coordinate related task pool jobs | sync | call `abort()` to stop group work |\n| `createtestworker()` | create an in process task pool test double | sync | task modules are not executed |\n| `exposetask()` | register worker task handler | sync | worker only import |\n| `exposestream()` | register worker stream handler | sync | worker only import |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/familiar` | pool factories, helpers, types, errors |\n| `@vielzeug/familiar/protocol` | versioned worker protocol and registration helpers |\n| `@vielzeug/familiar/testing` | task pool testing adapter |\n\n## pool factories\n\n### `createworker()`\n\n```ts\nfunction createworker<tinput, toutput>(url: url | string, options?: workeroptions): workerpool<tinput, toutput>;\n```\n\ncreates a task pool for a worker module registered with `exposetask()`.\n\n| parameter | type | description |\n| | | |\n| `url` | `url \\| string` | module worker url, usually `new url('./task.worker.ts', import.meta.url)` |\n| `options` | `workeroptions` | pool concurrency, queue, timeout, and worker error policy |\n\n**returns:** `workerpool<tinput, toutput>`.\n\n**example:**\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker<number, number>(new url('./double.worker.ts', import.meta.url));\n\ntry {\n console.log(await pool.run(21));\n} finally {\n pool.dispose();\n}\n```\n\n### `createstreamworker()`\n\n```ts\nfunction createstreamworker<tinput, tchunk>(url: url | string, options?: workeroptions): streamworkerpool<tinput, tchunk>;\n```\n\ncreates a stream only pool for a worker module registered with `exposestream()`.\n\n**returns:** `streamworkerpool<tinput, tchunk>`.\n\n \n\n### `batch()`\n\n```ts\nfunction batch<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n inputs: readonly tinput[],\n options?: batchoptions,\n): asynciterable<toutput>;\n```\n\nyields results in submission order. a failure or cancellation aborts remaining batch work.\n\n**returns:** `asynciterable<toutput>`.\n\n \n\n### `createtaskgroup()`\n\n```ts\nfunction createtaskgroup<tinput, toutput>(\n pool: workerpool<tinput, toutput>,\n name?: string,\n options?: taskgroupoptions,\n): taskgroup<tinput, toutput>;\n```\n\ncreates group scoped cancellation and settlement tracking for one task pool.\n\n**returns:** `taskgroup<tinput, toutput>`.\n\n## testing\n\n### `createtestworker()`\n\n```ts\nfunction createtestworker<tinput, toutput>(\n handler: (input: tinput) => toutput | promise<toutput>,\n options?: testworkeroptions,\n): testworkerhandle<tinput, toutput>;\n```\n\ncreates an in process task pool double. it structured clones values, records settlement, and matches task pool timeout and cancellation behavior without loading a worker module.\n\n**returns:** `testworkerhandle<tinput, toutput>`.\n\n## worker protocol\n\n### `exposetask()`\n\n```ts\nfunction exposetask<tinput, toutput>(handler: taskhandler<tinput, toutput>): void;\n```\n\nregisters one single result handler in a module worker.\n\n### `exposestream()`\n\n```ts\nfunction exposestream<tinput, tchunk>(handler: streamhandler<tinput, tchunk>): void;\n```\n\nregisters one chunk producing handler in a module worker.\n\n### `protocol_version`\n\n```ts\nconst protocol_version: 1;\n```\n\nversion included in every host request and worker response.\n\n## types\n\n### `workeroptions`\n\n```ts\ntype workeroptions = {\n concurrency?: number | 'auto';\n maxqueue?: number;\n onfull?: 'reject' | 'wait';\n timeout?: number;\n onsloterror?: (error: familiarruntimeerror) => void;\n};\n```\n\n### `runoptions`\n\n```ts\ntype runoptions = {\n priority?: number;\n signal?: abortsignal;\n timeout?: number;\n transferables?: transferable[];\n};\n```\n\n`signal` cancels capacity waits, queued work, and executing work. executing cancellation terminates and replaces its worker slot.\n\n### `workerpool`\n\n```ts\ninterface workerpool<tinput, toutput> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n run(input: tinput, options?: runoptions): promise<toutput>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly stats: workerstats;\n readonly status: workerstatus;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n}\n```\n\n### `streamworkerpool`\n\n```ts\ninterface streamworkerpool<tinput, tchunk> {\n [symbol.asyncdispose](): promise<void>;\n [symbol.dispose](): void;\n runstream(input: tinput, options?: runoptions): asynciterable<tchunk>;\n prime(): promise<void>;\n drain(options?: drainoptions): promise<void>;\n dispose(): void;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n readonly stats: workerstats;\n readonly status: workerstatus;\n}\n```\n\n### `workerstats`\n\n```ts\ntype workerstats = {\n readonly active: number;\n readonly completed: number;\n readonly failed: number;\n readonly queued: number;\n};\n```\n\n### `runningstream`\n\n```ts\ntype runningstream<tchunk> = {\n done: promise<void>;\n iterable: asynciterable<tchunk>;\n};\n```\n\n### `workerstatus`\n\n```ts\ntype workerstatus = 'idle' | 'running' | 'terminated';\n```\n\n### `batchoptions`\n\n```ts\ntype batchoptions = runoptions;\n```\n\n### `drainoptions`\n\n```ts\ntype drainoptions = {\n timeout?: number;\n};\n```\n\n### `taskgroup`\n\n```ts\ntype taskgroup<tinput, toutput> = {\n abort(reason?: unknown): void;\n drain(): promise<promisesettledresult<toutput>[]>;\n readonly name: string | undefined;\n readonly pending: number;\n run(input: tinput, options?: omit<runoptions, 'signal'>): promise<toutput>;\n readonly size: number;\n};\n```\n\n### `taskgroupoptions`\n\n```ts\ntype taskgroupoptions = {\n signal?: abortsignal;\n};\n```\n\n### `testworkeroptions`\n\n```ts\ntype testworkeroptions = omit<workeroptions, 'concurrency' | 'onsloterror'> & {\n concurrency?: number;\n};\n```\n\n### `testworkercall`\n\n```ts\ntype testworkercall<tinput, toutput> =\n | { input: tinput; status: 'fulfilled'; value: toutput }\n | { input: tinput; reason: unknown; status: 'rejected' };\n```\n\n### `testworkerhandle`\n\n```ts\ntype testworkerhandle<tinput, toutput> = workerpool<tinput, toutput> & {\n readonly calls: readonlyarray<testworkercall<tinput, toutput>>;\n};\n```\n\n### `serializederror`\n\n```ts\ntype serializederror = {\n message: string;\n name: string;\n stack?: string;\n};\n```\n\n### `workerrequest`\n\n```ts\ntype workerrequest<tinput> =\n | { id: number; input: tinput; kind: 'run'; version: 1 }\n | { id: number; input: tinput; kind: 'stream'; version: 1 };\n```\n\n### `workerresponse`\n\n```ts\ntype workerresponse<toutput> =\n | { id: number; kind: 'chunk'; value: toutput; version: 1 }\n | { error: serializederror; id: number; kind: 'error'; version: 1 }\n | { id: number; kind: 'result'; value: toutput; version: 1 };\n```\n\n### `taskhandler` and `streamhandler`\n\n```ts\ntype taskhandler<tinput, toutput> = (input: tinput) => toutput | promise<toutput>;\ntype streamhandler<tinput, tchunk> = (input: tinput) => asynciterable<tchunk> | promise<asynciterable<tchunk>>;\n```\n\n## errors\n\n| error | trigger | notable property |\n| | | |\n| `familiarerror` | base class for all familiar errors | `familiarerror.is(error)` |\n| `familiarinvalidoptionserror` | invalid factory or test options | — |\n| `familiarqueuefullerror` | queue limit reached with `onfull: 'reject'` | `maxqueue` |\n| `familiartaskerror` | worker handler throws or payload cannot clone | `cause` |\n| `familiartimeouterror` | task or drain deadline expires | `timeoutms` |\n| `familiarterminatederror` | pool is disposed or draining | — |\n| `familiarruntimeerror` | worker api or worker process fails | `cause` |\n",
402
+ "usage": " \ntitle: familiar — usage guide\ndescription: run task and stream module workers with bounded concurrency, cancellation, and test parity.\n \n\n[[toc]]\n\n## basic usage\n\nput task logic in a worker module. imports and helpers stay normal module code.\n\n```ts\n// normalize.worker.ts\nimport { exposetask } from '@vielzeug/familiar/protocol';\n\nimport { normalize } from './normalize';\n\nexposetask((text: string) => normalize(text));\n```\n\ncreate one long lived pool at its owner boundary.\n\n```ts\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker<string, string>(new url('./normalize.worker.ts', import.meta.url), {\n concurrency: 2,\n timeout: 2_000,\n});\n\ntry {\n const normalized = await pool.run(' familiar ');\n console.log(normalized);\n} finally {\n pool.dispose();\n}\n```\n\n## cancellation and timeouts\n\npass one signal to stop capacity waits, queued work, or active work. cancelling active work terminates and lazily replaces its slot.\n\n```ts\nconst controller = new abortcontroller();\nconst result = pool.run('input', { signal: controller.signal, timeout: 500 });\n\ncontroller.abort();\nawait result.catch((error) => console.log(error.name)); // aborterror\n```\n\n## queue policy and priority\n\nuse `maxqueue` to bound waiting work. higher priorities dispatch first once a slot opens.\n\n```ts\nconst pool = createworker<job, result>(new url('./job.worker.ts', import.meta.url), {\n concurrency: 2,\n maxqueue: 100,\n onfull: 'wait',\n});\n\nawait pool.run(criticaljob, { priority: 10 });\n```\n\n## batch and groups\n\ncompose task pools with free helpers instead of carrying unrelated methods on every pool.\n\n```ts\nimport { batch, createtaskgroup } from '@vielzeug/familiar';\n\nfor await (const value of batch(pool, inputs)) {\n console.log(value);\n}\n\nconst group = createtaskgroup(pool, 'import');\nconst tasks = rows.map((row) => group.run(row));\nawait group.drain();\nawait promise.all(tasks);\n```\n\n## streaming\n\nstream workers have their own capability and registration helper.\n\n```ts\n// tokenize.worker.ts\nimport { exposestream } from '@vielzeug/familiar/protocol';\n\nexposestream(async function* (text: string) {\n for (const token of text.split(/\\s+/)) yield token;\n});\n```\n\n```ts\nimport { createstreamworker } from '@vielzeug/familiar';\n\nconst pool = createstreamworker<string, string>(new url('./tokenize.worker.ts', import.meta.url));\nfor await (const token of pool.runstream('typed module workers')) {\n console.log(token);\n}\npool.dispose();\n```\n\n## testing\n\nuse `createtestworker()` when testing consumer code that depends on a task pool. it clones input/output, wraps task failures, and honors cancellation and timeout behavior.\n\n```ts\nimport { createtestworker } from '@vielzeug/familiar/testing';\n\nconst pool = createtestworker((value: number) => value * 2);\nawait expect(pool.run(21)).resolves.tobe(42);\nexpect(pool.calls).toequal([{ input: 21, status: 'fulfilled', value: 42 }]);\npool.dispose();\n```\n\ntest worker module business logic directly when possible. `createtestworker()` does not run module files or support stream pools.\n\n## framework integration\n\ncreate a pool once per component lifetime. abort obsolete requests during effect cleanup and dispose the pool on unmount.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, usememo } from 'react';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = usememo(() => createworker(new url('./sort.worker.ts', import.meta.url)), []);\n\nuseeffect(() => () => pool.dispose(), [pool]);\n```\n\n```ts [vue]\nimport { onunmounted } from 'vue';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker(new url('./sort.worker.ts', import.meta.url));\n\nonunmounted(() => pool.dispose());\n```\n\n```ts [svelte]\nimport { ondestroy } from 'svelte';\nimport { createworker } from '@vielzeug/familiar';\n\nconst pool = createworker(new url('./sort.worker.ts', import.meta.url));\n\nondestroy(() => pool.dispose());\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse `@vielzeug/arsenal` async helpers in application orchestration. keep worker module protocol registration in `@vielzeug/familiar/protocol`.\n\n## best practices\n\n put every task handler in its own module worker boundary.\n reuse pools for repeated work; dispose owner scoped pools.\n abort work made obsolete by navigation or newer input.\n transfer large binary buffers instead of cloning them.\n set explicit timeouts for work with a bounded latency budget.\n keep worker handlers deterministic and data only.\n test module logic directly; test pool consumers with `createtestworker()`.\n",
403
+ "examples": " \ntitle: familiar — examples\ndescription: module worker recipes for familiar.\n \n\n## examples\n\n [fibonacci with pool and timeout](./examples/fibonacci with pool and timeout.md)\n [data transformation pipeline](./examples/data transformation pipeline.md)\n [image processing](./examples/image processing.md)\n [using transferables](./examples/using transferables.md)\n [cancellable batch](./examples/cancellable batch.md)\n [priority queue](./examples/priority queue.md)\n [streaming with stream worker](./examples/streaming with runstream.md)\n [module worker](./examples/module worker.md)\n [typed error handling](./examples/typed error handling.md)\n [react integration](./examples/react integration.md)\n [testing with createtestworker](./examples/testing with createtestworker.md)\n"
404
+ },
405
+ "examples": [
406
+ {
407
+ "id": "error-contracts",
408
+ "text": "familiar error contracts import { familiartimeouterror } from '@vielzeug/familiar'\n\nconst error = new familiartimeouterror(500)\nconsole.log(error.name)\nconsole.log(error.timeoutms)"
409
+ }
410
+ ],
411
+ "exports": "createworker createstreamworker batch createtaskgroup familiarerror familiartimeouterror familiartaskerror familiarqueuefullerror familiarterminatederror familiarruntimeerror",
412
+ "keywords": "web workers module workers pool concurrency timeout cancellation streaming",
413
+ "name": "@vielzeug/familiar",
414
+ "related": "arsenal ripple herald",
415
+ "slug": "familiar",
416
+ "source": "export * from './worker';\n"
417
+ },
418
+ {
419
+ "category": "reactive",
420
+ "description": "reusable push streams with subscription owned cancellation, bounded buffering, and optional ecosystem adapters.",
421
+ "docs": {
422
+ "index": " \ntitle: flux — explicit push streams for typescript\ndescription: reusable push streams with subscription owned cancellation, bounded buffering, and optional ecosystem adapters.\npackage: flux\ncategory: reactive\nkeywords: [streams, reactive, operators, cancellation, buffering, channels]\nrelated: [ripple, herald, pulse, courier]\nexports: [stream, pipe, of, from, fromevent, interval, timer, map, filter, scan, switchmap, mergemap, concatmap, take, takeuntil, debounce, timeout, merge, concat, combinelatest, retry, toarray, first, last, toasynciterable]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"flux\" />\n\n## why flux?\n\nuse flux when an api pushes many values over time and consumers need independent cancellation. streams describe reusable work; subscriptions own cleanup. explicit queue capacity keeps async iteration from silently growing memory.\n\n```ts\n// before\nconst controller = new abortcontroller();\nconst render = (value: string) => console.log(value);\nconst handler = (event: event) => render((event.target as htmlinputelement).value);\ninput.addeventlistener('input', handler);\nsettimeout(() => controller.abort(), 5_000);\n\n// after\nimport { fromevent, map, pipe, takeuntil } from '@vielzeug/flux';\n\nconst updates = pipe(\n fromevent<inputevent>(input, 'input'),\n map((event) => (event.target as htmlinputelement).value),\n takeuntil(controller.signal),\n);\n\nupdates.subscribe({ error: console.error, next: render });\n```\n\n| feature | flux | rxjs | tc39 observable |\n| | | | |\n| bundle size | <packageinfo package=\"flux\" type=\"size\" /> | varies by imported operators | native proposal / polyfill |\n| runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| subscription owned cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| explicit async queue policy | <ore icon name=\"check\" size=\"16\"></ore icon> | operator dependent | no standard policy |\n| vielzeug adapters | ripple, courier, herald, pulse | manual adapters | manual adapters |\n\n<div class=\"decision callout\">\n\n**use flux when** you need a small typescript stream primitive, explicit cancellation, and first party vielzeug adapters.\n\n**consider rxjs when** you need its larger operator catalog or third party observable integrations.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/flux\n```\n\n```sh [npm]\nnpm install @vielzeug/flux\n```\n\n```sh [yarn]\nyarn add @vielzeug/flux\n```\n\n:::\n\n## quick start\n\n```ts\nimport { toarray, interval, map, pipe, take } from '@vielzeug/flux';\n\nconst firstthree = pipe(\n interval({ every: 100 }),\n map((value) => value * 2),\n take(3),\n);\n\ntry {\n console.log(await toarray(firstthree, { maxitems: 3 })); // [0, 2, 4]\n} catch (reason) {\n console.error('stream failed', reason);\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `stream()` — define cold reusable work with one teardown function\n `pipe()` — compose any number of typed operators\n `subscription` — own cancellation through `unsubscribe()` or `abortsignal`\n `createchannel()` — mutable multicast state with bounded replay\n `toasynciterable()` — explicit capacity and overflow policy for pull consumers\n `retry()` — retry failures with optional backoff\n `fromsignal()` / `tosignal()` — bridge ripple signals\n `fromquery()` — adapt courier query state\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/) — adapt reactive signal state through `@vielzeug/flux/ripple`.\n [courier](/courier/) — adapt query snapshots and sse events through `@vielzeug/flux/courier`.\n [herald](/herald/) — adapt typed bus events through `@vielzeug/flux/herald`.\n [pulse](/pulse/) — adapt connection and presence events through `@vielzeug/flux/pulse`.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
423
+ "api": " \ntitle: flux — api reference\ndescription: complete reference for @vielzeug/flux streams, operators, channels, and adapters.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `stream()` | create cold stream | lazy | return one teardown function |\n| `pipe()` | compose operators | lazy | source is first argument |\n| `of()` / `from()` | convert known values | sync / mixed | `from()` promise cannot be aborted |\n| `fromevent()` | adapt event target | async | unsubscribe removes listener |\n| `interval()` / `timer()` | create timed values | async | use `take()` or unsubscribe for intervals |\n| `map()` / `filter()` / `scan()` | transform values | sync | callback throws terminate stream |\n| `switchmap()` / `mergemap()` / `concatmap()` | flatten streams | mixed | `concatmap()` queue is bounded |\n| `take()` / `takeuntil()` | stop values | mixed | notifier emission completes output |\n| `debounce()` / `timeout()` / `retry()` | control time and failures | async | `timeout()` measures inactivity |\n| `merge()` / `concat()` / `combinelatest()` | combine streams | mixed | `combinelatest()` waits for every source |\n| `toarray()` / `first()` / `last()` | consume finite values | async | bound `toarray()` with `maxitems` |\n| `toasynciterable()` | use `for await` | async | capacity and overflow required |\n| `createchannel()` | imperative multicast boundary | sync | dispose to complete subscribers |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/flux` | core streams, operators, consumers, errors, and types |\n| `@vielzeug/flux/async` | `toasynciterable()` only |\n| `@vielzeug/flux/subjects` | `createchannel()` and channel types |\n| `@vielzeug/flux/ripple` | ripple signal adapters |\n| `@vielzeug/flux/courier` | courier query and sse adapters |\n| `@vielzeug/flux/herald` | herald bus adapters |\n| `@vielzeug/flux/pulse` | pulse event and presence adapters |\n\n## core\n\n### `stream()`\n\n```ts\nstream<t>(producer: producer<t>): stream<t>\n```\n\ncreates cold reusable work. producer runs once for every subscription.\n\n| parameter | type | description |\n| | | |\n| `producer` | `producer<t>` | emits through sink and returns optional teardown |\n\n**returns:** `stream<t>`.\n\n```ts\nimport { stream } from '@vielzeug/flux';\n\nconst ticks = stream<number>((sink) => {\n const id = setinterval(() => sink.next(date.now()), 1_000);\n return () => clearinterval(id);\n});\n```\n\n \n\n### `pipe()`\n\n```ts\npipe<input, operators>(source: stream<input>, ...operators: operators): stream<output>\n```\n\napplies operators left to right while inferring output value type.\n\n| parameter | type | description |\n| | | |\n| `source` | `stream<input>` | source stream |\n| `operators` | `operator[]` | operators applied in order |\n\n**returns:** transformed `stream<output>`.\n\n```ts\nimport { map, of, pipe } from '@vielzeug/flux';\n\nconst labels = pipe(of(1, 2), map((value) => `#${value}`));\n```\n\n## creation\n\n### `of()`\n\n```ts\nof<t>(...values: t[]): stream<t>\n```\n\nemits every value synchronously, then completes.\n\n```ts\nimport { of } from '@vielzeug/flux';\n\nof(1, 2, 3).subscribe(console.log);\n```\n\n \n\n### `from()`\n\n```ts\nfrom<t>(source: iterable<t> | asynciterable<t> | promise<t>): stream<t>\n```\n\nconverts iterable, async iterable, or promise into a stream. cancellation stops iterable consumption and calls `return()` when available.\n\n```ts\nimport { from } from '@vielzeug/flux';\n\nfrom(promise.resolve('ready')).subscribe({ error: console.error, next: console.log });\n```\n\n \n\n### `fromevent()`\n\n```ts\nfromevent<t = event>(\n target: {\n addeventlistener(type: string, listener: (event: t) => void): void;\n removeeventlistener(type: string, listener: (event: t) => void): void;\n },\n type: string,\n): stream<t>\n```\n\nemits target events until subscription ends.\n\n```ts\nimport { fromevent } from '@vielzeug/flux';\n\nfromevent<mouseevent>(document, 'click').subscribe(console.log);\n```\n\n \n\n### `interval()`\n\n```ts\ninterval(options: intervaloptions): stream<number>\n```\n\nemits incrementing values starting at zero.\n\n| option | type | description |\n| | | |\n| `every` | `number` | non negative interval duration in milliseconds |\n\n \n\n### `timer()`\n\n```ts\ntimer(options: timeroptions): stream<number>\n```\n\nemits zero after `delay`; optionally continues at `interval`.\n\n| option | type | description |\n| | | |\n| `delay` | `number` | non negative initial delay in milliseconds |\n| `interval` | `number` | optional non negative repeat duration |\n\n## transformation operators\n\n### `map()`\n\n```ts\nmap<a, b>(project: (value: a) => b): operator<a, b>\n```\n\nmaps every value. a thrown callback error terminates output.\n\n \n\n### `filter()`\n\n```ts\nfilter<t>(predicate: (value: t) => boolean): operator<t, t>\n```\n\nforwards values matching predicate.\n\n \n\n### `scan()`\n\n```ts\nscan<t, a>(reducer: (state: a, value: t) => a, initial: a): operator<t, a>\n```\n\nemits accumulated state after every source value.\n\n \n\n### `switchmap()`\n\n```ts\nswitchmap<a, b>(project: (value: a) => stream<b>): operator<a, b>\n```\n\ncancels previous inner stream when source emits.\n\n \n\n### `mergemap()`\n\n```ts\nmergemap<a, b>(project: (value: a) => stream<b>): operator<a, b>\n```\n\nruns every inner stream concurrently.\n\n \n\n### `concatmap()`\n\n```ts\nconcatmap<a, b>(project: (value: a) => stream<b>, options: concatmapoptions): operator<a, b>\n```\n\nruns inner streams in order. exceeding capacity errors output.\n\n| option | type | description |\n| | | |\n| `capacity` | `number` | positive maximum queued source values |\n\n## control operators\n\n### `take()`\n\n```ts\ntake<t>(count: number): operator<t, t>\n```\n\nforwards `count` values, cancels upstream, then completes. count must be non negative integer.\n\n \n\n### `takeuntil()`\n\n```ts\ntakeuntil<t>(notifier: abortsignal | stream<unknown>): operator<t, t>\n```\n\ncompletes when notifier aborts or emits.\n\n \n\n### `debounce()`\n\n```ts\ndebounce<t>(options: debounceoptions): operator<t, t>\n```\n\nemits latest value after configured silence. pending value flushes on source completion.\n\n| option | type | description |\n| | | |\n| `for` | `number` | non negative silence duration in milliseconds |\n\n \n\n### `timeout()`\n\n```ts\ntimeout<t>(options: timeoutoptions): operator<t, t>\n```\n\nerrors with `fluxtimeouterror` when source is silent too long.\n\n| option | type | description |\n| | | |\n| `after` | `number` | non negative inactivity duration in milliseconds |\n\n \n\n### `retry()`\n\n```ts\nretry<t>(options: retryoptions): operator<t, t>\n```\n\nresubscribes after source errors until attempts are exhausted.\n\n| option | type | description |\n| | | |\n| `attempts` | `number` | non negative retry count |\n| `delay` | `number \\| (attempt: number) => number` | optional delay or backoff function |\n\n## combination\n\n### `merge()`\n\n```ts\nmerge<t>(...sources: stream<t>[]): stream<t>\n```\n\nforwards values from all sources and completes after every source completes.\n\n \n\n### `concat()`\n\n```ts\nconcat<t>(...sources: stream<t>[]): stream<t>\n```\n\nsubscribes to each source only after previous source completes.\n\n \n\n### `combinelatest()`\n\n```ts\ncombinelatest<t extends readonly stream<unknown>[]>(...sources: t): stream<{ [k in keyof t]: t[k] extends stream<infer v> ? v : never }>\n```\n\nemits latest tuple after every source emits once. completes without emission when a source completes before first value.\n\n## value consumers\n\n### `toarray()`\n\n```ts\ntoarray<t>(source: stream<t>, options: toarrayoptions): promise<t[]>\n```\n\ncollects finite output. rejects on source error, abort, or `maxitems` overflow.\n\n| option | type | description |\n| | | |\n| `maxitems` | `number` | non negative maximum toarrayed values |\n| `signal` | `abortsignal` | optional cancellation signal |\n\n \n\n### `first()`\n\n```ts\nfirst<t>(source: stream<t>, options?: valueoptions): promise<t>\n```\n\nresolves first value and cancels source. rejects on source error or abort.\n\n \n\n### `last()`\n\n```ts\nlast<t>(source: stream<t>, options?: valueoptions): promise<t | undefined>\n```\n\nresolves last value on completion, or `undefined` when source completes empty.\n\n## async conversion\n\n### `toasynciterable()`\n\n```ts\ntoasynciterable<t>(source: stream<t>, options: asynciterableoptions): asynciterable<t>\n```\n\nconverts push stream to async iterable with bounded queue.\n\n| option | type | description |\n| | | |\n| `capacity` | `number` | positive queue capacity |\n| `overflow` | `overflowpolicy` | `error`, `drop oldest`, or `drop newest` |\n| `signal` | `abortsignal` | optional cancellation signal |\n\n## channels\n\n### `createchannel()`\n\n```ts\ncreatechannel<t>(options?: channeloptions<t>): channel<t>\n```\n\ncreates imperative multicast boundary. disposal completes subscribers.\n\n> **`initial` + `replay` interaction:** when `initial` is set and `replay` is omitted, `replay` defaults to `1` so the initial value is retained. setting `replay: 0` with `initial` throws `rangeerror` — the initial value would be immediately dropped.\n\n| option | type | description |\n| | | |\n| `initial` | `t` | optional initial replay value |\n| `replay` | `number` | non negative retained value count |\n\n## adapters\n\n### `@vielzeug/flux/ripple`\n\n```ts\nfromsignal<t>(source: readable<t>): stream<t>\ntosignal<t>(source: stream<t>, options: tosignaloptions<t>): signalbinding<t>\n```\n\n`fromsignal()` emits current value first. `tosignal()` preserves final value then disposes binding when source completes, errors, or supplied signal aborts. on source error, `tosignal()` calls `options.onerror` if provided (otherwise logs via `console.error` in dev — in production the log is stripped and the error is silently swallowed), then disposes — the signal freezes at its last value. pass `onerror` to surface source errors in production builds.\n\n### `@vielzeug/flux/courier`\n\n```ts\nfromquery<t extends { key: readonly unknown[]; fetch: (...args: never[]) => promise<unknown> }>(\n cache: { getsnapshot<t>(key: readonly unknown[]): t | null; subscribe(key: readonly unknown[], listener: () => void): () => void },\n definition: t,\n): stream<asyncstate<awaited<returntype<t['fetch']>>> | null>\n```\n\n`fromquery()` infers data from `definition.fetch` and emits courier compatible `asyncstate` snapshots.\n\n### `@vielzeug/flux/herald`\n\n```ts\nfrombus<t extends eventmap, k extends eventkey<t>>(bus: bus<t>, event: k): stream<t[k]>\ntobus<t extends eventmap, k extends eventkey<t>>(bus: bus<t>, event: k): operator<t[k], t[k]>\n```\n\n### `@vielzeug/flux/pulse`\n\n```ts\nfrompulse<s extends pulseschema, k extends eventkey<serverevents<s>>>(pulse: pulse<s>, event: k): stream<serverevents<s>[k]>\nfromroompresence<t>(room: presenceroomscope<t>): stream<readonlymap<string, t>>\n```\n\n## types\n\n```ts\ntype teardown = () => void;\n\ntype subscription = {\n [symbol.dispose](): void;\n readonly closed: boolean;\n unsubscribe(): void;\n};\n\ntype observer<t> = {\n complete?: () => void;\n error?: (reason: unknown) => void;\n next: (value: t) => void;\n};\n\ntype subscribeoptions = { signal?: abortsignal };\n\ntype sink<t> = {\n complete(): void;\n error(reason: unknown): void;\n next(value: t): void;\n};\n\ntype producer<t> = (sink: sink<t>, signal: abortsignal) => teardown | undefined;\ntype operator<a = unknown, b = unknown> = (source: stream<a>) => stream<b>;\n\ninterface stream<t> {\n subscribe(observer: observer<t> | ((value: t) => void), options?: subscribeoptions): subscription;\n}\n\ntype overflowpolicy = 'drop newest' | 'drop oldest' | 'error';\ntype asynciterableoptions = { capacity: number; overflow: overflowpolicy; signal?: abortsignal };\ntype intervaloptions = { every: number };\ntype timeroptions = { delay: number; interval?: number };\ntype debounceoptions = { for: number };\ntype timeoutoptions = { after: number };\ntype concatmapoptions = { capacity: number };\ntype retryoptions = { attempts: number; delay?: number | ((attempt: number) => number) };\ntype toarrayoptions = { maxitems: number; signal?: abortsignal };\ntype valueoptions = { signal?: abortsignal };\ntype channeloptions<t> = { initial?: t; replay?: number };\ntype channel<t> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n send(value: t): void;\n readonly stream: stream<t>;\n};\ntype tosignaloptions<t> = { initial: t; onerror?: (reason: unknown) => void; signal?: abortsignal };\ntype signalbinding<t> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n readonly signal: readable<t>;\n readonly value: t;\n};\n```\n\n## errors\n\n### `fluxerror`\n\nbase flux error. use `instanceof fluxerror` to narrow unknown values.\n\n### `fluxtimeouterror`\n\nraised by `timeout()`. `ms` contains configured inactivity duration.\n",
424
+ "usage": " \ntitle: flux — usage guide\ndescription: create streams, compose operators, consume values safely, and bridge vielzeug primitives.\n \n\n[[toc]]\n\n## basic usage\n\ndefine one cold stream. return teardown work from producer. every subscription runs producer independently.\n\n```ts\nimport { stream } from '@vielzeug/flux';\n\nconst clock = stream<number>((sink) => {\n let value = 0;\n const id = setinterval(() => sink.next(value++), 1_000);\n\n return () => clearinterval(id);\n});\n\nconst subscription = clock.subscribe({\n error: console.error,\n next: console.log,\n});\n\nsubscription.unsubscribe();\n```\n\npass `abortsignal` when another owner controls lifetime.\n\n```ts\nconst controller = new abortcontroller();\nclock.subscribe(console.log, { signal: controller.signal });\ncontroller.abort();\n```\n\n## compose streams\n\npass source first to `pipe()`. operators retain inferred value types across chains.\n\n```ts\nimport { filter, fromevent, map, pipe, take } from '@vielzeug/flux';\n\nconst clicks = pipe(\n fromevent<mouseevent>(document, 'click'),\n filter((event) => event.button === 0),\n map((event) => ({ x: event.clientx, y: event.clienty })),\n take(10),\n);\n\nclicks.subscribe({\n complete: () => console.log('done'),\n error: console.error,\n next: console.log,\n});\n```\n\nuse `switchmap()` for latest only work, `mergemap()` for concurrent work, and `concatmap()` for ordered work with bounded queue capacity.\n\n```ts\nimport { from, pipe, retry, switchmap } from '@vielzeug/flux';\n\nconst results = pipe(\n queries,\n switchmap((query) => from(fetch(`/api/search?q=${encodeuricomponent(query)}`).then((response) => response.json()))),\n retry({ attempts: 2, delay: (attempt) => 250 * (attempt + 1) }),\n);\n```\n\n## consume values\n\nuse bounded array conversion for finite streams. `toarray()` rejects once source exceeds `maxitems`.\n\n```ts\nimport { toarray, of } from '@vielzeug/flux';\n\ntry {\n const values = await toarray(of(1, 2, 3), { maxitems: 3 });\n console.log(values);\n} catch (reason) {\n console.error('collection failed', reason);\n}\n```\n\nuse `first()` for first emission and `last()` for last value before completion. pass `{ signal }` to cancel waiting; cancellation rejects with `aborterror`.\n\n## channels\n\nuse channels only at imperative boundaries. expose `channel.stream` to consumers; keep `send()` near event producer.\n\n```ts\nimport { createchannel } from '@vielzeug/flux/subjects';\n\nconst status = createchannel({ initial: 'starting', replay: 1 });\nstatus.stream.subscribe({ error: console.error, next: console.log });\nstatus.send('ready');\nstatus.dispose();\n```\n\ndisposal completes active and future subscribers. replay retains only configured latest values.\n\n## async iteration and bounds\n\nconvert push stream only when pull syntax is required. capacity and overflow policy are mandatory.\n\n```ts\nimport { interval, toasynciterable } from '@vielzeug/flux';\n\nconst values = toasynciterable(interval({ every: 100 }), {\n capacity: 32,\n overflow: 'error',\n});\n\nfor await (const value of values) {\n console.log(value);\n if (value === 2) break;\n}\n```\n\n`return()` from loop permanently completes iterator. use `drop oldest` or `drop newest` only when loss is acceptable.\n\n## testing\n\nuse fake timers for time operators. test producer cleanup through returned subscription.\n\n```ts\nimport { expect, it, vi } from 'vitest';\nimport { first, pipe, stream, timeout } from '@vielzeug/flux';\n\nit('fails after inactivity', async () => {\n vi.usefaketimers();\n const result = first(pipe(stream(() => {}), timeout({ after: 500 })));\n const expectation = expect(result).rejects.tothrow('timeout after 500ms');\n\n await vi.advancetimersbytimeasync(500);\n await expectation;\n vi.userealtimers();\n});\n```\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { useeffect, usestate } from 'react';\nimport type { stream } from '@vielzeug/flux';\n\nexport function usestream<t>(source: stream<t>, initial: t): t {\n const [value, setvalue] = usestate(initial);\n\n useeffect(() => {\n const subscription = source.subscribe({ error: console.error, next: setvalue });\n return () => subscription.unsubscribe();\n }, [source]);\n\n return value;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport type { stream } from '@vielzeug/flux';\n\nexport function usestream<t>(source: stream<t>, initial: t) {\n const value = ref(initial);\n const subscription = source.subscribe({ error: console.error, next: (next) => (value.value = next) });\n\n onunmounted(() => subscription.unsubscribe());\n\n return value;\n}\n```\n\n```ts [svelte]\nimport type { stream } from '@vielzeug/flux';\n\nexport function streamstore<t>(source: stream<t>, initial: t) {\n return {\n subscribe(run: (value: t) => void) {\n run(initial);\n const subscription = source.subscribe({ error: console.error, next: run });\n return () => subscription.unsubscribe();\n },\n };\n}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nimport adapters from dedicated subpaths. core flux does not require adapter peers.\n\n```ts\nimport { fromquery } from '@vielzeug/flux/courier';\nimport { frombus } from '@vielzeug/flux/herald';\nimport { fromroompresence } from '@vielzeug/flux/pulse';\nimport { fromsignal, tosignal } from '@vielzeug/flux/ripple';\n```\n\n`tosignal()` preserves final source value, then disposes binding when source completes, errors, or external signal aborts.\n\n## best practices\n\n return one idempotent producer teardown function.\n pass `{ signal }` from component, request, or task owner.\n provide `error` when subscription can recover locally.\n use `pipe(source, ...)`; never mutate stream definitions.\n bound `concatmap()` queue capacity.\n bound `toarray()` with realistic `maxitems`.\n choose async iterator overflow policy deliberately.\n keep `channel.send()` at integration boundaries.\n",
425
+ "examples": " \ntitle: flux — examples\ndescription: practical examples and recipes for @vielzeug/flux.\n \n\n## examples\n\n [debounced search input](./examples/debounce search.md)\n [combining streams with combinelatest](./examples/combine streams.md)\n [ripple signal integration](./examples/signal integration.md)\n"
426
+ },
427
+ "examples": [
428
+ {
429
+ "id": "async-conversion",
430
+ "text": "async conversion // consume a push stream with an explicit bounded async queue.\nimport { of, toasynciterable } from '@vielzeug/flux'\n\nasync function run() {\n const values = toasynciterable(of(1, 2, 3), {\n capacity: 3,\n overflow: 'error',\n })\n\n for await (const value of values) {\n console.log('value:', value)\n }\n\n console.log('iterator complete')\n}\n\nvoid run()"
431
+ },
432
+ {
433
+ "id": "basic-flux",
434
+ "text": "creating a stream // build a cold stream and convert a bounded derived sequence to an array.\nimport { toarray, map, pipe, stream, take } from '@vielzeug/flux'\n\nconst integers = stream((sink) => {\n let value = 0\n const id = setinterval(() => sink.next(value++), 50)\n\n return () => clearinterval(id)\n})\n\nconst values = pipe(\n integers,\n map((value) => value * 2),\n take(3),\n)\n\ntoarray(values, { maxitems: 3 })\n .then((result) => console.log('values:', result))\n .catch(console.error)"
435
+ },
436
+ {
437
+ "id": "cancellation",
438
+ "text": "cancelling an iterable // stop synchronous iterable work as soon as take() reaches its limit.\nimport { from, pipe, take } from '@vielzeug/flux'\n\nfunction* ids() {\n for (let value = 1; value <= 5; value++) {\n console.log('produced:', value)\n yield value\n }\n}\n\npipe(from(ids()), take(2)).subscribe({\n complete: () => console.log('complete'),\n error: console.error,\n next: (value) => console.log('consumed:', value),\n})"
439
+ },
440
+ {
441
+ "id": "combination",
442
+ "text": "combining streams // combine current filter and page state from replaying channels.\nimport { combinelatest } from '@vielzeug/flux'\nimport { createchannel } from '@vielzeug/flux/subjects'\n\nconst count = createchannel({ initial: 0 })\nconst label = createchannel({ initial: 'items' })\n\ncombinelatest(count.stream, label.stream).subscribe({\n error: console.error,\n next: ([value, text]) => console.log(value, text),\n})\n\ncount.send(1) // 1 items\nlabel.send('tasks') // 1 tasks\ncount.dispose()\nlabel.dispose()"
443
+ },
444
+ {
445
+ "id": "error-handling",
446
+ "text": "error handling // retry a transient producer error with a bounded attempt count.\nimport { toarray, pipe, retry, stream } from '@vielzeug/flux'\n\nlet attempt = 0\nconst source = stream((sink) => {\n attempt++\n console.log('attempt:', attempt)\n\n if (attempt < 3) {\n sink.error(new error('temporary failure'))\n return\n }\n\n sink.next('success')\n sink.complete()\n})\n\ntoarray(pipe(source, retry({ attempts: 2 })), { maxitems: 1 })\n .then((values) => console.log('result:', values))\n .catch(console.error)"
447
+ },
448
+ {
449
+ "id": "operators",
450
+ "text": "operators // build a typed transformation pipeline with filter, map, and scan.\nimport { toarray, filter, map, of, pipe, scan } from '@vielzeug/flux'\n\nconst result = pipe(\n of(1, 2, 3, 4, 5),\n filter((value) => value % 2 !== 0),\n map((value) => value * 10),\n scan((total, value) => total + value, 0),\n)\n\ntoarray(result, { maxitems: 3 })\n .then((values) => console.log('running totals:', values))\n .catch(console.error)"
451
+ },
452
+ {
453
+ "id": "subjects",
454
+ "text": "channels // replay latest events to late subscribers while keeping send() at one boundary.\nimport { createchannel } from '@vielzeug/flux/subjects'\n\nconst events = createchannel({ replay: 2 })\n\nevents.stream.subscribe({\n error: console.error,\n next: (value) => console.log('first:', value),\n})\nevents.send('connected')\nevents.send('ready')\nevents.send('updated')\n\nevents.stream.subscribe({\n error: console.error,\n next: (value) => console.log('late:', value),\n})\n// late: ready\n// late: updated\n\nevents.dispose()"
455
+ }
456
+ ],
457
+ "exports": "stream pipe of from fromevent interval timer map filter scan switchmap mergemap concatmap take takeuntil debounce timeout merge concat combinelatest retry toarray first last toasynciterable",
458
+ "keywords": "streams reactive operators cancellation buffering channels",
459
+ "name": "@vielzeug/flux",
460
+ "related": "ripple herald pulse courier",
461
+ "slug": "flux",
462
+ "source": "export { toasynciterable } from './async';\nexport { stream } from './core';\nexport { fluxerror, fluxtimeouterror } from './errors';\nexport { combinelatest, concat, merge } from './operators/combination';\nexport type { intervaloptions, timeroptions } from './operators/creation';\nexport { from, fromevent, interval, of, timer } from './operators/creation';\nexport type { debounceoptions, timeoutoptions } from './operators/filtering';\nexport { debounce, take, takeuntil, timeout } from './operators/filtering';\nexport type { concatmapoptions } from './operators/transformation';\nexport { concatmap, filter, map, mergemap, scan, switchmap } from './operators/transformation';\nexport type { retryoptions, toarrayoptions, valueoptions } from './operators/utility';\nexport { first, last, retry, toarray } from './operators/utility';\nexport { pipe } from './pipe';\nexport type {\n asynciterableoptions,\n observer,\n operator,\n overflowpolicy,\n producer,\n sink,\n stream,\n subscribeoptions,\n subscription,\n teardown,\n} from './types';\n"
463
+ },
464
+ {
465
+ "category": "forms",
466
+ "description": "framework agnostic immutable form state with focused object fields and explicit validation results.",
467
+ "docs": {
468
+ "index": " \ntitle: forge — immutable form state for typescript\ndescription: framework agnostic immutable form state with focused object fields and explicit validation results.\npackage: forge\ncategory: forms\nkeywords: [form state, validation, immutable, input, submission]\nrelated: [spell, vault, courier]\nexports: [createform, toformdata, bindfield, customvalidator, saveform, loadform]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"forge\" />\n\n## why forge?\n\nnative form state becomes difficult to inspect once values, validation, draft restoration, and ui bindings share mutable objects. forge owns one immutable value tree and gives you typed handles for object branches without string paths, scoped controllers, or framework state.\n\n```ts\n// before\nconst values = { email: '', password: '' };\nconst errors: record<string, string> = {};\n\nfunction submit() {\n errors.email = values.email.includes('@') ? '' : 'invalid email';\n errors.password = values.password.length >= 8 ? '' : 'use at least eight characters';\n}\n\n// after\nconst form = createform({\n initialvalues: { email: '', password: '' },\n validate: (value) => ({\n fields: {\n email: value.email.includes('@') ? undefined : 'invalid email',\n password: value.password.length >= 8 ? undefined : 'use at least eight characters',\n },\n }),\n});\n```\n\n| feature | forge | native form state | framework owned form state |\n| | | | |\n| bundle size | <packageinfo package=\"forge\" type=\"size\" /> | <ore icon name=\"check\" size=\"16\"></ore icon> | varies |\n| zero external dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| immutable nested values | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | varies |\n| typed object field handles | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | varies |\n| framework independent state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use forge when** form state needs framework independent immutable values, typed object fields, and one explicit validation boundary.\n\n**consider framework owned form state when** application only needs a single ui framework's native input bindings.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/forge\n```\n\n```sh [npm]\nnpm install @vielzeug/forge\n```\n\n```sh [yarn]\nyarn add @vielzeug/forge\n```\n\n:::\n\ninstall `@vielzeug/spell` or `@vielzeug/vault` only when importing forge's matching optional adapter.\n\n## quick start\n\ncreate a form, update a focused field, and submit only after validation passes.\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({\n initialvalues: { profile: { email: '', name: '' } },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'invalid email' } },\n }),\n});\n\nform.field('profile').field('email').set('ada@example.com');\n\nconst result = await form.submit(async (value) => {\n const response = await fetch('/api/profile', {\n body: json.stringify(value),\n headers: { 'content type': 'application/json' },\n method: 'post',\n });\n\n return response.ok;\n});\n\nif (!result.ok && result.type === 'validation') console.log(result.errors);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `form.value` exposes one immutable nested value tree.\n `form.field(key)` selects typed object branches without string paths.\n `field.set(updater)` replaces array values without index handles.\n `form.validate()` returns valid, invalid, or aborted results.\n `form.submit(handler)` touches, validates, and invokes the handler when valid.\n `bindfield()` connects one dom element without owning validation timing.\n `customvalidator()` maps spell schema errors into forge fields.\n `saveform()` and `loadform()` persist explicit vault draft records.\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [spell](/spell/) — adapt a spell schema through `customvalidator()`.\n [vault](/vault/) — save and restore explicit forge draft records.\n [courier](/courier/) — send a validated form value through a mutation.\n\n</div>\n\n<! markdownlint enable >\n",
469
+ "api": " \ntitle: forge — api reference\ndescription: complete reference for immutable forms, fields, validation, serialization, and optional adapters.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createform()` | create immutable form state | sync | `initialvalues` cannot contain mutable class instances |\n| `form.field()` | select a top level or object child field | sync | arrays have no index field handles |\n| `form.validate()` | validate complete value | async | handle `aborted` separately |\n| `form.submit()` | touch, validate, then invoke handler | async | concurrent calls reject |\n| `form.reset()` | restore or replace baseline | sync | `reset(next)` makes `next` clean |\n| `form.subscribe()` | observe form metadata | sync | throws after disposal |\n| `toformdata()` | serialize values for multipart transport | sync | `filelist` is transport only |\n| `bindfield()` | bind one dom element | sync | does not schedule validation |\n| `customvalidator()` | adapt a spell schema | async | does not transform `form.value` |\n| `saveform()` / `loadform()` | persist explicit vault records | async | formdraftcodec owns record shape |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/forge` | core form factory, serialization helper, types, and errors |\n| `@vielzeug/forge/dom` | `bindfield()` and dom binding types |\n| `@vielzeug/forge/spell` | `customvalidator()` |\n| `@vielzeug/forge/vault` | `saveform()`, `loadform()`, and `formdraftcodec` |\n\n## core functions\n\n### `createform(options)`\n\n```ts\nfunction createform<tvalues extends record<string, unknown>>(options: formoptions<tvalues>): form<tvalues>;\n```\n\ncreates a form with immutable initial values and an optional full form validator.\n\n| parameter | type | description |\n| | | |\n| `options.initialvalues` | `tvalues` | initial value and reset baseline. supports primitives, plain objects, arrays, `file`, and `blob`. |\n| `options.validate` | `formvalidator<tvalues>` | optional validator for the entire current value. |\n| `options.onsubscribererror` | `(error: unknown) => void` | optional subscriber failure reporter. |\n\n**returns:** `form<tvalues>`.\n\n**example:**\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\n```\n\n \n\n### `toformdata(values)`\n\n```ts\nfunction toformdata(values: record<string, unknown>): formdata;\n```\n\nconverts nested values into `formdata` with dot separated object keys and repeated array keys.\n\n**returns:** a populated `formdata` instance.\n\n**example:**\n\n```ts\nimport { toformdata } from '@vielzeug/forge';\n\nconst body = toformdata({ profile: { email: 'ada@example.com' }, tags: ['typescript', 'forms'] });\n```\n\n## form handles\n\n### `form<tvalues>`\n\n`createform()` returns this handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<tvalues>` | current immutable value. |\n| `state` | `formstate<tvalues>` | submission, validation, touch, and error metadata. |\n| `field(key)` | `field<tvalues[k]>` | select a top level field. |\n| `set(next)` | `void` | replace the complete value or derive a replacement. |\n| `reset(next?)` | `void` | restore baseline or make `next` the baseline. |\n| `validate(signal?)` | `promise<validationresult<tvalues>>` | run full form validation. |\n| `submit(handler)` | `promise<submitresult<tresult, tvalues>>` | touch, validate, and invoke handler when valid. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe form state; throws after disposal. |\n| `dispose()` | `void` | abort validation and clear subscribers. |\n| `disposed` | `boolean` | whether the form has been disposed. |\n| `disposalsignal` | `abortsignal` | aborts on disposal. |\n\n### `field<v>`\n\n`form.field(key)` and object field `.field(key)` return this handle.\n\n| member | signature | description |\n| | | |\n| `value` | `readonlydeep<v>` | current immutable branch value. |\n| `error` | `string \\| undefined` | current field error. |\n| `dirty` | `boolean` | whether branch differs from baseline. |\n| `touched` | `boolean` | whether field was touched. |\n| `field(key)` | `field<v[k]>` | select child object field only. |\n| `set(next)` | `void` | replace branch or derive a replacement. |\n| `reset()` | `void` | restore exact baseline branch. |\n| `touch()` | `void` | mark field touched. |\n| `subscribe(listener, options?)` | `unsubscribe` | observe field transitions; throws after disposal. |\n\n## validation results\n\n### `form.validate(signal?)`\n\n```ts\nfunction validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n```\n\nruns the configured validator against the complete value. a newer validation aborts the older run.\n\n**returns:** `validationresult<tvalues>`.\n\n```ts\nconst result = await form.validate();\n\nif (result.status === 'invalid') console.log(result.errors, result.formerror);\n```\n\n### `form.submit(handler)`\n\n```ts\nfunction submit<tresult = void>(handler: (values: readonlydeep<tvalues>) => maybepromise<tresult>): promise<submitresult<tresult, tvalues>>;\n```\n\ntouches all fields, validates once, and invokes `handler` when validation is valid.\n\n**returns:** `submitresult<tresult, tvalues>`. handler failures reject normally.\n\n```ts\nconst result = await form.submit((value) => promise.resolve(value));\n```\n\n## adapters\n\n### `bindfield(element, field, options)`\n\n```ts\nfunction bindfield<element extends htmlelement, v>(\n element: element,\n field: field<v>,\n options: fieldbindingoptions<element, v>,\n): () => void;\n```\n\nbinds one field to one element, marks it touched on blur, suppresses writeback from its own input event, and returns teardown.\n\n**example:**\n\n```ts\nimport { bindfield } from '@vielzeug/forge/dom';\n\nconst stop = bindfield(input, form.field('email'), {\n read: (element) => element.value,\n write: (element, value) => {\n element.value = value;\n },\n});\n```\n\n \n\n### `customvalidator(schema)`\n\n```ts\nfunction customvalidator<tvalues extends record<string, unknown>>(\n schema: schema<unknown, tvalues, schemamode>,\n): formvalidator<tvalues>;\n```\n\nadapts a spell schema. every failing union maps its closest branch while preserving unrelated errors. array item issues map to the parent array field; duplicate paths retain the first message.\n\n**example:**\n\n```ts\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({ email: s.string().email() });\nconst form = createform({ initialvalues: { email: '' }, validate: customvalidator(profile) });\n```\n\n \n\n### `saveform()` and `loadform()`\n\n```ts\nfunction saveform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, codec: formdraftcodec<tvalues, s, k>,\n): promise<void>;\n\nfunction loadform<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string>(\n form: form<tvalues>, adapter: vaultstore<s>, table: k, key: keyof<s, k>, codec: formdraftcodec<tvalues, s, k>,\n): promise<boolean>;\n```\n\npersists or restores a codec defined vault record. `loadform()` calls `form.reset()` when the codec decodes a record.\n\n**returns:** `loadform()` returns `false` for a missing or rejected record.\n\n## types\n\n```ts\ntype unsubscribe = () => void;\ntype maybepromise<t> = t | promiselike<t>;\ntype readonlydeep<t> = t extends (...args: never[]) => unknown\n ? t\n : t extends readonly (infer item)[]\n ? readonly readonlydeep<item>[]\n : t extends record<string, unknown>\n ? { readonly [k in keyof t]: readonlydeep<t[k]> }\n : t;\n\ntype formerrors<t> = t extends readonly unknown[]\n ? string\n : t extends record<string, unknown>\n ? string | { readonly [k in keyof t]?: formerrors<t[k]> }\n : string;\n\ntype validationerrors<tvalues extends record<string, unknown>> = readonly<{\n fields?: formerrors<tvalues>;\n formerror?: string;\n}>;\n\ntype formvalidator<tvalues extends record<string, unknown>> = (\n values: readonlydeep<tvalues>, signal: abortsignal,\n) => maybepromise<validationerrors<tvalues> | undefined>;\n\ntype formoptions<tvalues extends record<string, unknown>> = readonly<{\n initialvalues: tvalues;\n onsubscribererror?: (error: unknown) => void;\n validate?: formvalidator<noinfer<tvalues>>;\n}>;\n\ntype subscribeoptions = readonly<{ immediate?: boolean }>;\n\ntype fieldstate<v> = readonly<{\n dirty: boolean;\n error: string | undefined;\n touched: boolean;\n value: readonlydeep<v>;\n}>;\n\ntype formstate<tvalues extends record<string, unknown>> = readonly<{\n error: string | undefined;\n errors: formerrors<tvalues> | undefined;\n submitcount: number;\n submitting: boolean;\n touched: boolean;\n valid: boolean;\n validating: boolean;\n}>;\n\ntype validationresult<tvalues extends record<string, unknown>> =\n | readonly<{ status: 'aborted' }>\n | readonly<{ status: 'valid' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; status: 'invalid' }>;\n\ntype submitresult<tresult = void, tvalues extends record<string, unknown> = record<string, unknown>> =\n | readonly<{ ok: true; value: tresult }>\n | readonly<{ ok: false; type: 'aborted' }>\n | readonly<{ errors: formerrors<tvalues> | undefined; formerror: string | undefined; ok: false; type: 'validation' }>;\n```\n\n```ts\ntype field<v> = {\n readonly dirty: boolean;\n readonly error: string | undefined;\n readonly touched: boolean;\n readonly value: readonlydeep<v>;\n field<k extends keyof nonnullable<v> & string>(key: k): field<nonnullable<v>[k]>;\n reset(): void;\n set(next: v | ((previous: readonlydeep<v>) => v)): void;\n subscribe(listener: (state: fieldstate<v>) => void, options?: subscribeoptions): unsubscribe;\n touch(): void;\n};\n\ntype form<tvalues extends record<string, unknown>> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly state: formstate<tvalues>;\n readonly value: readonlydeep<tvalues>;\n dispose(): void;\n field<k extends keyof tvalues & string>(key: k): field<tvalues[k]>;\n reset(next?: tvalues): void;\n set(next: tvalues | ((previous: readonlydeep<tvalues>) => tvalues)): void;\n submit<tresult = void>(handler: (values: readonlydeep<tvalues>) => maybepromise<tresult>): promise<submitresult<tresult, tvalues>>;\n subscribe(listener: (state: formstate<tvalues>) => void, options?: subscribeoptions): unsubscribe;\n validate(signal?: abortsignal): promise<validationresult<tvalues>>;\n};\n\ntype fieldbindingoptions<element extends htmlelement, v> = readonly<{\n event?: keyof htmlelementeventmap;\n read(element: element): v;\n write?: (element: element, value: readonlydeep<v>) => void;\n}>;\n\ntype formdraftcodec<tvalues extends record<string, unknown>, s extends anyschema, k extends keyof s & string> = readonly<{\n fromrecord(record: recordof<s, k>): tvalues | undefined;\n torecord(values: readonlydeep<tvalues>): recordof<s, k>;\n}>;\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `forgeerror` | base forge error | `forgeerror.is(error)` narrows unknown values. |\n| `forgeconfigerror` | unsafe key or unsupported form value | extends `forgeerror`. |\n| `forgedisposederror` | operation or subscription after disposal | message names the attempted operation. |\n| `forgesubmiterror` | concurrent `submit()` call | extends `forgeerror`. |\n| `forgevalidationerror` | validator throws unexpectedly | preserves original error as `cause`. |\n",
470
+ "usage": " \ntitle: forge — usage guide\ndescription: build immutable forms, validate whole values, and use optional adapters.\n \n\n[[toc]]\n\n## basic usage\n\ncreate one form value and update object branches through stable typed operations. form values support primitives, plain objects, arrays, `file`, and `blob`; mutable class instances such as `date`, `map`, and `set` are rejected.\n\n```ts\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({\n initialvalues: { profile: { email: '', name: '' }, tags: [] as string[] },\n validate: (value) => ({\n fields: { profile: { email: value.profile.email.includes('@') ? undefined : 'invalid email' } },\n }),\n});\n\nconst email = form.field('profile').field('email');\nemail.set('ada@example.com');\nform.field('tags').set((tags) => [...tags, 'typescript']);\n\nconsole.log(form.value.profile.email);\n```\n\n## reset values and branches\n\nreset a field when one branch should return to its exact baseline. reset the form with a value when newly loaded data should become the clean baseline.\n\n```ts\nconst name = form.field('profile').field('name');\n\nname.set('ada');\nname.touch();\nname.reset();\n\nform.reset({ profile: { email: 'ada@example.com', name: 'ada' }, tags: [] });\n```\n\nan absent optional parent remains absent after a child reset. arrays are complete values; replace them with an updater instead of retaining index handles.\n\n## validate and submit\n\nreturn `fields` and an optional `formerror` from one validator. `validate()` replaces the complete validation snapshot and returns an explicit status.\n\n```ts\nconst passwordform = createform({\n initialvalues: { password: '', passwordconfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'use at least eight characters',\n passwordconfirmation: value.password === value.passwordconfirmation ? undefined : 'passwords must match',\n },\n }),\n});\n\nconst validation = await passwordform.validate();\n\nif (validation.status === 'invalid') console.log(validation.errors);\nif (validation.status === 'aborted') console.log('validation cancelled');\n\nconst result = await passwordform.submit((value) => promise.resolve(value.password.length));\n\nif (result.ok) console.log(result.value);\n```\n\nstarting another validation aborts the previous run. field edits preserve existing errors until the next validation replaces them. unexpected validator failures reject as `forgevalidationerror` with the original error as `cause`.\n\n## observe state\n\nuse form subscriptions for aggregate metadata and field subscriptions for one branch. subscribing after disposal throws `forgedisposederror`.\n\n```ts\nconst errors: unknown[] = [];\nconst observedform = createform({\n initialvalues: { email: '' },\n onsubscribererror: (error) => errors.push(error),\n});\n\nconst stopform = observedform.subscribe((state) => {\n console.log(state.valid, state.submitting);\n}, { immediate: true });\nconst stopfield = observedform.field('email').subscribe((state) => {\n console.log(state.value, state.error);\n}, { immediate: true });\n\nstopfield();\nstopform();\n```\n\nwithout `onsubscribererror`, forge rethrows subscriber failures asynchronously after completing its state transition.\n\n## testing\n\ntest the form without a dom. read its immutable value, invoke a method, then assert the resulting state or validation result.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createform } from '@vielzeug/forge';\n\ntest('requires an email address', async () => {\n const form = createform({\n initialvalues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'invalid email' } }),\n });\n\n await expect(form.validate()).resolves.toequal({\n errors: { email: 'invalid email' },\n formerror: undefined,\n status: 'invalid',\n });\n});\n```\n\n## framework integration\n\nuse `form.value` and subscriptions with any renderer. bind one dom input through `/dom`; validation scheduling remains application policy.\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\n\nexport function emailform() {\n const [, rerender] = usestate(0);\n\n useeffect(() => {\n const stop = form.subscribe(() => rerender((revision) => revision + 1));\n\n return () => stop();\n }, []);\n\n return <input value={form.field('email').value} onchange={(event) => form.field('email').set(event.target.value)} />;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport { createform } from '@vielzeug/forge';\n\nconst form = createform({ initialvalues: { email: '' } });\nconst revision = ref(0);\nconst stop = form.subscribe(() => revision.value++);\n\nonunmounted(stop);\n```\n\n```ts [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createform } from '@vielzeug/forge';\n\n const form = createform({ initialvalues: { email: '' } });\n let revision = 0;\n const stop = form.subscribe(() => revision++);\n\n ondestroy(stop);\n</script>\n\n<input value={form.field('email').value} on:input={(event) => form.field('email').set(event.currenttarget.value)} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse spell when one schema owns validation and vault when an explicit record codec owns persistence.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({ email: s.string().email() });\nconst form = createform({ initialvalues: { email: '' }, validate: customvalidator(profile) });\n```\n\n`customvalidator()` preserves unrelated spell errors, maps each union to its closest branch, and maps array item failures to the parent array field. parse again at the submit boundary when a spell transform must produce the outgoing payload.\n\n```ts\nimport { loadform, saveform } from '@vielzeug/forge/vault';\n\nawait saveform(form, db, 'drafts', codec);\nconst restored = await loadform(form, db, 'drafts', 'profile', codec);\nconsole.log(restored);\n```\n\n`loadform()` uses `form.reset()`, so a restored value is clean. store a selected `file`, not `filelist`, in form state; `filelist` is transport only for `toformdata()`.\n\n## best practices\n\n keep form values to primitives, plain objects, arrays, `file`, and `blob`.\n update array fields through immutable replacement functions.\n validate complete values instead of rebuilding field validator graphs.\n handle `aborted` validation results before rendering errors.\n preserve errors through field edits until a deliberate validation refresh.\n return subscription cleanup from framework lifecycle hooks.\n provide `onsubscribererror` when application subscribers can throw.\n decode vault records before passing them to `loadform()`.\n",
471
+ "examples": " \ntitle: forge — examples\ndescription: practical immutable form recipes.\n \n\n## examples\n\n [login form](./examples/login form.md)\n [conditional values](./examples/form with conditional fields.md)\n [dynamic arrays](./examples/dynamic form fields.md)\n [contact form with file upload](./examples/contact form with file upload.md)\n [registration form](./examples/registration form.md)\n [multi step wizard](./examples/multi step wizard.md)\n [search form with debounce](./examples/search form with debounce.md)\n"
472
+ },
473
+ "examples": [
474
+ {
475
+ "id": "array-fields",
476
+ "text": "immutable array updates import { createform } from '@vielzeug/forge'\n\nconst form = createform({ initialvalues: { tags: ['typescript'] } })\nconst tags = form.field('tags')\n\ntags.set((previous) => [...previous, 'forms'])\ntags.set((previous) => previous.filter((tag) => tag !== 'typescript'))\nconsole.log(form.value.tags)"
477
+ },
478
+ {
479
+ "id": "create-form",
480
+ "text": "create immutable form import { createform } from '@vielzeug/forge'\n\nconst form = createform({ initialvalues: { account: { email: '' }, name: '' } })\nconst email = form.field('account').field('email')\n\nemail.set('ada@example.com')\nconsole.log(form.value)\nconsole.log(form.state.valid)"
481
+ },
482
+ {
483
+ "id": "dynamic-fields",
484
+ "text": "dynamic values import { createform } from '@vielzeug/forge'\n\nconst form = createform({ initialvalues: { contacts: [] as { email: string }[] } })\nconst contacts = form.field('contacts')\n\ncontacts.set((previous) => [...previous, { email: 'ada@example.com' }])\ncontacts.set((previous) => previous.slice(1))\nconsole.log(form.value)"
485
+ },
486
+ {
487
+ "id": "field-binding",
488
+ "text": "dom field binding import { createform } from '@vielzeug/forge'\nimport { bindfield } from '@vielzeug/forge/dom'\n\nconst form = createform({\n initialvalues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'invalid email' } }),\n})\nconst email = form.field('email')\nconst input = document.createelement('input')\nconst stop = bindfield(input, email, {\n read: (element) => element.value,\n write: (element, value) => { element.value = value },\n})\n\ninput.value = 'ada'\ninput.dispatchevent(new event('input'))\ninput.dispatchevent(new event('blur'))\nconsole.log(email.value, email.touched)\nconsole.log(await form.validate())\nstop()"
489
+ },
490
+ {
491
+ "id": "field-operations",
492
+ "text": "focused field operations import { createform } from '@vielzeug/forge'\n\nconst form = createform({ initialvalues: { profile: { name: 'ada' } } })\nconst name = form.field('profile').field('name')\n\nname.set('grace')\nname.touch()\nconsole.log(name.value, name.dirty, name.touched)\nname.reset()\nconsole.log(form.value)"
493
+ },
494
+ {
495
+ "id": "form-submission",
496
+ "text": "form submission import { createform } from '@vielzeug/forge'\n\nconst form = createform({\n initialvalues: { email: '' },\n validate: (value) => ({ fields: { email: value.email.includes('@') ? undefined : 'invalid email' } }),\n})\n\nform.field('email').set('ada@example.com')\nconst result = await form.submit(async (value) => ({ ...value, saved: true }))\nconsole.log(result)"
497
+ },
498
+ {
499
+ "id": "form-subscriptions",
500
+ "text": "form and field subscriptions import { createform } from '@vielzeug/forge'\n\nconst form = createform({\n initialvalues: { email: '', name: '' },\n onsubscribererror: (error) => console.log('subscriber error:', error),\n})\nconst stopform = form.subscribe((state) => console.log('valid:', state.valid), { immediate: true })\nconst stopemail = form.field('email').subscribe((state) => console.log('email:', state), { immediate: true })\n\nform.field('name').set('ada')\nform.field('email').set('ada@example.com')\nstopemail()\nstopform()"
501
+ },
502
+ {
503
+ "id": "form-validation",
504
+ "text": "whole value validation import { createform } from '@vielzeug/forge'\n\nconst form = createform({\n initialvalues: { password: '', passwordconfirmation: '' },\n validate: (value) => ({\n fields: {\n password: value.password.length >= 8 ? undefined : 'use at least eight characters',\n passwordconfirmation: value.password === value.passwordconfirmation ? undefined : 'passwords must match',\n },\n }),\n})\n\nform.field('password').set('short')\nconsole.log(await form.validate())\n\nform.field('password').set('strong password')\nform.field('passwordconfirmation').set('strong password')\nconsole.log(await form.validate())"
505
+ },
506
+ {
507
+ "id": "schema-integration",
508
+ "text": "spell schema integration import { createform } from '@vielzeug/forge'\nimport { customvalidator } from '@vielzeug/forge/spell'\nimport { s } from '@vielzeug/spell'\n\nconst profile = s.object({ email: s.string().email() })\nconst form = createform({\n initialvalues: { email: '' },\n validate: customvalidator(profile),\n})\n\nform.field('email').set('ada')\nconsole.log(await form.validate())\nconsole.log(form.field('email').error)"
509
+ },
510
+ {
511
+ "id": "scoped-sub-forms",
512
+ "text": "nested field handles import { createform } from '@vielzeug/forge'\n\nconst form = createform({ initialvalues: { shipping: { city: '', street: '' } } })\nconst shipping = form.field('shipping')\n\nshipping.field('street').set('123 main street')\nshipping.field('city').set('portland')\nconsole.log(shipping.value)\nconsole.log(form.value.shipping)"
513
+ }
514
+ ],
515
+ "exports": "createform toformdata bindfield customvalidator saveform loadform",
516
+ "keywords": "form state validation immutable input submission",
517
+ "name": "@vielzeug/forge",
518
+ "related": "spell vault courier",
519
+ "slug": "forge",
520
+ "source": "export { toformdata } from './adapters/form data';\nexport { forgeconfigerror, forgedisposederror, forgeerror, forgesubmiterror, forgevalidationerror } from './errors';\nexport { createform } from './form';\nexport * from './types';\n"
521
+ },
522
+ {
523
+ "category": "events",
524
+ "description": "typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and abortsignal lifecycle.",
525
+ "docs": {
526
+ "index": " \ntitle: herald — typed event bus for typescript\ndescription: typed temporal event delivery with sync subscriptions, async waiting, streams, pipes, and abortsignal lifecycle.\npackage: herald\ncategory: events\nkeywords: [event bus, typed events, pub sub, async streams, abort signal]\nrelated: [ripple, wayfinder, familiar]\nexports: [createbus, pipeevents, combinesignals, heralderror, busdisposederror, heraldconfigerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"herald\" />\n\n## why herald?\n\nraw event emitters lose payload inference and leave waiting, streaming, cancellation, and teardown to every caller. herald keeps events temporal: use [ripple](/ripple/) when you need retained state.\n\n```ts\n// before\nconst listeners = new set<(payload: unknown) => void>();\nlisteners.add((payload) => loadprofile((payload as { id: string }).id));\n\n// after\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n}\n\nfunction loadprofile(id: string): void {\n console.log(id);\n}\n\nconst bus = createbus<appevents>();\nbus.on('user:login', ({ id }) => loadprofile(id));\n```\n\n| feature | herald | mitt | eventemitter3 |\n| | | | |\n| bundle size | <packageinfo package=\"herald\" type=\"size\" /> | ~200 b | ~1.5 kb |\n| typed payloads | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| async wait and streams | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| abortsignal lifecycle | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed event pipes | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use herald when** modules need typed temporal event delivery with owned lifecycle.\n\n**consider ripple when** consumers need current state and replayed values.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/herald\n```\n\n```sh [npm]\nnpm install @vielzeug/herald\n```\n\n```sh [yarn]\nyarn add @vielzeug/herald\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'user:login': { id: string };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('user:login', ({ id }) => console.log(id));\n\nbus.emit('user:login', { id: '42' });\nstop();\nbus.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `on()` / `once()` — typed subscriptions with explicit teardown\n `onany()` — cross cutting event observation\n `wait()` / `waitany()` — one shot async coordination\n `events()` — bounded async event streams\n `pipeevents()` — compatible cross bus forwarding\n `abortsignal` — cancellation and disposal ownership\n `createtestbus()` — emitted payload recording for tests\n `debugbus()` — development logging from `/devtools`\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ripple](/ripple/) — retained reactive state.\n [wayfinder](/wayfinder/) — route lifecycle events.\n [familiar](/familiar/) — worker completion events.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
527
+ "api": " \ntitle: herald — api reference\ndescription: reference for typed temporal event delivery, lifecycle ownership, and compatible event piping.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createbus()` | create typed temporal event bus | sync | `emit()` and middleware are synchronous |\n| `pipeevents()` | forward compatible source events | sync | payloads must be assignable to target event |\n| `combinesignals()` | abort when any input aborts | sync | public composition has no manual teardown |\n| `createtestbus()` | record dispatched test events | sync | available from `/testing` only |\n| `debugbus()` | create console debug instrumented bus | sync | available from `/devtools` only |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/herald` | runtime bus, pipes, public types, and errors |\n| `@vielzeug/herald/testing` | `createtestbus()` and `testbus` |\n| `@vielzeug/herald/devtools` | `debugbus()` |\n\n## core functions\n\n### `createbus()`\n\n```ts\nfunction createbus<t extends eventmap = record<string, unknown>>(\n options?: busoptions<t>,\n): bus<t>;\n```\n\ncreates a synchronous bus for future event delivery.\n\n| parameter | type | description |\n| | | |\n| `options` | `busoptions<t>` | optional middleware, validation, error handling, logging, and listener threshold configuration. |\n\n**returns:** `bus<t>`.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface events {\n count: number;\n ready: void;\n}\n\nconst bus = createbus<events>();\nbus.emit('count', 1);\nbus.emit('ready');\nbus.dispose();\n```\n\n \n\n### `pipeevents()`\n\n```ts\nfunction pipeevents<s extends eventmap, t extends eventmap>(\n source: bus<s>,\n target: bus<t>,\n entries: readonly [noinfer<pipeentry<s, t>>, ...noinfer<pipeentry<s, t>>[]],\n opts?: { signal?: abortsignal },\n): unsubscribe;\n```\n\nforwards listed compatible events until manually stopped, either bus disposes, or `options.signal` aborts.\n\n| parameter | type | description |\n| | | |\n| `source` | `bus<s>` | bus that emits source events. |\n| `target` | `bus<t>` | bus that receives compatible events. |\n| `entries` | non empty `pipeentry` tuple | same name keys or compatible `{ from, to }` mappings. |\n| `opts.signal` | `abortsignal` | optional pipe lifetime signal. |\n\n**returns:** idempotent `unsubscribe` function.\n\n```ts\nimport { createbus, pipeevents } from '@vielzeug/herald';\n\ninterface sourceevents {\n 'auth:login': { id: string };\n}\n\ninterface targetevents {\n 'user:authenticated': { id: string };\n}\n\nconst source = createbus<sourceevents>();\nconst target = createbus<targetevents>();\nconst stop = pipeevents(source, target, [{ from: 'auth:login', to: 'user:authenticated' }]);\n\nstop();\nsource.dispose();\ntarget.dispose();\n```\n\n \n\n### `combinesignals()`\n\n```ts\nfunction combinesignals(first: abortsignal, ...rest: abortsignal[]): abortsignal;\n```\n\nreturns a signal aborted with first input signal's reason.\n\n**returns:** `abortsignal`.\n\n```ts\nimport { combinesignals } from '@vielzeug/herald';\n\nconst signal = combinesignals(abortsignal.timeout(1_000), controller.signal);\n```\n\ninput listeners remain until an input aborts. bus apis that accept `{ signal }` clean their internal signal composition when their owned operation ends.\n\n## types\n\n### `eventmap` and `eventkey`\n\n```ts\ntype eventmap = object;\ntype eventkey<t extends eventmap> = extract<keyof t, string>;\n```\n\n`eventmap` accepts interfaces and type aliases. only string keys are event names.\n\n \n\n### `busoptions`\n\n```ts\ntype busoptions<t extends eventmap = eventmap> = {\n logger?: buslogger;\n maxlisteners?: number;\n middleware?: readonly middleware<t>[];\n name?: string;\n onerror?: (context: emissionerrorcontext<t>) => void;\n validatepayload?: <k extends eventkey<t>>(event: k, payload: t[k]) => void;\n};\n```\n\n| field | description |\n| | |\n| `logger` | optional debug and warning output. |\n| `maxlisteners` | warn when one event exceeds this active listener count. |\n| `middleware` | synchronous dispatch middleware. |\n| `name` | display name in debug logs and disposal errors. |\n| `onerror` | handles listener and validation errors instead of rethrowing. |\n| `validatepayload` | runs before middleware and listeners. |\n\n \n\n### `bus`\n\n```ts\ntype bus<t extends eventmap> = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n emit<k extends eventkey<t>>(event: k, ...args: t[k] extends void ? [] : [payload: t[k]]): number;\n eventnames(): eventkey<t>[];\n events<k extends eventkey<t>>(event: k, opts?: { maxbuffer?: number; signal?: abortsignal }): eventstream<t[k]>;\n listenercount(event?: eventkey<t>): number;\n on<k extends eventkey<t>>(event: k, listener: listener<t[k]>, opts?: subscribeoptions): unsubscribe;\n onany(listener: (event: eventkey<t>, payload: unknown) => void, opts?: subscribeoptions): unsubscribe;\n once<k extends eventkey<t>>(event: k, listener: listener<t[k]>, opts?: { signal?: abortsignal }): unsubscribe;\n wait<k extends eventkey<t>>(event: k, opts?: { signal?: abortsignal }): promise<t[k]>;\n waitany<const k extends readonly [eventkey<t>, eventkey<t>, ...eventkey<t>[]]>(\n events: k,\n opts?: { signal?: abortsignal },\n ): promise<waitanyresult<t, k>>;\n wildcardcount(): number;\n};\n```\n\n`emit()` returns listener count or `0` after disposal, blocked middleware, or handled validation rejection.\n\n \n\n### `buslogger`, `listener`, `subscribeoptions`, and `unsubscribe`\n\n```ts\ntype buslogger = {\n debug?: (message: string) => void;\n warn?: (message: string) => void;\n};\n\ntype listener<t> = (payload: t) => void;\ntype subscribeoptions = { once?: boolean; signal?: abortsignal };\ntype unsubscribe = () => void;\n```\n\n \n\n### `emissionerrorcontext` and `middleware`\n\n```ts\ntype emissionerrorcontext<t extends eventmap = eventmap> = {\n err: unknown;\n event: eventkey<t>;\n payload: unknown;\n timestamp: number;\n};\n\ntype middleware<t extends eventmap = eventmap> = (\n event: eventkey<t>,\n payload: unknown,\n next: () => void,\n) => void;\n```\n\ncall middleware `next()` synchronously at most once. omit it to block dispatch.\n\n \n\n### `eventstream` and `waitanyresult`\n\n```ts\ntype eventstream<t> = asyncgenerator<t> & asyncdisposable;\n\ntype waitanyresult<t extends eventmap, k extends readonly eventkey<t>[]> = {\n [i in keyof k]: k[i] extends eventkey<t> ? { event: k[i]; payload: t[k[i]] } : never;\n}[number];\n```\n\n \n\n### `pipeablekey`, `renamedpipeentry`, and `pipeentry`\n\n```ts\ntype pipeablekey<s extends eventmap, t extends eventmap> = {\n [k in eventkey<s> & eventkey<t>]: s[k] extends t[k] ? k : never;\n}[eventkey<s> & eventkey<t>];\n\ntype renamedpipeentry<s extends eventmap, t extends eventmap> = {\n [from in eventkey<s>]: {\n [to in eventkey<t>]: s[from] extends t[to] ? { from: from; to: to } : never;\n }[eventkey<t>];\n}[eventkey<s>];\n\ntype pipeentry<s extends eventmap, t extends eventmap> =\n | pipeablekey<s, t>\n | renamedpipeentry<s, t>;\n```\n\n## testing and devtools\n\n### `createtestbus()`\n\n```ts\nfunction createtestbus<t extends eventmap = record<string, unknown>>(\n options?: busoptions<t>,\n): testbus<t>;\n```\n\ncreates a bus that records dispatched payloads.\n\n**returns:** `testbus<t>`.\n\n### `testbus`\n\n```ts\ntype testbus<t extends eventmap> = bus<t> & {\n allemitted(): { [k in eventkey<t>]?: t[k][] };\n emitted<k extends eventkey<t>>(event: k): t[k][];\n emittedcount<k extends eventkey<t>>(event: k): number;\n reset(): void;\n};\n```\n\n### `debugbus()`\n\n```ts\nfunction debugbus<t extends eventmap>(\n options?: omit<busoptions<t>, 'logger'> & { logger?: { warn?: buslogger['warn'] } },\n): bus<t>;\n```\n\ncreates a bus with `console.debug` logging. import from `@vielzeug/herald/devtools`.\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `busdisposederror` | `wait()` or `waitany()` interrupted by disposal | bus name appears when configured. |\n| `heraldconfigerror` | invalid stream buffer, empty pipe entries, or fewer than two `waitany()` events | — |\n| `heralderror` | base class for herald originated errors | `instanceof heralderror` narrows subclasses. |\n",
528
+ "usage": " \ntitle: herald — usage guide\ndescription: typed event maps, lifecycle owned subscriptions, waits, streams, pipes, and testing.\n \n\n[[toc]]\n\n## basic usage\n\nuse interface or type alias event maps. events model facts that happened; use ripple for current state.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\n\ninterface appevents {\n 'cart:updated': { count: number };\n 'user:logout': void;\n}\n\nconst bus = createbus<appevents>();\nconst stop = bus.on('cart:updated', ({ count }) => console.log(count));\n\nbus.emit('cart:updated', { count: 1 });\nstop();\nbus.dispose();\n```\n\n## subscriptions\n\nuse `once()` for one event and `{ signal }` for owned subscription lifetime.\n\n```ts\nconst controller = new abortcontroller();\n\nbus.on('cart:updated', rendercart, { signal: controller.signal });\nbus.once('user:logout', clearsession);\ncontroller.abort();\n```\n\n## middleware and validation\n\nmiddleware is synchronous. call `next()` once to continue; omit it to block dispatch.\n\n```ts\nconst bus = createbus<appevents>({\n middleware: [\n (event, payload, next) => {\n audit(event, payload);\n next();\n },\n ],\n validatepayload: (event, payload) => {\n if (event === 'cart:updated' && payload.count < 0) throw new rangeerror('count must be non negative');\n },\n});\n```\n\n## awaiting events\n\n```ts\nconst cart = await bus.wait('cart:updated', { signal: abortsignal.timeout(5_000) });\nconst winner = await bus.waitany(['cart:updated', 'user:logout'], { signal: abortsignal.timeout(5_000) });\n```\n\n## streaming events\n\n`events()` subscribes eagerly. bound buffers for producers faster than consumers.\n\n```ts\nawait using stream = bus.events('cart:updated', { maxbuffer: 100 });\n\nfor await (const cart of stream) {\n rendercart(cart);\n}\n```\n\n## piping events\n\n`pipeevents()` only accepts compatible payloads. stop explicitly or tie pipe to signal.\n\n```ts\nconst stoppipe = pipeevents(sourcebus, auditbus, ['cart:updated'], { signal: pagesignal });\nstoppipe();\n```\n\n## testing\n\n`createtestbus()` records dispatched payloads without mocks.\n\n```ts\nimport { createtestbus } from '@vielzeug/herald/testing';\n\nconst bus = createtestbus<appevents>();\nbus.emit('cart:updated', { count: 2 });\nexpect(bus.emitted('cart:updated')).toequal([{ count: 2 }]);\nbus.dispose();\n```\n\n## debugging\n\n```ts\nimport { debugbus } from '@vielzeug/herald/devtools';\n\nconst bus = debugbus<appevents>({ name: 'cart' });\n```\n\n## working with other vielzeug libraries\n\nuse herald for temporal events. use ripple for retained reactive state. use familiar or courier completion handlers to emit application events.\n\n## best practices\n\n define one explicit event map per boundary.\n emit facts, not mutable application state.\n keep middleware synchronous and call `next()` once.\n pass abortsignals for component/request scoped work.\n set `maxbuffer` for long lived streams.\n use `wait()` only for one off coordination.\n use unsubscribe handles instead of global listener removal.\n dispose owner scoped buses.\n",
529
+ "examples": " \ntitle: herald — examples\ndescription: practical examples and recipes for herald.\n \n\n## examples\n\n [standalone entry](./examples/standalone entry.md)\n [module level bus](./examples/module level bus.md)\n [awaiting a one time event](./examples/awaiting a one time event.md)\n [inspecting listener counts](./examples/inspecting listener counts.md)\n [custom error boundary](./examples/custom error boundary.md)\n [handling disposal in async code](./examples/handling disposal in async code.md)\n [request scoping](./examples/request scoping.md)\n [streaming with events](./examples/streaming with events.md)\n [bus bridging with pipeevents](./examples/bus bridging with pipeevents.md)\n [testing with createtestbus](./examples/testing with createtestbus.md)\n"
530
+ },
531
+ "examples": [
532
+ {
533
+ "id": "abort-signal",
534
+ "text": "abortsignal & busdisposederror import { createbus, busdisposederror } from '@vielzeug/herald'\n\n// demonstrate abortsignal auto unsubscribe and busdisposederror\nconst bus = createbus()\n\nconst controller = new abortcontroller()\nconst { signal } = controller\n\nbus.on('message', (msg) => {\n console.log('listener received:', msg)\n}, { signal })\n\nbus.emit('message', 'first') // fires\nbus.emit('message', 'second') // fires\n\ncontroller.abort() // removes the listener\n\nbus.emit('message', 'third') // ignored — no listeners\nconsole.log('listeners after abort:', bus.listenercount())\n\n// busdisposederror: pending wait() rejects when bus is disposed\nconst bus2 = createbus()\n\nvoid bus2.wait('done').catch((err) => {\n if (err instanceof busdisposederror) {\n console.log('busdisposederror caught:', err.message)\n }\n})\n\nbus2.dispose()"
535
+ },
536
+ {
537
+ "id": "async-generator",
538
+ "text": "events() async generator import { createbus } from '@vielzeug/herald'\n\n// events() returns an async generator that yields each emitted value in order\nconst bus = createbus()\n\nasync function consumeticks() {\n let received = 0\n for await (const tick of bus.events('tick', { maxbuffer: 2 })) {\n console.log('tick:', tick)\n received++\n if (received >= 3) break\n }\n console.log('done after', received, 'ticks')\n}\n\nvoid consumeticks()\n\nlet count = 0\nconst interval = setinterval(() => {\n bus.emit('tick', ++count)\n if (count >= 5) {\n clearinterval(interval)\n bus.dispose()\n }\n}, 30)"
539
+ },
540
+ {
541
+ "id": "async-wait",
542
+ "text": "async wait() import { createbus } from '@vielzeug/herald'\n\n// await a single event with wait(), race multiple with waitany()\nconst bus = createbus()\n\n// emit after a short delay\nsettimeout(() => bus.emit('user:login', { userid: '42', email: 'alice@example.com' }), 50)\nsettimeout(() => bus.emit('theme:change', 'dark'), 80)\n\n// wait() resolves on the next emit of that event\nconst loginpayload = await bus.wait('user:login')\nconsole.log('got login:', loginpayload.userid)\n\n// reset and race two events — whichever fires first wins\nsettimeout(() => bus.emit('user:login', { userid: '1', email: 'a@b.com' }), 20)\nsettimeout(() => bus.emit('theme:change', 'light'), 60)\n\nconst result = await bus.waitany(['user:login', 'theme:change'])\n\nif (result.event === 'user:login') {\n console.log('login won the race:', result.payload.userid)\n} else {\n console.log('theme won the race:', result.payload)\n}\n\nbus.dispose()"
543
+ },
544
+ {
545
+ "id": "basic-bus",
546
+ "text": "basic bus import { createbus } from '@vielzeug/herald'\n\n// typed pub/sub with on(), emit(), and once()\nconst bus = createbus()\n\nconst unsub = bus.on('user:login', ({ userid, email }) => {\n console.log('login:', userid, email)\n})\n\nbus.once('user:logout', () => {\n console.log('logged out (fires once)')\n})\n\nbus.emit('user:login', { userid: '1', email: 'alice@example.com' })\nbus.emit('user:logout')\nbus.emit('user:logout') // once() already removed; no output\n\nunsub()\nbus.emit('user:login', { userid: '2', email: 'bob@example.com' }) // no output — unsubscribed\n\nbus.dispose()"
547
+ },
548
+ {
549
+ "id": "bus-basics",
550
+ "text": "createbus basics import { createbus } from '@vielzeug/herald'\n\n// on() returns an unsubscribe handle; emit() delivers to all active listeners\nconst bus = createbus()\n\nconst unsublogin = bus.on('user:login', (payload) => {\n console.log('login:', payload.name, '(' + payload.userid + ')')\n})\n\nbus.on('user:logout', () => console.log('logged out'))\nbus.on('notification', (msg) => console.log('notification:', msg))\n\nbus.emit('user:login', { userid: '123', name: 'alice' })\nbus.emit('notification', 'welcome back!')\n\nunsublogin()\n\nbus.emit('user:login', { userid: '456', name: 'bob' }) // no output — unsubscribed\nconsole.log('active listeners:', bus.listenercount())\n\nbus.dispose()"
551
+ },
552
+ {
553
+ "id": "disposal-signal",
554
+ "text": "disposalsignal import { createbus } from '@vielzeug/herald'\n\n// disposalsignal ties an external subscription's lifetime to this bus\nconst mainbus = createbus()\nconst childbus = createbus()\n\n// pass disposalsignal so the child listener is removed when mainbus disposes\nchildbus.on('data:update', ({ value }) => {\n console.log('child received:', value)\n}, { signal: mainbus.disposalsignal })\n\nconsole.log('child listeners before dispose:', childbus.listenercount())\n\nchildbus.emit('data:update', { value: 10 }) // fires — listener is active\n\nmainbus.dispose() // disposalsignal fires — child listener auto removed\n\nconsole.log('child listeners after mainbus.dispose():', childbus.listenercount())\n\nchildbus.emit('data:update', { value: 20 }) // no output — listener is gone\nconsole.log('mainbus.disposed:', mainbus.disposed)\nconsole.log('disposalsignal aborted:', mainbus.disposalsignal.aborted)"
555
+ },
556
+ {
557
+ "id": "error-handling",
558
+ "text": "error handling import { createbus, heralderror } from '@vielzeug/herald'\n\n// onerror captures listener throws — every listener still runs, even a buggy one\nconst errors = []\n\nconst bus = createbus({\n onerror: ({ err, event }) => errors.push({ event, message: err.message }),\n})\n\nbus.on('order:placed', () => console.log('confirmation email sent'))\nbus.on('order:placed', () => {\n throw new error('inventory check failed')\n})\nbus.on('order:placed', () => console.log('analytics event recorded')) // still runs\n\nbus.emit('order:placed', { id: 'ord 1', total: 49.99 })\n\nconsole.log('captured errors:', errors)\n// [{ event: 'order:placed', message: 'inventory check failed' }]\n\n// without onerror, the first error rethrows once every listener has run —\n// instanceof heralderror catches it without importing every herald error subclass\ntry {\n bus.waitany(['event a']) // waitany requires at least 2 event keys\n} catch (err) {\n console.log('caught herald error?', err instanceof heralderror, ' ', err.message)\n}\n\nbus.dispose()"
559
+ },
560
+ {
561
+ "id": "event-stream-take",
562
+ "text": "events() with break import { createbus } from '@vielzeug/herald'\n\n// collect the first 3 emits then break — await using ensures cleanup\nconst bus = createbus()\n\nasync function collectfirstthree() {\n const collected = []\n\n await using stream = bus.events('score')\n for await (const n of stream) {\n collected.push(n)\n console.log('score:', n)\n if (collected.length >= 3) break\n }\n\n console.log('done — collected:', collected.length, 'values')\n}\n\nvoid collectfirstthree()\n\nlet i = 0\nconst timer = setinterval(() => {\n bus.emit('score', ++i * 10)\n if (i >= 6) {\n clearinterval(timer)\n bus.dispose()\n }\n}, 40)"
563
+ },
564
+ {
565
+ "id": "logger-option",
566
+ "text": "custom logger import { createbus } from '@vielzeug/herald'\n\n// custom logger — route or suppress debug and warn output\nconst logs = []\n\nconst bus = createbus({\n maxlisteners: 2,\n logger: {\n debug: (msg) => logs.push('[debug] ' + msg),\n warn: (msg) => logs.push('[warn] ' + msg),\n },\n})\n\nbus.on('order:placed', (order) => console.log('order:', order.id))\nbus.on('order:placed', (order) => console.log('copy:', order.id))\nbus.on('order:placed', () => {}) // triggers maxlisteners warning (> 2)\n\nbus.emit('order:placed', { id: 'ord 001', total: 49.99 })\n\nbus.dispose()\n\nconsole.log('captured log lines:')\nlogs.foreach((l) => console.log(l))"
567
+ },
568
+ {
569
+ "id": "named-bus",
570
+ "text": "named bus import { createbus } from '@vielzeug/herald'\n\n// name appears in log prefixes and busdisposederror messages\nconst logs = []\nconst warns = []\n\nconst authbus = createbus({\n name: 'auth',\n logger: {\n debug: (msg) => { logs.push(msg); console.log(msg) },\n warn: (msg) => warns.push(msg),\n },\n maxlisteners: 1,\n})\n\nauthbus.on('login', (userid) => console.log('user:', userid))\nauthbus.on('login', (userid) => console.log('audit:', userid)) // triggers warn\n\nauthbus.emit('login', 'alice')\n\nconst pending = authbus.wait('logout')\nauthbus.dispose()\n\npending.catch((err) => {\n console.log('error name:', err.name)\n console.log('error message:', err.message)\n console.log('warn included name:', warns[0].includes('auth'))\n})"
571
+ },
572
+ {
573
+ "id": "once-and-wait",
574
+ "text": "once() and wait() import { createbus } from '@vielzeug/herald'\n\n// once() fires exactly once then auto removes; wait() resolves on the next emit\nconst bus = createbus()\n\nbus.once('data:ready', (payload) => {\n console.log('data (once):', payload.items.join(', '))\n})\n\nbus.emit('data:ready', { items: ['alpha', 'beta', 'gamma'] }) // fires\nbus.emit('data:ready', { items: ['ignored'] }) // once already consumed\n\nasync function waitfortask() {\n console.log('waiting for task...')\n const result = await bus.wait('task:done')\n console.log('task done! result:', result.result)\n}\n\nvoid waitfortask()\n\nsettimeout(() => {\n bus.emit('task:done', { result: 99 })\n}, 50)"
575
+ },
576
+ {
577
+ "id": "pipe-events",
578
+ "text": "pipeevents() import { createbus, pipeevents } from '@vielzeug/herald'\n\n// pipeevents forwards a subset of events from one bus to another\nconst appbus = createbus()\nconst auditbus = createbus()\n\n// auditbus only receives auth events, not cart events\nauditbus.on('user:login', ({ email, userid }) => {\n console.log('[audit] login:', email, '(id:', userid + ')')\n})\nauditbus.on('user:logout', () => {\n console.log('[audit] logout recorded')\n})\n\nconst controller = new abortcontroller()\nconst unpipe = pipeevents(appbus, auditbus, ['user:login', 'user:logout'], { signal: controller.signal })\n\nappbus.emit('user:login', { email: 'alice@example.com', userid: '1' })\nappbus.emit('cart:updated', { total: 99 }) // not forwarded\nappbus.emit('user:logout')\n\nunpipe() // stop forwarding\n\nappbus.emit('user:login', { email: 'bob@example.com', userid: '2' }) // not forwarded\nconsole.log('auditbus listeners after unpipe:', auditbus.listenercount())"
579
+ },
580
+ {
581
+ "id": "test-bus",
582
+ "text": "createtestbus() import { createtestbus } from '@vielzeug/herald/testing'\n\n// testbus wraps a normal bus with emission recording — no mocking required\nconst bus = createtestbus()\n\nconst stop = bus.on('cart:updated', (cart) => console.log('handler saw total:', cart.total))\n\nbus.emit('cart:updated', { items: 1, total: 19.99 })\nbus.emit('cart:updated', { items: 2, total: 39.98 })\nbus.emit('user:logout')\n\nconsole.log('emitted count:', bus.emittedcount('cart:updated')) // 2\nconsole.log('all payloads:', bus.emitted('cart:updated'))\nconsole.log('every recorded event:', bus.allemitted())\n\nstop()\nbus.emit('cart:updated', { items: 3, total: 59.97 }) // still recorded\n\nconsole.log('after unsubscribe:', bus.emitted('cart:updated'))\n\nbus.dispose()"
583
+ },
584
+ {
585
+ "id": "wait-any",
586
+ "text": "waitany() import { createbus } from '@vielzeug/herald'\n\n// waitany resolves with { event, payload } for whichever event fires first\nconst bus = createbus()\n\nasync function watchnextsessionevent() {\n console.log('waiting for first session event...')\n\n const result = await bus.waitany(['user:login', 'user:logout', 'session:expired'])\n\n if (result.event === 'user:login') {\n console.log('login:', result.payload.email, '(id:', result.payload.userid + ')')\n } else if (result.event === 'session:expired') {\n console.log('session expired:', result.payload.reason)\n } else {\n console.log('user logged out')\n }\n}\n\nvoid watchnextsessionevent()\n\nsettimeout(() => {\n bus.emit('user:login', { email: 'alice@example.com', userid: '42' })\n bus.emit('user:logout') // ignored — waitany already resolved\n}, 30)"
587
+ },
588
+ {
589
+ "id": "wildcard-listeners",
590
+ "text": "onany + wildcardcount() import { createbus } from '@vielzeug/herald'\n\ntype appevents = {\n 'user:login': { userid: string }\n 'user:logout': void\n 'cart:updated': { items: number }\n}\n\nconst bus = createbus<appevents>({ name: 'app' })\n\n// onany receives every event — useful for logging, analytics, tracing\nconst unsub = bus.onany((event, payload) => {\n console.log('[audit]', event, payload)\n})\n\nconsole.log('wildcard listeners:', bus.wildcardcount()) // 1\n\nbus.emit('user:login', { userid: 'alice' })\nbus.emit('cart:updated', { items: 3 })\nbus.emit('user:logout')\n\n// remove the wildcard listener\nunsub()\nconsole.log('after unsub:', bus.wildcardcount()) // 0\n\nbus.dispose()"
591
+ }
592
+ ],
593
+ "exports": "createbus pipeevents combinesignals heralderror busdisposederror heraldconfigerror",
594
+ "keywords": "event bus typed events pub sub async streams abort signal",
595
+ "name": "@vielzeug/herald",
596
+ "related": "ripple wayfinder familiar",
597
+ "slug": "herald",
598
+ "source": "export { combinesignals, createbus } from './bus';\nexport { busdisposederror, heraldconfigerror, heralderror } from './errors';\nexport { pipeevents } from './pipe';\nexport type {\n bus,\n buslogger,\n busoptions,\n emissionerrorcontext,\n eventkey,\n eventmap,\n eventstream,\n listener,\n middleware,\n pipeablekey,\n pipeentry,\n subscribeoptions,\n unsubscribe,\n waitanyresult,\n} from './types';\n"
599
+ },
600
+ {
601
+ "category": "app infrastructure",
602
+ "description": "target local keyboard shortcut manager with chords, event aware guards, modifier aliases, and terminal disposal.",
603
+ "docs": {
604
+ "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",
605
+ "api": " \ntitle: keymap — api reference\ndescription: complete api reference for @vielzeug/keymap bindings, chords, parsing, formatting, and lifecycle.\n \n\n[[toc]]\n\n## api overview\n\n### core api (most users)\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createkeymap()` | create shortcut manager | sync | `dispose()` is terminal |\n| `findshortcutconflicts()` | find duplicate and prefix paths | sync | invalid non empty input throws |\n| `formatshortcut()` | format shortcut labels | sync | invalid input returns `''` |\n| `chordstatechange` | type for chord state callback events | — | no 'completed' event; handler fires immediately when matched |\n\n### power user api (custom tooling)\n\nuse the power user api if you're building keyboard aware config validators, custom ui, or framework integrations.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `parseshortcut()` | strictly parse full shortcut | sync | empty input throws |\n| `parsestep()` | parse one step without throwing | sync | invalid input returns `null` |\n| `canonicalizeshortcut()` | create stable shortcut key | sync | input must already be parsed |\n| `matchstep()` | test event against parsed step | sync | extra modifiers prevent a match |\n| `detectmodkey()` | resolve platform primary modifier | sync | returns `ctrl` without `navigator` |\n\n### errors\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `keymaperror` | base keymap error | sync | includes parse and lifecycle errors |\n| `keymapparseerror` | strict parser error | sync | `parsestep()` never throws it |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/keymap` | root entry point for every runtime function, error class, and public type listed here. |\n\n## core manager\n\n### `createkeymap()`\n\n```ts\nfunction createkeymap(\n bindings?: record<string, bindingvalue>,\n options?: keymapoptions,\n): keymap;\n```\n\ncreates shortcut manager with independent chord state for each mounted target.\n\n| parameter | type | description |\n| | | |\n| `bindings` | `record<string, bindingvalue>` | initial bindings. keys must be non empty valid shortcut strings. |\n| `options` | `keymapoptions` | chord, modifier, event, and global guard configuration. |\n\n**returns:** `keymap`.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({ 'ctrl+s': () => console.log('save') });\nconst unmount = map.mount(document);\n\nunmount();\nmap.dispose();\n```\n\n| `keymap` member | return | contract |\n| | | |\n| `bind(shortcut, value)` | `() => void` | adds or replaces canonical shortcut. returned callback removes that binding while active. |\n| `mount(target)` | `() => void` | adds target listener. repeat mounts of same target are reference counted. |\n| `unbind(shortcut)` | `void` | removes canonical shortcut. warns in development when unknown. |\n| `listbindings()` | `readonly bindingentry[]` | returns a detached binding snapshot. |\n| `dispose()` | `void` | removes all listeners, aborts signal, and permanently disposes map. idempotent. |\n| `disposed` | `boolean` | `true` after first `dispose()`. |\n| `disposalsignal` | `abortsignal` | aborts when map is disposed. |\n| `[symbol.dispose]()` | `void` | calls `dispose()`. |\n\nafter disposal, `bind()`, `unbind()`, and `mount()` throw `keymaperror`.\n\n## conflict analysis\n\n### `findshortcutconflicts()`\n\n```ts\nfunction findshortcutconflicts(\n shortcut: string,\n entries: readonly bindingentry[],\n options?: conflictoptions,\n): bindingentry[];\n```\n\nreturns entries with same trigger exact or prefix conflicting shortcut paths.\n\n| parameter | type | description |\n| | | |\n| `shortcut` | `string` | proposed shortcut. empty or whitespace only input returns no conflicts. |\n| `entries` | `readonly bindingentry[]` | bindings to compare, commonly `map.listbindings()`. |\n| `options` | `conflictoptions` | optional modifier resolution and trigger filter. |\n\n**returns:** matching entries. returns `[]` when no conflict exists.\n\n```ts\nimport { createkeymap, findshortcutconflicts } from '@vielzeug/keymap';\n\nconst map = createkeymap({ g: () => console.log('top') });\nconst conflicts = findshortcutconflicts('g g', map.listbindings());\n\nconsole.log(conflicts.length); // 1\n```\n\n## formatting\n\n### `formatshortcut()`\n\n```ts\nfunction formatshortcut(shortcut: string, modkey?: 'ctrl' | 'meta'): string;\n```\n\nformats parsed shortcut into mac symbols for `meta` or word labels for `ctrl`.\n\n| parameter | type | description |\n| | | |\n| `shortcut` | `string` | shortcut string to format. |\n| `modkey` | `'ctrl' \\| 'meta'` | platform primary modifier. defaults to `detectmodkey()`. |\n\n**returns:** display label, or `''` for invalid input.\n\n```ts\nimport { formatshortcut } from '@vielzeug/keymap';\n\nformatshortcut('mod+shift+p', 'meta'); // ⇧⌘p\nformatshortcut('mod+shift+p', 'ctrl'); // ctrl+shift+p\n```\n\n## parsing and matching\n\n### `parseshortcut()`\n\n```ts\nfunction parseshortcut(raw: string, modkey?: 'ctrl' | 'meta'): shortcut;\n```\n\nstrictly parses one or more space separated shortcut steps.\n\n| parameter | type | description |\n| | | |\n| `raw` | `string` | full shortcut string. |\n| `modkey` | `'ctrl' \\| 'meta'` | platform primary modifier. defaults to `detectmodkey()`. |\n\n**returns:** parsed `shortcut`.\n\n```ts\nimport { parseshortcut } from '@vielzeug/keymap';\n\nconst shortcut = parseshortcut('ctrl+k ctrl+s', 'ctrl');\nconsole.log(shortcut.length); // 2\n```\n\nthrows `keymapparseerror` for empty, modifier only, or ambiguous steps.\n\n \n\n### `parsestep()`\n\n```ts\nfunction parsestep(raw: string, modkey?: 'ctrl' | 'meta'): shortcutstep | null;\n```\n\nparses one shortcut step without throwing.\n\n| parameter | type | description |\n| | | |\n| `raw` | `string` | one shortcut step. |\n| `modkey` | `'ctrl' \\| 'meta'` | platform primary modifier. defaults to `detectmodkey()`. |\n\n**returns:** parsed `shortcutstep`, or `null` for empty, modifier only, or ambiguous input.\n\n```ts\nimport { parsestep } from '@vielzeug/keymap';\n\nparsestep('ctrl+k', 'ctrl'); // { key: 'k', modifiers: set(['ctrl']) }\nparsestep('ctrl+k+j', 'ctrl'); // null\n```\n\n \n\n### `canonicalizeshortcut()`\n\n```ts\nfunction canonicalizeshortcut(steps: readonly shortcutstep[]): string;\n```\n\nconverts parsed steps into stable canonical string with sorted modifier order.\n\n| parameter | type | description |\n| | | |\n| `steps` | `readonly shortcutstep[]` | parsed shortcut steps. |\n\n**returns:** canonical shortcut string.\n\n```ts\nimport { canonicalizeshortcut, parseshortcut } from '@vielzeug/keymap';\n\ncanonicalizeshortcut(parseshortcut('shift+ctrl+k', 'ctrl')); // ctrl+shift+k\n```\n\n \n\n### `matchstep()`\n\n```ts\nfunction matchstep(event: keyboardevent, step: shortcutstep): boolean;\n```\n\ntests exact key and modifier equality for one parsed step.\n\n| parameter | type | description |\n| | | |\n| `event` | `keyboardevent` | event to match. missing runtime `key` returns `false`. |\n| `step` | `shortcutstep` | parsed step. |\n\n**returns:** `true` only when key and all modifier states match.\n\n```ts\nimport { matchstep, parsestep } from '@vielzeug/keymap';\n\nconst step = parsestep('ctrl+k', 'ctrl')!;\nmatchstep(new keyboardevent('keydown', { ctrlkey: true, key: 'k' }), step); // true\n```\n\n \n\n### `detectmodkey()`\n\n```ts\nfunction detectmodkey(): 'ctrl' | 'meta';\n```\n\ndetects mac platform from `navigator` and otherwise returns `ctrl`.\n\n**returns:** `'meta'` on mac platforms; `'ctrl'` elsewhere or without `navigator`.\n\n```ts\nimport { detectmodkey } from '@vielzeug/keymap';\n\nconst modkey = detectmodkey();\n```\n\n## types\n\n### `keymap`\n\nstateful shortcut manager returned by `createkeymap()`.\n\n```ts\ninterface keymap {\n [symbol.dispose](): void;\n bind(shortcut: string, value: bindingvalue): () => void;\n dispose(): void;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n listbindings(): readonly bindingentry[];\n mount(target: eventtarget): () => void;\n unbind(shortcut: string): void;\n}\n```\n\n### `keymapoptions`\n\noptions applied to every binding owned by one manager.\n\n```ts\ninterface keymapoptions {\n chordtimeout?: number;\n modkey?: 'ctrl' | 'meta';\n preventdefault?: boolean;\n stoppropagation?: boolean;\n when?: when;\n onchordstate?: (change: chordstatechange) => void;\n}\n```\n\n `when`: guard function called for all bindings. when combined with per binding `when` guards, both must return `true` for the handler to fire (and composition). global guard is checked first.\n `onchordstate`: optional callback to observe chord state changes (started, progressed, or timeout). useful for debugging, testing, logging, or implementing chord ui hints. callback errors are caught and logged in development. note: when a chord completes, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n### `bindingoptions`\n\nper binding handler configuration.\n\n```ts\ntype bindingoptions = {\n handler: handler;\n trigger?: 'keydown' | 'keyup';\n when?: when;\n};\n```\n\n### `bindingvalue`, `handler`, and `when`\n\naccepted values when registering a shortcut.\n\n```ts\ntype handler = (event: keyboardevent) => void;\ntype when = (event: keyboardevent) => boolean;\ntype bindingvalue = handler | bindingoptions;\n```\n\n### `bindingentry`\n\ndetached binding metadata returned by `listbindings()`.\n\n```ts\ntype bindingentry = {\n readonly shortcut: readonly shortcutstep[];\n readonly trigger: 'keydown' | 'keyup';\n};\n```\n\n### `modifierkey`, `shortcut`, and `shortcutstep`\n\nparser types used by `parseshortcut()`, `parsestep()`, `matchstep()`, and `canonicalizeshortcut()`.\n\n```ts\ntype modifierkey = 'alt' | 'ctrl' | 'meta' | 'shift';\n\ntype shortcutstep = {\n key: string;\n modifiers: set<modifierkey>;\n};\n\ntype shortcut = shortcutstep[];\n```\n\n### `conflictoptions`\n\ncomparison options for `findshortcutconflicts()`.\n\n```ts\ninterface conflictoptions {\n modkey?: 'ctrl' | 'meta';\n trigger?: 'keydown' | 'keyup';\n}\n```\n\n### `chordstatechange`\n\ndiscriminated union type for chord state events emitted by `onchordstate` callback. when a chord fully matches, the binding handler fires immediately; no separate 'completed' event is emitted.\n\n```ts\ntype chordstatechange =\n | { type: 'started'; target: eventtarget; step: shortcutstep; trigger: 'keydown' | 'keyup' }\n | { type: 'progressed'; target: eventtarget; steps: readonly shortcutstep[]; trigger: 'keydown' | 'keyup' }\n | { type: 'timeout'; target: eventtarget; trigger: 'keydown' | 'keyup' };\n```\n\n| event | fields | when | use case |\n| | | | |\n| `started` | `target`, `step`, `trigger` | first key of a chord is pressed. | show \"waiting for next key\" ui hint. |\n| `progressed` | `target`, `steps`, `trigger` | additional step(s) added to pending chord. | update chord hint with current progress. |\n| `timeout` | `target`, `trigger` | chord was pending but timed out without completing. | clear \"waiting\" ui state; log timeout for debugging. |\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n { 'g g': () => scrolltotop() },\n {\n onchordstate: (change) => {\n if (change.type === 'started') {\n console.log(`chord started: ${change.step.key}`);\n }\n if (change.type === 'progressed') {\n console.log(`chord progress: ${change.steps.map((s) => s.key).join(' ')}`);\n }\n if (change.type === 'timeout') {\n console.log('chord timed out');\n }\n },\n },\n);\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `keymaperror` | lifecycle operation after disposal | `keymaperror.is(error)` narrows keymap errors. |\n| `keymapparseerror` | strict shortcut parser receives invalid input | extends `keymaperror`. |\n",
606
+ "usage": " \ntitle: keymap — usage guide\ndescription: bind keyboard shortcuts, chords, event aware guards, and target local listeners with @vielzeug/keymap.\n \n\n[[toc]]\n\n## basic usage\n\nmount one keymap, then release its target listener and dispose its owner during teardown.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({\n 'ctrl+s': () => console.log('save'),\n 'ctrl+z': () => console.log('undo'),\n escape: () => console.log('close'),\n});\n\nconst unmount = map.mount(document);\n\n// call this when the owning ui scope ends.\nunmount();\nmap.dispose();\n```\n\n`unmount()` only releases that target. `dispose()` releases every target, aborts `disposalsignal`, and makes `bind()`, `unbind()`, and `mount()` unavailable.\n\n## modifier aliases\n\nuse aliases to accept platform terminology while keymap stores one canonical shortcut.\n\n| input | canonical modifier |\n| | |\n| `cmd`, `command`, `win` | `meta` |\n| `opt`, `option` | `alt` |\n| `ctrl`, `control` | `ctrl` |\n| `mod` | `meta` on mac; `ctrl` elsewhere |\n\npass `modkey` when rendering or testing a specific platform.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n { 'mod+k': () => console.log('open palette') },\n { modkey: 'ctrl' },\n);\n\nmap.mount(document);\n```\n\n## chord sequences\n\nseparate chord steps with spaces. keymap resets an incomplete sequence after `chordtimeout` milliseconds.\n\n```ts\nconst map = createkeymap(\n {\n 'ctrl+k ctrl+s': () => console.log('save'),\n 'g g': () => window.scrollto({ top: 0 }),\n 'g e': () => window.scrollto({ top: document.body.scrollheight }),\n },\n { chordtimeout: 800 },\n);\n```\n\ndo not bind a complete shortcut and a longer chord beginning with that shortcut. `g` fires immediately, so `g g` cannot complete. check proposed user bindings with `findshortcutconflicts()`.\n\n## binding options\n\nadd a guard or choose `keyup` with `bindingoptions`.\n\n```ts\nconst map = createkeymap({\n 'ctrl+s': () => savedocument(),\n escape: { handler: closepanel, when: (event) => event.target === panel },\n space: { handler: toggleplayback, trigger: 'keyup' },\n});\n```\n\na matching binding calls `preventdefault()` by default. set `preventdefault: false` for shortcuts that must retain browser behavior.\n\n## context guards\n\nuse global `when(event)` for policy shared by every binding. use per binding `when(event)` when one shortcut needs a narrower policy.\n\n```ts\nconst map = createkeymap(\n {\n escape: { handler: closepanel, when: (event) => event.target === panel },\n 'ctrl+s': () => savedocument(),\n },\n { when: (event) => !modalisopen() && event.istrusted },\n);\n```\n\nzero argument callbacks continue to work. accept `keyboardevent` when guard logic needs target, modifier, composition, or shadow dom context.\n\n### guard composition: global + per binding\n\nwhen you provide both a global `when` (in `keymapoptions`) and per binding `when` guards, both must return `true` for the handler to fire. this is and composition.\n\n**guard evaluation and chord tracking order:**\n\n1. **chord state is tracked independently of guards.** the chord tracker progresses through steps before any guard is checked.\n2. **global guard checked first.** if it returns `false`, all bindings are skipped and the handler does not fire — but chord state events still emit.\n3. **per binding guard checked only after global passes.** enables mixing global policy (e.g., \"skip when modal open\") with binding specific checks (e.g., \"only in this panel\").\n\nthink of it as: chord tracking (independent observation) → global gate (app level policy) and per binding gate (binding level context).\n\n```ts\nconst map = createkeymap(\n {\n 'escape': { handler: closepanel, when: (event) => event.target === panel },\n 'ctrl+s': () => savedocument(),\n },\n { when: (event) => !ismodalopen() && event.istrusted },\n);\n\n// global guard runs first; if false, both bindings are skipped (handler doesn't fire).\n// if global passes:\n// 'ctrl+s' handler fires immediately.\n// 'escape' handler fires only if event.target is the panel.\n// but chord state events emit regardless of guards.\n```\n\n### preserve native text editing\n\nuse `event.composedpath()` to keep browser undo and redo inside inputs, textareas, and `contenteditable` elements. kanban app shell uses this policy for its global undo and redo shortcuts.\n\n```ts\nconst istypinginfield = (event: keyboardevent): boolean =>\n event.composedpath().some(\n (target) =>\n target instanceof htmlelement &&\n (target instanceof htmlinputelement || target instanceof htmltextareaelement || target.iscontenteditable),\n );\n\nconst map = createkeymap(\n {\n 'mod+z': () => undo(),\n 'mod+shift+z': () => redo(),\n },\n { when: (event) => !istypinginfield(event) },\n);\n```\n\ndo not make editable field suppression a hidden package default. applications may intentionally bind shortcuts inside editable controls.\n\n## trigger control\n\nbind on `keyup` when an action must run after key release.\n\n```ts\nconst map = createkeymap({\n space: { handler: confirmaction, trigger: 'keyup' },\n});\n```\n\n`keydown` and `keyup` maintain independent chord state.\n\n## replace bindings at runtime\n\nbind replaces an existing binding with same canonical shortcut and returns a targeted removal callback.\n\n```ts\nconst map = createkeymap({ 'ctrl+k': defaultaction });\nconst removepluginbinding = map.bind('ctrl+k', pluginaction);\n\nremovepluginbinding();\nmap.bind('ctrl+k', defaultaction);\n```\n\n`unbind(shortcut)` removes canonicalized aliases and warns in development when no binding exists.\n\n## format shortcut labels\n\nformat labels with explicit platform behavior when your ui is cross platform.\n\n```ts\nimport { formatshortcut } from '@vielzeug/keymap';\n\nconsole.log(formatshortcut('mod+shift+p', 'meta')); // ⇧⌘p\nconsole.log(formatshortcut('mod+shift+p', 'ctrl')); // ctrl+shift+p\n```\n\n`formatshortcut()` returns `''` and emits a development warning for invalid input.\n\n## detect conflicts\n\ncheck a custom shortcut before binding it to prevent duplicate or unreachable chord paths.\n\n```ts\nimport { createkeymap, findshortcutconflicts } from '@vielzeug/keymap';\n\nconst map = createkeymap({ g: () => scrolltotop() });\nconst conflicts = findshortcutconflicts('g g', map.listbindings());\n\nif (conflicts.length === 0) map.bind('g g', () => scrolltobottom());\n```\n\nconflict detection compares only bindings with same trigger. an empty proposal returns no conflicts; other invalid proposals throw `keymapparseerror`.\n\n## observe chord state\n\ntrack chord progression for debugging, logging, testing, or implementing chord ui hints (e.g., \"you pressed 'g', press again to scroll\").\n\n**chord state tracking is independent of guards.** events emit even if the global or per binding guard would prevent the handler from firing. this allows you to show ui hints regardless of whether the binding is allowed to execute.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap(\n {\n 'g g': () => window.scrollto({ top: 0 }),\n 'ctrl+k ctrl+s': () => save(),\n },\n {\n onchordstate: (change) => {\n switch (change.type) {\n case 'started':\n console.log(`chord started: ${change.step.key} (${change.trigger})`);\n showhint(`press '${change.step.key}' again...`);\n break;\n case 'progressed':\n console.log(`waiting for: ${change.steps.map((s) => s.key).join(' → ?')}`);\n updatehint(`${change.steps.map((s) => s.key).join(' → ?')}`);\n break;\n case 'timeout':\n console.log('chord timed out; resetting');\n hidehint();\n break;\n }\n },\n },\n);\n```\n\n**error handling:** callback errors are caught and logged in development mode; they don't break binding execution. use error handling in your callback to prevent typos from blocking shortcuts.\n\n**per target isolation:** each mounted target maintains independent chord state. use `change.target` when mounting the same keymap on multiple targets to distinguish progress per target.\n\n## mount targets\n\nmount one keymap on multiple independent targets when each target should own its own chord progression.\n\n```ts\nconst map = createkeymap({ 'g g': () => console.log('go to top') });\nconst unmounteditor = map.mount(editor);\nconst unmountpreview = map.mount(preview);\n```\n\na chord started on `editor` cannot complete on `preview`. repeated `mount(editor)` calls share one listener and require one unmount call each. for nested targets, keymap handles one bubbled event at its innermost mounted target.\n\n## scoped maps\n\ncreate separate keymaps for separate ui owners. if maps share a target and shortcut, guards must be mutually exclusive because keymap has no implicit layer precedence.\n\n```ts\nconst basemap = createkeymap(\n { escape: () => closesidebar() },\n { when: () => !modalisopen() },\n);\n\nconst modalmap = createkeymap(\n { escape: () => closemodal() },\n { when: () => modalisopen() },\n);\n\nconst unmountbase = basemap.mount(document);\nconst unmountmodal = modalmap.mount(document);\n```\n\n## testing\n\ndispatch `keyboardevent` instances against a mounted dom target to test handlers and default prevention.\n\n```ts\nimport { expect, it, vi } from 'vitest';\n\nimport { createkeymap } from '@vielzeug/keymap';\n\nit('handles save', () => {\n const save = vi.fn();\n const target = document.createelement('button');\n const map = createkeymap({ 'ctrl+s': save });\n const unmount = map.mount(target);\n\n target.dispatchevent(new keyboardevent('keydown', { bubbles: true, ctrlkey: true, key: 's' }));\n\n expect(save).tohavebeencalledonce();\n unmount();\n map.dispose();\n});\n```\n\nmount nested dom targets in tests when your application uses both a container and a descendant listener. this verifies one bubbled event cannot complete a chord twice.\n\n## framework integration\n\ncreate map during framework lifecycle, then dispose it during teardown.\n\n::: code group\n\n```tsx [react]\nimport { useeffect } from 'react';\n\nimport { createkeymap } from '@vielzeug/keymap';\n\nexport function app() {\n useeffect(() => {\n const map = createkeymap({ 'ctrl+k': () => console.log('open palette') });\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n }, []);\n\n return null;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { onmounted, onunmounted } from 'vue';\n\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({ escape: () => console.log('close palette') });\nlet unmount: (() => void) | undefined;\n\nonmounted(() => {\n unmount = map.mount(document);\n});\n\nonunmounted(() => {\n unmount?.();\n map.dispose();\n});\n</script>\n```\n\n```ts [svelte]\nimport { onmount } from 'svelte';\n\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst map = createkeymap({ escape: () => console.log('close palette') });\n\nonmount(() => {\n const unmount = map.mount(document);\n\n return () => {\n unmount();\n map.dispose();\n };\n});\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### keymap + ledger\n\nconnect undo and redo handlers to a ledger owner.\n\n```ts\nimport { createkeymap } from '@vielzeug/keymap';\nimport { createledger } from '@vielzeug/ledger';\n\nconst ledger = createledger();\nconst reporthistoryerror = (error: unknown): void => console.error(error);\nconst map = createkeymap({\n 'mod+z': () => void ledger.undo().catch(reporthistoryerror),\n 'mod+shift+z': () => void ledger.redo().catch(reporthistoryerror),\n});\n\nmap.mount(document);\n```\n\n### keymap + herald\n\nemit domain events instead of calling application actions from shortcut handlers.\n\n```ts\nimport { createbus } from '@vielzeug/herald';\nimport { createkeymap } from '@vielzeug/keymap';\n\nconst bus = createbus<{ 'shortcut:save': void }>();\nconst map = createkeymap({\n 'ctrl+s': () => bus.emit('shortcut:save'),\n});\n\nmap.mount(document);\n```\n\n## best practices\n\n **dispose** every map when its owner ends.\n **unmount** temporary target listeners instead of disposing reusable maps.\n **guard** global text editing shortcuts with `event.composedpath()`.\n **check** conflicts before accepting customized shortcuts.\n **keep** shared target guards mutually exclusive.\n **use** `mod` for primary cross platform shortcuts.\n **avoid** prefix pairs such as `g` and `g g`.\n",
607
+ "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"
608
+ },
609
+ "examples": [
610
+ {
611
+ "id": "basic-shortcuts",
612
+ "text": "basic shortcuts import { createkeymap, formatshortcut } from '@vielzeug/keymap'\n\n// create a keymap — bindings fire on keydown by default.\nconst map = createkeymap({\n 'ctrl+s': () => console.log('save triggered'),\n escape: { handler: () => console.log('close panel'), when: () => true },\n space: { handler: () => console.log('toggle play'), trigger: 'keyup' },\n}, { modkey: 'ctrl' })\n\n// mount to document (required for event listening).\nconst unmount = map.mount(document)\n\n// simulate events for demonstration.\ndocument.dispatchevent(new keyboardevent('keydown', { key: 's', ctrlkey: true, bubbles: true }))\ndocument.dispatchevent(new keyboardevent('keydown', { key: 'escape', bubbles: true }))\ndocument.dispatchevent(new keyboardevent('keyup', { key: ' ', bubbles: true }))\n\n// format shortcuts for display in ui tooltips or menus.\nconsole.log(formatshortcut('ctrl+s', 'ctrl')) // 'ctrl+s'\nconsole.log(formatshortcut('mod+shift+p', 'meta')) // '⇧⌘p'\nconsole.log(formatshortcut('ctrl+k ctrl+s', 'ctrl')) // 'ctrl+k ctrl+s'\n\nunmount()"
613
+ },
614
+ {
615
+ "id": "chord-sequences",
616
+ "text": "chord sequences import { createkeymap, findshortcutconflicts } from '@vielzeug/keymap'\n\n// chord sequences fire only after all steps are pressed in order within the timeout.\n// shortcut strings are lowercased before matching, so 'g g' and 'g g' are the same\n// binding — writing both would silently overwrite one. vim's actual 'g' is shift+g,\n// a single keystroke, not a 'g' prefixed chord.\nconst map = createkeymap({\n 'ctrl+k ctrl+s': () => console.log('save all (vs code style)'),\n 'g g': () => console.log('go to top (vim style)'),\n 'shift+g': () => console.log('go to bottom'),\n}, { chordtimeout: 800, modkey: 'ctrl' })\n\nconst unmount = map.mount(document)\n\n// simulate completing 'ctrl+k ctrl+s' chord.\ndocument.dispatchevent(new keyboardevent('keydown', { key: 'k', ctrlkey: true, bubbles: true }))\ndocument.dispatchevent(new keyboardevent('keydown', { key: 's', ctrlkey: true, bubbles: true }))\n\n// simulate vim 'g g' chord.\ndocument.dispatchevent(new keyboardevent('keydown', { key: 'g', bubbles: true }))\ndocument.dispatchevent(new keyboardevent('keydown', { key: 'g', bubbles: true }))\n\n// gotcha: a single key binding sharing a chord's first step always wins immediately,\n// making the longer chord unreachable — findshortcutconflicts() catches this up front.\nconsole.log('would ctrl+k (alone) conflict with the chord above?')\nconsole.log(findshortcutconflicts('ctrl+k', map.listbindings()))\n\nunmount()"
617
+ },
618
+ {
619
+ "id": "conflict-detection",
620
+ "text": "conflict detection 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)"
621
+ },
622
+ {
623
+ "id": "parse-and-match",
624
+ "text": "parse & match 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}"
625
+ },
626
+ {
627
+ "id": "shortcut-utilities",
628
+ "text": "shortcut utilities 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)"
629
+ }
630
+ ],
631
+ "exports": "canonicalizeshortcut createkeymap detectmodkey findshortcutconflicts formatshortcut keymaperror keymapparseerror matchstep parseshortcut parsestep",
632
+ "keywords": "keyboard shortcuts hotkeys chord keybinding headless accessibility",
633
+ "name": "@vielzeug/keymap",
634
+ "related": "herald refine ore",
635
+ "slug": "keymap",
636
+ "source": "// core api — most users only need these\nexport type { conflictoptions } from './conflicts';\nexport { findshortcutconflicts } from './conflicts';\nexport { keymaperror, keymapparseerror } from './errors';\nexport { formatshortcut } from './format';\nexport { createkeymap } from './keymap';\n// power user api — use if building custom tooling, validators, or framework integrations\nexport type { modifierkey, shortcut, shortcutstep } from './parser';\nexport { canonicalizeshortcut, detectmodkey, matchstep, parseshortcut, parsestep } from './parser';\nexport type {\n bindingentry,\n bindingoptions,\n bindingvalue,\n chordstatechange,\n handler,\n keymap,\n keymapoptions,\n when,\n} from './types';\n"
637
+ },
638
+ {
639
+ "category": "utilities",
640
+ "description": "serialized reversible command history with cancellation ownership and atomic reactive snapshots.",
641
+ "docs": {
642
+ "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",
643
+ "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 | `instanceof ledgererror` narrows all ledger errors |\n",
644
+ "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",
645
+ "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"
646
+ },
647
+ "examples": [
648
+ {
649
+ "id": "cancellation",
650
+ "text": "cancellation 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()"
651
+ },
652
+ {
653
+ "id": "command-data",
654
+ "text": "history metadata & state 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()"
655
+ },
656
+ {
657
+ "id": "compose-commands",
658
+ "text": "compose reversible commands 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()"
659
+ },
660
+ {
661
+ "id": "do-undo-redo",
662
+ "text": "do / undo / redo 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()"
663
+ },
664
+ {
665
+ "id": "reactive-signals",
666
+ "text": "reactive state 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()"
667
+ },
668
+ {
669
+ "id": "rollback-error",
670
+ "text": "rollback error 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()"
671
+ }
672
+ ],
673
+ "exports": "compose createledger",
674
+ "keywords": "undo redo history commands async reactive ripple",
675
+ "name": "@vielzeug/ledger",
676
+ "related": "ripple keymap forge vault",
677
+ "slug": "ledger",
678
+ "source": "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"
679
+ },
680
+ {
681
+ "category": "i18n",
682
+ "description": "framework neutral locale catalogs, typed translations, and explicit plural messages.",
683
+ "docs": {
684
+ "index": " \ntitle: lingua — explicit localization for typescript\ndescription: framework neutral locale catalogs, typed translations, and explicit plural messages.\npackage: lingua\ncategory: i18n\nkeywords: [internationalization, translations, pluralization, locale, i18n, catalog loading]\nrelated: [ripple, wayfinder, courier]\nexports: [createcatalogtranslator, createtranslationstore, createtranslator, hydratetranslationstore, linguaerror, linguadisposederror, linguainvalidcatalogerror, linguainvalidlocaleerror, linguainvalidpluralcounterror, linguainvalidstateerror, linguamissingcatalogerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"lingua\" />\n\n## why lingua?\n\nlingua separates immutable translation from mutable locale state. use one catalog per locale, then select static or stateful api from whether locale can change.\n\n```ts\n// before\nconst message = catalogs[locale]?.inbox?.[count === 1 ? 'one' : 'other'] ?? 'inbox';\n\n// after\nconst output = i18n.translate('inbox', { count });\n```\n\n| feature | lingua | i18next | formatjs |\n| | | | |\n| bundle size | <packageinfo package=\"lingua\" type=\"size\" /> | varies by selected modules | varies by selected modules |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| explicit plural catalog nodes | <ore icon name=\"check\" size=\"16\"></ore icon> | convention/config dependent | icu message dependent |\n| declared lazy locale catalogs | <ore icon name=\"check\" size=\"16\"></ore icon> | plugin/config dependent | application defined |\n| immutable locale snapshots | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | application defined |\n\n<div class=\"decision callout\">\n\n**use lingua when** you need a compact typescript runtime with explicit catalog structure, deterministic fallback, and framework neutral subscriptions.\n\n**consider i18next or formatjs when** you need their plugin ecosystems, message extraction pipelines, or framework specific integrations.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/lingua\n```\n\n```sh [npm]\nnpm install @vielzeug/lingua\n```\n\n```sh [yarn]\nyarn add @vielzeug/lingua\n```\n\n:::\n\n## quick start\n\ncreate locale store with static catalogs, then dispose it when owner ends.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: {\n de: { inbox: { plural: { one: 'eine nachricht', other: '{count} nachrichten' } } },\n en: { inbox: { plural: { one: 'one message', other: '{count} messages' } } },\n },\n locale: 'en',\n});\n\ntry {\n console.log(i18n.translate('inbox', { count: 3 }));\n await i18n.setlocale('de');\n console.log(i18n.translate('inbox', { count: 1 }));\n} finally {\n i18n.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createcatalogtranslator()` compiles one immutable fixed locale catalog.\n `createtranslator()` compiles immutable locale keyed catalogs.\n `createtranslationstore()` manages locale changes and declared catalogs.\n `translate()` renders text and plural messages through explicit catalog nodes.\n `translatedynamic()` makes runtime key lookup explicit.\n `load()` deduplicates lazy catalog loading per locale.\n `getsnapshot()` and `subscribe()` expose immutable translator revisions.\n `serialize()` and `hydratetranslationstore()` transfer resolved ssr catalogs.\n `createformatter()` and `validatecatalog()` remain isolated subpath tools.\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/index.md) adapts lingua snapshots into reactive application state.\n [courier](../courier/index.md) can fetch locale catalogs before passing them to lingua loaders.\n [wayfinder](../wayfinder/index.md) can drive locale selection from route state.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
685
+ "api": " \ntitle: lingua — api reference\ndescription: complete api reference for @vielzeug/lingua.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createcatalogtranslator()` | compile one immutable locale catalog | sync | no fallback locales |\n| `createtranslator()` | compile immutable locale catalogs | sync | locale is fixed for translator lifetime |\n| `createtranslationstore()` | create mutable locale and catalog store | sync | load lazy locale explicitly |\n| `hydratetranslationstore()` | create store from serialized loaded catalogs | sync | serialized state never includes loaders |\n| `createformatter()` | format intl values from `/format` | sync | import from subpath |\n| `validatecatalog()` | check explicit plural forms from `/validate` | sync | import from subpath |\n| `linguaerror` | base class for lingua errors | sync | use `linguaerror.is()` for broad narrowing |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/lingua` | translation factories, state types, and lingua errors |\n| `@vielzeug/lingua/format` | `createformatter()` and formatter types |\n| `@vielzeug/lingua/validate` | `validatecatalog()` and `validationissue` |\n\n## translation factories\n\n### createcatalogtranslator\n\n```ts\nfunction createcatalogtranslator<c extends catalog>(\n catalog: c,\n options?: catalogtranslatoroptions,\n): translator<c>;\n```\n\ncompiles one catalog and returns an immutable fixed locale translator. locale defaults to `en` and controls plural selection and diagnostics.\n\n| parameter | type | description |\n| | | |\n| `catalog` | `c` | one catalog containing only messages and grouping objects |\n| `options` | `catalogtranslatoroptions` | locale and missing message handlers; fallback is unavailable |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator(\n { save: 'enregistrer' },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n \n\n### createtranslator\n\n```ts\nfunction createtranslator<c extends catalog>(catalogs: catalogs<c>, options?: translatoroptions): translator<c>;\n```\n\ncompiles locale catalogs and returns immutable translator.\n\n| parameter | type | description |\n| | | |\n| `catalogs` | `catalogs<c>` | locale keyed catalog objects |\n| `options` | `translatoroptions` | locale, fallback chain, and missing message handlers |\n\n**returns:** `translator<c>`.\n\n**example:**\n\n```ts\nimport { createtranslator } from '@vielzeug/lingua';\n\nconst translator = createtranslator(\n { en: { save: 'save' }, fr: { save: 'enregistrer' } },\n { locale: 'fr' },\n);\n\ntranslator.translate('save');\n```\n\n| method | signature | returns |\n| | | |\n| `translate` | `(textkey, options?)` or `(pluralkey, { count, ordinal?, values? })` | rendered string |\n| `translatedynamic` | `(key, options?)` | rendered string for runtime key |\n| `segments` | `(textkey, { values })` or `(pluralkey, { count, ordinal?, values? })` | string and typed value segments |\n| `segmentsdynamic` | `(key, options)` | segments for runtime key |\n| `locale` | `locale` | resolved active locale |\n\n \n\n### createtranslationstore\n\n```ts\nfunction createtranslationstore<c extends catalog>(options: translationstoreoptions<c>): translationstore<c>;\n```\n\ncreates catalog store, current locale state, and immutable translator snapshots.\n\n| parameter | type | description |\n| | | |\n| `options.catalogs` | `catalogsources<c>` | static catalogs or lazy locale loaders |\n| `options.locale` | `locale` | initial locale; defaults to `en` |\n| `options.fallback` | `locale \\| readonly locale[]` | fallback locale chain |\n| `options.onmissingkey` | `(key, locale) => string` | missing message handler |\n| `options.onmissingvalue` | `(name, key, locale) => string` | missing interpolation handler |\n\n**returns:** `translationstore<c>`, with every `translator<c>` method plus lifecycle methods.\n\n**example:**\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst translations = createtranslationstore({\n catalogs: { en: { title: 'home' }, fr: { title: 'accueil' } },\n locale: 'en',\n});\n\nawait translations.setlocale('fr');\ntranslations.translate('title');\n```\n\n| method or property | signature | returns |\n| | | |\n| `translate` | translator method | rendered string |\n| `segments` | translator method | string and typed value segments |\n| `load` | `({ locale? })` | `promise<void>` after catalog resolution |\n| `setlocale` | `(locale)` | `promise<void>` after locale commit; never loads implicitly |\n| `isloaded` | `({ locale? })` | `boolean` |\n| `getsnapshot` | `()` | `translationsnapshot<c>` |\n| `subscribe` | `(listener, { immediate?, signal? })` | unsubscribe function |\n| `serialize` | `()` | loader free `translationstate<c>` |\n| `dispose` | `()` | `void` |\n| `locale` | `locale` | current canonical locale |\n| `disposed` | `boolean` | disposal state |\n| `disposalsignal` | `abortsignal` | aborts on disposal |\n| `[symbol.dispose]` | `()` | delegates to `dispose()` |\n\n \n\n### hydratetranslationstore\n\n```ts\nfunction hydratetranslationstore<c extends catalog>(\n state: translationstate<c>,\n options?: omit<translationstoreoptions<c>, 'locale' | 'catalogs'>,\n): translationstore<c>;\n```\n\ncreates translation store from ssr state payload containing resolved raw catalogs.\n\n| parameter | type | description |\n| | | |\n| `state` | `translationstate<c>` | version `3`, active locale, and loader free catalogs |\n| `options` | `omit<translationstoreoptions<c>, 'locale' \\| 'catalogs'>` | fallback and missing message handlers |\n\n**returns:** `translationstore<c>`.\n\n**example:**\n\n```ts\nimport { createtranslationstore, hydratetranslationstore } from '@vielzeug/lingua';\n\nconst server = createtranslationstore({ catalogs: { en: { title: 'home' } }, locale: 'en' });\nconst client = hydratetranslationstore(server.serialize());\n\nclient.translate('title');\n```\n\n## formatting and validation\n\n### createformatter\n\n```ts\nfunction createformatter(source: string | (() => string)): formatter;\n```\n\ncreates cached intl formatters using static locale or locale getter.\n\n| parameter | type | description |\n| | | |\n| `source` | `string \\| (() => string)` | static locale or locale getter |\n\n**returns:** `formatter`.\n\n**example:**\n\n```ts\nimport { createformatter } from '@vielzeug/lingua/format';\n\nconst formatter = createformatter('en us');\nformatter.currency(19.99, 'usd');\n```\n\n| method | signature | returns |\n| | | |\n| `number` | `(value, options?)` | `string` |\n| `currency` | `(value, currency, options?)` | `string` |\n| `date` | `(value, options?)` | `string` |\n| `relative` | `(value, unit, options?)` | `string` |\n| `list` | `(value, options?)` | `string` |\n| `duration` | `(value, options?)` | `string` |\n\n### validatecatalog\n\n```ts\nfunction validatecatalog(catalog: catalog, locale: locale): validationissue[];\n```\n\nvalidates explicit plural messages against locale plural categories after catalog structural validation.\n\n| parameter | type | description |\n| | | |\n| `catalog` | `catalog` | explicit catalog to validate |\n| `locale` | `locale` | bcp 47 locale tag |\n\n**returns:** `validationissue[]`.\n\n**example:**\n\n```ts\nimport { validatecatalog } from '@vielzeug/lingua/validate';\n\nvalidatecatalog({ inbox: { plural: { one: 'one message' } } }, 'en');\n```\n\n## types\n\n```ts\ntype locale = string;\ntype pluralcategory = intl.ldmlpluralrule;\ntype pluralmessage = { readonly plural: partial<record<pluralcategory, string>> };\ntype catalognode = catalog | pluralmessage | string;\ntype catalog = { readonly [key: string]: catalognode };\ntype catalogs<c extends catalog = catalog> = record<locale, c>;\ntype catalogtranslatoroptions = omit<translatoroptions, 'fallback'>;\ntype catalogloader<c extends catalog = catalog> = () => promise<c>;\ntype catalogsource<c extends catalog = catalog> = c | catalogloader<c>;\ntype catalogsources<c extends catalog = catalog> = record<locale, catalogsource<c>>;\n\ntype translationstoreoptions<c extends catalog = catalog> = translatoroptions & {\n catalogs: catalogsources<c>;\n};\n\ntype translationstate<c extends catalog = catalog> = {\n readonly catalogs: catalogs<c>;\n readonly locale: locale;\n readonly version: 3;\n};\n\ntype translationsnapshot<c extends catalog = catalog> = {\n readonly locale: locale;\n readonly revision: number;\n readonly translator: translator<c>;\n};\n\ntype translationstore<c extends catalog = catalog> = translator<c> & {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n getsnapshot(): translationsnapshot<c>;\n isloaded(options?: { locale?: locale }): boolean;\n load(options?: { locale?: locale }): promise<void>;\n serialize(): translationstate<c>;\n setlocale(locale: locale): promise<void>;\n subscribe(listener: (snapshot: translationsnapshot<c>) => void, options?: subscribeoptions): () => void;\n [symbol.dispose](): void;\n};\n\ntype translator<c extends catalog = catalog> = {\n readonly locale: locale;\n segments<v>(key: textkey<c>, options: translateoptions & { values: record<string, v> }): array<string | v>;\n segments<v>(key: pluralkey<c>, options: pluraloptions & { values?: record<string, v> }): array<string | number | v>;\n segmentsdynamic<v>(\n key: string,\n options: (translateoptions | pluraloptions) & { values?: record<string, v> },\n ): array<string | number | v>;\n translate(key: textkey<c>, options?: translateoptions): string;\n translate(key: pluralkey<c>, options: pluraloptions): string;\n translatedynamic(key: string, options?: translateoptions | pluraloptions): string;\n};\n```\n\n```ts\ntype values = record<string, unknown>;\ntype translateoptions = { values?: values };\ntype pluraloptions = translateoptions & { count: number; ordinal?: boolean };\ntype translatoroptions = {\n fallback?: locale | readonly locale[];\n locale?: locale;\n onmissingkey?: (key: string, locale: locale) => string;\n onmissingvalue?: (name: string, key: string, locale: locale) => string;\n};\ntype subscribeoptions = { immediate?: boolean; signal?: abortsignal };\n\ntype messagekey<\n c,\n prefix extends string = '',\n depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = depth extends readonly [unknown, ...infer rest]\n ? c extends string | pluralmessage\n ? prefix\n : c extends catalog\n ? {\n [k in string & keyof c]: messagekey<c[k], prefix extends '' ? k : `${prefix}.${k}`, rest>;\n }[string & keyof c]\n : never\n : never;\n\ntype textkey<\n c,\n prefix extends string = '',\n depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = depth extends readonly [unknown, ...infer rest]\n ? c extends string\n ? prefix\n : c extends catalog\n ? {\n [k in string & keyof c]: textkey<c[k], prefix extends '' ? k : `${prefix}.${k}`, rest>;\n }[string & keyof c]\n : never\n : never;\n\ntype pluralkey<\n c,\n prefix extends string = '',\n depth extends readonly unknown[] = readonly [1, 1, 1, 1, 1, 1],\n> = depth extends readonly [unknown, ...infer rest]\n ? c extends pluralmessage\n ? prefix\n : c extends catalog\n ? {\n [k in string & keyof c]: pluralkey<c[k], prefix extends '' ? k : `${prefix}.${k}`, rest>;\n }[string & keyof c]\n : never\n : never;\n\ntype durationvalue = partial<record<\n 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'months' | 'nanoseconds' | 'seconds' | 'weeks' | 'years',\n number\n>>;\n\ntype durationformatoptions = {\n hours?: '2 digit' | 'numeric';\n microseconds?: 'numeric';\n milliseconds?: 'numeric';\n minutes?: '2 digit' | 'numeric';\n nanoseconds?: 'numeric';\n seconds?: '2 digit' | 'numeric';\n style?: 'digital' | 'long' | 'narrow' | 'short';\n};\n\ntype listformatoptions = { style?: 'long' | 'narrow' | 'short'; type?: 'and' | 'or' };\n\ntype formatter = {\n currency(value: number, currency: string, options?: omit<intl.numberformatoptions, 'currency' | 'style'>): string;\n date(value: date | number, options?: intl.datetimeformatoptions): string;\n duration(value: durationvalue, options?: durationformatoptions): string;\n list(value: array<string | number>, options?: listformatoptions): string;\n number(value: number, options?: intl.numberformatoptions): string;\n relative(value: number, unit: intl.relativetimeformatunit, options?: intl.relativetimeformatoptions): string;\n};\n\ntype validationissue = { key: string; locale: locale; missing: intl.ldmlpluralrule };\n```\n\n## errors\n\n| error | trigger |\n| | |\n| `linguadisposederror` | state mutation or subscription after `dispose()` |\n| `linguainvalidcatalogerror` | invalid catalog node or reserved key |\n| `linguainvalidlocaleerror` | invalid bcp 47 locale tag |\n| `linguainvalidpluralcounterror` | non finite plural count |\n| `linguainvalidstateerror` | unsupported serialized state version |\n| `linguamissingcatalogerror` | catalog has no source for requested locale |\n",
686
+ "usage": " \ntitle: lingua — usage guide\ndescription: translate explicit catalogs, load lazy locales, and connect locale snapshots to ui state.\n \n\n[[toc]]\n\n## basic usage\n\ncreate i18n store from locale keyed catalogs. strings are text messages; plural messages use `{ plural: ... }`.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: {\n en: {\n greeting: 'hello, {name}!',\n inbox: { plural: { one: 'one message', other: '{count} messages' } },\n },\n },\n locale: 'en',\n});\n\nconsole.log(i18n.translate('greeting', { values: { name: 'ada' } }));\nconsole.log(i18n.translate('inbox', { count: 3 }));\n```\n\ncall `dispose()` when store belongs to temporary request, test, or route owner.\n\n## define explicit catalogs\n\nuse nested objects only to group keys. a plural message always has `plural`, so regular objects containing `one` or `other` remain groups.\n\n```ts\nconst catalog = {\n account: {\n greeting: 'hello, {name}!',\n unread: { plural: { one: 'one unread message', other: '{count} unread messages' } },\n },\n};\n```\n\nuse `{ values }` for text replacements. pass `count` at top level for plural selection; lingua injects it into selected template. absent replacements render as `{name}` by default. `segments()` preserves an own `undefined` or `null` value; omit property to receive `{name}`.\n\ncatalogs contain strings, grouping objects, and explicit `{ plural: ... }` messages only. keep application data outside catalog, then translate display labels while constructing it.\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst messages = {\n status: { blocked: 'blocked', done: 'done', inprogress: 'in progress' },\n};\nconst statusdefinitions = [\n { labelkey: 'status.inprogress', value: 'in progress' },\n { labelkey: 'status.blocked', value: 'blocked' },\n { labelkey: 'status.done', value: 'done' },\n] as const;\nconst translator = createcatalogtranslator(messages);\nconst statusoptions = statusdefinitions.map(({ labelkey, value }) => ({ label: translator.translate(labelkey), value }));\n```\n\n## render framework content\n\nuse `segments()` when replacements are framework nodes, links, or other values that must not be stringified.\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator({ error: 'try {retry} or {support}.' });\n\nconst retry = { href: '/retry', label: 'retry' };\nconst support = { href: '/support', label: 'support' };\n\nconsole.log(translator.segments('error', { values: { retry, support } }));\n```\n\nrender returned array with framework fragment or list primitive. give ui values consumer owned keys before passing them to `segments()`; lingua preserves value identity and never clones or mutates them.\n\n## use static catalogs\n\nuse `createcatalogtranslator()` when one catalog and locale stay fixed for translator lifetime. it defaults locale to `en`; pass `locale` when plural rules or diagnostics need another locale. lingua snapshots catalog messages during construction. do not mutate source catalog objects afterward.\n\n```ts\nimport { createcatalogtranslator } from '@vielzeug/lingua';\n\nconst translator = createcatalogtranslator(\n { save: 'enregistrer' },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\nuse `createtranslator()` when fixed translation requires locale keyed catalogs and fallback resolution.\n\n```ts\nimport { createtranslator } from '@vielzeug/lingua';\n\nconst translator = createtranslator(\n { en: { save: 'save' }, fr: { save: 'enregistrer' } },\n { locale: 'fr' },\n);\n\nconsole.log(translator.translate('save'));\n```\n\n## load catalogs and switch locales\n\ndeclare one static catalog or lazy loader per locale. switch locale, then load it explicitly when source is lazy.\n\n```ts\nimport { createtranslationstore } from '@vielzeug/lingua';\n\nconst i18n = createtranslationstore({\n catalogs: {\n en: { navigation: { settings: 'settings' } },\n fr: async () => ({ navigation: { settings: 'réglages' } }),\n },\n locale: 'en',\n});\n\nawait i18n.setlocale('fr');\nawait i18n.load();\nconsole.log(i18n.translate('navigation.settings'));\n```\n\nconcurrent loads for same locale share work. `setlocale()` never triggers hidden loads.\n\n## subscribe to immutable snapshots\n\nsubscribe when ui state must change with locale or loaded active/fallback catalog. every callback receives snapshot containing translator for that revision.\n\n```ts\nconst unsubscribe = i18n.subscribe(\n ({ locale, translator }) => {\n console.log(locale, translator.translate('navigation.settings'));\n },\n { immediate: true },\n);\n\nunsubscribe();\n```\n\npass `{ signal }` when an `abortcontroller` owns subscription lifetime.\n\n## ssr state\n\nserialize resolved catalogs on server, then hydrate client store from same payload. `getsnapshot()` stays referentially stable until store revision changes, so use same hydrated store throughout initial client render.\n\n```ts\nimport { createtranslationstore, hydratetranslationstore } from '@vielzeug/lingua';\n\nconst servertranslationstore = createtranslationstore({\n catalogs: { en: { title: 'server title' } },\n locale: 'en',\n});\n\nconst state = servertranslationstore.serialize();\nconst clienttranslationstore = hydratetranslationstore(state, { fallback: 'en' });\n\nconsole.log(clienttranslationstore.translate('title'));\nservertranslationstore.dispose();\nclienttranslationstore.dispose();\n```\n\nstate contains raw loaded catalogs. it never contains loader functions.\n\n## formatting and validation\n\nimport formatting and catalog validation from dedicated subpaths to keep translation state focused.\n\n```ts\nimport { createformatter } from '@vielzeug/lingua/format';\nimport { validatecatalog } from '@vielzeug/lingua/validate';\n\nconst formatter = createformatter('en us');\nconst catalog = { inbox: { plural: { one: 'one message', other: '{count} messages' } } };\n\nconsole.log(formatter.currency(19.99, 'usd'));\nconsole.log(validatecatalog(catalog, 'en'));\n```\n\n## framework integration\n\npass stable `getsnapshot()` and `subscribe()` methods to framework state primitives. for ssr, create client store from same serialized state used by server before calling `usesyncexternalstore`.\n\n::: code group\n\n```ts [react]\nimport { usesyncexternalstore } from 'react';\n\nimport type { translationstore } from '@vielzeug/lingua';\n\nexport function usetranslator(i18n: translationstore) {\n const snapshot = usesyncexternalstore(i18n.subscribe, i18n.getsnapshot, i18n.getsnapshot);\n\n return snapshot.translator;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nimport type { translationstore } from '@vielzeug/lingua';\n\nexport function usetranslator(i18n: translationstore) {\n const snapshot = shallowref(i18n.getsnapshot());\n const unsubscribe = i18n.subscribe((next) => {\n snapshot.value = next;\n });\n\n onunmounted(unsubscribe);\n return snapshot;\n}\n```\n\n```ts [svelte]\nimport { readable } from 'svelte/store';\n\nimport type { translationstore } from '@vielzeug/lingua';\n\nexport function translatorstore(i18n: translationstore) {\n return readable(i18n.getsnapshot().translator, (set) => i18n.subscribe(({ translator }) => set(translator)));\n}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nbridge lingua subscriptions into ripple through flux when templates need reactive locale reads.\n\n```ts\nimport { stream } from '@vielzeug/flux';\nimport { tosignal } from '@vielzeug/flux/ripple';\nimport { computed } from '@vielzeug/ripple';\n\nconst localebinding = tosignal(\n stream<string>((observer) => {\n observer.next(i18n.locale);\n return i18n.subscribe(({ locale }) => observer.next(locale));\n }),\n { initial: i18n.locale },\n);\n\nexport const locale = computed(() => localebinding.value);\n```\n\nuse courier loaders when locale catalogs come from http rather than bundled modules; pass each loader to `catalogs`.\n\n## best practices\n\n define plural messages with `{ plural: ... }` and no sibling metadata.\n keep arrays and application metadata outside catalogs.\n treat source catalog objects as immutable after construction.\n use `translatedynamic()` only for runtime generated keys.\n load a lazy catalog before rendering it.\n give ui values keys before passing them to `segments()`.\n keep loader functions out of ssr payloads.\n dispose temporary stores after requests, tests, and route lifetimes.\n",
687
+ "examples": " \ntitle: lingua — examples\ndescription: focused examples for explicit catalogs and locale resources.\n \n\n [static translator](./examples/static translator.md)\n [lazy locale catalog](./examples/feature resources.md)\n [ssr hydration](./examples/ssr hydration.md)\n"
688
+ },
689
+ "examples": [
690
+ {
691
+ "id": "feature-resources",
692
+ "text": "lazy locale catalog import { createtranslationstore } from '@vielzeug/lingua'\n\nconst i18n = createtranslationstore({\n catalogs: {\n en: { home: 'home' },\n fr: async () => ({ home: 'accueil' }),\n },\n locale: 'en',\n})\n\nconsole.log(i18n.translate('home'))\nawait i18n.setlocale('fr')\nawait i18n.load()\nconsole.log(i18n.translate('home'))"
693
+ },
694
+ {
695
+ "id": "rich-segments",
696
+ "text": "rich segments import { createcatalogtranslator } from '@vielzeug/lingua'\n\n// segments() preserves components, nodes, or other non string replacements.\nconst translator = createcatalogtranslator({\n error: 'try {retry} or {support}.',\n})\n\nconst retry = { label: 'retry', href: '/retry' }\nconst support = { label: 'support', href: '/support' }\nconst result = translator.segments('error', { values: { retry, support } })\n\nconsole.log(result)\nconsole.log(result.map((part) => typeof part === 'string' ? part : part.label).join(''))"
697
+ },
698
+ {
699
+ "id": "static-translator",
700
+ "text": "static translator import { createcatalogtranslator } from '@vielzeug/lingua'\n\n// immutable translator: explicit text and plural catalog nodes.\nconst translator = createcatalogtranslator({\n greeting: 'bonjour, {name} !',\n inbox: { plural: { one: 'un message', other: '{count} messages' } },\n}, { locale: 'fr' })\n\nconsole.log(translator.translate('greeting', { values: { name: 'ada' } }))\nconsole.log(translator.translate('inbox', { count: 3 }))"
701
+ },
702
+ {
703
+ "id": "store",
704
+ "text": "reactive locale store import { createtranslationstore } from '@vielzeug/lingua'\n\nconst i18n = createtranslationstore({\n catalogs: {\n en: { save: 'save' },\n fr: { save: 'enregistrer' },\n },\n locale: 'en',\n})\n\ni18n.subscribe(({ locale, translator }) => {\n console.log(locale, translator.translate('save'))\n}, { immediate: true })\n\nawait i18n.setlocale('fr')"
705
+ }
706
+ ],
707
+ "exports": "createcatalogtranslator createtranslationstore createtranslator hydratetranslationstore linguaerror linguadisposederror linguainvalidcatalogerror linguainvalidlocaleerror linguainvalidpluralcounterror linguainvalidstateerror linguamissingcatalogerror",
708
+ "keywords": "internationalization translations pluralization locale i18n catalog loading",
709
+ "name": "@vielzeug/lingua",
710
+ "related": "ripple wayfinder courier",
711
+ "slug": "lingua",
712
+ "source": "export {\n linguadisposederror,\n linguaerror,\n linguainvalidcatalogerror,\n linguainvalidlocaleerror,\n linguainvalidpluralcounterror,\n linguainvalidstateerror,\n linguamissingcatalogerror,\n} from './errors';\nexport {\n createtranslationstore,\n hydratetranslationstore,\n type translationsnapshot,\n type translationstore,\n} from './i18n';\nexport { createcatalogtranslator, createtranslator, type translator } from './translator';\nexport type {\n catalog,\n catalogloader,\n catalognode,\n catalogsource,\n catalogsources,\n catalogs,\n catalogtranslatoroptions,\n locale,\n messagekey,\n pluralcategory,\n pluralkey,\n pluralmessage,\n pluraloptions,\n subscribeoptions,\n textkey,\n translateoptions,\n translationstate,\n translationstoreoptions,\n translatoroptions,\n values,\n} from './types';\n"
713
+ },
714
+ {
715
+ "category": "ui",
716
+ "description": "lifecycle owned web animations api primitives for native playback, groups, and additive flip transitions.",
717
+ "docs": {
718
+ "index": " \ntitle: necromancer — lifecycle owned dom animations\ndescription: lifecycle owned web animations api primitives for native playback, groups, and additive flip transitions.\npackage: necromancer\ncategory: ui\nkeywords: [animation, web animations api, waapi, flip, stagger, reduced motion]\nrelated: [orbit, ore]\nexports: [animate, animateeach, capturelayout]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"necromancer\" />\n\n## why necromancer?\n\nnative web animations api calls do not provide lifecycle ownership, reduced motion policy, grouped playback, or layout transitions. necromancer retains native keyframes and timing options while making ownership explicit for a component or dom feature. its default `180ms` duration makes the smallest call visible without hiding native timing control.\n\n```ts\n// before\nconst animation = element.animate(keyframes, { duration: 180 });\nanimation.addeventlistener('cancel', removelisteners);\n\n// after\nconst animation = animate(element, keyframes, { duration: 180 });\nanimation.dispose();\n```\n\n| feature | native waapi | necromancer | motion one |\n| | | | |\n| bundle size | 0 b | <packageinfo package=\"necromancer\" type=\"size\" /> | ~18 kb |\n| root dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| lifecycle handle | manual | `dispose()` | library specific controls |\n| reduced motion | manual | `motion: 'system'` default | configuration required |\n| layout transitions | manual flip math | `capturelayout().animate()` | separate api |\n\n<div class=\"decision callout\">\n\n**use necromancer when** you need native browser animations with explicit cancellation, reduced motion behavior, staggered groups, or positional flip transitions.\n\n**consider css transitions when** a static style change needs no playback control, cleanup, or layout measurement.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/necromancer\n```\n\n```sh [npm]\nnpm install @vielzeug/necromancer\n```\n\n```sh [yarn]\nyarn add @vielzeug/necromancer\n```\n\n:::\n\n## quick start\n\nstart the animation after its dom element mounts and release it when its ui owner is removed.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst notice = document.createelement('p');\nnotice.textcontent = 'saved';\ndocument.body.append(notice);\n\nconst animation = animate(\n notice,\n [{ opacity: 0, transform: 'translatey(8px)' }, { opacity: 1, transform: 'translatey(0)' }],\n { duration: 180, easing: 'ease out' },\n);\n\nawait animation.result;\nanimation.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `animate()` — native element animation with lifecycle ownership and direct native access\n `animateeach()` — group ownership with stable keyframe factories and `stagger`\n `capturelayout()` — one shot flip transition with additive `translate` (position) and `scale` (size)\n `motion` — `'system'` reduced motion support with explicit reduced outcomes\n `interrupt: 'cancel'` — replace active necromancer owned animation on an element\n `signal` — abort a handle from its parent lifecycle\n `dispose()` — idempotent cleanup with `[symbol.dispose]()`\n\n</div>\n\n## deliberate scope\n\nnecromancer owns explicit waapi keyframes. it does not generate css keyframes, observe css transitions, watch mutations, simulate springs, interpolate svg paths, or run a javascript tween loop. use css for declarative style changes and choose a dedicated tool when those capabilities are required.\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [orbit](/orbit/) — position floating ui before animating its appearance.\n [ore](/ore/) — own necromancer handles in a custom element's mount and disposal lifecycle.\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
719
+ "api": " \ntitle: necromancer — api reference\ndescription: api reference for @vielzeug/necromancer animation ownership, groups, reduced motion, and flip transitions.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `animate()` | animate one element | sync | defaults to a visible `180ms` duration |\n| `animateeach()` | animate a unique element group | sync | non zero `stagger` needs numeric `delay` |\n| `capturelayout()` | capture positions and create a one shot flip transition | sync | capture before changing layout |\n| `necromancererror` | base package error | sync | use `necromancererror.is()` to narrow unknown errors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/necromancer` | animation functions, types, and errors |\n| `@vielzeug/necromancer/testing` | jsdom test fakes for `element.animate()` and `getboundingclientrect()` |\n\n## animation functions\n\n### `animate()`\n\n```ts\nfunction animate(element: element, keyframes: keyframes, options?: animateoptions): animationhandle;\n```\n\nstarts a lifecycle owned native web animation. omitted `duration` defaults to `180` milliseconds; explicit native timing values, including `0`, are preserved. playback remains native:\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\nhandle.animation.pause();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n### `animateeach()`\n\n```ts\nfunction animateeach(\n elements: iterable<element>,\n keyframes: keyframes | keyframefactory,\n options?: animateeachoptions,\n): animationgroup;\n```\n\nstarts animations for unique elements in first seen order. necromancer resolves every keyframe factory before starting the first native animation. use each child handle's `animation` property for native playback control.\n\n## layout functions\n\n### `capturelayout()`\n\n```ts\nfunction capturelayout(elements: iterable<element>, options?: layoutcaptureoptions): layouttransition;\n```\n\ncaptures unique elements' positions and sizes and returns a one shot transition. rotation and other transforms are not captured or compensated. after changing layout, call `transition.animate(options)` to measure current positions and sizes and animate changed, connected elements with additive css `translate` (position) and `scale` (size). pass `getkey` when a framework replaces the captured elements during its render.\n\n```ts\nconst transition = capturelayout(beforeitems, {\n getkey: (element) => element.getattribute('data id')!,\n});\n\nrenderreordereditems();\n\nconst group = transition.animate({\n duration: 220,\n easing: 'ease out',\n elements: afteritems,\n});\n```\n\ncalling `animate()` twice on the same transition throws `necromancerconfigerror`.\n\n## types\n\n### `motionmode`\n\n```ts\ntype motionmode = 'full' | 'reduced' | 'system';\n```\n\n`'system'` is the default. reduced motion preserves the supplied keyframes while normalizing delay, duration, and end delay to `0`, and iterations to `1`.\n\n### `animationresult`\n\n```ts\ntype animationresult =\n | { readonly status: 'finished' }\n | { readonly status: 'reduced' }\n | { readonly reason?: unknown; readonly status: 'cancelled' };\n```\n\n`cancelled` describes native cancellation and includes its native rejection reason. a reason passed to `dispose()` or an abort signal takes precedence. the independent `disposed` property becomes `true` only when the lifecycle owner is explicitly disposed.\n\n### `animateoptions`\n\n```ts\ntype animateoptions = keyframeanimationoptions & {\n readonly interrupt?: 'cancel';\n readonly motion?: motionmode;\n readonly signal?: abortsignal;\n};\n```\n\nset `interrupt: 'cancel'` for rapid state changes that should replace every still active necromancer owned animation on the same element. it does not cancel animations created directly with `element.animate()`.\n\n### `animateeachoptions`\n\n```ts\ntype animateeachoptions = animateoptions & {\n readonly stagger?: number;\n};\n```\n\n`stagger` is a finite, non negative millisecond offset.\n\n### `layoutcaptureoptions`\n\n```ts\ninterface layoutcaptureoptions {\n readonly getkey?: (element: element) => string;\n}\n```\n\n`getkey` maps a captured element and its committed replacement to the same stable, non empty string. duplicate or empty keys throw `necromancerconfigerror`.\n\n### `layoutanimationoptions`\n\n```ts\ntype layoutanimationoptions = animateeachoptions & {\n readonly elements?: iterable<element>;\n};\n```\n\n`elements` is the collection in its committed layout. omit it to animate the same captured elements. with `getkey`, replacement elements animate from the positions of their captured predecessors. unmatched, removed, and newly entered elements are ignored.\n\n### `keyframes` and `keyframefactory`\n\n```ts\ntype keyframes = readonly keyframe[] | propertyindexedkeyframes;\ntype keyframefactory = (element: element, index: number, total: number) => keyframes;\n```\n\naccepts a `readonly` array so a reusable `as const` keyframe list can be passed without a cast.\n\n### `animationhandle`\n\n```ts\ninterface animationhandle {\n readonly animation: animation;\n readonly result: promise<animationresult>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [symbol.dispose](): void;\n}\n```\n\n### `animationgroup`\n\n```ts\ninterface animationgroup {\n readonly handles: readonly animationhandle[];\n readonly results: promise<readonly animationresult[]>;\n readonly disposed: boolean;\n dispose(reason?: unknown): void;\n [symbol.dispose](): void;\n}\n```\n\n`results` preserves the terminal result of every child in handle order. use `handles` for native playback control.\n\n### `layouttransition`\n\n```ts\ninterface layouttransition {\n animate(options?: layoutanimationoptions): animationgroup;\n}\n```\n\n## errors\n\n| error | trigger |\n| | |\n| `necromancererror` | base class for package errors |\n| `necromancerconfigerror` | invalid stagger, incompatible delay, or reused layout transition |\n| `necromancerunsupportederror` | `element.animate()` is unavailable |\n\n## testing (`@vielzeug/necromancer/testing`)\n\njsdom (and most non browser dom environments) do not implement `element.animate()`. import these from the `/testing` sub path, not the root entry point.\n\n### `animationcall`\n\n```ts\ntype animationcall = {\n readonly animation: fakeanimation;\n readonly keyframes: keyframe[] | propertyindexedkeyframes;\n readonly options?: keyframeanimationoptions;\n};\n```\n\none recorded invocation of `element.prototype.animate` from `installfakeanimations()`.\n\n### `installfakeanimations()`\n\n```ts\nfunction installfakeanimations(): { calls: animationcall[]; restore: () => void };\n```\n\nreplaces `element.prototype.animate` with a deterministic fake for the duration of a test. `calls` records every invocation in order; call `restore()` (for example in `aftereach`) to put the original implementation back.\n\n```ts\nimport { installfakeanimations } from '@vielzeug/necromancer/testing';\n\nconst { calls, restore } = installfakeanimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\ncalls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\nrestore();\n```\n\n### `fakeanimation`\n\n```ts\nclass fakeanimation {\n cancelcallcount: number;\n finishcallcount: number;\n finished: promise<void>;\n cancel(): void;\n finish(): void;\n}\n```\n\na minimal `animation` stand in. `cancel()` rejects `finished` with an `aborterror`; `finish()` resolves it. `cancelcallcount`/`finishcallcount` track how many times each was called, in place of a test runner specific spy.\n\n### `createrect()`\n\n```ts\nfunction createrect(x: number, y: number, width?: number, height?: number): domrect;\n```\n\nbuilds a `domrect` for mocking `element.getboundingclientrect()` in `capturelayout()` tests. `width`/`height` default to `20`.\n",
720
+ "usage": " \ntitle: necromancer — usage guide\ndescription: animate dom elements, coordinate groups, and create flip transitions with @vielzeug/necromancer.\n \n\n[[toc]]\n\n## basic usage\n\ncreate an animation after its element mounts, control playback through the native `animation`, and dispose its owner with the ui lifecycle.\n\n```ts\nimport { animate } from '@vielzeug/necromancer';\n\nconst handle = animate(\n element,\n [{ opacity: 0, transform: 'translatey(8px)' }, { opacity: 1, transform: 'translatey(0)' }],\n { duration: 180, easing: 'ease out', fill: 'both' },\n);\n\nhandle.animation.reverse();\nconst result = await handle.result;\nhandle.dispose();\n```\n\n`result` distinguishes natural completion, reduced timing, and cancellation. `disposed` reports only whether the owner was explicitly disposed.\n\nwhen `duration` is omitted, necromancer uses `180ms`; pass `duration: 0` when the caller intentionally wants an instant native animation.\n\n## replacing an active animation\n\nanimations normally run concurrently, including multiple necromancer animations on the same element. for state updates where only the newest animation should remain, set `interrupt: 'cancel'`.\n\n```ts\nconst first = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\nconst latest = animate(element, [{ opacity: 1 }, { opacity: 0 }], {\n interrupt: 'cancel',\n});\n\nawait first.result; // { status: 'cancelled', ... }\n```\n\ninterruption disposes only still active animations created by necromancer for that element. it never cancels an animation that your code started directly with `element.animate()`.\n\n## motion preferences\n\nuse `motion` to select how the animation responds to the operating system preference.\n\n```ts\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], {\n duration: 200,\n motion: 'system',\n});\n\nconst result = await handle.result;\n```\n\n`'system'` is the default and reduces movement when `prefers reduced motion: reduce` matches. `'full'` preserves the requested timing, while `'reduced'` always reduces it.\n\nreduced motion keeps the supplied keyframes but normalizes delay, duration, and end delay to zero and iterations to one. the result is `{ status: 'reduced' }`, and `handle.animation` still represents the requested visual transition.\n\n## parent cancellation\n\npass a parent `abortsignal` to release an animation when its owning work is cancelled.\n\n```ts\nconst controller = new abortcontroller();\nconst handle = animate(element, [{ scale: 0.96 }, { scale: 1 }], {\n duration: 160,\n signal: controller.signal,\n});\n\ncontroller.abort('route changed');\nconst result = await handle.result;\n// { status: 'cancelled', reason: 'route changed' }\n```\n\nan already aborted signal throws its reason before an animation starts.\n\n## staggering a group\n\npass an iterable of elements to `animateeach()`. duplicate elements are animated once in first seen order.\n\n```ts\nimport { animateeach } from '@vielzeug/necromancer';\n\nconst group = animateeach(\n document.queryselectorall('.card'),\n (_card, index) => [\n { opacity: 0, transform: `translatey(${12 + index * 2}px)` },\n { opacity: 1, transform: 'translatey(0)' },\n ],\n { duration: 220, easing: 'ease out', stagger: 45 },\n);\n\nconst results = await group.results;\ngroup.dispose();\n```\n\na group owns child lifecycles only. `results` preserves every child result in handle order; use `group.handles` when native playback control is required.\n\n## serial application flow\n\njavascript control flow is the clearest way to express serial, conditional, or branching animations:\n\n```ts\nfor (const step of steps) {\n const handle = animate(step.element, step.keyframes, {\n ...step.options,\n signal: controller.signal,\n });\n const result = await handle.result;\n\n if (result.status === 'cancelled') break;\n}\n```\n\none parent `abortsignal` cancels the active step without introducing a separate timeline abstraction.\n\n## animating a reorder with flip\n\ncapture positions before changing layout, then animate through the returned one shot transition.\n\n```ts\nimport { capturelayout } from '@vielzeug/necromancer';\n\nconst transition = capturelayout(items);\nlist.prepend(items[2]!);\n\nconst group = transition.animate({ duration: 220, easing: 'ease out' });\nawait group.results;\ngroup.dispose();\n```\n\nthe transition only animates changed, connected elements and can be animated once. it additively composes the individual css `translate` and `scale` properties, preserving authored `transform`, `translate`, and `scale`. a resized element (for example a list item whose content changed) animates from its captured size as well as its captured position.\n\n### replacing rendered elements\n\nwhen a framework replaces list nodes rather than reorders the captured elements, give `capturelayout()` a stable key and pass the committed nodes to `animate()`. capture before updating state, then call `animate()` only after the renderer has committed the new dom.\n\n```ts\nconst transition = capturelayout(beforeitems, {\n getkey: (element) => element.getattribute('data id')!,\n});\n\nrenderreordereditems();\n\ntransition.animate({\n duration: 220,\n easing: 'ease out',\n elements: afteritems,\n});\n```\n\nkeys must be unique, non empty strings in both collections. items with no matching predecessor are not enter animations; animate those explicitly with `animate()` or `animateeach()`.\n\nfor sortable lists, dnd exposes its pre commit layout seam through `onbeforereorder`; see the [dnd optimistic reorder recipe](/dnd/examples/optimistic reorder with revert.md).\n\n## scope\n\nnecromancer creates and owns explicit web animations api work. it does not observe css authored transitions or animations, inject `@keyframes`, watch dom mutations, generate springs, interpolate svg geometry, or provide a javascript tween fallback. keep css as the owner of declarative component styling and use a dedicated charting or tweening tool when the animation needs capabilities beyond waapi keyframes.\n\n## framework integration\n\ncreate handles in a client mount lifecycle and dispose them during unmount. the same composition works with reactive effect systems: start the animation in the effect and return `handle.dispose()` as its cleanup.\n\n```tsx\nimport { useeffect, useref } from 'react';\nimport { animate } from '@vielzeug/necromancer';\n\nexport function notice() {\n const elementref = useref<htmldivelement>(null);\n\n useeffect(() => {\n const element = elementref.current;\n if (!element) return;\n\n const handle = animate(element, [{ opacity: 0 }, { opacity: 1 }], { duration: 180 });\n return () => handle.dispose();\n }, []);\n\n return <div ref={elementref}>saved</div>;\n}\n```\n\n## testing\n\njsdom does not implement `element.animate()`, so code under test needs a fake. `@vielzeug/necromancer/testing` has no test runner import — it works the same under vitest, jest, or any other runner.\n\n```ts\nimport { installfakeanimations } from '@vielzeug/necromancer/testing';\nimport { animate } from '@vielzeug/necromancer';\n\nconst { calls, restore } = installfakeanimations();\nconst handle = animate(element, [{ opacity: 0 }, { opacity: 1 }]);\n\ncalls[0]?.animation.finish();\nawait handle.result; // { status: 'finished' }\nrestore();\n```\n\ncall `restore()` after each test (for example in `aftereach`) to put back whatever `element.prototype.animate` was before. use `createrect()` to mock `element.getboundingclientrect()` when testing code that calls `capturelayout()`.\n\n## best practices\n\n start animations only after their elements mount in the browser.\n dispose each handle or group with its ui owner.\n use native `animation` objects for playback control.\n respect the default `'system'` motion setting unless movement is essential.\n use a parent `abortsignal` for cancellable application flow.\n keep `delay` numeric when combining it with non zero `stagger`.\n capture layout before mutation and animate each transition exactly once.\n",
721
+ "examples": " \ntitle: necromancer — examples\ndescription: practical animation and flip layout recipes for @vielzeug/necromancer.\n \n\n## examples\n\n [animate on mount](./examples/animate on mount.md)\n [stagger a list](./examples/stagger a list.md)\n [animate a reorder](./examples/animate a reorder.md)\n\n"
722
+ },
723
+ "examples": [
724
+ {
725
+ "id": "flip",
726
+ "text": "capturelayout() flip reorder import { capturelayout } from '@vielzeug/necromancer'\n\nconst panel = document.createelement('section')\npanel.style.csstext = 'display: grid; gap: 12px; max width: 320px; padding: 20px; border: 1px solid #cbd5e1; border radius: 12px; background: #fff;'\n\nconst reorder = document.createelement('button')\nreorder.textcontent = 'move last item to top'\n\nconst list = document.createelement('div')\nlist.style.csstext = 'display: grid; gap: 8px;'\n\nconst items = ['alpha', 'beta', 'gamma'].map((label) => {\n const wrapper = document.createelement('div')\n const content = document.createelement('div')\n content.textcontent = label\n content.style.csstext = 'padding: 12px; border radius: 8px; color: #fff; background: #ea580c; font: 600 14px system ui;'\n wrapper.dataset.id = label\n wrapper.appendchild(content)\n list.appendchild(wrapper)\n return wrapper\n})\n\nconst status = document.createelement('output')\nstatus.textcontent = 'ready to reorder'\n\npanel.append(reorder, list, status)\ndocument.body.appendchild(panel)\n\nreorder.addeventlistener('click', () => {\n const transition = capturelayout(items, {\n getkey: (item) => item.dataset.id!,\n })\n const last = items.pop()\n if (!last) return\n\n items.unshift(last)\n const replacements = items.map((item) => item.clonenode(true) as htmldivelement)\n list.replacechildren(...replacements)\n items.splice(0, items.length, ...replacements)\n\n const group = transition.animate({\n duration: 260,\n easing: 'ease out',\n elements: replacements,\n })\n status.textcontent = 'animating layout change'\n group.results.then(() => {\n status.textcontent = 'flip animation finished'\n })\n})"
727
+ },
728
+ {
729
+ "id": "lifecycle",
730
+ "text": "animate() lifecycle handle import { animate } from '@vielzeug/necromancer'\n\nconst panel = document.createelement('section')\npanel.style.csstext = 'display: grid; gap: 12px; max width: 320px; padding: 20px; border: 1px solid #cbd5e1; border radius: 12px; background: #fff;'\n\nconst card = document.createelement('div')\ncard.textcontent = 'lifecycle owned animation'\ncard.style.csstext = 'padding: 18px; border radius: 8px; color: #fff; background: #2563eb; font: 600 16px system ui;'\n\nconst replay = document.createelement('button')\nreplay.textcontent = 'replay animation'\n\nconst status = document.createelement('output')\nstatus.textcontent = 'ready'\n\npanel.append(card, replay, status)\ndocument.body.appendchild(panel)\n\nlet current\n\nfunction run() {\n current?.dispose('replayed')\n current = animate(\n card,\n [\n { opacity: 0, transform: 'translatey(14px) scale(.96)' },\n { opacity: 1, transform: 'translatey(0) scale(1)' },\n ],\n { duration: 280, easing: 'ease out', fill: 'both' },\n )\n status.textcontent = 'animating…'\n current.result.then((result) => {\n status.textcontent = result.status === 'finished' ? 'finished — handle remains disposable' : result.status === 'reduced' ? 'finished with reduced timing' : 'cancelled'\n })\n}\n\nreplay.addeventlistener('click', run)\n"
731
+ },
732
+ {
733
+ "id": "reduced-motion",
734
+ "text": "motion reduced motion import { animate } from '@vielzeug/necromancer'\n\nconst panel = document.createelement('section')\npanel.style.csstext = 'display: grid; gap: 12px; max width: 320px; padding: 20px; border: 1px solid #cbd5e1; border radius: 12px; background: #fff;'\n\nconst card = document.createelement('div')\ncard.textcontent = 'motion preference'\ncard.style.csstext = 'padding: 18px; border radius: 8px; color: #fff; background: #7c3aed; font: 600 16px system ui;'\n\nconst systembutton = document.createelement('button')\nsystembutton.textcontent = 'animate with system preference'\n\nconst skipbutton = document.createelement('button')\nskipbutton.textcontent = 'use reduced motion'\n\nconst status = document.createelement('output')\nstatus.textcontent = 'choose a mode'\n\npanel.append(card, systembutton, skipbutton, status)\ndocument.body.appendchild(panel)\n\nfunction run(motion) {\n const handle = animate(\n card,\n [\n { opacity: 0, transform: 'translatex( 18px)' },\n { opacity: 1, transform: 'translatex(0)' },\n ],\n { duration: 320, easing: 'ease out', motion, fill: 'both' },\n )\n status.textcontent = 'running with motion: ' + motion\n handle.result.then((result) => {\n status.textcontent = result.status === 'reduced' ? 'keyframes finished with reduced timing' : 'finished'\n })\n}\n\nsystembutton.addeventlistener('click', () => run('system'))\nskipbutton.addeventlistener('click', () => run('reduced'))"
735
+ },
736
+ {
737
+ "id": "stagger",
738
+ "text": "animateeach() stagger a group import { animateeach } from '@vielzeug/necromancer'\n\nconst panel = document.createelement('section')\npanel.style.csstext = 'display: grid; gap: 12px; max width: 320px; padding: 20px; border: 1px solid #cbd5e1; border radius: 12px; background: #fff;'\n\nconst replay = document.createelement('button')\nreplay.textcontent = 'stagger cards'\n\nconst stack = document.createelement('div')\nstack.style.csstext = 'display: grid; gap: 8px;'\n\nconst cards = ['first', 'second', 'third', 'fourth'].map((label) => {\n const card = document.createelement('div')\n card.textcontent = label\n card.style.csstext = 'padding: 12px; border radius: 8px; color: #fff; background: #0891b2; font: 600 14px system ui;'\n stack.appendchild(card)\n return card\n})\n\nconst status = document.createelement('output')\nstatus.textcontent = 'ready'\n\npanel.append(replay, stack, status)\ndocument.body.appendchild(panel)\n\nfunction run() {\n const group = animateeach(\n cards,\n (_card, index) => [\n { opacity: 0, transform: 'translatey(' + (16 + index * 2) + 'px)' },\n { opacity: 1, transform: 'translatey(0)' },\n ],\n { duration: 240, easing: 'ease out', stagger: 70, fill: 'both' },\n )\n status.textcontent = 'staggering ' + group.handles.length + ' cards'\n group.results.then(() => {\n status.textcontent = 'all cards settled'\n })\n}\n\nreplay.addeventlistener('click', run)\n"
739
+ }
740
+ ],
741
+ "exports": "animate animateeach capturelayout",
742
+ "keywords": "animation web animations api waapi flip stagger reduced motion",
743
+ "name": "@vielzeug/necromancer",
744
+ "related": "orbit ore",
745
+ "slug": "necromancer",
746
+ "source": "export { animate } from './animate';\nexport { animateeach } from './animate each';\nexport { necromancerconfigerror, necromancererror, necromancerunsupportederror } from './errors';\nexport { capturelayout } from './layout';\nexport type {\n animateeachoptions,\n animateoptions,\n animationgroup,\n animationhandle,\n animationresult,\n keyframefactory,\n keyframes,\n layoutanimationoptions,\n layoutcaptureoptions,\n layouttransition,\n motionmode,\n} from './types';\n"
747
+ },
748
+ {
749
+ "category": "ui",
750
+ "description": "dependency free floating positioning with lifecycle owned geometry and middleware.",
751
+ "docs": {
752
+ "index": " \ntitle: orbit — floating ui positioning\ndescription: dependency free floating positioning with lifecycle owned geometry and middleware.\npackage: orbit\ncategory: ui\nkeywords: [positioning, tooltip, popover, dropdown, middleware, floating ui]\nexports: [autoupdate, computeposition, createpositioner]\nrelated: [ore, refine, prism]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"orbit\" />\n\n## why orbit?\n\nfloating ui needs one owner for css coordinates, clipping boundaries, updates, and cleanup. orbit provides a lifecycle positioner for normal ui and a pure computation api for advanced integrations.\n\n```ts\n// before\nconst { x, y } = computesomehow(trigger, panel);\npanel.style.left = `${x}px`;\npanel.style.top = `${y}px`;\n\n// after\nconst positioner = createpositioner(trigger, panel);\npositioner.start();\n```\n\n| feature | manual dom positioning | orbit |\n| | | |\n| bundle size | 0 b | <packageinfo package=\"orbit\" type=\"size\" /> |\n| root dependencies | application defined | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| clipping boundary | manual geometry | `clippingancestors` default |\n| coordinate strategy | consumer logic | `fixed` / `absolute` |\n| cleanup | manual listeners | `dispose()` |\n\n<div class=\"decision callout\">\n\n**use orbit when** floating ui needs robust placement, collision handling, or reactive updates.\n\n**consider direct css when** placement is static and never depends on element geometry.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/orbit\n```\n\n```sh [npm]\nnpm install @vielzeug/orbit\n```\n\n```sh [yarn]\nyarn add @vielzeug/orbit\n```\n\n:::\n\n## quick start\n\nstart a positioner only after its reference and floating elements mount.\n\n```ts\nimport { createpositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createpositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.start();\npositioner.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createpositioner()` — lifecycle owned floating positioning\n `computeposition()` — low level calculation for advanced integrations\n `autoupdate()` — scroll, viewport, resize, and animation frame updates\n middleware — offset, flip, shift, size, hide, arrow, inline, auto placement\n `strategy` — explicit `fixed` or `absolute` coordinate behavior\n `/reactive` — optional ripple position readable\n\n</div>\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](/refine/) — accessible components using floating ui behavior.\n [ore](/ore/) — lifecycle ownership for custom element positioning.\n [prism](/prism/) — chart tooltips positioned from virtual references.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
753
+ "api": " \ntitle: orbit — api reference\ndescription: api reference for @vielzeug/orbit positioners, computation, updates, middleware, and optional reactive integration.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createpositioner()` | lifecycle owned floating positioning | sync | call `start()` after mount |\n| `computeposition()` | low level geometry computation | sync | caller owns css application |\n| `autoupdate()` | listen for geometry changes | sync | call returned cleanup |\n| `createreactivepositioner()` | optional ripple position readable | sync | requires `@vielzeug/ripple` |\n| middleware factories | adjust placement and size | sync | order is explicit |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/orbit` | positioner, computation, updates, middleware, and types. |\n| `@vielzeug/orbit/reactive` | optional ripple position adapter. |\n| `@vielzeug/orbit/presets` | preset placement and middleware options. |\n| `@vielzeug/orbit/devtools` | development overlay. |\n\n## core functions\n\n### `createpositioner()`\n\n```ts\nfunction createpositioner(\n reference: referenceelement,\n floating: htmlelement,\n options?: positioneroptions,\n): positioner;\n```\n\ncreates an unstarted positioner.\n\n| parameter | type | description |\n| | | |\n| `reference` | `referenceelement` | dom or virtual anchor. |\n| `floating` | `htmlelement` | positioned element. |\n| `options` | `positioneroptions` | strategy, clipping, middleware, updates, and application callback. |\n\n**returns:** `positioner`.\n\n```ts\nimport { createpositioner } from '@vielzeug/orbit';\n\nconst positioner = createpositioner(trigger, tooltip);\npositioner.start();\npositioner.dispose();\n```\n\n| member | return | contract |\n| | | |\n| `start()` | `void` | starts positioning once. |\n| `update()` | `void` | recomputes and applies position. |\n| `getposition()` | `computepositionresult \\| null` | latest result; null before first update. |\n| `dispose()` | `void` | stops updates and aborts disposal signal. |\n\n### `computeposition()`\n\n```ts\nfunction computeposition(\n reference: referenceelement,\n floating: htmlelement,\n options?: computepositionoptions,\n): computepositionresult;\n```\n\ncalculates position without applying dom styles or creating listeners.\n\n**returns:** `computepositionresult`.\n\n### `autoupdate()`\n\n```ts\nfunction autoupdate(\n reference: referenceelement,\n floating: htmlelement,\n update: () => void,\n options?: autoupdateoptions,\n): () => void;\n```\n\ncalls `update` immediately, then on relevant scroll, viewport, resize, and optional animation frame changes.\n\n**returns:** cleanup callback.\n\n## middleware\n\n```ts\ntype middleware = (state: middlewarestate) => middlewareresult | undefined;\n```\n\nbuilt in factories: `arrow`, `autoplacement`, `flip`, `hide`, `inline`, `offset`, `shift`, `limitshift`, and `size`.\n\n```ts\nconst middleware = [offset(8), flip(), shift({ padding: 6 }), size()];\n```\n\n`middlewaredata` is `middlewaredata`; narrow custom data at the consuming boundary.\n\n## reactive adapter\n\n```ts\nfunction createreactivepositioner(\n reference: referenceelement,\n floating: htmlelement,\n options?: omit<positioneroptions, 'apply'>,\n): reactivepositioner;\n```\n\n`reactivepositioner.position` is `readable<computepositionresult | null>`. imported from `@vielzeug/orbit/reactive`.\n\n## types\n\n```ts\ntype side = 'top' | 'bottom' | 'left' | 'right';\ntype alignment = 'start' | 'end';\ntype placement = side | `${side} ${alignment}`;\n\ninterface rect {\n height: number;\n width: number;\n x: number;\n y: number;\n}\n\ninterface virtualreference {\n getboundingclientrect: () => domrect | rect;\n getclientrects?: () => domrectlist | domrect[];\n}\n\ntype referenceelement = element | virtualreference;\n\ninterface sideobject {\n bottom: number;\n left: number;\n right: number;\n top: number;\n}\n\ntype padding = number | partial<sideobject>;\n\ninterface arrowdata {\n centeroffset: number;\n constrained: boolean;\n x?: number;\n y?: number;\n}\n\ninterface flipdata {\n skippedplacements: placement[];\n}\n\ninterface shiftdata {\n x: number;\n y: number;\n}\n\ninterface hidedata {\n escaped?: boolean;\n escapedoffsets?: sideobject;\n referencehidden?: boolean;\n referencehiddenoffsets?: sideobject;\n}\n\ninterface sizedata {\n availableheight: number;\n availablewidth: number;\n}\n\ninterface middlewaredata {\n arrow?: arrowdata;\n flip?: flipdata;\n hide?: hidedata;\n shift?: shiftdata;\n size?: sizedata;\n [key: string]: unknown;\n}\n\ninterface middlewarestate {\n boundary?: element | rect;\n elements: { floating: htmlelement; reference: referenceelement };\n initialplacement: placement;\n middlewaredata: middlewaredata;\n padding?: padding;\n placement: placement;\n rects: { floating: rect; reference: rect };\n x: number;\n y: number;\n}\n\ntype middlewarereset = {\n placement?: placement;\n rects?: middlewarestate['rects'];\n remeasure?: boolean;\n};\n\ninterface middlewareresult {\n data?: middlewaredata;\n placement?: placement;\n reset?: middlewarereset;\n x?: number;\n y?: number;\n}\n\ntype middleware = (state: middlewarestate) => middlewareresult | undefined;\n\ninterface computepositionresult {\n middlewaredata: middlewaredata;\n placement: placement;\n x: number;\n y: number;\n}\n\ninterface computepositionoptions {\n boundary?: element | rect;\n containingblock?: element | null;\n middleware?: readonly middleware[];\n padding?: padding;\n placement?: placement;\n}\n\ninterface detectoverflowoptions {\n boundary?: element | rect;\n padding?: padding;\n}\n\ntype positionstrategy = 'absolute' | 'fixed';\n\ninterface positioneroptions extends omit<computepositionoptions, 'boundary' | 'containingblock'> {\n apply?: (result: computepositionresult) => void;\n autoupdate?: autoupdateoptions | false;\n boundary?: computepositionoptions['boundary'] | 'clippingancestors';\n strategy?: positionstrategy;\n}\n\ninterface positioner {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n getposition(): computepositionresult | null;\n start(): void;\n update(): void;\n [symbol.dispose](): void;\n}\n\ninterface autoupdateoptions {\n animationframe?: boolean;\n observeancestors?: boolean;\n observefloating?: boolean;\n observevisualviewport?: boolean;\n pausewhenhidden?: boolean;\n throttle?: number;\n}\n\ninterface reactivepositioner extends positioner {\n readonly position: readable<computepositionresult | null>;\n}\n\ninterface arrowoptions {\n element: htmlelement;\n padding?: padding;\n}\n\ninterface autoplacementoptions extends detectoverflowoptions {\n alignment?: alignment | null;\n allowedplacements?: placement[];\n}\n\ninterface flipoptions extends detectoverflowoptions {\n fallbackplacements?: placement[];\n}\n\ninterface hideoptions extends detectoverflowoptions {\n strategy?: 'referencehidden' | 'escaped' | 'both';\n}\n\ntype offsetconfig = {\n crossaxis?: number;\n mainaxis?: number;\n};\n\ntype offsetvalue = number | offsetconfig | ((state: middlewarestate) => number | offsetconfig);\n\ntype shiftlimiter = (\n state: middlewarestate,\n correction: { crossaxis: number; mainaxis: number },\n) => { crossaxis: number; mainaxis: number };\n\ninterface limitshiftoptions {\n offset?: number | ((state: middlewarestate) => number);\n}\n\ninterface shiftoptions extends detectoverflowoptions {\n crossaxis?: boolean;\n limiter?: shiftlimiter;\n}\n\ninterface inlineoptions {\n padding?: padding;\n x?: number;\n y?: number;\n}\n\ntype sizeoptions = detectoverflowoptions;\n\ninterface positioningpreset {\n middleware: middleware[];\n placement: placement;\n}\n\ninterface presetoptions {\n offset?: number;\n padding?: number;\n placement?: placement;\n}\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `orbitconfigerror` | invalid middleware reset configuration | extends `orbiterror` |\n| `orbiterror` | base orbit error | `instanceof orbiterror` narrows orbit errors |\n",
754
+ "usage": " \ntitle: orbit — usage guide\ndescription: position floating ui with lifecycle ownership, explicit coordinate strategy, middleware, and optional reactive state.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a positioner after both elements mount, then dispose it with their owner.\n\n```ts\nimport { createpositioner, flip, offset, shift } from '@vielzeug/orbit';\n\nconst positioner = createpositioner(trigger, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 6 })],\n placement: 'top',\n});\n\npositioner.start();\npositioner.dispose();\n```\n\n`createpositioner()` owns clipping boundary resolution, updates, css strategy, and cleanup.\n\n## coordinate strategy\n\nuse `fixed` for viewport positioned overlays. use `absolute` when the floating element should position within its offset parent.\n\n```ts\nconst positioner = createpositioner(trigger, dropdown, {\n placement: 'bottom start',\n strategy: 'absolute',\n});\n\npositioner.start();\n```\n\norbit resolves clipping ancestors by default. pass an explicit `boundary` when your application owns a different visible region.\n\n## middleware\n\npass middleware in the exact order it should execute.\n\n```ts\nconst positioner = createpositioner(trigger, panel, {\n middleware: [\n offset(8),\n flip(),\n shift({ padding: 8 }),\n size(),\n arrow({ element: arrowelement }),\n ],\n});\n```\n\nuse either `flip()` or `autoplacement()` for one positioner. custom middleware writes data into `result.middlewaredata`.\n\n## virtual references\n\nuse a virtual reference for cursor anchored ui.\n\n```ts\nconst reference = {\n getboundingclientrect: () => ({ height: 0, width: 0, x: event.clientx, y: event.clienty }),\n};\n\nconst positioner = createpositioner(reference, menu, { placement: 'bottom start' });\npositioner.start();\n```\n\n## manual positioning\n\nuse `computeposition()` only when your application owns css application and lifecycle itself.\n\n```ts\nimport { computeposition, offset } from '@vielzeug/orbit';\n\nconst result = computeposition(reference, floating, { middleware: [offset(8)] });\nfloating.style.left = `${result.x}px`;\nfloating.style.top = `${result.y}px`;\n```\n\n## reactive adapter\n\ninstall ripple and import the optional adapter only when your ui needs a reactive position value.\n\n```ts\nimport { createreactivepositioner } from '@vielzeug/orbit/reactive';\nimport { effect } from '@vielzeug/ripple';\n\nconst positioner = createreactivepositioner(trigger, tooltip);\n\neffect(() => {\n const position = positioner.position.value;\n if (!position) return;\n\n tooltip.style.left = `${position.x}px`;\n tooltip.style.top = `${position.y}px`;\n});\n```\n\n## client lifecycle\n\norbit root imports are server safe. invoke geometry apis only from a client mount lifecycle, where dom elements exist.\n\n```ts\nonmounted(() => {\n const positioner = createpositioner(trigger, panel);\n positioner.start();\n oncleanup(() => positioner.dispose());\n});\n```\n\n## framework integration\n\ncreate and dispose positioners with component lifecycle.\n\n::: code group\n\n```tsx [react]\nuseeffect(() => {\n const positioner = createpositioner(trigger, panel);\n positioner.start();\n\n return () => positioner.dispose();\n}, [trigger, panel]);\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nonmounted(() => {\n const positioner = createpositioner(trigger.value!, panel.value!);\n positioner.start();\n onunmounted(() => positioner.dispose());\n});\n</script>\n```\n\n```ts [svelte]\nonmount(() => {\n const positioner = createpositioner(trigger, panel);\n positioner.start();\n\n return () => positioner.dispose();\n});\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### orbit + prism\n\nuse `strategy: 'absolute'` for a tooltip rendered inside a chart container.\n\n```ts\nconst positioner = createpositioner(cursorreference, tooltip, {\n autoupdate: false,\n strategy: 'absolute',\n});\n\npositioner.start();\npositioner.dispose();\n```\n\n## best practices\n\n **start** a positioner after both dom elements mount.\n **dispose** it with its ui owner.\n **choose** `fixed` or `absolute` intentionally.\n **keep** middleware order explicit.\n **use** `computeposition()` only for advanced platform managed paths.\n **install** ripple only when importing `/reactive`.\n **invoke** geometry apis only on the client.\n",
755
+ "examples": " \ntitle: orbit — examples\ndescription: worked examples for @vielzeug/orbit.\n \n\n## examples\n\n [context menu](./examples/context menu.md)\n [custom middleware](./examples/custom middleware.md)\n [dropdown select](./examples/dropdown select.md)\n [popover with arrow](./examples/popover with arrow.md)\n [reactive adapter](./examples/reactive adapter.md)\n [tooltip](./examples/tooltip.md)\n [using presets](./examples/using presets.md)\n [with ore component](./examples/with ore component.md)\n"
756
+ },
757
+ "examples": [
758
+ {
759
+ "id": "auto-update",
760
+ "text": "autoupdate track on scroll/resize import { autoupdate, computeposition, flip, offset, shift } from '@vielzeug/orbit'\n\nconst button = document.createelement('button')\nbutton.textcontent = 'reference'\nbutton.style.csstext = 'position: fixed; left: 50%; top: 50%; transform: translate( 50%, 50%); padding: 8px 16px;'\ndocument.body.appendchild(button)\n\nconst dropdown = document.createelement('div')\ndropdown.textcontent = 'dropdown'\n// position: fixed with left: 0; top: 0 so left/top writes are absolute viewport coords\ndropdown.style.csstext = 'position: fixed; left: 0; top: 0; background: #fff; border: 1px solid #e5e5e5; border radius: 6px; padding: 12px 16px; box shadow: 0 4px 12px rgba(0,0,0,.1); z index: 1000;'\ndocument.body.appendchild(dropdown)\n\nconst middleware = [offset(4), flip(), shift({ padding: 8 })]\n\nfunction update() {\n const { x, y, placement } = computeposition(button, dropdown, {\n placement: 'bottom start',\n middleware,\n })\n dropdown.style.left = x + 'px'\n dropdown.style.top = y + 'px'\n dropdown.dataset.placement = placement\n console.log('positioned:', placement)\n}\n\n// autoupdate calls update immediately then re calls on scroll/resize/mutation\nconst cleanup = autoupdate(button, dropdown, update)\n\nconsole.log('autoupdate running — try resizing the window')\nconsole.log('cleanup type (call to stop):', typeof cleanup)"
761
+ },
762
+ {
763
+ "id": "inline-middleware",
764
+ "text": "inline multi line inline reference import { computeposition, flip, inline, shift } from '@vielzeug/orbit'\n\n// inline() corrects the reference rect for multi line inline elements.\n// it picks the client rect closest to the cursor (or floating element).\nconst span = document.createelement('span')\nspan.textcontent = 'hover to reveal tooltip — this is a long inline element that may wrap'\nspan.style.csstext = 'line height: 1.8; cursor: pointer; background: #f0f0f0; padding: 2px 4px; border radius: 3px;'\ndocument.body.appendchild(span)\n\nconst tooltip = document.createelement('div')\ntooltip.textcontent = 'inline() picks the nearest client rect'\ntooltip.style.csstext = 'position: fixed; left: 0; top: 0; background: #1a1a2e; color: #e0e0ff; padding: 6px 10px; border radius: 5px; font size: 12px; pointer events: none; z index: 1000;'\ntooltip.hidden = true\ndocument.body.appendchild(tooltip)\n\nlet cursorx = 0\nlet cursory = 0\n\nspan.addeventlistener('mousemove', (e) => {\n cursorx = e.clientx\n cursory = e.clienty\n tooltip.hidden = false\n\n const { x, y } = computeposition(span, tooltip, {\n placement: 'top',\n middleware: [inline({ x: cursorx, y: cursory }), flip(), shift({ padding: 4 })],\n })\n tooltip.style.left = x + 'px'\n tooltip.style.top = y + 'px'\n})\n\nspan.addeventlistener('mouseleave', () => { tooltip.hidden = true })\n\nconsole.log('hover over the span to see inline() in action')"
765
+ },
766
+ {
767
+ "id": "position-basic",
768
+ "text": "computeposition basic import { computeposition } from '@vielzeug/orbit'\n\n// create reference and floating elements\nconst button = document.createelement('button')\nbutton.textcontent = 'anchor'\nbutton.style.csstext = 'padding: 8px 16px; margin: 50px;'\ndocument.body.appendchild(button)\n\nconst tooltip = document.createelement('div')\ntooltip.textcontent = 'tooltip'\ntooltip.style.csstext = 'position: fixed; background: #333; color: #fff; padding: 8px 12px; border radius: 4px; font size: 12px; z index: 1000;'\ndocument.body.appendchild(tooltip)\n\n// computeposition is sync and returns x/y/placement/middlewaredata\nfunction updateposition() {\n const { x, y, placement } = computeposition(button, tooltip, {\n placement: 'top',\n })\n tooltip.style.left = x + 'px'\n tooltip.style.top = y + 'px'\n console.log(`positioned at ${placement}: (${math.round(x)}, ${math.round(y)})`)\n}\n\nbutton.addeventlistener('click', updateposition)\nupdateposition()\nconsole.log('tooltip positioned relative to button')"
769
+ },
770
+ {
771
+ "id": "position-float",
772
+ "text": "createpositioner with middleware import { createpositioner, offset, flip, shift } from '@vielzeug/orbit'\n\nconst button = document.createelement('button')\nbutton.textcontent = 'hover me'\nbutton.style.csstext = 'margin: 100px; padding: 8px 16px;'\ndocument.body.appendchild(button)\n\nconst tooltip = document.createelement('div')\ntooltip.textcontent = 'tooltip with middleware'\ntooltip.style.csstext = 'position: fixed; background: #1e293b; color: #fff; padding: 8px 12px; border radius: 6px; font size: 13px; pointer events: none; display: none;'\ndocument.body.appendchild(tooltip)\n\nlet positioner = null\n\nfunction show() {\n tooltip.style.display = 'block'\n positioner?.dispose()\n positioner = createpositioner(button, tooltip, {\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n placement: 'top',\n })\n positioner.start()\n console.log('placement:', positioner.getposition()?.placement)\n}\n\nfunction hide() {\n tooltip.style.display = 'none'\n positioner?.dispose()\n positioner = null\n}\n\nbutton.addeventlistener('mouseenter', show)\nbutton.addeventlistener('mouseleave', hide)\n\nconsole.log('hover the button to position the tooltip')"
773
+ },
774
+ {
775
+ "id": "presets",
776
+ "text": "presets ready made middleware stacks import { createpositioner } from '@vielzeug/orbit'\nimport { tooltip, dropdown, popover, contextmenu } from '@vielzeug/orbit/presets'\n\n// presets are pre configured middleware stacks for common ui patterns.\n// each factory returns { placement, middleware } — spread into createpositioner().\n\n// tooltip() \nconst tooltippreset = tooltip()\nconsole.log('tooltip placement:', tooltippreset.placement)\nconsole.log('tooltip middleware count:', tooltippreset.middleware.length)\n\n// customise placement and offset:\nconst toptooltip = tooltip({ placement: 'top', offset: 12 })\nconsole.log('custom tooltip placement:', toptooltip.placement)\n\n// dropdown() \nconst dropdownpreset = dropdown()\nconsole.log('dropdown placement:', dropdownpreset.placement)\n\nconst widedropdown = dropdown({ offset: 8, padding: 6 })\nconsole.log('wide dropdown middleware count:', widedropdown.middleware.length)\n\n// popover() \nconst popoverpreset = popover()\nconsole.log('popover placement:', popoverpreset.placement)\n\n// contextmenu() \nconst menupreset = contextmenu()\nconsole.log('contextmenu placement:', menupreset.placement)\n\nconst menutopstart = contextmenu({ placement: 'top start' })\nconsole.log('contextmenu custom placement:', menutopstart.placement)\n\n// spread a preset into createpositioner() \nconst trigger = document.createelement('button')\ntrigger.textcontent = 'hover me'\ntrigger.style.csstext = 'margin: 80px; padding: 8px 16px;'\ndocument.body.appendchild(trigger)\n\nconst tip = document.createelement('div')\ntip.textcontent = 'tooltip()'\ntip.style.csstext = 'position: fixed; background: #1e293b; color: #fff; padding: 6px 10px; border radius: 6px; font size: 13px; display: none;'\ndocument.body.appendchild(tip)\n\nlet positioner = null\n\ntrigger.addeventlistener('mouseenter', () => {\n tip.style.display = 'block'\n positioner = createpositioner(trigger, tip, tooltip())\n positioner.start()\n})\ntrigger.addeventlistener('mouseleave', () => {\n tip.style.display = 'none'\n positioner?.dispose()\n positioner = null\n})\n\nconsole.log('hover the button to see tooltip() in action')"
777
+ },
778
+ {
779
+ "id": "size-middleware",
780
+ "text": "size() constrain height import { createpositioner, offset, flip, size } from '@vielzeug/orbit'\n\nconst button = document.createelement('button')\nbutton.textcontent = 'open dropdown'\nbutton.style.csstext = 'margin:50px;padding:8px 16px;'\ndocument.body.appendchild(button)\n\nconst dropdown = document.createelement('div')\ndropdown.style.csstext = 'position:fixed;left:0;top:0;background:#fff;border:1px solid #e5e5e5;border radius:6px;overflow y:auto;box shadow:0 4px 12px rgba(0,0,0,.1);'\n// populate dropdown with many items\nfor (let i = 1; i <= 20; i++) {\n const item = document.createelement('div')\n item.textcontent = 'option ' + i\n item.style.csstext = 'padding:8px 16px;cursor:pointer;'\n dropdown.appendchild(item)\n}\ndocument.body.appendchild(dropdown)\n\n// size() writes availableheight/availablewidth to middlewaredata.size.\n// read it in the positioner apply callback to constrain the floating element.\nconst positioner = createpositioner(button, dropdown, {\n placement: 'bottom start',\n middleware: [offset(4), flip(), size({ padding: 8 })],\n apply(result) {\n const sizedata = result.middlewaredata.size\n if (sizedata) {\n dropdown.style.maxheight = math.min(sizedata.availableheight, 300) + 'px'\n console.log('available height:', sizedata.availableheight)\n }\n dropdown.style.left = result.x + 'px'\n dropdown.style.top = result.y + 'px'\n console.log('resolved placement:', result.placement)\n },\n})\npositioner.start()\n\nconsole.log('size() constrains dropdown height to available space')"
781
+ }
782
+ ],
783
+ "exports": "autoupdate computeposition createpositioner",
784
+ "keywords": "positioning tooltip popover dropdown middleware floating ui",
785
+ "name": "@vielzeug/orbit",
786
+ "related": "ore refine prism",
787
+ "slug": "orbit",
788
+ "source": "// auto update\nexport type { autoupdateoptions } from './auto update';\nexport { autoupdate } from './auto update';\n// core engine\nexport { computeposition } from './core';\n// errors\nexport { orbitconfigerror, orbiterror } from './errors';\n// high level api\nexport type { positioner, positioneroptions, positionstrategy } from './float';\nexport { createpositioner } from './float';\n// inline middleware\nexport type { inlineoptions } from './inline';\nexport { inline } from './inline';\n// middleware\nexport type { arrowoptions } from './middleware/arrow';\nexport { arrow } from './middleware/arrow';\nexport type { autoplacementoptions } from './middleware/auto placement';\nexport { autoplacement } from './middleware/auto placement';\nexport type { flipoptions } from './middleware/flip';\nexport { flip } from './middleware/flip';\nexport type { hideoptions } from './middleware/hide';\nexport { hide } from './middleware/hide';\nexport type { offsetconfig, offsetvalue } from './middleware/offset';\nexport { offset } from './middleware/offset';\nexport type { limitshiftoptions, shiftlimiter, shiftoptions } from './middleware/shift';\nexport { limitshift, shift } from './middleware/shift';\nexport type { sizeoptions } from './middleware/size';\nexport { size } from './middleware/size';\n// overflow helpers\nexport { detectoverflow, getclippingancestorrect } from './overflow';\n// preset types (functions live on the @vielzeug/orbit/presets sub path)\nexport type { positioningpreset, presetoptions } from './presets';\n// types\nexport type {\n alignment,\n arrowdata,\n computepositionoptions,\n computepositionresult,\n detectoverflowoptions,\n flipdata,\n hidedata,\n middleware,\n middlewaredata,\n middlewarereset,\n middlewareresult,\n middlewarestate,\n padding,\n placement,\n rect,\n referenceelement,\n shiftdata,\n side,\n sideobject,\n sizedata,\n virtualreference,\n} from './types';\n// public utilities\nexport { getalignment, getside } from './utils';\n"
789
+ },
790
+ {
791
+ "category": "ui primitives",
792
+ "description": "functional custom element authoring with typed props, reactive templates, lifecycle helpers, observers, and testing utilities.",
793
+ "docs": {
794
+ "index": " \ntitle: ore — web component authoring with signals\ndescription: functional custom element authoring with typed props, reactive templates, lifecycle helpers, observers, and testing utilities.\npackage: ore\ncategory: ui primitives\nkeywords: [web components, custom elements, reactive, templates, signals, lifecycle]\nrelated: [ripple, refine, orbit]\nexports: [define, prop, html, css, ref, createcontext, inject, injectstrict, provide, onmounted, oncleanup, onevent, onelement, onformreset, watcheffect, useemit, useslots, gethost, bind, each, when, classmap, stylemap, live, unsafehtml, usefield, intersectionobserver, mediaobserver, mutationobserver, resizeobserver, createid, createstableid, resetstableidcounter, oreerror, oreapierror, oreinternalerror, orelifecycleerror, bindoptions]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ore\" />\n\n## why ore?\n\nore keeps custom elements functional and signal driven while giving you direct control over templates, lifecycle hooks, host bindings, and form associated behavior.\n\n```ts\n// before — vanilla custom element boilerplate\nclass mycounter extends htmlelement {\n #count = 0;\n connectedcallback() {\n this.attachshadow({ mode: 'open' });\n this.#render();\n }\n #render() {\n this.shadowroot!.innerhtml = `<button>${this.#count}</button>`;\n this.shadowroot!.queryselector('button')!.onclick = () => {\n this.#count++;\n this.#render();\n };\n }\n}\ncustomelements.define('my counter', mycounter);\n\n// after — ore\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('my counter', {\n setup() {\n const count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n },\n});\n```\n\n| feature | ore | lit | stencil |\n| | | | |\n| bundle size | <packageinfo package=\"ore\" type=\"size\" /> | ~12 kb | ~60 kb+ toolchain |\n| signal first runtime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> (separate signals package) | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| functional component setup | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed prop helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| host binding helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | partial |\n| form associated helpers | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use ore when** you want typed, signal driven custom elements with minimal runtime overhead and no framework lock in.\n\n**consider lit when** you need a mature ecosystem with wide community adoption and don't need signal based reactivity.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ore @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ore @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { bind, css, define, html, onmounted, prop } from '@vielzeug/ore';\n\ndefine('my counter', {\n props: {\n label: prop.string('count'),\n step: prop.number(1),\n },\n styles: [\n css`\n :host {\n display: inline grid;\n gap: 0.5rem;\n }\n `,\n ],\n setup(props) {\n const count = signal(0);\n const doubled = computed(() => count.value * 2);\n\n bind({ class: { 'is positive': () => count.value > 0 } });\n\n onmounted(() => console.log('mounted'));\n\n return html`\n <button @click=${() => (count.value += props.step.value)}>${props.label}: ${count}</button>\n <p>doubled: ${doubled}</p>\n `;\n },\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n signal first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly\n functional component authoring via `define(tag, { props, setup, styles, formassociated })`\n props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneof`, `prop.json`, `prop.data`) or raw `propdef` objects\n `setup(props)` takes only props and returns an `htmlresult` directly: `return html\\`...\\``\n lifecycle hooks — `onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls\n directives: `each` (keyed reactive list rendering), `classmap`, `stylemap`, `when`, `live`, `unsafehtml`\n host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off host element\n reactive aria sync via `bind({ aria }, { target })` — applies `aria *` attributes reactively to any element, auto cleanup on disconnect\n context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useemit<emits>()` / `useslots<slotnames>()`\n form associated `usefield()` and observer helpers are root exports\n testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderhook`, `flush`, `cleanup`\n generic testing utilities (scoped queries, named event dispatchers, and async waits) are exported by `@vielzeug/assay`\n debug utilities (`@vielzeug/ore/testing`) — `debugflush()` for diagnosing update timing\n\n</div>\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis: components, directives, `usefield`, and observers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle flushing, hooks, cleanup, and form internals |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](../refine/index.md) for prebuilt accessible components powered by ore.\n [ripple](../ripple/index.md) for reactive state used inside ore components.\n [forge](../forge/index.md) for typed form state that integrates with ore.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
795
+ "api": " \ntitle: ore — api reference\ndescription: complete api reference for @vielzeug/ore and @vielzeug/ore/testing.\n \n\n[[toc]]\n\n## api overview\n\nall browser runtime symbols below are imported from `@vielzeug/ore`. lifecycle/context/binding functions (`onmounted`, `oncleanup`, `onevent`, `onelement`, `watcheffect`, `bind`, `provide`, `useemit`, `useslots`, `gethost`) resolve the active component through an implicit \"current component\" context — they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.\n\n> `watcheffect` is not named `watch` — `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `define()` | register a custom element with reactive setup | sync | tag must contain a hyphen; call before first use |\n| `html` | tagged template literal returning htmlresult | sync | expressions must be signals, functions, or primitives |\n| `prop.*` | typed prop helpers (string, bool, number, …) | sync | prop values are signals — read `.value` |\n| `provide()`/`inject()` | context api for parent to descendant sharing | setup only | must be called synchronously during `setup()` |\n| `ref()` | reactive reference to a dom element | sync | value is null until after first mount |\n| `createcontext()` | create a typed injection key | sync | context is scoped to the component tree |\n| `each()` | keyed list rendering with dom diffing | sync | duplicate keys report `ore:error`; plain `t[]` is a one time static render |\n| `when()` | conditional branch rendering | sync | getter fn computed disposed on cleanup; static bool skips subscription |\n| `live(signal)` | one way binding that skips stale writes during input | sync | use for controlled inputs alongside a manual `@input` handler |\n| `onmounted(fn)` | dom ready callback | setup only | must be called synchronously during `setup()` |\n| `oncleanup(fn)` | register teardown | setup only | called on component disconnect |\n| `onevent(target, …)` | scoped event listener with auto cleanup | setup only | no ops on null target; removed on disconnect |\n| `usefield(options)` | wire signal to form `elementinternals` | setup only | requires `formassociated: true` on the component definition |\n| `onformreset(fn)` | run work when the ancestor `<form>` resets | setup only | fires every reset (not one shot); only for `formassociated: true` components |\n| `useemit<emits>()` | typed `emit()` bound to the current host | setup only | call once per component; returns `dispatchevent`'s boolean (`false` if a listener called `preventdefault()`) |\n| `useslots<slotnames>()`| reactive slot presence/element signals | setup only | safe to call more than once — the underlying registry is created once |\n| `gethost()` | the current component's host element | setup only | prefer a higher level helper (`bind`, …) when one exists |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/ore` | all browser runtime apis, including directives, fields, and observers |\n| `@vielzeug/ore/testing` | ore specific mounting, lifecycle, hook, cleanup, and form test support |\n| `@vielzeug/assay` | generic dom events, scoped queries, and async waiting |\n\n## core component api\n\n### `define(tag, definition)`\n\n```ts\ndefine<props>(tag: string, definition: componentdefinition<props>): void;\n```\n\nthe `setup()` function receives only typed prop signals:\n\n```ts\nsetup(props) {\n return html`<div>${props.label}</div>`;\n}\n```\n\neverything else — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, html, onmounted, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my card', {\n setup(_props) {\n const emit = useemit<{ close: undefined }>();\n const slots = useslots<'header' | 'footer'>();\n\n onmounted(() => console.log('mounted'));\n\n // emit() returns dispatchevent's boolean — false if a listener called preventdefault()\n const notcancelled = emit('close');\n\n return html`${when(slots.has('header'), () => html`<slot name=\"header\"></slot>`)}`;\n },\n});\n```\n\n`useemit<emits>()` and `useslots<slotnames>()` are factory hooks — call them once per setup run to get a typed\n`emit`/`slots` bound to the current host. `useslots()` is safe to call more than once within that setup run.\n\n### componentdefinition\n\n```ts\ntype componentdefinition<props> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom (no shadow root)\n styles?: (string | cssstylesheet | cssresult)[];\n};\n```\n\n## runtime helpers\n\n`onmounted`, `oncleanup`, `onevent`, `onelement`, and `watcheffect` are plain functions imported from `@vielzeug/ore`. call them directly during `setup()`.\n\n```ts\nimport { html, oncleanup, onevent, onmounted } from '@vielzeug/ore';\n\nsetup(props) {\n onmounted(() => {\n // dom is ready; return a function for mount scoped cleanup\n return () => { /* cleanup on unmount */ };\n });\n\n oncleanup(() => { /* called on disconnect */ });\n\n onevent(window, 'keydown', (e) => { /* auto removed on disconnect */ });\n\n return html`...`;\n}\n```\n\nbecause these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too — no need to pass hooks in as options:\n\n```ts\nimport { oncleanup } from '@vielzeug/ore';\n\nfunction usemyhelper() {\n oncleanup(() => { /* teardown */ });\n}\n\n// in setup:\nsetup(_props) {\n usemyhelper();\n return html`...`;\n}\n```\n\n## props api\n\n| helper | signature | notes |\n| | | |\n| `prop.string(defaultvalue?)` | `propdef<string>` | reflects by default |\n| `prop.bool(defaultvalue?)` | `propdef<boolean>` | any non null attribute value other than `\"false\"` parses as `true`; `\"false\"` or absent attribute is `false` |\n| `prop.number(defaultvalue?)` | `propdef<number>` | returns default (not nan) and warns in dev when attribute is not a valid number |\n| `prop.oneof(allowed, defaultvalue)` | `propdef<t>` | restricts to provided string union |\n| `prop.json(defaultvalue)` | `propdef<t>` | json.parse from attribute; `reflect: false` |\n| `prop.data<t>(defaultvalue?)` | `propdef<t>` | js only — never reads/writes an attribute; use for objects, arrays, callbacks, or any non serialisable value |\n\n> **choosing the right prop helper:**\n>\n> **`prop.json`** — value can be declared in html (`<my el config='{\"x\":1}'>`); attribute string is `json.parse`d.\n> **`prop.data`** — value is always set from javascript (objects, arrays, callbacks, class instances); the attribute is never read. use this for both data and function props.\n\nwhen you need custom parsing or `reflect: false`, use a raw `propdef` object:\n\n```ts\nprops: {\n items: { default: [], parse: () => [], reflect: false },\n}\n```\n\nuse `prop.data` for props that hold js only values (including callbacks) that cannot be serialised through an html attribute:\n\n```ts\ndefine('data grid', {\n props: {\n getrowkey: prop.data<(row: unknown) => string>(),\n columns: prop.data<datagridcolumn[]>([]),\n onsort: prop.data<(key: string) => void>(),\n },\n setup(props) {\n // set from js: grid.getrowkey = (row) => row.id\n return html`...`;\n },\n});\n```\n\n## template and directives\n\n### `html`\n\ntagged template literal that returns an `htmlresult`. supports text interpolation, ordinary attributes (`attr=`),\nboolean attributes (`?attr=`), events (`@event=`), refs (`ref=`), and nested templates.\n\n### `css`\n\ntagged template literal that returns a `cssresult` for use in `styles`.\n\n### directives\n\n| directive | purpose |\n| | |\n| `each(source, key, render, fallback?)` | keyed reactive list; render receives `readable<t>` and `readable<number>`; plain `t[]` is a one time static snapshot |\n| `when(condition, truthy, falsy?)` | conditional rendering |\n| `classmap(record)` | reactive class string from object map |\n| `stylemap(record)` | reactive inline style string from object map |\n| `live(signal)` | one way binding that skips stale writes during active user input; use with `@input` handler |\n| `unsafehtml(value)` | html rendering sink; sanitize untrusted values before calling |\n\n### `unsafehtml`\n\n`unsafehtml()` is an explicit html injection sink. it has no global sanitizer: sanitize untrusted\ncontent before passing it to the directive, so the trust boundary remains at the call site.\n\n```ts\nimport { unsafehtml } from '@vielzeug/ore';\n\nconst safearticle = sanitize(usersuppliedarticle);\n\nreturn html`<article>${unsafehtml(safearticle)}</article>`;\n```\n\n## host bindings\n\n`bind(config, options?)` is a plain function imported from `@vielzeug/ore`:\n\n```ts\nbind({\n attr: { role: 'button', 'aria expanded': () => string(open.value) },\n class: { 'is open': open },\n style: { ' height': () => height.value + 'px' },\n on: { click: handleclick },\n});\n```\n\n`bind()` auto registers cleanup with the component scope — no manual `oncleanup` needed. returns a cleanup function for early teardown.\n\n### off host bindings\n\npass `{ target: el }` as a second argument to bind to any element other than the host:\n\n```ts\nbind(\n { attr: { 'aria expanded': () => string(isopen.value) } },\n { target: triggerel },\n);\n```\n\nevent listener options (`once`, `capture`, `passive`) are also accepted in the second argument. cleanup is auto registered with the component scope when called during setup.\n\n### reactive aria attributes\n\nfor reactive aria attribute syncing, use `bind({ aria: config }, { target })`. shorthand keys are normalised to `aria *` automatically (`expanded` → `aria expanded`; `role` is passed verbatim):\n\n```ts\n// inside setup — cleanup auto registered\nbind(\n {\n aria: {\n expanded: () => isopen.value,\n controls: panelid,\n haspopup: 'listbox',\n },\n },\n { target: triggerel },\n);\n\n// manage cleanup manually — bind() always returns a cleanup fn\nconst stoparia = bind({ aria: { expanded: () => isopen.value } }, { target: triggerel });\n// call stoparia() when the trigger is swapped out\n```\n\nstatic values (strings, numbers, booleans) are applied once. getter functions and signals create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n## slots\n\n `slots.has(name?)` — `readable<boolean>` — whether the named (or default) slot has assigned content\n `slots.elements(name?)` — `readable<element[]>` — the assigned elements for the slot\n\nslot signals update reactively when assigned content changes, including when slots are inserted dynamically (via `when()` or `each()`) after mount.\n\n## context api\n\n `createcontext<t>(description?)` — create a typed injection key\n `provide(key, value)` — provide a value to descendants\n `inject(key)` — resolve from nearest ancestor; returns `undefined` if not found\n `inject(key, fallback)` — resolve with a fallback value\n `injectstrict(key)` — resolve or throw if absent\n\n`provide()` and `inject()` must be called synchronously during `setup()`. calling them outside a setup context throws\n`'lifecycle hooks must be called during component setup'`. context resolution walks the ancestor chain including shadow\ndom boundaries. `inject()` resolves and caches its result once per consumer — provide a `readable` (signal/computed)\nrather than a raw value if descendants need to observe later changes; re calling `provide()` with a new raw value\nafterward is not seen by consumers that already resolved it (a dev mode warning fires when a key is provided twice on\nthe same element).\n\n## utilities\n\n `ref<t>()` — create a `signal<t | null>` element reference. set to the element via `ref=` in templates.\n `createid(prefix = 'id')` — generate a unique incremental string id (e.g. `'id 1'`, `'id 2'`). each call returns a new id — it does not deduplicate by prefix.\n `createstableid(prefix = 'id')` — generate a unique id that also embeds a short random tag shared across all ids generated in the session (e.g. `'field a3k21'`), reducing collision risk when multiple app instances run on the same page. like `createid()`, every call returns a new id.\n `resetstableidcounter()` — reset the `createstableid()` counter to 0. call in test `beforeeach` for deterministic ids. scoped to `createstableid()` only — `createid()` has no public reset (it's for uniqueness, not cross test determinism).\n\n## form associated api\n\nimport from `@vielzeug/ore`.\n\n### `usefield(options)`\n\nwire a form associated element to `elementinternals`. requires `formassociated: true` on the component definition. the `disabled` state tracking via `internals.states` (customstateset) is skipped with a dev warning if the api is unavailable in the current environment.\n\n```ts\ntype formfieldoptions<t> = {\n disabled?: readable<boolean>;\n /** defaults to the host element active during setup. */\n el?: htmlelement;\n /**\n * when true, a null/undefined value is submitted as '' instead of null,\n * keeping the field's key present in formdata even when the value is absent.\n * only applies to the default toformvalue; ignored if toformvalue is provided.\n * @default false\n */\n emptystringfornull?: boolean;\n /** called when the ancestor <form> resets (see onformreset) — restore local field state here. */\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n /** recomputed reactively and passed straight to internals.setvalidity(). null = always valid. */\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity(): boolean;\n readonly internals: elementinternals;\n reportvalidity(): boolean;\n /** set (non empty message) or clear (empty string) a custom validity error. */\n setcustomvalidity(message: string): void;\n};\n```\n\npass `validity`/`validationmessage` to make `required` style constraints participate in native constraint validation\nthrough `checkvalidity()` and `reportvalidity()`:\n\n```ts\nconst isblank = (v: string) => v.trim() === '';\n\nusefield({\n validationmessage: computed(() => (required.value && isblank(value.value) ? 'this field is required.' : '')),\n validity: computed(() => (required.value && isblank(value.value) ? { valuemissing: true } : null)),\n value,\n});\n```\n\n## observer apis\n\nimport from `@vielzeug/ore`.\n\n `resizeobserver(element)` — returns `readable<{ height: number; width: number }>`, initialised to `{ height: 0, width: 0 }`\n `intersectionobserver(element, options?)` — returns `readable<intersectionobserverentry | null>`, initialised to `null`\n `mutationobserver(element, options?)` — returns `readable<{ entries: mutationrecord[]; latest: mutationrecord | null }>`, initialised to `{ entries: [], latest: null }`\n `mediaobserver(query)` — returns `readable<boolean>`, initialised to the query's current `matches` state\n\n## testing apis\n\nimport from `@vielzeug/ore/testing`.\n\n| api | purpose |\n| | |\n| `mount(setup, options?)` | mount a component and return a test fixture |\n| `cleanup()` | remove all mounted elements and reset test state |\n| `install(aftereach, options?)` | register auto cleanup; pass `{ forminternals: true }` to also install the `elementinternals`/`formdata`/`<form>.reset()` jsdom polyfill (see below) |\n| `installforminternalspolyfill()` | installs the form internals polyfill directly (returns an `uninstall()` that restores every patched global). usually called via `install(aftereach, { forminternals: true })` |\n| `walkflattree(root, visit)` | walks the flat tree (expanding `<slot>` via `assignedelements()`) — for finding slotted content across a shadow boundary that `queryselectorall()` can't cross |\n| `flush(options?)` | drain reactive updates and animation frames |\n| `debugflush()` | run `flush()` with `console.debug` diagnostics |\n| `mock(tag, template?)` | register a no op stub custom element |\n| `renderhook(setup)` | run lifecycle hooks in isolation; overload accepts `propdefs` as first arg for typed props |\n| `resetorefortests()` | reset styles and id counters when mounting is managed manually |\n| `oretimeouterror` | error thrown when `flush()` cannot settle tracked ore work |\n\n> **test isolation:** `cleanup()` removes mounted elements and resets all cross test ore state (the stylesheet cache and id counters) via `resetorefortests()`. call it in `aftereach` (or use `install()`) to prevent state leaking between tests.\n\nimport `within`, named dispatchers such as `fireclick`, and waits such as `waituntil` or `waitforevent` from\n`@vielzeug/assay`.\n\n> **form associated component testing:** jsdom implements none of the `elementinternals` form association api — `install(aftereach, { forminternals: true })` polyfills `setformvalue`/`setvalidity`/`checkvalidity`/`reportvalidity`/`validationmessage`/`validity`/`states`, mixes `checkvalidity`/`reportvalidity`/`validity`/`validationmessage` onto the host element itself (real browsers do this for any `formassociated: true` element), makes `formdata` collect a form associated element's set value, and makes `<form>.reset()` invoke `formresetcallback()`. every patch is a guarded no op when its target already exists, and `installforminternalspolyfill()` returns an `uninstall()` that restores every patched global. the polyfill is opt in (`{ forminternals: true }`) because the patches are global — suites without form associated components shouldn't carry them. a downstream package (e.g. a component library built on `ore`) should rely on this instead of hand rolling its own copy.\n\n#### `fixture` interface\n\n```ts\ninterface fixture<t extends htmlelement = htmlelement> {\n [symbol.dispose](): void; // delegates to dispose() — enables `using` declarations\n element: t;\n readonly disposed: boolean; // true after dispose() has been called\n readonly shadow: shadowroot | null;\n get<e extends element>(selector: string): e;\n query<e extends element>(selector: string): e | null;\n queryall<e extends element>(selector: string): e[];\n getbytext<e extends element>(text: string, selector?: string): e;\n querybytext<e extends element>(text: string, selector?: string): e | null;\n queryallbytext<e extends element>(text: string, selector?: string): e[];\n getbytestid<e extends element>(testid: string): e;\n querybytestid<e extends element>(testid: string): e | null;\n queryallbytestid<e extends element>(testid: string): e[];\n attr(name: string, value: string | number | boolean): promise<void>;\n attrs(record: record<string, string | number | boolean>): promise<void>;\n flush(options?: flushoptions): promise<void>;\n act(fn: () => unknown): promise<void>;\n dispose(): void; // removes the component from the dom — idempotent\n}\n```\n\n#### `renderhook`\n\nuseful for testing composable lifecycle hooks (`onmounted`, `watcheffect`, `inject`, etc.) without a template. `onmounted`/`oncleanup`/`watcheffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current component context:\n\n```ts\n// without props\nconst { result, flush, dispose } = await renderhook(() => {\n const count = signal(0);\n onmounted(() => {\n count.value = 1;\n });\n return count;\n});\nexpect(result.value).tobe(1);\n\n// with typed props (prop defs overload)\nconst { result } = await renderhook({ label: prop.string('hello'), count: prop.number(0) }, (props) => props.label);\nexpect(result.value).tobe('hello');\n```\n\n## ripple primitives\n\nore does **not** re export reactive primitives. import them directly from `@vielzeug/ripple`:\n\n```ts\nimport { batch, computed, signal, watch } from '@vielzeug/ripple';\n```\n\nsee the [ripple documentation](/ripple/) for the full api.\n\n## lifecycle events\n\n| event | when |\n| | |\n| `ore:connect` | after every `connectedcallback` (including reconnects) |\n| `ore:disconnect` | after `disconnectedcallback`, before component state is reset |\n| `ore:error` | when a lifecycle callback fails — bubbles, composed; detail is `orelifecycleerror` |\n\n## types\n\n```ts\ntype propdef<t> = {\n readonly default: t;\n readonly parse: (value: string | null) => t;\n reflect?: boolean;\n};\n\ntype propsdef<t extends record<string, unknown>> = {\n [k in keyof required<t>]: propdef<t[k & keyof t]>;\n};\n\ntype propinputdefs = record<string, propdef<unknown>>;\n\n/**\n * infer reactive props type from a propinputdefs map.\n * each entry becomes readable<t> keyed by prop name.\n */\ntype inferprops<d extends propinputdefs> = {\n readonly [k in keyof d] ?: readable<inferpropvalue<d[k]>>;\n};\n\n// runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.\ntype onmountedcallback = () => cleanup | undefined;\ntype onformresetcallback = () => void;\n\ndeclare function onmounted(fn: onmountedcallback): void; // dom ready callback; runs after each connection's render\ndeclare function oncleanup(fn: cleanup): void; // register teardown; called on disconnect\ndeclare function onelement<t extends htmlelement>(\n ref: readable<t | null>,\n callback: (el: t) => cleanup | undefined,\n): () => void;\ndeclare function onevent<k extends keyof htmlelementeventmap>(\n target: eventtarget | null | undefined,\n event: k,\n listener: (e: htmlelementeventmap[k]) => void,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onevent(\n target: eventtarget | null | undefined,\n event: string,\n listener: eventlistener,\n options?: addeventlisteneroptions,\n): void;\ndeclare function onformreset(fn: onformresetcallback): void; // runs on every ancestor <form> reset; formassociated only\ndeclare function watcheffect(fn: () => cleanup | undefined): () => void; // scoped reactive effect; auto cleaned on disconnect\ndeclare function bind(config: hostbindconfig, options?: bindoptions): () => void; // bindings for host or any target element\ndeclare function provide<t>(key: injectionkey<t>, value: t): void; // register a context value on the host element\ndeclare function inject<t>(key: injectionkey<t>): t | undefined;\ndeclare function inject<t>(key: injectionkey<t>, fallback: t): t;\ndeclare function gethost(): htmlelement; // the current component's host element\ndeclare function useemit<emits extends record<string, unknown> = record<string, never>>(): emitfn<emits>;\ndeclare function useslots<slotnames extends string = string>(): componentslots<slotnames>;\n\ntype componentdefinition<props extends record<string, unknown> = record<never, never>> = {\n formassociated?: boolean;\n props?: propsdef<props>;\n setup: (props: inferprops<propsdef<props>>) => htmlresult | null;\n shadow?: partial<shadowrootinit> | false; // false = light dom\n styles?: (string | cssstylesheet | cssresult)[];\n};\n\ntype hostbindingvalue =\n | (() => string | number | boolean | null | undefined)\n | readable<string | number | boolean | null | undefined>\n | string\n | number\n | boolean\n | null\n | undefined;\n\ntype reflectconfig = record<string, hostbindingvalue>;\n\ntype hostbindconfig = {\n aria?: reflectconfig;\n attr?: reflectconfig;\n class?: (() => record<string, boolean>) | record<string, readable<boolean> | (() => boolean) | boolean>;\n on?: record<string, ((event: event) => void) | undefined>;\n style?: record<string, hostbindingvalue>;\n};\n\ntype bindoptions = addeventlisteneroptions & {\n target?: element;\n};\n\ntype hostbindfn = (config: hostbindconfig, options?: bindoptions) => () => void;\n\ntype componentslots<s extends string = string> = {\n elements(name?: s): readable<element[]>;\n has(name?: s): readable<boolean>;\n};\n\ntype ref<t extends element> = signal<t | null>;\n\ntype refcallback<t extends element> = (el: t | null) => void;\n\ntype injectionkey<t> = symbol & { readonly __ore_injection_key?: t };\n\ninterface htmlresult {\n mount(\n parent: parentnode,\n anchor: node | null,\n registercleanup: (fn: () => void) => void,\n ): node[];\n}\n\ntype cssresult = {\n content: string;\n tostring(): string;\n};\n\ntype livebinding<t> = { readonly source: readable<t> };\n\ntype emitfn<t extends record<string, unknown>> = {\n <k extends keyswithoutdetail<t>>(event: k): boolean;\n <k extends exclude<keyof t, keyswithoutdetail<t>>>(event: k, detail: t[k]): boolean;\n};\n// keyswithoutdetail is an internal helper type, not exported.\n\ntype formfieldoptions<t = unknown> = {\n disabled?: readable<boolean>;\n el?: htmlelement;\n emptystringfornull?: boolean;\n onreset?: () => void;\n toformvalue?: (value: t) => file | formdata | string | null;\n validationmessage?: readable<string>;\n validity?: readable<validitystateflags | null>;\n value: signal<t> | readable<t>;\n};\n\ntype formfieldhandle = {\n checkvalidity: () => boolean;\n readonly internals: elementinternals;\n reportvalidity: () => boolean;\n setcustomvalidity: (message: string) => void;\n};\n\ntype mutationobservervalue = {\n entries: mutationrecord[];\n latest: mutationrecord | null;\n};\n\n/** phase in which a oreerror occurred. */\ntype oreerrorphase = 'each reconcile' | 'form reset' | 'mounted' | 'setup';\n```\n\n## errors\n\n`oreerror` is the base class for every ore error class — `err instanceof oreerror` catches all of them.\n`oreerror.is(err)` is the equivalent static type guard.\n\n **`oreapierror`** — thrown when the `ore` api itself is misused: calling `define()` with a duplicate tag, calling a lifecycle hook (`inject`, `onmounted`, `oncleanup`, `onevent`, …) outside of `setup()`, or passing an invalid prop definition to `define()`.\n **`oreinternalerror`** — thrown when an ore invariant fails, indicating a package bug rather than invalid application code.\n **`orelifecycleerror`** — reported in the `ore:error` event when component `setup()`, a mounted callback, a form reset callback, or `each()` reconciliation fails. extends `oreerror` with:\n `component: string` — the element's local name\n `phase: oreerrorphase` — `'setup'` | `'mounted'` | `'form reset'` | `'each reconcile'`\n `cause: error` — the original error thrown by `setup()`\n **`oretimeouterror`** — thrown by `flush()` (from `@vielzeug/ore/testing`) when pending ore work does not settle before its timeout.\n\nlifecycle failures dispatch a bubbling, composed `ore:error` event whose `detail` is the `orelifecycleerror`. setup\nfailures still rethrow their original error; mounted and form reset callback failures are reported through the same\nevent so their remaining callbacks can continue.\n",
796
+ "usage": " \ntitle: ore — usage guide\ndescription: practical ore usage patterns for components, props, templates, slots, context, forms, observers, and tests.\n \n\n[[toc]]\n\n## basic usage\n\n`define(tag, definition)` registers a custom element.\n\nyour `setup()` function receives typed prop signals and returns an `htmlresult` directly. its state belongs to the\ncurrent connection: disconnect disposes it, and reconnecting the same element runs setup again.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('status chip', {\n setup() {\n const online = signal(true);\n\n return html`\n <button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'online' : 'offline')}</button>\n `;\n },\n});\n```\n\neverything besides `props` — lifecycle hooks, host bindings, context, slots, emit — is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):\n\n```ts\nimport { define, gethost, html, bind, useemit, useslots } from '@vielzeug/ore';\n\ndefine('my widget', {\n setup(_props) {\n const el = gethost(); // the host htmlelement\n const emit = useemit<{ close: undefined }>(); // typed event emitter\n const slots = useslots<'header'>(); // reactive slot observation\n\n bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)\n\n return html`<slot></slot>`;\n },\n});\n```\n\n## signals and effects\n\nore does not re export ripple primitives — import them directly from `@vielzeug/ripple`.\n\n```ts\nimport { batch, computed, effect, signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst doubled = computed(() => count.value * 2);\n\neffect(() => {\n console.log('doubled =', doubled.value);\n});\n\nwatch(count, (next, prev) => {\n console.log('count changed', prev, ' >', next);\n});\n\nbatch(() => {\n count.value = 1;\n count.value = 2;\n});\n```\n\n## onmounted and lifecycle\n\nuse `onmounted()` for dom dependent initialization that must run after the template is mounted. use `onelement(ref, cb)` for work tied to a specific dom node. `onevent()` attaches a listener that is automatically removed on disconnect.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, onelement, onevent, onmounted, ref, useslots } from '@vielzeug/ore';\n\ndefine('deferred init', {\n setup(_props) {\n const tabindex = signal(0);\n const inputref = ref<htmlinputelement>();\n const slots = useslots<'items'>();\n\n onmounted(() => {\n const items = slots.elements('items').value;\n console.log('found', items.length, 'items');\n });\n\n onelement(inputref, (input) => {\n input.focus();\n });\n\n onevent(window, 'keydown', (e: keyboardevent) => {\n if (e.key === 'escape') tabindex.value = 0;\n });\n\n return html`<div><slot name=\"items\"></slot><input ref=${inputref} /></div>`;\n },\n});\n```\n\n## prop definitions\n\nuse `prop.*` helpers for common cases, or raw `propdef` objects for custom parsing or `reflect: false`.\n\n```ts\nimport { define, html, prop } from '@vielzeug/ore';\n\ndefine('x button', {\n props: {\n label: prop.string('button'),\n disabled: prop.bool(false),\n variant: prop.oneof(['primary', 'secondary'] as const, 'primary'),\n count: prop.number(0),\n },\n setup(props) {\n return html`\n <button ?disabled=${props.disabled} data variant=${props.variant}>${props.label} (${props.count})</button>\n `;\n },\n});\n```\n\n## template bindings\n\n`html` supports text, attributes, booleans, properties, events, refs, and nested templates.\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\nimport { define, html, ref } from '@vielzeug/ore';\n\ndefine('profile name', {\n setup() {\n const name = signal('alice');\n const inputref = ref<htmlinputelement>();\n\n return html`\n <label title=${computed(() => 'current: ' + name.value)}>name</label>\n <input\n ref=${inputref}\n value=${name}\n aria label=${() => 'current name ' + name.value}\n @input=${(event: event) => {\n name.value = (event.target as htmlinputelement).value;\n }} />\n <p>hello ${name}</p>\n `;\n },\n});\n```\n\n## directives\n\nore exports `each`, `classmap`, `stylemap`, `when`, `live`, and `unsafehtml` from `@vielzeug/ore`. use ordinary\nattribute bindings plus native event handlers for two way input state; no special model directive is required.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { classmap, define, each, html, stylemap, when } from '@vielzeug/ore';\n\ndefine('task list', {\n setup() {\n const tasks = signal([{ id: 1, text: 'write tests' }]);\n const active = signal(true);\n\n return html`\n <ul\n class=\"${classmap({ ready: () => tasks.value.length > 0 })}\"\n style=${stylemap({ opacity: () => (active.value ? 1 : 0.5) })}>\n ${when(\n () => active.value,\n () => html`<li>active</li>`,\n () => html`<li>paused</li>`,\n )}\n ${each(\n tasks,\n (task) => task.id,\n (task) => html`<li>${() => task.value.text}</li>`,\n )}\n </ul>\n `;\n },\n});\n```\n\n### each() api\n\n`each(source, key, render, fallback?)` takes positional arguments:\n\n **source** — signal, getter, or plain array\n **key** — function returning a unique key per item\n **render** — receives reactive `item` and `index` signals\n **fallback** — optional, rendered when the list is empty\n\n```ts\neach(\n items,\n (item) => item.id,\n (item, index) => html`<li>#${index}: ${() => item.value.label}</li>`,\n () => html`<li>no items</li>`,\n);\n```\n\n## live form bindings\n\nuse `live(signal)` for inputs that should preserve in progress user edits instead of overwriting the dom on stale writes.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, live } from '@vielzeug/ore';\n\ndefine('live search', {\n setup() {\n const query = signal('');\n\n return html`\n <input value=${live(query)} @input=${(e: event) => (query.value = (e.target as htmlinputelement).value)} />\n `;\n },\n});\n```\n\n## host bindings\n\n`bind()` wires reactive attrs, classes, styles, and events to the host element.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html } from '@vielzeug/ore';\n\ndefine('x toggle', {\n setup(_props) {\n const open = signal(false);\n\n bind({\n attr: { 'aria expanded': () => string(open.value), role: 'button', tabindex: 0 },\n class: { 'is open': open },\n on: { click: () => (open.value = !open.value) },\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nthe `bind` config supports `attr`, `class`, `style`, and `on` sections.\n\n## aria bindings\n\nuse `bind({ aria: config }, { target })` to reactively sync aria attributes to any element. shorthand keys are normalised to `aria *` automatically — `expanded` becomes `aria expanded`, `role` is set verbatim.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted } from '@vielzeug/ore';\n\ndefine('x disclosure', {\n setup(_props) {\n const open = signal(false);\n const panelid = 'disclosure panel';\n\n bind({\n attr: { role: 'button', tabindex: 0 },\n on: { click: () => (open.value = !open.value) },\n });\n\n onmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n if (trigger) {\n // bind() registers cleanup automatically when called inside setup\n bind(\n {\n aria: {\n controls: panelid,\n expanded: () => string(open.value),\n haspopup: 'region',\n },\n },\n { target: trigger },\n );\n }\n });\n\n return html`<slot></slot>`;\n },\n});\n```\n\nstatic values are applied once. getter functions create reactive effects. setting a value to `null`, `undefined`, or `false` removes the attribute.\n\n`bind()` always returns a cleanup function. use it to stop syncing early when a trigger element can be swapped out:\n\n```ts\nonmounted(() => {\n const trigger = document.queryselector('#trigger') as htmlelement;\n const stoparia = bind({ aria: { expanded: () => string(open.value) } }, { target: trigger });\n\n // stop syncing when the trigger is replaced\n oncleanup(stoparia);\n});\n```\n\n### binding a non host element with `bind()`\n\npass `{ target: el }` as a second argument to bind attributes, classes, styles, or events to any element:\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { bind, define, html, onmounted, ref } from '@vielzeug/ore';\n\ndefine('button wrapper', {\n setup(_props) {\n const visible = signal(false);\n const btnref = ref<htmlbuttonelement>();\n\n onmounted(() => {\n const btn = btnref.value;\n if (!btn) return;\n\n bind(\n {\n attr: { 'aria pressed': () => string(visible.value) },\n on: { click: () => (visible.value = !visible.value) },\n },\n { target: btn },\n );\n });\n\n return html`<button ref=${btnref}>toggle</button>`;\n },\n});\n```\n\n## slots and emits\n\n```ts\nimport { define, html, useemit, useslots, when } from '@vielzeug/ore';\n\ndefine('card with footer', {\n setup(_props) {\n const slots = useslots<'header' | 'footer'>();\n const emit = useemit<{ action: undefined }>();\n\n return html`\n <div class=\"card\">\n <slot name=\"header\"></slot>\n <slot></slot>\n ${when(slots.has('footer'), () => html`<footer><slot name=\"footer\"></slot></footer>`)}\n </div>\n <button @click=${() => emit('action')}>go</button>\n `;\n },\n});\n```\n\npass a `slotnames` type parameter to `useslots<slotnames>()` to get typed `slots.has()` and `slots.elements()` calls.\n\n## context provide/inject\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { createcontext, define, html, injectstrict, provide } from '@vielzeug/ore';\n\nconst count_ctx = createcontext<returntype<typeof signal<number>>>('count');\n\ndefine('count provider', {\n setup(_props) {\n const count = signal(0);\n provide(count_ctx, count);\n\n return html`<button @click=${() => count.value++}><slot></slot></button>`;\n },\n});\n\ndefine('count consumer', {\n setup() {\n const count = injectstrict(count_ctx);\n\n return html`<p>count: ${count}</p>`;\n },\n});\n```\n\n## form associated elements\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { define, html, prop } from '@vielzeug/ore';\nimport { usefield } from '@vielzeug/ore';\n\ndefine('rating input', {\n formassociated: true,\n setup() {\n const value = signal(0);\n const field = usefield({ value });\n\n return html`\n <button @click=${() => (value.value = 1)}>1</button>\n <button @click=${() => (value.value = 2)}>2</button>\n <button @click=${() => (value.value = 3)}>3</button>\n <button @click=${() => field.reportvalidity()}>validate</button>\n <p>current: ${value}</p>\n `;\n },\n});\n```\n\n## platform observers\n\nobserver helpers from `@vielzeug/ore` require real dom nodes, so call them inside `onmounted()`.\n\n```ts\nimport { effect } from '@vielzeug/ripple';\nimport { define, html, intersectionobserver, mediaobserver, onmounted, ref, resizeobserver } from '@vielzeug/ore';\n\ndefine('x observed', {\n setup(_props) {\n const boxref = ref<htmldivelement>();\n\n onmounted(() => {\n const element = boxref.value;\n if (!element) return;\n\n const size = resizeobserver(element);\n const visible = intersectionobserver(element, { threshold: 0.5 });\n const dark = mediaobserver('(prefers color scheme: dark)');\n\n // effect() auto tracks every signal read inside — re runs when any of the three change.\n effect(() => {\n console.log(size.value.width, visible.value?.isintersecting, dark.value);\n });\n });\n\n return html`<div ref=${boxref}>observe me</div>`;\n },\n});\n```\n\n## testing utilities\n\nimport from `@vielzeug/ore/testing`.\n\n```ts\nimport { aftereach, describe, expect, it } from 'vitest';\nimport { signal } from '@vielzeug/ripple';\nimport { fireclick } from '@vielzeug/assay';\nimport { html } from '@vielzeug/ore';\nimport { cleanup, mount } from '@vielzeug/ore/testing';\n\ndescribe('my counter', () => {\n aftereach(cleanup);\n\n it('increments on click', async () => {\n let count!: returntype<typeof signal<number>>;\n const { query, act } = await mount(() => {\n count = signal(0);\n return html`<button @click=${() => count.value++}>${count}</button>`;\n });\n\n expect(query('button')?.textcontent).tobe('0');\n\n await act(() => fireclick(query('button')!));\n\n expect(query('button')?.textcontent).tobe('1');\n });\n});\n```\n\n## framework integration\n\nore components are standard custom elements and work natively in any framework.\n\n::: code group\n\n```tsx [react]\n// react 19+ supports custom elements natively.\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\n\nfunction app() {\n return <x toggle aria label=\"open menu\" />;\n}\n```\n\n```ts [vue 3]\n<script setup lang=\"ts\">\nimport './x toggle'; // wherever define('x toggle', { ... }) is called\nimport { ref } from 'vue';\n\nconst open = ref(false);\n</script>\n\n<template>\n <x toggle :aria label=\"'open menu'\" @click=\"open = !open\" />\n</template>\n```\n\n```svelte [svelte]\n<script>\n import './x toggle'; // wherever define('x toggle', { ... }) is called\n\n function handleclick() {\n console.log('toggled');\n }\n</script>\n\n<x toggle aria label=\"open menu\" on:click={handleclick} />\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ripple\n\nimport ripple primitives directly from `@vielzeug/ripple` for standalone reactive state outside components.\n\n```ts\nimport { signal, computed } from '@vielzeug/ripple';\nimport { define, html } from '@vielzeug/ore';\n\n// shared state created outside any component\nconst theme = signal<'light' | 'dark'>('light');\nconst isdark = computed(() => theme.value === 'dark');\n\ndefine('theme toggle', {\n setup() {\n return html`\n <button @click=${() => (theme.value = isdark.value ? 'light' : 'dark')}>\n ${() =>\n isdark.value ? '<ore icon name=\"sun\" size=\"16\"></ore icon>' : '<ore icon name=\"moon\" size=\"16\"></ore icon>'}\n </button>\n `;\n },\n});\n```\n\n### with forge\n\nuse `@vielzeug/forge` for typed form state. `usefield()` remains intentionally narrow: it connects a form associated\ncustom element to native `elementinternals` without imposing submission, validation, or dirty state policy.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { define, html } from '@vielzeug/ore';\n\ndefine('signup form', {\n setup(_props) {\n const form = createform({ initialvalues: { email: '' } });\n\n return html`\n <form\n @submit=${(event: submitevent) => {\n event.preventdefault();\n void form.submit(async (values) => {\n console.log(values);\n });\n }}>\n <slot></slot>\n </form>\n `;\n },\n});\n```\n\n## best practices\n\n setup returns `html\\`...\\`` directly — not a function wrapping the template.\n use `watcheffect()` for reactive subscriptions tied to component lifetime — it auto registers cleanup on disconnect.\n use `onelement(ref, cb)` instead of `onmounted` when the work is tied to a single dom node.\n bind host attributes and classes via `bind()` rather than mutating the element directly.\n provide context at the nearest ancestor — avoid global context singletons.\n call `oncleanup()` for every resource allocated in `setup()` (websockets, intervals, external subscriptions).\n use `live(signal)` for form inputs to prevent clobbering user in progress edits.\n extract composable helper functions freely — `onmounted`/`oncleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.\n test component mounting and lifecycle with `@vielzeug/ore/testing`; import generic dom events, queries, and waits\n from `@vielzeug/assay`.\n",
797
+ "examples": " \ntitle: ore — examples\ndescription: practical examples and recipes for ore.\n \n\n## examples\n\n [counter component](./examples/counter component.md)\n [typed props and emits](./examples/typed props and emits.md)\n [observers in onmounted()](./examples/observers in onmount.md)\n [search list with directives](./examples/search list with directives.md)\n [context provider and consumer](./examples/context provider and consumer.md)\n [prop helpers and raw propdef](./examples/propsof builder api.md)\n [form associated rating input](./examples/form associated rating input.md)\n [test example with @vielzeug/ore/testing](./examples/test example at vielzeug ore testing.md)\n"
798
+ },
799
+ "examples": [],
800
+ "exports": "define prop html css ref createcontext inject injectstrict provide onmounted oncleanup onevent onelement onformreset watcheffect useemit useslots gethost bind each when classmap stylemap live unsafehtml usefield intersectionobserver mediaobserver mutationobserver resizeobserver createid createstableid resetstableidcounter oreerror oreapierror oreinternalerror orelifecycleerror bindoptions",
801
+ "keywords": "web components custom elements reactive templates signals lifecycle",
802
+ "name": "@vielzeug/ore",
803
+ "related": "ripple refine orbit",
804
+ "slug": "ore",
805
+ "source": "export type { componentdefinition } from './component types';\nexport { createcontext, type injectionkey, inject, injectstrict, provide } from './context';\nexport { define, prop } from './define';\n// near universal template directives — used in most non trivial components (lists,\n// conditionals, and class/style maps. kept in the main entry alongside\n// `html`/`define` rather than a separate sub path: tree shaking already means an unused export\n// costs nothing in a bundled consumer, so splitting these off only adds an extra import line\n// for functionality most components need on day one. `unsafehtml()` and `live()` remain here\n// too: their explicit names make their specialized behavior clear without a second import path.\nexport { classmap } from './directives/classmap';\nexport { each } from './directives/each';\nexport { type livebinding, live } from './directives/live';\nexport { stylemap } from './directives/stylemap';\nexport { unsafehtml } from './directives/unsafe html';\nexport { when } from './directives/when';\nexport { oreapierror, oreerror, type oreerrorphase, oreinternalerror, orelifecycleerror } from './errors';\nexport { type formfieldhandle, type formfieldoptions, usefield } from './forms/field';\nexport {\n type bindoptions,\n bind,\n type hostbindconfig,\n type hostbindfn,\n type hostbindingvalue,\n type reflectconfig,\n} from './host bind';\nexport { intersectionobserver } from './observers/intersection observe';\nexport { mediaobserver } from './observers/media observe';\nexport { type mutationobservervalue, mutationobserver } from './observers/mutation observe';\nexport { resizeobserver } from './observers/resize observe';\nexport type { inferprops, propdef, propinputdefs, propsdef } from './props';\n// lifecycle hooks — plain functions, called during setup() or a composable it invokes.\nexport {\n gethost,\n type onformresetcallback,\n type onmountedcallback,\n oncleanup,\n onelement,\n onevent,\n onformreset,\n onmounted,\n watcheffect,\n} from './runtime';\nexport { type componentslots, useslots } from './slots';\nexport { html } from './template/instantiator';\nexport { type htmlresult, type ref, type refcallback, ref } from './template/result';\nexport { type cssresult, css } from './utils/css';\nexport { type emitfn, useemit } from './utils/emit';\n\nexport { createid, createstableid, resetstableidcounter } from './utils/id';\n"
806
+ },
807
+ {
808
+ "category": "ui",
809
+ "description": "reactive svg charting library — line, bar, and area charts. signal driven updates, css themeable, accessible.",
810
+ "docs": {
811
+ "index": " \ntitle: prism — reactive svg data visualization\ndescription: reactive svg charting library — line, bar, and area charts. signal driven updates, css themeable, accessible.\npackage: prism\ncategory: ui\nkeywords: [chart, svg, visualization, reactive, line chart, bar chart, area chart, signals, typescript]\nrelated: [ripple, refine, orbit]\nexports:\n [\n createlinechart,\n createbarchart,\n createareachart,\n createpiechart,\n createsparkline,\n linearscale,\n timescale,\n bandscale,\n seriescolor,\n settheme,\n resettheme,\n animate,\n prismerror,\n charta11y,\n animationtarget,\n easingfn,\n legendstate,\n tooltipstate,\n chartplugincontext,\n point,\n scaffoldcontext,\n scaffoldgroups,\n charteventhandlers,\n stacksegment,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"prism\" />\n\n## why prism?\n\ncharting libraries typically require a framework binding, bundle heavy dependencies, or force canvas rendering that can't be styled with css. prism takes a different approach:\n\n```ts\n// before — chart.js, imperative setup with a canvas you can't css theme\nimport chart from 'chart.js/auto';\nconst ctx = document.getelementbyid('mychart') as htmlcanvaselement;\nnew chart(ctx, {\n type: 'line',\n data: { labels, datasets: [{ data: values }] },\n // re render manually when data changes, no signals, canvas not css styleable\n});\n\n// after — prism, declarative svg chart driven by a signal\nimport { createlinechart } from '@vielzeug/prism';\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([\n { key: 1, value: 12 },\n { key: 2, value: 40 },\n { key: 3, value: 28 },\n]);\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'users by day' },\n series: [{ name: 'users', data }],\n tooltip: true,\n});\n// chart auto updates when data.value changes — no manual re render\ndata.value = [...data.value, { key: 4, value: 65 }];\n```\n\n| feature | prism | chart.js | lightweight charts | d3 |\n| | | | | |\n| bundle size | <packageinfo package=\"prism\" type=\"size\" /> | ~60 kb | ~45 kb | ~30 kb (core) |\n| renderer | svg | canvas | canvas | svg/canvas |\n| reactive data model | ripple signals | plugin specific | plugin specific | manual |\n| css themeable | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | limited | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| reactive (signals) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| accessible svg | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | manual |\n| typescript first | <ore icon name=\"check\" size=\"16\"></ore icon> | partial | <ore icon name=\"check\" size=\"16\"></ore icon> | types available |\n\n<div class=\"decision callout\">\n\n**use prism when** you need lightweight, reactive charts that integrate with signal based state and can be styled purely with css. ideal for dashboards, admin panels, and data heavy applications using vielzeug.\n\n**consider alternatives when** you need 50+ chart types (echarts), financial trading charts (lightweight charts), or low level visualization grammar (d3).\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/prism\n```\n\n```sh [npm]\nnpm install @vielzeug/prism\n```\n\n```sh [yarn]\nyarn add @vielzeug/prism\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { signal } from '@vielzeug/ripple';\nimport '@vielzeug/prism/theme';\n\nconst data = signal([\n { key: 1, value: 10 },\n { key: 2, value: 25 },\n { key: 3, value: 18 },\n { key: 4, value: 32 },\n]);\n\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'revenue by month' },\n series: [{ name: 'revenue', data, color: '#3b82f6' }],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n crosshair: true,\n onhover: (event) => console.log(event?.datum),\n});\n\n// update data → chart re renders automatically\ndata.value = [...data.value, { key: 5, value: 28 }];\n\n// cleanup when done\nchart.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createlinechart(container, config)`** — line chart with linear, monotone, or step interpolation\n **`createbarchart(container, config)`** — bar chart with four layout variants: grouped, stacked, grouped horizontal, stacked horizontal\n **`createareachart(container, config)`** — filled area with configurable opacity\n **`createsparkline(container, config)`** — minimal inline sparkline (line, area, or bar variant)\n **`createpiechart(container, config)`** — pie, donut, or semi circle donut chart\n **`linearscale(config)`** — continuous numeric scale with nice tick generation\n **`timescale(config)`** — date/time scale with interval based ticks\n **`bandscale(config)`** — categorical scale for bar charts\n **`maybesignal<t>`** — pass plain values or `@vielzeug/ripple` signals; both work seamlessly\n **`seriescolor(index, override?)`** — resolve css palette color by series index\n **`settheme(theme)` / `resettheme()`** — apply or clear custom colors, font, and grid tokens at runtime\n **event hooks** — `onclick` and `onhover` callbacks on every chart\n **plugin system** — extend charts with `chartplugin` (`install()`/`dispose()` lifecycle, each isolated from the other's failures); supported by all chart types including `createpiechart`\n **devtools** — `debugchart()` from `@vielzeug/prism/devtools` logs mount/resize/dispose to `console.debug`; tree shaken from production unless imported\n **css custom properties** — full theme control via ` prism *` tokens\n **responsive** — auto resizes via `resizeobserver`\n **accessible** — aria labels and semantic svg structure\n **`symbol.dispose`** — explicit resource management following tc39 proposal\n\n</div>\n\n## sub paths\n\n| import | purpose |\n| | |\n| `@vielzeug/prism` | all chart factories, scales, and types |\n| `@vielzeug/prism/theme` | default css (custom properties + dark mode) |\n| `@vielzeug/prism/devtools` | `debugchart()` — opt in `console.debug` lifecycle logging, tree shaken in production |\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/) — reactive signals that power prism's auto updating charts\n [refine](/refine/) — accessible web components that pair well with prism for dashboards\n [orbit](/orbit/) — floating element positioning for chart tooltips and popovers\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
812
+ "api": " \ntitle: prism — api reference\ndescription: complete type signatures, parameter docs, and return values for every export in @vielzeug/prism.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlinechart()` | reactive line chart with curves and interpolation | sync | container must have explicit dimensions before mount |\n| `createbarchart()` | bar chart: grouped, stacked, horizontal variants | sync | use `variant` to switch layout; default is `'grouped'` |\n| `createareachart()` | filled area chart | sync | container must have explicit dimensions before mount |\n| `linearscale()` | continuous numeric → pixel scale | sync | config is not `maybesignal` — call again if domain/range changes |\n| `timescale()` | date → pixel scale | sync | config is not `maybesignal` — call again if domain/range changes |\n| `bandscale()` | categorical → pixel band scale | sync | config is not `maybesignal` — call again if domain/range changes |\n| `createsparkline()` | minimal inline sparkline (line/area/bar) | sync | defaults to decorative (`aria hidden=\"true\"`); set `a11y` to label |\n| `createpiechart()` | pie, donut, or semi circle donut chart | sync | `onclick`/`onhover` use slice signatures, not `chartevent` |\n| `seriescolor()` | css variable color for series index | sync | wraps at 8 colors; pass `override` to bypass the palette |\n| `settheme()` | apply custom palette / css tokens at runtime | sync | call before mounting charts; clears unset color slots from prior theme |\n| `resettheme()` | clear all custom theme overrides back to defaults | sync | use in test teardown or theme switcher reset |\n| `animate()` | animate svg element attributes via raf | async (raf) | returns a cancel function; `duration: 0` sets attributes synchronously |\n| `debugchart()` | wrap a `charthandle` with lifecycle logging | sync | import from `@vielzeug/prism/devtools`; tree shaken in production |\n| `prismerror` | base class for all prism originated errors | — | use `instanceof prismerror` to catch any prism error |\n| `charta11y` | accessibility intent (labelled or decorative) | — | omitting `a11y` defaults to `role=\"img\"` (scaffolded) or decorative (sparkline) |\n| `legendstate` | live legend state object (plugin api) | — | `el` is `null` when no legend is configured |\n| `tooltipstate` | live tooltip state object (plugin api) | — | `el` is `null` when no tooltip is configured |\n| `chartplugincontext` | context object passed to `chartplugin.install()` | — | use `disposalsignal` for plugin cleanup instead of overriding `dispose()` |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/prism` | all chart factories, scales, types, and utilities |\n| `@vielzeug/prism/theme` | default css custom properties (light + dark) |\n| `@vielzeug/prism/devtools` | `debugchart()` — opt in `console.debug` lifecycle logging, tree shaken in production |\n\n \n\n## chart factories\n\n### `createlinechart`\n\n```ts\nfunction createlinechart(container: htmlelement, config: linechartconfig): charthandle;\n```\n\ncreates a reactive line chart. supports multiple series, curve interpolation, tooltips, crosshair, and event hooks.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | dom element to render into (must have width/height) |\n| `config` | `linechartconfig` | chart configuration |\n\n**returns** — [`charthandle`](#charthandle)\n\n \n\n### `createbarchart`\n\n```ts\nfunction createbarchart(container: htmlelement, config: barchartconfig): charthandle;\n```\n\ncreates a reactive bar chart. use `variant` to switch between grouped, stacked, horizontal variants.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | dom element to render into |\n| `config` | `barchartconfig` | chart configuration |\n\n**returns** — [`charthandle`](#charthandle)\n\n \n\n### `createareachart`\n\n```ts\nfunction createareachart(container: htmlelement, config: areachartconfig): charthandle;\n```\n\ncreates a reactive filled area chart with configurable opacity, curve, and event hooks.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | dom element to render into |\n| `config` | `areachartconfig` | chart configuration |\n\n**returns** — [`charthandle`](#charthandle)\n\n \n\n### `createpiechart`\n\n```ts\nfunction createpiechart(container: htmlelement, config: piechartconfig): charthandle;\n```\n\ncreates a pie, donut, or semi circle donut chart. all three variants share the same `piechartconfig` — select via `variant`.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | dom element to render into (sized by css) |\n| `config` | `piechartconfig` | chart configuration |\n\n**returns** — [`charthandle`](#charthandle)\n\n \n\n### `createsparkline`\n\n```ts\nfunction createsparkline(container: htmlelement, config: sparklineconfig): charthandle;\n```\n\ncreates a minimal inline chart with no axes, no legend, and no margin. designed for use in tables, cards, and inline data contexts.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | dom element to render into (sized by css) |\n| `config` | `sparklineconfig` | sparkline configuration |\n\n**returns** — [`charthandle`](#charthandle)\n\n \n\n## scale factories\n\n### `linearscale`\n\n```ts\nfunction linearscale(config: linearscaleconfig): scale<number>;\n```\n\ncontinuous linear scale mapping a numeric domain to a pixel range. unlike chart config fields, scale factory config is not `maybesignal` — pass plain values and call `linearscale()` again if the domain/range changes.\n\n| field | type | default | description |\n| | | | |\n| `config.domain` | `[number, number]` | — | input data range `[min, max]`. a reversed domain (`min > max`) is supported for inverted axes. |\n| `config.range` | `[number, number]` | — | output pixel range `[min, max]` |\n| `config.nice` | `boolean` | `true` | extend domain to nice round numbers |\n| `config.clamp` | `boolean` | `false` | clamp output to range bounds |\n\n \n\n### `timescale`\n\n```ts\nfunction timescale(config: timescaleconfig): scale<date>;\n```\n\ntime scale mapping `date` values to pixels. automatically selects tick intervals (seconds → years).\n\n| field | type | default | description |\n| | | | |\n| `config.domain` | `[date, date]` | — | input date range `[start, end]` |\n| `config.range` | `[number, number]` | — | output pixel range |\n| `config.nice` | `boolean` | `true` | extend domain to nice boundaries |\n\n \n\n### `bandscale`\n\n```ts\nfunction bandscale(config: bandscaleconfig): bandscale;\n```\n\ncategorical scale dividing the range into equal bands with configurable padding.\n\n| field | type | default | description |\n| | | | |\n| `config.domain` | `string[]` | — | category names |\n| `config.range` | `[number, number]` | — | output pixel range |\n| `config.padding` | `number` | `0.1` | inner padding ratio (0–1) |\n| `config.paddingouter` | `number` | same as `padding` | outer edge padding ratio |\n\n \n\n## types\n\n### `charta11y`\n\naccessibility intent for a chart's root `<svg>` element. discriminated union: either explicitly decorative, or labelled with an accessible name.\n\n```ts\ntype charta11y =\n | { readonly decorative: true }\n | {\n readonly arialabel: string;\n readonly decorative?: false;\n readonly description?: string;\n };\n```\n\n| variant | field | type | description |\n| | | | |\n| decorative | `decorative` | `true` | marks the svg `aria hidden=\"true\"` — excluded from the accessibility tree |\n| labelled | `arialabel` | `string` | sets `role=\"img\"` + `aria label` on the svg; exposes the chart to assistive technology |\n| labelled | `description` | `string` | optional longer description; sets `aria description` if supported |\n\n> **default:** when `a11y` is omitted entirely, scaffolded charts (line/bar/area/pie) render with `role=\"img\"` but no `aria label`; sparklines render as `aria hidden=\"true\"` (decorative). set `a11y: { arialabel: '…' }` to label a chart, or `a11y: { decorative: true }` to explicitly mark it decorative.\n\n \n\n### `charthandle`\n\nreturned by all chart factories.\n\n```ts\ninterface charthandle {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly el: svgsvgelement;\n dispose(): void;\n [symbol.dispose](): void;\n}\n```\n\n| member | description |\n| | |\n| `el` | the root `svgsvgelement` (for styling or external manipulation) |\n| `disposed` | `true` once `dispose()` has run; useful for guarding late callbacks |\n| `disposalsignal` | aborted when the chart is disposed — tie your own cleanup (raf loops, observers) to this instead of overriding `dispose()` |\n| `dispose()` | tear down all effects, observers, dom nodes, tooltip, and legend. calling it more than once is a no op |\n| `[symbol.dispose]()` | same as `dispose()` — for tc39 `using` declarations |\n\n> **note:** charts re render automatically when signal data changes. there is no `update()` method — reactivity is fully automatic.\n\n \n\n### `chartevent`\n\npassed to `onclick` and `onhover` callbacks.\n\n```ts\ninterface chartevent {\n datum: datum;\n originalevent: event;\n series: series;\n}\n```\n\n \n\n### `chartplugin`\n\ninterface for extending charts with custom behavior. plugins are installed after the chart is mounted and torn down on `dispose()`.\n\n```ts\ninterface chartplugin {\n install(ctx: chartplugincontext): void;\n dispose(): void;\n}\n```\n\nsee [`chartplugincontext`](#chartplugincontext) for the object passed to `install()`.\n\n \n\n### `basechartconfig`\n\nshared configuration inherited by all chart config types.\n\n```ts\ninterface basechartconfig {\n a11y?: charta11y;\n legend?: boolean | legendconfig;\n margin?: partial<chartmargin>;\n onclick?: (event: chartevent) => void;\n onhover?: (event: chartevent | null) => void;\n plugins?: chartplugin[];\n tooltip?: boolean | tooltipconfig;\n transition?: transitionconfig;\n xaxis?: axisconfig;\n yaxis?: axisconfig;\n}\n```\n\n| field | type | description |\n| | | |\n| `a11y` | `charta11y` | accessibility intent — labelled (`{ arialabel: '…' }`) or decorative (`{ decorative: true }`) |\n| `legend` | `boolean \\| legendconfig` | show a series legend |\n| `margin` | `partial<chartmargin>` | override chart margins |\n| `onclick` | `(event: chartevent) => void` | fired when a data point is clicked |\n| `onhover` | `(event: chartevent \\| null) => void` | fired on mousemove (null on mouseleave) |\n| `plugins` | `chartplugin[]` | extension plugins installed at mount |\n| `tooltip` | `boolean \\| tooltipconfig` | hover tooltip |\n| `transition` | `transitionconfig` | enter/update animation |\n| `xaxis` | `axisconfig` | x axis configuration |\n| `yaxis` | `axisconfig` | y axis configuration |\n\n \n\n### `maybesignal<t>`\n\n```ts\ntype maybesignal<t> = readable<t> | t;\n```\n\naccepts either a plain value or a `@vielzeug/ripple` `readable<t>` signal (e.g. one created with `signal()`). used for `series`/`data` fields on chart configs — when a signal is passed, the chart re renders automatically on `.value` changes. not used by the scale factories (`linearscale`/`timescale`/`bandscale`), whose config fields are always plain values.\n\n \n\n### `scale<t>`\n\n```ts\ninterface scale<t> {\n readonly domain: readonly [t, t];\n readonly range: readonly [number, number];\n map(value: t): number;\n invert(pixel: number): t;\n ticks(count?: number): t[];\n}\n```\n\n| member | description |\n| | |\n| `domain` | input domain `[min, max]` — readonly computed tuple |\n| `range` | output pixel range — readonly computed tuple |\n| `map(value)` | domain value → pixel position |\n| `invert(pixel)` | pixel position → domain value |\n| `ticks(count?)` | nicely spaced tick values (default: 10) |\n\n \n\n### `bandscale`\n\n```ts\ninterface bandscale {\n readonly domain: readonly string[];\n readonly range: readonly [number, number];\n map(value: string): number;\n bandwidth(): number;\n gap(): number;\n ticks(count?: number): string[];\n}\n```\n\n| member | description |\n| | |\n| `map(value)` | left edge pixel position of a category's band |\n| `bandwidth()` | width of each band in pixels |\n| `gap()` | pixel gap between adjacent bands (`bandwidth × padding`) |\n| `ticks(count?)` | all domain categories, or at most `count` evenly sampled values |\n\n \n\n### `point`\n\n```ts\ninterface point {\n x: number;\n y: number;\n}\n```\n\na pixel space 2d point used by path builders and area renderers. exported for plugin authors who build custom svg paths.\n\n \n\n### `datum`\n\na single data point in a cartesian chart series.\n\n```ts\ninterface datum {\n key: date | number | string;\n value: number;\n meta?: record<string, unknown>;\n}\n```\n\n| field | type | description |\n| | | |\n| `key` | `date \\| number \\| string` | x axis identity. use `number` or `date` for line/area charts; `string` for bar categories |\n| `value` | `number` | y axis measured quantity |\n| `meta` | `record<string, unknown>` | optional arbitrary metadata (available in tooltip `render` callbacks) |\n\n \n\n### `series`\n\n```ts\ninterface series {\n name: string;\n data: maybesignal<datum[]>;\n color?: string;\n}\n```\n\n \n\n### `animationtarget`\n\n```ts\ninterface animationtarget {\n attrs: record<string, { from: number; to: number }>;\n el: svgelement;\n}\n```\n\none element + attribute map for use with `animate()`. each attribute entry specifies the start (`from`) and end (`to`) pixel value.\n\n \n\n## pie / donut types\n\n### `piechartconfig`\n\nextends [`basechartconfig`](#basechartconfig) (inherits `a11y`, `legend`, `plugins`, `tooltip`, `transition`). overrides `onclick`/`onhover` with pie specific slice signatures and omits `margin`/`xaxis`/`yaxis` (not applicable to radial charts).\n\n```ts\ninterface piechartconfig extends omit<basechartconfig, 'margin' | 'onclick' | 'onhover' | 'xaxis' | 'yaxis'> {\n cornerradius?: number;\n data: maybesignal<piesliceconfig[]>;\n innerradius?: number;\n onclick?: (slice: piesliceconfig, index: number) => void;\n onhover?: (slice: piesliceconfig | null, index: number | null) => void;\n padpixels?: number;\n variant?: pievariant;\n}\n```\n\n| field | type | default | description |\n| | | | |\n| `data` | `maybesignal<piesliceconfig[]>` | — | slice definitions |\n| `variant` | `pievariant` | `'pie'` | chart style: `'pie'`, `'donut'`, or `'semi'` |\n| `innerradius` | `number` | `55%` of outer (donut/semi), `0` (pie) | inner hole radius in pixels |\n| `padpixels` | `number` | `0` (pie), `8` (donut/semi) | pixel gap between slices (uniform across arc thickness) |\n| `cornerradius` | `number` | `0` (pie), `8` (donut/semi) | rounded arc corners (pixels) |\n| `onclick` | `(slice, index) => void` | — | fired on slice click |\n| `onhover` | `(slice\\|null, index\\|null) => void` | — | fired on hover; `null` on mouseleave |\n\n> inherited `basechartconfig` fields (`tooltip`, `transition`, `legend`, `a11y`, `plugins`) behave identically to other chart types. `margin`, `xaxis`, and `yaxis` are omitted (not applicable to radial charts).\n\n### `piesliceconfig`\n\n```ts\ninterface piesliceconfig {\n color?: string;\n label?: string;\n value: number;\n}\n```\n\n| field | type | description |\n| | | |\n| `value` | `number` | numeric weight of the slice |\n| `color` | `string` | slice fill color; defaults to ` prism color {n}` |\n| `label` | `string` | optional text rendered at the arc centroid |\n\n### `pievariant`\n\n```ts\ntype pievariant = 'donut' | 'pie' | 'semi';\n```\n\n **`pie`** — full circle, no hole\n **`donut`** — full circle with inner hole (~55% of outer radius by default)\n **`semi`** — top half semicircle with inner hole (useful for gauges/progress)\n\n \n\n## sparkline types\n\n### `sparklineconfig`\n\n```ts\ninterface sparklineconfig {\n a11y?: charta11y;\n color?: string;\n cornerradius?: number;\n curve?: 'linear' | 'monotone' | 'step';\n data: maybesignal<number[] | stacksegment[]>;\n fillopacity?: number;\n onclick?: (index: number, value: number) => void;\n onhover?: (index: number | null, value: number | null) => void;\n padpixels?: number;\n strokewidth?: number;\n transition?: transitionconfig;\n variant?: sparklinevariant;\n}\n```\n\n| field | type | default | description |\n| | | | |\n| `data` | `maybesignal<number[] \\| stacksegment[]>` | — | numeric values, or `stacksegment[]` for `'stack'` variant |\n| `variant` | `sparklinevariant` | `'line'` | chart style |\n| `a11y` | `charta11y` | decorative | accessibility intent — labelled (`{ arialabel: '…' }`) or decorative (`{ decorative: true }`). defaults to decorative when omitted |\n| `color` | `string` | `var( prism color 1)` | stroke/fill color (line/area/bar only) |\n| `curve` | `'linear' \\| 'monotone' \\| 'step'` | `'linear'` | line interpolation (line/area only) |\n| `strokewidth` | `number` | `1.5` | line stroke width (line/area only) |\n| `fillopacity` | `number` | `0.2` | fill opacity (area only) |\n| `cornerradius` | `number` | `4` | rounded corners for stack segments in pixels. stack variant only — no effect on line/area/bar |\n| `padpixels` | `number` | `0` | gap between stack segments in pixels. stack variant only — no effect on line/area/bar |\n| `transition` | `transitionconfig` | — | enter animation (bar/stack only; line/area use raf interpolation) |\n| `onclick` | `(index, value) => void` | — | called on click with nearest data index. not fired for 0 or 1 point data |\n| `onhover` | `(index\\|null, value\\|null) => void` | — | called on mousemove; `null` on mouseleave. not fired for 0 or 1 point data |\n\n### `sparklinevariant`\n\n```ts\ntype sparklinevariant = 'area' | 'bar' | 'line' | 'stack';\n```\n\n **`line`** — polyline path (default)\n **`area`** — filled area + line overlay\n **`bar`** — vertical bar per data point\n **`stack`** — horizontal proportional segments; use `stacksegment[]` for `data` with per segment colors\n\n### `stacksegment`\n\n```ts\ninterface stacksegment {\n color?: string;\n label?: string;\n value: number;\n}\n```\n\n> **accessibility:** without `a11y` the svg is marked `aria hidden=\"true\"` (decorative). set `a11y: { arialabel: '…' }` to expose the chart to assistive technology — the svg will carry `role=\"img\"` and the provided label.\n\n \n\n## chart config types\n\n### `linechartconfig`\n\nextends [`basechartconfig`](#basechartconfig).\n\n```ts\ninterface linechartconfig extends basechartconfig {\n series: maybesignal<lineseriesconfig[]>;\n crosshair?: boolean | crosshairconfig;\n}\n```\n\n### `lineseriesconfig`\n\n```ts\ninterface lineseriesconfig extends series {\n curve?: 'linear' | 'monotone' | 'step'; // default: 'linear'\n strokewidth?: number; // default: 2\n showpoints?: boolean; // default: false\n pointradius?: number; // default: 3\n}\n```\n\n \n\n### `barchartconfig`\n\nextends [`basechartconfig`](#basechartconfig).\n\n```ts\ntype barvariant =\n | 'grouped' // vertical grouped (default)\n | 'stacked' // vertical stacked\n | 'grouped horizontal' // horizontal grouped\n | 'stacked horizontal'; // horizontal stacked\n\ninterface barchartconfig extends basechartconfig {\n series: maybesignal<barseriesconfig[]>;\n variant?: barvariant; // default: 'grouped'\n}\n```\n\n### `barseriesconfig`\n\n```ts\ninterface barseriesconfig extends series {\n borderradius?: number; // default: 0\n}\n```\n\n \n\n### `areachartconfig`\n\nextends [`basechartconfig`](#basechartconfig).\n\n```ts\ninterface areachartconfig extends basechartconfig {\n series: maybesignal<areaseriesconfig[]>;\n crosshair?: boolean | crosshairconfig;\n}\n```\n\n### `areaseriesconfig`\n\n```ts\ninterface areaseriesconfig extends series {\n curve?: 'linear' | 'monotone' | 'step'; // default: 'linear'\n fillopacity?: number; // default: 0.3\n showline?: boolean; // default: true\n}\n```\n\n \n\n## shared config types\n\n### `axisconfig`\n\n```ts\ninterface axisconfig {\n position?: axisposition; // defaults to 'bottom' for xaxis, 'left' for yaxis\n tickcount?: number;\n tickformat?: (value: date | number | string) => string;\n label?: string;\n grid?: boolean | gridconfig;\n}\n```\n\n### `gridconfig`\n\n```ts\ninterface gridconfig {\n color?: string;\n dash?: string; // svg stroke dasharray value, e.g. '4 2'\n}\n```\n\n### `tooltipconfig`\n\n```ts\ninterface tooltipconfig {\n offset?: number; // default: 8\n render?: (datum: datum, series: series) => string; // returns html string\n sanitize?: (html: string) => string; // applied before innerhtml injection\n}\n```\n\nthe tooltip is appended inside the chart container (not `document.body`), so it is automatically scoped and cleaned up on `dispose()`.\n\n> ⚠️ **security:** the string returned by `render` is injected via `innerhtml`. pass `sanitize` to apply a sanitizer (e.g. dompurify) before injection, or ensure all user supplied values are escaped before interpolation. a `warn` is emitted in development when `render` is set without `sanitize`.\n\n### `crosshairconfig`\n\n```ts\ninterface crosshairconfig {\n vertical?: boolean; // default: true\n horizontal?: boolean; // default: false\n snap?: boolean; // default: true\n}\n```\n\n### `legendconfig`\n\n```ts\ninterface legendconfig {\n position?: 'top' | 'bottom' | 'left' | 'right'; // default: 'bottom'\n}\n```\n\n### `transitionconfig`\n\n```ts\ninterface transitionconfig {\n duration?: number; // ms, default: 300\n easing?: 'linear' | 'ease in' | 'ease out' | 'ease in out' | ((t: number) => number);\n preference?: 'always' | 'never' | 'system'; // respects `prefers reduced motion` when `'system'`\n stagger?: number; // ms delay between bar enter animations, default: 0\n}\n```\n\n> **`stagger`** applies only to bar chart enter animations — new bars grow in sequence with a `stagger`ms delay between each one.\n\n### `chartmargin`\n\n```ts\ninterface chartmargin {\n top: number; // default: 20\n right: number; // default: 20\n bottom: number; // default: 40\n left: number; // default: 50\n}\n```\n\n### `chartdimensions`\n\n```ts\ninterface chartdimensions {\n height: number;\n margin: chartmargin;\n width: number;\n}\n```\n\n### `axisposition`\n\n```ts\ntype axisposition = 'bottom' | 'left' | 'right' | 'top';\n```\n\n### `legendposition`\n\n```ts\ntype legendposition = 'bottom' | 'left' | 'right' | 'top';\n```\n\n### `prismtheme`\n\n```ts\ninterface prismtheme {\n colors?: string[];\n fontfamily?: string;\n gridcolor?: string;\n gridopacity?: number;\n}\n```\n\n### `barvariant`\n\n```ts\ntype barvariant = 'grouped' | 'grouped horizontal' | 'stacked' | 'stacked horizontal';\n```\n\n \n\n## utilities\n\n### `seriescolor`\n\n```ts\nfunction seriescolor(index: number, override?: string): string;\n```\n\nreturns the css variable reference for palette color at `index` (wraps at 8). if `override` is provided it is returned as is. used internally by all chart factories.\n\n```ts\nimport { seriescolor } from '@vielzeug/prism';\n\nseriescolor(0); // 'var( prism color 1)'\nseriescolor(0, '#ff0'); // '#ff0'\n```\n\n### `settheme`\n\n```ts\ninterface prismtheme {\n colors?: string[]; // replaces prism color 1 … 8\n fontfamily?: string; // sets prism font family\n gridcolor?: string; // sets prism grid color\n gridopacity?: number; // sets prism grid opacity\n}\n\nfunction settheme(theme: prismtheme): void;\n```\n\napplies css custom properties to `document.documentelement`. call once at app startup before mounting charts. setting `colors` clears any unset color slots left over from a previous `settheme()` call, so a theme with fewer colors than the last one doesn't leave stale high index colors behind.\n\n```ts\nimport { settheme } from '@vielzeug/prism';\n\nsettheme({ colors: ['#6366f1', '#22d3ee', '#f59e0b', '#10b981'] });\n```\n\n### `resettheme`\n\n```ts\nfunction resettheme(): void;\n```\n\nclears every css custom property `settheme()` can set, restoring prism's default theme (from `@vielzeug/prism/theme`). useful for test teardown or a theme switcher's \"reset to default\" action.\n\n```ts\nimport { resettheme, settheme } from '@vielzeug/prism';\n\nsettheme({ colors: ['#6366f1'] });\nresettheme(); // back to the default palette\n```\n\n> `seriescolor`, `settheme`, and `resettheme` are all exported from `@vielzeug/prism` (not from the `/theme` css subpath).\n\n \n\n## interaction types\n\n> exported from `@vielzeug/prism` for use in plugins and custom chart extensions. both types reflect the live state object created internally; `el` is `null` when no legend/tooltip is configured.\n\n### `legendstate`\n\n```ts\ninterface legendstate {\n dispose(): void;\n [symbol.dispose](): void;\n el: htmldivelement | null;\n update(series: { color: string; name: string }[]): void;\n}\n```\n\nthe live legend object available on `ctx.legend` inside `chartplugin.install`. call `update()` to re render legend items, `dispose()` to remove the element.\n\n### `tooltipstate`\n\n```ts\ninterface tooltipstate {\n dispose(): void;\n [symbol.dispose](): void;\n el: htmldivelement | null;\n hide(): void;\n show(x: number, y: number, datum: datum, series: series): void;\n}\n```\n\nthe live tooltip object available on `ctx.tooltip` inside `chartplugin.install`. `x`/`y` are pixel coordinates relative to the chart area; `show()` positions and renders the tooltip.\n\n \n\n### `chartplugincontext`\n\n```ts\ninterface chartplugincontext {\n container: htmlelement;\n dimensions: readable<chartdimensions>;\n disposalsignal: abortsignal;\n svg: svgsvgelement;\n}\n```\n\npassed to `chartplugin.install(ctx)`. gives plugins access to the reactive `dimensions` signal, the host `container`, the root `svg` element, and a `disposalsignal` aborted when the chart is torn down.\n\n```ts\nimport type { chartplugin } from '@vielzeug/prism';\nimport { effect } from '@vielzeug/ripple';\n\nconst watermarkplugin: chartplugin = {\n dispose() {},\n install(ctx) {\n // react to size changes\n effect(() => {\n const { width, height } = ctx.dimensions.value;\n /* re layout watermark */\n });\n },\n};\n```\n\n> **note:** to observe future resize events use `effect(() => { ctx.dimensions.value; })` from `@vielzeug/ripple` within a reactive scope. to run cleanup when the chart is disposed without relying on your own `dispose()` implementation being called, add a listener to `ctx.disposalsignal` instead: `ctx.disposalsignal.addeventlistener('abort', cleanup)`.\n>\n> **error isolation:** if a plugin's `install()` or `dispose()` throws, the error is logged (dev builds only) and the rest of the chart — and any other installed plugins — continues to work. a throwing plugin never aborts chart creation or teardown.\n\n \n\n## animation utilities\n\n> exported from `@vielzeug/prism` for use in plugins and custom chart extensions.\n\n### `animate`\n\n```ts\nfunction animate(\n targets: animationtarget[],\n config?: transitionconfig,\n oncomplete?: () => void,\n signal?: abortsignal,\n): () => void;\n```\n\nanimates svg element attributes from `from` to `to` values over the given `transitionconfig` duration. calls `oncomplete` when all animations finish. returns a cancel function — call it to stop the in flight animation early (its `requestanimationframe` loop is cancelled and `oncomplete` is not called).\n\n **empty targets or `duration: 0`** — attributes are set immediately and `oncomplete` is called synchronously; no raf is scheduled. the returned cancel function is a no op in this case.\n **negative `stagger`** — clamped to `0`; all elements animate in parallel.\n **`signal`** — if provided and already aborted (or aborted mid animation), the raf loop stops rescheduling itself on its next frame, same effect as calling the returned cancel function.\n\n**parameters — `animationtarget`:**\n\n| field | type | description |\n| | | |\n| `el` | `svgelement` | target element |\n| `attrs` | `record<string, { from: number; to: number }>` | attribute name → start/end values |\n\n```ts\nimport { animate } from '@vielzeug/prism';\n\nconst cancel = animate([{ attrs: { opacity: { from: 0, to: 1 } }, el: rect }], { duration: 300, easing: 'ease out' });\n\n// stop early if the element is removed before the animation completes:\ncancel();\n```\n\n### `easingfn`\n\n```ts\ntype easingfn = (t: number) => number;\n```\n\na custom easing function. receives a normalised time value `t ∈ [0, 1]` and returns a progress value (also typically `[0, 1]`). pass as `transitionconfig.easing`. unknown or invalid easing name strings fall back to `'ease out'` rather than throwing.\n\n \n\n## devtools\n\n> **import:** `@vielzeug/prism/devtools`\n\nopt in debug logging, separate from the internal dev mode validation warnings in `_dev.ts` (those run automatically and need no import). tree shaken from production bundles when this sub path isn't imported — there is no environment gate to configure.\n\n### `debugchart`\n\n```ts\ninterface debugchartoptions {\n label?: string; // defaults to 'chart', producing log prefixes like [prism:chart]\n}\n\nfunction debugchart<t extends charthandle>(handle: t, options?: debugchartoptions): t;\n```\n\nwraps an already created `charthandle` with lifecycle logging to `console.debug`. logs the chart's mount, every resize (via its own `resizeobserver` on `handle.el`, independent of the chart's internal one), and disposal — each prefixed with `[prism:<label>]`. returns the same handle unchanged, so it can wrap any `create*chart()` call in place.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { debugchart } from '@vielzeug/prism/devtools';\n\nconst chart = debugchart(createlinechart(container, config), { label: 'revenue' });\n// [prism:revenue] mounted\n// [prism:revenue] resized 600×300\nchart.dispose();\n// [prism:revenue] disposed\n```\n\n \n\n## errors\n\n### `prismerror`\n\nbase class for all prism errors. use `instanceof prismerror` to catch any prism originated error.\n\n```ts\nclass prismerror extends error {}\n```\n\n**named subclasses**\n\n| class | thrown when |\n| | |\n| `prismrendererror` | a chart is given a structurally invalid configuration it cannot render at all (e.g. a non `element` `container`). recoverable issues like empty or malformed data emit a dev mode warning instead — they do not throw. |\n",
813
+ "usage": " \ntitle: prism — usage guide\ndescription: concepts, patterns, and best practices for @vielzeug/prism — reactive svg charts.\n \n\n[[toc]]\n\n## basic usage\n\nevery chart needs a container element with defined dimensions and the theme css:\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport '@vielzeug/prism/theme';\n\nconst container = document.queryselector<htmlelement>('#chart')!;\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 16 },\n ],\n },\n ],\n});\n\nchart.dispose();\n```\n\n```html\n<div id=\"chart\" style=\"width: 100%; height: 300px;\"></div>\n```\n\nprism observes the container size via `resizeobserver` and re renders automatically on resize. if the container has zero dimensions at mount time, a `warn` is emitted in development — ensure the container has layout before calling the chart factory.\n\n## reactivity with signals\n\nprism accepts both plain values and `@vielzeug/ripple` signals for any data property. when a signal changes, the chart re renders automatically in the next animation frame.\n\n### static data\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'static',\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 20 },\n ],\n },\n ],\n});\n```\n\n### reactive data\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([\n { key: 1, value: 10 },\n { key: 2, value: 20 },\n]);\n\nconst chart = createlinechart(container, {\n series: [{ name: 'live', data }],\n});\n\n// later — chart updates automatically\ndata.value = [...data.value, { key: 3, value: 30 }];\n```\n\n### the `maybesignal<t>` pattern\n\nall data bearing config fields use the `maybesignal<t>` type:\n\n```ts\ntype maybesignal<t> = readable<t> | t;\n```\n\npass a plain value when data is fixed, or a `@vielzeug/ripple` signal when it changes dynamically. the chart handles both identically.\n\n## line charts\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 1, value: 100 },\n { key: 2, value: 150 },\n { key: 3, value: 130 },\n ],\n color: '#3b82f6',\n curve: 'monotone', // 'linear' | 'monotone' | 'step'\n strokewidth: 2,\n showpoints: true,\n pointradius: 4,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n crosshair: true,\n});\n```\n\n### multiple series\n\n```ts\nconst chart = createlinechart(container, {\n series: [\n { name: 'revenue', data: revenuedata, color: '#3b82f6' },\n { name: 'expenses', data: expensedata, color: '#ef4444' },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n});\n```\n\n### time based x axis\n\nwhen data points use `date` objects for `key`, prism automatically applies a time scale:\n\n```ts\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'signups',\n data: [\n { key: new date('2024 01 01'), value: 50 },\n { key: new date('2024 02 01'), value: 80 },\n { key: new date('2024 03 01'), value: 120 },\n ],\n },\n ],\n xaxis: { position: 'bottom', tickformat: (d) => (d as date).tolocaledatestring() },\n yaxis: { position: 'left' },\n});\n```\n\n## bar charts\n\n```ts\nimport { createbarchart } from '@vielzeug/prism';\n\nconst chart = createbarchart(container, {\n series: [\n {\n name: 'sales',\n data: [\n { key: 'q1', value: 200 },\n { key: 'q2', value: 350 },\n { key: 'q3', value: 280 },\n { key: 'q4', value: 400 },\n ],\n borderradius: 4,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n});\n```\n\n### variants\n\nselect the bar layout with `variant`:\n\n| value | layout |\n| | |\n| `'grouped'` | vertical grouped (default) |\n| `'stacked'` | vertical stacked |\n| `'grouped horizontal'` | horizontal grouped |\n| `'stacked horizontal'` | horizontal stacked |\n\n> **note:** `tooltip` and `legend` are always available on the scaffold — omitting them uses a no op null object internally, so no conditional checks are needed in plugins or custom render logic.\n\n```ts\nconst chart = createbarchart(container, {\n variant: 'stacked',\n series: [\n { name: 'mobile', data: mobiledata, color: '#3b82f6', borderradius: 0 },\n { name: 'desktop', data: desktopdata, color: '#10b981', borderradius: 0 },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n legend: true,\n});\n```\n\nfor horizontal layouts, categories appear on the y axis and values on the x axis:\n\n```ts\nconst chart = createbarchart(container, {\n variant: 'grouped horizontal',\n series: [{ name: 'revenue', data, color: '#3b82f6' }],\n xaxis: { position: 'bottom', grid: true },\n yaxis: { position: 'left' },\n});\n```\n\n### grouped bars\n\nmultiple series with `variant: 'grouped'` (default) render side by side:\n\n```ts\nconst chart = createbarchart(container, {\n series: [\n { name: '2023', data: lastyeardata, color: '#94a3b8' },\n { name: '2024', data: thisyeardata, color: '#3b82f6' },\n ],\n});\n```\n\n## area charts\n\n```ts\nimport { createareachart } from '@vielzeug/prism';\n\nconst chart = createareachart(container, {\n series: [\n {\n name: 'users',\n data: userdata,\n curve: 'monotone',\n fillopacity: 0.2,\n showline: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n});\n```\n\n## pie, donut, and semi circle charts\n\nall three variants use `createpiechart` with the `variant` field:\n\n```ts\nimport { createpiechart } from '@vielzeug/prism';\n\nconst chart = createpiechart(container, {\n data: [\n { label: 'direct', value: 42, color: '#3b82f6' },\n { label: 'organic', value: 28, color: '#10b981' },\n { label: 'referral', value: 18, color: '#f59e0b' },\n { label: 'social', value: 12, color: '#8b5cf6' },\n ],\n variant: 'donut', // 'pie' | 'donut' | 'semi'\n tooltip: true,\n transition: { duration: 400, easing: 'ease out' },\n});\n```\n\n### variants\n\n| value | shape |\n| | |\n| `'pie'` | full circle, no hole |\n| `'donut'` | full circle with inner hole (~55% of outer by default) |\n| `'semi'` | top half semicircle with inner hole — useful for gauges |\n\n### inner radius\n\n`innerradius` overrides the automatic calculation:\n\n```ts\ncreatepiechart(container, {\n data,\n variant: 'donut',\n innerradius: 60, // explicit pixels\n});\n```\n\n### slice labels\n\nset `label` on each `piesliceconfig` to render text at the arc centroid:\n\n```ts\n{ value: 42, label: '42%' }\n```\n\nstyle labels via css:\n\n```css\n:root {\n prism pie label color: #fff;\n prism pie label size: 11px;\n}\n```\n\n### reactive data\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([\n { label: 'a', value: 40 },\n { label: 'b', value: 60 },\n]);\n\nconst chart = createpiechart(container, { data, variant: 'donut' });\n\ndata.value = [\n { label: 'a', value: 55 },\n { label: 'b', value: 45 },\n];\n```\n\n### event hooks\n\n```ts\ncreatepiechart(container, {\n data,\n onhover: (slice, index) => {\n // slice/index are null on mouseleave\n if (slice) console.log(slice.label, slice.value);\n },\n onclick: (slice, index) => {\n console.log('clicked', slice.label);\n },\n});\n```\n\n## sparklines\n\nsparklines are minimal inline charts with no axes, no legend, and no margin — designed to live inline with text or inside table cells.\n\n```ts\nimport { createsparkline } from '@vielzeug/prism';\n\nconst spark = createsparkline(container, {\n data: [12, 18, 14, 22, 19, 28],\n variant: 'line', // 'line' | 'area' | 'bar' (default: 'line')\n color: '#3b82f6',\n curve: 'monotone',\n strokewidth: 1.5,\n});\n\nspark.dispose();\n```\n\n### variants\n\n **`line`** — simple polyline path (default)\n **`area`** — filled area + line overlay\n **`bar`** — vertical bar for each data point\n **`stack`** — horizontal proportional segments; use `stacksegment[]` for `data` with per segment colors\n\n### reactive data\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([12, 18, 14, 22]);\n\nconst spark = createsparkline(container, { data, variant: 'area' });\n\ndata.value = [...data.value, 30]; // re renders automatically\n```\n\n### event hooks\n\nsparklines use simplified hooks — index based rather than full `chartevent`:\n\n```ts\nconst spark = createsparkline(container, {\n data: [10, 20, 30],\n onhover: (index, value) => {\n // index/value are null on mouseleave\n if (index !== null) console.log(`hovering point ${index}: ${value}`);\n },\n onclick: (index, value) => {\n console.log(`clicked point ${index}: ${value}`);\n },\n});\n```\n\n> **note:** sparkline svgs are marked `aria hidden=\"true\"` since they are decorative. provide meaningful surrounding text context for accessibility.\n\n## axes and grid\n\n```ts\n{\n xaxis: {\n position: 'bottom', // 'top' | 'bottom'\n tickcount: 5,\n tickformat: (v) => `$${v}`,\n label: 'month',\n grid: true, // or { color: '#ddd', dash: '4 2' }\n },\n yaxis: {\n position: 'left', // 'left' | 'right'\n grid: { color: '#f0f0f0' },\n label: 'revenue ($)',\n },\n}\n```\n\n## tooltips\n\nenable with `tooltip: true` for default rendering, or provide a custom `render` function returning an html string:\n\n```ts\n{\n tooltip: {\n offset: 12,\n render: (datum, series) => `\n <strong>${series.name}</strong><br/>\n value: ${datum.value.tolocalestring()}\n `,\n },\n}\n```\n\nthe `render` output is injected via `innerhtml`. if you interpolate user supplied data, pass a `sanitize` function to guard against xss:\n\n```ts\nimport dompurify from 'dompurify';\n\n{\n tooltip: {\n render: (datum, series) => `<b>${series.name}</b>: ${datum.value}`,\n sanitize: (html) => dompurify.sanitize(html),\n },\n}\n```\n\nthe tooltip element is scoped inside the chart container (not `document.body`) and is removed automatically on `dispose()`.\n\n## crosshair\n\na vertical guide that snaps to the nearest data point:\n\n```ts\n{\n crosshair: true,\n // or configure:\n crosshair: { vertical: true, horizontal: true, snap: true },\n}\n```\n\n## legend\n\nenable with `legend: true` (defaults to `bottom`) or configure position:\n\n```ts\n{\n legend: true,\n // or:\n legend: { position: 'top' }, // 'top' | 'bottom' | 'left' | 'right'\n}\n```\n\nthe legend renders as a `div` placed outside the svg. each item shows a color swatch and the series `name`. customize via css:\n\n```css\n:root {\n prism legend gap: 1rem;\n prism legend dot size: 0.5rem;\n prism legend font size: 0.75rem;\n}\n```\n\n## event hooks\n\nall charts expose `onclick` and `onhover` callbacks on the config:\n\n```ts\nconst chart = createlinechart(container, {\n series: [{ name: 'revenue', data }],\n onhover: (event) => {\n // event is chartevent | null (null on mouseleave)\n if (event) console.log(event.datum, event.series);\n },\n onclick: (event) => {\n console.log('clicked', event.datum);\n },\n});\n```\n\n`chartevent` provides:\n\n `datum` — the nearest `datum`\n `series` — the corresponding `series` config\n `originalevent` — the raw `mouseevent`\n\n> **pie chart events differ** — `onhover` and `onclick` receive `(slice: piesliceconfig, index: number)` instead of `chartevent`. see [`piechartconfig`](./api.md#piechartconfig) for details.\n\n## plugins\n\nextend any chart with custom behavior using the `chartplugin` interface. all chart types — including `createpiechart` — support `plugins`.\n\n```ts\nimport type { chartplugin } from '@vielzeug/prism';\n\nfunction createclicklogger(): chartplugin {\n const handler = (e: mouseevent) => console.log('chart clicked', e);\n // `dispose()` receives no arguments, so capture whatever `install()` needs\n // to clean up (here, the svg it attached the listener to) in this closure.\n let svg: svgsvgelement | undefined;\n\n return {\n install(ctx) {\n svg = ctx.svg;\n svg.addeventlistener('click', handler);\n },\n dispose() {\n svg?.removeeventlistener('click', handler);\n },\n };\n}\n\nconst chart = createlinechart(container, {\n series: [{ name: 'revenue', data }],\n plugins: [createclicklogger()],\n});\n\n// works for pie charts too:\nconst pie = createpiechart(container, {\n data,\n plugins: [createclicklogger()],\n});\n```\n\n> **alternative to `dispose()`:** `install(ctx)` can instead listen for `ctx.disposalsignal`'s `abort` event to run cleanup, without needing to capture anything for a separate `dispose()` implementation: `ctx.disposalsignal.addeventlistener('abort', () => svg.removeeventlistener('click', handler))`.\n>\n> **error isolation:** if a plugin's `install()` or `dispose()` throws, the error is logged in development and the rest of the chart — plus any other installed plugins — keeps working. a throwing plugin never aborts chart creation or teardown.\n\n## animations\n\npass a `transition` config to animate enter and update transitions:\n\n```ts\n{\n transition: {\n duration: 400,\n easing: 'ease out',\n stagger: 30, // bar charts only: ms delay between each bar's enter animation\n },\n}\n```\n\nall chart types use requestanimationframe based interpolation. bar charts additionally support `stagger` — a per bar delay that creates a cascade effect on first render.\n\n## theming\n\nimport the default theme:\n\n```ts\nimport '@vielzeug/prism/theme';\n```\n\n### programmatic theme with `settheme`\n\ncall `settheme` once at app startup to apply custom tokens programmatically:\n\n```ts\nimport { settheme } from '@vielzeug/prism';\n\nsettheme({\n colors: ['#6366f1', '#22d3ee', '#f59e0b', '#10b981'], // replaces prism color 1 through 4\n fontfamily: 'inter, system ui, sans serif', // sets prism font family\n gridcolor: '#e2e8f0', // sets prism grid color\n gridopacity: 0.6, // sets prism grid opacity\n});\n```\n\n`settheme` writes to `document.documentelement` style, so it takes precedence over css file defaults. call `resettheme()` to clear every custom property `settheme` can set and restore the default theme — useful for a theme switcher's \"reset\" action or test teardown:\n\n```ts\nimport { resettheme } from '@vielzeug/prism';\n\nresettheme();\n```\n\n### custom theme (css)\n\n```css\n:root {\n prism color 1: #6366f1;\n prism color 2: #22c55e;\n prism axis color: #71717a;\n prism grid color: #f4f4f5;\n prism text color: #18181b;\n prism tooltip bg: #27272a;\n prism font family: 'inter', system ui, sans serif;\n}\n```\n\n### scoped themes\n\napply tokens to a specific container:\n\n```css\n.dark dashboard {\n prism axis color: #64748b;\n prism grid color: #334155;\n prism text color: #e2e8f0;\n}\n```\n\n### available tokens\n\n| token | default | description |\n| | | |\n| ` prism color {1 8}` | tailwind palette | series color palette |\n| ` prism bg` | `transparent` | chart background |\n| ` prism axis color` | `#94a3b8` | axis lines and ticks |\n| ` prism grid color` | `#e2e8f0` | grid lines |\n| ` prism text color` | `#334155` | axis labels and text |\n| ` prism font family` | `system ui` | chart font |\n| ` prism font size` | `12px` | label font size |\n| ` prism tooltip bg` | `#1e293b` | tooltip background |\n| ` prism tooltip color` | `#f8fafc` | tooltip text |\n| ` prism tooltip radius` | `6px` | tooltip border radius |\n| ` prism crosshair color` | `#64748b` | crosshair line |\n| ` prism crosshair dash` | `4 2` | crosshair dash pattern |\n\n## scales (standalone)\n\nscales can be used independently for custom visualizations:\n\n```ts\nimport { linearscale, timescale, bandscale } from '@vielzeug/prism';\n\nconst y = linearscale({ domain: [0, 100], range: [300, 0] });\ny.map(50); // → 150\ny.invert(150); // → 50\ny.ticks(5); // → [0, 20, 40, 60, 80, 100]\n\nconst x = bandscale({ domain: ['a', 'b', 'c'], range: [0, 300] });\nx.map('b'); // → pixel left edge of band b\nx.bandwidth(); // → width of each band\n```\n\n## lifecycle and cleanup\n\nevery chart returns a `charthandle`. always call `dispose()` when removing a chart:\n\n```ts\nconst chart = createlinechart(container, config);\n\n// when done:\nchart.dispose();\n\n// or with tc39 explicit resource management:\n{\n using chart = createlinechart(container, config);\n // auto disposed at block end\n}\n```\n\ncalling `dispose()`:\n\n cancels all reactive signal effects\n disconnects the `resizeobserver`\n removes the svg element, tooltip, and legend from the dom\n calls `dispose()` on all plugins (a plugin that throws is logged and skipped — it never blocks the rest of teardown)\n is idempotent — safe to call multiple times\n\n> **reactivity is automatic** — charts re render whenever signal data changes. there is no manual `update()` call needed.\n\n## responsive behavior\n\ncharts resize automatically when the container dimensions change. prism uses `resizeobserver` internally — no manual `resize()` call is needed.\n\n## devtools\n\nimport `debugchart()` from the `/devtools` subpath to log a chart's mount, resize, and dispose events to `console.debug`. it's separate from prism's internal validation warnings (those run automatically in development, no import needed) and is tree shaken from production bundles when this subpath isn't imported.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { debugchart } from '@vielzeug/prism/devtools';\n\nconst chart = debugchart(createlinechart(container, config), { label: 'revenue' });\n// [prism:revenue] mounted\n// [prism:revenue] resized 600×300\nchart.dispose();\n// [prism:revenue] disposed\n```\n\n> `debugchart()` wraps and returns the same `charthandle` unchanged, so it drops into any `create*chart()` call without restructuring your code.\n\n## framework integration\n\nprism renders into a plain dom element. attach charts inside mount/unmount lifecycle hooks for any framework.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\nimport { createlinechart, type datum } from '@vielzeug/prism';\n\nfunction linechart({ data }: { data: datum[] }) {\n const containerref = useref<htmldivelement>(null);\n\n useeffect(() => {\n const chart = createlinechart(containerref.current!, {\n series: [{ data, name: 'series' }],\n });\n return () => chart.dispose();\n }, [data]);\n\n return <div ref={containerref} style={{ width: '100%', height: 300 }} />;\n}\n```\n\n```ts [vue 3]\nimport { onmounted, onunmounted, ref } from 'vue';\nimport { createlinechart, type charthandle, type datum } from '@vielzeug/prism';\n\nfunction uselinechart(data: datum[]) {\n const containerref = ref<htmlelement | null>(null);\n let chart: charthandle | null = null;\n\n onmounted(() => {\n chart = createlinechart(containerref.value!, { series: [{ data, name: 'series' }] });\n });\n\n onunmounted(() => chart?.dispose());\n return { containerref };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createlinechart, type datum } from '@vielzeug/prism';\n\n export let data: datum[] = [];\n let container: htmldivelement;\n\n onmount(() => {\n const chart = createlinechart(container, { series: [{ data, name: 'series' }] });\n return () => chart.dispose();\n });\n</script>\n\n<div bind:this={container} style=\"width:100%;height:300px\"></div>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with ripple\n\npass ripple signals as chart data properties. prism re renders automatically when a signal changes.\n\n```ts\nimport { signal } from '@vielzeug/ripple';\nimport { createlinechart } from '@vielzeug/prism';\n\nconst data = signal([\n { key: 1, value: 10 },\n { key: 2, value: 20 },\n]);\n\nconst chart = createlinechart(container, {\n series: [{ data, name: 'series' }], // signal passed directly\n});\n\n// updating the signal triggers an automatic re render:\ndata.value = [\n { key: 1, value: 15 },\n { key: 2, value: 25 },\n];\n```\n\n### with sourcerer\n\nbind chart data to a sourcerer remote source so charts update whenever the list refreshes.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\nimport { computed, signal } from '@vielzeug/ripple';\nimport { createbarchart } from '@vielzeug/prism';\n\nconst source = createpagesource({ load: ({ query, signal }) => api.stats.list(query, { signal }) });\nconst snapshot = signal(source.snapshot);\nsource.subscribe((next) => (snapshot.value = next));\n\nconst chartdata = computed(() => snapshot.value.data.map((item) => ({ key: item.label, value: item.count })));\n\nconst chart = createbarchart(container, {\n series: [{ data: chartdata, name: 'series' }],\n});\n```\n\n## accessibility\n\naccessibility is a hard requirement for every chart factory. each chart's root `<svg>` carries `role=\"img\"` and must have either an `aria label` or `aria hidden=\"true\"` — set via the `a11y` config field.\n\nlabel a chart that conveys meaningful data:\n\n```ts\ncreatelinechart(container, {\n a11y: { arialabel: 'revenue by month' },\n series: [...],\n});\n```\n\nmark a decorative chart (e.g. a sparkline next to a text label) to exclude it from the accessibility tree:\n\n```ts\ncreatesparkline(container, {\n a11y: { decorative: true },\n data: [...],\n});\n```\n\nwhen `a11y` is omitted, scaffolded charts (line/bar/area/pie) render with `role=\"img\"` but no `aria label`; sparklines default to `aria hidden=\"true\"`. always set `a11y: { arialabel: '…' }` on charts that users need to understand.\n\n## best practices\n\n ensure the container element has explicit dimensions before calling a chart factory — `resizeobserver` needs a non zero layout size to trigger the first render.\n call `chart.dispose()` in your framework's unmount/cleanup phase to cancel signal effects and remove dom nodes.\n prefer `signal()` from ripple for mutable data properties — charts re render automatically when signals change, with no manual `update()` call.\n set `a11y: { arialabel: '…' }` on every chart that conveys meaningful data — accessibility is a hard requirement, not an optional add on.\n wrap a chart with `debugchart()` from the `/devtools` subpath only in development code paths; it is tree shaken in production.\n for ssr, skip chart creation server side — prism depends on dom apis and `resizeobserver`. render charts only after hydration in a `onmounted`/`useeffect` callback.\n",
814
+ "examples": " \ntitle: prism — examples\ndescription: interactive code examples for @vielzeug/prism charts.\n \n\n[[toc]]\n\n## line chart\n\nbasic line chart with tooltip and crosshair:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex line\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createlinechart } = prism;\n createlinechart(document.getelementbyid('ex line'), {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 1, value: 120 },\n { key: 2, value: 180 },\n { key: 3, value: 150 },\n { key: 4, value: 220 },\n { key: 5, value: 195 },\n { key: 6, value: 280 },\n ],\n color: '#3b82f6',\n curve: 'monotone',\n strokewidth: 2,\n showpoints: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n crosshair: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## multi series line chart\n\nmultiple lines with different curves:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex multi line\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createlinechart } = prism;\n createlinechart(document.getelementbyid('ex multi line'), {\n series: [\n {\n name: 'product a',\n data: [\n { key: 1, value: 40 },\n { key: 2, value: 65 },\n { key: 3, value: 55 },\n { key: 4, value: 80 },\n { key: 5, value: 72 },\n ],\n color: '#3b82f6',\n curve: 'monotone',\n },\n {\n name: 'product b',\n data: [\n { key: 1, value: 20 },\n { key: 2, value: 35 },\n { key: 3, value: 60 },\n { key: 4, value: 45 },\n { key: 5, value: 90 },\n ],\n color: '#10b981',\n curve: 'monotone',\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## legend — line chart\n\nadd `legend: true` to label each series below the chart:\n\n<componentpreview vertical height=\"360px\">\n\n```html\n<div id=\"ex legend line\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createlinechart } = prism;\n createlinechart(document.getelementbyid('ex legend line'), {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 1, value: 120 },\n { key: 2, value: 180 },\n { key: 3, value: 150 },\n { key: 4, value: 220 },\n { key: 5, value: 195 },\n ],\n color: '#3b82f6',\n curve: 'monotone',\n },\n {\n name: 'expenses',\n data: [\n { key: 1, value: 80 },\n { key: 2, value: 95 },\n { key: 3, value: 110 },\n { key: 4, value: 130 },\n { key: 5, value: 125 },\n ],\n color: '#ef4444',\n curve: 'monotone',\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n crosshair: true,\n legend: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## bar chart\n\ngrouped bar chart comparing categories:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex bar\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex bar'), {\n series: [\n {\n name: '2023',\n data: [\n { key: 'q1', value: 120 },\n { key: 'q2', value: 180 },\n { key: 'q3', value: 150 },\n { key: 'q4', value: 210 },\n ],\n color: '#94a3b8',\n borderradius: 4,\n },\n {\n name: '2024',\n data: [\n { key: 'q1', value: 150 },\n { key: 'q2', value: 220 },\n { key: 'q3', value: 190 },\n { key: 'q4', value: 280 },\n ],\n color: '#3b82f6',\n borderradius: 4,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## stacked bar chart\n\nbar chart with `variant: 'stacked'` — series stack vertically per category:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex bar stacked\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex bar stacked'), {\n series: [\n {\n name: 'mobile',\n data: [\n { key: 'q1', value: 80 },\n { key: 'q2', value: 110 },\n { key: 'q3', value: 95 },\n { key: 'q4', value: 130 },\n ],\n color: '#3b82f6',\n borderradius: 0,\n },\n {\n name: 'desktop',\n data: [\n { key: 'q1', value: 60 },\n { key: 'q2', value: 90 },\n { key: 'q3', value: 75 },\n { key: 'q4', value: 100 },\n ],\n color: '#10b981',\n borderradius: 0,\n },\n {\n name: 'tablet',\n data: [\n { key: 'q1', value: 20 },\n { key: 'q2', value: 30 },\n { key: 'q3', value: 25 },\n { key: 'q4', value: 35 },\n ],\n color: '#f59e0b',\n borderradius: 0,\n },\n ],\n variant: 'stacked',\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n legend: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## horizontal bar chart\n\nbar chart with `variant: 'grouped horizontal'` — categories on the y axis, values on the x axis:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex bar horizontal\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex bar horizontal'), {\n variant: 'grouped horizontal',\n series: [\n {\n name: 'revenue',\n data: [\n { key: 'q1', value: 80 },\n { key: 'q2', value: 110 },\n { key: 'q3', value: 95 },\n { key: 'q4', value: 130 },\n ],\n color: '#3b82f6',\n },\n ],\n xaxis: { position: 'bottom', grid: true },\n yaxis: { position: 'left' },\n tooltip: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## horizontal stacked bar chart\n\nuse `variant: 'stacked horizontal'` — horizontal bars stacked per category:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex bar h stacked\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex bar h stacked'), {\n variant: 'stacked horizontal',\n series: [\n {\n name: 'mobile',\n data: [\n { key: 'q1', value: 80 },\n { key: 'q2', value: 110 },\n { key: 'q3', value: 95 },\n { key: 'q4', value: 130 },\n ],\n color: '#3b82f6',\n borderradius: 0,\n },\n {\n name: 'desktop',\n data: [\n { key: 'q1', value: 60 },\n { key: 'q2', value: 90 },\n { key: 'q3', value: 75 },\n { key: 'q4', value: 100 },\n ],\n color: '#10b981',\n borderradius: 0,\n },\n ],\n xaxis: { position: 'bottom', grid: true },\n yaxis: { position: 'left' },\n tooltip: true,\n legend: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## legend — bar chart\n\ngrouped bar chart with a legend positioned at the top:\n\n<componentpreview vertical height=\"360px\">\n\n```html\n<div id=\"ex legend bar\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex legend bar'), {\n series: [\n {\n name: '2023',\n data: [\n { key: 'q1', value: 120 },\n { key: 'q2', value: 180 },\n { key: 'q3', value: 150 },\n { key: 'q4', value: 210 },\n ],\n color: '#94a3b8',\n borderradius: 4,\n },\n {\n name: '2024',\n data: [\n { key: 'q1', value: 150 },\n { key: 'q2', value: 220 },\n { key: 'q3', value: 190 },\n { key: 'q4', value: 280 },\n ],\n color: '#3b82f6',\n borderradius: 4,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n legend: { position: 'top' },\n });\n</script>\n```\n\n</componentpreview>\n\n## area chart\n\nfilled area with monotone curve and low opacity:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex area\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createareachart } = prism;\n createareachart(document.getelementbyid('ex area'), {\n series: [\n {\n name: 'signups',\n data: [\n { key: 1, value: 500 },\n { key: 2, value: 650 },\n { key: 3, value: 800 },\n { key: 4, value: 720 },\n { key: 5, value: 900 },\n { key: 6, value: 1100 },\n ],\n color: '#8b5cf6',\n curve: 'monotone',\n fillopacity: 0.2,\n showline: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: { color: '#f1f5f9' } },\n crosshair: { vertical: true },\n });\n</script>\n```\n\n</componentpreview>\n\n## legend — area chart\n\nmulti series area chart with a bottom legend:\n\n<componentpreview vertical height=\"360px\">\n\n```html\n<div id=\"ex legend area\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createareachart } = prism;\n createareachart(document.getelementbyid('ex legend area'), {\n series: [\n {\n name: 'mobile',\n data: [\n { key: 1, value: 300 },\n { key: 2, value: 420 },\n { key: 3, value: 510 },\n { key: 4, value: 480 },\n { key: 5, value: 620 },\n { key: 6, value: 750 },\n ],\n color: '#8b5cf6',\n curve: 'monotone',\n fillopacity: 0.25,\n },\n {\n name: 'desktop',\n data: [\n { key: 1, value: 200 },\n { key: 2, value: 230 },\n { key: 3, value: 290 },\n { key: 4, value: 240 },\n { key: 5, value: 280 },\n { key: 6, value: 350 },\n ],\n color: '#06b6d4',\n curve: 'monotone',\n fillopacity: 0.25,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n legend: true,\n });\n</script>\n```\n\n</componentpreview>\n\n## step line chart\n\nline chart with step interpolation:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex step\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createlinechart } = prism;\n createlinechart(document.getelementbyid('ex step'), {\n series: [\n {\n name: 'status',\n data: [\n { key: 1, value: 0 },\n { key: 2, value: 1 },\n { key: 3, value: 1 },\n { key: 4, value: 0 },\n { key: 5, value: 1 },\n { key: 6, value: 0 },\n ],\n color: '#f59e0b',\n curve: 'step',\n strokewidth: 3,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left' },\n });\n</script>\n```\n\n</componentpreview>\n\n## reactive chart\n\nchart that updates when signal data changes:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div style=\"margin bottom:8px;\">\n <button id=\"ex reactive btn\" style=\"padding:4px 12px;border:1px solid #e2e8f0;border radius:4px;cursor:pointer;\">\n add data point\n </button>\n</div>\n<div id=\"ex reactive\" style=\"width:100%;height:250px;\"></div>\n<script>\n const { createlinechart } = prism;\n const { signal } = ripple;\n\n const data = signal([\n { key: 1, value: 20 },\n { key: 2, value: 35 },\n { key: 3, value: 28 },\n { key: 4, value: 45 },\n ]);\n\n createlinechart(document.getelementbyid('ex reactive'), {\n series: [{ name: 'live', data, color: '#10b981', curve: 'monotone', showpoints: true }],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n transition: { duration: 400, easing: 'ease out' },\n });\n\n document.getelementbyid('ex reactive btn').addeventlistener('click', function () {\n var prev = data.value;\n var nextx = prev.length + 1;\n var nexty = 20 + math.floor(math.random() * 40);\n data.value = prev.concat([{ key: nextx, value: nexty }]);\n });\n</script>\n```\n\n</componentpreview>\n\n## reactive bar chart\n\nbar chart that updates when signal data changes, with stagger animation on new bars:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div style=\"margin bottom:8px;\">\n <button id=\"ex reactive bar btn\" style=\"padding:4px 12px;border:1px solid #e2e8f0;border radius:4px;cursor:pointer;\">\n add category\n </button>\n</div>\n<div id=\"ex reactive bar\" style=\"width:100%;height:250px;\"></div>\n<script>\n const { createbarchart } = prism;\n const { signal } = ripple;\n\n const bardata = signal([\n { key: 'q1', value: 120 },\n { key: 'q2', value: 180 },\n { key: 'q3', value: 150 },\n { key: 'q4', value: 210 },\n ]);\n\n createbarchart(document.getelementbyid('ex reactive bar'), {\n series: [{ name: 'revenue', data: bardata, color: '#6366f1', borderradius: 4 }],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: true,\n transition: { duration: 400, easing: 'ease out', stagger: 40 },\n });\n\n var quarters = ['q5', 'q6', 'q7', 'q8'];\n var qidx = 0;\n document.getelementbyid('ex reactive bar btn').addeventlistener('click', function () {\n if (qidx >= quarters.length) return;\n var nexty = 150 + math.floor(math.random() * 120);\n bardata.value = bardata.value.concat([{ key: quarters[qidx++], value: nexty }]);\n });\n</script>\n```\n\n</componentpreview>\n\n## event hooks\n\nusing `onhover` and `onclick` to react to chart interactions:\n\n<componentpreview vertical height=\"360px\">\n\n```html\n<div id=\"ex events info\" style=\"margin bottom:8px;font size:13px;color:#64748b;min height:20px;\"></div>\n<div id=\"ex events\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createlinechart } = prism;\n\n const info = document.getelementbyid('ex events info');\n\n createlinechart(document.getelementbyid('ex events'), {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 1, value: 120 },\n { key: 2, value: 180 },\n { key: 3, value: 150 },\n { key: 4, value: 220 },\n { key: 5, value: 195 },\n { key: 6, value: 280 },\n ],\n color: '#3b82f6',\n curve: 'monotone',\n showpoints: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n onhover: function (event) {\n info.textcontent = event ? 'hovering key=' + event.datum.key + ' value=' + event.datum.value : '';\n },\n onclick: function (event) {\n info.textcontent = 'clicked key=' + event.datum.key + ' value=' + event.datum.value;\n },\n });\n</script>\n```\n\n</componentpreview>\n\n## pie chart\n\nbasic pie chart with labeled slices:\n\n<componentpreview vertical height=\"340px\">\n\n```html\n<div id=\"ex pie\" style=\"width:300px;height:300px;\"></div>\n<script>\n const { createpiechart } = prism;\n createpiechart(document.getelementbyid('ex pie'), {\n data: [\n { label: 'direct', value: 42, color: '#3b82f6' },\n { label: 'organic', value: 28, color: '#10b981' },\n { label: 'referral', value: 18, color: '#f59e0b' },\n { label: 'social', value: 12, color: '#8b5cf6' },\n ],\n variant: 'pie',\n transition: { duration: 600, easing: 'ease out' },\n });\n</script>\n```\n\n</componentpreview>\n\n## donut chart\n\ndonut chart with tooltip:\n\n<componentpreview vertical height=\"340px\">\n\n```html\n<div id=\"ex donut\" style=\"width:300px;height:300px;\"></div>\n<script>\n const { createpiechart } = prism;\n createpiechart(document.getelementbyid('ex donut'), {\n data: [\n { label: 'direct', value: 42, color: '#3b82f6' },\n { label: 'organic', value: 28, color: '#10b981' },\n { label: 'referral', value: 18, color: '#f59e0b' },\n { label: 'social', value: 12, color: '#8b5cf6' },\n ],\n variant: 'donut',\n tooltip: true,\n transition: { duration: 600, easing: 'ease out' },\n });\n</script>\n```\n\n</componentpreview>\n\n## semi circle donut\n\nsemicircle donut — useful for gauges and progress indicators:\n\n<componentpreview vertical height=\"220px\">\n\n```html\n<div id=\"ex semi\" style=\"width:300px;height:180px;\"></div>\n<script>\n const { createpiechart } = prism;\n createpiechart(document.getelementbyid('ex semi'), {\n data: [\n { label: 'used', value: 68, color: '#3b82f6' },\n { label: 'free', value: 32, color: '#e2e8f0' },\n ],\n variant: 'semi',\n transition: { duration: 800, easing: 'ease out' },\n });\n</script>\n```\n\n</componentpreview>\n\n## sparkline — line\n\nminimal inline sparkline inside a table cell or card:\n\n<componentpreview vertical height=\"80px\">\n\n```html\n<div id=\"ex spark line\" style=\"width:200px;height:40px;\"></div>\n<script>\n const { createsparkline } = prism;\n createsparkline(document.getelementbyid('ex spark line'), {\n data: [12, 18, 14, 22, 19, 28, 24, 32],\n variant: 'line',\n color: '#3b82f6',\n curve: 'monotone',\n strokewidth: 1.5,\n });\n</script>\n```\n\n</componentpreview>\n\n## sparkline — area\n\narea variant with fill:\n\n<componentpreview vertical height=\"80px\">\n\n```html\n<div id=\"ex spark area\" style=\"width:200px;height:40px;\"></div>\n<script>\n const { createsparkline } = prism;\n createsparkline(document.getelementbyid('ex spark area'), {\n data: [12, 18, 14, 22, 19, 28, 24, 32],\n variant: 'area',\n color: '#8b5cf6',\n curve: 'monotone',\n fillopacity: 0.25,\n });\n</script>\n```\n\n</componentpreview>\n\n## sparkline — bar\n\nbar variant — one rect per value:\n\n<componentpreview vertical height=\"80px\">\n\n```html\n<div id=\"ex spark bar\" style=\"width:200px;height:40px;\"></div>\n<script>\n const { createsparkline } = prism;\n createsparkline(document.getelementbyid('ex spark bar'), {\n data: [12, 18, 14, 22, 19, 28, 24, 32],\n variant: 'bar',\n color: '#10b981',\n transition: { duration: 400, easing: 'ease out', stagger: 30 },\n });\n</script>\n```\n\n</componentpreview>\n\n## sparkline — reactive\n\nsparkline that updates when signal data changes:\n\n<componentpreview vertical height=\"120px\">\n\n```html\n<div style=\"margin bottom:8px;\">\n <button id=\"ex spark btn\" style=\"padding:4px 12px;border:1px solid #e2e8f0;border radius:4px;cursor:pointer;\">\n add point\n </button>\n</div>\n<div id=\"ex spark reactive\" style=\"width:200px;height:40px;\"></div>\n<script>\n const { createsparkline } = prism;\n const { signal } = ripple;\n\n const sparkdata = signal([10, 15, 12, 18, 14]);\n\n createsparkline(document.getelementbyid('ex spark reactive'), {\n data: sparkdata,\n variant: 'area',\n color: '#f59e0b',\n curve: 'monotone',\n fillopacity: 0.2,\n transition: { duration: 300, easing: 'ease out' },\n });\n\n document.getelementbyid('ex spark btn').addeventlistener('click', function () {\n sparkdata.value = sparkdata.value.concat([10 + math.floor(math.random() * 25)]);\n });\n</script>\n```\n\n</componentpreview>\n\n## sparkline — stack\n\nhorizontal stacked bar — proportional segments with per segment colors:\n\n<componentpreview vertical height=\"80px\">\n\n```html\n<div id=\"ex spark stack\" style=\"width:200px;height:40px;\"></div>\n<script>\n const { createsparkline } = prism;\n createsparkline(document.getelementbyid('ex spark stack'), {\n variant: 'stack',\n data: [\n { label: 'chrome', value: 341, color: '#3b82f6' },\n { label: 'safari', value: 217, color: '#06b6d4' },\n { label: 'firefox', value: 124, color: '#10b981' },\n { label: 'edge', value: 53, color: '#f59e0b' },\n ],\n cornerradius: 4,\n padpixels: 4,\n });\n</script>\n```\n\n</componentpreview>\n\n## custom tooltip\n\nrich html tooltip with custom formatting:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex tooltip\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createbarchart } = prism;\n createbarchart(document.getelementbyid('ex tooltip'), {\n series: [\n {\n name: 'revenue',\n data: [\n { key: 'jan', value: 4200 },\n { key: 'feb', value: 5100 },\n { key: 'mar', value: 4800 },\n { key: 'apr', value: 6300 },\n { key: 'may', value: 5900 },\n { key: 'jun', value: 7200 },\n ],\n color: '#6366f1',\n borderradius: 6,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n tooltip: {\n render: function (datum, series) {\n return (\n '<div style=\"font weight:600\">' +\n series.name +\n '</div>' +\n '<div style=\"opacity:0.7;font size:11px\">' +\n datum.key +\n '</div>' +\n '<div style=\"font size:14px;margin top:2px\">$' +\n datum.value.tolocalestring() +\n '</div>'\n );\n },\n },\n });\n</script>\n```\n\n</componentpreview>\n\n## pie chart with plugin\n\na donut chart that installs a custom plugin to draw a total count label in the center hole:\n\n<componentpreview vertical height=\"320px\">\n\n```html\n<div id=\"ex pie plugin\" style=\"width:100%;height:280px;\"></div>\n<script>\n const { createpiechart } = prism;\n\n const data = [\n { label: 'direct', value: 42, color: '#6366f1' },\n { label: 'organic', value: 28, color: '#10b981' },\n { label: 'social', value: 18, color: '#f59e0b' },\n { label: 'referral', value: 12, color: '#8b5cf6' },\n ];\n\n const total = data.reduce((s, d) => s + d.value, 0);\n let centerlabel;\n\n const centerplugin = {\n install(ctx) {\n const ns = 'http://www.w3.org/2000/svg';\n centerlabel = document.createelementns(ns, 'text');\n centerlabel.setattribute('text anchor', 'middle');\n centerlabel.setattribute('dominant baseline', 'middle');\n centerlabel.setattribute('font size', '20');\n centerlabel.setattribute('font weight', '600');\n centerlabel.setattribute('fill', 'var( prism text color, #334155)');\n centerlabel.textcontent = total;\n ctx.svg.appendchild(centerlabel);\n // position at svg center once dimensions are available\n requestanimationframe(() => {\n const { width, height } = ctx.dimensions.value;\n if (width && height) {\n centerlabel.setattribute('x', string(width / 2));\n centerlabel.setattribute('y', string(height / 2));\n }\n });\n },\n dispose() {\n centerlabel?.remove();\n },\n };\n\n createpiechart(document.getelementbyid('ex pie plugin'), {\n data,\n variant: 'donut',\n tooltip: true,\n transition: { duration: 400, easing: 'ease out' },\n plugins: [centerplugin],\n });\n</script>\n```\n\n</componentpreview>\n"
815
+ },
816
+ "examples": [],
817
+ "exports": "createlinechart createbarchart createareachart createpiechart createsparkline linearscale timescale bandscale seriescolor settheme resettheme animate prismerror charta11y animationtarget easingfn legendstate tooltipstate chartplugincontext point scaffoldcontext scaffoldgroups charteventhandlers stacksegment",
818
+ "keywords": "chart svg visualization reactive line chart bar chart area chart signals typescript",
819
+ "name": "@vielzeug/prism",
820
+ "related": "ripple refine orbit",
821
+ "slug": "prism",
822
+ "source": "// public api — all exports for @vielzeug/prism\n\nexport type { easingfn } from './animation/easing';\nexport type { animationtarget } from './animation/transition';\n// animation utilities (for plugin authors)\nexport { animate } from './animation/transition';\n// chart factories\nexport { createareachart } from './charts/area';\nexport { createbarchart } from './charts/bar';\nexport { createlinechart } from './charts/line';\nexport { createpiechart } from './charts/pie';\nexport { createsparkline } from './charts/sparkline';\n// error classes\nexport { prismerror, prismrendererror } from './errors';\n// interaction types (useful for plugin authors)\nexport type { legendstate } from './interaction/legend';\nexport type { tooltipstate } from './interaction/tooltip';\n// scale factories\nexport { bandscale } from './scales/band';\nexport { linearscale } from './scales/linear';\nexport { timescale } from './scales/time';\n// svg primitives (for plugin authors)\nexport type { point } from './svg/path';\n// theme utilities\nexport { resettheme, seriescolor, settheme } from './theme';\nexport type {\n areachartconfig,\n areaseriesconfig,\n axisconfig,\n axisposition,\n bandscale,\n barchartconfig,\n barseriesconfig,\n barvariant,\n basechartconfig,\n charta11y,\n chartdimensions,\n chartevent,\n charthandle,\n chartmargin,\n chartplugin,\n chartplugincontext,\n crosshairconfig,\n datum,\n gridconfig,\n legendconfig,\n legendposition,\n linechartconfig,\n lineseriesconfig,\n maybesignal,\n piechartconfig,\n piesliceconfig,\n pievariant,\n prismtheme,\n scale,\n series,\n sparklineconfig,\n sparklinevariant,\n stacksegment,\n tooltipconfig,\n transitionconfig,\n} from './types';\n"
823
+ },
824
+ {
825
+ "category": "websockets",
826
+ "description": "explicitly connected, typed websocket sessions with scoped channels, ref counted rooms with reactive presence, reconnect restoration, and heartbeat.",
827
+ "docs": {
828
+ "index": " \ntitle: pulse — typed websocket sessions\ndescription: explicitly connected, typed websocket sessions with scoped channels, ref counted rooms with reactive presence, reconnect restoration, and heartbeat.\npackage: pulse\ncategory: websockets\nkeywords: [websocket, realtime, channels, presence, rooms, reconnect, heartbeat, typed messaging, ripple]\nrelated: [herald, ripple, courier, clockwork]\nexports:\n [\n createpulse,\n pulse,\n pulsechannel,\n roomscope,\n roomscopebase,\n presenceroomscope,\n pulseoptions,\n pulseschema,\n channeldefinition,\n channeldefinitions,\n roomdefinition,\n roomdefinitions,\n roomoptions,\n outgoingmessage,\n outgoingtransform,\n pulseerror,\n pulseconnectionerror,\n pulsetimeouterror,\n pulseroomtimeouterror,\n pulseaborterror,\n pulsedisposederror,\n pulseprotocolerror,\n ]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"pulse\" />\n\n## why pulse?\n\nnative websocket leaves connection ownership, event routing, reconnect restoration, and cleanup to each application. pulse provides those boundaries while making readiness explicit: applications connect before sending, and disconnected messages never disappear silently.\n\n```ts\n// before\nconst socket = new websocket('wss://api.example.com/ws');\nsocket.addeventlistener('message', (event) => route(json.parse(event.data)));\nsocket.addeventlistener('close', () => settimeout(() => reconnect(), 1_000));\n\n// after\nconst pulse = createpulse<{ server: { 'chat:message': { text: string } }; client: { 'chat:send': { text: string } } }>(\n 'wss://api.example.com/ws',\n { reconnect: true },\n);\ntry {\n await pulse.connect();\n pulse.on('chat:message', (message) => console.log(message.text));\n pulse.send('chat:send', { text: 'hello!' });\n} catch (error) {\n console.error('pulse connection failed:', error);\n}\n```\n\n| feature | pulse | native websocket | socket.io client |\n| | | | |\n| bundle size | <packageinfo package=\"pulse\" type=\"size\" /> | 0 b | ~44 kb gzip |\n| explicit readiness | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| session restoration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | protocol specific |\n| typed scoped channels | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | basic |\n| typed rooms with presence | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero runtime dependencies | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> ripple | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use pulse when** you need a typed websocket session whose reconnect and cleanup behavior must be deterministic.\n\n**consider native websocket when** a single untyped connection does not need retry, routing, or session restoration.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/pulse @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/pulse @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ndefine the protocol schema at construction time, create scopes, then connect before sending.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n server: { 'chat:message': { text: string } };\n client: { 'chat:send': { text: string } };\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n };\n rooms: {\n lobby: { presence: { name: string } };\n };\n};\n\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: true,\n onerror: (error) => console.error(error),\n});\nconst chat = pulse.channel('chat');\nconst lobby = pulse.room('lobby');\n\ntry {\n await pulse.connect();\n chat.send('send', { text: 'hello!' });\n await lobby.joined;\n lobby.updatepresence({ name: 'ada' });\n} catch (error) {\n console.error('pulse connection failed:', error);\n}\n\npulse.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`connect()`** — explicit readiness; application messages throw while disconnected.\n **`channel()`** — named, schema bound scopes with independent disposal and reference counted server subscriptions.\n **`room()`** — named, schema bound ref counted room scopes with optional reactive presence. the first scope sends `join`; the last disposal sends `leave`.\n **`reconnect`** — ordered restoration of channel subscriptions, room memberships, and local presence state.\n **`transform`** — one synchronous transform or filter for application messages.\n **`onerror`** — typed connection and protocol errors.\n **`heartbeat`** — ping/pong liveness detection that uses the same reconnect controller.\n **`status` and `rooms`** — ripple readables for transport and confirmed membership state.\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/) — provides the reactive values exposed by pulse.\n [herald](/herald/) — receives routed pulse events in an in process application bus.\n [courier](/courier/) — handles request/response traffic alongside a pulse session.\n [clockwork](/clockwork/) — models application level authentication or session workflows.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
829
+ "api": " \ntitle: api — pulse\ndescription: complete api reference for pulse, including schema types, options, scopes, and error classes.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createpulse()` | create a typed websocket session instance. | sync (returns `pulse`) | does not open the connection — call `connect()`. |\n| `pulse` | main instance: channels, rooms, messaging, lifecycle. | sync methods, async `connect()`/`wait()` | `send()` throws while disconnected. |\n| `pulsechannel` | scoped channel namespace with independent disposal. | sync methods, async `wait()` | each call returns a new scope; ref counted subscription. |\n| `roomscope` | ref counted room membership with optional presence. | sync methods, async `joined` | `joined` rejects on transport close or timeout. |\n| `pulseschema` | declares server/client events, channels, and rooms. | type only | infer all named scope types from this schema. |\n| `pulseoptions` | configuration: heartbeat, reconnect, transform, onerror. | type only | `reconnect` and `heartbeat` default to `false`. |\n| `pulseerror` | base class for all pulse errors. | runtime | check `instanceof` against subclasses. |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/pulse` | all public exports: `createpulse`, types, and error classes. |\n\n## `createpulse()`\n\n```ts\nfunction createpulse<s extends pulseschema = pulseschema>(url: string, options?: pulseoptions): pulse<s>\n```\n\ncreates a pulse instance. the websocket is not opened until `connect()` is called.\n\n### type parameters\n\n| parameter | constraint | description |\n| | | |\n| `s` | `pulseschema` | schema declaring server events, client events, channels, and rooms. |\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `url` | `string` | websocket url. |\n| `options` | `pulseoptions` | optional configuration. |\n\n### returns\n\n`pulse<s>` — the pulse instance.\n\n \n\n## `pulseschema`\n\n```ts\ntype pulseschema = {\n server?: messagemap;\n client?: messagemap;\n channels?: channeldefinitions;\n rooms?: roomdefinitions;\n};\n```\n\ndeclare all protocol surfaces once at construction. named scopes infer their types from this schema.\n\n| field | type | description |\n| | | |\n| `server` | `messagemap` | root events the server sends. |\n| `client` | `messagemap` | root events the client sends. |\n| `channels` | `channeldefinitions` | named channel schemas. |\n| `rooms` | `roomdefinitions` | named room schemas with optional presence. |\n\n \n\n## `pulseoptions`\n\n```ts\ntype pulseoptions = {\n heartbeat?: boolean | heartbeatoptions;\n onerror?: (error: pulseerror) => void;\n protocols?: string | string[];\n reconnect?: boolean | reconnectoptions;\n transform?: outgoingtransform;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `heartbeat` | `boolean \\| heartbeatoptions` | `false` | ping/pong keep alive. |\n| `onerror` | `(error: pulseerror) => void` | — | receives typed transport and protocol errors. |\n| `protocols` | `string \\| string[]` | — | sub protocols passed to the websocket constructor. |\n| `reconnect` | `boolean \\| reconnectoptions` | `false` | auto reconnect on unexpected close. |\n| `transform` | `outgoingtransform` | — | transform or filter outgoing application messages. |\n\n \n\n## `heartbeatoptions`\n\n```ts\ntype heartbeatoptions = {\n interval?: number;\n timeout?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `interval` | `number` | `30_000` | interval between pings in ms. |\n| `timeout` | `number` | `5_000` | how long to wait for a pong before treating the connection as dead. |\n\n \n\n## `reconnectoptions`\n\n```ts\ntype reconnectoptions = {\n delay?: number | ((attempt: number) => number);\n maxattempts?: number;\n};\n```\n\n| option | type | default | description |\n| | | | |\n| `delay` | `number \\| ((attempt: number) => number)` | full jitter exponential backoff capped at 30 s | delay between reconnect attempts in ms. `attempt` is zero based. |\n| `maxattempts` | `number` | `5` | maximum number of reconnect attempts after initial failure. |\n\n \n\n## `outgoingmessage`\n\n```ts\ntype outgoingmessage = { channel?: string; event: string; payload: unknown };\n```\n\nan outgoing application message before it is serialized.\n\n \n\n## `outgoingtransform`\n\n```ts\ntype outgoingtransform = (message: readonly<outgoingmessage>) => outgoingmessage | null;\n```\n\ntransform or filter outgoing application messages. internal protocol frames (subscribe, join, leave, presence, ping) bypass this hook. return `null` to drop the message.\n\n \n\n## `pulse`\n\n```ts\ntype pulse<s extends pulseschema = pulseschema> = {\n // channels\n channel<k extends keyof channelmap<s> & string>(\n name: k,\n ): pulsechannel<channelmap<s>[k]['server'], channelmap<s>[k]['client']>;\n\n // connection\n connect(): promise<void>;\n disconnect(code?: number, reason?: string): void;\n\n // lifecycle\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n\n // messaging\n on<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n once<k extends eventkey<serverevents<s>>>(event: k, handler: (payload: serverevents<s>[k]) => void): unsubscribe;\n send<k extends eventkey<clientevents<s>>>(event: k, payload: clientevents<s>[k]): void;\n wait<k extends eventkey<serverevents<s>>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<serverevents<s>[k]>;\n\n // rooms\n room<k extends keyof roommap<s> & string>(name: k, opts?: roomoptions): roomscope<roommap<s>[k]>;\n readonly rooms: readable<readonlyset<string>>;\n\n // status\n readonly status: readable<pulsestatus>;\n\n [symbol.dispose](): void;\n};\n```\n\n### `channel(name)`\n\ncreates an isolated message namespace over the shared connection. each call returns an independently disposable scope. the server subscription is reference counted.\n\n### `connect()`\n\nexplicitly opens the connection. resolves after session restoration completes. rejects if the connection closes before opening.\n\n### `disconnect(code?, reason?)`\n\ncloses the connection without triggering reconnection. default code is `1000`.\n\n### `dispose()`\n\npermanently closes the connection and releases all resources. idempotent.\n\n### `on(event, handler)`\n\nsubscribes to a typed server event. returns an unsubscribe function.\n\n### `once(event, handler)`\n\nsubscribes once — auto removes after first invocation.\n\n### `send(event, payload)`\n\nsends a typed event to the server. throws `pulseconnectionerror` unless the connection is open.\n\n### `wait(event, opts?)`\n\nresolves on the next emission of the given server event. rejects when `opts.signal` aborts, the timeout elapses, or the instance is disposed.\n\n### `room(name, opts?)`\n\ncreates a ref counted room scope. the first scope sends `join`; the last disposal sends `leave`. when the room definition includes `presence`, the scope exposes reactive presence state.\n\n### `rooms`\n\nreactive set of rooms the client is currently a confirmed member of.\n\n### `status`\n\nreactive connection status: `'connecting' | 'open' | 'reconnecting' | 'closed'`.\n\n \n\n## `pulsechannel`\n\n```ts\ntype pulsechannel<tserver extends messagemap = messagemap, tclient extends messagemap = messagemap> = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n dispose(): void;\n on<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n once<k extends eventkey<tserver>>(event: k, handler: (payload: tserver[k]) => void): unsubscribe;\n send<k extends eventkey<tclient>>(event: k, payload: tclient[k]): void;\n wait<k extends eventkey<tserver>>(event: k, opts?: { signal?: abortsignal; timeout?: number }): promise<tserver[k]>;\n [symbol.dispose](): void;\n};\n```\n\n \n\n## `roomscope`\n\n```ts\ntype roomscope<r extends roomdefinition = roomdefinition> = r extends { presence: infer p }\n ? p extends undefined\n ? roomscopebase\n : presenceroomscope<p>\n : roomscopebase;\n```\n\na room scope. when the room definition includes `presence`, the scope is a `presenceroomscope`; otherwise it is a `roomscopebase`.\n\n### `roomscopebase`\n\n```ts\ntype roomscopebase = {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly name: string;\n readonly joined: promise<void>;\n dispose(): void;\n [symbol.dispose](): void;\n};\n```\n\n### `presenceroomscope`\n\n```ts\ntype presenceroomscope<t = unknown> = roomscopebase & {\n readonly presence: readable<readonlymap<string, t>>;\n updatepresence(state: t): void;\n onjoin(handler: (memberid: string, state: t) => void): unsubscribe;\n onleave(handler: (memberid: string) => void): unsubscribe;\n};\n```\n\n| member | type | description |\n| | | |\n| `presence` | `readable<readonlymap<string, t>>` | reactive map of `memberid → state`. |\n| `updatepresence(state)` | `(state: t) => void` | broadcast this client's presence state. throws `pulseconnectionerror` unless open. |\n| `onjoin(handler)` | `(handler) => unsubscribe` | called whenever a new member joins with their initial state. |\n| `onleave(handler)` | `(handler) => unsubscribe` | called whenever a member leaves. |\n\n### `roomoptions`\n\n```ts\ntype roomoptions = {\n signal?: abortsignal;\n timeout?: number;\n};\n```\n\n| option | type | description |\n| | | |\n| `signal` | `abortsignal` | aborts the join, rejecting `joined` with `pulseaborterror`. |\n| `timeout` | `number` | join timeout in ms. rejects `joined` with `pulseroomtimeouterror`. |\n\n \n\n## errors\n\nall errors extend `pulseerror`.\n\n### `pulseerror`\n\nbase class for all pulse errors.\n\n### `pulseconnectionerror`\n\ntransport failure, send while disconnected, or room join rejected on close.\n\n### `pulseprotocolerror`\n\nmalformed frame or server error frame.\n\n### `pulsetimeouterror`\n\n`wait()` timed out before the server event arrived.\n\n### `pulseroomtimeouterror`\n\nroom scope `joined` timed out before the server confirmed membership.\n\n### `pulseaborterror`\n\n`wait()` or room `joined` aborted via abortsignal.\n\n### `pulsedisposederror`\n\noperation attempted after disposal.\n\n \n\n## channel and room definitions\n\n### `channeldefinition`\n\n```ts\ntype channeldefinition = { client: messagemap; server: messagemap };\n```\n\n### `channeldefinitions`\n\n```ts\ntype channeldefinitions = record<string, channeldefinition>;\n```\n\n### `roomdefinition`\n\n```ts\ntype roomdefinition = { presence?: unknown };\n```\n\n### `roomdefinitions`\n\n```ts\ntype roomdefinitions = record<string, roomdefinition>;\n```\n\n \n\n## utility types\n\n### `messagemap`\n\n```ts\ntype messagemap = record<string, unknown>;\n```\n\n### `eventkey`\n\n```ts\ntype eventkey<t extends messagemap> = keyof t & string;\n```\n\n### `serverevents`\n\n```ts\ntype serverevents<s extends pulseschema> = s extends { server: infer m extends messagemap } ? m : messagemap;\n```\n\nextract server events from a schema, defaulting to an empty map.\n\n### `clientevents`\n\n```ts\ntype clientevents<s extends pulseschema> = s extends { client: infer m extends messagemap } ? m : messagemap;\n```\n\nextract client events from a schema, defaulting to an empty map.\n\n### `roommap`\n\n```ts\ntype roommap<s extends pulseschema> = s extends { rooms: infer r extends roomdefinitions } ? r : roomdefinitions;\n```\n\nextract room definitions from a schema, defaulting to an empty map.\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n### `pulsestatus`\n\n```ts\ntype pulsestatus = 'connecting' | 'open' | 'reconnecting' | 'closed';\n```\n",
830
+ "usage": " \ntitle: usage — pulse\ndescription: practical guide for connecting, sending, subscribing, joining rooms, and managing lifecycle with pulse.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n[[toc]]\n\n## basic usage\n\ndeclare server events, client events, channel schemas, and room schemas once at construction. named scopes infer their types from this schema.\n\n```ts\nimport { createpulse } from '@vielzeug/pulse';\n\ntype schema = {\n // root events the server sends\n server: { 'chat:message': { text: string }; notice: string };\n // root events the client sends\n client: { 'chat:send': { text: string } };\n // named channel scopes\n channels: {\n chat: {\n client: { send: { text: string } };\n server: { message: { text: string } };\n };\n alerts: {\n client: { subscribe: { topic: string } };\n server: { alert: { topic: string; severity: 'info' | 'warn' | 'error' } };\n };\n };\n // named room scopes with optional presence state\n rooms: {\n lobby: { presence: { name: string; color: string } };\n announcements: {};\n };\n};\n```\n\n## create and connect\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 5 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n onerror: (error) => console.error(error),\n});\n\ntry {\n await pulse.connect();\n} catch (error) {\n console.error('connection failed:', error);\n}\n```\n\n`connect()` opens the websocket and resolves after session restoration completes. `send()` throws `pulseconnectionerror` while disconnected — pulse never silently drops or buffers application messages.\n\n## send and receive root events\n\n```ts\npulse.on('chat:message', (message) => console.log(message.text));\npulse.send('chat:send', { text: 'hello!' });\n```\n\n## channels\n\neach `channel()` call returns an independently disposable scope. the server subscription is reference counted: the first scope sends `subscribe`, the last disposal sends `unsubscribe`.\n\n```ts\nconst chat = pulse.channel('chat');\n\nchat.on('message', (message) => console.log(message.text));\nchat.send('send', { text: 'hello!' });\n\n// later\nchat.dispose();\n```\n\nuse `using` for automatic cleanup:\n\n```ts\n{\n using chat = pulse.channel('chat');\n chat.on('message', (message) => console.log(message.text));\n} // chat.dispose() called automatically\n```\n\n## rooms and presence\n\neach `room()` call returns a ref counted room scope. the first scope sends `join`; the last disposal sends `leave`. when the room definition includes `presence`, the scope exposes reactive presence state.\n\n```ts\nconst lobby = pulse.room('lobby');\n\n// joined resolves when the server confirms membership\nawait lobby.joined;\n\n// reactive presence map: memberid → state\nlobby.onjoin((memberid, state) => console.log(`${memberid} joined: ${state.name}`));\nlobby.onleave((memberid) => console.log(`${memberid} left`));\n\n// broadcast your presence\nlobby.updatepresence({ name: 'ada', color: 'blue' });\n\n// read current presence\nfor (const [memberid, state] of lobby.presence.value) {\n console.log(`${memberid}: ${state.name}`);\n}\n\n// leave\nlobby.dispose();\n```\n\nplain rooms (without presence) work the same way but don't expose presence members:\n\n```ts\nconst announcements = pulse.room('announcements');\nawait announcements.joined;\nannouncements.dispose();\n```\n\n### room scope options\n\n```ts\n// timeout if the server doesn't confirm in time\nconst lobby = pulse.room('lobby', { timeout: 5_000 });\ntry {\n await lobby.joined;\n} catch (error) {\n console.error('join failed:', error);\n}\n\n// abort via abortsignal\nconst ctrl = new abortcontroller();\nconst lobby = pulse.room('lobby', { signal: ctrl.signal });\nctrl.abort(); // joined rejects with pulseaborterror, scope auto disposes\n```\n\n### reactive rooms set\n\n`pulse.rooms` is a ripple readable that tracks confirmed room memberships:\n\n```ts\nimport { effect } from '@vielzeug/ripple';\n\neffect(() => {\n console.log('joined rooms:', [...pulse.rooms.value]);\n});\n```\n\n## reconnect\n\nwhen the connection drops unexpectedly, pulse reconnects using the configured strategy. on reconnect, it restores:\n\n1. channel subscriptions (sends `subscribe` for each active channel).\n2. room memberships (sends `join` for each active room scope).\n3. local presence state (sends `presence` with the last successfully published state).\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n reconnect: {\n delay: (attempt) => math.min(1_000 * 2 ** attempt, 30_000),\n maxattempts: 5,\n },\n});\n```\n\n`joined` rejects on transport close. for post reconnect membership, read `pulse.rooms` instead.\n\n## heartbeat\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n heartbeat: { interval: 30_000, timeout: 5_000 },\n});\n```\n\npulse sends periodic pings. if a pong doesn't arrive before the timeout, it forces a reconnect using the same reconnect controller.\n\n## transform outgoing messages\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n transform: (message) => {\n // add a timestamp to all messages\n return { ...message, payload: { ...message.payload, ts: date.now() } };\n },\n});\n```\n\nreturn `null` to drop a message:\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n transform: (message) => (message.event === 'debug' ? null : message),\n});\n```\n\n## wait for a specific event\n\n```ts\nconst notice = await pulse.wait('notice', { timeout: 10_000 });\nconsole.log(notice);\n```\n\n## dispose\n\n```ts\npulse.dispose();\n```\n\ndisposal is idempotent. it closes the connection, rejects pending room joins, clears all listeners, and aborts all scope disposal signals.\n\n## error handling\n\n```ts\nconst pulse = createpulse<schema>('wss://api.example.com/ws', {\n onerror: (error) => {\n if (error instanceof pulseconnectionerror) {\n console.error('connection error:', error);\n } else if (error instanceof pulseprotocolerror) {\n console.error('protocol error:', error);\n }\n },\n});\n```\n\n| error | when |\n| | |\n| `pulseconnectionerror` | transport failure, send while disconnected, room join rejected on close. |\n| `pulseprotocolerror` | malformed frame or server error frame. |\n| `pulsetimeouterror` | `wait()` times out. |\n| `pulseroomtimeouterror` | room scope `joined` times out. |\n| `pulseaborterror` | `wait()` or room `joined` aborted via abortsignal. |\n| `pulsedisposederror` | operation attempted after disposal. |\n\n## best practices\n\n await `connect()` before sending; never assume construction opens the transport.\n define the full schema at `createpulse()` so named scopes are type safe without per call generics.\n use `using` declarations for channel and room scopes so disposal is automatic at block exit.\n always call `dispose()` when done — it closes the connection, rejects pending joins, and clears listeners.\n provide an `onerror` handler; pulse reports transport and protocol errors there rather than throwing asynchronously.\n read `pulse.rooms` for post reconnect membership; `joined` rejects on transport close.\n set a `timeout` on room scopes when the server may never confirm membership.\n keep `transform` synchronous; resolve async policy decisions before calling `send()`.\n",
831
+ "examples": " \ntitle: examples — pulse\ndescription: practical examples for common pulse usage patterns.\npackage: pulse\ncategory: websockets\n \n\n<! markdownlint disable md025 >\n\n [basic connection](./examples/basic connection.md)\n [channel multiplexing](./examples/channels.md)\n [outgoing transform](./examples/middleware.md)\n [reconnect and heartbeat](./examples/reconnect and heartbeat.md)\n [rooms and presence](./examples/rooms and presence.md)\n"
832
+ },
833
+ "examples": [
834
+ {
835
+ "id": "channels",
836
+ "text": "typed channels import { createpulse } from '@vielzeug/pulse'\n\n// isolated channel namespace — listeners and sends are scoped to 'chat'\nconst pulse = createpulse('wss://api.example.com/ws')\nconst chat = pulse.channel('chat')\n\n// listeners scoped to the channel\nchat.on('message', ({ from, text }) => {\n console.log('[chat] ' + from + ': ' + text)\n})\n\ntry {\n await pulse.connect()\n // send scoped to the channel\n chat.send('send', { text: 'hey!' })\n} catch (err) {\n console.log('connect failed:', err.message)\n}\n\n// wait with a per event timeout\ntry {\n const msg = await chat.wait('message', { timeout: 3_000 })\n console.log('got:', msg.text)\n} catch (err) {\n console.log('channel wait timed out:', err.message)\n}\n\n// disposing the channel removes all its listeners\n// but the underlying pulse connection stays open\nchat.dispose()\nconsole.log('channel disposed, pulse still open:', pulse.status.value)\n\npulse.dispose()"
837
+ },
838
+ {
839
+ "id": "connect-and-send",
840
+ "text": "connect & send import { createpulse } from '@vielzeug/pulse'\n\n// typed websocket client: on(), once(), send(), wait()\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { maxattempts: 5 },\n onerror: (error) => console.log('transport error:', error.message),\n})\n\n// subscribe before connecting — listeners are synchronous\nconst unsub = pulse.on('chat:message', ({ from, text }) => {\n console.log('[' + from + '] ' + text)\n})\n\n// one shot listener: fires once and auto removes\npulse.once('chat:message', (msg) => {\n console.log('first message:', msg.text)\n})\n\n// connect; send when open\ntry {\n await pulse.connect()\n pulse.send('chat:send', { text: 'hello, world!' })\n} catch (err) {\n console.log('connect failed:', err.message)\n}\n\n// await next server event with a 5 s deadline\ntry {\n const msg = await pulse.wait('chat:message', { timeout: 500 })\n console.log('received:', msg.text)\n} catch (err) {\n console.log('wait ended:', err.message)\n}\n\nunsub()\npulse.dispose()"
841
+ },
842
+ {
843
+ "id": "lifecycle",
844
+ "text": "lifecycle & disposal import { createpulse, pulsedisposederror } from '@vielzeug/pulse'\n\n// status signal, disposalsignal, and error handling on dispose\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 1_000, maxattempts: 3 },\n heartbeat: { interval: 30_000, timeout: 5_000 },\n onerror: (error) => console.log('pulse error:', error.message),\n})\n\n// construction is closed. connect() makes the transport available.\nconsole.log('initial status:', pulse.status.value)\n\n// disposalsignal aborts when dispose() is called\npulse.disposalsignal.addeventlistener('abort', () => {\n console.log('disposal signal fired')\n})\n\ntry {\n await pulse.connect()\n console.log('connected:', pulse.status.value)\n} catch (err) {\n console.log('connect failed:', err.message)\n}\n\n// dispose() is idempotent — safe to call multiple times\npulse.dispose()\npulse.dispose()\nconsole.log('disposed:', pulse.disposed)\n\n// methods reject with pulsedisposederror after dispose\ntry {\n await pulse.connect()\n} catch (err) {\n if (err instanceof pulsedisposederror) {\n console.log('connect() rejected with pulsedisposederror — correct')\n }\n}"
845
+ },
846
+ {
847
+ "id": "reconnect",
848
+ "text": "reconnect & restoration import { createpulse, pulseconnectionerror } from '@vielzeug/pulse'\n\n// channels, rooms, and local presence state are restored on reconnect.\nconst pulse = createpulse('wss://api.example.com/ws', {\n reconnect: { delay: 500, maxattempts: 3 },\n onerror: (error) => console.log('transport error:', error.message),\n})\n\n// channel is tracked: re subscribed automatically after every reconnect\nconst chat = pulse.channel('chat')\nchat.on('message', ({ from, text }) => console.log(from + ': ' + text))\n\n// connect explicitly to observe the status\ntry {\n await pulse.connect()\n console.log('connected, status:', pulse.status.value)\n} catch (err) {\n if (err instanceof pulseconnectionerror) {\n console.log('connection failed:', err.message)\n }\n}\n\nconsole.log('channel name:', chat.name)\nconsole.log('channel disposed?', chat.disposed)\n\n// disposing a channel removes it from re subscription tracking\nchat.dispose()\nconsole.log('channel disposed, pulse still running:', !pulse.disposed)\n\npulse.dispose()"
849
+ },
850
+ {
851
+ "id": "rooms-presence",
852
+ "text": "rooms & presence import { createpulse } from '@vielzeug/pulse'\n\n// room scopes: ref counted membership with reactive presence\nconst pulse = createpulse('wss://api.example.com/ws')\nconst lobby = pulse.room('lobby')\n\ntry {\n await pulse.connect()\n\n // wait for server confirmation\n await lobby.joined\n console.log('joined lobby, rooms:', [...pulse.rooms.value])\n\n // broadcast our own presence\n lobby.updatepresence({ avatar: '/me.png', name: 'alice', status: 'online' })\n\n // reactive presence map: memberid → state\n const printmembers = () => {\n for (const [id, state] of lobby.presence.value) {\n console.log(' ' + id + ': ' + state.name + ' (' + state.status + ')')\n }\n }\n\n // react to individual joins and leaves\n lobby.onjoin((id, state) => console.log(state.name + ' joined'))\n lobby.onleave((id) => console.log(id + ' left'))\n} catch (err) {\n console.log('connection or room operation failed:', err.message)\n}\n\n// dispose the room scope — sends leave when last scope is released\nlobby.dispose()\nconsole.log('rooms after leave:', [...pulse.rooms.value])\n\npulse.dispose()"
853
+ }
854
+ ],
855
+ "exports": "createpulse pulse pulsechannel roomscope roomscopebase presenceroomscope pulseoptions pulseschema channeldefinition channeldefinitions roomdefinition roomdefinitions roomoptions outgoingmessage outgoingtransform pulseerror pulseconnectionerror pulsetimeouterror pulseroomtimeouterror pulseaborterror pulsedisposederror pulseprotocolerror",
856
+ "keywords": "websocket realtime channels presence rooms reconnect heartbeat typed messaging ripple",
857
+ "name": "@vielzeug/pulse",
858
+ "related": "herald ripple courier clockwork",
859
+ "slug": "pulse",
860
+ "source": "export {\n pulseaborterror,\n pulseconnectionerror,\n pulsedisposederror,\n pulseerror,\n pulseprotocolerror,\n pulseroomtimeouterror,\n pulsetimeouterror,\n} from './errors';\nexport { createpulse } from './pulse';\nexport type {\n channeldefinition,\n channeldefinitions,\n clientevents,\n eventkey,\n heartbeatoptions,\n messagemap,\n outgoingmessage,\n outgoingtransform,\n presenceroomscope,\n pulse,\n pulsechannel,\n pulseoptions,\n pulseschema,\n pulsestatus,\n reconnectoptions,\n roomdefinition,\n roomdefinitions,\n roommap,\n roomoptions,\n roomscope,\n roomscopebase,\n serverevents,\n unsubscribe,\n} from './types';\n"
861
+ },
862
+ {
863
+ "category": "ui components",
864
+ "description": "accessible, themeable web components built with ore for framework and vanilla dom apps.",
865
+ "docs": {
866
+ "index": " \ntitle: refine — web component library\ndescription: accessible, themeable web components built with ore for framework and vanilla dom apps.\npackage: refine\ncategory: ui components\nkeywords: [web components, accessible, themeable, ui, components, design system]\nrelated: [ore, orbit, forge, keymap]\nexports:\n [\n ore accordion,\n ore accordion item,\n ore alert,\n ore async,\n ore avatar,\n ore avatar group,\n ore badge,\n ore box,\n ore breadcrumb,\n ore breadcrumb item,\n ore button,\n ore button group,\n ore calendar,\n ore card,\n ore carousel,\n ore chat message,\n ore checkbox,\n ore checkbox group,\n ore chip,\n ore combobox,\n ore command palette,\n ore command palette item,\n ore datagrid,\n ore date picker,\n ore dialog,\n ore drawer,\n ore file input,\n ore grid,\n ore grid item,\n ore icon,\n ore input,\n ore list,\n ore list item,\n ore menu,\n ore menu item,\n ore menu separator,\n ore message composer,\n ore navbar,\n ore navbar item,\n ore number input,\n ore otp input,\n ore pagination,\n ore password strength,\n ore popover,\n ore progress,\n ore radio,\n ore radio group,\n ore rating,\n ore select,\n ore separator,\n ore sidebar,\n ore sidebar group,\n ore sidebar item,\n ore skeleton,\n ore slider,\n ore step,\n ore stepper,\n ore switch,\n ore tab item,\n ore tab panel,\n ore table,\n ore tabs,\n ore text,\n ore textarea,\n ore time picker,\n ore toast,\n ore tooltip,\n ore typing indicator,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"refine\" />\n\n## why refine?\n\nevery project needs ui primitives. refine provides accessible web components that work natively anywhere html is rendered—no framework required.\n\n```html\n<! before — roll your own button with aria >\n<button class=\"btn btn primary\" role=\"button\" aria pressed=\"false\" tabindex=\"0\">\n <span class=\"btn spinner\" aria hidden=\"true\"></span>\n save\n</button>\n\n<! after — refine >\n<ore button variant=\"primary\" loading>save</ore button>\n```\n\n| feature | refine | shoelace | material web |\n| | | | |\n| bundle size | <packageinfo package=\"refine\" type=\"size\" /> | ~145 kb | ~200 kb |\n| built with | ore | lit | lit |\n| accessible | wcag aa | wcag aa | wcag aa |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use refine when** you want accessible web components that match the vielzeug design system without a heavy framework dependency.\n\n**consider shoelace or material web** if your team is already standardized on those ecosystems and you need their established component catalogs.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/refine\n```\n\n```sh [npm]\nnpm install @vielzeug/refine\n```\n\n```sh [yarn]\nyarn add @vielzeug/refine\n```\n\n:::\n\n## quick start\n\n```ts\n// 1. import global styles once\nimport '@vielzeug/refine/fouc.css'; // hide unupgraded custom elements until first paint\nimport '@vielzeug/refine/tokens.css'; // tokens, animations, cascade layers\n\n// 2. register only the elements you need\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/card';\n```\n\n```html\n<ore button variant=\"solid\" color=\"primary\">save</ore button>\n<ore input label=\"email\" type=\"email\" required></ore input>\n<ore card padding=\"lg\">\n <span slot=\"header\">account</span>\n <p>card content goes here.</p>\n</ore card>\n```\n\n```ts\n```\n\n### cdn / vanilla html\n\nuse the self contained iife bundle to load refine directly from a cdn in any html page — no build step required:\n\n```html\n<! 1. styles >\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/fouc.css\" />\n<link rel=\"stylesheet\" href=\"https://unpkg.com/@vielzeug/refine/dist/styles/tokens.css\" />\n\n<! 2. all components (iife — registers global refine namespace) >\n<script src=\"https://unpkg.com/@vielzeug/refine/dist/refine.iife.js\"></script>\n```\n\nfor bundler based projects that still want a cdn url, use the esm bundle via an import map:\n\n```html\n<script type=\"importmap\">\n {\n \"imports\": {\n \"@vielzeug/refine\": \"https://esm.sh/@vielzeug/refine\",\n \"@vielzeug/refine/button\": \"https://esm.sh/@vielzeug/refine/button\",\n \"@vielzeug/refine/input\": \"https://esm.sh/@vielzeug/refine/input\"\n }\n }\n</script>\n\n<script type=\"module\">\n import '@vielzeug/refine/button';\n import '@vielzeug/refine/input';\n</script>\n```\n\n### package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | global design tokens and cascade layers |\n| `@vielzeug/refine/styles/preflight.css` | optional browser default reset (includes fouc suppression) |\n\ncomponent registration happens through side effect imports such as `@vielzeug/refine/button` and `@vielzeug/refine/dialog`.\n\n### components\n\n**content:** `ore avatar`, `ore avatar group`, `ore breadcrumb`, `ore card`, `ore carousel`, `ore carousel slide`, `ore chat message`, `ore icon`, `ore list`, `ore list item`, `ore marquee`, `ore pagination`, `ore separator`, `ore step`, `ore stepper`, `ore table`, `ore text`\n\n**disclosure:** `ore accordion`, `ore accordion item`, `ore tabs`, `ore tab item`, `ore tab panel`\n\n**feedback:** `ore alert`, `ore async`, `ore badge`, `ore chip`, `ore password strength`, `ore progress`, `ore skeleton`, `ore toast`, `ore typing indicator`\n\n**inputs:** `ore button`, `ore button group`, `ore calendar`, `ore checkbox`, `ore checkbox group`, `ore column`, `ore combobox`, `ore datagrid`, `ore date picker`, `ore file input`, `ore input`, `ore message composer`, `ore number input`, `ore otp input`, `ore radio`, `ore radio group`, `ore rating`, `ore select`, `ore slider`, `ore switch`, `ore textarea`, `ore time picker`\n\n**layout:** `ore box`, `ore grid`, `ore grid item`, `ore navbar`, `ore sidebar`\n\n**overlay:** `ore command palette`, `ore command palette item`, `ore dialog`, `ore drawer`, `ore menu`, `ore popover`, `ore tooltip`\n\n## features\n\n<div class=\"features grid\">\n\n **accessible** — keyboard navigation, aria wiring, and focus management across interactive components\n **themeable** — global tokens plus component level css custom properties\n **framework agnostic** — works anywhere html can be rendered\n **tree shakeable** — import only the component entry points you register\n **comprehensive surface** — inputs, content, disclosure, feedback, layout, and overlay primitives\n **zero runtime deps** — <packageinfo package=\"refine\" type=\"size\" /> gzipped\n\n</div>\n\n### prerequisites\n\n browser runtime with custom elements support.\n import `@vielzeug/refine/fouc.css` and `@vielzeug/refine/tokens.css` before rendering components.\n for ssr, render placeholders server side and hydrate components only on the client.\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [ore](/ore/) — web component runtime that powers refine\n [orbit](/orbit/) — floating ui positioning used in refine's overlays\n [forge](/forge/) — form state management for use with refine inputs\n [keymap](/keymap/) — keyboard shortcut manager that powers the command palette's global trigger\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
867
+ "api": " \ntitle: refine — api reference\ndescription: published component registration and stylesheet entry points for @vielzeug/refine.\n \n\n# api reference\n\n[[toc]]\n\nrefine deliberately publishes components, not a second headless framework. register each element through its component\nsubpath and import its types from the same path.\n\n## styles\n\n```ts\nimport '@vielzeug/refine/fouc.css'; // hide unupgraded custom elements until first paint\nimport '@vielzeug/refine/tokens.css'; // required: tokens, animations, cascade layers\nimport '@vielzeug/refine/styles/preflight.css'; // optional: normalizes browser defaults.\n```\n\n`fouc.css` suppresses flash of unstyled content by hiding custom elements (`:not(:defined)`)\nuntil their shadow dom attaches. import it in your css bundle — not via js injection — so the\nrule is available at first paint. `tokens.css` defines refine's design tokens, animations, and\ncascade layer order without modifying global element defaults. `preflight.css` is a separate\nopt in reset that also imports `fouc.css`.\n\ndirect css entry points are also available when needed:\n\n| import path | purpose |\n| | |\n| `@vielzeug/refine/fouc.css` | fouc suppression for unupgraded custom elements |\n| `@vielzeug/refine/tokens.css` | tokens, animation helpers, and cascade layers |\n| `@vielzeug/refine/styles/theme.css` | theme token declarations |\n| `@vielzeug/refine/styles/animation.css` | animation helpers |\n| `@vielzeug/refine/styles/layers.css` | cascade layer declarations |\n| `@vielzeug/refine/styles/preflight.css` | optional browser default reset (includes fouc suppression) |\n\n## components\n\neach component has a single registration and type entry point:\n\n```ts\nimport '@vielzeug/refine/button';\nimport type { orebuttonevents, orebuttonprops } from '@vielzeug/refine/button';\n```\n\nthe package root only exports `refineerror`; it does not register elements. this keeps component ownership and bundle\ncontents explicit.\n\n| area | components |\n| | |\n| content | `accordion`, `accordion item`, `avatar`, `avatar group`, `badge`, `breadcrumb`, `card`, `carousel`, `chat message`, `code window`, `copy command`, `icon`, `list`, `list item`, `marquee`, `pagination`, `separator`, `step`, `stepper`, `table`, `text` |\n| feedback | `alert`, `async`, `chip`, `password strength`, `progress`, `skeleton`, `toast`, `typing indicator` |\n| inputs | `button`, `button group`, `calendar`, `checkbox`, `checkbox group`, `combobox`, `datagrid`, `date picker`, `file input`, `input`, `message composer`, `number input`, `otp input`, `radio`, `radio group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time picker` |\n| layout | `box`, `grid`, `grid item`, `navbar`, `sidebar` |\n| overlays | `command palette`, `dialog`, `drawer`, `menu`, `popover`, `tooltip` |\n\neach component's documentation page describes its attributes, properties, events, slots, parts, and custom properties.\n\n## events and form controls\n\nform controls expose their current `.value` or `.checked` property and dispatch standard `input` and `change` events.\nread the property from `event.currenttarget`; do not rely on framework specific custom event casts.\n\nstateful overlays expose `open` and `default open` properties/attributes and dispatch `open change` with\n`{ open, reason }` detail. the per component pages describe valid reasons and focus behavior.\n",
868
+ "usage": " \ntitle: refine — usage guide\ndescription: installation, attributes, events, slots, and ecosystem integration for refine components.\n \n\n# usage guide\n\n[[toc]]\n\nrefine components are native web components. once imported, they behave like regular html elements — set attributes, listen to dom events, use slots for content projection.\n\n## installation\n\nimport the global styles first, then register only the components you need:\n\n```ts\nimport '@vielzeug/refine/tokens.css';\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport '@vielzeug/refine/dialog';\n```\n\nthe token stylesheet supplies refine's design tokens and cascade layers without changing browser defaults. add the reset only when your application explicitly wants it:\n\n```ts\nimport '@vielzeug/refine/styles/preflight.css';\n```\n\n## attributes and events\n\nset attributes directly on the element. attributes map to component props:\n\n```html\n<ore button variant=\"outline\" color=\"secondary\" size=\"lg\" disabled>\n large outline button\n</ore button>\n```\n\ncomponents emit standard dom events. common event names: `click`, `input`, `change`, and `open change`. custom events carry a `detail` object:\n\n```javascript\nconst input = document.queryselector('ore input');\n\ninput.addeventlistener('input', () => {\n console.log(input.value);\n});\n```\n\nnative browser events (`click`, `focus`, `blur`) work as normal. custom events with `event.detail` require `addeventlistener` in react 18 and earlier — see the [framework integration](./frameworks.md) guide.\n\n## slots\n\nslots let you pass html into named regions of a component without javascript.\n\ncontent placed directly inside the element fills the default slot:\n\n```html\n<ore button>save changes</ore button>\n<ore card>any html content here</ore card>\n```\n\ncomponents with distinct regions expose named slots:\n\n```html\n<ore card>\n <span slot=\"header\">card heading</span>\n <p>main body content fills the default slot.</p>\n <div slot=\"footer\">\n <ore button size=\"sm\" variant=\"outline\">cancel</ore button>\n <ore button size=\"sm\">confirm</ore button>\n </div>\n</ore card>\n```\n\nmany input components expose `prefix` and `suffix` slots for icons or actions:\n\n```html\n<ore button>\n <ore icon slot=\"prefix\" name=\"arrow left\" size=\"18\"></ore icon>\n back\n</ore button>\n\n<ore input label=\"search\">\n <ore icon slot=\"suffix\" name=\"search\" size=\"18\" aria hidden=\"true\"></ore icon>\n</ore input>\n```\n\neach component's available slots are listed in its api reference table.\n\n## composing with ore and ripple\n\nrefine components are plain html elements — they compose naturally with [ore](/ore/) custom elements and [ripple](/ripple/) signals.\n\n**build a custom component that wraps refine elements:**\n\n```ts\nimport '@vielzeug/refine/button';\nimport '@vielzeug/refine/input';\nimport { define, html } from '@vielzeug/ore';\nimport { signal } from '@vielzeug/ripple';\n\ndefine('my search bar', () => {\n const query = signal('');\n return html`\n <ore input\n .value=${query}\n @input=${(e) => (query.value = e.currenttarget.value)}\n label=\"search\"\n />\n <ore button @click=${() => search(query.value)} variant=\"solid\" color=\"primary\">\n search\n </ore button>\n `;\n});\n```\n\n**drive component state from reactive signals:**\n\n```ts\nimport { signal, effect } from '@vielzeug/ripple';\n\nconst isloading = signal(false);\nconst btn = document.queryselector('ore button');\n\neffect(() => {\n btn.loading = isloading.value;\n});\n```\n\n## framework integration\n\nfor react, vue, svelte, and angular wiring — including event handling, typescript declarations, vite setup, and ssr guards — see the [framework integration](./frameworks.md) guide.\n\n## accessibility\n\nall refine components target wcag 2.1 aa. aria roles and states are managed automatically. for the full compliance contract, per component coverage, and testing strategy, see the [accessibility](./accessibility.md) page.\n\nthe two things you always control:\n\n **icon only buttons** require a `label` attribute — it becomes `aria label`.\n **decorative icons** should have `aria hidden=\"true\"` so screen readers skip them.\n"
869
+ },
870
+ "examples": [],
871
+ "exports": "ore accordion ore accordion item ore alert ore async ore avatar ore avatar group ore badge ore box ore breadcrumb ore breadcrumb item ore button ore button group ore calendar ore card ore carousel ore chat message ore checkbox ore checkbox group ore chip ore combobox ore command palette ore command palette item ore datagrid ore date picker ore dialog ore drawer ore file input ore grid ore grid item ore icon ore input ore list ore list item ore menu ore menu item ore menu separator ore message composer ore navbar ore navbar item ore number input ore otp input ore pagination ore password strength ore popover ore progress ore radio ore radio group ore rating ore select ore separator ore sidebar ore sidebar group ore sidebar item ore skeleton ore slider ore step ore stepper ore switch ore tab item ore tab panel ore table ore tabs ore text ore textarea ore time picker ore toast ore tooltip ore typing indicator",
872
+ "keywords": "web components accessible themeable ui components design system",
873
+ "name": "@vielzeug/refine",
874
+ "related": "ore orbit forge keymap",
875
+ "slug": "refine",
876
+ "source": "/**\n * refine components register through their explicit component entry points.\n *\n * keeping the package root free of registration side effects makes dependency\n * ownership and bundle contents obvious to application code.\n */\nexport { refineerror } from './errors';\n"
877
+ },
878
+ {
879
+ "category": "state",
880
+ "description": "framework agnostic signals, derived values, effects, scopes, watchers, and async resources.",
881
+ "docs": {
882
+ "index": " \ntitle: ripple — reactive graphs\ndescription: framework agnostic signals, derived values, effects, scopes, watchers, and async resources.\npackage: ripple\ncategory: state\nkeywords: [reactive, signals, computed, effects, graph, scope, batch, watch, resource, async]\nrelated: [ore, clockwork, ledger]\nexports: [createripple, signal, computed, effect, batch, createscope, untrack, watch, resource, isreactive]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ripple\" />\n\n## why ripple?\n\nhand rolled reactive state spreads subscription, cleanup, and derived value rules across application code. ripple gives you one graph boundary with explicit disposal and fine grained dependencies while keeping rendering and routing outside the runtime.\n\n```ts\n// before\nlet count = 0;\nconst listeners = new set<() => void>();\n\nfunction setcount(next: number) {\n count = next;\n for (const listener of listeners) listener();\n}\n\n// after\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\ncount.value = 1;\nstop.dispose();\nripple.dispose();\n```\n\n| feature | ripple | zustand | jotai |\n| | | | |\n| bundle size | <packageinfo package=\"ripple\" type=\"size\" /> | ~3.5 kb | ~7 kb |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | react first |\n| explicit graph lifetime | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| fine grained derived values | <ore icon name=\"check\" size=\"16\"></ore icon> | selectors | atoms |\n\n<div class=\"decision callout\">\n\n**use ripple when** you need framework independent state with explicit graph lifetime and small composable primitives.\n\n**consider a framework store when** component bindings, server cache, or framework specific tooling matter more than portable reactive state.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ripple\n```\n\n```sh [npm]\nnpm install @vielzeug/ripple\n```\n\n```sh [yarn]\nyarn add @vielzeug/ripple\n```\n\n:::\n\n## quick start\n\ncreate one graph, derive a value, observe it, then dispose resources when the graph lifetime ends.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst doubled = ripple.computed(() => count.value * 2);\nconst stop = ripple.effect(() => console.log(doubled.value));\n\nripple.batch(() => {\n count.value = 1;\n count.value = 2;\n});\n\nstop.dispose();\nripple.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createripple()` creates an isolated graph and lifetime boundary.\n `signal()` stores writable values with configurable equality.\n `computed()` derives lazy read only values.\n `effect()` reacts to dependency changes with cleanup support.\n `batch()` coalesces synchronous writes and notifications.\n `createscope()` groups owned reactive work.\n `watch()` observes one selected source transition.\n `resource()` loads async values with stale work cancellation.\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 [ore](/ore/) — uses ripple signals and effects for web component reactivity.\n [clockwork](/clockwork/) — exposes machine state through reactive ripple values.\n [ledger](/ledger/) — adds command based undo and redo beside ripple state.\n\n</div>\n\n<! markdownlint enable >\n",
883
+ "api": " \ntitle: ripple — api reference\ndescription: complete reference for reactive graphs, signals, effects, scopes, watchers, and resources.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createripple()` | create isolated graph | sync | disposal is terminal; create a new graph instead of reusing it |\n| `signal()` | create writable value | sync | default graph is process wide |\n| `computed()` | create lazy derived value | sync | keep derivation pure |\n| `effect()` | react to dependency reads | sync | dispose handle or return cleanup |\n| `batch()` | coalesce synchronous writes | sync | does not roll back writes |\n| `createscope()` | group owned reactive work | sync | call `run()` to activate it |\n| `untrack()` | read without tracking | sync | read still happens immediately |\n| `watch()` | observe selected output | sync | use `effect()` for broad reads |\n| `resource()` | load async source | async | read dependencies in source callback |\n| `isreactive()` | test `readable` identity | sync | does not test arbitrary objects |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ripple` | all primitives, types, and errors — signals, computed, effects, scopes, watch, resource, and the isolated graph factory |\n\n## graph creation\n\n### `createripple(options?)`\n\n```ts\nfunction createripple(options?: rippleoptions): ripple;\n```\n\ncreates one isolated reactive graph. factories on the returned object share scheduling, ownership, observer, and error boundaries. `dispose()` is terminal: `ripple.disposed` becomes `true`, existing owned work is disposed, and creating more graph work throws `rippledisposedruntimeerror`. create a new graph for a new lifetime.\n\n| parameter | type | description |\n| | | |\n| `options.onerror` | `(error, context) => void` | receives effect, cleanup, listener, or observer failures. |\n| `options.observer` | `reactiveobserver` | receives graph events. |\n\n**returns:** `ripple`.\n\n**example:**\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n \n\n### `isreactive(value)`\n\n```ts\nfunction isreactive<t>(value: t | readable<t>): value is readable<t>;\n```\n\ntests whether a value is a ripple created readable node, including `resource`. recognition works across duplicated ripple module graphs.\n\n**returns:** `true` for a ripple `signal`, computed value, or `resource`; otherwise `false`.\n\n**example:**\n\n```ts\nimport { isreactive, signal } from '@vielzeug/ripple';\n\nconsole.log(isreactive(signal(0)));\n```\n\n## default graph functions\n\n### `signal(initial, options?)`\n\n```ts\nfunction signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n```\n\ncreates writable state on the default graph. use `update()` for immutable replacement patterns.\n\n**returns:** `signal<t>`.\n\n**example:**\n\n```ts\nimport { signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\ncount.value += 1;\n\nconst cart = signal({ items: 0 });\ncart.update((state) => ({ ...state, items: state.items + 1 }));\n```\n\n \n\n### `computed(derive, options?)`\n\n```ts\nfunction computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n```\n\ncreates a lazy read only value from reactive reads in `derive`.\n\n**returns:** `readable<t>`.\n\n**example:**\n\n```ts\nimport { computed, signal } from '@vielzeug/ripple';\n\nconst count = signal(2);\nconst doubled = computed(() => count.value * 2);\nconsole.log(doubled.value);\n```\n\n \n\n### `effect(callback, options?)`\n\n```ts\nfunction effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n```\n\nruns immediately and reruns when its tracked reads change. a returned cleanup runs before the next callback or disposal.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { effect, signal } from '@vielzeug/ripple';\n\nconst connected = signal(false);\nconst stop = effect(() => {\n if (!connected.value) return;\n\n return () => console.log('disconnect');\n});\n\nstop.dispose();\n```\n\n \n\n### `batch(fn)` and `untrack(fn)`\n\n```ts\nfunction batch<t>(fn: () => t): t;\nfunction untrack<t>(fn: () => t): t;\n```\n\n`batch()` defers effects and listeners until its callback returns. `untrack()` reads current state without adding dependencies to an enclosing effect.\n\n**returns:** the callback result.\n\n**example:**\n\n```ts\nimport { batch, signal, untrack } from '@vielzeug/ripple';\n\nconst first = signal('ada');\nconst last = signal('lovelace');\nconst locale = signal('en us');\n\nbatch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n\nconsole.log(untrack(() => locale.value));\n```\n\n \n\n### `createscope(name?)`\n\n```ts\nfunction createscope(name?: string): scope;\n```\n\ncreates a disposable ownership boundary. work created inside `scope.run()` belongs to that scope.\n\n**returns:** `scope`.\n\n**example:**\n\n```ts\nimport { createscope, effect, signal } from '@vielzeug/ripple';\n\nconst scope = createscope('panel');\nconst count = signal(0);\n\nscope.run(() => effect(() => console.log(count.value)));\nscope.dispose();\n```\n\n## watch and resources\n\n### `watch(source, callback, options?)`\n\n```ts\nfunction watch<t>(\n source: readable<t> | (() => t),\n callback: (value: t, previous: t | undefined) => void,\n options?: watchoptions<t>,\n): effecthandle;\n```\n\nobserves selected output changes using the default graph or a `ripple.watch()` method.\n\n**returns:** `effecthandle`.\n\n**example:**\n\n```ts\nimport { signal, watch } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst stop = watch(count, (value, previous) => console.log(previous, value), { immediate: true });\nstop.dispose();\n```\n\n \n\n### `resource(source, loader, options?)`\n\n```ts\nfunction resource<source, value>(\n source: () => source,\n loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>,\n options?: resourceoptions,\n): resource<value>;\n```\n\ntracks `source`, aborts stale loader work, and exposes `asyncstate<value>`. source and loader failures become `status: 'error'` state; handle them from `resource.value` rather than `rippleoptions.onerror`, which is reserved for runtime callback, cleanup, listener, and observer failures.\n\n**returns:** `resource<value>`.\n\n**example:**\n\n```ts\nimport { resource, signal } from '@vielzeug/ripple';\n\nconst userid = signal('42');\nconst user = resource(() => userid.value, async (id) => ({ id }));\n\nif (user.value.status === 'error') console.error(user.value.error);\nuser.dispose();\n```\n\n## types\n\n```ts\ntype cleanup = () => void;\ntype equality<t> = (previous: t, next: t) => boolean;\ntype unsubscribe = () => void;\n\ntype signaloptions<t> = { equals?: equality<t>; name?: string };\ntype computedoptions<t> = { equals?: equality<t>; name?: string };\ntype effectoptions = { name?: string; scheduler?: 'microtask' | 'sync' };\ntype watchoptions<t> = { equals?: equality<t>; immediate?: boolean; name?: string; once?: boolean };\ntype resourceoptions = { name?: string };\n\ntype reactiveevent =\n | { readonly kind: 'compute'; readonly name?: string }\n | { readonly kind: 'effect'; readonly name?: string }\n | { readonly kind: 'write'; readonly name?: string; readonly next: unknown; readonly previous: unknown }\n | { readonly kind: 'dispose'; readonly name?: string; readonly node: 'effect' | 'scope' };\n\ntype reactiveobserver = (event: reactiveevent) => void;\ntype reactiveerrorcontext = { readonly kind: 'cleanup' | 'effect' | 'listener' | 'observer'; readonly name?: string };\ntype rippleoptions = { observer?: reactiveobserver; onerror?: (error: unknown, context: reactiveerrorcontext) => void };\n\ntype asyncstate<t> =\n | { readonly previous?: t; readonly status: 'pending' }\n | { readonly status: 'success'; readonly value: t }\n | { readonly error: unknown; readonly previous?: t; readonly status: 'error' };\n\ninterface readable<t> {\n readonly name?: string;\n peek(): t;\n subscribe(listener: () => void): unsubscribe;\n readonly value: t;\n}\n\ninterface signal<t> extends readable<t> { update(updater: (prev: t) => t): void; value: t }\ninterface disposable { dispose(): void; readonly disposed: boolean; readonly disposalsignal: abortsignal; [symbol.dispose](): void }\ntype effecthandle = disposable;\ninterface scope extends disposable { run<t>(fn: () => t): t }\n\ninterface resource<t> extends readable<asyncstate<t>>, disposable { reload(): void }\n\ninterface ripple {\n batch<t>(fn: () => t): t;\n computed<t>(derive: () => t, options?: computedoptions<t>): readable<t>;\n createscope(name?: string): scope;\n dispose(): void;\n readonly disposed: boolean;\n effect(callback: () => cleanup | undefined, options?: effectoptions): effecthandle;\n resource<source, value>(source: () => source, loader: (source: source, context: { readonly signal: abortsignal }) => promise<value>, options?: resourceoptions): resource<value>;\n signal<t>(initial: t, options?: signaloptions<t>): signal<t>;\n untrack<t>(fn: () => t): t;\n watch<t>(source: readable<t> | (() => t), callback: (value: t, previous: t | undefined) => void, options?: watchoptions<t>): effecthandle;\n}\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `rippleerror` | base ripple error | use `instanceof rippleerror` to narrow unknown values. |\n| `ripplecomputedcycleerror` | computed dependency reads itself through a cycle | extends `rippleerror`. |\n| `rippledisposedruntimeerror` | factory or execution api used after `ripple.dispose()` | extends `rippleerror`. |\n| `rippledisposedscopeerror` | `scope.run()` after scope disposal | extends `rippleerror`. |\n| `rippleinfinitelooperror` | effect flush exceeds graph iteration limit | extends `rippleerror`. |\n",
884
+ "usage": " \ntitle: ripple — usage guide\ndescription: build reactive state with one explicit graph boundary.\n \n\n[[toc]]\n\n## basic usage\n\nuse top level functions when one application lifetime graph is sufficient. read a signal inside an effect to make that read reactive.\n\n```ts\nimport { computed, effect, signal } from '@vielzeug/ripple';\n\nconst count = signal(0);\nconst label = computed(() => `count: ${count.value}`);\nconst stop = effect(() => console.log(label.value));\n\ncount.value = 1;\nstop.dispose();\n```\n\n## isolated graphs\n\nuse `createripple()` for tests, ssr requests, embedded applications, or independently disposable features. never mix reactive values from separate graphs.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple({\n onerror(error, context) {\n console.log(context.kind, error);\n },\n});\n\nconst count = ripple.signal(0);\nconst stop = ripple.effect(() => console.log(count.value));\n\nstop.dispose();\nripple.dispose();\n```\n\n## derived values and batches\n\nuse `computed()` for pure derivation. use `untrack()` when a current read must not become an effect dependency. use `batch()` for related synchronous writes.\n\n```ts\nconst first = ripple.signal('ada');\nconst last = ripple.signal('lovelace');\nconst locale = ripple.signal('en us');\nconst name = ripple.computed(() => `${first.value} ${last.value}`);\n\nripple.effect(() => {\n console.log({ locale: ripple.untrack(() => locale.value), name: name.value });\n});\n\nripple.batch(() => {\n first.value = 'grace';\n last.value = 'hopper';\n});\n```\n\n## scheduling and subscriptions\n\nripple propagates every synchronous write before flushing effects. each flush pass runs effects queued at its\nstart before direct `subscribe()` listeners queued at its start. work queued by either runs in a later pass.\neffects using `scheduler: 'microtask'` join a later microtask and coalesce writes made before that task runs.\n\n```ts\nconst count = ripple.signal(0);\nconst log: string[] = [];\n\nripple.effect(() => log.push(`effect: ${count.value}`));\ncount.subscribe(() => log.push(`listener: ${count.value}`));\nripple.effect(() => log.push(`deferred: ${count.value}`), { scheduler: 'microtask' });\n\nlog.length = 0; // ignore synchronous creation runs.\ncount.value = 1;\nconsole.log(log); // ['effect: 1', 'listener: 1']\n\nawait promise.resolve();\nconsole.log(log); // ['effect: 1', 'listener: 1', 'deferred: 1']\n```\n\n## ownership with scopes\n\ncreate a scope when a group of effects or derived values shares one lifetime. dispose the scope when its feature ends.\n\n```ts\nconst scope = ripple.createscope('panel');\nconst count = ripple.signal(0);\n\nscope.run(() => {\n ripple.effect(() => console.log(`panel count: ${count.value}`));\n});\n\ncount.value = 1;\nscope.dispose();\n```\n\n## watch selected values\n\nuse `watch()` for one selected output. use `effect()` when every reactive read in the callback should be a dependency.\n\n```ts\nconst stopwatch = ripple.watch(\n () => `${first.value} ${last.value}`,\n (value, previous) => console.log({ previous, value }),\n { immediate: true },\n);\n\nstopwatch.dispose();\n```\n\n## async data\n\n`resource()` captures source dependencies synchronously and passes a cancellation signal to the loader.\n\n```ts\nconst userid = ripple.signal('42');\nconst user = ripple.resource(\n () => userid.value,\n async (id, { signal }) => {\n const response = await fetch(`/users/${id}`, { signal });\n if (!response.ok) throw new error(`request failed: ${response.status}`);\n\n return response.json() as promise<{ id: string; name: string }>;\n },\n);\n\nif (user.value.status === 'success') console.log(user.value.value.name);\nif (user.value.status === 'error') console.error(user.value.error);\nuser.dispose();\n```\n\n## object state\n\n`signal()` with `update()` holds one value and supports immutable replacement patterns. return replacement objects from `update()` when object consumers depend on immutable updates.\n\n```ts\nconst cart = ripple.signal({ items: 0, label: 'empty' });\nconst items = ripple.computed(() => cart.value.items);\n\ncart.update((state) => ({ ...state, items: state.items + 1 }));\ncart.value = { items: 3, label: 'ready' };\n\nconsole.log(items.value);\n```\n\n## testing\n\ncreate an isolated graph per test. disposal prevents effects and resource work from leaking into later tests.\n\n```ts\nimport { expect, test } from 'vitest';\nimport { createripple } from '@vielzeug/ripple';\n\ntest('derives a doubled count', () => {\n const ripple = createripple();\n const count = ripple.signal(2);\n const doubled = ripple.computed(() => count.value * 2);\n\n expect(doubled.value).tobe(4);\n ripple.dispose();\n});\n```\n\n## framework integration\n\nuse signals and effects with any renderer. dispose component owned effects when the component unmounts.\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\n\nexport function counter() {\n const [, rerender] = usestate(0);\n\n useeffect(() => {\n const stop = ripple.effect(() => {\n void count.value;\n rerender((revision) => revision + 1);\n });\n\n return () => stop.dispose();\n }, []);\n\n return <button onclick={() => (count.value += 1)}>{count.value}</button>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, ref } from 'vue';\nimport { createripple } from '@vielzeug/ripple';\n\nconst ripple = createripple();\nconst count = ripple.signal(0);\nconst revision = ref(0);\nconst stop = ripple.effect(() => {\n void count.value;\n revision.value++;\n});\n\nonunmounted(() => stop.dispose());\n```\n\n```ts [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createripple } from '@vielzeug/ripple';\n\n const ripple = createripple();\n const count = ripple.signal(0);\n let revision = 0;\n const stop = ripple.effect(() => {\n void count.value;\n revision++;\n });\n\n ondestroy(() => stop.dispose());\n</script>\n\n<button on:click={() => (count.value += 1)}>{count.value}</button>\n```\n\n:::\n\n## working with other vielzeug libraries\n\nore uses ripple for component reactivity. clockwork actors expose framework neutral snapshots; bridge actor subscriptions into a ripple signal. ledger adds undo/redo commands around state changes without replacing graph.\n\n```ts\nimport { createripple } from '@vielzeug/ripple';\nimport { definemachine } from '@vielzeug/clockwork';\n\nconst ripple = createripple();\nconst actor = definemachine<record<string, never>, { type: 'start' }>()({\n initial: 'idle',\n states: { active: {}, idle: { on: { start: { target: 'active' } } } },\n}).createactor();\n\nconst snapshot = ripple.signal(actor.snapshot);\nconst stop = actor.subscribe((next) => (snapshot.value = next));\nconst status = ripple.computed(() => snapshot.value.state);\nconsole.log(status.value);\n\nstop();\nactor.dispose();\nripple.dispose();\n```\n\n## best practices\n\n create one graph per ownership boundary.\n keep computed callbacks pure.\n return cleanup from effects.\n dispose request, test, and feature graphs.\n batch related synchronous writes.\n use `watch()` only for selected source transitions.\n read dependencies in a resource source, not its loader.\n use `onerror` for runtime callback, cleanup, listener, and observer failures; handle resource source and loader failures through `resource.value.status === 'error'`.\n",
885
+ "examples": " \ntitle: ripple — examples\ndescription: practical ripple recipes.\n \n\n## examples\n\n [reactive counter](./examples/reactive counter.md)\n [batch and untrack](./examples/batch and untrack.md)\n [scope ownership](./examples/scope ownership.md)\n [watch selected value](./examples/watch selected value.md)\n [immutable state](./examples/immutable store.md)\n [isolated graph](./examples/isolated runtime.md)\n [async resource](./examples/async resource.md)\n"
886
+ },
887
+ "examples": [
888
+ {
889
+ "id": "async-resource",
890
+ "text": "async resource import { createripple } from '@vielzeug/ripple'\n\n// resource reloads from tracked input and ignores stale loader work.\nconst ripple = createripple()\nconst userid = ripple.signal('u1')\nconst user = ripple.resource(\n () => userid.value,\n async (id, { signal }) => {\n await new promise((resolve) => settimeout(resolve, 30))\n if (signal.aborted) throw new error('request aborted')\n return { id, name: 'user ' + id }\n },\n)\n\nripple.effect(() => console.log(user.value))\n\nuserid.value = 'u2'\nsettimeout(() => user.reload(), 50)\nsettimeout(() => {\n user.dispose()\n ripple.dispose()\n}, 100)"
891
+ },
892
+ {
893
+ "id": "basic-signal",
894
+ "text": "create graph, signal, computed & effect import { createripple } from '@vielzeug/ripple'\n\n// one graph owns state, derived values, effects, and disposal.\nconst ripple = createripple()\nconst count = ripple.signal(0)\nconst doubled = ripple.computed(() => count.value * 2)\n\nconst stop = ripple.effect(() => {\n console.log({ count: count.value, doubled: doubled.value })\n})\n\ncount.value = 1\ncount.value = 2\n\nstop.dispose()\nripple.dispose()"
895
+ },
896
+ {
897
+ "id": "batch-untrack",
898
+ "text": "batch & untrack import { createripple } from '@vielzeug/ripple'\n\n// batch coalesces updates; untrack reads current state without subscribing.\nconst ripple = createripple()\nconst first = ripple.signal('ada')\nconst last = ripple.signal('lovelace')\nconst locale = ripple.signal('en us')\n\nconst stop = ripple.effect(() => {\n const name = first.value + ' ' + last.value\n const currentlocale = ripple.untrack(() => locale.value)\n console.log({ name, currentlocale })\n})\n\nripple.batch(() => {\n first.value = 'grace'\n last.value = 'hopper'\n})\nlocale.value = 'de de'\n\nstop.dispose()\nripple.dispose()"
899
+ },
900
+ {
901
+ "id": "effect-options",
902
+ "text": "microtask effect import { createripple } from '@vielzeug/ripple'\n\n// microtask effects coalesce writes until current task ends.\nconst ripple = createripple()\nconst count = ripple.signal(0)\nconst stop = ripple.effect(\n () => console.log('count:', count.value),\n { name: 'count logger', scheduler: 'microtask' },\n)\n\ncount.value = 1\ncount.value = 2\ncount.value = 3\nconsole.log('writes complete')\n\nqueuemicrotask(() => {\n stop.dispose()\n ripple.dispose()\n})"
903
+ },
904
+ {
905
+ "id": "scope-ownership",
906
+ "text": "nested effect ownership import { createripple } from '@vielzeug/ripple'\n\n// nested work automatically belongs to parent effect run.\nconst ripple = createripple()\nconst enabled = ripple.signal(true)\nconst count = ripple.signal(0)\n\nconst stop = ripple.effect(() => {\n if (!enabled.value) return\n\n ripple.effect(() => console.log('nested count:', count.value))\n})\n\ncount.value = 1\nenabled.value = false\ncount.value = 2\n\nstop.dispose()\nripple.dispose()"
907
+ },
908
+ {
909
+ "id": "store-basics",
910
+ "text": "immutable state import { createripple } from '@vielzeug/ripple'\n\n// signal.update keeps immutable object updates explicit.\nconst ripple = createripple()\nconst user = ripple.signal({ name: 'ada', visits: 0 })\nconst greeting = ripple.computed(() => user.value.name + ': ' + user.value.visits)\n\nconst stop = ripple.effect(() => console.log(greeting.value))\n\nuser.update((state) => ({ ...state, visits: state.visits + 1 }))\nuser.value = { name: 'grace', visits: 5 }\n\nstop.dispose()\nripple.dispose()"
911
+ },
912
+ {
913
+ "id": "watch-selected-value",
914
+ "text": "watch selected value import { createripple } from '@vielzeug/ripple'\n\n// watch receives selected value transitions, not every graph update.\nconst ripple = createripple()\nconst first = ripple.signal('ada')\nconst last = ripple.signal('lovelace')\nconst fullname = ripple.computed(() => first.value + ' ' + last.value)\n\nconst stop = ripple.watch(fullname, (value, previous) => {\n console.log({ previous, value })\n}, { immediate: true })\n\nfirst.value = 'grace'\nlast.value = 'hopper'\n\nstop.dispose()\nripple.dispose()"
915
+ }
916
+ ],
917
+ "exports": "createripple signal computed effect batch createscope untrack watch resource isreactive",
918
+ "keywords": "reactive signals computed effects graph scope batch watch resource async",
919
+ "name": "@vielzeug/ripple",
920
+ "related": "ore clockwork ledger",
921
+ "slug": "ripple",
922
+ "source": "export type { asyncstate, resource, resourceoptions } from './_async';\nexport { createripple, type ripple } from './_default';\nexport type { watchoptions } from './_watch';\nexport {\n ripplecomputedcycleerror,\n rippledisposedruntimeerror,\n rippledisposedscopeerror,\n rippleerror,\n rippleinfinitelooperror,\n} from './errors';\nexport { isreactive } from './runtime';\nexport type {\n cleanup,\n computedoptions,\n disposable,\n effecthandle,\n effectoptions,\n equality,\n reactiveerrorcontext,\n reactiveevent,\n reactiveobserver,\n readable,\n rippleoptions,\n scope,\n signal,\n signaloptions,\n unsubscribe,\n} from './types';\n\nimport { defaultripple } from './_default';\n\nexport const signal = defaultripple.signal;\nexport const computed = defaultripple.computed;\nexport const effect = defaultripple.effect;\nexport const batch = defaultripple.batch;\nexport const createscope = defaultripple.createscope;\nexport const untrack = defaultripple.untrack;\nexport const watch = defaultripple.watch;\nexport const resource = defaultripple.resource;\n"
923
+ },
924
+ {
925
+ "category": "logging",
926
+ "description": "browser/node logger with levels, namespaces, pluggable transports, lazy bindings, and timing helpers.",
927
+ "docs": {
928
+ "index": " \ntitle: rune — structured logging for typescript\ndescription: browser/node logger with levels, namespaces, pluggable transports, lazy bindings, and timing helpers.\npackage: rune\ncategory: logging\nkeywords: [logging, console, structured, scoped, transports, remote logging, levels, namespaces, lazy bindings]\nrelated: [courier, herald, familiar]\nexports:\n [\n createlogger,\n defaultlogger,\n consoletransport,\n remotetransport,\n jsontransport,\n batchtransport,\n sampletransport,\n redacttransport,\n pipe,\n lazy,\n islevelenabled,\n resolvetheme,\n default_theme,\n priority,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"rune\" />\n\n## why rune?\n\nplain `console.log` lacks structure: no log levels, no namespacing, no remote delivery, no way to silence logs in production.\n\n```ts\n// before — manual approach\nconst path = '/users';\nconsole.log(`[api] get ${path}`);\nfetch('/api/logs', { body: json.stringify({ level: 'error', path }), method: 'post' });\n\n// after — rune\nimport { consoletransport, createlogger, remotetransport } from '@vielzeug/rune';\n\nconst api = createlogger({\n namespace: 'api',\n transports: [\n consoletransport({ level: 'debug' }),\n remotetransport({\n handler: (_type, data) => console.debug('remote log', data),\n level: 'error',\n }),\n ],\n});\n\napi.info({ method: 'get', path }, 'request');\n```\n\n| feature | rune | winston | pino | console |\n| | | | | |\n| bundle size | <packageinfo package=\"rune\" type=\"size\" /> | ~44 kb | ~4 kb | 0 kb |\n| browser support | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| scoped loggers | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | child | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| pluggable transports | <ore icon name=\"check\" size=\"16\"></ore icon> built in factories | <ore icon name=\"check\" size=\"16\"></ore icon> transports | <ore icon name=\"check\" size=\"16\"></ore icon> streams | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| structured log entry | <ore icon name=\"check\" size=\"16\"></ore icon> `logentry` type | partial | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| lazy bindings | <ore icon name=\"check\" size=\"16\"></ore icon> `lazy(fn)` | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| styled output | <ore icon name=\"check\" size=\"16\"></ore icon> css badges | text only | text only | manual |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> (15+) | <ore icon name=\"x\" size=\"16\"></ore icon> (5+) | n/a |\n\n<div class=\"decision callout\">\n\n**use rune when** you need isomorphic logging (browser + node.js), namespaced module loggers, or remote error delivery without a heavy dependency chain.\n\n**consider alternatives when** you need high throughput file based logging (pino), file rotation (winston), or your team already uses a logging framework.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/rune\n```\n\n```sh [npm]\nnpm install @vielzeug/rune\n```\n\n```sh [yarn]\nyarn add @vielzeug/rune\n```\n\n:::\n\n## quick start\n\n```ts\nimport { batchtransport, consoletransport, createlogger, lazy, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n loglevel: 'debug',\n namespace: 'server',\n transports: [\n consoletransport({ timestamp: true }),\n remotetransport({\n handler: (_type, data) => console.debug('remote log', data),\n level: 'error',\n }),\n ],\n});\n\nconst requestlog = log.withbindings({\n diagnostics: lazy(() => ({ queuedepth: 0 })),\n requestid: 'abc 123',\n});\n\nrequestlog.info({ method: 'get', path: '/users' }, 'request');\nconst users = await requestlog.time('load users', () => promise.resolve(['user 1']));\nconsole.log(users);\n\nconst batch = batchtransport({ onflush: (entries) => console.debug('batch', entries) });\nconst bufferedlog = createlogger({ transports: [batch.transport] });\n\nbufferedlog.info('queued for delivery');\nawait batch.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n level filtering (`debug` to `off`) with `enabled()` checks, including `fatal` above `error`\n immutable config after construction — use `child()` or `withbindings()` to scope\n three call forms: `log.info('msg')`, `log.error(err, { id }, 'msg')` (error first), or `log.info({ key: 'val' }, 'msg')` — error first form auto serializes to `data.err`\n `error` values in context fields are also auto serialized to `{ message, name, stack }` — survives json.stringify\n pinned context bindings via `withbindings({ requestid })` — fields on every line\n lazy bindings via `lazy(fn)` — expensive computations gated behind the level check\n namespaced child loggers via `createlogger('name')` or `logger.child({ namespace })`\n middleware pipeline via `use(fn)` — transform or filter entries before transport dispatch\n pluggable transport pipeline: `consoletransport`, `remotetransport`, `jsontransport`, `batchtransport`, `sampletransport`, `redacttransport`\n fan out via `pipe()` — dispatch to multiple transports independently, fault tolerant\n structured `time()` wrapper: emits the label as message with `{ duration_ms }` in context\n `group()` and `groupcollapsed()` wrappers that auto close on throw/reject\n `logentry.data` — single merged flat object for transports; no manual merging needed\n zero dependencies — <packageinfo package=\"rune\" type=\"size\" /> gzipped\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 [courier](/courier/) — http client with built in request/response interception; pipe rune as a transport to log every api call with structured context\n [herald](/herald/) — typed event bus; emit log level change or flush events across modules without coupling loggers directly\n [familiar](/familiar/) — web worker pool; use rune inside task functions to surface structured worker side logs back to the main thread\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
929
+ "api": " \ntitle: rune — api reference\ndescription: api reference for @vielzeug/rune exports, logger methods, configuration types, and transport factories.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlogger()` | create an isolated `logger` instance | sync | omitting `transports` defaults to `consoletransport()` |\n| `defaultlogger` | pre created default logger singleton | — | shared instance — use `child()` or `withbindings()` to scope |\n| `lazy(fn)` | defer a binding value past the level check | sync | factory runs on every emit, not once |\n| `pipe()` | fan out dispatcher to multiple transports | sync | errors in one transport don't propagate to others |\n| `islevelenabled()` | utility: test whether a level passes a threshold | sync | `'off'` always returns `false` |\n| `priority` | numeric priority table backing `islevelenabled()`| — | lower number = more verbose |\n| `resolvetheme()` | merge a partial theme onto the default | sync | returns a fully populated `resolvedtheme` |\n| `consoletransport()` | styled console output | sync | theme is resolved once at factory call, not per entry |\n| `remotetransport()` | async http/webhook delivery | async | handler errors are swallowed to `console.warn` |\n| `jsontransport()` | ndjson to stdout or a custom sink | sync | `process.stdout` is unavailable in browsers |\n| `batchtransport()` | buffered batch delivery with flush interval | async | await `.dispose()` and handle rejected delivery |\n| `sampletransport()` | probabilistic entry forwarding | sync | `rate: 1` forwards all entries; `rate: 0` forwards none |\n| `redacttransport()` | sensitive field stripping before forwarding | sync | place this closest to the remote transport, not console |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/rune` | all exports — logger, transport factories, `lazy`, types |\n\n## createlogger(initial?, options?)\n\ncreates an isolated logger instance.\n\n```ts\ncreatelogger(namespace: string, options?: omit<runeoptions, 'namespace'>): logger\ncreatelogger(options?: runeoptions): logger\n```\n\n `string` shorthand sets namespace: `createlogger('api')` or `createlogger('api', { loglevel: 'warn' })`.\n each call produces a fully independent instance — no shared mutable state.\n default transport is `consoletransport()` when `transports` is omitted.\n\n> **note — disposed loggers:** after `dispose()` is called, all log methods (`debug`, `info`, `warn`, `error`, `fatal`), `time()`, and `group()` / `groupcollapsed()` silently no op. the `fn` callback in `group()` still runs — only the group header is suppressed.\n\n> **note — transport/middleware fault isolation:** if a transport or middleware function throws, the logger catches it, reports it via a dev only warning, and continues — a single misbehaving transport can never crash the caller of `log.info()`/etc., and sibling transports still receive the entry. a throwing middleware drops just that one entry.\n\n**returns:** `logger`\n\n**example:**\n\n```ts\nimport { createlogger } from '@vielzeug/rune';\nimport { consoletransport, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({ loglevel: 'warn', namespace: 'app' });\n\nconst serverlog = createlogger({\n namespace: 'server',\n transports: [\n consoletransport(),\n remotetransport({\n handler: async (_type, data) => {\n await fetch('/api/logs', { body: json.stringify(data), method: 'post' });\n },\n level: 'error',\n }),\n ],\n});\n```\n\n## defaultlogger\n\n`defaultlogger` is the pre created default logger (`createlogger()` called once at module load).\n\nuse it as a quick start singleton or create a child for module level use:\n\n```ts\nimport { defaultlogger } from '@vielzeug/rune';\n\nconst log = defaultlogger.child({ namespace: 'app.worker' });\n```\n\n## lazy(fn)\n\ndefers evaluation of an expensive binding value until after the level check passes.\nthe factory function is never called when the log level suppresses the entry.\n\n```ts\nlazy(fn: () => unknown): lazybinding\n```\n\n```ts\nimport { lazy } from '@vielzeug/rune';\n\nconst reqlog = log.withbindings({\n diagnostics: lazy(() => buildexpensivediagnostics()),\n});\n\nreqlog.debug('trace'); // diagnostics() only called when debug is enabled\n```\n\n**returns:** `lazybinding`\n\n## logger methods\n\n### logging\n\nall five methods share the same signature:\n\n```ts\nlog.debug / info / warn / error / fatal(message: string): void\nlog.debug / info / warn / error / fatal(error: error, message?: string): void\nlog.debug / info / warn / error / fatal(error: error, context: bindings, message?: string): void\nlog.debug / info / warn / error / fatal(context: bindings, message?: string): void\n```\n\nargument rules:\n\n string only calls accept a single message argument.\n **error first form:** pass an `error` as the first argument — it is auto serialized to `{ message, name, stack }` under the `err` key. optionally follow with a `bindings` object and/or a message string.\n context object comes first when providing structured data without a top level error. `error` values inside the context object are also auto serialized to `{ message, name, stack }`.\n\n```ts\nlog.error(err, 'request failed'); // err auto serialized to data.err\nlog.error(err, { requestid }, 'request failed'); // err + context + message\nlog.error({ err: new error('boom') }, 'failed'); // error nested in context object\n```\n\n### composition\n\n| method | returns | what it does |\n| | | |\n| `child(overrides?)` | `logger` | clones config, applies overrides, inherits bindings |\n| `withbindings(fields)` | `logger` | pins fields to every subsequent call, returns a new child logger |\n| `use(middleware)` | `logger` | appends a middleware function to the pipeline, returns new logger |\n\n`child()` transport inheritance:\n\n omit `transports` → inherit parent transports (default).\n pass `transports: []` → disable all transports on the child.\n pass `transports: [...]` → replace entirely with the given list.\n\n`child()` namespace joining:\n\n `parent.child({ namespace: 'auth' })` on a logger with namespace `'api'` produces `'api.auth'`.\n omit `namespace` → inherits parent namespace unchanged.\n\n### utilities\n\n| method | returns | description |\n| | | |\n| `enabled(level)` | `boolean` | true if entries at this level pass the configured threshold |\n| `time(label, fn, level?)` | `t` | measures sync/async execution; emits at `level` (default `'debug'`), label as message, `{ duration_ms }` in `data`. when `fn` throws or rejects, `{ err }` is also included. |\n| `group(label, fn, level?)` | `t` | wraps callback in `console.group`; closes even on throw/reject. pass `level` to gate the group header on the configured threshold (e.g. `'debug'` suppresses when `loglevel` is `'warn'`). |\n| `groupcollapsed(label, fn, level?)` | `t` | same as `group`, using `console.groupcollapsed`. |\n| `dispose()` | `void` | silences all subsequent log calls on this logger instance. does **not** auto dispose batch transports — hold a reference and call `batchtransport.dispose()` on shutdown. idempotent. |\n\n### properties\n\n| property | type | description |\n| | | |\n| `loglevel` | `loglevel` | active log level threshold |\n| `namespace` | `string` | effective namespace string |\n| `middleware` | `readonly logmiddleware[]` | middleware pipeline snapshot |\n| `transports` | `readonly transport[]` | transport pipeline snapshot |\n| `bindings` | `readonly<bindings>` | snapshot of currently pinned fields |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called. use to tie external lifetimes. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called |\n| `[symbol.dispose]` | `() => void` | delegates to `dispose()`. enables `using` declarations. |\n\n## transport factories\n\n### consoletransport(options?)\n\n```ts\nconsoletransport(options?: consoletransportoptions): transport\n```\n\nwrites styled output to the browser console (css badges) or node terminal (plain text). this is the default transport.\n\n| option | type | default | description |\n| | | | |\n| `level` | `loglevel` | `'debug'` | minimum level to output |\n| `timestamp` | `boolean` | `true` | include `hh:mm:ss.mmm` |\n| `ansi` | `boolean` | auto | force ansi color codes on/off (node only) |\n| `format` | `'json' \\| 'raw'` | `'raw'` | context serialization: `'json'` uses json.stringify |\n| `inspectfn` | `(v: unknown) => string` | — | custom object formatter (e.g. `util.inspect`) |\n| `theme` | `consoletheme` | — | override default badge colours for this transport |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { consoletransport, createlogger } from '@vielzeug/rune';\nimport { inspect } from 'node:util';\n\nconst log = createlogger({\n transports: [consoletransport({ level: 'info', timestamp: true, inspectfn: inspect })],\n});\n```\n\n### remotetransport(options)\n\n```ts\nremotetransport(options: remotetransportoptions): transport\n```\n\nforwards entries asynchronously to a remote handler. fire and forget — handler errors are swallowed to `console.warn` and never propagate to the caller.\n\n| option | type | default | description |\n| | | | |\n| `handler` | `(type: logtype, data: remotelogdata) => void` | — | required. receives each forwarded entry |\n| `level` | `loglevel` | `'debug'` | minimum level to forward |\n| `env` | `'production' \\| 'development'` | auto detected | override the runtime environment marker |\n| `onerror` | `(error: unknown, data: remotelogdata) => void` | — | called when the handler throws or rejects. default: a dev only `console.warn`. silent in production — provide an explicit handler for production observability. |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { createlogger, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n transports: [\n remotetransport({\n handler: async (_type, data) => {\n await fetch('/api/logs', { body: json.stringify(data), method: 'post' });\n },\n level: 'error',\n }),\n ],\n});\n```\n\n### jsontransport(options?)\n\n```ts\njsontransport(options?: jsontransportoptions): transport\n```\n\noutputs newline delimited json (ndjson) to `stdout` or a custom function. useful for server side log aggregation pipelines (elk, datadog, etc.).\n\neach line is a flat json object with `level`, `time` (iso), and optional `ns`, `msg`, plus all merged context fields.\n\n| option | type | default | description |\n| | | | |\n| `level` | `loglevel` | `'debug'` | minimum level |\n| `output` | `(line: string) => void` | `process.stdout` | custom output sink |\n| `safe` | `boolean` | `false` | replace circular references with `'[circular]'` instead of throwing |\n| `fields` | `{ level?, msg?, ns?, time? }` | — | custom output field names for aggregator compatibility (e.g. `'severity'` for datadog) |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { createlogger, jsontransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n namespace: 'api',\n transports: [jsontransport({ level: 'info' })],\n});\n\nlog.info({ path: '/users', status: 200 }, 'request');\n// {\"path\":\"/users\",\"status\":200,\"level\":\"info\",\"time\":\"2026 05 30t...\",\"ns\":\"api\",\"msg\":\"request\"}\n```\n\n### batchtransport(options)\n\n```ts\nbatchtransport(options: batchtransportoptions): batchhandle\n```\n\nbuffers entries and delivers them in order. flushes when the buffer reaches `maxsize` or after `interval` elapses; `flush()` and `dispose()` wait for accepted batch delivery.\n\n| option | type | default | description |\n| | | | |\n| `onflush` | `(entries: logentry[]) => void \\| promise<void>` | — | required. receives each batch; implement retry here when successful retry must fulfill drain |\n| `onflusherror` | `(entries: logentry[], error: unknown) => void` | — | observes delivery failure; matching `flush()` or later `dispose()` rejects |\n| `level` | `loglevel` | `'debug'` | minimum level to buffer |\n| `interval` | `number` | `5000` | finite interval in milliseconds greater than zero |\n| `maxsize` | `number` | `50` | finite positive integer batch size before early flush |\n| `maxbuffer` | `number` | unbounded | finite non negative integer hard cap; oldest entries drop when exceeded |\n\nreturns a `batchhandle` with:\n\n `.transport` — the `transport` function to pass to `createlogger({ transports: [handle.transport] })`.\n `.flush()` — immediately send buffered entries and resolve after delivery; rejects when delivery fails.\n `.dispose()` — stop the interval, reject new entries, and settle after every accepted batch completes. rejects if any automatic or final delivery fails. idempotent.\n `.disposed` — `true` when disposal starts.\n `[symbol.asyncdispose]()` — delegates to `.dispose()`. enables `await using` declarations.\n\nafter `dispose()`, the transport becomes inert: new entries are silently dropped.\n\n**returns:** `batchhandle`\n\n**example:**\n\n```ts\nimport { batchtransport, createlogger } from '@vielzeug/rune';\n\nconst batch = batchtransport({\n interval: 10_000,\n maxsize: 100,\n onflush: (entries) => console.debug('batch', entries),\n});\n\nconst log = createlogger({ transports: [batch.transport] });\n\nasync function shutdown() {\n await batch.dispose();\n}\n```\n\n### sampletransport(options)\n\n```ts\nsampletransport(options: sampletransportoptions): transport\n```\n\nprobabilistically forwards entries to a downstream transport.\n\n| option | type | default | description |\n| | | | |\n| `rate` | `number` | — | required finite fraction of entries to forward (0–1) |\n| `transport` | `transport` | — | required. downstream transport |\n| `level` | `loglevel` | `'debug'` | minimum level to sample |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { createlogger, remotetransport, sampletransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n transports: [\n sampletransport({\n rate: 0.1,\n transport: remotetransport({ handler: (_type, data) => console.debug('sampled log', data) }),\n }),\n ],\n});\n```\n\n### redacttransport(options)\n\n```ts\nredacttransport(options: redacttransportoptions): transport\n```\n\nstrips sensitive fields from `bindings` and `context` before forwarding. redaction is applied recursively at any depth (up to 20 levels).\n\n::: warning key matching\n`keys` matches **exact field names** at any nesting depth. dot path notation (e.g. `'user.password'`) is **not** supported — use `'password'` to redact every field named `password` regardless of nesting.\n:::\n\n| option | type | default | description |\n| | | | |\n| `keys` | `string[]` | — | required. field names to redact |\n| `maxdepth` | `number` | `20` | finite non negative integer max nesting depth. fields deeper than this are not redacted. |\n| `replacement` | `string` | `'[redacted]'` | replacement value |\n| `transport` | `transport` | — | required. downstream transport |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { createlogger, redacttransport, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n transports: [\n redacttransport({\n keys: ['password', 'token', 'ssn'],\n transport: remotetransport({ handler: (_type, data) => console.debug('redacted log', data) }),\n }),\n ],\n});\n```\n\n### pipe(...transports) / pipe(options, ...transports)\n\n```ts\npipe(...transports: transport[]): transport\npipe(options: pipeoptions, ...transports: transport[]): transport\n```\n\ndispatches each `logentry` to every transport in the list independently. an error thrown by one transport does not stop the others. use in place of separate array entries when you want fault isolation or a shared error observer.\n\n`pipe()` with no arguments creates a valid no op transport — useful for conditional pipeline construction: `pipe(condition ? remotetransport(opts) : undefined!)` pattern, or simply as a placeholder during development.\n\n| option | type | description |\n| | | |\n| `onerror` | `(error: unknown, entry: logentry) => void` | called with the error and entry when any transport throws |\n\n**returns:** `transport`\n\n**example:**\n\n```ts\nimport { consoletransport, createlogger, pipe, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n transports: [\n pipe(\n { onerror: (error) => console.warn('transport error', error) },\n consoletransport(),\n remotetransport({\n handler: (_type, data) => console.debug('remote log', data),\n level: 'error',\n }),\n ),\n ],\n});\n```\n\n````\n\n## utilities\n\n### islevelenabled(threshold, level)\n\n```ts\nislevelenabled(threshold: loglevel, level: loglevel): boolean\n````\n\nreturns `true` when `level` is at or above `threshold`. always returns `false` when `level` is `'off'`. useful for building custom transports that respect level filtering.\n\n```ts\nimport { islevelenabled } from '@vielzeug/rune';\n\nislevelenabled('warn', 'error'); // true\nislevelenabled('warn', 'info'); // false\nislevelenabled('debug', 'off'); // false\n```\n\n### resolvetheme(override?)\n\n```ts\nresolvetheme(override: consoletheme | undefined): resolvedtheme\n```\n\ndeep merges a partial `consoletheme` override onto `default_theme`. returns a fully populated `resolvedtheme` where every level and every field is present. used internally by `consoletransport()` — call directly when building a custom transport that needs to honour theme overrides.\n\n```ts\nimport { resolvetheme } from '@vielzeug/rune';\n\nconst theme = resolvetheme({ warn: { badge: '⚡' } });\n// theme.warn.badge === '⚡', theme.warn.bg === default_theme.warn.bg (unchanged)\n```\n\n### default_theme\n\nthe built in badge and namespace colour definitions used by `consoletransport()`. override per transport via `consoletransportoptions.theme`.\n\n### priority\n\n```ts\npriority: record<loglevel, number>\n```\n\nnumeric priority for each level (`debug: 0`, `info: 1`, `warn: 2`, `error: 3`, `fatal: 4`, `off: 5`) — lower is more verbose. exported for transport/middleware authors building custom level comparison logic; `islevelenabled()` is built directly on top of it.\n\n## types\n\n### logtype\n\n`'debug' | 'error' | 'fatal' | 'info' | 'warn'`\n\n### loglevel\n\n`logtype | 'off'` — threshold order: `debug < info < warn < error < fatal < off`\n\n### bindings\n\n`record<string, unknown>` — key value context pinned via `withbindings()` or passed per call.\n\n### logentry\n\nthe structured record produced by every log call and dispatched to all transports.\n\n| field | type | description |\n| | | |\n| `data` | `readonly<bindings>` | merged result of pinned bindings and per call context — already resolved |\n| `level` | `logtype` | log level |\n| `message` | `string?` | log message |\n| `namespace` | `string` | effective namespace at time of call |\n| `timestamp` | `date` | exact moment of the call, shared across transports |\n\n### transport\n\n```ts\ntype transport = (entry: logentry) => void;\n```\n\nreceives every `logentry` that passes the logger's level threshold. responsible for its own formatting, delivery, and per transport level filtering.\n\n### remotelogdata\n\npayload shape delivered to `remotetransportoptions.handler`:\n\n| field | type | description |\n| | | |\n| `data` | `bindings?` | merged structured data (omitted if empty) |\n| `env` | `'production' \\| 'development'` | runtime env marker |\n| `level` | `logtype` | log level |\n| `message` | `string?` | log message |\n| `namespace` | `string?` | effective namespace |\n| `timestamp` | `string` | full iso timestamp |\n\n### pipeoptions\n\n| field | type | description |\n| | | |\n| `onerror` | `(error: unknown, entry: logentry) => void` | called when a transport in the pipe throws or rejects |\n\n### consolethemeentry\n\n```ts\ntype consolethemeentry = {\n badge: string;\n bg: string;\n border: string;\n color: string;\n};\n```\n\nper level style definition for the console transport. all fields are optional when providing a level override — unspecified fields fall back to the default theme.\n\n### consoletheme\n\n```ts\ntype consoletheme = partial<record<logtype | 'group' | 'ns', partial<consolethemeentry>>>;\n```\n\npartial theme overrides merged on top of the default theme. each level entry is also partial — only specify the fields you want to change.\n\n### resolvedtheme\n\n`record<logtype | 'group' | 'ns', consolethemeentry>` — fully resolved theme with all fields populated.\n\n### runeoptions\n\n| field | type | default | description |\n| | | | |\n| `loglevel` | `loglevel?` | `'debug'` | logger level threshold |\n| `namespace` | `string?` | `''` | namespace prefix |\n| `transports` | `transport[]?` | `[consoletransport()]` | transport pipeline |\n| `bindings` | `bindings?` | `{}` | initial pinned bindings |\n| `middleware` | `logmiddleware[]?` | `[]` | entry transform/filter chain |\n\n### logmethod\n\n```ts\ntype logmethod = {\n (message: string): void;\n (error: error, message?: string): void;\n (error: error, context: bindings, message?: string): void;\n (context: bindings, message?: string): void;\n};\n```\n\nevery log level method uses this signature. three call forms are supported:\n\n **string only:** `log.info('message')`\n **error first:** `log.error(err, { requestid }, 'failed')` — `error` is auto serialized to `{ message, name, stack }` under `data.err`. optionally follow with a `bindings` object and/or a message string.\n **context first:** `log.info({ key: 'value' }, 'message')` — structured context object, optional message. `error` values nested inside the context are also auto serialized.\n\n### logmiddleware\n\n```ts\ntype logmiddleware = (entry: logentry) => logentry | null;\n```\n\nmiddleware functions intercept entries before they reach transports. return the (optionally mutated) entry to continue, or return `null` to drop the entry. added via `use(fn)` or `runeoptions.middleware`.\n\n### lazybinding\n\nopaque type returned by `lazy()`. pass as a value inside `withbindings()`. the factory is only called when the entry is actually emitted (after the level check passes).\n\n### batchhandle\n\n```ts\ntype batchhandle = {\n [symbol.asyncdispose]: () => promise<void>;\n dispose: () => promise<void>;\n readonly disposed: boolean;\n flush: () => promise<void>;\n transport: transport;\n};\n```\n\nreturned by `batchtransport()`. pass `handle.transport` to `createlogger({ transports })`; await `handle.dispose()` during graceful shutdown. `disposed` is `true` when disposal starts.\n\n### logger\n\nthe full interface returned by `createlogger()` and `defaultlogger`:\n\n```ts\ntype logger = {\n [symbol.dispose]: () => void;\n readonly bindings: readonly<bindings>;\n child: (overrides?: runeoptions) => logger;\n debug: logmethod;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n enabled: (type: loglevel) => boolean;\n error: logmethod;\n fatal: logmethod;\n group: <t>(label: string, fn: () => t, level?: logtype) => t;\n groupcollapsed: <t>(label: string, fn: () => t, level?: logtype) => t;\n info: logmethod;\n readonly loglevel: loglevel;\n readonly middleware: readonly logmiddleware[];\n readonly namespace: string;\n time: <t>(label: string, fn: () => t, level?: logtype) => t;\n readonly transports: readonly transport[];\n use: (middleware: logmiddleware) => logger;\n warn: logmethod;\n /** returns a new child logger with additional pinned bindings. the returned logger is fully independent — disposing it does not affect the parent, and vice versa. */\n withbindings: (bindings: bindings) => logger;\n};\n```\n\n### consoletransportoptions\n\n| field | type | default | description |\n| | | | |\n| `level` | `loglevel` | `'debug'` | minimum level to output |\n| `timestamp` | `boolean` | `true` | include `hh:mm:ss.mmm` |\n| `ansi` | `boolean` | auto | force ansi color codes on/off (node only) |\n| `format` | `'json' \\| 'raw'` | `'raw'` | context serialization: `'json'` uses json.stringify |\n| `inspectfn` | `(v: unknown) => string` | — | custom object formatter (e.g. `util.inspect`) |\n| `theme` | `consoletheme` | — | override default badge colours for this transport |\n\n### remotetransportoptions\n\n| field | type | default | description |\n| | | | |\n| `handler` | `(type: logtype, data: remotelogdata) => void` | — | required. receives each forwarded entry |\n| `level` | `loglevel` | `'debug'` | minimum level to forward |\n| `env` | `'production' \\| 'development'` | auto detected | override the runtime environment marker |\n| `onerror` | `(error: unknown, data: remotelogdata) => void` | — | called when the handler throws |\n\n### jsontransportoptions\n\n| field | type | default | description |\n| | | | |\n| `level` | `loglevel` | `'debug'` | minimum level |\n| `output` | `(line: string) => void` | `process.stdout` | custom output sink |\n| `safe` | `boolean` | `false` | replace circular references with `'[circular]'` instead of throwing |\n| `fields` | `{ level?, msg?, ns?, time? }` | — | custom output field names (e.g. `level: 'severity'` for datadog) |\n\n### batchtransportoptions\n\n| field | type | default | description |\n| | | | |\n| `onflush` | `(entries: logentry[]) => void \\| promise<void>` | — | required. receives each batch (may be async) |\n| `onflusherror` | `(entries: logentry[], error: unknown) => void` | — | observes delivery failure; matching `flush()` or later `dispose()` rejects |\n| `level` | `loglevel` | `'debug'` | minimum level to buffer |\n| `interval` | `number` | `5000` | finite interval in milliseconds greater than zero |\n| `maxsize` | `number` | `50` | finite positive integer batch size before early flush |\n| `maxbuffer` | `number` | unbounded | finite non negative integer cap; drops oldest entries when exceeded |\n\n### sampletransportoptions\n\n| field | type | default | description |\n| | | | |\n| `rate` | `number` | — | required finite fraction of entries to forward (0–1) |\n| `transport` | `transport` | — | required. downstream transport |\n| `level` | `loglevel` | `'debug'` | minimum level to sample |\n\n### redacttransportoptions\n\n| field | type | default | description |\n| | | | |\n| `keys` | `string[]` | — | required. field names to redact at any depth |\n| `maxdepth` | `number` | `20` | finite non negative integer nesting depth. fields deeper than this are not redacted — a dev only warning is emitted when hit. **security:** the warning is suppressed in production; ensure sensitive fields are not nested beyond this limit. |\n| `replacement` | `string` | `'[redacted]'` | replacement value |\n| `transport` | `transport` | — | required. downstream transport |\n",
930
+ "usage": " \ntitle: rune — usage guide\ndescription: configuration, transports, scoped loggers, lazy bindings, timers, groups, and best practices for rune.\n \n\n[[toc]]\n\n::: tip new to rune?\nstart with the [overview](./index.md), then use this page for detailed usage patterns.\n:::\n\n## basic usage\n\n`defaultlogger` is the default singleton logger instance. use `createlogger()` for isolated config.\n\n```ts\nimport { createlogger, defaultlogger } from '@vielzeug/rune';\n\nconst applog = defaultlogger;\nconst apilog = createlogger({ namespace: 'api' });\nconst authlog = createlogger('auth'); // shorthand namespace\n```\n\neach `createlogger()` call is fully independent with its own transport pipeline.\n\nthe two arg shorthand combines namespace and options cleanly:\n\n```ts\nconst log = createlogger('api', { loglevel: 'warn', transports: [transport] });\n```\n\n## transports\n\ntransports are the delivery layer. every `logentry` that passes the logger's level threshold is dispatched to each transport in order. transports handle their own formatting, level filtering, and delivery.\n\n```ts\nimport { consoletransport, createlogger, remotetransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n loglevel: 'debug',\n transports: [\n consoletransport({ timestamp: true }),\n remotetransport({\n handler: (_type, data) => console.debug('remote log', data),\n level: 'error',\n }),\n ],\n});\n```\n\nwhen `transports` is omitted, `consoletransport()` is used automatically.\n\n### built in transport factories\n\n| factory | use case |\n| | |\n| `consoletransport()` | styled console output (default) |\n| `remotetransport()` | http/webhook delivery |\n| `jsontransport()` | ndjson for server side log aggregation |\n| `batchtransport()` | buffered delivery to reduce i/o overhead |\n| `sampletransport()` | probabilistic volume reduction |\n| `redacttransport()` | sensitive field stripping before forwarding |\n| `pipe()` | fan out dispatcher to multiple transports |\n\n### composing transports\n\ntransport factories are composable wrappers. chain them to build a pipeline.\n\nwrap a downstream transport to redact fields, sample volume, and batch delivery:\n\n```ts\nimport { batchtransport, consoletransport, createlogger, redacttransport, sampletransport } from '@vielzeug/rune';\n\nconst batch = batchtransport({\n interval: 30_000,\n onflush: (entries) => console.debug('batch', entries),\n});\n\nconst log = createlogger({\n transports: [\n consoletransport({ level: 'debug' }),\n redacttransport({\n keys: ['password', 'token'],\n transport: sampletransport({\n rate: 0.1,\n transport: batch.transport,\n }),\n }),\n ],\n});\n\nawait batch.dispose();\n```\n\nuse `pipe()` when every downstream transport must receive an entry despite sibling transport failures:\n\n```ts\nimport { consoletransport, createlogger, pipe, remotetransport } from '@vielzeug/rune';\n\nconst fanout = pipe(\n { onerror: (error) => console.warn('transport error', error) },\n consoletransport(),\n remotetransport({\n handler: (_type, data) => console.debug('remote log', data),\n level: 'error',\n }),\n);\n\nconst log = createlogger({ transports: [fanout] });\n```\n\n### batch transport lifecycle\n\n`batchtransport` starts an interval timer on first use. await `.dispose()` during graceful application shutdown to stop the timer and finish delivery for every accepted batch:\n\n```ts\nimport { batchtransport, createlogger } from '@vielzeug/rune';\n\nconst batch = batchtransport({\n interval: 10_000,\n maxsize: 100,\n onflush: (entries) => console.debug('batch', entries),\n});\n\nconst log = createlogger({ transports: [batch.transport] });\n\nasync function shutdown() {\n try {\n await batch.dispose();\n } catch (error) {\n console.error('log delivery failed during shutdown', error);\n throw error;\n }\n}\n```\n\n`batchtransport.dispose()` is idempotent — repeated calls return the same drain promise and never double flush. it rejects when an accepted batch cannot deliver. `[symbol.asyncdispose]` is available for `await using` declarations. do not use a node `exit` handler: node cannot await asynchronous cleanup there.\n\n::: warning\n`log.dispose()` silences the logger but does not flush or stop batch transports. keep a direct batch reference and `await batch.dispose()` during shutdown.\n:::\n\n::: warning\nafter `log.dispose()`, the logger is silenced — all log calls (`debug`, `info`, `warn`, `error`, `fatal`, `time`, `group`) become no ops. the `fn` callback in `group()` still executes, but no group header is rendered. this is intentional to prevent logging after application teardown.\n:::\n\n### node.js: structured json logging\n\nfor server side log pipelines (elk, datadog, cloudwatch), `jsontransport` emits ndjson to stdout:\n\n```ts\nimport { jsontransport } from '@vielzeug/rune';\n\nconst log = createlogger({\n namespace: 'api',\n transports: [jsontransport({ level: 'info' })],\n});\n\nlog.info({ path: '/users', status: 200 }, 'request');\n// outputs: {\"level\":\"info\",\"time\":\"2026 05 30t...\",\"ns\":\"api\",\"path\":\"/users\",\"status\":200,\"msg\":\"request\"}\n```\n\n## configuration\n\nuse `child()` to derive immutable logger variants.\n\n```ts\nconst applog = defaultlogger.child({\n loglevel: 'warn',\n namespace: 'app',\n // transports inherited from defaultlogger by default\n // pass transports: [] to disable all, or transports: [...] to replace\n});\n\n// individual getters — no config snapshot\nconsole.log(applog.loglevel); // 'warn'\nconsole.log(applog.namespace); // 'app'\nconsole.log(applog.transports); // [...]\n```\n\nlevel threshold order: `debug` < `info` < `warn` < `error` < `fatal` < `off`\n\n## call signature\n\nall log methods share a consistent three form signature:\n\n```ts\nlog.info('message'); // string only\nlog.error(err, 'request failed'); // error first — auto serialized to data.err\nlog.error(err, { requestid }, 'request failed'); // error + context + message\nlog.info({ key: 'value' }, 'message'); // context object first, message second\nlog.error({ err: new error('boom') }, 'request failed'); // error nested in context — also auto serialized\n```\n\n **error first form:** pass an `error` as the first argument. it is automatically serialized to `{ message, name, stack }` under the `err` key in `data`. optionally follow with a `bindings` object and/or a message string. this is the idiomatic form when the error is the primary subject of the call.\n **context first form:** pass a plain object as the first argument. `error` values nested inside are also auto serialized. optionally follow with a message string.\n **string only form:** a single string message, no structured context.\n\nthe per call context is shallow merged with `withbindings()` bindings into `entry.data`.\n\n## logging methods\n\n```ts\ndefaultlogger.debug('debug details');\ndefaultlogger.info({ port: 3000 }, 'server started');\ndefaultlogger.warn('cache stale');\ndefaultlogger.error({ err: new error('timeout') }, 'request failed'); // error auto serialized in context\ndefaultlogger.fatal({ service: 'db' }, 'terminating'); // above error, use for unrecoverable state\n```\n\nuse `enabled()` to avoid expensive payload construction before the level check:\n\n```ts\nif (defaultlogger.enabled('debug')) {\n defaultlogger.debug({ diagnostics: buildlargepayload() }, 'diagnostics');\n}\n```\n\nor use `lazy()` to let rune gate it automatically:\n\n```ts\nconst reqlog = defaultlogger.withbindings({ diagnostics: lazy(() => buildlargepayload()) });\nreqlog.debug('diagnostics'); // buildlargepayload() only called when debug is enabled\n```\n\n## pinned bindings\n\n`withbindings(fields)` returns a child logger where the given fields are merged into every log call. this is the idiomatic way to attach per request or per user context.\n\n```ts\nconst api = defaultlogger.child({ namespace: 'api' });\n\nconst reqlog = api.withbindings({ requestid: 'abc 123', userid: 42 });\nreqlog.info('get /users'); // always includes requestid and userid\nreqlog.warn({ slow: true }, 'query took 2s'); // call site fields merged in\n```\n\nthe parent logger is not affected. bindings stack additively through chained `withbindings()` calls:\n\n```ts\nconst base = defaultlogger.withbindings({ service: 'api' });\nconst req = base.withbindings({ requestid: 'xyz' });\n// req emits both service and requestid on every call\n```\n\nthe `bindings` getter returns a defensive snapshot:\n\n```ts\nconsole.log(reqlog.bindings); // { requestid: 'abc 123', userid: 42 }\n```\n\n## lazy bindings\n\n`lazy(fn)` defers evaluation of a binding value until after the level check passes. the factory is never called when the entry would be suppressed.\n\n```ts\nimport { lazy } from '@vielzeug/rune';\n\nconst log = defaultlogger.withbindings({\n // only called when debug entries are emitted\n snapshot: lazy(() => json.stringify(getfullappstate())),\n // regular values are always included as is\n service: 'api',\n});\n\nlog.debug('state trace'); // snapshot() only called here\nlog.warn('cache miss'); // snapshot() not called — warn doesn't need it\n```\n\nlazy bindings are resolved on every emitted call, not cached:\n\n```ts\nconst counter = { n: 0 };\nconst log = defaultlogger.withbindings({ tick: lazy(() => ++counter.n) });\n\nlog.info('a'); // tick: 1\nlog.info('b'); // tick: 2\n```\n\n## child loggers\n\n`child(overrides?)` creates a new logger scoped to a namespace, level, or transport set. use it to create module level or service level loggers.\n\n```ts\nconst api = defaultlogger.child({ namespace: 'api' });\nconst auth = api.child({ namespace: 'auth' }); // → 'api.auth' (dot joined automatically)\n\napi.info('get /users');\nauth.warn('token expiring');\n```\n\n`child(overrides?)` clones current config and applies overrides. transports are inherited by default.\n\n```ts\nconst base = createlogger({ loglevel: 'info', namespace: 'app' });\nconst verbose = base.child({ loglevel: 'debug' }); // inherits transports\n\n// replace transports entirely on the child\nconst silent = base.child({ transports: [] }); // no output\n\n// override with a different transport set\nconst jsonchild = base.child({ transports: [jsontransport()] });\n```\n\nchild and parent configs remain independent after creation.\n\n## timing\n\n`time(label, fn, level?)` measures execution time of sync or async functions. emits a structured entry with `{ duration_ms }` in `data` and `label` as the message. when `fn` throws or rejects, the entry also includes `{ err }` with the serialized error.\n\n```ts\n// sync\nconst result = log.time('parse', () => parsedocument(input));\n// emits: { level: 'debug', message: 'parse', data: { duration_ms: 2.4 } }\n\n// async\nconst users = await log.time('db.users', () => db.query('select * from users'));\n// emits even on rejection, with { err } included in data\n\n// custom level\nlog.time('health check', () => ping(), 'info');\n\n// skipped when loglevel is 'off', but fn still executes\n```\n\nto forward timing data to a remote endpoint, include `remotetransport` in the pipeline — `debug` level entries will be forwarded at its threshold.\n\n## groups\n\n`group(label, fn, level?)` and `groupcollapsed(label, fn, level?)` wrap a callback in a console group, ensuring `groupend` is called even when the callback throws or rejects.\n\n```ts\nawait log.groupcollapsed('job', async () => {\n await log.time('process', () => runjob());\n log.info('done');\n});\n\n// gate the group header on a log level — suppresses when loglevel is above 'debug'\nlog.group(\n 'verbose trace',\n () => {\n log.debug('internal state', state);\n },\n 'debug',\n);\n```\n\nwhen `loglevel` is `'off'`, the group wrapper is bypassed but the callback still executes. when a `level` is provided and it is below the configured threshold, the group header is skipped but the callback still runs.\n\n## testing\n\nuse a test transport to assert log entries without mocking `console`. this approach is more robust and does not require spy cleanup:\n\n```ts\nimport { expect, it } from 'vitest';\nimport { createlogger } from '@vielzeug/rune';\nimport type { logentry, transport } from '@vielzeug/rune';\n\nfunction createtesttransport() {\n const entries: logentry[] = [];\n const transport: transport = (entry) => entries.push(entry);\n return { entries, transport };\n}\n\nit('logs errors when enabled', () => {\n const { entries, transport } = createtesttransport();\n const log = createlogger({ loglevel: 'error', transports: [transport] });\n\n log.error('boom');\n\n expect(entries).tohavelength(1);\n expect(entries[0].level).tobe('error');\n expect(entries[0].message).tobe('boom');\n});\n\nit('suppresses debug when loglevel is warn', () => {\n const { entries, transport } = createtesttransport();\n const log = createlogger({ loglevel: 'warn', transports: [transport] });\n\n log.debug('silent');\n log.warn('loud');\n\n expect(entries).tohavelength(1);\n});\n```\n\nyou can still spy on `console` methods when testing `consoletransport` output directly:\n\n```ts\nimport { aftereach, expect, it, vi } from 'vitest';\nimport { consoletransport, createlogger } from '@vielzeug/rune';\n\naftereach(() => vi.restoreallmocks());\n\nit('writes error to console.error', () => {\n const spy = vi.spyon(console, 'error').mockimplementation(() => {});\n const log = createlogger({ loglevel: 'error', transports: [consoletransport({ timestamp: false })] });\n\n log.error('boom');\n\n expect(spy).tohavebeencalled();\n});\n```\n\n## framework integration\n\nrune is framework agnostic and works as a module level singleton or a context injected instance.\n\n::: code group\n\n```tsx [react]\nimport { createcontext, usestate, usecontext } from 'react';\nimport { createlogger } from '@vielzeug/rune';\n\nconst logcontext = createcontext(createlogger({ namespace: 'app' }));\n\nfunction uselogger() {\n return usecontext(logcontext);\n}\n\nfunction app() {\n const [requestlogger] = usestate(() => createlogger({ namespace: 'app' }).withbindings({ userid: '42' }));\n return (\n <logcontext.provider value={requestlogger}>\n <dashboard />\n </logcontext.provider>\n );\n}\n\nfunction dashboard() {\n const log = uselogger();\n log.info('dashboard mounted');\n return <div>dashboard</div>;\n}\n```\n\n```ts [vue 3]\nimport { inject, provide } from 'vue';\nimport { createlogger, type logger } from '@vielzeug/rune';\n\nconst loggerkey = symbol('logger');\n\nfunction providelogger(namespace: string) {\n const logger = createlogger({ namespace });\n provide(loggerkey, logger);\n return logger;\n}\n\nfunction uselogger(): logger {\n const logger = inject<logger>(loggerkey);\n if (!logger) throw new error('logger not provided');\n return logger;\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { setcontext, getcontext } from 'svelte';\n import { createlogger } from '@vielzeug/rune';\n\n const logger = createlogger({ namespace: 'app' });\n setcontext('logger', logger);\n</script>\n\n<! child component >\n<script lang=\"ts\">\n import { getcontext } from 'svelte';\n import type { logger } from '@vielzeug/rune';\n\n const logger = getcontext<logger>('logger');\n logger.info('component mounted');\n</script>\n```\n\n:::\n\n### pitfalls\n\n **react:** creating the logger without a stable initializer recreates it on every re render. use `usestate(() => createlogger(...))`.\n **vue 3:** `inject()` must be called at the top level of `setup()`, not inside callbacks.\n **svelte:** `getcontext()` must be called synchronously during component initialization.\n\n## working with other vielzeug libraries\n\n### with courier\n\n```ts\nimport { createcourier, withlogging } from '@vielzeug/courier';\nimport { createlogger } from '@vielzeug/rune';\n\nconst log = createlogger({ namespace: 'courier' });\nconst courier = createcourier({ baseurl: 'https://api.example.com' });\ncourier.use(withlogging({ logger: (message, meta) => log.debug(meta, message) }));\n```\n\n### with herald\n\n```ts\nimport { createbus } from '@vielzeug/herald';\nimport { createlogger } from '@vielzeug/rune';\n\nconst log = createlogger({ namespace: 'bus' });\nconst bus = createbus<appevents>({\n ondispatch: (event, payload) => log.debug({ event, payload }, 'dispatched'),\n onerror: (err, event) => log.error(err, `handler error in \"${event}\"`),\n});\n```\n\n## best practices\n\n create one child logger per module boundary using `defaultlogger.child({ namespace: 'module.name' })` or `createlogger('module.name')`.\n use `withbindings()` to pin request/session context instead of repeating fields on each call.\n use `lazy()` for expensive diagnostics bindings only needed at `debug` level.\n set `loglevel` from environment (`'debug'` in dev, `'warn'` or `'error'` in prod).\n use `enabled()` before expensive payload construction that `lazy()` cannot defer.\n configure transports at the application root; pass scoped loggers via di or context.\n keep remote handlers resilient — network failures should not block app flow.\n await `batchtransport.dispose()` during graceful shutdown to drain remaining accepted entries.\n use `redacttransport` closest to any remote/persistent transport — never strip before console.\n to style console output, pass `consoletransport({ theme })` explicitly in `transports`.\n use `fatal()` only for genuinely unrecoverable states.\n",
931
+ "examples": " \ntitle: rune — examples\ndescription: practical examples and recipes for rune.\n \n\n## examples\n\n [module logger pattern](./examples/module logger pattern.md)\n [child logger overrides](./examples/child logger overrides.md)\n [production setup](./examples/production setup.md)\n [timing and grouping](./examples/timing and grouping.md)\n [react integration](./examples/react integration.md)\n [request middleware](./examples/request middleware.md)\n [testing](./examples/testing.md)\n"
932
+ },
933
+ "examples": [
934
+ {
935
+ "id": "basic-logging",
936
+ "text": "basic logging import { defaultlogger } from '@vielzeug/rune'\n\n// message only\ndefaultlogger.debug('app starting')\ndefaultlogger.info('ready')\n\n// context first — structured data before message\ndefaultlogger.info({ port: 3000 }, 'server listening')\ndefaultlogger.warn({ retries: 3 }, 'retrying request')\n\n// pass error as a context field — auto serialized to { message, name, stack }\nconst err = new error('connection refused')\ndefaultlogger.error({ err }, 'service unavailable')\ndefaultlogger.error({ err, requestid: 'r 001' }, 'request failed')\n\nconsole.log('(open devtools console to see styled output)')"
937
+ },
938
+ {
939
+ "id": "lazy-and-timing",
940
+ "text": "lazy bindings & timing import { createlogger, lazy } from '@vielzeug/rune'\n\nconst entries = []\nconst log = createlogger({ transports: [(e) => entries.push(e)] })\n\n// lazy() defers factory evaluation until after the level check\nlet callcount = 0\nconst reqlog = log.withbindings({\n snapshot: lazy(() => ({ n: ++callcount, size: 1024 })),\n})\n\nreqlog.debug('trace') // snapshot() called — callcount becomes 1\nreqlog.info('step 2') // snapshot() called — callcount becomes 2\n\n// suppress debug: lazy factory is never called\nconst quietlog = log.child({ loglevel: 'warn' })\nconst quietreq = quietlog.withbindings({ val: lazy(() => ++callcount) })\nquietreq.debug('not emitted') // factory skipped\n\nconsole.log('factory calls:', callcount) // 2, not 3\n\n// time() measures execution and emits { duration_ms } in data\nconst parsed = log.time('parse', () => json.parse('[1,2,3]'))\nconsole.log('parsed:', parsed)\nconsole.log('timer data:', entries[entries.length 1].data)\n\n// when the timed fn throws, { err } is also included in data\ntry {\n log.time('risky', () => { throw new error('oops') })\n} catch {}\nconsole.log('error data:', entries[entries.length 1].data)\n\n// dispose() marks the logger as disposed; subsequent calls become no ops\nlog.dispose()\nlog.info('silenced') // no op\nconsole.log('disposed:', log.disposed) // true"
941
+ },
942
+ {
943
+ "id": "level-filtering",
944
+ "text": "level filtering import { createlogger } from '@vielzeug/rune'\n\nconst entries = []\nconst log = createlogger({\n loglevel: 'debug',\n namespace: 'app',\n transports: [(e) => entries.push(e)],\n})\n\n// threshold order: debug < info < warn < error < fatal < off\nlog.debug('msg')\nlog.info('msg')\nlog.warn('msg')\nlog.error('msg')\nlog.fatal('msg')\nconsole.log('all levels:', entries.map((e) => e.level))\nentries.length = 0\n\n// child() with raised threshold — debug and info are suppressed\nconst prodlog = log.child({ loglevel: 'warn' })\nprodlog.debug('suppressed')\nprodlog.info('suppressed')\nprodlog.warn('passes')\nprodlog.error('passes')\nconsole.log('threshold warn:', entries.map((e) => e.level))\nentries.length = 0\n\n// individual getters expose config without a snapshot object\nconsole.log('log.loglevel:', log.loglevel) // 'debug'\nconsole.log('prodlog.loglevel:', prodlog.loglevel) // 'warn'\nconsole.log('log.namespace:', log.namespace) // 'app'\n\n// enabled() guards expensive payload construction\nconsole.log('debug enabled:', log.enabled('debug')) // true\nconsole.log('debug enabled (prod):', prodlog.enabled('debug')) // false"
945
+ },
946
+ {
947
+ "id": "lifecycle",
948
+ "text": "logger lifecycle & disposal import { batchtransport, createlogger } from '@vielzeug/rune';\n\n// two arg shorthand: namespace + options\nconst log = createlogger('api', { loglevel: 'debug' });\n\nlog.info('logger created');\nlog.debug({ url: '/health' }, 'request start');\n\n// disposed logger silences all subsequent calls\nlog.dispose();\nlog.info('this is silenced — no output');\n\nconsole.log('log.disposed:', log.disposed);\n\n// batchtransport idempotency — double dispose does not double flush\nconst flushed: string[] = [];\nconst batch = batchtransport({\n interval: 60_000,\n onflush: (entries) => {\n flushed.push(...entries.map((e) => e.message ?? ''));\n },\n});\n\nconst batchlog = createlogger('batch', { transports: [batch.transport] });\nbatchlog.info('entry 1');\nbatchlog.warn('entry 2');\n\nawait batch.dispose(); // flushes once and waits for delivery\nawait batch.dispose(); // same settled promise — no double flush\n\nconsole.log('flushed messages:', flushed);\n"
949
+ },
950
+ {
951
+ "id": "scoped-loggers",
952
+ "text": "scoped loggers import { defaultlogger } from '@vielzeug/rune'\n\n// child() dot joins namespaces automatically\nconst api = defaultlogger.child({ namespace: 'api' }) // 'api'\nconst auth = api.child({ namespace: 'auth' }) // 'api.auth'\nconst worker = api.child({ namespace: 'worker' }) // 'api.worker'\n\n// individual getters — no config snapshot\nconsole.log('root:', defaultlogger.namespace) // ''\nconsole.log('api:', api.namespace) // 'api'\nconsole.log('auth:', auth.namespace) // 'api.auth'\nconsole.log('worker:', worker.namespace) // 'api.worker'\n\n// withbindings() pins fields to every call\nconst reqlog = auth.withbindings({ requestid: 'r 001', userid: 'u 42' })\nreqlog.info('token check')\nreqlog.warn({ expired: false }, 'token refreshed')\n\nconsole.log('bindings snapshot:', reqlog.bindings)\n\n// enabled() checks whether a level passes the logger threshold\nconsole.log('debug enabled:', api.enabled('debug')) // true (default level is debug)\nconst warnlog = api.child({ loglevel: 'warn' })\nconsole.log('warnlog debug:', warnlog.enabled('debug')) // false\n\nconsole.log('(open devtools console to see styled output)')"
953
+ },
954
+ {
955
+ "id": "transport-pipeline",
956
+ "text": "transport pipeline import { createlogger } from '@vielzeug/rune'\n\n// a custom inline transport captures entries synchronously\nconst entries = []\nconst log = createlogger({\n loglevel: 'debug',\n namespace: 'app',\n transports: [(entry) => entries.push(entry)],\n})\n\nlog.info({ path: '/users', method: 'get' }, 'request')\nlog.warn('cache miss')\nlog.error({ err: new error('timeout') }, 'request failed')\n\n// inspect the structured logentry objects captured by the transport\nentries.foreach((e, i) => {\n console.log('entry ' + (i + 1) + ' [' + e.level + ']:', json.stringify({\n namespace: e.namespace,\n message: e.message,\n data: e.data,\n }))\n})"
957
+ }
958
+ ],
959
+ "exports": "createlogger defaultlogger consoletransport remotetransport jsontransport batchtransport sampletransport redacttransport pipe lazy islevelenabled resolvetheme default_theme priority",
960
+ "keywords": "logging console structured scoped transports remote logging levels namespaces lazy bindings",
961
+ "name": "@vielzeug/rune",
962
+ "related": "courier herald familiar",
963
+ "slug": "rune",
964
+ "source": "export type { consoletheme, consolethemeentry, consoletransportoptions, resolvedtheme } from './console';\nexport { consoletransport, default_theme, resolvetheme } from './console';\nexport type { lazybinding } from './lazy';\nexport { lazy } from './lazy';\nexport { createlogger, defaultlogger } from './logger';\nexport { batchtransport, jsontransport, pipe, redacttransport, remotetransport, sampletransport } from './transports';\nexport type {\n batchhandle,\n batchtransportoptions,\n bindings,\n jsontransportoptions,\n logentry,\n logger,\n loglevel,\n logmethod,\n logmiddleware,\n logtype,\n pipeoptions,\n redacttransportoptions,\n remotelogdata,\n remotetransportoptions,\n runeoptions,\n sampletransportoptions,\n transport,\n} from './types';\nexport { islevelenabled, priority } from './types';\n"
965
+ },
966
+ {
967
+ "category": "ui primitives",
968
+ "description": "isolated iframe runtime with a typed postmessage bridge for safe execution of untrusted html — component previews, playgrounds, plugin sandboxes, and more.",
969
+ "docs": {
970
+ "index": " \ntitle: sandbox — sandboxed iframe runtime\ndescription: isolated iframe runtime with a typed postmessage bridge for safe execution of untrusted html — component previews, playgrounds, plugin sandboxes, and more.\npackage: sandbox\ncategory: ui primitives\nkeywords: [sandbox, iframe, isolation, playground, csp, postmessage, security, components]\nexports:\n [\n createsandbox,\n buildcsp,\n builddocument,\n sandboxconfigurationerror,\n sandboxerror,\n sandboxtimeouterror,\n sandboxhandle,\n sandboxoptions,\n sandboxbridge,\n sandboxmessage,\n sandboxstateupdatedetail,\n unsubscribe,\n ]\nrelated: [codex, refine]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sandbox\" />\n\n## why sandbox?\n\nrunning untrusted html in the main window is unsafe — arbitrary code can access the dom, cookies, and user data. sandbox creates an isolated `<iframe sandbox=\"allow scripts\">` that receives content over a typed postmessage bridge. the sandbox cannot reach the host page.\n\n```ts\n// before\ncontainer.innerhtml = untrustedhtml;\n\n// after\nconst sandbox = createsandbox(container);\nawait sandbox.render(untrustedhtml);\n```\n\ncommon use cases:\n\n **component previews** — render isolated html/css examples in documentation or design tools\n **code playgrounds** — execute user provided code with full error forwarding and state injection\n **plugin sandboxes** — host third party or user authored plugin ui without granting host access\n **user generated content** — display untrusted html (emails, form output, external widgets) safely\n **widget embedding** — wrap third party widgets with strict csp and bidirectional messaging\n **ai generated ui** — render llm produced html components with guaranteed isolation\n\n| feature | raw `<iframe>` | sandbox |\n| | | |\n| bundle size | 0 b (built in) | <packageinfo package=\"sandbox\" type=\"size\" /> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| content security policy | manual | auto generated, strict by default |\n| typed postmessage protocol | <ore icon name=\"x\" size=\"16\"></ore icon> | `setstate()` / `sandboxmessage` union |\n| error forwarding | <ore icon name=\"x\" size=\"16\"></ore icon> | `onerror` + `unhandledrejection` → host |\n| dispose / `using` | manual `remove()` | `dispose()` + `[symbol.dispose]` |\n\n<div class=\"decision callout\">\n\n**use sandbox when** you need to render untrusted or user provided html in the browser with guaranteed isolation, csp enforcement, and a typed event bridge.\n\n**consider a raw `<iframe>` when** you only need to embed a known third party url — sandbox is for programmatic `srcdoc` content, not url based embedding.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sandbox\n```\n\n```sh [npm]\nnpm install @vielzeug/sandbox\n```\n\n```sh [yarn]\nyarn add @vielzeug/sandbox\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createsandbox } from '@vielzeug/sandbox';\n\nconst container = document.getelementbyid('preview')!;\nconst sandbox = createsandbox(container);\n\ntry {\n // render() resolves when the document is ready\n await sandbox.render('<ore button variant=\"primary\">click me</ore button>');\n\n // push state into the sandbox\n sandbox.setstate('theme', 'dark');\n} catch (error) {\n console.error('sandbox render failed', error);\n}\n\n// receive events from sandbox code (ready is not forwarded — internal use only)\nsandbox.onmessage((msg) => {\n if (msg.type === 'custom') console.log(msg.event, msg.detail);\n if (msg.type === 'error') console.error(msg.message);\n if (msg.type === 'resize') console.log('height:', msg.height);\n});\n\n// re render: await the returned promise\nawait sandbox.render(newhtml);\n\n// clean up — removes iframe, clears listeners\nsandbox.dispose();\n// or: using sandbox = createsandbox(container);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createsandbox()` — creates an isolated `<iframe sandbox=\"allow scripts\">` in the given container\n `sandboxhandle.ready` — promise resolving on first render's ready signal (also resolves on dispose; check `sandbox.disposed` to distinguish)\n `sandboxhandle.disposalsignal` — `abortsignal` aborted when the sandbox is disposed; tie async work to sandbox lifetime\n `sandboxhandle.disposed` — observable disposed state; check before deferred calls\n `render(html, { signal? })` — lazy iframe creation; returns `promise<void>` resolving when ready, or rejecting with `sandboxtimeouterror` if the bridge never signals ready; pass `abortsignal` to skip cancelled renders\n `replacebody(html)` — replace body descendants without navigating; head scripts/styles survive while descendant state is replaced; suited to host owned streaming markup\n `updatestyle(id, css)` — hot patch a named `<style id=\"…\">` block live without re rendering; also updates baseline for next render\n `setstate(key, value)` — push state into the sandbox; received as `sandbox:state update` customevent\n `setstateall(record)` — push multiple state values in a single postmessage; more efficient than repeated `setstate()` calls for initial setup\n `namedstyles` option — named `<style id=\"key\">` blocks in document `<head>`; individually patchable via `updatestyle()`\n `lang` / `title` options — set basic language tag and `<title>` on generated documents for screen reader correctness\n `sandboxbridge` type — ambient type for `window.__sandbox__` in sandbox side typescript; `onstate(key, handler)` subscribes to state pushed via `setstate()`/`setstateall()`\n `custom` messages — sandbox code emits `window.__sandbox__.emit(event, detail)` to the host\n `resize` messages — auto emitted by the bridge's built in `resizeobserver`; no manual wiring needed\n strict csp — `default src 'none'`, inline scripts only, no network by default\n `nonce` option — cryptographic nonce for bridge `<script>` tag and `script src` csp\n `scripts` option — inject cdn scripts with `crossorigin=\"anonymous\"`; origins auto added to `script src`\n `buildcsp()` — build a standalone csp string using the same `sandboxoptions`\n `builddocument()` — build static isolated sandbox markup for server side or offline use; use `createsandbox()` for host managed runtime controls\n error forwarding — `onerror` + `unhandledrejection` forwarded as `{ type: 'error' }` messages\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 guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [codex](/codex/) — mcp server with `generate sandbox document` and `get state bridge spec` tools; generates document templates for use with sandbox\n [refine](/refine/) — web component library; renders correctly inside the sandbox via `<script>` injection and `allowedscriptorigins`\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
971
+ "api": " \ntitle: sandbox — api reference\ndescription: full api reference for @vielzeug/sandbox — createsandbox, buildcsp, builddocument, sandboxhandle, and all types.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createsandbox()` | create an isolated sandboxed iframe runtime | sync (returns handle); `render()` is async | iframe dom is created lazily — nothing exists until the first `render()` call |\n| `buildcsp()` | build a csp string from `sandboxoptions` | sync | invalid configuration throws `sandboxconfigurationerror` |\n| `builddocument()` | build a complete standalone sandbox html document | sync | returns markup, not a host runtime handle; use `createsandbox()` for host managed state or lifecycle |\n| `sandboxhandle` | object returned by `createsandbox()` | — | `setstate()`/`setstateall()` warn in dev if called before `render()` resolves |\n| `sandboxoptions` | unified options for `createsandbox`, `buildcsp`, `builddocument` | — | all fields are optional; defaults documented per field below |\n| `sandboxbridge` | bridge api at `window.__sandbox__` inside sandbox documents | — | `emit()` sends events to the host; `onstate()` only receives — there is no way to call host functions directly |\n| `sandboxmessage` | application messages the sandbox sends to the host | — | `'ready'` is not part of this union — it resolves `render()` internally instead |\n| `sandboxerror` | base error class for `@vielzeug/sandbox` | — | use `instanceof sandboxerror` to narrow package errors |\n| `sandboxconfigurationerror` | thrown for invalid origins, urls, nonces, language tags, or style ids | — | fix configuration rather than relying on sanitization |\n| `sandboxtimeouterror` | thrown by `render()` when no `'ready'` signal arrives in time | — | extends `sandboxerror`; the document is likely missing the bridge script |\n| `sandboxstateupdatedetail` | detail payload of the sandbox side `sandbox:state update` customevent | — | only relevant inside sandbox documents, not on the host |\n| `unsubscribe` | return type of `onmessage()` and `sandboxbridge.onstate()` | — | calling it more than once is a safe no op |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/sandbox` | main exports and types |\n| `@vielzeug/sandbox/testing` | `createsandboxtesthelpers` — postmessage simulation helpers for tests |\n\n```ts\nimport {\n buildcsp,\n builddocument,\n createsandbox,\n sandboxconfigurationerror,\n sandboxerror,\n sandboxtimeouterror,\n} from '@vielzeug/sandbox';\nimport type {\n sandboxbridge,\n sandboxhandle,\n sandboxmessage,\n sandboxoptions,\n sandboxstateupdatedetail,\n unsubscribe,\n} from '@vielzeug/sandbox';\n\nimport { createsandboxtesthelpers } from '@vielzeug/sandbox/testing';\n```\n\n## `createsandbox(container, options?)`\n\ncreates a sandboxed `<iframe>` inside `container` and returns a `sandboxhandle`.\n\n```ts\nfunction createsandbox(container: htmlelement, options?: sandboxoptions): sandboxhandle\n```\n\nthe iframe is created lazily on the first `render()` call — `createsandbox()` is a cheap factory with no dom work until content is ready. the iframe uses `sandbox=\"allow scripts\"` and `referrerpolicy=\"no referrer\"`. content is loaded via `srcdoc` with an auto generated csp meta tag. the sandbox cannot access host cookies, storage, or the dom.\n\n**parameters**\n\n `container` — the dom element to append the iframe to.\n `options` — optional `sandboxoptions`.\n\n**returns** a `sandboxhandle`.\n\n**example**\n\n```ts\nconst sandbox = createsandbox(document.getelementbyid('preview')!);\nawait sandbox.render('<p>hello from the sandbox</p>');\n```\n\n## `sandboxhandle`\n\n```ts\ninterface sandboxhandle {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly ready: promise<void>;\n dispose(): void;\n onmessage(handler: (msg: sandboxmessage) => void): unsubscribe;\n replacebody(html: string): void;\n render(html: string, options?: { signal?: abortsignal }): promise<void>;\n setstate(key: string, value: unknown): void;\n setstateall(record: record<string, unknown>): void;\n updatestyle(id: string, css: string): void;\n [symbol.dispose](): void;\n}\n```\n\n| member | description |\n| | |\n| `disposalsignal` | `abortsignal` that is aborted when `dispose()` is called. pass to `fetch` and other async operations to tie their lifetime to the sandbox. |\n| `disposed` | `true` once `dispose()` has been called. |\n| `ready` | promise that resolves when the **first** sandbox document signals it has loaded. also resolves if the sandbox is disposed before the first render — check `sandbox.disposed` after awaiting to distinguish the two cases. does **not** reset on re renders — use the promise returned by `render()` for subsequent renders. |\n| `replacebody(html)` | replace `document.body.innerhtml` without navigating. head scripts, document/window listeners, and `namedstyles` survive; body descendants, their listeners, references, form state, and scripts in replacement html do not. call after `render()` resolves. |\n| `render(html, options?)` | replace the entire sandboxed document (full page reset). creates the iframe lazily. returns a `promise<void>` that resolves when the new document signals ready, or **rejects with `sandboxtimeouterror`** if no `'ready'` signal arrives within 5s. if a second `render()` starts before the first resolves, the first promise resolves (not rejects) immediately — the document simply navigated away. pass `options.signal` to skip if already aborted. emits a dev warning when `html` is empty or whitespace only. |\n| `updatestyle(id, css)` | hot patch a named `<style id=\"…\">` block in the live iframe via postmessage, and update the baseline for the next `render()`. no ops if the sandbox is disposed. safe to call before the first render (baseline only). warns in dev if `id` is not a known key in `namedstyles`. |\n| `setstate(key, value)` | push a state value into the sandbox. dispatches a `sandbox:state update` customevent inside the iframe. warns in dev if called before `render()` resolves. |\n| `setstateall(record)` | push multiple state values in a single postmessage. dispatches one `sandbox:state update` customevent per key inside the iframe. more efficient than calling `setstate()` repeatedly for initial state setup. warns in dev if called before `render()` resolves. |\n| `onmessage(handler)` | subscribe to `sandboxmessage` events (`error`, `custom`, and `resize`). the `ready` lifecycle signal is not forwarded. returns an `unsubscribe` function. |\n| `dispose()` | remove the iframe from the dom and clear all listeners. resolves any pending `ready` promise and aborts `disposalsignal`. |\n| `[symbol.dispose]()` | alias for `dispose()` — enables `using sandbox = createsandbox(…)`. |\n\n::: warning dev warnings\ncalling `render()`, `setstate()`, `setstateall()`, `updatestyle()`, or `onmessage()` on a disposed sandbox emits a warning in development (when `import.meta.env.prod` is not `true`).\n\ncalling `setstate()` or `setstateall()` before `render()` resolves emits a dev warning — the bridge may not have set up its listener yet and the state update may be silently dropped. always await the promise returned by `render()` before calling either.\n\nin production all guard paths are silent no ops (no warnings).\n:::\n\n::: warning render() can reject\nunlike the other guard paths above, the `sandboxtimeouterror` rejection from `render()` is **not** a dev only warning — it fires in every build. always attach a `.catch()` or wrap `await sandbox.render(...)` in `try`/`catch`:\n\n```ts\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (err instanceof sandboxerror) {\n console.error('sandbox failed to load:', err.message);\n }\n}\n```\n:::\n\n## `sandboxoptions`\n\nunified options for `createsandbox`, `buildcsp`, and `builddocument`. all fields are optional.\n\n```ts\ninterface sandboxoptions {\n allowedfontorigins?: string[];\n allowedimageorigins?: string[];\n allowedscriptorigins?: string[];\n allowedstyleorigins?: string[];\n lang?: string;\n namedstyles?: record<string, string>;\n nonce?: string;\n scripts?: string[];\n title?: string;\n}\n```\n\n| option | type | default | description |\n| | | | |\n| `allowedfontorigins` | `string[]` | `[]` | absolute `http:` or `https:` origins added to `font src`; paths, query strings, fragments, and credentials are rejected. default directive value: `'none'`. |\n| `allowedimageorigins` | `string[]` | `[]` | absolute `http:` or `https:` origins added to `img src`. `data:` is always included. |\n| `allowedscriptorigins` | `string[]` | `[]` | absolute `http:` or `https:` origins added to `script src`. merged with origins extracted from `scripts`. |\n| `allowedstyleorigins` | `string[]` | `[]` | absolute `http:` or `https:` origins added to `style src`. `'unsafe inline'` is always included. |\n| `lang` | `string` | `'en'` | basic language tag: 2–3 letter primary language followed by optional 2–8 character subtags, such as `en`, `de`, or `zh hant`. |\n| `namedstyles` | `record<string, string>` | `{}` | named `<style id=\"key\">` blocks in document `<head>`. keys start with a letter and contain only letters, digits, `_`, or ` `; each block is patchable via `updatestyle(id, css)`. |\n| `nonce` | `string` | `undefined` | non empty base64/base64url style token added to both bridge scripts and `script src`. in csp level 3 browsers the nonce suppresses `'unsafe inline'`; `'unsafe inline'` remains for csp level 2 fallback. |\n| `scripts` | `string[]` | `[]` | absolute `http:` or `https:` script urls injected before user content with `crossorigin=\"anonymous\"`. their origins are added to `script src`. |\n| `title` | `string` | `''` | title for generated document, placed in `<title>`. providing a title improves screen reader compatibility. |\n\n::: warning security\n`title` and css content are escaped before interpolation. origins, script urls, `nonce`, `lang`, and `namedstyles` ids are validated before document generation; invalid configuration throws `sandboxconfigurationerror` instead of being rewritten.\n:::\n\n## `buildcsp(options?)`\n\nbuilds a strict content security policy string for sandboxed iframe documents.\n\n```ts\nfunction buildcsp(options?: sandboxoptions): string\n```\n\naccepts `sandboxoptions` directly. origins from `scripts` urls are extracted and merged with `allowedscriptorigins` automatically. returns a semicolon separated csp string with eight directives. `base uri 'none'` is always included to block `<base>` tag injection, and `connect src 'none'` / `form action 'none'` block network requests and form submission by default.\n\n**default output (no options)**\n\n```\ndefault src 'none'; script src 'unsafe inline'; style src 'unsafe inline'; img src data:; font src 'none'; connect src 'none'; form action 'none'; base uri 'none'\n```\n\n**example**\n\n```ts\nconst csp = buildcsp({\n allowedstyleorigins: ['https://fonts.googleapis.com'],\n allowedfontorigins: ['https://fonts.gstatic.com'],\n scripts: ['https://cdn.example.com/refine.iife.js'],\n});\n// script src includes 'unsafe inline' + https://cdn.example.com automatically\n```\n\n## `builddocument(html, options?)`\n\nbuilds a complete, standalone sandbox html document.\n\n```ts\nfunction builddocument(html: string, options?: sandboxoptions): string\n```\n\nincludes the `<html lang=\"…\">` attribute, `<title>`, csp meta tag, injected scripts, `namedstyles` rendered as `<style id=\"key\">` blocks, user content, and bridge script. returns isolated markup for `iframe.srcdoc` or server generation (for example, through `@vielzeug/codex`).\n\n`builddocument()` does not return a `sandboxhandle`. use `createsandbox()` when the host must push state, replace body content, update styles, await readiness, or manage disposal.\n\nexternal scripts are placed **before** user content with `crossorigin=\"anonymous\"`, so the bridge's error handler receives full error details for cross origin script errors. the bridge emits `ready` after preceding parser blocking scripts execute, then observes `document.body` for resize messages.\n\n`lang` defaults to `'en'` and `title` defaults to `''` — both are html escaped before interpolation.\n\n**example**\n\n```ts\nimport { builddocument } from '@vielzeug/sandbox';\n\nconst html = builddocument('<p>hello</p>', {\n lang: 'de',\n title: 'component preview',\n namedstyles: {\n base: 'body { font family: sans serif; }',\n theme: ':root { bg: #fff; }',\n },\n});\n\niframe.srcdoc = html;\n```\n\n## bridge protocol\n\n### `sandboxmessage`\n\napplication level messages the sandbox sends to the host, received via `sandbox.onmessage(handler)`. the `ready` lifecycle signal is **intentionally excluded** — it resolves `sandbox.ready` and the promise returned by `render()` internally and is not forwarded to subscribers.\n\n```ts\ntype sandboxmessage =\n | { detail: unknown; event: string; type: 'custom' }\n | { message: string; stack?: string; type: 'error' }\n | { height: number; type: 'resize' };\n```\n\n| type | fields | description |\n| | | |\n| `error` | `message: string`, `stack?: string` | fired on uncaught errors or unhandled promise rejections inside the sandbox. |\n| `custom` | `event: string`, `detail: unknown` | user defined events emitted from sandbox code via `window.__sandbox__.emit(event, detail)`. |\n| `resize` | `height: number` | emitted automatically when sandbox content height changes. the bridge script sets up a `resizeobserver` on `document.body` — no manual wiring needed. |\n\n### `sandboxstateupdatedetail`\n\ndetail payload of the `sandbox:state update` customevent dispatched **inside** sandbox documents by `setstate()`/`setstateall()`. only relevant to sandbox side code — the host never sees this type directly.\n\n```ts\ninterface sandboxstateupdatedetail {\n key: string;\n value: unknown;\n}\n```\n\n**emitting custom events from inside the sandbox:**\n\n```js\nwindow.__sandbox__.emit('button:click', { label: 'save', timestamp: date.now() });\n```\n\n**receiving on the host:**\n\n```ts\nsandbox.onmessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('button clicked:', msg.detail);\n }\n if (msg.type === 'error') {\n console.error('[sandbox]', msg.message, msg.stack);\n }\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\n### `sandboxbridge`\n\nthe bridge api available as `window.__sandbox__` inside sandbox documents. export this type to add typescript support for sandbox side code:\n\n```ts\ninterface sandboxbridge {\n emit(event: string, detail?: unknown): void;\n onstate(key: string, handler: (value: unknown) => void): unsubscribe;\n}\n```\n\nadd an ambient declaration in your sandbox side typescript project:\n\n```ts\n// sandbox env.d.ts\ndeclare interface window {\n __sandbox__: import('@vielzeug/sandbox').sandboxbridge;\n}\n```\n\n`onstate(key, handler)` subscribes to state pushed via `sandbox.setstate()`/`setstateall()` for a specific key — it wraps the raw `sandbox:state update` customevent so sandbox side code doesn't need to filter by key manually. returns an `unsubscribe` function:\n\n```ts\nconst off = window.__sandbox__.onstate('theme', (value) => {\n document.body.dataset.theme = string(value);\n});\n\n// later, stop listening:\noff();\n```\n\n### state updates\n\n`sandbox.setstate(key, value)` sends a single state value into the sandbox; `sandbox.setstateall(record)` sends multiple values in one postmessage. both dispatch a `sandbox:state update` customevent per key, described by `sandboxstateupdatedetail`. inside the sandbox, either listen via the dom directly or use `window.__sandbox__.onstate()`:\n\n```js\ndocument.addeventlistener('sandbox:state update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n});\n```\n\n```ts\n// single value\nsandbox.setstate('theme', 'dark');\n\n// multiple values in one postmessage — fires 'sandbox:state update' twice, once per key\nsandbox.setstateall({ theme: 'dark', locale: 'en' });\n```\n\n::: warning security\ntreat all `sandboxmessage` data as untrusted. the sandbox controls what `custom` event payloads contain — do not execute or evaluate any message field.\n:::\n\n## types\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\nreturn type of `onmessage()` and `sandboxbridge.onstate()`. calling it more than once is a safe no op.\n\n## errors\n\n### `sandboxerror`\n\nbase class for all `@vielzeug/sandbox` errors. extends `error`.\n\n```ts\nclass sandboxerror extends error {}\n```\n\nuse `instanceof sandboxerror` to narrow package errors in catch blocks. it also matches subclasses like `sandboxtimeouterror`:\n\n```ts\nimport { sandboxerror } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (err instanceof sandboxerror) {\n console.error(err.message);\n }\n}\n```\n\n### `sandboxconfigurationerror`\n\nthrown when sandbox configuration cannot produce a valid csp or document. origins must be absolute `http:` or `https:` origins without paths, query strings, fragments, or credentials. scripts must be absolute `http:` or `https:` urls. nonces, basic language tags, and named style ids must match their documented syntax.\n\n```ts\nimport { sandboxconfigurationerror } from '@vielzeug/sandbox';\n\ntry {\n buildcsp({ allowedscriptorigins: ['cdn.example.com/path'] });\n} catch (error) {\n if (error instanceof sandboxconfigurationerror) console.error(error.message);\n}\n```\n\n### `sandboxtimeouterror`\n\nthrown as a rejection from `render()` when no `'ready'` signal arrives within 5 seconds, in every build (not a dev only warning). extends `sandboxerror`. the sandbox document is most likely missing the bridge script — use `builddocument()` to generate documents that include it, rather than hand writing the `srcdoc` html.\n\n```ts\nimport { sandboxtimeouterror } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(customhtmlmissingbridge);\n} catch (err) {\n if (err instanceof sandboxtimeouterror) {\n console.error('sandbox never signaled ready:', err.message);\n }\n}\n```\n\n## test utilities\n\n`@vielzeug/sandbox/testing` exports helpers for code that integrates with the sandbox:\n\n```ts\nimport { createsandboxtesthelpers } from '@vielzeug/sandbox/testing';\n\nconst helpers = createsandboxtesthelpers(container);\n\nsandbox.render('<p>test</p>');\nhelpers.fireready(); // simulate bridge ready signal\nhelpers.firecustom('click', { x: 1 }); // simulate window.__sandbox__.emit()\nhelpers.fireresize(420); // simulate resizeobserver callback\nhelpers.fireerror('typeerror: x is not defined', 'at eval:1');\n```\n\nthese helpers encapsulate the internal postmessage protocol so test code doesn't need to know message shapes.\n",
972
+ "usage": " \ntitle: sandbox — usage guide\ndescription: how to render untrusted html, pass state, handle errors, configure csp, and integrate the sandbox with your application.\n \n\n[[toc]]\n\n::: tip new to sandbox?\nstart with the [overview](./index.md) for installation and a quick example, then come back here for in depth usage patterns.\n:::\n\n## basic usage\n\ncreate a sandbox by passing a container element. the returned `sandboxhandle` is your entire interface to the iframe.\n\n```ts\nimport { createsandbox } from '@vielzeug/sandbox';\n\nconst container = document.getelementbyid('preview')!;\nconst sandbox = createsandbox(container);\n\nawait sandbox.render('<p>hello from the sandbox</p>');\n```\n\n`render()` returns a `promise<void>` that resolves when the sandbox document signals it is ready. no dom is created until `render()` is called — `createsandbox()` is a cheap factory.\n\nfor reactive frameworks, subscribe via `onmessage` to receive `error`, `custom`, and `resize` events.\n\n## rendering html\n\n`render(html)` replaces the entire sandboxed document with a new one containing your html in the body.\n\n```ts\nawait sandbox.render(`\n <style>body { font family: sans serif; }</style>\n <h1>component preview</h1>\n <ore button variant=\"primary\">click me</ore button>\n`);\n```\n\neach call to `render()` is a full page reset — scripts reinitialise, css is re applied, and any dom state is lost. for incremental updates, push state via `setstate()` or patch styles via `updatestyle()` rather than re rendering.\n\n## incremental updates with replacebody()\n\n`replacebody(html)` replaces `document.body.innerhtml` in the live document without navigating the iframe. head scripts, document/window listeners, named styles, and global state survive. body descendants, their listeners, references, form state, and scripts inside replacement html do not survive.\n\nuse it for streaming ai generated output or live previews when the host owns accumulated markup.\n\n```ts\n// initial render — sets up the document, scripts, and styles\nawait sandbox.render(`\n <script>\n document.addeventlistener('sandbox:state update', (e) => {\n document.body.dataset.theme = e.detail.value;\n });\n </script>\n <p>loading…</p>\n`);\n\n// subsequent updates replace body descendants\nsandbox.replacebody('<p>first chunk arrived</p>');\nsandbox.replacebody('<p>first chunk arrived</p><p>second chunk…</p>');\nsandbox.replacebody('<p>complete response</p>');\n```\n\n**`replacebody()` vs `render()`:**\n\n| | `render()` | `replacebody()` |\n| | | |\n| full page reset | yes | no |\n| returns a promise | yes | no |\n| head scripts re run | yes | no |\n| `namedstyles` preserved | re injected | yes |\n| body descendants/listeners | recreated | replaced |\n| when to use | initial load, structural reset | streaming markup, live preview |\n\n**`replacebody()` must be called after `render()` resolves.** the bridge must be initialized before it can receive the replacement.\n\n## passing state\n\n`setstate(key, value)` pushes data into the sandbox without re rendering.\n\nalways call `setstate()` after `render()` resolves — calling it before the bridge finishes initializing will silently drop the update in a real browser, and a dev warning will fire.\n\n```ts\n// correct: await render() before pushing state\nawait sandbox.render('<div id=\"root\"></div>');\nsandbox.setstate('theme', 'dark');\nsandbox.setstate('user', { name: 'alice' });\n```\n\ninside the sandbox document, listen for the `sandbox:state update` custom event on `document`:\n\n```html\n<script>\ndocument.addeventlistener('sandbox:state update', (e) => {\n const { key, value } = e.detail;\n if (key === 'theme') document.body.dataset.theme = value;\n if (key === 'user') document.queryselector('#name').textcontent = value.name;\n});\n</script>\n```\n\n## batch state updates\n\n`setstateall(record)` pushes multiple state values in a single postmessage — one call instead of one `setstate()` per key. use it for initial state setup where several values become available at the same time.\n\n```ts\nawait sandbox.render('<div id=\"root\"></div>');\n\n// one postmessage instead of two setstate() calls\nsandbox.setstateall({\n theme: 'dark',\n user: { name: 'alice' },\n});\n```\n\nthe sandbox side listens the same way as for `setstate()` — each key in the record fires its own `sandbox:state update` event.\n\n## handling errors\n\nsubscribe to `onmessage` before calling `render()` to catch runtime errors in sandbox content.\n\n```ts\nsandbox.onmessage((msg) => {\n if (msg.type === 'error') {\n console.error('[sandbox error]', msg.message);\n if (msg.stack) console.debug(msg.stack);\n }\n});\n```\n\nboth synchronous errors (`window.onerror`) and unhandled promise rejections (`unhandledrejection`) are forwarded as `{ type: 'error' }` messages.\n\n### `render()` rejection\n\n`render()` rejects with a `sandboxtimeouterror` if the document never signals `'ready'` within 5 seconds — this happens in every build, not just dev. it usually means the document is missing the bridge script (custom `srcdoc` html built by hand instead of via `builddocument()`). always handle it:\n\n```ts\nimport { sandboxerror } from '@vielzeug/sandbox';\n\ntry {\n await sandbox.render(html);\n} catch (err) {\n if (err instanceof sandboxerror) {\n console.error('sandbox failed to load:', err.message);\n }\n}\n```\n\na second `render()` call superseding the first does **not** trigger this — the superseded promise resolves, not rejects.\n\n## injecting scripts and styles\n\nuse `sandboxoptions` to inject external scripts and styles into every rendered document.\n\n```ts\nconst sandbox = createsandbox(container, {\n scripts: [\n 'https://cdn.example.com/ore.js',\n 'https://cdn.example.com/refine.js',\n ],\n namedstyles: {\n base: `\n :root { color primary: #0066cc; }\n body { margin: 0; font family: var( font sans); }\n `,\n },\n});\n```\n\nscript urls are injected before user content. their origins are automatically added to `script src` in the csp — you do not need to configure `buildcsp` separately.\n\n## setting document language and title\n\nuse `lang` and `title` to set the generated document's `<html lang=\"…\">` attribute and `<title>`. both improve screen reader behaviour for sandboxed content.\n\n```ts\nconst sandbox = createsandbox(container, {\n lang: 'de',\n title: 'component preview',\n});\n```\n\n`lang` defaults to `'en'`; use a 2–3 letter primary language with optional 2–8 character subtags, such as `de` or `zh hant`. `title` defaults to `''` and is html escaped before document generation. invalid language tags throw `sandboxconfigurationerror`.\n\n## hot patching named styles\n\n`namedstyles` injects named `<style id=\"key\">` blocks into the document `<head>`. named blocks can be updated live without a full re render using `updatestyle(id, css)`.\n\n```ts\nconst sandbox = createsandbox(container, {\n namedstyles: {\n theme: ':root { color primary: #0066cc; bg: #fff; }',\n },\n});\n\nawait sandbox.render('<ore button variant=\"primary\">click me</ore button>');\n\n// switch theme live — no re render\nsandbox.updatestyle('theme', ':root { color primary: #bb33ff; bg: #111; }');\n```\n\n`updatestyle()` sends a postmessage to the iframe, patching `<style id=\"theme\">` in place. it also updates the baseline so the next `render()` starts with the patched css. safe to call before the first render (baseline only — no postmessage sent to an uninitialized iframe).\n\n## resize notifications\n\nthe bridge script automatically emits `resize` messages via a `resizeobserver` on `document.body`. no manual wiring is needed in your sandbox content.\n\n```ts\nsandbox.onmessage((msg) => {\n if (msg.type === 'resize') {\n container.style.height = `${msg.height}px`;\n }\n});\n```\n\nthe `resize` message fires whenever the `document.body` height changes — on initial load, after content updates via `setstate()`, and after style patches via `updatestyle()`.\n\n## tying async work to sandbox lifetime\n\n`disposalsignal` is an `abortsignal` that is aborted when the sandbox is disposed. pass it to any async operation that should stop when the sandbox is torn down.\n\n```ts\nconst sandbox = createsandbox(container);\n\n// polling loop tied to sandbox lifetime\nasync function poll() {\n while (!sandbox.disposalsignal.aborted) {\n const data = await fetch('/api/data', { signal: sandbox.disposalsignal }).then(r => r.json()).catch(() => null);\n if (data) sandbox.setstate('data', data);\n await new promise(resolve => settimeout(resolve, 5000));\n }\n}\n\npoll();\n```\n\nwhen `sandbox.dispose()` is called, `disposalsignal` aborts, cancelling in flight fetches and stopping the loop.\n\n## configuring csp\n\nuse `allowedstyleorigins`, `allowedfontorigins`, and `allowedimageorigins` to allow cdn resources.\n\n```ts\nconst sandbox = createsandbox(container, {\n allowedstyleorigins: ['https://fonts.googleapis.com'],\n allowedfontorigins: ['https://fonts.gstatic.com'],\n allowedimageorigins: ['https://images.example.com'],\n});\n```\n\nthen render html that uses those resources:\n\n```ts\nawait sandbox.render(`\n <link rel=\"stylesheet\" href=\"https://fonts.googleapis.com/css2?family=inter\">\n <p style=\"font family: inter, sans serif\">hello</p>\n`);\n```\n\norigins must be absolute `http:` or `https:` origins without paths, query strings, fragments, or credentials. script urls must be absolute `http:` or `https:` urls. nonces must be non empty base64/base64url style tokens. invalid configuration throws `sandboxconfigurationerror`; generated csp always includes `base uri 'none'` to block `<base>` tag injection.\n\n## disposal\n\ndispose the sandbox when it is no longer needed. this removes the iframe from the dom and clears all message listeners.\n\n```ts\n// explicit\nsandbox.dispose();\n\n// using explicit resource management (typescript 5.2+)\n{\n using sandbox = createsandbox(container);\n await sandbox.render('<p>temporary preview</p>');\n} // sandbox.dispose() called automatically\n```\n\n## multiple listeners\n\n`onmessage` supports multiple independent subscriptions. each call returns its own unsubscribe function.\n\n```ts\nconst unsuberrors = sandbox.onmessage((msg) => {\n if (msg.type === 'error') logerror(msg);\n});\n\nconst unsubevents = sandbox.onmessage((msg) => {\n if (msg.type === 'custom') handlecustomevent(msg);\n});\n\n// remove a single subscription\nunsuberrors();\n\n// remove all — dispose() clears all listeners at once\nsandbox.dispose();\n```\n\n## receiving events from the sandbox\n\nsandbox code calls `window.__sandbox__.emit(event, detail)` to send events to the host. receive them via `onmessage` with `msg.type === 'custom'`.\n\n```html\n<! inside sandbox content >\n<button onclick=\"window.__sandbox__.emit('button:click', { label: 'save' })\">save</button>\n```\n\n```ts\n// host\nsandbox.onmessage((msg) => {\n if (msg.type === 'custom' && msg.event === 'button:click') {\n console.log('sandbox button clicked:', msg.detail);\n }\n});\n```\n\n**typescript support for sandbox side code** — add an ambient declaration referencing `sandboxbridge`:\n\n```ts\n// sandbox env.d.ts\ndeclare interface window {\n __sandbox__: import('@vielzeug/sandbox').sandboxbridge;\n}\n```\n\n## awaiting subsequent renders\n\n`render()` returns a `promise<void>` that resolves when the new document signals ready. await it directly for each render:\n\n```ts\nawait sandbox.render(firsthtml); // first render complete\nawait sandbox.render(secondhtml); // second render complete\n```\n\nif a second `render()` starts before the first resolves, the first promise resolves immediately (superseded). multiple concurrent callers can each await their own returned promise.\n\n## cancelling renders with abortsignal\n\npass an `abortsignal` to `render()` to skip the render if it has already been cancelled. useful in streaming or queued workflows:\n\n```ts\nlet controller = new abortcontroller();\n\nasync function streamrender(html: string) {\n controller.abort(); // cancel previous pending render\n controller = new abortcontroller();\n await sandbox.render(html, { signal: controller.signal });\n}\n```\n\nif the signal is already aborted when `render()` is called, the render is skipped with no warning and no dom change.\n\n## building sandbox documents directly\n\nuse `builddocument` when you need static isolated markup outside `createsandbox`, such as server generated html or a codex template. use `createsandbox` instead when the host needs state updates, body replacement, style updates, readiness, or disposal.\n\n```ts\nimport { builddocument } from '@vielzeug/sandbox';\n\nconst html = builddocument('<p>hello</p>', {\n allowedstyleorigins: ['https://fonts.googleapis.com'],\n allowedfontorigins: ['https://fonts.gstatic.com'],\n namedstyles: {\n theme: ':root { bg: #fff; }',\n },\n});\n\n// html is a complete <!doctype html> document — assign directly to srcdoc\niframe.srcdoc = html;\n```\n\nuse `buildcsp` if you only need the csp string for an existing document template:\n\n```ts\nimport { buildcsp } from '@vielzeug/sandbox';\n\nconst csp = buildcsp({ allowedfontorigins: ['https://fonts.gstatic.com'] });\n// → \"default src 'none'; ... font src https://fonts.gstatic.com; ...\"\n```\n\n## testing\n\nuse `createsandboxtesthelpers` from the `/testing` subpath to simulate sandbox→host messages without a real `srcdoc` script execution (jsdom does not execute iframe `srcdoc` scripts).\n\n```ts\nimport { createsandbox } from '@vielzeug/sandbox';\nimport { createsandboxtesthelpers } from '@vielzeug/sandbox/testing';\nimport { describe, expect, it } from 'vitest';\n\ndescribe('preview panel', () => {\n it('forwards a custom event from the sandbox', async () => {\n const container = document.createelement('div');\n const sandbox = createsandbox(container);\n const helpers = createsandboxtesthelpers(container);\n\n const received: unknown[] = [];\n\n sandbox.onmessage((msg) => received.push(msg));\n\n const renderpromise = sandbox.render('<button>save</button>');\n\n helpers.fireready(); // simulate the bridge script's initial postmessage\n await renderpromise;\n\n helpers.firecustom('button:click', { label: 'save' });\n expect(received).toequal([{ type: 'custom', event: 'button:click', detail: { label: 'save' } }]);\n\n sandbox.dispose();\n });\n});\n```\n\n`sandboxtesthelpers` also exposes `fireresize(height)` and `fireerror(message, stack?)` for testing resize and error handling without a live browser.\n\n## framework integration\n\ncreate the sandbox once per mount and dispose it on unmount — the container element is stable for the component's lifetime.\n\n::: code group\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\nimport { createsandbox } from '@vielzeug/sandbox';\n\nfunction sandboxpreview({ html }: { html: string }) {\n const containerref = useref<htmldivelement>(null);\n\n useeffect(() => {\n if (!containerref.current) return;\n\n const sandbox = createsandbox(containerref.current);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n }, [html]);\n\n return <div ref={containerref} />;\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { onmounted, onunmounted, ref } from 'vue';\nimport { createsandbox, type sandboxhandle } from '@vielzeug/sandbox';\n\nconst props = defineprops<{ html: string }>();\nconst containerref = ref<htmldivelement>();\nlet sandbox: sandboxhandle | undefined;\n\nonmounted(() => {\n if (!containerref.value) return;\n sandbox = createsandbox(containerref.value);\n sandbox.render(props.html);\n});\n\nonunmounted(() => sandbox?.dispose());\n</script>\n\n<template>\n <div ref=\"containerref\" />\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { onmount } from 'svelte';\n import { createsandbox } from '@vielzeug/sandbox';\n\n export let html: string;\n let container: htmldivelement;\n\n onmount(() => {\n const sandbox = createsandbox(container);\n\n sandbox.render(html);\n\n return () => sandbox.dispose();\n });\n</script>\n\n<div bind:this={container}></div>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n**with codex:**\nthe `generate sandbox document` and `get state bridge spec` mcp tools in `@vielzeug/codex` are designed to work with sandbox. they generate complete sandbox ready document templates and document the bridge protocol.\n\n```ts\n// after codex generates an html document:\nawait sandbox.render(generateddocument);\n```\n\n**with refine:**\ninject the refine/ore runtime into the sandbox via `scripts`:\n\n```ts\nconst sandbox = createsandbox(container, {\n scripts: ['https://cdn.example.com/refine.iife.js'],\n namedstyles: {\n theme: '/* refine theme tokens */',\n },\n});\n\nawait sandbox.render('<ore card><ore button>save</ore button></ore card>');\n```\n\n## best practices\n\n **await `render()` before calling `setstate()`/`setstateall()`** — both warn in dev if called before the bridge is ready. use `setstateall()` to bootstrap several values in one postmessage instead of calling `setstate()` repeatedly.\n **use `await sandbox.render(html)` for each render** — `render()` returns a `promise<void>` that resolves when the document is ready. no separate readiness api is needed.\n **use `updatestyle()` for theme switching** — updating a named style avoids a full `render()` and preserves current document state.\n **check `disposed` before deferred calls** — across async operations, check `sandbox.disposed` before calling any method to avoid spurious dev warnings.\n **tie async work to `disposalsignal`** — pass `disposalsignal` to `fetch` and other async operations so they cancel automatically on dispose.\n **treat all messages as untrusted** — sandbox code controls `sandboxmessage` payloads. do not `eval()` or execute any message field.\n **one sandbox per preview** — `createsandbox()` is a cheap factory; create a new sandbox per user session or component rather than reusing across unrelated renders.\n **use `using` in functions** — in typescript 5.2+ contexts, `using` guarantees cleanup even on exceptions.\n **use `replacebody()` or `setstate()` for incremental updates** — `render()` resets document state. `replacebody()` replaces body descendants; `setstate()` updates live code without replacing dom.\n",
973
+ "examples": " \ntitle: sandbox — examples\ndescription: recipes for common sandbox use cases — component previews, user script sandboxes, and embedded widgets.\n \n\n## examples\n\n [component preview](./examples/component preview.md)\n [user script sandbox](./examples/user script sandbox.md)\n [embedded widget](./examples/embedded widget.md)\n [ai ui renderer](./examples/ai ui renderer.md)\n"
974
+ },
975
+ "examples": [
976
+ {
977
+ "id": "build-csp",
978
+ "text": "build csp string import { buildcsp } from '@vielzeug/sandbox'\n\n// default strict csp — no external resources allowed\nconst defaultcsp = buildcsp()\nconsole.log('default csp:')\nconsole.log(defaultcsp)\n\n// allow google fonts (stylesheet + font files)\nconst fontscsp = buildcsp({\n allowedstyleorigins: ['https://fonts.googleapis.com'],\n allowedfontorigins: ['https://fonts.gstatic.com'],\n})\nconsole.log('\\nwith google fonts:')\nconsole.log(fontscsp)\n\n// allow a cdn script origin and an image host\nconst cdncsp = buildcsp({\n allowedscriptorigins: ['https://cdn.example.com'],\n allowedimageorigins: ['https://images.example.com'],\n})\nconsole.log('\\nwith cdn + images:')\nconsole.log(cdncsp)"
979
+ },
980
+ {
981
+ "id": "build-document",
982
+ "text": "build document import { builddocument } from '@vielzeug/sandbox'\n\n// build a complete standalone sandbox html document — useful for ssr previews,\n// static artifacts, or anywhere you need the document string without a live iframe.\nconst html = builddocument('<h1>hello from the sandbox</h1><p>no live iframe required.</p>', {\n lang: 'en',\n title: 'sandbox preview',\n namedstyles: {\n base: 'body { font family: system ui, sans serif; margin: 0; padding: 1rem; }',\n },\n})\n\nconsole.log('document length:', html.length, 'characters')\nconsole.log('has <html lang=\"en\">:', html.includes('lang=\"en\"'))\nconsole.log('has <title>sandbox preview</title>:', html.includes('<title>sandbox preview</title>'))\nconsole.log('has named style block #base:', html.includes('<style id=\"base\">'))\nconsole.log('includes the bridge script:', html.includes('window.__sandbox__'))\n\nconsole.log('\\nfirst 300 characters:')\nconsole.log(html.slice(0, 300))"
983
+ },
984
+ {
985
+ "id": "error-normalize",
986
+ "text": "normalize errors with sandboxerror import { sandboxerror } from '@vielzeug/sandbox'\n\n// normalize any caught error into a typed sandboxerror, preserving the original cause\nfunction tosandboxerror(err) {\n if (err instanceof sandboxerror) return err\n const message = err instanceof error ? err.message : string(err)\n return new sandboxerror(`sandbox operation failed: ${message}`, { cause: err })\n}\n\ntry {\n json.parse('{ not valid json')\n} catch (parseerror) {\n const sandboxerror = tosandboxerror(parseerror)\n console.log('wrapped error name:', sandboxerror.name)\n console.log('wrapped error message:', sandboxerror.message)\n console.log('original cause preserved:', sandboxerror.cause === parseerror)\n console.log('instanceof sandboxerror:', sandboxerror instanceof sandboxerror)\n console.log('instanceof error:', sandboxerror instanceof error)\n}\n\n// custom subclasses are still recognised by instanceof\nclass sandboxtimeouterror extends sandboxerror {}\nconst timeouterror = new sandboxtimeouterror('render() did not resolve in time')\nconsole.log('\\nsubclass name:', timeouterror.name)\nconsole.log('subclass instanceof sandboxerror:', timeouterror instanceof sandboxerror)\nconsole.log('plain error rejected:', !(new error('nope') instanceof sandboxerror))"
987
+ }
988
+ ],
989
+ "exports": "createsandbox buildcsp builddocument sandboxconfigurationerror sandboxerror sandboxtimeouterror sandboxhandle sandboxoptions sandboxbridge sandboxmessage sandboxstateupdatedetail unsubscribe",
990
+ "keywords": "sandbox iframe isolation playground csp postmessage security components",
991
+ "name": "@vielzeug/sandbox",
992
+ "related": "codex refine",
993
+ "slug": "sandbox",
994
+ "source": "export { builddocument } from './_document.js';\nexport { buildcsp } from './_policy.js';\nexport { createsandbox } from './_runtime.js';\nexport { sandboxconfigurationerror, sandboxerror, sandboxtimeouterror } from './errors.js';\nexport type {\n sandboxbridge,\n sandboxhandle,\n sandboxmessage,\n sandboxoptions,\n sandboxstateupdatedetail,\n unsubscribe,\n} from './types.js';\n"
995
+ },
996
+ {
997
+ "category": "utilities",
998
+ "description": "trigram indexed fuzzy search with per field weights, match highlighting, and an optional reactive layer.",
999
+ "docs": {
1000
+ "index": " \ntitle: scout — fast fuzzy search for typescript\ndescription: trigram indexed fuzzy search with per field weights, match highlighting, and an optional reactive layer.\npackage: scout\ncategory: utilities\nkeywords: [fuzzy search, search, trigram, full text, filter, highlight, reactive, ripple]\nexports:\n [\n createindex,\n createreactivesearch,\n createsearch,\n scoutconfigurationerror,\n scoutdisposederror,\n scouterror,\n debugsearch,\n findmatchranges,\n highlight,\n highlightfield,\n segmentwords,\n tofilterpredicate,\n tosearchmatcher,\n ]\nrelated: [arsenal, sourcerer, vault, ripple]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"scout\" />\n\n## why scout?\n\narsenal's `fuzzy` / `fuzzyfilter` helpers perform pairwise levenshtein distance — o(n·m) per item per query. for ≤200 items they are fine. for 500–100k items with real time keystrokes, you need an index.\n\nscout builds a **trigram inverted index** at construction time. query time scores only items sharing a trigram with the query; broad queries can still approach o(n), while selective queries avoid scoring the whole corpus.\n\n```ts\n// before\nconst matches = users.filter((user) => user.name.tolowercase().includes(query.tolowercase()));\n\n// after\nimport { createindex } from '@vielzeug/scout';\n\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst matches = index.search(query);\n```\n\n| feature | arsenal `fuzzy*` | scout `createindex` | fuse.js |\n| | | | |\n| bundle size | ~3 kb | <packageinfo package=\"scout\" type=\"size\" /> | ~23 kb |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> `@vielzeug/ripple` runtime dependency | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| algorithm | levenshtein | trigram + overlap coefficient | bitap |\n| query time | o(n·m) | o(candidates) | o(n·m) |\n| stateful index | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| match highlighting | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| reactive layer | <ore icon name=\"x\" size=\"16\"></ore icon> | ripple signals + debounce | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| incremental updates | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n\n<div class=\"decision callout\">\n\n**use scout when** you need search over 500+ items, real time ui search boxes (combobox, command palette), or reactive query state with ripple signals.\n\n**consider `arsenal.fuzzyfilter` when** you have fewer than 200 items and don't need a persistent index.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/scout\n```\n\n```sh [npm]\nnpm install @vielzeug/scout\n```\n\n```sh [yarn]\nyarn add @vielzeug/scout\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'ada lovelace' },\n { email: 'grace@example.com', name: 'grace hopper' },\n];\n\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n});\n\nconst results = index.search('ada');\nconsole.log(results[0]?.item.name); // ada lovelace\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createindex()` — trigram inverted index; construction o(corpus × field_length), query o(candidates)\n per field weights — promote `name` matches over secondary fields; finite positive weights and custom `stringify` functions supported\n `createreactivesearch()` — index + reactive `searchstate` in one call; `.index` for incremental mutations\n `createsearch()` — reactive search state backed by an existing `scoutindex`; share one index across many states\n `highlight()` / `highlightfield()` — split field text into `highlightpart[]` fragments for styled rendering\n `findmatchranges()` — compute match ranges for custom display strings (truncated previews, formatted values)\n `tosearchmatcher()` — matcher adapter for sourcerer's `localsource`\n `tofilterpredicate()` — snapshot `(item: t) => boolean` predicate for `array.filter` or vault queries\n `setitems()` — reconcile a refreshed corpus by reference, preserve incoming order, and notify once\n incremental updates — `add()` / `remove()` / `reindex()` patch individual items in o(field_length)\n `onmutate()` — subscribe to index mutations; powers `createsearch()`'s reactivity and bulk reconciliation\n `segmentwords()` — split unsegmented script text (cjk, thai, ...) into words via native `intl.segmenter`\n debug logging via `debugsearch()` (`@vielzeug/scout/devtools`) — logs query/results transitions, tree shaken from production bundles\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 [arsenal](/arsenal/) — use `fuzzyfilter` for ad hoc filtering of small lists (< 200 items) without building an index\n [ripple](/ripple/) — `createreactivesearch()` and `createsearch()` use ripple signals for reactive query state and debounce\n [sourcerer](/sourcerer/) — use a `scoutindex` inside `createlocalsource`'s explicit `match` callback\n [vault](/vault/) — `tofilterpredicate()` wraps a one time scout query as a vault compatible `filter()` predicate\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1001
+ "api": " \ntitle: scout — api reference\ndescription: complete api reference for @vielzeug/scout — createindex, createreactivesearch, createsearch, highlight, highlightfield, tosearchmatcher, tofilterpredicate.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createindex()` | build trigram index from an item array | sync | index is built at call time — pass all initial items |\n| `scoutindex.search()` | query the index, returns scored + highlighted results | sync | empty query returns all items with `score = 1` |\n| `scoutindex.add()` | add one item to the index | sync | no op if same reference already indexed |\n| `scoutindex.remove()` | remove one item by reference | sync | no op for unknown references |\n| `scoutindex.reindex()` | re index a mutated item in place; preserves order | sync | call after mutating item properties; no op if not in index |\n| `scoutindex.setitems()` | reconcile a refreshed corpus in one mutation | sync | uses reference identity; duplicate references collapse |\n| `scoutindex.items` | all indexed items in insertion order | sync | returns a new array snapshot each call |\n| `scoutindex.revision` | monotonic counter incremented after each mutation | sync | use as a cache busting token for external result caches |\n| `scoutindex.onmutate()` | subscribe to changed index mutations | sync | a changed `setitems()` reconciliation emits once; no ops emit nothing |\n| `createsearch()` | reactive search state backed by a `scoutindex` | sync | requires `@vielzeug/ripple` — dispose when done |\n| `createreactivesearch()` | one call index + reactive search state | sync | exposes `.index` for incremental mutations |\n| `findmatchranges()` | compute match ranges for a text + query pair | sync | returns sorted, non overlapping `[start, end]` ranges |\n| `highlight()` | split text into highlighted/unhighlighted fragments | sync | ranges must be sorted and non overlapping |\n| `highlightfield()` | highlight a named field from a `searchresult` | sync | shorthand for the `matches.find(…).ranges → highlight()` pattern |\n| `tosearchmatcher()` | adapt `scoutindex` to sourcerer's `match` callback | sync | recomputes cached query matches after index mutation |\n| `tofilterpredicate()` | snapshot predicate from a one time query | sync | re call when query or corpus changes |\n| `segmentwords()` | split unsegmented script text (cjk, thai, ...) into words | sync | uses native `intl.segmenter` — not applied inside `tokenize()` itself (see pitfalls) |\n| `debugsearch()` | log a `searchstate`'s query/results transitions | sync | import from `@vielzeug/scout/devtools`, not the main entry point |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/scout` | all exports — index/search/highlighting/adapters, `scoutconfigurationerror`, `scoutdisposederror`, `scouterror`, and all types |\n| `@vielzeug/scout/devtools` | `debugsearch` — reactive search state logger (dev only) |\n\n \n\n## `createindex(items, options)`\n\nbuilds a trigram inverted index from `items`. construction is o(corpus × field_length); subsequent `search()` calls are o(candidates).\n\n```ts\nfunction createindex<t>(items: t[], options: scoutindexoptions<t>): scoutindex<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required; at least one entry. |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring; shorter queries use o(n) containment scan (default `3`). |\n\n**example**\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst products = [\n { sku: 'wgt 001', title: 'widget pro' },\n { sku: 'gad 002', title: 'gadget plus' },\n];\n\nconst index = createindex(products, {\n fields: [\n { field: 'title', weight: 2 },\n { field: 'sku' },\n ],\n threshold: 0.25,\n limit: 20,\n});\n```\n\n \n\n## `scoutindex<t>`\n\nreturned by `createindex()`.\n\n### `.search(query, options?)`\n\n```ts\nsearch(query: string, options?: searchconstraints): searchresult<t>[]\n```\n\nreturns results sorted by score descending. empty query returns all items with `score = 1`. results below `threshold` are excluded; at most `limit` results are returned.\n\n```ts\nconst results = index.search('alice');\n// [{ item, score, matches }]\n```\n\n### `.add(item)`\n\nadds `item` to the index. no op if the same reference is already indexed. o(field_length).\n\n### `.remove(item)`\n\nremoves `item` by reference equality. no op if not found. o(field_length).\n\n### `.reindex(item)`\n\nre reads the item's current field values and rebuilds its index entry in place, updating only fields whose values changed. preserves insertion order. no op if the item is not in the index.\n\n```ts\nitem.name = 'new name';\nindex.reindex(item);\n```\n\n### `.setitems(items)`\n\n```ts\nsetitems(items: readonly t[]): void\n```\n\nreconciles the index to a refreshed corpus in one mutation. existing references are reindexed, missing references are removed, added references are indexed, and incoming first occurrence order becomes index order. duplicate references collapse to one item. calls `onmutate()` once when indexed values, membership, or order changes.\n\n```ts\nindex.setitems(latestusers);\n```\n\n### `.size`\n\n`number` — current number of indexed items.\n\n### `.items`\n\n`readonly t[]` — all indexed items in insertion order. returns a new array snapshot each call.\n\n```ts\nconst all = index.items;\n```\n\n### `.onmutate(listener)`\n\n```ts\nonmutate(listener: () => void): () => void\n```\n\nsubscribes `listener` to run after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. no ops, including unchanged bulk reconciliation, do not fire it. a changed `setitems()` reconciliation fires once. `createsearch()` uses this internally to keep `results` in sync with index mutations; most callers building on `createindex()` directly will not need it.\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n console.log(`index changed — now ${index.size} items`);\n});\n\nindex.add(newuser); // logs \"index changed — now 6 items\"\nunsubscribe();\n```\n\n### `.revision`\n\n`number` — monotonically increasing counter, incremented after every changed `add()` / `remove()` / `reindex()` / `setitems()` operation. use as a cache busting token when caching search results outside the index — `tosearchmatcher()` uses it for this purpose.\n\n \n\n## `createsearch(index, options?)`\n\nwraps a `scoutindex` in a reactive search state powered by `@vielzeug/ripple` signals.\n\n```ts\nfunction createsearch<t>(index: scoutindex<t>, options?: createsearchoptions): searchstate<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `options.debounce` | `number` | finite non negative integer milliseconds before query commit (default `200`). pass `0` for immediate updates. |\n| `options.limit` | `number` | finite non negative integer override of index level limit. |\n| `options.threshold` | `number` | finite `0..1` override of index level threshold. |\n| `options.minquerylength` | `number` | finite positive integer override of index level minimum query length. |\n\n**returns `searchstate<t>`**\n\n| member | type | description |\n| | | |\n| `query` | `signal<string>` | writable search query. set `.value` to trigger search. |\n| `results` | `readable<searchresult<t>[]>` | reactive results, updated after debounce. |\n| `issearching` | `readable<boolean>` | `true` during the debounce window. |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called. use to tie other lifecycles to this search. |\n| `disposed` | `boolean` | `true` after `dispose()` has been called. |\n| `clear()` | `() => void` | resets query, cancels debounce, clears results synchronously. |\n| `dispose()` | `() => void` | releases all reactive subscriptions. |\n| `[symbol.dispose]()` | `() => void` | `using` compatible disposal. |\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ name: 'ada lovelace' }, { name: 'grace hopper' }];\nconst index = createindex(users, { fields: ['name'] });\nconst search = createsearch(index, { debounce: 150 });\n\neffect(() => {\n console.log(search.results.value.map((result) => result.item.name));\n});\n\nsearch.query.value = 'ada';\n```\n\n \n\n## `createreactivesearch(items, options)`\n\ncreates a `scoutindex` and a reactive `searchstate` in one call — the shorthand for `createindex` + `createsearch`. returns a `reactivesearch<t>` which extends `searchstate<t>` with a `.index` property for incremental mutations.\n\n```ts\nfunction createreactivesearch<t>(\n items: t[],\n options: scoutindexoptions<t> & { debounce?: number },\n): reactivesearch<t>\n```\n\n**parameters**\n\n| param | type | description |\n| | | |\n| `items` | `t[]` | initial corpus to index. |\n| `options.fields` | `readonlyarray<fielddef<t>>` | fields to index. required. |\n| `options.debounce` | `number` | finite non negative integer debounce milliseconds (default `200`). |\n| `options.threshold` | `number` | finite overlap score in `0..1` (default `0.2`). |\n| `options.limit` | `number` | finite non negative integer max results (default `50`). |\n| `options.minquerylength` | `number` | finite positive integer min chars before trigram scoring (default `3`). |\n\n**returns `reactivesearch<t>`** — all `searchstate<t>` members plus:\n\n| member | type | description |\n| | | |\n| `index` | `scoutindex<t>` | the underlying index for `add`, `remove`, `reindex`. |\n\n**example**\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => console.log(search.results.value.map((result) => result.item.name)));\n\nsearch.index.add({ email: 'grace@example.com', name: 'grace hopper' });\nsearch.dispose();\n```\n\n \n\n## `findmatchranges(text, query)`\n\nnormalizes raw `query` with scout's tokenizer, then computes sorted, non overlapping literal ranges for each normalized token within `text`. useful when you need to apply highlighting to a different string than the indexed field value (e.g. a truncated preview or a differently formatted display string).\n\n```ts\nfunction findmatchranges(text: string, query: string): [number, number][]\n```\n\n**example**\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst ranges = findmatchranges('alice johnson', 'alice!');\n// [[0, 5]]\n\nconst parts = highlight('alice johnson', ranges);\n// [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n```\n\nreturns an empty array if either `text` or `query` is empty.\n\n \n\n## `highlight(text, ranges)`\n\nsplits `text` into `highlightpart[]` fragments based on `ranges` from `fieldmatch.ranges`.\n\n```ts\nfunction highlight(text: string, ranges: [number, number][]): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { highlight } from '@vielzeug/scout';\n\nhighlight('hello world', [[0, 5]]);\n// [{ text: 'hello', highlighted: true }, { text: ' world', highlighted: false }]\n```\n\nreturns an empty array when `text` is empty. returns a single unhighlighted part when `ranges` is empty.\n\n \n\n## `highlightfield(result, field, text)`\n\nconvenience shorthand that finds the match ranges for `field` in `result.matches` and calls `highlight()` in one step. eliminates the manual `result.matches.find(m => m.field === …).ranges` lookup.\n\n```ts\nfunction highlightfield<t>(result: searchresult<t>, field: keyof t & string, text: string): highlightpart[]\n```\n\n**example**\n\n```ts\nimport { createindex, highlightfield } from '@vielzeug/scout';\n\nconst users = [{ name: 'alice johnson' }];\nconst index = createindex(users, { fields: ['name'] });\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n console.log(parts.map((part) => part.highlighted ? `[${part.text}]` : part.text).join(''));\n}\n```\n\nwhen the field has no match (e.g. the query matched via a different field), returns a single unhighlighted part.\n\n \n\n## `tosearchmatcher(index, options?)`\n\nreturns an `(item, query) => boolean` matcher compatible with `sourcerer`'s `match` option.\n\n```ts\nfunction tosearchmatcher<t>(index: scoutindex<t>, options?: searchconstraints): (item: t, query: string) => boolean\n```\n\none matching item set is cached per query and index revision, so filtering does not repeat index work per item and stays current after index mutation.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst users = [{ email: 'ada@example.com', name: 'ada lovelace' }];\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst source = createlocalsource(users, { match: tosearchmatcher(index) });\n```\n\n \n\n## `tofilterpredicate(index, query, options?)`\n\nreturns a `(item: t) => boolean` predicate computed from a one time query. use with `array.filter` or vault's `query.filter()`.\n\n```ts\nfunction tofilterpredicate<t>(\n index: scoutindex<t>,\n query: string,\n options?: searchconstraints,\n): (item: t) => boolean\n```\n\nthe predicate is a snapshot — re call `tofilterpredicate` if the query or corpus changes.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst products = [{ title: 'widget pro' }, { title: 'gadget plus' }];\nconst index = createindex(products, { fields: ['title'] });\nconst results = products.filter(tofilterpredicate(index, 'widget'));\n\nconst top5 = products.filter(tofilterpredicate(index, 'widget', { limit: 5 }));\n```\n\n \n\n## `segmentwords(text)`\n\nsplits `text` into whitespace joined word segments using the runtime's native `intl.segmenter` — no dependency beyond the platform api. falls back to returning `text` unchanged where `intl.segmenter` isn't available.\n\n```ts\nfunction segmentwords(text: string): string\n```\n\n`tokenize()`'s trigram based scoring already works on unsegmented scripts (chinese, japanese, thai, ...) without this — trigrams are generated per character, not per word. `segmentwords()` is for `findmatchranges()` / highlighting and the multi word query semantics on `searchconstraints`, which assume space separated words. **not applied inside `tokenize()` itself** — benchmarked at ~15x slower than the plain regex path for the common whitespace delimited case, which would regress `createindex()`'s construction cost for every caller, not just those indexing unsegmented scripts.\n\n**example**\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst documents = [{ title: '日本語を勉強しています' }];\nconst index = createindex(documents, {\n fields: [{ field: 'title', stringify: (value) => segmentwords(string(value)) }],\n});\n```\n\n \n\n## `debugsearch(search)` <badge type=\"tip\" text=\"@vielzeug/scout/devtools\" />\n\n```ts\ndebugsearch<t>(search: searchstate<t>): () => void\n```\n\nlogs `query` → `issearching` → `results` transitions of a `searchstate` to `console.debug`. returns a function that unsubscribes all listeners installed by this call. import from the dedicated sub path so it's tree shaken from production bundles.\n\n::: warning development only\nlogs the full, literal search query string — if your queries may carry pii (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n**example**\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\nimport { debugsearch } from '@vielzeug/scout/devtools';\n\nconst index = createindex([{ name: 'ada lovelace' }], { fields: ['name'] });\nconst search = createsearch(index);\nconst stopdebugging = debugsearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query > \"alice\"\n// [scout:search] issearching > true\n// [scout:search] issearching > false\n// [scout:search] results > 1 item(s)\n\nstopdebugging();\n```\n\n \n\n## types\n\n### `searchconstraints`\n\nshared search tuning knobs used by `scoutindexoptions`, `createsearchoptions`, and all search functions.\n\n```ts\ntype searchconstraints = {\n limit?: number; // finite non negative integer; default 50\n minquerylength?: number; // finite positive integer; default 3\n threshold?: number; // finite 0..1 value; default 0.2\n};\n```\n\n### `fielddef<t>`\n\n```ts\ntype fielddef<t> =\n | (keyof t & string)\n | {\n field: keyof t & string;\n weight?: number; // default 1\n stringify?: (value: unknown) => string;\n };\n```\n\n### `scoutindexoptions<t>`\n\n```ts\ntype scoutindexoptions<t> = searchconstraints & {\n fields: readonlyarray<fielddef<t>>;\n};\n```\n\n### `createsearchoptions`\n\n```ts\ntype createsearchoptions = searchconstraints & {\n debounce?: number; // finite non negative integer; default 200\n};\n```\n\n### `searchresult<t>`\n\n```ts\ntype searchresult<t> = {\n item: t;\n matches: fieldmatch<keyof t & string>[]; // literal normalized token ranges; may be empty for fuzzy only results\n score: number; // [0, 1]; 1 when query is empty\n};\n```\n\n### `fieldmatch<f>`\n\ngeneric over the union of field names — `match.field` is typed to the actual fields of `t`.\n\n```ts\ntype fieldmatch<f extends string = string> = {\n field: f;\n ranges: [number, number][]; // literal normalized token [start, end] ranges in original field value\n};\n```\n\n### `highlightpart`\n\n```ts\ntype highlightpart = {\n highlighted: boolean;\n text: string;\n};\n```\n\n### `searchstate<t>`\n\n```ts\ntype searchstate<t> = {\n readonly query: signal<string>;\n readonly results: readable<searchresult<t>[]>;\n readonly issearching: readable<boolean>;\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n clear(): void;\n dispose(): void;\n [symbol.dispose](): void;\n};\n```\n\nsee `createsearch()` above for member descriptions.\n\n### `reactivesearch<t>`\n\n```ts\ntype reactivesearch<t> = searchstate<t> & {\n readonly index: scoutindex<t>;\n};\n```\n\nsee `createreactivesearch()` above.\n\n \n\n## errors\n\n### `scouterror`\n\nbase class for all scout errors. use `instanceof scouterror` to catch any scout originated error.\n\n```ts\nclass scouterror extends error {}\n```\n\n**named subclasses**\n\n| class | thrown when |\n| | |\n| `scoutconfigurationerror` | an index, search, or reactive search receives invalid fields or numeric options |\n| `scoutdisposederror` | a method is called on a disposed `searchstate` instance |\n",
1002
+ "usage": " \ntitle: scout — usage guide\ndescription: how to guide for @vielzeug/scout — building indexes, reactive search, highlighting, and integrating with sourcerer and vault.\n \n\n[[toc]]\n\n## basic usage\n\n### building an index\n\npass your item array and field configuration to `createindex`. all items are indexed immediately at construction time.\n\n```ts\nimport { createindex } from '@vielzeug/scout';\n\nconst users = [\n { email: 'ada@example.com', name: 'ada lovelace' },\n { email: 'grace@example.com', name: 'grace hopper' },\n];\n\nconst index = createindex(users, {\n fields: ['name', 'email'],\n});\n```\n\n### searching\n\ncall `index.search(query)` with any string. results are sorted by score descending.\n\n```ts\nconst results = index.search('alice');\n\nfor (const { item, score, matches } of results) {\n console.log(item.name, score);\n}\n```\n\nan empty `query` returns all items with `score = 1`:\n\n```ts\nindex.search(''); // all items, score = 1 each\n```\n\n### per field weights\n\ngive fields different weights to control score ranking. a match on a high weight field ranks the item higher than a match on a low weight field.\n\n```ts\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 3 }, // name matches rank 3× higher\n { field: 'department', weight: 1 },\n { field: 'bio', weight: 0.5 },\n ],\n});\n```\n\n### non string fields\n\nuse `stringify` to convert numeric or boolean fields to searchable text.\n\n```ts\nconst index = createindex(products, {\n fields: [\n 'title',\n { field: 'price', stringify: (v) => `$${v}` },\n { field: 'instock', stringify: (v) => (v ? 'available in stock' : 'out of stock') },\n ],\n});\n```\n\n### non latin scripts (cjk, thai, ...)\n\n`tokenize()` indexes any script correctly — trigrams are generated per character, so chinese, japanese, cyrillic, and accented latin text are all searchable out of the box. what it doesn't do is insert word boundaries for scripts that don't use spaces (chinese, japanese, thai, ...), which affects `findmatchranges()` / highlighting and multi word query semantics. pre segment those fields with `segmentwords()`:\n\n```ts\nimport { createindex, segmentwords } from '@vielzeug/scout';\n\nconst docs = [{ title: '日本語を勉強しています' }, { title: '我喜欢学习中文' }];\n\nconst index = createindex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentwords(string(v)) }],\n});\n\nindex.search('日本語'); // matches the first document\n```\n\n`segmentwords()` uses the runtime's native `intl.segmenter` — no dependency. it's opt in per field rather than built into `tokenize()` because it benchmarks ~15x slower than the default regex path for ordinary whitespace delimited text.\n\n### limiting results\n\npass `limit`, `threshold`, and `minquerylength` in options to control result count and quality. `limit` must be a finite non negative integer, `threshold` a finite value in `0..1`, and `minquerylength` a finite positive integer; invalid values throw `scoutconfigurationerror`.\n\n```ts\n// at most 10 results, minimum overlap score 0.3\nconst results = index.search('widget', { limit: 10, threshold: 0.3 });\n```\n\nper call options override the index level defaults set in `createindex`.\n\nscores come from the overlap (szymkiewicz–simpson) coefficient — the fraction of the *shorter*\ntrigram set (almost always the query) found in the longer one. this is deliberate for the\nautocomplete/command palette use case `createindex` targets: a short query that's a clean prefix\nof a much longer field value (e.g. `'fin'` against `'finalize q3 budget report'`) scores on how\nmuch of the query matched, not diluted by how much longer the target field happens to be.\n\n### controlling short query behaviour\n\nqueries shorter than `minquerylength` (default `3`) fall back to an o(n) substring containment scan. short query matches return `score = 1.0`.\n\n```ts\n// use trigram scoring even for 1 char queries (good for small corpora)\nconst index = createindex(items, { fields: ['name'], minquerylength: 1 });\n\n// force containment scan for all queries up to 8 chars (good for autocomplete on large sets)\nconst results = index.search('alice', { minquerylength: 8 });\n```\n\n## reactive search\n\n### `createreactivesearch()` — recommended\n\nfor most use cases, `createreactivesearch` builds the index and reactive state together in one call. it returns a `reactivesearch<t>` — a `searchstate<t>` with an extra `.index` property for incremental mutations:\n\n```ts\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { effect } from '@vielzeug/ripple';\n\nconst search = createreactivesearch(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n});\n\neffect(() => {\n if (search.issearching.value) showloadingspinner();\n else renderresults(search.results.value.map(r => r.item));\n});\n\ninput.addeventlistener('input', e => {\n search.query.value = e.currenttarget.value;\n});\n\n// add items at runtime via the exposed index\nsearch.index.add(newuser);\n\n// dispose when this owner is no longer needed\nsearch.dispose();\n```\n\n### `createsearch()` — separate index and state\n\nuse `createsearch` when you need to create the index independently — for example when sharing it across multiple reactive states:\n\n```ts\nimport { createindex, createsearch } from '@vielzeug/scout';\n\nconst index = createindex(users, { fields: ['name', 'email'] });\nconst search = createsearch(index, { debounce: 150 });\n```\n\n### `using` declaration\n\n```ts\n{\n using search = createreactivesearch(users, { fields: ['name'] });\n // search.dispose() called automatically at scope exit\n}\n```\n\n### zero debounce for synchronous updates\n\npass `debounce: 0` if you want results updated synchronously (no `issearching` flash). other debounce values must be finite non negative integers; invalid values throw `scoutconfigurationerror`.\n\n```ts\nconst search = createreactivesearch(users, { fields: ['name'], debounce: 0 });\n\nsearch.query.value = 'alice';\nconsole.log(search.results.value); // already updated\n```\n\n### resetting search\n\n```ts\nsearch.clear(); // resets query + results + issearching synchronously\n```\n\n### composing with ripple signals\n\n`search.results` is a `readable` signal — compose it into other computed values:\n\n```ts\nimport { computed } from '@vielzeug/ripple';\n\nconst topresult = computed(() => search.results.value[0]?.item ?? null);\n```\n\n## incremental updates\n\nuse `add()`, `remove()`, and `reindex()` for individual reference based mutations. use `setitems()` when a refreshed collection replaces the current corpus; scout reconciles membership, current field values, and source order in one notification.\n\n```ts\nconst index = createindex(products, { fields: ['title'] });\n\n// add a newly created item\nconst newproduct = { id: 99, title: 'new widget' };\nindex.add(newproduct);\n\n// remove a deleted item (by reference)\nindex.remove(products[0]);\n\n// re index a mutated item after in place mutation\nproducts[1].title = 'updated title';\nindex.reindex(products[1]);\n```\n\n> `remove()`, `reindex()`, and `setitems()` use **reference equality** (`===`). pass retained object references from the current corpus; `setitems()` collapses duplicate references.\n\n### replacing a refreshed corpus\n\n```ts\nconst latestproducts = await loadproducts();\n\nindex.setitems(latestproducts);\n```\n\n`setitems()` removes references absent from `latestproducts`, adds new references, reindexes retained references, and adopts the incoming order. it calls `onmutate()` once only when index membership, field values, or order changes.\n\n### inspecting the corpus\n\nuse `.items` to read all currently indexed items in insertion order, or `.size` for a count:\n\n```ts\nconsole.log(index.size); // 42\nconsole.log(index.items); // [{ id: 1, title: ... }, ...]\n```\n\n### reacting to mutations directly\n\n`createsearch()` already keeps `results` in sync with `add()`/`remove()`/`reindex()`/`setitems()` internally. `tosearchmatcher()` also invalidates its query cache after index mutation. if you're building your own reactivity on top of a plain `scoutindex` (no `ripple` involved), subscribe with `onmutate()`:\n\n```ts\nconst unsubscribe = index.onmutate(() => {\n rerenderresultslist();\n});\n\nindex.add(newproduct); // triggers rerenderresultslist()\n\nunsubscribe(); // when done\n```\n\n`onmutate()` only fires for mutations that actually change the index — a duplicate `add()` or a `remove()` of an unindexed item is a no op and doesn't notify listeners.\n\n## match highlighting\n\nevery `searchresult` carries `matches` — per field literal normalized token ranges. a fuzzy trigram candidate can have `matches: []` when no literal query token appears in its field text.\n\n### `highlightfield()` — recommended\n\n`highlightfield(result, field, text)` is the shorthand that does the field lookup and fragment split in one step:\n\n```ts\nimport { highlightfield } from '@vielzeug/scout';\n\nfor (const result of index.search('alice')) {\n const parts = highlightfield(result, 'name', result.item.name);\n // [{ text: 'alice', highlighted: true }, { text: ' johnson', highlighted: false }]\n renderhighlightedtext(parts);\n}\n```\n\n::: warning `part.text` is unescaped\n`highlight()` / `highlightfield()` return the **original, unescaped** field text split into\nfragments — never concatenate `part.text` into an html string for `innerhtml`. render each\npart as text (`textcontent`, a framework's text binding) and wrap `highlighted` parts in your\nown element:\n\n```ts\nfunction renderhighlightedtext(parts: highlightpart[]): documentfragment {\n const fragment = document.createdocumentfragment();\n\n for (const part of parts) {\n if (part.highlighted) {\n const mark = document.createelement('mark');\n\n mark.textcontent = part.text; // textcontent — never innerhtml\n fragment.appendchild(mark);\n } else {\n fragment.appendchild(document.createtextnode(part.text));\n }\n }\n\n return fragment;\n}\n```\n\n:::\n\n### `findmatchranges()` + `highlight()` — manual\n\nuse `findmatchranges()` when you need to apply match ranges to a different string than the indexed field value — for example a truncated preview or a differently formatted display string:\n\n```ts\nimport { findmatchranges, highlight } from '@vielzeug/scout';\n\nconst [result] = index.search('alice');\nconst preview = result.item.bio.slice(0, 100);\nconst ranges = findmatchranges(preview, 'alice');\nconst parts = highlight(preview, ranges);\n```\n\nor use `highlight()` directly when you already have the ranges from `result.matches`:\n\n```ts\nconst [result] = index.search('alice');\nconst namematch = result.matches.find(m => m.field === 'name');\nconst parts = highlight(result.item.name, namematch?.ranges ?? []);\n```\n\n## debug logging\n\nimport `debugsearch` from the dedicated `/devtools` sub path to log a `searchstate`'s `query` → `issearching` → `results` transitions to `console.debug`. the sub path is tree shaken from production bundles when not imported.\n\n::: warning development only\n`debugsearch()` logs the full, literal search query string — if your queries may carry pii (names, emails, medical/financial terms typed by end users), don't enable this in production.\n:::\n\n```ts\nimport { debugsearch } from '@vielzeug/scout/devtools';\n\nconst search = createsearch(index, { debounce: 150 });\nconst stopdebugging = debugsearch(search);\n\nsearch.query.value = 'alice';\n// [scout:search] query > \"alice\"\n// [scout:search] issearching > true\n// [scout:search] issearching > false\n// [scout:search] results > 1 item(s)\n\nstopdebugging();\n```\n\n## framework integration\n\n::: code group\n\n```tsx [react]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { useeffect, useref, usesyncexternalstore } from 'react';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const ref = useref(\n createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n }),\n );\n\n const search = ref.current;\n\n const results = usesyncexternalstore(\n (cb) => search.results.subscribe(cb),\n () => search.results.value,\n );\n\n useeffect(() => () => search.dispose(), [search]);\n\n return { query: search.query, results };\n}\n```\n\n```ts [vue 3]\nimport { createreactivesearch } from '@vielzeug/scout';\nimport { onscopedispose, ref, watch } from 'vue';\n\ntype user = { id: number; name: string; email: string };\n\nfunction usescoutsearch(items: user[]) {\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n const query = ref('');\n const results = ref(search.results.value);\n\n const unsub = search.results.subscribe(() => {\n results.value = search.results.value;\n });\n\n watch(query, (q) => { search.query.value = q; });\n\n onscopedispose(() => { unsub(); search.dispose(); });\n\n return { query, results };\n}\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createreactivesearch } from '@vielzeug/scout';\n import { ondestroy } from 'svelte';\n\n type user = { id: number; name: string; email: string };\n\n export let items: user[];\n\n const search = createreactivesearch(items, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n debounce: 150,\n });\n\n let query = '';\n let results = search.results.value;\n\n const unsub = search.results.subscribe(() => {\n results = search.results.value;\n });\n\n $: search.query.value = query;\n\n ondestroy(() => { unsub(); search.dispose(); });\n</script>\n\n<input bind:value={query} placeholder=\"search…\" />\n{#each results as { item }}\n <p>{item.name}</p>\n{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with sourcerer\n\n`tosearchmatcher()` adapts a `scoutindex` to `createlocalsource`'s explicit `match` callback. scout decides which items match; sourcerer keeps source query and pagination.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst index = createindex(users, {\n fields: [{ field: 'name', weight: 2 }, 'email'],\n});\n\nconst source = createlocalsource(users, {\n match: tosearchmatcher(index),\n});\n\nsource.setquery({ search: 'alice' });\n```\n\n> keep the index in sync using `index.add()` / `index.remove()` / `index.reindex()`.\n\n### with vault\n\n`tofilterpredicate()` returns an `(item: t) => boolean` snapshot predicate — pass it to vault's `query.filter()` or plain `array.filter`.\n\n```ts\nimport { createindex, tofilterpredicate } from '@vielzeug/scout';\n\nconst index = createindex(products, { fields: ['title', 'sku'] });\n\nconst matching = products.filter(tofilterpredicate(index, 'widget'));\n\nconst rows = await db.query('products')\n .filter(tofilterpredicate(index, searchterm))\n .toarray();\n```\n\ncall `tofilterpredicate` again whenever the query or corpus changes — the predicate is a snapshot, not reactive.\n\n## best practices\n\n **build the index once** — `createindex()` runs in o(corpus × field_length). create it at module level or in an effect, not inside render loops.\n **keep the index in sync** — call `index.add()` / `remove()` / `reindex()` when items mutate. stale index entries return wrong scores.\n **tune threshold before limit** — set a meaningful `threshold` (e.g. `0.25–0.4`) to suppress noise, then use `limit` to cap the list length.\n **set `minquerylength` for your corpus size** — the default `3` works well for most cases. lower it for small corpora where single char queries are expected; raise it for large corpora to avoid expensive o(n) scans.\n **dispose reactive state** — always call `search.dispose()` or use `using` when the component unmounts.\n **weight by importance** — name/title fields should have weight `2–3`; secondary fields (description, tags) stay at `1`.\n **segment cjk/thai fields explicitly** — `segmentwords()` is opt in per field, not automatic, to keep `createindex()` fast for the common whitespace delimited case.\n",
1003
+ "examples": " \ntitle: scout — examples\ndescription: practical examples for @vielzeug/scout — basic search, reactive combobox, and sourcerer integration.\n \n\n## examples\n\n [basic search](./examples/basic search)\n [reactive combobox](./examples/reactive combobox)\n [sourcerer integration](./examples/sourcerer integration)\n"
1004
+ },
1005
+ "examples": [
1006
+ {
1007
+ "id": "basic-search",
1008
+ "text": "basic search import { createindex, highlightfield } from '@vielzeug/scout'\n\nconst users = [\n { name: 'alice johnson', email: 'alice@example.com', role: 'admin' },\n { name: 'bob smith', email: 'bob@example.com', role: 'editor' },\n { name: 'charlie brown', email: 'charlie@example.com', role: 'viewer' },\n { name: 'alicia keys', email: 'alicia@example.com', role: 'editor' },\n { name: 'dave alison', email: 'dave@example.com', role: 'viewer' },\n]\n\nconst index = createindex(users, {\n fields: [\n { field: 'name', weight: 2 },\n { field: 'email' },\n ],\n threshold: 0.2,\n})\n\nconst results = index.search('alice')\n\nfor (const result of results) {\n const parts = highlightfield(result, 'name', result.item.name)\n const display = parts.map(p => p.highlighted ? `[${p.text}]` : p.text).join('')\n\n console.log(`${display} — ${result.item.email} (${result.score.tofixed(2)})`)\n}"
1009
+ },
1010
+ {
1011
+ "id": "highlight-results",
1012
+ "text": "highlight results import { createindex, highlightfield } from '@vielzeug/scout'\n\nconst docs = [\n { id: 1, title: 'getting started with typescript', body: 'typescript adds static types to javascript.' },\n { id: 2, title: 'advanced typescript patterns', body: 'generics, conditional types, and more.' },\n { id: 3, title: 'javascript fundamentals', body: 'learn the basics of javascript.' },\n { id: 4, title: 'react with typescript', body: 'build strongly typed react components.' },\n]\n\nconst index = createindex(docs, {\n fields: [\n { field: 'title', weight: 3 },\n { field: 'body', weight: 1 },\n ],\n})\n\nconst results = index.search('typescript')\n\nfor (const result of results) {\n const { item } = result\n console.log(`\\n[doc ${item.id}] ${item.title}`)\n\n const titleparts = highlightfield(result, 'title', item.title)\n console.log(' title:', titleparts.map(p => p.highlighted ? `>>>${p.text}<<<` : p.text).join(''))\n\n const bodyparts = highlightfield(result, 'body', item.body)\n console.log(' body: ', bodyparts.map(p => p.highlighted ? `>>>${p.text}<<<` : p.text).join(''))\n}"
1013
+ },
1014
+ {
1015
+ "id": "incremental-updates",
1016
+ "text": "incremental updates import { createindex } from '@vielzeug/scout'\n\nconst products = [\n { id: 1, title: 'wireless mouse', price: 25 },\n { id: 2, title: 'mechanical keyboard', price: 80 },\n { id: 3, title: 'usb c hub', price: 35 },\n]\n\nconst index = createindex(products, { fields: ['title'] })\n\n// onmutate() fires after changed add()/remove()/reindex()/setitems() operations —\n// not on no ops like removing an item that isn't indexed\nconst unsubscribe = index.onmutate(() => {\n console.log(` (index changed — now ${index.size} items)`)\n})\n\nconsole.log('search \"keyboard\":', index.search('keyboard').map(r => r.item.title))\n\n// add a newly created item\nindex.add({ id: 4, title: 'gaming keyboard', price: 120 })\nconsole.log('after add():', index.search('keyboard').map(r => r.item.title))\n\n// re index a mutated item — reference equality, so mutate in place first\nproducts[0].title = 'wireless trackball'\nindex.reindex(products[0])\nconsole.log('after reindex():', index.search('trackball').map(r => r.item.title))\n\n// reconcile a refreshed corpus in one mutation — removes missing references,\n// adds new ones, reindexes retained values, and preserves this incoming order\nindex.setitems([products[0], { id: 4, title: 'portable ssd', price: 95 }])\nconsole.log('after setitems():', index.items.map(item => item.title))\n\nunsubscribe()"
1017
+ },
1018
+ {
1019
+ "id": "reactive-search",
1020
+ "text": "reactive search import { createreactivesearch } from '@vielzeug/scout'\n\nconst users = [\n { name: 'alice johnson', email: 'alice@example.com' },\n { name: 'bob smith', email: 'bob@example.com' },\n { name: 'charlie brown', email: 'charlie@example.com' },\n { name: 'alicia keys', email: 'alicia@example.com' },\n]\n\n// one call creates the index and the reactive search state together\nconst search = createreactivesearch(users, { fields: ['name', 'email'], debounce: 0 })\n\nconst show = (label) => {\n console.log(label, '\\u2192', search.results.value.map(r => r.item.name).join(', ') || '(none)')\n}\n\nshow('empty query') // all 4 users\n\nsearch.query.value = 'ali'\nshow('query: \"ali\"') // alice johnson, alicia keys, dave alison\n\nsearch.query.value = 'alice'\nshow('query: \"alice\"') // alice johnson\n\n// add a new user at runtime via the exposed index\nsearch.index.add({ name: 'alice cooper', email: 'cooper@example.com' })\nshow('after add()') // now includes alice cooper\n\nsearch.clear()\nshow('after clear()') // all 5 users\n\nsearch.dispose()\nconsole.log('disposed:', search.disposed)"
1021
+ },
1022
+ {
1023
+ "id": "segment-words",
1024
+ "text": "segmenting non latin text import { createindex, segmentwords } from '@vielzeug/scout'\n\n// cjk text has no spaces between words — segmentwords() inserts them via the\n// runtime's native intl.segmenter, so word boundary features work like they do for latin text\nconst docs = [\n { id: 1, title: '日本語を勉強しています' },\n { id: 2, title: '我喜欢学习中文' },\n { id: 3, title: 'learning japanese is fun' },\n]\n\nconsole.log('segmented:', segmentwords('日本語を勉強しています'))\n\nconst index = createindex(docs, {\n fields: [{ field: 'title', stringify: (v) => segmentwords(string(v)) }],\n})\n\nconst results = index.search('日本語')\nconsole.log('search \"日本語\":', results.map(r => r.item.title))"
1025
+ }
1026
+ ],
1027
+ "exports": "createindex createreactivesearch createsearch scoutconfigurationerror scoutdisposederror scouterror debugsearch findmatchranges highlight highlightfield segmentwords tofilterpredicate tosearchmatcher",
1028
+ "keywords": "fuzzy search search trigram full text filter highlight reactive ripple",
1029
+ "name": "@vielzeug/scout",
1030
+ "related": "arsenal sourcerer vault ripple",
1031
+ "slug": "scout",
1032
+ "source": "export { tofilterpredicate, tosearchmatcher } from './adapters';\nexport { scoutconfigurationerror, scoutdisposederror, scouterror } from './errors';\nexport { findmatchranges, highlight, highlightfield } from './highlight';\nexport type { reactivesearch } from './reactive';\nexport { createreactivesearch, createsearch } from './reactive';\nexport type { scoutindex } from './scout index';\nexport { createindex } from './scout index';\nexport { segmentwords } from './segment';\nexport type {\n createsearchoptions,\n fielddef,\n fieldmatch,\n highlightpart,\n scoutindexoptions,\n searchconstraints,\n searchresult,\n searchstate,\n} from './types';\n"
1033
+ },
1034
+ {
1035
+ "category": "ui performance",
1036
+ "description": "lightweight, framework agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.",
1037
+ "docs": {
1038
+ "index": " \ntitle: scroll — virtual list engine for typescript\ndescription: lightweight, framework agnostic virtual list engine with variable heights, sticky headers, grid support, and reactive integration.\npackage: scroll\ncategory: ui performance\nkeywords: [virtual list, virtualization, windowing, scroll, performance, large lists]\nrelated: [dnd, ore, refine]\nexports:\n [\n createvirtualizer,\n createdomvirtuallist,\n createvirtualscroller,\n creategroupedvirtualizer,\n creategridvirtualizer,\n createmeasurementcache,\n scrollconfigurationerror,\n scrollerror,\n scrollrangeerror,\n default_estimate_size,\n default_overscan,\n ]\nenvironments: [browser]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"scroll\" />\n\n## why scroll?\n\nrendering thousands of items as real dom nodes freezes the browser. each node consumes layout, paint, and memory — long lists need to render only what is visible in the viewport.\n\n```ts\n// before — render all 10 000 items (browser freezes)\nlist.replacechildren();\nitems.foreach((item) => {\n const el = document.createelement('div');\n el.textcontent = item.name;\n list.appendchild(el); // 10 000 dom nodes\n});\n\n// after — scroll (only ~15 visible rows in the dom at any time)\nimport { createvirtualizer } from '@vielzeug/scroll';\nconst virtualizer = createvirtualizer(scrollel, {\n count: items.length,\n estimatesize: 36,\n onchange: ({ items: visibleitems, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n for (const { index, start } of visibleitems) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${start}px;height:36px;`;\n el.textcontent = items[index].name;\n list.appendchild(el);\n }\n },\n});\n```\n\n| feature | scroll | tanstack virtual | react window |\n| | | | |\n| bundle size | <packageinfo package=\"scroll\" type=\"size\" /> | ~5 kb | ~8 kb |\n| framework agnostic | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | react only |\n| variable heights | <ore icon name=\"check\" size=\"16\"></ore icon> measured | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> static |\n| o(log n) lookup | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| `using` support | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> `@vielzeug/ripple` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use scroll when** you need to render large lists in a framework agnostic environment with precise control over item measurement and scroll position.\n\n**consider tanstack virtual** if you need its framework adapters and ecosystem integration.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/scroll\n```\n\n```sh [npm]\nnpm install @vielzeug/scroll\n```\n\n```sh [yarn]\nyarn add @vielzeug/scroll\n```\n\n:::\n\n## quick start\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst spacer = document.queryselector<htmlelement>('.spacer')!;\nconst list = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n // stretch the container so the scrollbar reflects the full list\n spacer.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textcontent = `row ${item.index}`;\n list.appendchild(el);\n }\n },\n});\n\n// clean up\nvirt.dispose();\n```\n\n### entry points\n\nall apis export from a single entry: `@vielzeug/scroll`.\n\n## features\n\n<div class=\"features grid\">\n\n **framework agnostic** — callback based `onchange` connects to any rendering layer (react, vue, svelte, lit, vanilla dom)\n **fixed and variable heights** — pass a fixed number, a per index estimator function, or call `measure()` after rendering for exact heights\n **batched measurements** — calling `measure()` many times in a single tick coalesces into one prefix sum rebuild via `queuemicrotask`\n **stable key reflow** — call `refresh()` after reorder/filter changes to rebuild offsets without discarding measured sizes\n **sticky headers** — mark items with `sticky` to pin them at the viewport top; `creategroupedvirtualizer` handles section headers automatically\n **grouped sections** — `creategroupedvirtualizer` virtualizes sectioned data with per section headers, `onchange` state, and `scrolltosection`/`scrolltoitem`\n **grid virtualization** — `creategridvirtualizer` virtualizes two dimensional data with independent row/column measurement and `scrolltocell`\n **reactive state** — provide a `signal` factory to expose current state as a ripple `signal`\n **keyboard navigation** — enable `keyboardscroll` for arrow/page/home/end key support\n **auto measurement** — enable `automeasure` to automatically measure visible items via `resizeobserver`\n **dom adapter** — `createdomvirtuallist` and `createvirtualscroller` manage virtualizer lifecycle, list height styles, and dom node pooling\n **skipped re renders** — `onchange` is not called when a scroll event doesn't move the visible window across an item boundary\n **programmatic scrolling** — `scrolltoindex()` with `start`, `end`, `center`, and `auto` alignment; `scrolltooffset()` for pixel control; `scrolltorow()`/`scrolltocolumn()` for grids; all support `behavior: 'smooth'`\n **horizontal + window targets** — supports both element and `window` scrolling, in vertical or horizontal mode\n **asymmetric overscan + gap** — tune start/end overscan independently and add inter item spacing\n **atomic updates** — `virt.update(...)` lets you change count, estimator, overscan, and more in one call\n **clamp safe** — `scrolltoindex` silently clamps out of range indices\n **scroll state events** — `onscrollingchange` fires when scrolling starts/stops; `onscrollend` fires once scrolling settles (native `scrollend` or debounce fallback); `isscrolling` getter available at any time\n **scroll anchor** — viewport position is preserved visually when `estimatesize` changes via `update()`\n **prepend support** — `prepend()` adds items at the top while keeping the viewport visually stable\n **disposable** — implements `[symbol.dispose]` for `using` declarations\n `scrollconfigurationerror` — rejects malformed static configuration before listeners attach or updates apply\n\n</div>\n\n## how it works\n\nscroll maintains a prefix sum offset array. on every scroll event it runs two binary searches — one for the first visible index, one for the last — to determine the render window in o(log n) time. only the items within that window (plus `overscan` on each side) are passed to `onchange`.\n\n```text\nitems: [0] [1] [2] [3] [4] [5] [6] ...\noffsets: 0 36 72 108 144 180 216 ...\n\nscrolltop = 90, containerheight = 120 → visible items 2–5\nwith overscan=3: render items 0–8\n```\n\nthe offset array is rebuilt (o(n)) only when layout inputs change: on `measure()` flush, `refresh()`, `update({ count })`, `update({ estimatesize })`, or `invalidate()`. scroll and resize events recompute the visible window without rebuilding offsets.\n\n## documentation\n\n<div class=\"doc links\">\n\n [usage guide](./usage.md)\n [api reference](./api.md)\n [examples](./examples.md)\n [migration guide](./migration.md)\n\n</div>\n\n## see also\n\n<div class=\"see also\">\n\n [refine](/refine/) — accessible web components that use scroll internally for virtualized listboxes and comboboxes\n [ore](/ore/) — web component authoring layer; use with scroll to build virtualizing custom elements\n [dnd](/dnd/) — drag and drop engine; combine with scroll to make sortable virtual lists\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1039
+ "api": " \ntitle: scroll — api reference\ndescription: complete api reference for the scroll virtual list engine.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createvirtualizer()` | core 1d virtualizer | sync | `onchange` fires on construction — wire dom first |\n| `createdomvirtuallist()` | dom adapter for dropdown/listbox uis | sync | virtualizer is created lazily on first `setitems()` |\n| `createvirtualscroller()` | self contained scroller (creates dom) | sync | `dispose()` removes the generated scroll element |\n| `creategroupedvirtualizer()` | sectioned list with sticky headers | sync | `update()` preserves measured sizes — call `invalidate()` only on font/layout changes |\n| `creategridvirtualizer()` | two dimensional grid virtualizer | sync | `onrangechange` fires even when `onchange` is omitted |\n\n## package entry point\n\neverything exports from a single entry:\n\n```ts\nimport {\n createvirtualizer,\n createdomvirtuallist,\n createvirtualscroller,\n creategroupedvirtualizer,\n creategridvirtualizer,\n createmeasurementcache,\n default_estimate_size,\n default_overscan,\n scrollerror,\n scrollconfigurationerror,\n scrollrangeerror,\n type virtualizer,\n type virtualitem,\n type virtualizerstate,\n type virtualizeroptions,\n type virtualizerupdateoptions,\n type scrolltoindexoptions,\n type overscan,\n type virtualkey,\n type measurementcache,\n type scrolltarget,\n type domvirtuallistoptions,\n type domvirtuallistcontroller,\n type domvirtuallistrenderargs,\n type recyclefn,\n type virtualrenderitem,\n type sticktobottomoptions,\n type virtualscrolleroptions,\n type groupsection,\n type groupvirtualizer,\n type groupvirtualizeroptions,\n type groupvirtualizerstate,\n type groupvirtualizerupdateoptions,\n type groupvirtualheader,\n type groupvirtualitem,\n type gridvirtualizer,\n type gridvirtualizeroptions,\n type gridvirtualizerstate,\n type gridvirtualizerupdateoptions,\n type gridrangechangeevent,\n type scrolltocelloptions,\n} from '@vielzeug/scroll';\n```\n\n## `createvirtualizer(target, options)`\n\n```ts\ncreatevirtualizer(target: scrolltarget, options: virtualizeroptions): virtualizer;\n```\n\ncreates and immediately attaches a virtualizer to the provided scroll container. `onchange` fires synchronously on construction with the initial visible window. call `dispose()` on unmount.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst rows = [{ label: 'ada lovelace' }, { label: 'grace hopper' }];\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst listel = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n gap: 8,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const row = document.createelement('div');\n row.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:${item.size}px;`;\n row.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(row);\n }\n },\n});\n```\n\n### parameters\n\n| parameter | type | description |\n| | | |\n| `target` | `htmlelement \\| window` | scroll container to observe |\n| `options` | `virtualizeroptions` | initial options |\n\n### `virtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `count` | `number` | required | total item count |\n| `estimatesize` | `number \\| (index: number) => number` | `36` | fixed size or per index estimate in pixels |\n| `gap` | `number` | `0` | gap between adjacent items in pixels |\n| `getitemkey` | `(index: number) => string \\| number` | `index => index` | stable key for the measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along the x axis instead of y |\n| `initialoffset` | `number` | — | initial scroll position; applied once on construction |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `automeasure` | `boolean` | `false` | automatically measure visible items via resizeobserver |\n| `measurementcache` | `measurementcache` | — | shared external cache for scroll restoration or ssr pre measurement |\n| `onchange` | `(state: virtualizerstate) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric on both sides |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) used to detect scroll end when native `scrollend` is unavailable |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number) => boolean` | — | mark an item as a sticky header (pinned at viewport top) |\n\ncallbacks and `scrollenddelay` can be replaced through `update()`; `horizontal` and `initialoffset` remain construction only.\n\n**returns:** `virtualizer`\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n`items` contains the currently visible items plus overscan. `stickyitems` contains items marked sticky that are pinned at the viewport top.\n\n### `virtualizer` — read only properties\n\n| property | type | description |\n| | | |\n| `count` | `number` | current item count |\n| `disposalsignal` | `abortsignal` | aborted when `dispose()` is called |\n| `disposed` | `boolean` | `true` after `dispose()` is called |\n| `isscrolling` | `boolean` | `true` while the user is scrolling; `false` once settled |\n| `items` | `virtualitem[]` | currently rendered items. always populated. |\n| `scrolloffset` | `number` | current scroll position in pixels |\n| `stickyitems` | `virtualitem[]` | items pinned at the viewport top (requires `sticky` option) |\n| `totalsize` | `number` | total height (or width in horizontal mode) |\n\n### `virtualizer` — methods\n\n| method | signature | description |\n| | | |\n| `update` | `(next: virtualizerupdateoptions) => void` | atomically update live options |\n| `measure` | `(index: number, size: number) => void` | record one measured size; rebuild batched in microtask |\n| `measurebatch` | `(entries: array<{ index: number; size: number }>) => void` | record many sizes; single rebuild |\n| `measureel` | `(index: number, el: htmlelement) => () => void` | attach resizeobserver to auto measure. returns a disconnect function |\n| `refresh` | `() => void` | rebuild offset table and re emit; preserves cached measurements |\n| `prepend` | `(additionalcount: number) => void` | add items at the top; adjusts scroll offset to keep viewport stable |\n| `scrolltoindex` | `(index: number, options?: scrolltoindexoptions) => void` | scroll to an item; out of range indices are clamped |\n| `scrolltooffset` | `(offset: number, options?: { behavior?: scrollbehavior }) => void` | scroll to a raw pixel offset |\n| `scrolltotop` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to offset `0` |\n| `scrolltobottom` | `(options?: { behavior?: scrollbehavior }) => void` | scroll to the end of the list |\n| `isatend` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto follow (chat \"stick to bottom\") |\n| `invalidate` | `() => void` | clear all measurements and rebuild from estimates |\n| `dispose` | `() => void` | detach listeners; idempotent |\n| `[symbol.dispose]` | `() => void` | delegates to `dispose()` — enables `using` declarations |\n\n### `update(next)`\n\natomically updates one or more live options. accepts: `automeasure`, `count`, `estimatesize`, `gap`, `getitemkey`, `keyboardscroll`, `measurementcache`, `onchange`, `onscrollend`, `onscrollingchange`, `overscan`, `scrollenddelay`, and `sticky`. `horizontal` and `initialoffset` remain construction only. invalid static numeric values throw `scrollconfigurationerror` before any update applies.\n\nwhen `estimatesize` changes, the measurement cache is cleared and a scroll anchor is applied to keep the current viewport position visually stable.\n\n```ts\nvirt.update({ count: rows.length });\nvirt.update({ estimatesize: 40 });\nvirt.update({ gap: 8, overscan: { start: 5, end: 5 } });\n```\n\n### `measure(index, size)` and `measurebatch(entries)`\n\nreport exact sizes for variable height rows. calls within one microtask tick coalesce into a single offset rebuild. `measure()` is a no op when the new size equals the current effective size.\n\n```ts\nvirt.measure(item.index, el.offsetheight);\n\n// prefer measurebatch for resizeobserver batches\nvirt.measurebatch(entries.map((e) => ({ index: number(e.target.dataset.index), size: e.contentrect.height })));\n```\n\n### `measureel(index, el)`\n\nattaches a `resizeobserver` to auto measure `el` on resize. returns a disconnect function. the\nobserver is also disconnected automatically when the virtualizer is disposed, so calling the\nreturned function is only needed to stop observing a specific element early (e.g. before it is\nrecycled or removed).\n\n```ts\nconst disconnect = virt.measureel(item.index, rowel);\n// later: disconnect();\n```\n\n### `refresh()`\n\nrebuilds the full offset table and re emits. preserves cached measurements. use after reordering, filtering, or any data change where sizes may have changed.\n\n### `prepend(additionalcount)`\n\nadds `additionalcount` items at the front while adjusting scroll offset so the viewport stays visually stable. use for \"load previous page\" patterns.\n\n### `scrolltoindex(index, options?)`\n\nscroll to an item. out of range indices are clamped silently.\n\n| `align` | behavior |\n| | |\n| `'start'` | item top at viewport top |\n| `'end'` | item bottom at viewport bottom |\n| `'center'` | item centered in the viewport |\n| `'auto'` (default) | no scroll if already fully visible; otherwise minimum scroll |\n\n```ts\nvirt.scrolltoindex(0, { align: 'start' });\nvirt.scrolltoindex(500, { align: 'center', behavior: 'smooth' });\nvirt.scrolltoindex(focusedindex, { align: 'auto' });\n```\n\n### `scrolltooffset(offset, options?)`\n\n```ts\nvirt.scrolltooffset(number(sessionstorage.getitem('scrolloffset') ?? '0'));\n```\n\n### `invalidate()`\n\nclears all measured sizes and rebuilds from estimator values.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\n### `dispose()` and `[symbol.dispose]()`\n\n`dispose()` detaches observers and event listeners. it is idempotent.\n\n```ts\n{\n using virt = createvirtualizer(scrollel, { count: rows.length, onchange: render });\n} // → dispose() called automatically\n```\n\n## `createdomvirtuallist(options)`\n\n```ts\ncreatedomvirtuallist<t>(options: domvirtuallistoptions<t>): domvirtuallistcontroller<t>;\n```\n\ndom focused adapter. manages virtualizer lifecycle, applies list height styles automatically, and provides a node pool via `recycle`. the virtualizer is created lazily on the first non empty `setitems()` call and destroyed automatically when `setitems([])` is called.\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\nconst ctrl = createdomvirtuallist<row>({\n estimatesize: 36,\n getitemkey: (_, row) => row.id,\n listelement: listel,\n scrollelement: scrollel,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);height:${item.size}px;`;\n el.textcontent = item.data.label;\n listel.appendchild(el);\n }\n },\n});\n\nctrl.setitems(rows);\nctrl.scrolltoindex(focusedindex, { align: 'auto' });\nctrl.dispose();\n```\n\n### `domvirtuallistoptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `scrollelement` | `htmlelement \\| window` | required | scroll container to observe |\n| `listelement` | `htmlelement` | required | element that receives height and item children |\n| `render` | `(args: domvirtuallistrenderargs<t>) => void` | required | called on every visible window change |\n| `estimatesize` | `number \\| (index, item) => number` | `36` | fixed or per item size estimate |\n| `gap` | `number` | `0` | gap between items in pixels |\n| `getitemkey` | `(index, item) => string \\| number` | — | stable key; keeps measurements across `setitems()` calls |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | extra items outside the viewport; number = symmetric |\n| `signal` | `(init: virtualizerstate) => signal<virtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n| `sticky` | `(index: number, item: t) => boolean` | — | mark items as sticky headers |\n| `clear` | `(listel: htmlelement) => void` | — | custom teardown for listel; defaults to `textcontent = ''` |\n| `sticktobottom` | `boolean \\| sticktobottomoptions` | — | auto scroll to the end after `setitems()` whenever the list was already at (or near) the end — the chat \"stick to bottom on new message\" pattern |\n\nwithout `getitemkey`, each `setitems()` call drops cached measurements.\n\n### `sticktobottomoptions`\n\n| option | type | default | description |\n| | | | |\n| `enabled` | `boolean` | `true` | enable/disable at runtime — pass the object form to toggle without removing it |\n| `threshold` | `number` | `48` | distance in pixels from the end still considered \"at the end\" |\n\n`sticktobottom` fires on **any** `setitems()` call made while the list is at the end — not just when the item count grows. this also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. it never fires while the user has scrolled away from the end, so reading older messages is never interrupted.\n\n```ts\nconst chat = createdomvirtuallist<message>({\n estimatesize: 48,\n getitemkey: (_, m) => m.id,\n listelement: listel,\n render: rendermessages,\n scrollelement: scrollel,\n sticktobottom: true, // or { threshold: 80 } for a larger \"still at bottom\" tolerance\n});\n\nchat.setitems(messages); // scrolls to bottom on first load\n// … later, a new message arrives (or the last one grows while streaming) …\nchat.setitems([...messages, newmessage]); // follows along only if the user was already at the bottom\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>; // visible items — each has .data + layout fields\n listel: htmlelement;\n recycle: recyclefn; // node pool — returns existing node or calls create()\n stickyitems: array<virtualrenderitem<t>>; // sticky items (requires sticky option)\n totalsize: number;\n};\n```\n\n`virtualrenderitem<t>` is `virtualitem` (`start`, `end`, `size`, `index`) enriched with `data: t`.\n\n`recycle(key, create)` returns a live node for `key` if one exists in the pool, or calls `create()` for a new one. nodes not reused in a render cycle are removed automatically. `listel.style.height` is set before `render` is called — you do not need to set it yourself.\n\n### `domvirtuallistcontroller<t>`\n\nextends `virtualizer` (minus `prepend` and `update`) with `setitems()`. all virtualizer methods and live getters are available directly.\n\n| member | description |\n| | |\n| `setitems(items)` | set the current item array. spawns virtualizer on first non empty call; destroys it on `[]` |\n| `count` | current item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once settled (live getter) |\n| `items` | currently rendered virtual items (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `scrolloffset` | current scroll position (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `measure` | delegate to underlying virtualizer; no op before first `setitems` |\n| `measurebatch` | batch measurement delegate |\n| `measureel` | attach auto measuring resizeobserver |\n| `refresh` | rebuild offset table and re emit |\n| `invalidate` | clear measurements and rebuild from estimates |\n| `scrolltoindex` | scroll to an item |\n| `scrolltooffset` | scroll to a pixel offset |\n| `scrolltotop` | scroll to offset `0` |\n| `scrolltobottom` | scroll to the end of the list |\n| `isatend` | `true` when within `threshold` px of the end |\n| `dispose` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called (live getter) |\n| `[symbol.dispose]` | delegates to `dispose()` |\n\n## `createvirtualscroller(container, options)`\n\n```ts\ncreatevirtualscroller<t>(container: htmlelement, options: virtualscrolleroptions<t>): domvirtuallistcontroller<t>;\n```\n\ncreates a scroll container `div` and inner list `div`, appends them to `container`, and returns a fully wired `domvirtuallistcontroller`. useful when the scroll dom doesn't already exist.\n\n```ts\nconst list = createvirtualscroller<row>(document.getelementbyid('root')!, {\n estimatesize: 36,\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const el = recycle(item.data.id, () => document.createelement('div'));\n el.textcontent = item.data.label;\n el.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);`;\n listel.appendchild(el);\n }\n },\n});\n\nlist.setitems(rows);\nlist.dispose(); // also removes the generated scroll container\n```\n\n`virtualscrolleroptions<t>` is `domvirtuallistoptions<t>` minus `listelement`/`scrollelement`, plus:\n\n| option | type | description |\n| | | |\n| `containerclass` | `string` | css class applied to the generated scroll element |\n\n`dispose()` removes the generated scroll container from the dom.\n\n## `creategroupedvirtualizer(target, options)`\n\n```ts\ncreategroupedvirtualizer<t>(target: scrolltarget, options: groupvirtualizeroptions<t>): groupvirtualizer<t>;\n```\n\nvirtualizes a sectioned list. headers are automatically sticky (pinned at viewport top while the section is in view).\n\n```ts\nimport { creategroupedvirtualizer } from '@vielzeug/scroll';\n\ntype contact = { id: number; name: string };\n\nconst virt = creategroupedvirtualizer<contact>(scrollel, {\n estimateheadersize: 32,\n estimateitemsize: 48,\n sections: [\n { label: 'a', items: [{ id: 1, name: 'alice' }] },\n { label: 'b', items: [{ id: 2, name: 'bob' }] },\n ],\n onchange: ({ headers, items, stickyheader, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n if (stickyheader) {\n const el = document.createelement('div');\n el.classname = 'sticky header';\n el.textcontent = stickyheader.label;\n listel.appendchild(el);\n }\n\n for (const header of headers) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${header.start}px;height:${header.size}px;`;\n el.textcontent = header.label;\n listel.appendchild(el);\n }\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;height:${item.size}px;`;\n el.textcontent = item.data.name;\n listel.appendchild(el);\n }\n },\n});\n\nvirt.scrolltosection(1, { align: 'start' });\nvirt.update(nextsections);\nvirt.dispose();\n```\n\n### `groupvirtualizeroptions<t>`\n\n| option | type | default | description |\n| | | | |\n| `sections` | `array<groupsection<t>>` | required | initial sections |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | — | called when the visible window changes; replace through `update()`. |\n| `onscrollend` | `(offset: number) => void` | — | called when scrolling settles; replace through `update()`. |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | — | called when scroll activity starts or stops; replace through `update()`. |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | `36` | header height estimate |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | `36` | item height estimate |\n| `getitemkey` | `(item: t, itemindex: number, sectionindex: number) => virtualkey` | — | stable key for measurement cache |\n| `horizontal` | `boolean` | `false` | virtualize along x axis |\n| `measurementcache` | `measurementcache` | — | external measurement cache |\n| `overscan` | `number \\| { start?: number; end?: number }` | `3` | overscan on each side (number = symmetric) |\n| `scrollenddelay` | `number` | `150` | debounce delay (ms) for scroll end detection |\n| `signal` | `(init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>` | — | optional signal factory to expose state as a reactive signal |\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n`stickyheader` is the header of the section currently at or above the viewport top, or `null` when at the very top. render it as a floating overlay above the list.\n\n### `groupvirtualitem<t>` and `groupvirtualheader`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number; // index within the section\n sectionindex: number;\n}\n\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizer<t>` — methods\n\n`groupvirtualizer<t>` is an independent interface that exposes all core virtualizer methods directly, plus grouped specific navigation.\n\n| method / property | description |\n| | |\n| `update(sections, opts?)` | replace all sections with optional config overrides; see `groupvirtualizerupdateoptions<t>` |\n| `scrolltosection(i, options?)` | scroll to section header at index `i`. out of range is a no op |\n| `scrolltoitem(s, i, options?)` | scroll to item `i` in section `s`. out of range is a no op |\n| `scrolltoindex(i, options?)` | scroll to flat index `i` (from underlying virtualizer) |\n| `scrolltooffset(offset, options?)` | scroll to a raw pixel offset |\n| `scrolltotop(options?)` | scroll to offset `0` |\n| `scrolltobottom(options?)` | scroll to the end of the list |\n| `measure(index, size)` | record a measurement for a flat index |\n| `measurebatch(entries)` | batch record measurements for flat indices |\n| `measureel(index, el)` | attach auto measuring resizeobserver. returns disconnect function |\n| `invalidate()` | clear all measurements and rebuild |\n| `refresh()` | rebuild offset table without clearing measurements |\n| `count` | total flat item count (live getter) |\n| `disposalsignal` | `abortsignal` aborted on `dispose()` |\n| `isscrolling` | `true` while the user is scrolling; `false` once scroll settles |\n| `items` | currently rendered group items (live getter) |\n| `scrolloffset` | current scroll position in pixels (live getter) |\n| `stickyitems` | sticky items pinned at viewport top (live getter) |\n| `totalsize` | total list size in pixels (live getter) |\n| `dispose()` | teardown; idempotent |\n| `disposed` | `true` after `dispose()` is called |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\nall scroll methods accept an optional `scrolltoindexoptions` object (`{ align?, behavior?, oncomplete? }`).\n\n### `groupvirtualizerupdateoptions<t>`\n\npassed as the second argument to `groupvirtualizer.update()`. all fields are optional — omit any you don't want to change.\n\n| option | type | description |\n| | | |\n| `estimateheadersize` | `number \\| (section, sectionindex) => number` | new header size estimate, applied on next rebuild |\n| `estimateitemsize` | `number \\| (item, itemindex, sectionindex) => number` | new item size estimate, applied on next rebuild |\n| `getitemkey` | `(item, itemindex, sectionindex) => virtualkey` | new item key function |\n| `measurementcache` | `measurementcache` | hot swap the measurement cache |\n| `onchange` | `(state: groupvirtualizerstate<t>) => void` | replace the active onchange callback |\n| `onscrollend` | `(offset: number) => void` | replace the active onscrollend callback |\n| `onscrollingchange` | `(isscrolling: boolean) => void` | replace the active onscrollingchange callback |\n| `overscan` | `number \\| { start?, end? }` | new overscan count |\n| `scrollenddelay` | `number` | new debounce delay (ms) for scroll end detection |\n\n> `horizontal` remains construction only.\n\n## `creategridvirtualizer(target, options)`\n\n```ts\ncreategridvirtualizer(target: scrolltarget, options: gridvirtualizeroptions): gridvirtualizer;\n```\n\ntwo dimensional virtualizer. fires `onchange` with visible row and column descriptors. callers form the cross product `rows × cols` to render visible cells.\n\n```ts\nimport { creategridvirtualizer } from '@vielzeug/scroll';\n\nconst grid = creategridvirtualizer(scrollel, {\n rowcount: 10_000,\n colcount: 50,\n estimaterowsize: 36,\n estimatecolsize: 120,\n onchange: ({ rows, cols, totalheight, totalwidth }) => {\n containerel.style.csstext = `position:relative;height:${totalheight}px;width:${totalwidth}px;`;\n containerel.replacechildren();\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createelement('div');\n cell.style.csstext = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;`;\n cell.textcontent = `${row.index},${col.index}`;\n containerel.appendchild(cell);\n }\n }\n },\n});\n\ngrid.scrolltocell(500, 10, { rowalign: 'center', colalign: 'start' });\ngrid.dispose();\n```\n\n### `gridvirtualizeroptions`\n\n| option | type | default | description |\n| | | | |\n| `rowcount` | `number` | required | total row count |\n| `colcount` | `number` | required | total column count |\n| `estimaterowsize` | `number \\| (row) => number` | `36` | row height estimate |\n| `estimatecolsize` | `number \\| (col) => number` | `36` | column width estimate |\n| `rowgap` | `number` | `0` | gap between rows |\n| `colgap` | `number` | `0` | gap between columns |\n| `overscany` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | row overscan |\n| `overscanx` | `{ start?: number; end?: number }` | `{ start: 3, end: 3 }` | column overscan |\n| `initialscrolltop` | `number` | — | initial vertical scroll position |\n| `initialscrollleft` | `number` | — | initial horizontal scroll position |\n| `keyboardscroll` | `boolean` | `false` | enable keyboard navigation (arrow/page/home/end keys) |\n| `onchange` | `(state: gridvirtualizerstate) => void` | — | called when the visible window changes |\n| `onrangechange` | `(range: gridrangechangeevent) => void` | — | zero allocation range callback |\n| `rowmeasurementcache` | `map<number, number>` | — | external row measurement cache |\n| `colmeasurementcache` | `map<number, number>` | — | external column measurement cache |\n| `signal` | `(init: gridvirtualizerstate) => signal<gridvirtualizerstate>` | — | optional signal factory to expose state as a reactive signal |\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `gridvirtualizer` — properties and methods\n\n**read only properties:** `rows`, `cols`, `scrolltop`, `scrollleft`, `totalheight`, `totalwidth`, `disposalsignal`, `disposed`\n\n| method | description |\n| | |\n| `update(next)` | atomically update row/col counts, estimates, gaps, and overscan |\n| `measurerow(row, size)` | record a row height |\n| `measurecolumn(col, size)` | record a column width |\n| `measurebatch(rows, cols)` | measure rows and columns in a single coordinated rebuild pass |\n| `measurerowel(row, el)` | auto measure row height via resizeobserver. returns disconnect fn |\n| `measurecolel(col, el)` | auto measure column width via resizeobserver. returns disconnect fn |\n| `refresh()` | rebuild offset tables from current measurements |\n| `invalidate()` | clear all measurements and rebuild from estimates |\n| `scrolltocell(row, col, options?)` | scroll to bring a cell into view; no op when `rowcount === 0` or `colcount === 0` |\n| `scrolltorow(row, options?)` | scroll to bring a row into view; `rowalign` controls alignment |\n| `scrolltocolumn(col, options?)` | scroll to bring a column into view; `colalign` controls alignment |\n| `prependrows(n)` | add `n` rows at the top; adjusts scroll offset to keep viewport stable |\n| `dispose()` | teardown; idempotent |\n| `[symbol.dispose]()` | delegates to `dispose()` |\n\n`measurerowel`/`measurecolel`'s `resizeobserver` is also disconnected automatically on `dispose()` —\nthe returned disconnect function is only needed to stop observing a specific element early.\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n## types\n\n### `virtualitem`\n\n```ts\ninterface virtualitem {\n end: number;\n index: number;\n size: number;\n start: number;\n}\n```\n\n### `virtualizerstate`\n\n```ts\ninterface virtualizerstate {\n readonly items: virtualitem[];\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n}\n```\n\n### `scrolltoindexoptions`\n\n```ts\ninterface scrolltoindexoptions {\n align?: 'auto' | 'center' | 'end' | 'start';\n behavior?: scrollbehavior;\n /** called when the scroll animation completes (instant scrolls: next microtask). */\n oncomplete?: () => void;\n}\n```\n\n### `overscan`\n\n```ts\ntype overscan = number | { end?: number; start?: number };\n```\n\npassing a number is shorthand for symmetric overscan on both sides.\n\n### `virtualkey`\n\n```ts\ntype virtualkey = number | string;\n```\n\n### `virtualrenderitem<t>`\n\n```ts\ntype virtualrenderitem<t> = virtualitem & { readonly data: t };\n```\n\n### `scrolltarget`\n\n```ts\ntype scrolltarget = htmlelement | window;\n```\n\n### `measurementcache`\n\n```ts\ntype measurementcache = map<virtualkey, number>;\n```\n\nuse `createmeasurementcache()` to create an empty cache:\n\n```ts\nimport { createmeasurementcache } from '@vielzeug/scroll';\n\nconst cache = createmeasurementcache();\nconst virt1 = createvirtualizer(el1, { count: 100, measurementcache: cache });\nconst virt2 = createvirtualizer(el2, { count: 100, measurementcache: cache });\n```\n\n### `recyclefn`\n\n```ts\ntype recyclefn = (key: virtualkey, create: () => htmlelement) => htmlelement;\n```\n\n### `virtualizerupdateoptions`\n\n```ts\ninterface virtualizerupdateoptions {\n automeasure?: boolean;\n count?: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: ((index: number) => virtualkey) | undefined;\n keyboardscroll?: boolean;\n /** replace the active measurement cache. existing entries are used immediately on the next rebuild. */\n measurementcache?: measurementcache;\n onchange?: ((state: virtualizerstate) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n sticky?: ((index: number) => boolean) | undefined;\n}\n```\n\n### `virtualscrolleroptions<t>`\n\n`domvirtuallistoptions<t>` minus `listelement` and `scrollelement`, plus:\n\n```ts\ntype virtualscrolleroptions<t> = omit<domvirtuallistoptions<t>, 'listelement' | 'scrollelement'> & {\n /** css class applied to the generated scroll container element. */\n containerclass?: string;\n};\n```\n\n### `gridvirtualizerupdateoptions`\n\n```ts\ninterface gridvirtualizerupdateoptions {\n colcount?: number;\n colgap?: number;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n keyboardscroll?: boolean;\n onchange?: ((state: gridvirtualizerstate) => void) | undefined;\n onrangechange?: ((range: gridrangechangeevent) => void) | undefined;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount?: number;\n rowgap?: number;\n}\n```\n\n### `gridrangechangeevent`\n\nfired by `onrangechange` on `creategridvirtualizer`. zero allocation alternative to `onchange` — no `rows`/`cols` arrays are allocated.\n\n```ts\ninterface gridrangechangeevent {\n firstcol: number;\n firstrow: number;\n lastcol: number;\n lastrow: number;\n}\n```\n\n### `virtualizeroptions`\n\n```ts\ninterface virtualizeroptions {\n automeasure?: boolean;\n count: number;\n estimatesize?: number | ((index: number) => number);\n gap?: number;\n getitemkey?: (index: number) => virtualkey;\n horizontal?: boolean;\n initialoffset?: number;\n keyboardscroll?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: virtualizerstate) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n sticky?: (index: number) => boolean;\n}\n```\n\n### `virtualizer`\n\n```ts\ninterface virtualizer {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n isatend: (threshold?: number) => boolean;\n readonly isscrolling: boolean;\n readonly items: virtualitem[];\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n prepend: (additionalcount: number) => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (next: virtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `sticktobottomoptions`\n\n```ts\ntype sticktobottomoptions = {\n enabled?: boolean;\n threshold?: number;\n};\n```\n\n### `domvirtuallistoptions<t>`\n\n```ts\ntype domvirtuallistoptions<t> = {\n clear?: (listel: htmlelement) => void;\n estimatesize?: number | ((index: number, item: t) => number);\n gap?: number;\n getitemkey?: (index: number, item: t) => virtualkey;\n horizontal?: boolean;\n keyboardscroll?: boolean;\n listelement: htmlelement;\n measurementcache?: measurementcache;\n overscan?: overscan;\n render: (args: domvirtuallistrenderargs<t>) => void;\n scrollelement: htmlelement | window;\n sticktobottom?: boolean | sticktobottomoptions;\n sticky?: (index: number, item: t) => boolean;\n signal?: (init: virtualizerstate) => signal<virtualizerstate>;\n};\n```\n\n### `domvirtuallistcontroller<t>`\n\n`virtualizer` minus `prepend` and `update`, plus `setitems()`.\n\n```ts\ntype domvirtuallistcontroller<t> = omit<virtualizer, 'prepend' | 'update'> & {\n setitems: (items: t[]) => void;\n};\n```\n\n### `domvirtuallistrenderargs<t>`\n\n```ts\ntype domvirtuallistrenderargs<t> = {\n items: array<virtualrenderitem<t>>;\n listel: htmlelement;\n recycle: recyclefn;\n stickyitems: array<virtualrenderitem<t>>;\n totalsize: number;\n};\n```\n\n### `groupsection<t>`\n\n```ts\ninterface groupsection<t> {\n items: t[];\n label: string;\n}\n```\n\n### `groupvirtualizerstate<t>`\n\n```ts\ninterface groupvirtualizerstate<t> {\n readonly headers: groupvirtualheader[];\n readonly items: array<groupvirtualitem<t>>;\n readonly stickyheader: groupvirtualheader | null;\n readonly totalsize: number;\n}\n```\n\n### `groupvirtualitem<t>`\n\n```ts\ninterface groupvirtualitem<t> extends virtualitem {\n data: t;\n itemindex: number;\n sectionindex: number;\n}\n```\n\n### `groupvirtualheader`\n\n```ts\ninterface groupvirtualheader extends virtualitem {\n label: string;\n sectionindex: number;\n}\n```\n\n### `groupvirtualizeroptions<t>`\n\n```ts\ninterface groupvirtualizeroptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n horizontal?: boolean;\n measurementcache?: measurementcache;\n onchange?: (state: groupvirtualizerstate<t>) => void;\n onscrollend?: (offset: number) => void;\n onscrollingchange?: (isscrolling: boolean) => void;\n overscan?: overscan;\n scrollenddelay?: number;\n sections: array<groupsection<t>>;\n signal?: (init: groupvirtualizerstate<t>) => signal<groupvirtualizerstate<t>>;\n}\n```\n\n### `groupvirtualizerupdateoptions<t>`\n\n```ts\ninterface groupvirtualizerupdateoptions<t> {\n estimateheadersize?: number | ((section: groupsection<t>, sectionindex: number) => number);\n estimateitemsize?: number | ((item: t, itemindex: number, sectionindex: number) => number);\n getitemkey?: (item: t, itemindex: number, sectionindex: number) => virtualkey;\n measurementcache?: measurementcache;\n onchange?: ((state: groupvirtualizerstate<t>) => void) | undefined;\n onscrollend?: ((offset: number) => void) | undefined;\n onscrollingchange?: ((isscrolling: boolean) => void) | undefined;\n overscan?: overscan;\n scrollenddelay?: number;\n}\n```\n\n### `groupvirtualizer<t>`\n\n```ts\ninterface groupvirtualizer<t> {\n readonly count: number;\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n readonly isscrolling: boolean;\n readonly items: readonlyarray<groupvirtualitem<t>>;\n measure: (index: number, size: number) => void;\n measurebatch: (entries: array<{ index: number; size: number }>) => void;\n measureel: (index: number, el: htmlelement) => () => void;\n refresh: () => void;\n readonly scrolloffset: number;\n scrolltobottom: (options?: { behavior?: scrollbehavior }) => void;\n scrolltoindex: (index: number, options?: scrolltoindexoptions) => void;\n scrolltoitem: (sectionindex: number, itemindex: number, options?: scrolltoindexoptions) => void;\n scrolltooffset: (offset: number, options?: { behavior?: scrollbehavior }) => void;\n scrolltosection: (sectionindex: number, options?: scrolltoindexoptions) => void;\n scrolltotop: (options?: { behavior?: scrollbehavior }) => void;\n readonly stickyitems: virtualitem[];\n readonly totalsize: number;\n update: (sections: array<groupsection<t>>, opts?: groupvirtualizerupdateoptions<t>) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n### `gridvirtualizerstate`\n\n```ts\ninterface gridvirtualizerstate {\n readonly cols: virtualitem[];\n readonly rows: virtualitem[];\n readonly totalheight: number;\n readonly totalwidth: number;\n}\n```\n\n### `scrolltocelloptions`\n\n```ts\ninterface scrolltocelloptions {\n behavior?: scrollbehavior;\n colalign?: 'auto' | 'center' | 'end' | 'start';\n rowalign?: 'auto' | 'center' | 'end' | 'start';\n}\n```\n\n### `gridvirtualizeroptions`\n\n```ts\ninterface gridvirtualizeroptions {\n colcount: number;\n colgap?: number;\n colmeasurementcache?: map<number, number>;\n estimatecolsize?: number | ((col: number) => number);\n estimaterowsize?: number | ((row: number) => number);\n initialscrollleft?: number;\n initialscrolltop?: number;\n keyboardscroll?: boolean;\n onchange?: (state: gridvirtualizerstate) => void;\n onrangechange?: (range: gridrangechangeevent) => void;\n overscanx?: overscan;\n overscany?: overscan;\n rowcount: number;\n rowgap?: number;\n rowmeasurementcache?: map<number, number>;\n signal?: (init: gridvirtualizerstate) => signal<gridvirtualizerstate>;\n}\n```\n\n### `gridvirtualizer`\n\n```ts\ninterface gridvirtualizer {\n readonly cols: virtualitem[];\n readonly disposalsignal: abortsignal;\n dispose: () => void;\n readonly disposed: boolean;\n invalidate: () => void;\n measurebatch: (rows: array<{ index: number; size: number }>, cols: array<{ index: number; size: number }>) => void;\n measurecolel: (col: number, el: htmlelement) => () => void;\n measurecolumn: (col: number, size: number) => void;\n measurerow: (row: number, size: number) => void;\n measurerowel: (row: number, el: htmlelement) => () => void;\n prependrows: (additionalrowcount: number) => void;\n refresh: () => void;\n readonly rows: virtualitem[];\n readonly scrollleft: number;\n scrolltocell: (row: number, col: number, options?: scrolltocelloptions) => void;\n scrolltocolumn: (col: number, options?: pick<scrolltocelloptions, 'behavior' | 'colalign'>) => void;\n readonly scrolltop: number;\n scrolltorow: (row: number, options?: pick<scrolltocelloptions, 'behavior' | 'rowalign'>) => void;\n readonly totalheight: number;\n readonly totalwidth: number;\n update: (next: gridvirtualizerupdateoptions) => void;\n [symbol.dispose]: () => void;\n}\n```\n\n## errors\n\n| class | thrown when | notable properties |\n| | | |\n| `scrollerror` | base class for every scroll error. | `scrollerror.is(error)` narrows errors from this package. |\n| `scrollconfigurationerror` | a constructor or `update()` receives invalid static configuration. | extends `scrollerror`; malformed javascript values also use this class. |\n| `scrollrangeerror` | a dom virtual list render detects that a caller mutated its items array without calling `setitems()` again. | extends `scrollerror`; message includes stale index and current item count. |\n\nruntime estimator failures, stale measurements, and out of range navigation remain resilient: they fall back, no op, or clamp as documented.\n\n### constants\n\n```ts\nconst default_estimate_size = 36; // default estimatesize\nconst default_overscan = 3; // default overscan on each side\n```\n",
1040
+ "usage": " \ntitle: scroll — usage guide\ndescription: fixed and variable heights, measurement, programmatic scrolling, and framework integration for scroll.\n \n\n[[toc]]\n\n## basic usage\n\nrender only visible rows by passing a scroll container, a total item count, and a size estimate. scroll calls `onchange` with the visible window whenever it changes.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\n\nconst scrollel = document.queryselector<htmlelement>('.scroll container')!;\nconst listel = document.queryselector<htmlelement>('.list')!;\n\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = `row ${item.index}`;\n listel.appendchild(el);\n }\n },\n});\n\n// cleanup\nvirt.dispose();\n```\n\n```html\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;position:relative;\">\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## dom layout requirements\n\nscroll uses **absolute positioning** for rendered items inside a relative container that stretches to the full list height. your html needs three elements:\n\n```html\n<! 1. scroll container — has a fixed height and overflow:auto/scroll >\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;position:relative;\">\n <! 2. spacer — height set to totalsize so the scrollbar is correct >\n <div class=\"spacer\" style=\"position:relative;\">\n <! 3. item container — items positioned absolutely inside here >\n <div class=\"items\"></div>\n </div>\n</div>\n```\n\na common alternative is to make the spacer and item container the same element:\n\n```html\n<div class=\"scroll container\" style=\"height:400px;overflow:auto;\">\n <! single relative container; items are absolute children >\n <div class=\"list\" style=\"position:relative;\"></div>\n</div>\n```\n\n## dom adapter for dropdowns and listboxes\n\nif your component already has a dropdown scroll container and a listbox element, use `createdomvirtuallist`. it wraps the `virtualizer` lifecycle and keeps the integration surface small. items arrive as `virtualrenderitem<t>` — a `virtualitem` enriched with a `.data` field. use `recycle` for efficient dom node reuse.\n\nthe virtualizer is created lazily on the first non empty `setitems()` call and destroyed automatically when `setitems([])` is called (clearing list styles in the process).\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\ntype option = { disabled?: boolean; label: string; value: string };\n\nlet options: option[] = [];\n\nconst domvirtuallist = createdomvirtuallist<option>({\n estimatesize: 36,\n gap: 6,\n getitemkey: (_index, option) => option.value,\n listelement: listboxel,\n overscan: { end: 4, start: 4 },\n render: ({ items, listel, recycle }) => {\n for (const item of items) {\n const row = recycle(item.data.value, () => document.createelement('button'));\n row.type = 'button';\n row.classname = 'option';\n row.style.csstext = `position:absolute;top:0;left:0;right:0;transform:translatey(${item.start}px);height:${item.size}px;`;\n row.textcontent = item.data.label;\n row.disabled = !!item.data.disabled;\n listel.appendchild(row);\n }\n },\n scrollelement: dropdownel,\n});\n\n// keep in sync when options change\ndomvirtuallist.setitems(options);\n\n// open: setitems populates the list\n// close: setitems([]) destroys the virtualizer and clears list styles\ndomvirtuallist.setitems(isopen ? options : []);\n\n// keyboard nav\ndomvirtuallist.scrolltoindex(focusedindex, { align: 'auto' });\n\n// component teardown\ndomvirtuallist.dispose();\n```\n\nfor variable height rows, pass `getitemkey` so measurements survive `setitems()` calls when items reorder or are filtered.\n\nwhen multiple sizes are available at once, use `measurebatch` to coalesce into a single rebuild:\n\n```ts\ndomvirtuallist.measurebatch(\n entries.map((e) => ({ index: number(e.target.dataset.index), size: e.contentrect.height })),\n);\n```\n\nuse `domvirtuallist.invalidate()` to discard all cached measurements.\n\n## fixed heights\n\npass a single number to `estimatesize` when all rows are the same height. this is the simplest and most performant case — the offset table never needs to be rebuilt during scrolling.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: 10_000,\n estimatesize: 36, // every row is 36px\n onchange: ({ items, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = data[item.index].name;\n list.appendchild(el);\n }\n },\n});\n```\n\n## variable heights — estimator\n\npass a **per index function** to `estimatesize` when rows have predictable but non uniform heights (e.g. group headers vs. regular rows). the offset table is built once at attach time using these estimates.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: flatlist.length,\n estimatesize: (i) => (flatlist[i].type === 'header' ? 48 : 36),\n onchange: ({ items, totalsize }) => {\n // render...\n },\n});\n```\n\n## variable heights — measured\n\nfor truly dynamic heights (e.g. text wrapping, embedded images), render items at their estimated size first, then report the actual measured height with `measure()`. scroll will coalesce all measurement calls within a single microtask tick into one offset rebuild.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 60, // initial estimate\n onchange: ({ items, totalsize }) => {\n list.style.height = `${totalsize}px`;\n list.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.dataset.index = string(item.index);\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;`;\n el.textcontent = rows[item.index].body;\n list.appendchild(el);\n }\n\n // measure after the dom has painted\n requestanimationframe(() => {\n for (const item of items) {\n const el = list.queryselector<htmlelement>(`[data index=\"${item.index}\"]`);\n if (el) virt.measure(item.index, el.offsetheight);\n }\n });\n },\n});\n```\n\n::: tip measurement is idempotent\n`measure(index, height)` is a no op when the new height matches the current effective height (measured or estimated). it is safe to call on every render without triggering unnecessary rebuilds.\n:::\n\n## variable heights — batch measurement\n\nwhen a `resizeobserver` fires with multiple entries at once, use `measurebatch()` to apply all sizes in a single offset rebuild instead of triggering one rebuild per `measure()` call.\n\n```ts\nconst observer = new resizeobserver((entries) => {\n virt.measurebatch(\n entries\n .filter((e) => e.target instanceof htmlelement && e.target.dataset.index)\n .map((e) => ({\n index: number((e.target as htmlelement).dataset.index),\n size: e.contentrect.height,\n })),\n );\n});\n\n// observe each rendered row\nfor (const item of virt.items) {\n const el = listel.queryselector<htmlelement>(`[data index=\"${item.index}\"]`);\n if (el) observer.observe(el);\n}\n```\n\n## overscan\n\n`overscan` controls how many extra items render outside the visible viewport on each side. higher values reduce the chance of blank rows during fast scrolling; lower values keep the dom smaller.\n\n```ts\ncreatevirtualizer(scrollel, {\n count: 1_000,\n estimatesize: 36,\n overscan: 5, // symmetric shorthand — same as { start: 5, end: 5 } (default: 3)\n onchange: () => {\n /* ... */\n },\n});\n```\n\nasymmetric overscan:\n\n```ts\ncreatevirtualizer(scrollel, {\n count: 1_000,\n estimatesize: 36,\n overscan: { start: 8, end: 2 },\n onchange: () => {\n /* ... */\n },\n});\n```\n\n## horizontal lists\n\nset `horizontal: true` to virtualize along the x axis.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: chips.length,\n estimatesize: 120,\n horizontal: true,\n onchange: ({ items, totalsize }) => {\n list.style.width = `${totalsize}px`;\n\n for (const item of items) {\n const chip = document.createelement('button');\n chip.style.csstext = `position:absolute;left:${item.start}px;top:0;width:${item.size}px;`;\n chip.textcontent = chips[item.index].label;\n list.appendchild(chip);\n }\n },\n});\n```\n\n## window scroll target\n\n`createvirtualizer` accepts `window` as the scroll target.\n\n```ts\nconst virt = createvirtualizer(window, {\n count: rows.length,\n estimatesize: 40,\n initialoffset: 320,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = `${totalsize}px`;\n renderrows(items);\n },\n});\n```\n\n## scroll state\n\nuse `virt.scrolloffset` to read the current scroll position at any time.\n\n```ts\nconst virt = createvirtualizer(scrollel, { count: rows.length, estimatesize: 36, onchange: render });\n\n// accessed outside onchange\nconsole.log(virt.scrolloffset);\n```\n\n## updating options\n\nwhen data or render strategy changes, call `update()` with one or more option fields. updates apply atomically and trigger re render when needed. counts, gaps, and overscan must be finite non negative integers; numeric size estimates must be finite positive values; offsets and `scrollenddelay` must be finite non negative numbers. invalid constructor or `update()` values throw `scrollconfigurationerror` before any change applies.\n\nruntime layout data stays resilient: estimator callbacks that throw or return invalid sizes fall back to the default estimate, stale measurements are ignored, and out of range navigation clamps or no ops.\n\n```ts\n// load more data\ndata.push(...newitems);\nvirt.update({ count: data.length });\n```\n\n```ts\n// change multiple options together\nvirt.update({ count: data.length, overscan: { start: 5, end: 5 } });\n\n// rebuild after reordering/filtering stable key rows\nvirt.refresh();\n```\n\n## switching row density\n\nupdating `estimatesize` clears all previously measured heights, rebuilds offsets, and re renders. this makes density switching (compact / comfortable / spacious views) straightforward.\n\n```ts\nfunction setdensity(mode: 'compact' | 'comfortable') {\n virt.update({ estimatesize: mode === 'compact' ? 32 : 48 });\n}\n```\n\n## programmatic scrolling\n\n### `scrolltoindex(index, options?)`\n\nscroll to bring a specific item into view.\n\n| `align` | behaviour |\n| | |\n| `'start'` | item top aligns with the container top |\n| `'end'` | item bottom aligns with the container bottom |\n| `'center'` | item is centered in the viewport |\n| `'auto'` (default) | no scroll if already fully visible; otherwise scrolls the minimum amount |\n\n```ts\n// jump to item 500 at the top of the viewport\nvirt.scrolltoindex(500, { align: 'start' });\n\n// smooth scroll to an item, centering it\nvirt.scrolltoindex(500, { align: 'center', behavior: 'smooth' });\n\n// scroll only if the item is not already visible\nvirt.scrolltoindex(focusedindex, { align: 'auto' });\n```\n\nout of range indices are clamped silently: negative values scroll to item `0`, values ≥ `count` scroll to the last item.\n\n### `scrolltooffset(offset, options?)`\n\nscroll to an exact pixel position, useful for restoring a previously saved scroll state.\n\n```ts\n// restore scroll position\nconst savedoffset = sessionstorage.getitem('scrolloffset');\nif (savedoffset) virt.scrolltooffset(number(savedoffset));\n\n// save on scroll\nscrollel.addeventlistener('scroll', () => {\n sessionstorage.setitem('scrolloffset', string(scrollel.scrolltop));\n});\n```\n\n### `scrolltotop(options?)` / `scrolltobottom(options?)`\n\nconvenience wrappers to jump directly to the start or end of the list.\n\n```ts\n// jump to the top\nvirt.scrolltotop();\n\n// jump to the bottom with smooth scroll\nvirt.scrolltobottom({ behavior: 'smooth' });\n```\n\n### chat \"stick to bottom on new message\"\n\n`createdomvirtuallist`'s `sticktobottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.\n\n```ts\nimport { createdomvirtuallist } from '@vielzeug/scroll';\n\nconst chat = createdomvirtuallist<message>({\n estimatesize: 48,\n getitemkey: (_, m) => m.id,\n listelement: listel,\n render: rendermessages,\n scrollelement: scrollel,\n sticktobottom: true, // or { threshold: 80 } to widen the \"still at bottom\" tolerance\n});\n\nchat.setitems(messages);\n\n// new message arrives — follows only if the user hasn't scrolled up.\nsocket.on('message', (msg) => {\n messages = [...messages, msg];\n chat.setitems(messages);\n});\n```\n\nit also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setitems()` call re checks \"was the list at the end before this update?\", not just count changes. build `isatend()` from `createvirtualizer` directly for custom cases (e.g. showing a \"jump to latest\" button only while scrolled away):\n\n```ts\nconst showjumpbutton = !virt.isatend();\n```\n\n## infinite scroll — loading more at the end\n\nuse `isatend(threshold)` to fetch the next page as the user nears the bottom. `isatend()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.\n\n```ts\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\nlet rows = await fetchpage(0);\nlet loading = false;\n\nlet virt: virtualizer;\nvirt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n\n if (!loading && virt.isatend(200)) {\n loading = true;\n fetchpage(rows.length).then((nextrows) => {\n rows = [...rows, ...nextrows];\n virt.update({ count: rows.length });\n loading = false;\n });\n }\n },\n});\n```\n\n`isatend(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re triggers `onchange`, which re checks `isatend()` against the new total on the next scroll.\n\n## shared measurement cache\n\nwhen the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `measurementcache` created by `createmeasurementcache()`. measurements recorded by one virtualizer are immediately available to all others using the same cache.\n\n```ts\nimport { createmeasurementcache, createvirtualizer } from '@vielzeug/scroll';\n\nconst cache = createmeasurementcache();\n\nconst listvirt = createvirtualizer(listscrollel, {\n count: rows.length,\n estimatesize: 36,\n measurementcache: cache,\n onchange: renderlist,\n});\n\nconst previewvirt = createvirtualizer(previewscrollel, {\n count: rows.length,\n estimatesize: 36,\n measurementcache: cache,\n onchange: renderpreview,\n});\n\n// a measurement on listvirt is reflected in previewvirt immediately.\nlistvirt.measure(0, 72);\n```\n\nthe cache is a plain `map<virtualkey, number>` — you can pre populate it from server data or persist it across sessions.\n\n```ts\n// pre populate from server sent sizes\nconst cache = createmeasurementcache();\nfor (const { id, height } of serversizes) cache.set(id, height);\n```\n\n## invalidating measurements\n\ncall `invalidate()` after an event that changes item heights without a data change — for example, a font load, a viewport width change that causes text to reflow, or toggling between a grid and list layout.\n\n```ts\ndocument.fonts.ready.then(() => virt.invalidate());\n```\n\non variable height lists, `scrolltoindex()` uses the current estimate/measured cache. if you need an exact post layout position after heights change, call `invalidate()` before scrolling again.\n\nfor same length updates, call `setitems()` (dom adapter) or `update()` (core). if the rendered height of rows changed, call `invalidate()` before scrolling again.\n\n## lifecycle — create and dispose\n\n`createvirtualizer(el, options)` attaches immediately to the provided scroll container. if your container is replaced, dispose the old instance and create a new one.\n\n```ts\nlet virt = createvirtualizer(scrollcontainerel, {\n count: rows.length,\n estimatesize: 36,\n onchange: render,\n});\n\nfunction remount(nextscrollcontainerel: htmlelement) {\n virt.dispose();\n virt = createvirtualizer(nextscrollcontainerel, {\n count: rows.length,\n estimatesize: 36,\n onchange: render,\n });\n}\n```\n\n`dispose()` is idempotent and safe to call multiple times.\n\n### explicit resource management\n\n```ts\n// the `using` keyword calls virt.dispose() automatically at block exit\n{\n using virt = createvirtualizer(scrollel, { count: rows.length, onchange: render });\n // ... use virt ...\n} // → virt.dispose() called here\n```\n\n## keyboard navigation\n\nenable keyboard based scrolling with the `keyboardscroll` option. users can navigate lists using arrow keys, page up/down, home, and end.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: 1000,\n estimatesize: 36,\n keyboardscroll: true, // enable keyboard navigation\n onchange: render,\n});\n```\n\n**supported keys:**\n **arrow up/down** (or left/right for horizontal lists) — scroll by one estimated item height\n **page up/down** — scroll by ~80% of viewport height\n **home** — jump to the start of the list\n **end** — jump to the end of the list\n\n**requirements:**\n the scroll container (or a descendant) must have keyboard focus for events to fire\n works with all factories: `createvirtualizer`, `createdomvirtuallist`, `creategroupedvirtualizer`, `creategridvirtualizer`\n arrow key step size is automatically calculated from your `estimatesize` (or `estimaterowsize`/`estimatecolsize` for grids)\n\n## auto measurement\n\nenable automatic item measurement for dynamic or user generated content that changes size. when `automeasure` is enabled, the virtualizer measures visible items via `resizeobserver` and updates layout in real time.\n\n```ts\nconst virt = createvirtualizer(scrollel, {\n count: messages.length,\n estimatesize: 36, // initial guess; will be measured\n automeasure: true, // automatically measure visible items\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const el = document.createelement('div');\n // important: set data vz key for auto measure to find the element\n el.setattribute('data vz key', string(item.index));\n el.textcontent = messages[item.index]?.text ?? '';\n listel.appendchild(el);\n }\n },\n});\n```\n\n**requirements:**\n every rendered item must have a `data vz key` attribute with a unique value\n must use a dom scroll target (not `window`)\n elements must be in the dom by the time `resizeobserver` fires (usually the next microtask)\n\n**use cases:**\n chat lists where messages expand on load\n expandable sections with collapsing text\n lazy loaded thumbnails that arrive with unknown heights\n user resizable rows or dynamic content (videos, iframes)\n\n**performance notes:**\n auto measurement queries the dom every render cycle — avoid with very large visible windows (100+ items)\n for finer control, use the manual `measureel()` method instead\n enable only on lists with truly variable height items\n\n## reactive integration\n\nexpose virtualizer state to a reactive `signal` from `@vielzeug/ripple` using the `signal` option. this works on all factories and pairs with your existing `onchange` callback.\n\n```ts\nimport { createvirtualizer } from '@vielzeug/scroll';\nimport { signal, effect } from '@vielzeug/ripple';\n\n// create an empty signal with the initial state shape\nconst scrollstate = signal({ items: [], stickyitems: [], totalsize: 0 });\n\nconst virt = createvirtualizer(scrollel, {\n count: 1000,\n estimatesize: 36,\n signal: () => scrollstate, // return the signal on each init\n onchange: render, // both signal and callback get the state\n});\n\n// react to state changes\neffect(() => {\n const { totalsize, items } = scrollstate.value;\n console.log(`visible: ${items.length} items, total height: ${totalsize}px`);\n});\n```\n\n**why a signal factory instead of a direct signal?**\nthe `signal` option receives a factory function so that if your component mounts/unmounts and recreates the virtualizer, the signal is also recreated with a fresh initial state. if you want to share state across multiple virtualizers or preserve it across disposal, create the signal in outer scope and return it from the factory:\n\n```ts\n// shared signal across remounts\nconst scrollstate = signal({ items: [], stickyitems: [], totalsize: 0 });\n\nfunction createlist() {\n return createvirtualizer(scrollel, {\n count: 1000,\n signal: () => scrollstate, // always return the same instance\n });\n}\n```\n\n## framework integration\n\nscroll is rendering layer agnostic. the pattern is always the same: create the virtualizer when your scroll container is mounted, re render your dom in `onchange`, and call `dispose()` on unmount.\n\n::: code group\n\n```tsx [react]\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\nimport { useeffect, uselayouteffect, useref } from 'react';\n\ninterface row {\n id: number;\n label: string;\n}\n\nfunction virtuallist({ rows }: { rows: row[] }) {\n const scrollref = useref<htmldivelement>(null);\n const listref = useref<htmldivelement>(null);\n const virtref = useref<virtualizer | null>(null);\n\n useeffect(() => {\n const scrollel = scrollref.current;\n const listel = listref.current;\n if (!scrollel || !listel) return;\n\n const virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n virtref.current = virt;\n return () => virt.dispose();\n }, []); // attach once\n\n // uselayouteffect, not useeffect: syncs count before paint. with useeffect,\n // the dom (and anything reading `rows`) paints once with the new length before\n // the virtualizer's internal count catches up, which can render stale/out of bounds indices.\n uselayouteffect(() => {\n virtref.current?.update({ count: rows.length });\n }, [rows.length]);\n\n return (\n <div ref={scrollref} style={{ height: 400, overflow: 'auto', position: 'relative' }}>\n <div ref={listref} style={{ position: 'relative' }} />\n </div>\n );\n}\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\nimport { onmounted, onunmounted, ref, watch } from 'vue';\n\nconst props = defineprops<{ rows: { id: number; label: string }[] }>();\nconst scrollref = ref<htmlelement | null>(null);\nconst listref = ref<htmlelement | null>(null);\nlet virt: virtualizer | null = null;\n\nonmounted(() => {\n if (!scrollref.value || !listref.value) return;\n const listel = listref.value;\n virt = createvirtualizer(scrollref.value, {\n count: props.rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = props.rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n});\nwatch(\n () => props.rows.length,\n (n) => {\n virt?.update({ count: n });\n },\n);\nonunmounted(() => virt?.dispose());\n</script>\n\n<template>\n <div ref=\"scrollref\" style=\"height:400px;overflow:auto;position:relative;\">\n <div ref=\"listref\" style=\"position:relative;\" />\n </div>\n</template>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\n let { rows }: { rows: { id: number; label: string }[] } = $props();\n let scrollel: htmlelement;\n let listel: htmlelement;\n let virt: virtualizer;\n\n $effect(() => {\n virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n return () => virt.dispose();\n });\n\n $effect(() => { virt?.update({ count: rows.length }); });\n</script>\n\n<div bind:this={scrollel} style=\"height:400px;overflow:auto;position:relative;\">\n <div bind:this={listel} style=\"position:relative;\" />\n</div>\n```\n\n```ts [web components]\nimport { litelement, html, css } from 'lit';\nimport { customelement, property } from 'lit/decorators.js';\nimport { createvirtualizer, type virtualizer } from '@vielzeug/scroll';\n\n@customelement('virtual list')\nclass virtuallist extends litelement {\n static styles = css`\n .scroll {\n height: 400px;\n overflow: auto;\n position: relative;\n }\n .list {\n position: relative;\n }\n `;\n\n @property({ type: array }) rows: { label: string }[] = [];\n #virt: virtualizer | null = null;\n\n firstupdated() {\n const scrollel = this.renderroot.queryselector<htmlelement>('.scroll')!;\n const listel = this.renderroot.queryselector<htmlelement>('.list')!;\n this.#virt = createvirtualizer(scrollel, {\n count: this.rows.length,\n estimatesize: 36,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n for (const item of items) {\n const el = document.createelement('div');\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;\n el.textcontent = this.rows[item.index]?.label ?? '';\n listel.appendchild(el);\n }\n },\n });\n }\n\n updated() {\n this.#virt?.update({ count: this.rows.length });\n }\n disconnectedcallback() {\n this.#virt?.dispose();\n super.disconnectedcallback();\n }\n render() {\n return html`<div class=\"scroll\"><div class=\"list\"></div></div>`;\n }\n}\n```\n\n:::\n\n### pitfalls\n\n **react:** putting `rows` in the `useeffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. only include the scroll element reference. call `virt.update({ count })` from a separate `useeffect` for data changes.\n **react:** use `uselayouteffect`, not `useeffect`, for the `count` sync effect. `useeffect` fires after paint — a new `count` can reach the dom (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out of bounds indices for one frame.\n **vue 3:** `ref.value` is `null` inside `setup()` — the dom doesn't exist yet. always create the virtualizer inside `onmounted`, not in `setup()`.\n **svelte:** in svelte 5, `$effect` with `bind:this` runs after the dom is painted. the `bind:this` variable is available when the `$effect` runs — no extra tick needed.\n **web components:** `firstupdated` fires once after the first render. use `updated()` for subsequent prop changes — lit calls it every time `rows` changes.\n\n## working with other vielzeug libraries\n\n### with ore\n\nbuild a virtualizing custom element using ore for the component shell and scroll for the rendering engine.\n\n```ts\nimport { define, html, onmounted, ref } from '@vielzeug/ore';\nimport { createvirtualizer } from '@vielzeug/scroll';\n\ndefine('virtual list', {\n setup() {\n const scrollref = ref<htmlelement>();\n const listref = ref<htmlelement>();\n\n onmounted(() => {\n if (!scrollref.value || !listref.value) return;\n const listel = listref.value;\n const virt = createvirtualizer(scrollref.value, {\n count: 1000,\n estimatesize: 40,\n onchange: ({ items, totalsize }) => {\n listel.style.height = `${totalsize}px`;\n listel.replacechildren();\n\n for (const item of items) {\n const row = document.createelement('div');\n\n row.style.csstext = `position:absolute;top:${item.start}px;height:40px;`;\n row.textcontent = `row ${item.index}`;\n listel.appendchild(row);\n }\n },\n },\n },\n });\n return () => virt.dispose();\n });\n\n return () => html`\n <div ref=${scrollref} style=\"height:400px;overflow:auto;position:relative\">\n <div ref=${listref} style=\"position:relative\"></div>\n </div>\n `;\n },\n});\n```\n\n## best practices\n\n always provide `count` and `estimatesize` as a starting point, even for variable height lists — measurements refine the estimates.\n call `dispose()` in the framework cleanup callback (useeffect return, onunmounted, ondestroy) to free resize observers.\n use `overscan` to pre render rows above and below the visible area to reduce blank flicker during fast scrolling.\n prefer `scrolltoindex()` with `align: 'start'` for programmatic navigation; use `align: 'center'` for focus management.\n use `createdomvirtuallist()` for comboboxes, listboxes, and selects — it manages the virtualizer lifecycle and dom node pooling for you.\n invalidate measurements with `invalidate()` when item content changes size (e.g., after expanding an accordion row).\n for very large lists (>100k items), set a narrower `overscan` to limit dom node count at any one time.\n use `refresh()` when item data or sizes may have changed; it rebuilds the offset table and re emits.\n",
1041
+ "examples": " \ntitle: scroll — examples\ndescription: practical examples and recipes for scroll.\n \n\n## examples\n\n [basic fixed height list](./examples/basic fixed height list.md)\n [variable height with measurement](./examples/variable height with measurement.md)\n [grouped list headers plus rows](./examples/grouped list headers plus rows.md)\n [infinite scroll load more](./examples/infinite scroll load more.md)\n [keyboard navigation](./examples/keyboard navigation.md)\n [restore scroll position](./examples/restore scroll position.md)\n [density toggle compact comfortable](./examples/density toggle compact comfortable.md)\n [dom virtual list combobox pattern](./examples/dom virtual list combobox pattern.md)\n [grid virtualizer](./examples/grid virtualizer.md)\n [reactive virtualizer](./examples/reactive virtualizer.md)\n [infinite scroll with analytics and prefetch](./examples/on range change.md)\n [sticky items in dom virtual list](./examples/dom virtual list sticky.md)\n [recreate on remount](./examples/using virtualizer directly without createvirtualizer.md)\n [explicit resource management (`using`)](./examples/explicit resource management using.md)\n"
1042
+ },
1043
+ "examples": [
1044
+ {
1045
+ "id": "basic-list",
1046
+ "text": "virtualizer basic list import { createvirtualizer } from '@vielzeug/scroll'\n\nconst item_count = 100\nconst row_height = 40\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:400px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;margin:1rem;'\ndocument.body.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst virtualizer = createvirtualizer(container, {\n count: item_count,\n estimatesize: row_height,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align items:center;padding:0 16px;border bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textcontent = `row #${index + 1} of ${item_count}`\n content.appendchild(row)\n })\n },\n})\n\nconsole.log(`✓ virtualizer created with ${item_count} rows`)\nconsole.log('rendered dom nodes:', virtualizer.items.length, '(out of', item_count, ')')"
1047
+ },
1048
+ {
1049
+ "id": "dynamic-count",
1050
+ "text": "virtualizer dynamic count import { createvirtualizer } from '@vielzeug/scroll'\n\nlet items = ['alpha', 'beta', 'gamma']\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:200px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\ndocument.body.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst virtualizer = createvirtualizer(container, {\n count: items.length,\n estimatesize: 44,\n onchange: ({ items: virtualitems, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n virtualitems.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;line height:${size}px;padding:0 16px;border bottom:1px solid #f5f5f5;`\n row.textcontent = items[index]\n content.appendchild(row)\n })\n },\n})\n\nconsole.log('initial count:', virtualizer.count)\n\n// dynamically add more items\nsettimeout(() => {\n items = [...items, 'delta', 'epsilon', 'zeta', 'eta', 'theta']\n virtualizer.update({ count: items.length })\n console.log('updated count:', virtualizer.count)\n}, 300)\n\n// reorder items with stable keys — refresh() forces rebuild while preserving sizes\nsettimeout(() => {\n items = [...items].sort(() => math.random() 0.5)\n virtualizer.refresh()\n console.log('items reordered — refresh() called (sizes preserved by key)')\n}, 700)"
1051
+ },
1052
+ {
1053
+ "id": "grid-virtualizer",
1054
+ "text": "grid virtualizer import { creategridvirtualizer } from '@vielzeug/scroll'\n\n// two dimensional grid virtualization — only the visible rows × cols\n// cross product is mounted, independent of total grid size.\n\nconst row_count = 10_000\nconst col_count = 20\nconst row_h = 32\nconst col_w = 100\n\nconst scrollel = document.createelement('div')\nscrollel.style.csstext = 'height:320px;overflow:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\ndocument.body.appendchild(scrollel)\n\nconst container = document.createelement('div')\nscrollel.appendchild(container)\n\nconst grid = creategridvirtualizer(scrollel, {\n colcount: col_count,\n estimatecolsize: col_w,\n estimaterowsize: row_h,\n rowcount: row_count,\n onchange: ({ cols, rows, totalheight, totalwidth }) => {\n container.style.csstext = `position:relative;height:${totalheight}px;width:${totalwidth}px;`\n container.replacechildren()\n\n for (const row of rows) {\n for (const col of cols) {\n const cell = document.createelement('div')\n cell.style.csstext = `position:absolute;top:${row.start}px;left:${col.start}px;height:${row.size}px;width:${col.size}px;box sizing:border box;border right:1px solid #f0f0f0;border bottom:1px solid #f0f0f0;line height:${row.size}px;padding:0 8px;overflow:hidden;white space:nowrap;font size:12px;`\n cell.textcontent = 'r' + row.index + 'c' + col.index\n container.appendchild(cell)\n }\n }\n },\n})\n\nconsole.log('grid:', row_count, 'rows x', col_count, 'cols')\nconsole.log('visible cells this frame:', grid.rows.length * grid.cols.length)\n\n// jump to a specific cell\ngrid.scrolltocell(500, 10, { colalign: 'start', rowalign: 'center' })\n\n// cleanup\nwindow.addeventlistener('beforeunload', () => grid.dispose())"
1055
+ },
1056
+ {
1057
+ "id": "grouped-list",
1058
+ "text": "grouped list with sticky headers import { creategroupedvirtualizer } from '@vielzeug/scroll'\n\n// grouped contact list with sticky section headers\n\ntype contact = { id: number; name: string }\n\nconst sections = [\n { label: 'a', items: [{ id: 1, name: 'alice' }, { id: 2, name: 'andrew' }] },\n { label: 'b', items: [{ id: 3, name: 'bob' }, { id: 4, name: 'brenda' }] },\n { label: 'c', items: [{ id: 5, name: 'carol' }, { id: 6, name: 'charlie' }, { id: 7, name: 'chloe' }] },\n { label: 'd', items: [{ id: 8, name: 'david' }, { id: 9, name: 'diana' }] },\n]\n\nconst app = document.createelement('div')\napp.style.csstext = 'font family:system ui,sans serif;max width:360px;margin:1rem;'\ndocument.body.appendchild(app)\n\nconst label = document.createelement('div')\nlabel.style.csstext = 'font size:11px;font weight:600;color:#6b7280;margin bottom:6px;'\nlabel.textcontent = 'contacts'\napp.appendchild(label)\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:320px;overflow y:auto;border:1px solid #e5e5e5;border radius:8px;position:relative;background:#fff;'\napp.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\n// sticky header overlay — floats above the list\nconst stickyel = document.createelement('div')\nstickyel.style.csstext = 'position:sticky;top:0;z index:1;background:#f9fafb;border bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line height:32px;font size:12px;font weight:700;color:#374151;display:none;'\ncontainer.appendchild(stickyel)\n\nconst virt = creategroupedvirtualizer<contact>(container, {\n estimateheadersize: 32,\n estimateitemsize: 48,\n sections,\n onchange: ({ headers, items, stickyheader, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n headers.foreach(({ start, size, label: text }) => {\n const el = document.createelement('div')\n el.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;background:#f9fafb;border bottom:1px solid #e5e5e5;padding:0 14px;line height:${size}px;font size:12px;font weight:700;color:#374151;`\n el.textcontent = text\n content.appendchild(el)\n })\n items.foreach(({ start, size, data }) => {\n const el = document.createelement('div')\n el.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align items:center;padding:0 14px;border bottom:1px solid #f3f4f6;font size:14px;color:#111827;`\n el.textcontent = data.name\n content.appendchild(el)\n })\n if (stickyheader) {\n stickyel.textcontent = stickyheader.label\n stickyel.style.display = 'block'\n } else {\n stickyel.style.display = 'none'\n }\n },\n})\n\nconsole.log('sections:', sections.length, '| total items:', sections.reduce((n, s) => n + s.items.length, 0))\nconsole.log('flat item count:', virt.count)"
1059
+ },
1060
+ {
1061
+ "id": "measurement-cache",
1062
+ "text": "createmeasurementcache shared cache import { createmeasurementcache, createvirtualizer } from '@vielzeug/scroll'\n\n// shared cache — measurements from lista flow into listb automatically.\nconst cache = createmeasurementcache()\n\nconst makelist = (label, left) => {\n const heading = document.createelement('p')\n heading.textcontent = label\n heading.style.csstext = `position:absolute;top:0;left:${left}px;width:220px;margin:0;font weight:600;font size:13px;`\n document.body.appendchild(heading)\n\n const container = document.createelement('div')\n container.style.csstext = `position:absolute;top:24px;left:${left}px;width:220px;height:360px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:absolute;`\n document.body.appendchild(container)\n\n const spacer = document.createelement('div')\n const content = document.createelement('div')\n content.style.csstext = 'position:absolute;top:0;left:0;right:0;'\n container.appendchild(spacer)\n container.appendchild(content)\n\n return { container, content, spacer }\n}\n\nconst { container: containera, content: contenta, spacer: spacera } = makelist('list a (measures items)', 16)\nconst { container: containerb, content: contentb, spacer: spacerb } = makelist('list b (reads shared cache)', 260)\n\nconst count = 200\n\nconst virta = createvirtualizer(containera, {\n count: count,\n estimatesize: 40,\n measurementcache: cache,\n onchange: ({ items, totalsize }) => {\n spacera.style.height = totalsize + 'px'\n contenta.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;min height:${size}px;padding:8px 12px;border bottom:1px solid #f0f0f0;word wrap:break word;font size:13px;`\n row.textcontent = `row ${index} — ${'word '.repeat((index % 4) + 1).trim()}`\n contenta.appendchild(row)\n // report actual height after paint\n requestanimationframe(() => virta.measure(index, row.offsetheight))\n })\n },\n})\n\nconst virtb = createvirtualizer(containerb, {\n count: count,\n estimatesize: 40,\n measurementcache: cache,\n onchange: ({ items, totalsize }) => {\n spacerb.style.height = totalsize + 'px'\n contentb.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align items:center;padding:0 12px;border bottom:1px solid #f0f0f0;font size:13px;`\n row.textcontent = `row ${index} (size: ${size}px)`\n contentb.appendchild(row)\n })\n },\n})\n\nconsole.log('✓ two virtualizers share one measurementcache')\nconsole.log('scroll list a to measure rows — list b reflects the same sizes')"
1063
+ },
1064
+ {
1065
+ "id": "on-range-change",
1066
+ "text": "infinite scroll import { createvirtualizer } from '@vielzeug/scroll'\n\n// infinite scroll: detect when the user is near the bottom\n// inside onchange and load more data.\n\nconst app = document.createelement('div')\napp.style.csstext = 'font family:system ui,sans serif;padding:16px;max width:480px;'\ndocument.body.appendchild(app)\n\nconst badge = document.createelement('div')\nbadge.style.csstext = 'background:#f0f9ff;border:1px solid #bae6fd;border radius:6px;padding:8px 12px;margin bottom:12px;font size:13px;color:#0369a1;'\nbadge.textcontent = 'scroll to the bottom — more items load automatically'\napp.appendchild(badge)\n\nconst rangeel = document.createelement('div')\nrangeel.style.csstext = 'font size:12px;font weight:600;color:#6b7280;margin bottom:8px;'\nrangeel.textcontent = 'visible: —'\napp.appendchild(rangeel)\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:360px;overflow y:auto;border:1px solid #e5e5e5;border radius:6px;position:relative;'\napp.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nlet count = 50\nlet loading = false\n\nconst virt = createvirtualizer(container, {\n count,\n estimatesize: 44,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align items:center;padding:0 14px;border bottom:1px solid #f3f4f6;font size:13px;`\n row.textcontent = `row ${index + 1} of ${count}`\n content.appendchild(row)\n })\n const first = items[0]?.index ?? 1\n const last = items.at( 1)?.index ?? 1\n if (first >= 0) rangeel.textcontent = `visible: ${first} – ${last}`\n if (!loading && last >= count 10) {\n loading = true\n settimeout(() => { count += 50; virt.update({ count }); loading = false }, 300)\n }\n },\n})"
1067
+ },
1068
+ {
1069
+ "id": "reactive-grouped-list",
1070
+ "text": "reactive grouped virtualizer import { signal } from '@vielzeug/ripple'\nimport { creategroupedvirtualizer } from '@vielzeug/scroll'\n\n// reactive grouped virtualizer — state emitted through a signal\n// from @vielzeug/ripple. create the signal yourself, pass a factory\n// that returns it, then subscribe in effect().\n\ntype contact = { id: number; name: string }\n\nconst sections = [\n { label: 'a', items: [{ id: 1, name: 'alice' }, { id: 2, name: 'andrew' }] },\n { label: 'b', items: [{ id: 3, name: 'bob' }, { id: 4, name: 'brenda' }] },\n { label: 'c', items: [{ id: 5, name: 'carol' }, { id: 6, name: 'charlie' }] },\n]\n\nconst app = document.createelement('div')\napp.style.csstext = 'font family:system ui,sans serif;max width:360px;margin:1rem;'\ndocument.body.appendchild(app)\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:280px;overflow y:auto;border:1px solid #e5e5e5;border radius:8px;position:relative;background:#fff;'\napp.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst stickyel = document.createelement('div')\nstickyel.style.csstext = 'position:sticky;top:0;z index:1;background:#f9fafb;border bottom:1px solid #e5e5e5;padding:0 14px;height:32px;line height:32px;font size:12px;font weight:700;color:#374151;display:none;'\ncontainer.appendchild(stickyel)\n\nconst state = signal({ headers: [], items: [], stickyheader: null, totalsize: 0 })\n\nconst virt = creategroupedvirtualizer<contact>(container, {\n estimateheadersize: 32,\n estimateitemsize: 44,\n sections,\n signal: (init) => state,\n})\n\nfunction render() {\n const { headers, items, stickyheader, totalsize } = state.value\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n headers.foreach(({ start, size, label: text }) => {\n const el = document.createelement('div')\n el.style.csstext = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;background:#f9fafb;border bottom:1px solid #e5e5e5;padding:0 14px;line height:${size}px;font size:12px;font weight:700;color:#374151;`\n el.textcontent = text\n content.appendchild(el)\n })\n items.foreach(({ start, size, data }) => {\n const el = document.createelement('div')\n el.style.csstext = `position:absolute;top:${start}px;height:${size}px;left:0;right:0;padding:0 14px 0 22px;line height:${size}px;font size:14px;color:#111827;border bottom:1px solid #f3f4f6;`\n el.textcontent = data.name\n content.appendchild(el)\n })\n if (stickyheader) {\n stickyel.style.display = 'block'\n stickyel.textcontent = stickyheader.label\n } else {\n stickyel.style.display = 'none'\n }\n}\n\nrender()\ncontainer.addeventlistener('scroll', render)\n\n// live update demo \nconst btn = document.createelement('button')\nbtn.textcontent = 'add section d'\nbtn.style.csstext = 'margin top:10px;padding:6px 14px;font size:13px;border:1px solid #d1d5db;border radius:6px;cursor:pointer;background:#fff;'\nbtn.onclick = () => {\n virt.update([\n ...sections,\n { label: 'd', items: [{ id: 8, name: 'david' }, { id: 9, name: 'diana' }] },\n ])\n render()\n btn.disabled = true\n btn.style.opacity = '0.5'\n}\napp.appendchild(btn)\n\n// cleanup\nwindow.addeventlistener('beforeunload', () => virt.dispose())"
1071
+ },
1072
+ {
1073
+ "id": "reactive-virtualizer",
1074
+ "text": "reactive virtualizer import { signal } from '@vielzeug/ripple'\nimport { createvirtualizer } from '@vielzeug/scroll'\n\n// passing a `signal` factory to createvirtualizer emits state through a\n// signal<virtualizerstate> from @vielzeug/ripple. create the signal yourself,\n// pass a factory that returns it, then subscribe in effect(). here we\n// simulate that with a manual render() call on every scroll event.\n\nconst rows = array.from({ length: 50_000 }, (_, i) => ({ id: i, label: 'row ' + i }))\n\nconst scrollel = document.createelement('div')\nscrollel.style.csstext = 'height:280px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\ndocument.body.appendchild(scrollel)\n\nconst listel = document.createelement('div')\nlistel.style.csstext = 'position:absolute;top:0;left:0;right:0;'\nscrollel.appendchild(listel)\n\nconst state = signal({ items: [], stickyitems: [], totalsize: 0 })\n\nconst virt = createvirtualizer(scrollel, {\n count: rows.length,\n estimatesize: 32,\n signal: (init) => state,\n})\n\nfunction render() {\n const { items, totalsize } = state.value\n listel.style.height = totalsize + 'px'\n listel.replacechildren()\n for (const item of items) {\n const el = document.createelement('div')\n el.style.csstext = `position:absolute;top:${item.start}px;left:0;right:0;height:32px;line height:32px;padding:0 12px;border bottom:1px solid #f0f0f0;`\n el.textcontent = rows[item.index].label\n listel.appendchild(el)\n }\n}\n\nrender()\nscrollel.addeventlistener('scroll', render)\n\nconsole.log('reactive virtualizer wired to', rows.length, 'rows')\nconsole.log('live getter (not a snapshot):', state.value.items.length, 'items visible')\n\n// standard virtualizer methods remain available directly on the returned object\nvirt.scrolltoindex(rows.length 1, { align: 'end', behavior: 'smooth' })\n\n// cleanup\nwindow.addeventlistener('beforeunload', () => virt.dispose())"
1075
+ },
1076
+ {
1077
+ "id": "scroll-to-index",
1078
+ "text": "virtualizer scrolltoindex import { createvirtualizer } from '@vielzeug/scroll'\n\nconst item_count = 1_000\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:300px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\ndocument.body.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst virtualizer = createvirtualizer(container, {\n count: item_count,\n estimatesize: 48,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;line height:${size}px;padding:0 16px;border bottom:1px solid #f5f5f5;`\n row.textcontent = `item ${index}`\n content.appendchild(row)\n })\n },\n})\n\n// scroll to specific indexes\nsettimeout(() => {\n console.log('scrolling to index 500 (start align)')\n virtualizer.scrolltoindex(500, { align: 'start', behavior: 'smooth' })\n}, 200)\n\nsettimeout(() => {\n console.log('scrolling to index 999 (end align)')\n virtualizer.scrolltoindex(999, { align: 'end', behavior: 'smooth' })\n}, 800)\n\nsettimeout(() => {\n console.log('scrolling to index 250 (center align)')\n virtualizer.scrolltoindex(250, { align: 'center', behavior: 'smooth' })\n}, 1400)"
1079
+ },
1080
+ {
1081
+ "id": "scroll-to-top-bottom",
1082
+ "text": "virtualizer scrolltotop / scrolltobottom import { createvirtualizer } from '@vielzeug/scroll'\n\nconst item_count = 500\n\nconst wrapper = document.createelement('div')\nwrapper.style.csstext = 'display:flex;flex direction:column;gap:8px;padding:1rem;'\ndocument.body.appendchild(wrapper)\n\nconst btnrow = document.createelement('div')\nbtnrow.style.csstext = 'display:flex;gap:8px;'\n\nconst btntop = document.createelement('button')\nbtntop.textcontent = '⬆ scrolltotop'\nbtntop.style.csstext = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border radius:4px;'\n\nconst btnbottom = document.createelement('button')\nbtnbottom.textcontent = '⬇ scrolltobottom'\nbtnbottom.style.csstext = 'padding:6px 12px;cursor:pointer;border:1px solid #ccc;border radius:4px;'\n\nbtnrow.appendchild(btntop)\nbtnrow.appendchild(btnbottom)\nwrapper.appendchild(btnrow)\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:360px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\nwrapper.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst virt = createvirtualizer(container, {\n count: item_count,\n estimatesize: 40,\n onchange: ({ items, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n items.foreach(({ index, start, size }) => {\n const row = document.createelement('div')\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;height:${size}px;display:flex;align items:center;padding:0 16px;border bottom:1px solid #f0f0f0;background:${index % 2 ? '#fafafa' : '#fff'};`\n row.textcontent = `row ${index + 1} / ${item_count}`\n content.appendchild(row)\n })\n },\n})\n\nbtntop.addeventlistener('click', () => {\n console.log('scrolltotop()')\n virt.scrolltotop({ behavior: 'smooth' })\n})\n\nbtnbottom.addeventlistener('click', () => {\n console.log('scrolltobottom()')\n virt.scrolltobottom({ behavior: 'smooth' })\n})\n\nconsole.log(`✓ ${item_count} rows — use the buttons to jump to top or bottom`)"
1083
+ },
1084
+ {
1085
+ "id": "variable-height",
1086
+ "text": "virtualizer variable height import { createvirtualizer } from '@vielzeug/scroll'\n\nconst items = array.from({ length: 500 }, (_, i) => ({\n id: i,\n text: 'item ' + i + ': ' + 'lorem ipsum '.repeat(math.floor(math.random() * 3) + 1).trim(),\n}))\n\nconst container = document.createelement('div')\ncontainer.style.csstext = 'height:400px;overflow y:auto;border:1px solid #e5e5e5;border radius:4px;position:relative;'\ndocument.body.appendchild(container)\n\nconst spacer = document.createelement('div')\nconst content = document.createelement('div')\ncontent.style.csstext = 'position:absolute;top:0;left:0;right:0;'\ncontainer.appendchild(spacer)\ncontainer.appendchild(content)\n\nconst virtualizer = createvirtualizer(container, {\n count: items.length,\n estimatesize: 60,\n getitemkey: (index) => items[index]?.id ?? index,\n onchange: ({ items: virtualitems, totalsize }) => {\n spacer.style.height = totalsize + 'px'\n content.replacechildren()\n virtualitems.foreach(({ index, start }) => {\n const row = document.createelement('div')\n row.dataset.index = string(index)\n row.style.csstext = `position:absolute;top:${start}px;left:0;right:0;padding:12px 16px;border bottom:1px solid #f0f0f0;word wrap:break word;`\n row.textcontent = items[index].text\n content.appendchild(row)\n })\n // batch report all measured heights in a single rebuild\n requestanimationframe(() => {\n const measurements = []\n virtualitems.foreach(({ index }) => {\n const row = content.queryselector(`[data index=\"${index}\"]`)\n if (row) measurements.push({ index, size: row.offsetheight })\n })\n if (measurements.length) virtualizer.measurebatch(measurements)\n })\n },\n})\n\nconsole.log('variable height list with', items.length, 'items')\nconsole.log('initial rendered:', virtualizer.items.length, 'rows (estimates)')"
1087
+ }
1088
+ ],
1089
+ "exports": "createvirtualizer createdomvirtuallist createvirtualscroller creategroupedvirtualizer creategridvirtualizer createmeasurementcache scrollconfigurationerror scrollerror scrollrangeerror default_estimate_size default_overscan",
1090
+ "keywords": "virtual list virtualization windowing scroll performance large lists",
1091
+ "name": "@vielzeug/scroll",
1092
+ "related": "dnd ore refine",
1093
+ "slug": "scroll",
1094
+ "source": "export type {\n domvirtuallistcontroller,\n domvirtuallistoptions,\n domvirtuallistrenderargs,\n recyclefn,\n sticktobottomoptions,\n virtualrenderitem,\n virtualscrolleroptions,\n} from './dom virtual list';\nexport { createdomvirtuallist, createvirtualscroller } from './dom virtual list';\nexport { scrollconfigurationerror, scrollerror, scrollrangeerror } from './errors';\nexport type {\n gridrangechangeevent,\n gridvirtualizer,\n gridvirtualizeroptions,\n gridvirtualizerstate,\n gridvirtualizerupdateoptions,\n scrolltocelloptions,\n} from './grid virtualizer';\nexport { creategridvirtualizer } from './grid virtualizer';\nexport type {\n groupsection,\n groupvirtualheader,\n groupvirtualitem,\n groupvirtualizer,\n groupvirtualizeroptions,\n groupvirtualizerstate,\n groupvirtualizerupdateoptions,\n} from './grouped virtualizer';\nexport { creategroupedvirtualizer } from './grouped virtualizer';\nexport type {\n measurementcache,\n overscan,\n scrolltarget,\n scrolltoindexoptions,\n virtualitem,\n virtualizer,\n virtualizeroptions,\n virtualizerstate,\n virtualizerupdateoptions,\n virtualkey,\n} from './virtualizer';\nexport { createmeasurementcache, createvirtualizer, default_estimate_size, default_overscan } from './virtualizer';\n"
1095
+ },
1096
+ {
1097
+ "category": "data",
1098
+ "description": "framework agnostic collection sources for local, page, cursor, and infinite pagination.",
1099
+ "docs": {
1100
+ "index": " \ntitle: sourcerer — reactive query sources\ndescription: framework agnostic collection sources for local, page, cursor, and infinite pagination.\npackage: sourcerer\ncategory: data\nkeywords: [pagination, data source, cursor, infinite scroll, search]\nrelated: [courier, ripple, scout, wayfinder]\nexports:\n [\n createcursorsource,\n createinfinitesource,\n createlocalsource,\n createpagesource,\n anypagination,\n cursorpagination,\n cursorquery,\n cursorquerypatch,\n cursorresult,\n cursorsource,\n cursorsourceconfig,\n infinitepagination,\n infinitequery,\n infinitequerypatch,\n infinitesource,\n infinitesourceconfig,\n localquery,\n localquerypatch,\n localsource,\n localsourceconfig,\n loadcontext,\n pagepagination,\n pagequery,\n pagequerypatch,\n pageresult,\n pagesource,\n pagesourceconfig,\n source,\n sourcesnapshot,\n ]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"sourcerer\" />\n\n## why sourcerer?\n\nlists often combine pagination, search, request cancellation, and render state. sourcerer gives local arrays and remote loaders one snapshot contract while leaving caching, retries, and transport policy to your application.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\n\n// before: query changes can mix old items with new loading and page state.\nlet items: user[] = [];\nlet page = 1;\nlet isloading = false;\n\n// after: one source publishes internally consistent loaded state.\nconst source = createpagesource<user>({\n autostart: false,\n load: async () => ({ data: [{ id: 1, name: 'ada' }], total: 1 }),\n});\nsource.subscribe((snapshot) => console.log(snapshot.data));\nsource.dispose();\n```\n\n| feature | sourcerer | manual list state | courier query cache |\n| | | | |\n| bundle size | <packageinfo package=\"sourcerer\" type=\"size\" /> | application defined | <packageinfo package=\"courier\" type=\"size\" /> |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| local and remote collections | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| cursor and infinite pagination | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> |\n| latest request cancellation | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | transport level |\n\n<div class=\"decision callout\">\n\n**use sourcerer when** one ui collection needs local or remote pagination with an explicit, framework independent snapshot contract.\n\n**consider courier alone when** you only need cached http queries and pagination state belongs elsewhere.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/sourcerer\n```\n\n```sh [npm]\nnpm install @vielzeug/sourcerer\n```\n\n```sh [yarn]\nyarn add @vielzeug/sourcerer\n```\n\n:::\n\n## quick start\n\ncreate a page source, load it, then dispose it with its owner.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\ntype user = { id: number; name: string };\n\nconst source = createpagesource<user>({\n autostart: false,\n load: async ({ query }) => {\n const users = [\n { id: 1, name: 'ada' },\n { id: 2, name: 'grace' },\n { id: 3, name: 'linus' },\n ];\n const start = (query.page 1) * query.pagesize;\n\n return { data: users.slice(start, start + query.pagesize), total: users.length };\n },\n});\n\ntry {\n await source.reload();\n console.log(source.snapshot.data);\n} catch (error) {\n console.error(error);\n} finally {\n source.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createlocalsource()` — synchronous search and numbered pagination over an array\n `createpagesource()` — numbered remote pages with latest request cancellation\n `createcursorsource()` — sequential opaque cursor navigation\n `createinfinitesource()` — append only page loading\n `sourcesnapshot` — loaded `query`, `data`, and `pagination` plus optional `pendingquery`\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 [courier](/courier/) — use as transport, caching, and retry policy inside a page loader\n [scout](/scout/) — adapt an indexed search matcher for local sources\n [ripple](/ripple/) — project source snapshots into reactive application state\n [wayfinder](/wayfinder/) — validate and synchronize page query fields with route state\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1101
+ "api": " \ntitle: sourcerer — api reference\ndescription: public api for @vielzeug/sourcerer.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `createlocalsource()` | in memory search and pagination | sync | prepare filtering and ranking before `setdata()` |\n| `createpagesource()` | numbered async pages | async | `query` remains loaded state while `pendingquery` is active |\n| `createcursorsource()` | cursor based async pages | async | `after` and `before` cannot coexist |\n| `createinfinitesource()` | appended async pages | async | `loadmore()` does nothing while fetching or exhausted |\n| `sourcesnapshot` | atomic loaded state plus pending request | type | read `pendingquery` for newer in flight state |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/sourcerer` | factories and public types |\n\n## factories\n\n### `createlocalsource()`\n\n```ts\nfunction createlocalsource<t>(data: readonly t[], config?: localsourceconfig<t>): localsource<t>\n```\n\ncreates a synchronous source over an in memory collection.\n\n| option | type | description |\n| | | |\n| `initialquery` | `localquerypatch` | initial page, page size, or search value |\n| `match` | `(item, search) => boolean` | explicit search predicate |\n\n**returns:** `localsource<t>`.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst users = createlocalsource(\n [{ id: 1, name: 'ada' }],\n {\n initialquery: { pagesize: 20 },\n match: (user, search) => user.name.tolowercase().includes(search.tolowercase()),\n },\n);\n\nusers.setquery({ search: 'ada' });\n```\n\n \n\n### `createpagesource()`\n\n```ts\nfunction createpagesource<t, tfilter = unknown, tsort = unknown>(\n config: pagesourceconfig<t, tfilter, tsort>,\n): pagesource<t, tfilter, tsort>\n```\n\ncreates a numbered source. new queries abort older work. loaded state stays in `snapshot`; newer work appears in `snapshot.pendingquery`.\n\n| option | type | description |\n| | | |\n| `autostart` | `boolean` | start initial request; default `true` |\n| `initialquery` | `pagequerypatch<tfilter, tsort>` | initial query values |\n| `load` | `(context) => promise<pageresult<t>>` | transport callback |\n\n**returns:** `pagesource<t, tfilter, tsort>`.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst users = createpagesource({\n autostart: false,\n load: async () => ({ data: [{ id: 1, name: 'ada' }], total: 1 }),\n});\n\nawait users.setquery({ page: 1 });\nusers.dispose();\n```\n\n \n\n### `createcursorsource()`\n\n```ts\nfunction createcursorsource<t, tcursor = string>(\n config: cursorsourceconfig<t, tcursor>,\n): cursorsource<t, tcursor>\n```\n\ncreates a sequential cursor source. search and page size changes reset cursors.\n\n**returns:** `cursorsource<t, tcursor>`.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst orders = createcursorsource({\n autostart: false,\n load: async () => ({ data: ['order 1'] }),\n});\n\nawait orders.reload();\nawait orders.page.next();\norders.dispose();\n```\n\n \n\n### `createinfinitesource()`\n\n```ts\nfunction createinfinitesource<t>(config: infinitesourceconfig<t>): infinitesource<t>\n```\n\ncreates an append only source. query changes replace loaded collection after successful first page load.\n\n**returns:** `infinitesource<t>`.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst feed = createinfinitesource({\n autostart: false,\n load: async () => ({ data: ['post 1'], total: 1 }),\n});\n\nawait feed.loadmore();\nfeed.dispose();\n```\n\n## types\n\n### source primitives\n\n```ts\ntype disposable = {\n [symbol.dispose](): void;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n};\n\ntype sourcesnapshot<t, tquery, tpagination extends anypagination = anypagination> = readonly<{\n data: readonly t[];\n error: error | null;\n isfetching: boolean;\n pagination: tpagination;\n pendingquery?: tquery;\n query: tquery;\n}>;\n\ntype source<t, tquery, tpagination extends anypagination = anypagination> = disposable & {\n readonly snapshot: sourcesnapshot<t, tquery, tpagination>;\n subscribe(listener: (snapshot: sourcesnapshot<t, tquery, tpagination>) => void): () => void;\n};\n```\n\n### numbered pages\n\n```ts\ntype pagepagination = readonly<{\n count: number;\n hasnext: boolean;\n hasprevious: boolean;\n index: number;\n kind: 'page';\n size: number;\n total: number;\n}>;\n\ntype pagequery<tfilter = unknown, tsort = unknown> = readonly<{\n filter?: tfilter;\n page: number;\n pagesize: number;\n search: string;\n sort?: tsort;\n}>;\n\ntype pagequerypatch<tfilter = unknown, tsort = unknown> = readonly<{\n filter?: tfilter | undefined;\n page?: number;\n pagesize?: number;\n search?: string;\n sort?: tsort | undefined;\n}>;\n\ntype pageresult<t> = readonly<{ data: readonly t[]; total: number }>;\ntype loadcontext<tquery> = readonly<{ query: tquery; signal: abortsignal }>;\n\ntype pagesourceconfig<t, tfilter = unknown, tsort = unknown> = readonly<{\n autostart?: boolean;\n initialquery?: pagequerypatch<tfilter, tsort>;\n load(context: loadcontext<pagequery<tfilter, tsort>>): promise<pageresult<t>>;\n}>;\n\ntype pagesource<t, tfilter = unknown, tsort = unknown> = source<t, pagequery<tfilter, tsort>, pagepagination> & {\n readonly page: readonly<{\n go(index: number): promise<void>;\n last(): promise<void>;\n next(): promise<void>;\n previous(): promise<void>;\n }>;\n reload(): promise<void>;\n setquery(changes: pagequerypatch<tfilter, tsort>): promise<void>;\n};\n```\n\n### local sources\n\n```ts\ntype localquery = readonly<{ page: number; pagesize: number; search: string }>;\ntype localquerypatch = readonly<{ page?: number; pagesize?: number; search?: string }>;\ntype localsourceconfig<t> = readonly<{\n initialquery?: localquerypatch;\n match?: (item: t, search: string) => boolean;\n}>;\n\ntype localsource<t> = source<t, localquery, pagepagination> & {\n readonly page: readonly<{\n go(index: number): void;\n last(): void;\n next(): void;\n previous(): void;\n }>;\n setdata(data: readonly t[]): void;\n setquery(changes: localquerypatch): void;\n};\n```\n\n### cursor and infinite sources\n\n```ts\ntype cursorpagination<tcursor = string> = readonly<{\n hasnext: boolean;\n hasprevious: boolean;\n kind: 'cursor';\n nextcursor?: tcursor;\n previouscursor?: tcursor;\n total?: number;\n}>;\n\ntype cursorquery<tcursor = string> = readonly<{\n after?: tcursor;\n before?: tcursor;\n pagesize: number;\n search: string;\n}>;\n\ntype cursorquerypatch<tcursor = string> = readonly<{\n after?: tcursor | undefined;\n before?: tcursor | undefined;\n pagesize?: number;\n search?: string;\n}>;\n\ntype cursorresult<t, tcursor = string> = readonly<{\n data: readonly t[];\n nextcursor?: tcursor;\n previouscursor?: tcursor;\n total?: number;\n}>;\n\ntype cursorsourceconfig<t, tcursor = string> = readonly<{\n autostart?: boolean;\n initialquery?: cursorquerypatch<tcursor>;\n load(context: loadcontext<cursorquery<tcursor>>): promise<cursorresult<t, tcursor>>;\n}>;\n\ntype cursorsource<t, tcursor = string> = source<t, cursorquery<tcursor>, cursorpagination<tcursor>> & {\n readonly page: readonly<{ next(): promise<void>; previous(): promise<void> }>;\n reload(): promise<void>;\n setquery(changes: cursorquerypatch<tcursor>): promise<void>;\n};\n\ntype infinitepagination = readonly<{\n hasmore: boolean;\n isloadingmore: boolean;\n kind: 'infinite';\n loaded: number;\n total: number;\n}>;\n\ntype infinitequery = readonly<{ pagesize: number; search: string }>;\ntype infinitequerypatch = readonly<{ pagesize?: number; search?: string }>;\n\ntype infinitesourceconfig<t> = readonly<{\n autostart?: boolean;\n initialquery?: infinitequerypatch;\n load(context: loadcontext<pagequery>): promise<pageresult<t>>;\n}>;\n\ntype infinitesource<t> = source<t, infinitequery, infinitepagination> & {\n loadmore(): promise<void>;\n reload(): promise<void>;\n setquery(changes: infinitequerypatch): promise<void>;\n};\n```\n\n### shared helpers\n\n```ts\ntype anypagination = cursorpagination<unknown> | infinitepagination | pagepagination;\n```\n",
1102
+ "usage": " \ntitle: sourcerer — usage guide\ndescription: build local, page, cursor, and infinite collection sources.\n \n\n[[toc]]\n\n## basic usage\n\nuse a local source when data already exists in memory.\n\n```ts\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst source = createlocalsource(\n [\n { id: 1, name: 'ada' },\n { id: 2, name: 'grace' },\n { id: 3, name: 'linus' },\n ],\n {\n initialquery: { pagesize: 2 },\n match: (user, search) => user.name.tolowercase().includes(search.tolowercase()),\n },\n);\n\nsource.setquery({ search: 'a' });\nconsole.log(source.snapshot.data);\nsource.dispose();\n```\n\nread `snapshot.query`, `snapshot.data`, and `snapshot.pagination` together. they always describe one loaded result.\n\n## handle pending remote queries\n\nuse `pendingquery` to distinguish loaded data from newer work.\n\n```ts\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource<string>({\n autostart: false,\n load: async ({ query }) => {\n const data = ['ada', 'grace', 'linus'];\n const start = (query.page 1) * query.pagesize;\n\n return { data: data.slice(start, start + query.pagesize), total: data.length };\n },\n});\n\nsource.subscribe((snapshot) => {\n if (snapshot.pendingquery) console.log('loading:', snapshot.pendingquery);\n console.log('loaded:', snapshot.query, snapshot.data);\n});\n\nawait source.setquery({ page: 2 });\nsource.dispose();\n```\n\nnew `setquery()` calls abort older requests. a failed current request preserves prior loaded data, records `snapshot.error`, and rejects the returned promise.\n\n## use cursor pagination\n\nuse cursors when an api cannot provide stable page numbers.\n\n```ts\nimport { createcursorsource } from '@vielzeug/sourcerer';\n\nconst rows = ['a', 'b', 'c', 'd'];\nconst source = createcursorsource<string, number>({\n autostart: false,\n initialquery: { pagesize: 2 },\n load: async ({ query }) => {\n const start = query.after ?? 0;\n const data = rows.slice(start, start + query.pagesize);\n const nextcursor = start + data.length;\n\n return {\n data,\n nextcursor: nextcursor < rows.length ? nextcursor : undefined,\n previouscursor: start > 0 ? math.max(0, start query.pagesize) : undefined,\n };\n },\n});\n\nawait source.reload();\nawait source.page.next();\nconsole.log(source.snapshot.data);\nsource.dispose();\n```\n\n`after` and `before` cannot coexist. search or page size changes reset cursor state.\n\n## build an infinite feed\n\nuse an infinite source when each page should append.\n\n```ts\nimport { createinfinitesource } from '@vielzeug/sourcerer';\n\nconst source = createinfinitesource<number>({\n autostart: false,\n initialquery: { pagesize: 2 },\n load: async ({ query }) => {\n const values = [1, 2, 3, 4, 5];\n const start = (query.page 1) * query.pagesize;\n\n return { data: values.slice(start, start + query.pagesize), total: values.length };\n },\n});\n\nawait source.loadmore();\nawait source.loadmore();\nconsole.log(source.snapshot.data);\nsource.dispose();\n```\n\n`loadmore()` is a no op while fetching or after `pagination.hasmore` becomes false.\n\n## testing and debugging\n\ninject deterministic loaders in unit tests. await source commands before reading final state.\n\n```ts\nimport { expect, it } from 'vitest';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nit('loads first page', async () => {\n const source = createpagesource({\n autostart: false,\n load: async () => ({ data: ['ada'], total: 1 }),\n });\n\n await source.reload();\n expect(source.snapshot.data).toequal(['ada']);\n source.dispose();\n});\n```\n\n## framework integration\n\nsubscribe through each framework’s lifecycle. keep source creation stable across renders.\n\n::: code group\n\n```tsx [react]\nimport { createpagesource } from '@vielzeug/sourcerer';\nimport { useeffect, usememo, usesyncexternalstore } from 'react';\n\nexport function users() {\n const source = usememo(\n () => createpagesource({ load: async () => ({ data: [{ id: 1, name: 'ada' }], total: 1 }) }),\n [],\n );\n const snapshot = usesyncexternalstore(source.subscribe, () => source.snapshot);\n\n useeffect(() => () => source.dispose(), [source]);\n\n return <p>{snapshot.isfetching ? 'loading' : snapshot.data.length}</p>;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({ load: async () => ({ data: [{ id: 1, name: 'ada' }], total: 1 }) });\nconst snapshot = shallowref(source.snapshot);\nconst stop = source.subscribe((next) => (snapshot.value = next));\n\nonunmounted(() => {\n stop();\n source.dispose();\n});\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n import { createpagesource } from '@vielzeug/sourcerer';\n\n const source = createpagesource({ load: async () => ({ data: [{ id: 1, name: 'ada' }], total: 1 }) });\n let snapshot = source.snapshot;\n const stop = source.subscribe((next) => (snapshot = next));\n\n ondestroy(() => {\n stop();\n source.dispose();\n });\n</script>\n\n{#if snapshot.isfetching}loading{/if}\n{#each snapshot.data as user}{user.name}{/each}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse courier for transport policy. sourcerer owns request succession; courier owns http behavior.\n\n```ts\nimport { createcourier } from '@vielzeug/courier';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst courier = createcourier({ baseurl: '/api' });\nconst source = createpagesource({\n load: ({ query, signal }) => courier.get('/users', { query, signal }),\n});\n```\n\nuse scout’s matcher when local search needs an index.\n\n```ts\nimport { createindex, tosearchmatcher } from '@vielzeug/scout';\nimport { createlocalsource } from '@vielzeug/sourcerer';\n\nconst users = [{ name: 'ada' }, { name: 'grace' }];\nconst index = createindex(users, { fields: ['name'] });\nconst source = createlocalsource(users, { match: tosearchmatcher(index) });\n```\n\n## best practices\n\n dispose each source with its owning view, request, or scope.\n read one snapshot object per render instead of mixing source fields across updates.\n inspect `pendingquery` before rendering controls for in flight work.\n validate url query values before passing them to `setquery()`.\n keep caching, retries, polling, and optimistic writes in your transport layer.\n use `setdata()` with prepared local collections; keep ranking and filtering explicit.\n debounce text inputs before updating remote source queries.\n",
1103
+ "examples": " \ntitle: sourcerer — examples\ndescription: recipes for local, page, cursor, infinite, and framework source usage.\n \n\n## examples\n\n [local pagination and search](./examples/local pagination and filtering.md)\n [page query with url state](./examples/remote search with url state.md)\n [cursor based pagination](./examples/cursor based pagination.md)\n [infinite scroll](./examples/infinite scroll.md)\n [framework integration](./examples/framework integration.md)\n [remote data with courier](./examples/sourcerer with courier.md)\n [reactive controls with ripple](./examples/sourcerer with ripple.md)\n [url synced list with wayfinder](./examples/sourcerer with wayfinder.md)\n"
1104
+ },
1105
+ "examples": [
1106
+ {
1107
+ "id": "cursor-source",
1108
+ "text": "cursor source import { createcursorsource } from '@vielzeug/sourcerer'\n\nconst items = array.from({ length: 30 }, (_, index) => ({ id: index + 1, label: `item ${index + 1}` }))\n\nconst source = createcursorsource({\n initialquery: { pagesize: 10 },\n load: async ({ query }) => {\n const start = query.after ? number(query.after) : 0\n const data = items.slice(start, start + query.pagesize)\n const next = start + data.length\n return { data, nextcursor: next < items.length ? string(next) : undefined, previouscursor: start ? string(math.max(0, start query.pagesize)) : undefined }\n },\n})\n\nawait source.reload()\nawait source.page.next()\nconsole.log(source.snapshot.data.map((item) => item.label))\nconsole.log(source.snapshot.pagination)\n\nsource.dispose()"
1109
+ },
1110
+ {
1111
+ "id": "error-handling",
1112
+ "text": "error handling import { createpagesource } from '@vielzeug/sourcerer'\n\nconst source = createpagesource({\n autostart: false,\n load: async () => { throw new error('network down') },\n})\n\ntry {\n await source.reload()\n} catch (error) {\n console.log((error as error).message)\n}\n\nconsole.log(source.snapshot.error?.message)\nconsole.log(source.snapshot.error?.message)\nsource.dispose()"
1113
+ },
1114
+ {
1115
+ "id": "infinite-source",
1116
+ "text": "infinite source import { createinfinitesource } from '@vielzeug/sourcerer'\n\nconst posts = array.from({ length: 25 }, (_, index) => ({ id: index + 1, title: `post ${index + 1}` }))\n\nconst source = createinfinitesource({\n initialquery: { pagesize: 8 },\n load: async ({ query }) => {\n const start = (query.page 1) * query.pagesize\n return { data: posts.slice(start, start + query.pagesize), total: posts.length }\n },\n})\n\nawait source.reload()\nawait source.loadmore()\nconsole.log(source.snapshot.data.length)\nconsole.log(source.snapshot.pagination)\n\nsource.dispose()"
1117
+ },
1118
+ {
1119
+ "id": "lifecycle",
1120
+ "text": "source lifecycle import { createpagesource } from '@vielzeug/sourcerer'\n\nconst source = createpagesource({\n autostart: false,\n load: async () => ({ data: ['item'], total: 1 }),\n})\n\nconsole.log(source.disposed)\nsource.disposalsignal.addeventlistener('abort', () => console.log('disposed'))\nawait source.reload()\nconsole.log(source.snapshot.data)\nsource.dispose()\nconsole.log(source.disposalsignal.aborted)"
1121
+ },
1122
+ {
1123
+ "id": "local-source",
1124
+ "text": "local source import { createlocalsource } from '@vielzeug/sourcerer'\n\nconst users = [\n { id: 1, name: 'ada', role: 'admin' },\n { id: 2, name: 'grace', role: 'admin' },\n { id: 3, name: 'linus', role: 'user' },\n]\n\nconst source = createlocalsource(users, {\n initialquery: { pagesize: 2 },\n match: (user, search) => user.name.tolowercase().includes(search.tolowercase()),\n})\n\nsource.setquery({ search: 'a' })\nconsole.log(source.snapshot.data)\nconsole.log(source.snapshot.pagination)\n\nsource.dispose()"
1125
+ },
1126
+ {
1127
+ "id": "page-source",
1128
+ "text": "page source import { createpagesource } from '@vielzeug/sourcerer'\n\nconst allitems = array.from({ length: 47 }, (_, index) => ({ id: index + 1, name: `item ${index + 1}` }))\n\nconst source = createpagesource({\n initialquery: { pagesize: 10 },\n load: async ({ query }) => {\n const filtered = query.search ? allitems.filter((item) => item.name.includes(query.search)) : allitems\n const start = (query.page 1) * query.pagesize\n return { data: filtered.slice(start, start + query.pagesize), total: filtered.length }\n },\n})\n\nawait source.reload()\nawait source.setquery({ search: 'item 4' })\nconsole.log(source.snapshot.data.map((item) => item.name))\nconsole.log(source.snapshot.pagination)\n\nsource.dispose()"
1129
+ }
1130
+ ],
1131
+ "exports": "createcursorsource createinfinitesource createlocalsource createpagesource anypagination cursorpagination cursorquery cursorquerypatch cursorresult cursorsource cursorsourceconfig infinitepagination infinitequery infinitequerypatch infinitesource infinitesourceconfig localquery localquerypatch localsource localsourceconfig loadcontext pagepagination pagequery pagequerypatch pageresult pagesource pagesourceconfig source sourcesnapshot",
1132
+ "keywords": "pagination data source cursor infinite scroll search",
1133
+ "name": "@vielzeug/sourcerer",
1134
+ "related": "courier ripple scout wayfinder",
1135
+ "slug": "sourcerer",
1136
+ "source": "export { createcursorsource } from './cursorsource';\nexport { createinfinitesource } from './infinitesource';\nexport { createlocalsource } from './localsource';\nexport { createpagesource } from './pagesource';\nexport type {\n anypagination,\n cursorpagination,\n cursorquery,\n cursorquerypatch,\n cursorresult,\n cursorsource,\n cursorsourceconfig,\n infinitepagination,\n infinitequery,\n infinitequerypatch,\n infinitesource,\n infinitesourceconfig,\n loadcontext,\n localquery,\n localquerypatch,\n localsource,\n localsourceconfig,\n pagepagination,\n pagequery,\n pagequerypatch,\n pageresult,\n pagesource,\n pagesourceconfig,\n source,\n sourcesnapshot,\n} from './types';\n"
1137
+ },
1138
+ {
1139
+ "category": "validation",
1140
+ "description": "schema validation with explicit sync/async checks, portable definitions, json schema export, and tree shakeable entry points.",
1141
+ "docs": {
1142
+ "index": " \ntitle: spell — schema validation for typescript\ndescription: schema validation with explicit sync/async checks, portable definitions, json schema export, and tree shakeable entry points.\npackage: spell\ncategory: validation\nkeywords: [schema, validation, parsing, json schema, locale, typescript, descriptors]\nrelated: [forge, courier, vault]\nexports:\n [s, schema, pipeschema, spellvalidationerror, spelldefinitionerror, errorcode, diagnostics, './json', './predicates']\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"spell\" />\n\n## why spell?\n\nspell keeps runtime validation, static inference, and portable definitions in one api. use `s` for schema construction; import json conversion and predicates from dedicated subpaths.\n\nthis example shows the difference between manual branching and a single reusable schema.\n\n```ts\n// before\nfunction parseuserbefore(value: unknown) {\n if (typeof value !== 'object' || value === null) throw new error('expected object');\n\n const candidate = value as record<string, unknown>;\n\n if (typeof candidate.email !== 'string' || !candidate.email.includes('@')) {\n throw new error('expected valid email');\n }\n\n if (typeof candidate.role !== 'string' || !['admin', 'editor', 'viewer'].includes(candidate.role)) {\n throw new error('expected valid role');\n }\n\n return {\n email: candidate.email,\n role: candidate.role,\n };\n}\n\n// after\nimport { s } from '@vielzeug/spell';\n\nconst user = s.object({\n email: s.string().email(),\n role: s.enum(['admin', 'editor', 'viewer'] as const),\n});\n\nconst user = user.parse({ email: 'ada@example.com', role: 'admin' });\n```\n\n| feature | spell | zod | yup |\n| | | | |\n| bundle size | <packageinfo package=\"spell\" type=\"size\" /> | ~62 kb | ~14 kb |\n| type inference | <ore icon name=\"check\" size=\"16\"></ore icon> `infer<t>` | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n| coercion api | <ore icon name=\"check\" size=\"16\"></ore icon> `s.coerce.*` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| async validation | <ore icon name=\"check\" size=\"16\"></ore icon> `.checkasync()` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| error flattening | <ore icon name=\"check\" size=\"16\"></ore icon> `flatten()` + `flattenfirst()` | <ore icon name=\"check\" size=\"16\"></ore icon> | partial |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use spell when** you want a fluent schema api with strong typescript inference, structured errors, and no third party runtime dependencies.\n\n**consider alternatives when** you are already standardized on another validator ecosystem and migration cost outweighs the api benefits.\n\n</div>\n\n## installation\n\nuse your workspace package manager to add spell.\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/spell\n```\n\n```sh [npm]\nnpm install @vielzeug/spell\n```\n\n```sh [yarn]\nyarn add @vielzeug/spell\n```\n\n:::\n\n## quick start\n\nstart with a schema, then parse unknown input and use the inferred output type everywhere else.\n\n```ts\nimport { s, type infer } from '@vielzeug/spell';\n\nconst user = s\n .object({\n email: s.string().email(),\n name: s.string().min(1),\n role: s.enum(['admin', 'editor', 'viewer'] as const),\n })\n .relaxed(); // allow extra keys — omit for strict mode (default)\n\ntype user = infer<typeof user>;\n\nconst payload: unknown = {\n email: 'ada@example.com',\n name: 'ada',\n role: 'admin',\n team: 'platform',\n};\n\nconst result = user.safeparse(payload);\n\nif (!result.success) throw result.error;\nconst user = result.data;\n```\n\n## features\n\n<div class=\"features grid\">\n\n namespace and tree shakeable schema builders.\n sync and async parsing with `parse()`, `safeparse()`, `parseasync()`, and `safeparseasync()`.\n explicit `check()` and `checkasync()` rules; sync parsing never skips an async check.\n wrapper modes for `optional`, `nullable`, `nullish`, `default`, `catch`, and `required`.\n frozen declarative definitions through `definition()` and json schema export via `fromdefinition()` from `@vielzeug/spell/json`.\n grouped `diagnostics` and `predicates` utilities keep schema construction focused.\n ordered union parsing produces the same selected branch in sync and async modes.\n structured errors with direct path lookup, flattened views, and best match union diagnostics.\n object parsing is hardened against prototype pollution style keys.\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 [forge](/forge/) — typed form state that uses spell schemas as its validation layer\n [courier](/courier/) — http client for validating request and response payloads at service boundaries\n [vault](/vault/) — unified storage api that accepts spell schemas to type gate persisted data\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1143
+ "api": " \ntitle: spell — api reference\ndescription: reference for spell schema builders, parsing, diagnostics, and tooling exports.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `s` | creates schemas | sync or async, depending on checks | `checkasync()` requires async parsing |\n| `schema` / `pipeschema` | base schema abstractions | sync or async | use `infer` rather than assuming input equals output |\n| `diagnostics` | parse context and error helpers | sync | context is per parse/request, not global |\n| `spellvalidationerror` | validation failure details | sync/async parse failures | use `safeparse()` to handle it as a result |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/spell` | schema builders, errors, types, and diagnostics |\n| `@vielzeug/spell/json` | convert portable definitions to json schema |\n| `@vielzeug/spell/predicates` | standalone format and type predicates |\n\n```ts\nimport { diagnostics, s, type infer } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\nimport { isemail } from '@vielzeug/spell/predicates';\n```\n\n## `s`\n\nall builders live under `s`.\n\n| builder | purpose |\n| | |\n| `string`, `number`, `boolean`, `bigint`, `date` | primitive values |\n| `literal`, `enum`, `null`, `undefined`, `unknown`, `any`, `never` | exact and universal values |\n| `array`, `tuple`, `set`, `map`, `record`, `object` | collections |\n| `union`, `intersect`, `discriminatedunion`, `lazy` | composition |\n| `coerce.*` | coercing primitive schemas |\n\n```ts\nconst user = s.object({\n email: s.string().email(),\n id: s.string().uuid(),\n role: s.enum(['admin', 'member'] as const),\n});\n\ntype user = infer<typeof user>;\n```\n\nobject schemas reject unknown keys. use `.relaxed()` to retain extras.\n\n## parsing\n\nevery schema provides:\n\n```ts\nschema.parse(value, context?); // output or spellvalidationerror\nschema.safeparse(value, context?); // parseresult<output>\nschema.parseasync(value, context?); // promise<output>\nschema.safeparseasync(value, context?); // promise<parseresult<output>>\nschema.is(value); // value is output\nschema.assert(value, label?); // assertion\n```\n\n`parse()` and `safeparse()` are available on synchronous schemas. calling `checkasync()` returns an async only schema, where typescript exposes only `parseasync()` and `safeparseasync()`. that async only mode propagates through compositional schemas when a child is asynchronous.\n\n## custom checks\n\n`check()` is synchronous. `checkasync()` is asynchronous. do not return a promise from `check()`.\n\n```ts\nconst signup = s.object({ confirm: s.string(), password: s.string() }).check((value, context) => {\n if (value.password !== value.confirm) {\n context.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] });\n }\n});\n\nconst availableemail = s\n .string()\n .email()\n .checkasync(async (value) => {\n return (await emailavailable(value)) || 'email is already registered';\n });\n```\n\n`checkcontext.addissue()` takes `{ code, message, params?, path? }`. paths are relative to current schema.\n\n## modifiers and transforms\n\n```ts\ns.string().optional();\ns.string().nullable();\ns.string().nullish();\ns.string().required();\ns.string().default('guest');\ns.string().catch('guest');\ns.string()\n .trim()\n .transform((value) => value.tolowercase());\ns.string().pipe(s.string().slug());\ns.string().label('user name');\n```\n\n`default()`, `catch()`, preprocessors, transforms, and checks are runtime behavior. they cannot become portable definitions.\n\n## definitions and json schema\n\n`definition()` is only for schemas containing declarative structure. it returns frozen data and throws `spelldefinitionerror` when runtime behavior is present.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\n\nconst product = s.object({\n id: s.string().uuid(),\n name: s.string().min(1),\n});\n\nconst definition = product.definition();\nconst jsonschema = fromdefinition(definition);\n```\n\nno implicit schema to json conversion exists. make definition boundary explicit.\n\n## diagnostics\n\n`diagnostics` contains pure helpers and immutable parse context creation.\n\n```ts\nimport { diagnostics, s } from '@vielzeug/spell';\n\nconst context = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'unsupported field' },\n});\n\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'ada@example.com', extra: true }, context);\n\nif (!result.success) {\n const messages = result.error.messagesat('email');\n console.log(messages);\n}\n```\n\n`diagnostics.fail(code, message, params?)` and `diagnostics.prependissuepath(issues, segment)` support custom parser implementations.\n\n## errors\n\n `spellerror` — base class. use `spellerror.is(error)` for cross boundary narrowing.\n `spellvalidationerror` — validation failure with `issues`, `bestmatch()`, `messagesat()`, `flatten()`, and `flattenfirst()`.\n `spelldefinitionerror` — schema cannot create portable definition.\n\n```ts\nconst result = s.object({ email: s.string().email() }).safeparse({ email: 'invalid' });\n\nif (!result.success) {\n const { fielderrors, formerrors } = result.error.flatten();\n console.log(fielderrors, formerrors);\n}\n```\n\n## types\n\n### core schema types\n\n```ts\ntype schemamode = 'async' | 'sync';\n\ntype anyschema<output = unknown, input = output, mode extends schemamode = schemamode> = schemasurface<\n output,\n input,\n mode\n>;\n\ntype schemasurface<output = unknown, input = output, mode extends schemamode = schemamode> = {\n _parsefullasync(value: unknown, ctx?: parsecontext): promise<{ data: unknown; issues: issue[] }>;\n _parsefullsync(value: unknown, ctx?: parsecontext): { data: unknown; issues: issue[] };\n definition(): schemadescriptor;\n isoptional: boolean;\n optional(): schemasurface<output | undefined, input | undefined, mode>;\n required(): schemasurface<exclude<output, undefined>, exclude<input, undefined>, mode>;\n readonly [schemainput]: input;\n readonly [schemamode]: mode;\n readonly [schemaoutput]: output;\n walk<r>(visitor: schemawalker<r>): r | null;\n};\n```\n\n`schemamode` is the public symbol marking a schema's parsing capability.\n\n### inference types\n\n```ts\ntype inferoutput<t> =\n t extends schema<infer output, unknown, schemamode>\n ? output\n : t extends { readonly [schemaoutput]: infer output }\n ? output\n : never;\ntype inferinput<t> = t extends { readonly [schemainput]: infer input } ? input : unknown;\ntype infer<t> = inferoutput<t>;\ntype inferschemamode<t> = t extends { readonly [schemamode]: infer mode extends schemamode } ? mode : never;\ntype mergeschemamodes<modes extends schemamode> = 'async' extends modes ? 'async' : 'sync';\n```\n\n### parse result and issues\n\n```ts\ntype parseresult<t> = { data: t; success: true } | { error: spellvalidationerror; success: false };\n\ntype issue =\n | { code: 'custom'; message: string; params?: record<string, unknown>; path: (string | number)[] }\n | { code: 'invalid_base64'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_date'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_duration'; message: string; params: { format: string }; path: (string | number)[] }\n | { code: 'invalid_enum'; message: string; params: { values: readonly unknown[] }; path: (string | number)[] }\n | { code: 'invalid_finite'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_integer'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_keys'; message: string; params: { keys: string[] }; path: (string | number)[] }\n | { code: 'invalid_length'; message: string; params: { exact: number }; path: (string | number)[] }\n | { code: 'invalid_literal'; message: string; params: { expected: unknown }; path: (string | number)[] }\n | { code: 'invalid_multiple_of'; message: string; params: { step: number | bigint }; path: (string | number)[] }\n | { code: 'invalid_safe'; message: string; params?: undefined; path: (string | number)[] }\n | {\n code: 'invalid_string';\n message: string;\n params: { format?: string; includes?: string; pattern?: string; prefix?: string; suffix?: string };\n path: (string | number)[];\n }\n | { code: 'invalid_type'; message: string; params?: undefined; path: (string | number)[] }\n | { code: 'invalid_union'; message: string; params: { errors: issue[][] }; path: (string | number)[] }\n | { code: 'invalid_unique'; message: string; params: { unique: true }; path: (string | number)[] }\n | { code: 'invalid_url'; message: string; params: { format: string }; path: (string | number)[] }\n | {\n code: 'invalid_variant';\n message: string;\n params: { discriminator: string; expected: string[] };\n path: (string | number)[];\n }\n | {\n code: 'too_big';\n message: string;\n params: { exclusive?: boolean; max: number | bigint | date };\n path: (string | number)[];\n }\n | {\n code: 'too_small';\n message: string;\n params: { exclusive?: boolean; min: number | bigint | date };\n path: (string | number)[];\n }\n | { code: string & {}; message: string; params?: record<string, unknown>; path: (string | number)[] };\n```\n\n`errorcode` is a const object mapping each issue code to its string literal.\n\n### validation contracts\n\n```ts\ntype parsecontext = { messages: messages };\n\ntype validatefn = (value: unknown, ctx?: parsecontext) => issue[] | null | promise<issue[] | null>;\n\ntype checkcontext = {\n addissue: (issue: {\n code: string;\n message: string;\n params?: record<string, unknown>;\n path?: (string | number)[];\n }) => void;\n};\n\ntype validateresult = boolean | null | undefined | string;\n```\n\n### messages\n\n```ts\ntype messagefn<ctx extends record<string, unknown> = record<string, unknown>> = string | ((ctx: ctx) => string);\n\ntype messages = {\n array: { length: (ctx: { exact: number; value: unknown[] }) => string; max: (ctx: { max: number; value: unknown[] }) => string; min: (ctx: { min: number; value: unknown[] }) => string; nonempty: () => string; type: () => string; unique: () => string };\n bigint: { max: (ctx: { max: bigint; value: bigint }) => string; min: (ctx: { min: bigint; value: bigint }) => string; multipleof: (ctx: { step: bigint; value: bigint }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; type: () => string };\n boolean: { type: () => string };\n check: { default: () => string };\n date: { max: (ctx: { max: date; value: date }) => string; min: (ctx: { min: date; value: date }) => string; type: () => string };\n enum: { invalid: (ctx: { values: readonly unknown[] }) => string };\n instanceof: { type: (ctx: { classname: string }) => string };\n literal: { expected: (ctx: { expected: unknown }) => string };\n map: { max: (ctx: { max: number; value: map<unknown, unknown> }) => string; min: (ctx: { min: number; value: map<unknown, unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: map<unknown, unknown> }) => string; type: () => string };\n never: { invalid: () => string };\n number: { finite: () => string; int: () => string; max: (ctx: { max: number; value: number }) => string; min: (ctx: { min: number; value: number }) => string; multipleof: (ctx: { step: number; value: number }) => string; negative: () => string; nonnegative: () => string; nonpositive: () => string; positive: () => string; safe: () => string; type: () => string };\n object: { invalidkeys: (ctx: { keys: string[] }) => string; type: () => string };\n set: { max: (ctx: { max: number; value: set<unknown> }) => string; min: (ctx: { min: number; value: set<unknown> }) => string; nonempty: () => string; size: (ctx: { exact: number; value: set<unknown> }) => string; type: () => string };\n string: { base64: () => string; base64url: () => string; cuid: () => string; cuid2: () => string; date: () => string; datetime: () => string; duration: () => string; email: () => string; emoji: () => string; endswith: (ctx: { suffix: string; value: string }) => string; hex: () => string; hexcolor: () => string; includes: (ctx: { substr: string; value: string }) => string; ip: () => string; jwt: () => string; length: (ctx: { exact: number; value: string }) => string; max: (ctx: { max: number; value: string }) => string; min: (ctx: { min: number; value: string }) => string; nanoid: () => string; nonempty: () => string; numeric: () => string; regex: (ctx: { value: string }) => string; semver: () => string; slug: () => string; startswith: (ctx: { prefix: string; value: string }) => string; time: () => string; type: () => string; ulid: () => string; url: () => string; uuid: () => string };\n tuple: { length: (ctx: { exact: number }) => string; min: (ctx: { min: number }) => string; type: () => string };\n union: { invalid: () => string };\n variant: { invaliddiscriminator: (ctx: { discriminator: string; expected: string[] }) => string; type: () => string };\n};\n\ntype deeppartial<t> = {\n [k in keyof t]?: t[k] extends record<string, unknown> ? deeppartial<t[k]> : t[k];\n};\n```\n\n### descriptor and json schema\n\n```ts\ntype schemadescriptor = basedescriptor &\n (\n | { kind: 'any' | 'unknown' | 'never' | 'boolean' | 'bigint' | 'date' | 'lazy' }\n | { classname: string; kind: 'instanceof' }\n | { contentencoding?: string; format?: string; kind: 'string'; maxlength?: number; minlength?: number; pattern?: string | null }\n | { exclusivemaximum?: number; exclusiveminimum?: number; kind: 'number'; maximum?: number; minimum?: number; multipleof?: number; typehint?: 'integer' }\n | { kind: 'literal'; value: string | number | boolean | null | undefined }\n | { kind: 'enum'; values: readonly (string | number)[] }\n | { items: schemadescriptor; kind: 'array'; maxitems?: number; minitems?: number }\n | { items: schemadescriptor[]; kind: 'tuple'; rest: schemadescriptor | null }\n | { fields: record<string, schemadescriptor>; kind: 'object'; strict: boolean }\n | { key: schemadescriptor; kind: 'record'; value: schemadescriptor }\n | { items: schemadescriptor; kind: 'set' }\n | { key: schemadescriptor; kind: 'map'; value: schemadescriptor }\n | { branches: schemadescriptor[]; kind: 'union' | 'intersect' }\n | { branches: record<string, schemadescriptor>; discriminator: string; kind: 'variant' }\n | { from: schemadescriptor; kind: 'pipe'; to: schemadescriptor }\n );\n\ntype jsonschema = record<string, unknown>;\n```\n\n### schema walker\n\n```ts\ntype schemawalker<r> = {\n array?: <t extends anyschema, mode extends schemamode>(schema: arrayschema<t, mode>, item: r | null) => r;\n bigint?: <input, mode extends schemamode>(schema: bigintschema<input, mode>) => r;\n boolean?: <input, mode extends schemamode>(schema: booleanschema<input, mode>) => r;\n date?: <input, mode extends schemamode>(schema: dateschema<input, mode>) => r;\n enum?: <t extends enumvalues, mode extends schemamode>(schema: enumschema<t, mode>) => r;\n instanceof?: <t, mode extends schemamode>(schema: instanceofschema<t, mode>) => r;\n intersect?: <t extends readonly anyschema[], mode extends schemamode>(schema: intersectschema<t, mode>, branches: (r | null)[]) => r;\n lazy?: <t, input, mode extends schemamode>(schema: lazyschema<t, input, mode>) => r;\n literal?: <t extends string | number | boolean | null | undefined, mode extends schemamode>(schema: literalschema<t, mode>) => r;\n map?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: mapschema<k, v, mode>, key: r | null, value: r | null) => r;\n never?: <mode extends schemamode>(schema: neverschema<mode>) => r;\n number?: <input, mode extends schemamode>(schema: numberschema<input, mode>) => r;\n object?: <t extends objectshape, mode extends schemamode>(schema: objectschema<t, mode>, fields: record<string, r | null>) => r;\n pipe?: <to extends anyschema, from extends anyschema, mode extends schemamode>(schema: pipeschema<to, from, mode>, from: r | null, to: r | null) => r;\n record?: <k extends anyschema, v extends anyschema, mode extends schemamode>(schema: recordschema<k, v, mode>, key: r | null, value: r | null) => r;\n set?: <t extends anyschema, mode extends schemamode>(schema: setschema<t, mode>, item: r | null) => r;\n string?: <input, mode extends schemamode>(schema: stringschema<input, mode>) => r;\n tuple?: <t extends tupleschemas, rest extends anyschema | null, mode extends schemamode>(schema: tupleschema<t, rest, mode>, items: (r | null)[], rest: r | null) => r;\n union?: <t extends readonly anyschema[], mode extends schemamode>(schema: unionschema<t, mode>, branches: (r | null)[]) => r;\n unknown?: (schema: anyschema) => r;\n variant?: <k extends string, m extends record<string, objectschema<any, any>>, mode extends schemamode>(schema: variantschema<k, m, mode>, branches: record<string, r | null>) => r;\n};\n```\n\n### error helpers\n\n```ts\ntype flaterror = { messages: string[]; path: (string | number)[] };\ntype flaterrorfirst = { message: string; path: (string | number)[] };\n```\n",
1144
+ "usage": " \ntitle: spell — usage guide\ndescription: learn how to build schemas, compose wrappers, customize locales, and integrate spell with other vielzeug packages.\n \n\n[[toc]]\n\n## basic usage\n\nstart with `safeparse()` when you want explicit success and failure branches.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst signup = s.object({\n email: s.string().email(),\n password: s.string().min(12),\n referralcode: s.string().optional(),\n});\n\nconst result = signup.safeparse({\n email: 'ada@example.com',\n password: 'horse battery staple',\n});\n\nif (!result.success) {\n console.error(result.error.issues);\n} else {\n console.log(result.data.email);\n}\n```\n\nuse `parse()` when invalid input should throw immediately. use `safeparse()` when invalid input is part of normal control flow.\n\n## building schemas\n\nuse the namespace form when readability matters more than bundle trimming.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst article = s.object({\n id: s.string().uuid(),\n title: s.string().trim().min(1).max(120),\n slug: s.string().slug(),\n tags: s.array(s.string().min(1)).default(() => []),\n meta: s\n .object({\n published: s.boolean(),\n publishedat: s.date().nullable(),\n })\n .relaxed(),\n});\n```\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst todo = s.object({\n done: s.boolean(),\n tags: s.array(s.string().min(1)).default(() => []),\n title: s.string().min(1),\n});\n```\n\nobject schemas reject unknown keys by default. call `.relaxed()` when you need to preserve extra properties.\n\ncall `.defaults()` to get a fully default filled object without providing any input. every required field must have a `.default()` set, or a `spellvalidationerror` is thrown. call `.partialdefaults()` when only some fields have defaults — fields without a default are silently omitted instead of throwing.\n\n```ts\nconst config = s.object({\n host: s.string().default('localhost'),\n port: s.number().default(3000),\n});\n\nconfig.defaults(); // { host: 'localhost', port: 3000 }\n\nconst form = s.object({ name: s.string(), role: s.string().default('viewer') });\nform.partialdefaults(); // { role: 'viewer' }\n```\n\n## wrapper modes, defaults, and fallbacks\n\nchain wrappers to describe missing values and recovery rules without losing schema metadata.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst displayname = s.string().trim().min(2).label('display name').optional().default('guest').nullable();\n\ndisplayname.parse(undefined); // 'guest'\ndisplayname.parse(null); // null\ndisplayname.description; // 'display name'\n```\n\ncall `.required()` to remove `undefined` without removing `null`.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst nullablebutrequired = s.string().optional().nullable().required();\n\nnullablebutrequired.parse('ada');\nnullablebutrequired.parse(null);\n// nullablebutrequired.parse(undefined); // throws\n```\n\nuse `.catch()` when you want a fallback output after validation fails.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst port = s.number().int().min(1).max(65535).catch(3000);\n\nport.parse('not a number'); // 3000\n```\n\n## custom validation\n\nuse `check()` for synchronous domain rules and `checkasync()` for asynchronous rules. sync parsing rejects schemas with asynchronous checks.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\n// boolean shorthand: return false to fail with default message\nconst evennumber = s.number().check((n) => n % 2 === 0);\n\n// string shorthand: return the message as a string\nconst username = s\n .string()\n .min(3)\n .check((v) => !v.startswith('_') || 'cannot start with underscore');\n\n// multiple issues via ctx.addissue()\nconst signup = s.object({ confirm: s.string(), password: s.string() }).check((v, ctx) => {\n if (v.password !== v.confirm) {\n ctx.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] });\n }\n});\n```\n\n`checkasync()` returns an async only schema: typescript exposes `parseasync()` and `safeparseasync()` but not `parse()` or `safeparse()`. this mode survives fluent modifiers and propagates through nested arrays, objects, unions, intersections, tuples, maps, records, sets, lazy schemas, pipelines, and `s.discriminatedunion(...)` branches. sync parsing also fails at runtime instead of accepting an unchecked value.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst takenemails = new set(['ada@example.com']);\n\nconst accountemail = s\n .string()\n .email()\n .checkasync(async (value, ctx) => {\n if (takenemails.has(value)) {\n ctx.addissue({ code: 'custom', message: 'email is already taken', path: [] });\n }\n });\n\n// async checks require parseasync\nawait accountemail.parseasync('grace@example.com');\n```\n\nuse `check()` for predicate only rules too. return `true` on success or message on failure.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst positiveprice = s.number().check((value) => value > 0 || 'must be positive');\npositiveprice.parse(9.99);\n```\n\n## strings, numbers, and safe regex usage\n\nuse schema helpers for common string and number constraints instead of hand written predicates.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst password = s.string().min(12).regex(/[a z]/).regex(/[0 9]/);\nconst price = s.number().nonnegative().multipleof(0.01);\nconst launchwindow = s.date().min(new date('2025 01 01t00:00:00.000z'));\n```\n\nspell strips stateful `/g` and `/y` flags from `regex()` patterns before validation. repeated parses stay deterministic even when the original regular expression is reused.\n\n## coercion and transforms\n\nuse coercion when input arrives as strings, query parameters, or form values.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst query = s.object({\n draft: s.coerce.boolean().default(false),\n limit: s.coerce.number().int().positive().default(20),\n publishedat: s.coerce.date().nullable(),\n search: s.coerce.string().trim().min(1).optional(),\n});\n\nconst parsed = query.parse({\n draft: 'true',\n limit: '50',\n publishedat: '2025 04 01t12:00:00.000z',\n search: ' vielzeug ',\n});\n```\n\nuse `transform()` or `pipe()` after validation when downstream code needs a different output shape.\n\n```ts\nimport { s } from '@vielzeug/spell';\n\nconst trimmedtags = s.array(s.string().trim().min(1)).transform((tags) => tags.map((tag) => tag.tolowercase()));\nconst slug = s.string().trim().min(1).pipe(s.string().slug());\n```\n\n## introspection, round trips, and json schema\n\nuse declarative definitions when schemas need to cross process boundaries or feed tooling.\n\n```ts\nimport { s } from '@vielzeug/spell';\nimport { fromdefinition } from '@vielzeug/spell/json';\n\nconst product = s\n .object({\n id: s.string().uuid(),\n name: s.string().min(1),\n price: s.number().positive().multipleof(0.01),\n })\n .label('product');\n\nconst definition = product.definition();\nconst jsonschema = fromdefinition(definition);\n\nproduct.parse({ id: '550e8400 e29b 41d4 a716 446655440000', name: 'keyboard', price: 129.99 });\nconsole.log(jsonschema.title);\n```\n\ndefinitions are frozen serializable snapshots of declarative schema structure. use `definition()` and `fromdefinition()` for external tooling. schemas with runtime checks, transforms, defaults, catches, or preprocessors intentionally have no definition.\n\n## messages\n\nspell has no mutable process wide configuration. build one parse context per request, locale, or form, then pass it explicitly.\n\n```ts\nimport { diagnostics, s } from '@vielzeug/spell';\n\nconst user = s.object({ email: s.string().email() });\nconst german = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'keine unbekannten felder erlaubt' },\n});\n\nuser.safeparse({ email: 'ada@example.com', extra: true }, german);\n```\n\ninternal development warnings always use `console.warn` in development builds. route application diagnostics in application code instead of mutating library wide logger state.\n\n## working with validation errors\n\nuse `spellvalidationerror` helpers when you need ui ready error structures.\n\n```ts\nimport { s, spellvalidationerror } from '@vielzeug/spell';\n\nconst user = s.object({\n email: s.string().email(),\n profile: s.object({\n name: s.string().min(2),\n }),\n});\n\nconst result = user.safeparse({ email: 'nope', profile: { name: '' } });\n\nif (!result.success && result.error instanceof spellvalidationerror) {\n const profileerrors = result.error.messagesat('profile', 'name');\n console.log(profileerrors);\n}\n```\n\nuse `bestmatch()` on a union failure when you want the branch that came closest to succeeding. pass a specific `invalid_union` issue when one validation produced multiple union failures.\n\n## schema traversal with walk()\n\nuse `walk()` to inspect or transform a schema tree without importing internal implementation classes.\n\n```ts\nimport { s, type schemawalker } from '@vielzeug/spell';\n\nconst fields: string[] = [];\n\nconst collectfields: schemawalker<void> = {\n object(schema) {\n for (const [key, child] of object.entries(schema.shape)) {\n fields.push(key);\n child.walk(collectfields);\n }\n },\n unknown() {},\n};\n\nconst user = s.object({\n email: s.string().email(),\n profile: s.object({ name: s.string() }),\n});\n\nuser.walk(collectfields);\nconsole.log(fields); // ['email', 'profile', 'name']\n```\n\n`walk()` dispatches by `schema.kind`. if no handler matches and no `unknown` fallback is provided, `walk()` returns `null`. add an `unknown` handler to capture any kind not explicitly listed in your visitor.\n\n## framework integration\n\nspell works anywhere you can call a function before state enters your app.\n\n::: code group\n\n```tsx [react]\nimport { s } from '@vielzeug/spell';\n\nconst searchparams = s\n .object({\n page: s.coerce.number().int().positive().default(1),\n q: s.string().trim().optional(),\n })\n .relaxed();\n\nexport function searchpage({ rawparams }: { rawparams: unknown }) {\n const params = searchparams.parse(rawparams);\n\n return (\n <div>\n {params.q ?? 'all results'} — page {params.page}\n </div>\n );\n}\n```\n\n```ts [vue]\nimport { computed, ref } from 'vue';\nimport { s } from '@vielzeug/spell';\n\nconst settings = s.object({\n locale: s.string().min(2),\n compact: s.coerce.boolean().default(false),\n});\n\nconst raw = ref<unknown>({ locale: 'en', compact: 'true' });\nconst settings = computed(() => settings.parse(raw.value));\n```\n\n:::\n\nuse `safeparse()` at event boundaries and `parse()` inside trusted data flows.\n\n## working with other vielzeug libraries\n\nuse spell as the validation layer and let other packages focus on transport, forms, or storage.\n\n```ts\nimport { createform } from '@vielzeug/forge';\nimport { customvalidator } from '@vielzeug/forge/spell';\nimport { createcourier } from '@vielzeug/courier';\nimport { s } from '@vielzeug/spell';\n\nconst profile = s.object({\n displayname: s.string().min(2),\n newsletter: s.boolean(),\n});\n\nconst form = createform({\n initialvalues: {\n displayname: '',\n newsletter: false,\n },\n validate: customvalidator(profile),\n});\n\nconst courier = createcourier({ baseurl: '/api' });\nconst profile = profile.parse(await courier.get('/profile'));\n```\n\nuse spell definitions with `@vielzeug/codex` or other tooling when you need generated docs or external schema consumers.\n\n## best practices\n\n keep schemas close to the boundary where unknown data enters your app.\n use `s` consistently for construction; use explicit `/json` and `/predicates` subpaths for tooling.\n use `.default(() => value)` for mutable defaults such as arrays, objects, `map`, and `set`.\n call `.required()` when you want to remove `undefined` but keep `null` semantics intact.\n use `check()` with a `ctx` argument when you need `ctx.addissue()`; return a message for simple predicate failures.\n use `checkasync()` and `parseasync()` for every asynchronous domain rule.\n build a parse context per request or test; never rely on mutable process wide configuration.\n use `definition()` with `fromdefinition()` from `@vielzeug/spell/json` for external tooling.\n",
1145
+ "examples": " \ntitle: spell — examples\ndescription: practical examples and recipes for spell.\n \n\n## examples\n\n [validating api payloads](./examples/api.md)\n [form safe parsing](./examples/forms.md)\n [async business rules](./examples/async.md)\n [schema introspection and round trips](./examples/introspection.md)\n [unions, intersections, and variants](./examples/unions.md)\n [schema traversal with walk()](./examples/walk.md)\n"
1146
+ },
1147
+ "examples": [
1148
+ {
1149
+ "id": "array-validation",
1150
+ "text": "array validation // validate a product tag list before it hits search filters.\nimport { s } from '@vielzeug/spell'\n\nconst producttags = s.array(s.string().trim().min(2)).min(1).max(4).unique()\n\nconsole.log('valid tags:', producttags.safeparse(['ui', 'forms', 'docs']).success)\n\nconst invalid = producttags.safeparse(['ui', 'ui', 'x', 'search', 'extra'])\nconsole.log('invalid tags:', invalid.success)\n\nif (!invalid.success) {\n console.log('issues:', invalid.error.issues.map((issue) => issue.message))\n}"
1151
+ },
1152
+ {
1153
+ "id": "async-validate",
1154
+ "text": "async validation // checkasync() declares asynchronous domain rules.\n// use safeparseasync() or parseasync() for schemas containing async checks.\nimport { s } from '@vielzeug/spell'\n\n// simulated async check (e.g. database lookup)\nfunction isusernameavailable(name) {\n return new promise(resolve => settimeout(() => resolve(name !== 'taken'), 50))\n}\n\nconst usernameschema = s.string()\n .min(3)\n .checkasync(async (name) => {\n const available = await isusernameavailable(name)\n return available || 'username is already taken'\n })\n\n// async checks require safeparseasync() or parseasync()\nconst ok = await usernameschema.safeparseasync('alice')\nconsole.log('alice:', ok.success ? 'available' : ok.error.issues[0].message)\n\nconst fail = await usernameschema.safeparseasync('taken')\nconsole.log('taken:', fail.success ? 'available' : fail.error.issues[0].message)\n\nconst tooshort = await usernameschema.safeparseasync('ab')\nconsole.log('ab:', tooshort.success ? 'available' : tooshort.error.issues[0].message)"
1155
+ },
1156
+ {
1157
+ "id": "basic-parsing",
1158
+ "text": "basic parsing // schema definition, type inference, and safe parsing\nimport { s } from '@vielzeug/spell'\n\nconst product = s.object({\n id: s.string().uuid(),\n name: s.string().min(1).max(120),\n price: s.number().positive().multipleof(0.01),\n tags: s.array(s.string().min(1)).default(() => []),\n})\n\n// infer the typescript type directly from the schema\n// type product = { id: string; name: string; price: number; tags: string[] }\n\n// parse() throws on failure — use when invalid input is a programmer error\nconst product = product.parse({\n id: '550e8400 e29b 41d4 a716 446655440000',\n name: 'mechanical keyboard',\n price: 129.99,\n})\nconsole.log('parsed:', product.name, '— tags:', product.tags)\n\n// safeparse() returns a tagged result union — use at untrusted boundaries\nconst bad = product.safeparse({ id: 'not a uuid', name: '', price: 5 })\nif (!bad.success) {\n const paths = bad.error.issues.map(i => i.path.join('.') || 'root')\n console.log('validation failed at:', paths.join(', '))\n}"
1159
+ },
1160
+ {
1161
+ "id": "basic-schema",
1162
+ "text": "basic schema validation // validate a signup payload before it enters application state.\nimport { s } from '@vielzeug/spell'\n\nconst signup = s.object({\n email: s.string().email(),\n password: s.string().min(12),\n referralcode: s.string().optional(),\n})\n\nconsole.log('accepted:', signup.parse({\n email: 'ada@example.com',\n password: 'horse battery staple',\n}))\n\nconst invalid = signup.safeparse({\n email: 'not an email',\n password: 'short',\n})\n\nif (!invalid.success) {\n console.log('email errors:', invalid.error.messagesat('email'))\n console.log('password errors:', invalid.error.messagesat('password'))\n}"
1163
+ },
1164
+ {
1165
+ "id": "coercion",
1166
+ "text": "type coercion // coerce query params into typed search options with safe defaults.\nimport { s } from '@vielzeug/spell'\n\nconst searchquery = s.object({\n draft: s.coerce.boolean().default(false),\n limit: s.coerce.number().int().positive().default(20),\n page: s.coerce.number().int().positive().default(1),\n q: s.coerce.string().trim().min(1).optional(),\n})\n\nconst parsed = searchquery.parse({\n draft: 'true',\n limit: '50',\n page: '2',\n q: ' vielzeug ',\n})\n\nconsole.log(parsed)\nconsole.log('limit type:', typeof parsed.limit)"
1167
+ },
1168
+ {
1169
+ "id": "descriptor-roundtrip",
1170
+ "text": "declarative definition export import { s } from '@vielzeug/spell'\nimport { fromdefinition } from '@vielzeug/spell/json'\n\nconst product = s.object({\n id: s.string().uuid(),\n name: s.string().min(1),\n price: s.number().positive(),\n})\n\nconst definition = product.definition()\nconst jsonschema = fromdefinition(definition)\n\nconsole.log(definition.kind)\nconsole.log(jsonschema)"
1171
+ },
1172
+ {
1173
+ "id": "discriminated-union",
1174
+ "text": "discriminated union // s.discriminatedunion() validates a discriminated union — objects sharing a common tag field.\n// spell automatically injects the discriminator literal into each branch.\nimport { s } from '@vielzeug/spell'\n\nconst event = s.discriminatedunion('type', {\n click: s.object({ x: s.number(), y: s.number() }),\n keydown: s.object({ key: s.string(), repeat: s.boolean() }),\n resize: s.object({ width: s.number(), height: s.number() }),\n})\n\nconst click = event.parse({ type: 'click', x: 100, y: 200 })\nconsole.log('click:', click)\n\nconst key = event.parse({ type: 'keydown', key: 'enter', repeat: false })\nconsole.log('keydown:', key)\n\n// wrong discriminator value\nconst bad = event.safeparse({ type: 'unknown', x: 0 })\nconsole.log('unknown type:', bad.success ? 'ok' : bad.error.issues[0].message)\n\n// missing required field in matched branch\nconst missingfield = event.safeparse({ type: 'resize', width: 800 })\nconsole.log('missing height:', missingfield.success ? 'ok' : missingfield.error.issues[0].message)"
1175
+ },
1176
+ {
1177
+ "id": "format-validators",
1178
+ "text": "format predicates import { s } from '@vielzeug/spell'\nimport { isemail, isuuid } from '@vielzeug/spell/predicates'\n\nconsole.log(isemail('ada@example.com'))\nconsole.log(isemail('not an email'))\nconsole.log(isuuid('550e8400 e29b 41d4 a716 446655440000'))\nconsole.log(isuuid('short'))\n\nconst userid = s.string().uuid()\nconsole.log(userid.safeparse('550e8400 e29b 41d4 a716 446655440000').success)"
1179
+ },
1180
+ {
1181
+ "id": "messages-override",
1182
+ "text": "request local messages import { diagnostics, s } from '@vielzeug/spell'\n\nconst context = diagnostics.createparsecontext({\n object: { invalidkeys: () => 'use only supported fields' },\n})\n\nconsole.log(s.object({ email: s.string().email() }).safeparse({ email: 'ada@example.com', extra: true }, context).success)"
1183
+ },
1184
+ {
1185
+ "id": "nested-objects",
1186
+ "text": "variant responses // model an api response with a discriminator instead of a loose union.\nimport { s } from '@vielzeug/spell'\n\nconst searchresponse = s.discriminatedunion('status', {\n error: s.object({\n message: s.string().min(1),\n status: s.literal('error'),\n }),\n success: s.object({\n results: s.array(s.object({ id: s.string().uuid(), title: s.string().min(1) })).default(() => []),\n status: s.literal('success'),\n }),\n})\n\nconsole.log('success branch:', searchresponse.parse({\n status: 'success',\n results: [{ id: '550e8400 e29b 41d4 a716 446655440000', title: 'spell docs' }],\n}))\n\nconst invalid = searchresponse.safeparse({ status: 'success', message: 'no results here' })\nconsole.log('invalid branch accepted:', invalid.success)"
1187
+ },
1188
+ {
1189
+ "id": "number-validation",
1190
+ "text": "number validation // enforce money like numeric constraints for a checkout amount.\nimport { s } from '@vielzeug/spell'\n\nconst checkouttotal = s.number().nonnegative().multipleof(0.01).max(9999)\n\nfor (const value of [129.99, 4, 19.999, 15000]) {\n const result = checkouttotal.safeparse(value)\n console.log(value, '=>', result.success ? 'accepted' : result.error.issues[0].message)\n}"
1191
+ },
1192
+ {
1193
+ "id": "object-defaults",
1194
+ "text": "object defaults import { s } from '@vielzeug/spell';\n\n// schema where all fields have defaults\nconst serverconfig = s.object({\n host: s.string().default('localhost'),\n port: s.number().int().positive().default(3000),\n tls: s.boolean().default(false),\n});\n\n// get a fully filled config without providing any input\nconst config = serverconfig.defaults();\nconsole.log(config);\n// { host: 'localhost', port: 3000, tls: false }\n\n// works with nested schemas too\nconst appconfig = s.object({\n server: serverconfig,\n debug: s.boolean().default(false),\n});\n\n// parse with partial input — missing fields use their defaults\nconst parsed = appconfig.parse({ server: { host: 'prod.example.com', port: 443, tls: true }, debug: true });\nconsole.log(parsed.server.host); // 'prod.example.com'\n\n// schema with required field (no default) — throws if .defaults() called\nconst strict = s.object({ name: s.string() });\nconst result = strict.safeparse({});\nconsole.log(result.success); // false — name is required\n"
1195
+ },
1196
+ {
1197
+ "id": "object-merge",
1198
+ "text": "object merge & aliases import { s } from '@vielzeug/spell';\n\n// merge() combines two object schemas (right hand fields win on conflict)\nconst base = s.object({\n id: s.string().uuid(),\n createdat: s.date(),\n});\n\nconst withmeta = s.object({\n description: s.string().optional(),\n tags: s.array(s.string()).default(() => []),\n});\n\nconst resource = base.merge(withmeta);\n\nconst result = resource.parse({\n createdat: new date('2025 01 01'),\n id: '550e8400 e29b 41d4 a716 446655440000',\n tags: ['api', 'v2'],\n});\nconsole.log(result.tags); // ['api', 'v2']\nconsole.log(result.id); // '550e8400 ...'\n\n// merge() inherits the right hand schema's strict/relaxed mode\nconst strict = s.object({ a: s.string() });\nconst relaxed = s.object({ b: s.number() }).relaxed();\n\nconst merged = strict.merge(relaxed);\n// extra keys are allowed because relaxed is the right hand schema\nconsole.log(merged.safeparse({ a: 'hi', b: 1, extra: true }).success); // true\n\nconst idorslug = s.union(s.string().uuid(), s.string().slug());\nconsole.log(idorslug.safeparse('550e8400 e29b 41d4 a716 446655440000').success); // true\nconsole.log(idorslug.safeparse('my slug').success); // true\nconsole.log(idorslug.safeparse(42).success); // false\n\nconst nonemptystring = s.intersect(s.string(), s.string().min(1));\nconsole.log(nonemptystring.parse('hello')); // 'hello'\n"
1199
+ },
1200
+ {
1201
+ "id": "optional-nullable",
1202
+ "text": "optional and nullable fields // preserve defaults and validators while tightening undefined away with required().\nimport { s } from '@vielzeug/spell'\n\nconst displayname = s.string().trim().min(2).optional().default('guest').nullable()\nconst requireddisplayname = displayname.required()\n\nconsole.log('default for undefined:', displayname.parse(undefined))\nconsole.log('null stays null:', displayname.parse(null))\n\nconst short = requireddisplayname.safeparse('a')\nconsole.log('short name accepted:', short.success)\n\nconst missing = requireddisplayname.safeparse(undefined)\nconsole.log('undefined accepted after required():', missing.success)\n\nconsole.log('null accepted after required():', requireddisplayname.parse(null))"
1203
+ },
1204
+ {
1205
+ "id": "refinements",
1206
+ "text": "custom validation // check() and checkasync() — explicit custom domain rules\nimport { s } from '@vielzeug/spell'\n\nconst reserved = new set(['admin', 'root'])\n\n// check() is synchronous; return a string to fail with that message\nconst username = s.string().min(3).check((value) =>\n !reserved.has(value) || value + ' is reserved'\n)\n\n// check() receives context for multiple issues or custom error codes\nconst signup = s.object({ password: s.string().min(8), confirm: s.string() })\n .check((v, ctx) => {\n if (v.password !== v.confirm)\n ctx.addissue({ code: 'custom', message: 'passwords must match', path: ['confirm'] })\n })\n\n// check() also covers predicate only domain rules\nconst evenport = s.number().int().min(1).max(65535)\n .check((n) => n % 2 === 0 || 'port must be even')\n\nfor (const name of ['ad', 'admin', 'grace']) {\n const r = username.safeparse(name)\n console.log(name, ' >', r.success ? 'ok' : r.error.issues[0].message)\n}\n\nconst signupresult = signup.safeparse({ password: 'secure123', confirm: 'different' })\nconsole.log('signup:', signupresult.success ? 'ok' : signupresult.error.issues[0].message)\n\nfor (const port of [8080, 3001, 443]) {\n const r = evenport.safeparse(port)\n console.log('port', port, ' >', r.success ? 'ok' : r.error.issues[0].message)\n}"
1207
+ },
1208
+ {
1209
+ "id": "schema-walk",
1210
+ "text": "schema traversal // traverse a schema tree with walk() to extract field metadata.\nimport { s } from '@vielzeug/spell'\n\nconst order = s.object({\n id: s.string().uuid(),\n amount: s.number().positive(),\n customer: s.object({\n email: s.string().email(),\n name: s.string().min(1),\n }),\n tags: s.array(s.string()).optional(),\n})\n\n// collect every field name and whether it is optional.\nconst fields: { name: string; required: boolean }[] = []\n\norder.walk({\n object(node) {\n for (const [key, child] of object.entries(node.shape)) {\n fields.push({ name: key, required: !child.isoptional })\n child.walk(this)\n }\n },\n // unknown() catches any kind without a handler; omitting it returns null instead of throwing\n unknown() {},\n})\n\nconsole.log('fields:')\nfields.foreach(f => console.log(' ', f.name, f.required ? '(required)' : '(optional)'))\nconsole.log('total:', fields.length)"
1211
+ },
1212
+ {
1213
+ "id": "string-validation",
1214
+ "text": "string validation // reuse one stateful regex safely across repeated parses in the browser repl.\nimport { s } from '@vielzeug/spell'\n\nconst hexcolor = s.string().regex(/#[0 9a f]{6}/gy)\n\nfor (const value of ['#ff8800', '#ff8800', 'oops']) {\n const result = hexcolor.safeparse(value)\n console.log(value, '=>', result.success)\n}"
1215
+ },
1216
+ {
1217
+ "id": "wrappers-and-defaults",
1218
+ "text": "wrappers & defaults // optional(), nullable(), default(), catch() — missing value semantics\nimport { s } from '@vielzeug/spell'\n\n// optional: accepts undefined, passes through validation otherwise\nconst nickname = s.string().min(2).optional().default('guest')\n\nconsole.log(nickname.parse(undefined)) // 'guest'\nconsole.log(nickname.parse('ada')) // 'ada'\n\n// nullable: accepts null explicitly\nconst bio = s.string().max(200).nullable()\n\nconsole.log(bio.parse(null)) // null\nconsole.log(bio.parse('loves types')) // 'loves types'\n\n// nullish: accepts both null and undefined\nconst avatar = s.string().url().nullish()\n\nconsole.log(avatar.parse(null)) // null\nconsole.log(avatar.parse(undefined)) // undefined\n\n// required(): strips undefined without removing null\nconst nullablebutrequired = s.string().optional().nullable().required()\nconsole.log(nullablebutrequired.parse(null)) // null\nconsole.log(nullablebutrequired.safeparse(undefined).success) // false\n\n// catch(): returns a fallback when validation fails — never throws\nconst port = s.number().int().min(1).max(65535).catch(3000)\nconsole.log(port.parse(8080)) // 8080\nconsole.log(port.parse('not a port')) // 3000"
1219
+ }
1220
+ ],
1221
+ "exports": "s schema pipeschema spellvalidationerror spelldefinitionerror errorcode diagnostics ./json ./predicates",
1222
+ "keywords": "schema validation parsing json schema locale typescript descriptors",
1223
+ "name": "@vielzeug/spell",
1224
+ "related": "forge courier vault",
1225
+ "slug": "spell",
1226
+ "source": "import { fail, prependissuepath } from './errors';\nimport { createparsecontext } from './messages';\n\nexport type {\n anyschema,\n checkcontext,\n flaterror,\n flaterrorfirst,\n infer,\n inferinput,\n inferoutput,\n inferschemamode,\n issue,\n jsonschema,\n mergeschemamodes,\n messagefn,\n messages,\n parsecontext,\n parseresult,\n schemadescriptor,\n schemamode,\n schemawalker,\n validatefn,\n validateresult,\n} from './core';\nexport {\n errorcode,\n pipeschema,\n schema,\n spelldefinitionerror,\n spellerror,\n spellvalidationerror,\n schemamode,\n} from './core';\nexport type { deeppartial } from './messages';\nexport { s } from './s';\n\n/** error helpers and immutable parse context creation are secondary operations. */\nexport const diagnostics = {\n createparsecontext,\n fail,\n prependissuepath,\n};\n"
1227
+ },
1228
+ {
1229
+ "category": "time",
1230
+ "description": "explicit temporal parsing, timezone safe arithmetic, and localized date/time formatting for typescript.",
1231
+ "docs": {
1232
+ "index": " \ntitle: tempo — temporal date and time utilities\ndescription: explicit temporal parsing, timezone safe arithmetic, and localized date/time formatting for typescript.\npackage: tempo\ncategory: time\nkeywords: [temporal, date time, timezone, formatting, arithmetic, dst, intl]\nrelated: [rune, vault]\nexports: [temporal, parse, now, nowinstant, isvalid, toinstant, intimezone, shift, difference, contains, clamp, isbefore, isafter, issame, startof, endof, format, formatparts, formatrange, formatrangeparts, formatinstant, formatzoned, formatrelative, parseduration, formatduration, classifyexpiry, timediff, humanize, daterange, recurrence, tempoerror, tempoinvalidinputerror, tempoinvalidtzerror, tempomissingtzerror, tempounsupportedinputerror]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"tempo\" />\n\n## why tempo?\n\ndate/time bugs come from treating an instant and a wall clock value as interchangeable. tempo requires an explicit parse target and requires `timezone` whenever a wall clock value becomes an instant.\n\n```ts\n// before\nconst reminder = new date(meeting.gettime() 15 * 60_000);\n\n// after\nimport { parse, shift, toinstant } from '@vielzeug/tempo';\n\nconst localmeeting = parse('2026 03 21t10:30:00', { as: 'plaindatetime' });\nconst meeting = toinstant(localmeeting, { timezone: 'america/new_york' });\nconst reminder = shift(meeting, { minutes: 15 }, { timezone: 'america/new_york' });\n```\n\n| feature | tempo | date fns | native date |\n| | | | |\n| bundle size | <packageinfo package=\"tempo\" type=\"size\" /> | ~10 kb | 0 kb |\n| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> `@js temporal/polyfill` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| explicit wall time conversion | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| dst safe arithmetic | <ore icon name=\"check\" size=\"16\"></ore icon> | manual | manual |\n| localized formatting | <ore icon name=\"check\" size=\"16\"></ore icon> `intl` | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use tempo when** you need temporal values, explicit timezone rules, and dst safe operations.\n\n**consider native `date` when** your data is only elapsed milliseconds and you do not need calendar or timezone behavior.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/tempo\n```\n\n```sh [npm]\nnpm install @vielzeug/tempo\n```\n\n```sh [yarn]\nyarn add @vielzeug/tempo\n```\n\n:::\n\n## quick start\n\nparse a wall clock input explicitly, attach its timezone, then format it for a user.\n\n```ts\nimport { format, intimezone, parse, shift, toinstant } from '@vielzeug/tempo';\n\nconst localmeeting = parse('2026 03 21t10:30:00', { as: 'plaindatetime' });\nconst meeting = toinstant(localmeeting, { timezone: 'america/new_york' });\nconst reminder = shift(meeting, { minutes: 15 }, { timezone: 'america/new_york' });\nconst text = format(intimezone(reminder, 'america/new_york'), {\n locale: 'en us',\n pattern: 'short',\n});\n```\n\n## features\n\n<div class=\"features grid\">\n\n `parse()` — requires an explicit iso target: instant, zoned date time, plain date time, or plain date.\n `toinstant()` / `intimezone()` — convert wall clock and absolute values with explicit timezone semantics.\n `shift()` / `difference()` — perform dst safe arithmetic and duration calculation.\n `contains()` / `clamp()` — use named range fields instead of ambiguous positional inputs.\n `classifyexpiry()` — classify fixed elapsed time thresholds in milliseconds or larger units without month or year approximation.\n `format()` / `formatrelative()` / `formatduration()` — render ui, relative, and duration values through `intl`.\n `daterange()` / `recurrence()` — lazily generate zoned calendar sequences.\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 [rune](/rune/) — format stable temporal timestamps before writing structured log records.\n [vault](/vault/) — derive explicit expiry moments before storing records with ttl policies.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1233
+ "api": " \ntitle: tempo — api reference\ndescription: reference for tempo temporal parsing, conversion, arithmetic, formatting, and classification apis.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `parse()` | parse iso text to an explicit temporal kind | sync | `as` is required |\n| `isvalid()` | narrow an unknown runtime value to `timeinput` | sync | does not parse strings |\n| `toinstant()` | resolve a value as an absolute instant | sync | plain values require `timezone` |\n| `intimezone()` | project a value to a zone | sync | preserves instant, changes wall clock fields |\n| `shift()` / `difference()` | dst safe arithmetic | sync | calendar work needs a timezone |\n| `contains()` / `clamp()` | named range operations | sync | bounds normalize automatically |\n| `classifyexpiry()` | classify fixed elapsed time thresholds | sync | use milliseconds or larger units; months and years are rejected |\n| `format()` family | localized and machine formatting | sync | use `timezone`, not `tz` |\n| `daterange()` / `recurrence()` | lazy zoned sequences | sync | plain inputs need `timezone` |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/tempo` | tempo utilities, errors, types, and shared `temporal` namespace |\n\n## core functions\n\n### `parse(input, { as })`\n\n```ts\nparse(input: string, options: { as: 'instant' }): temporal.instant;\nparse(input: string, options: { as: 'zoneddatetime' }): temporal.zoneddatetime;\nparse(input: string, options: { as: 'plaindatetime' }): temporal.plaindatetime;\nparse(input: string, options: { as: 'plaindate' }): temporal.plaindate;\n```\n\nparses an iso 8601 string as the requested temporal kind.\n\n**parameters**\n\n| parameter | type | description |\n| | | |\n| `input` | `string` | iso 8601 input |\n| `options.as` | `parseas` | required result kind |\n\n**returns:** requested temporal value.\n\n**example:**\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026 03 21t10:15:30z', { as: 'instant' });\n```\n\n \n\n### `isvalid(value)`\n\n```ts\nisvalid(value: unknown): value is timeinput;\n```\n\nreturns whether `value` is a tempo supported temporal value. it does not parse iso strings.\n\n**example:**\n\n```ts\nimport { isvalid, parse } from '@vielzeug/tempo';\n\nconst value: unknown = parse('2026 03 21t10:15:30z', { as: 'instant' });\nconst valid = isvalid(value); // true\n```\n\n \n\n### `now({ timezone })` / `nowinstant()`\n\n```ts\nnow(options: { timezone: string }): temporal.zoneddatetime;\nnowinstant(): temporal.instant;\n```\n\nreturns current zoned or absolute time.\n\n**example:**\n\n```ts\nimport { now, nowinstant } from '@vielzeug/tempo';\n\nnow({ timezone: 'europe/berlin' });\nnowinstant();\n```\n\n \n\n### `toinstant(input, options?)` / `intimezone(input, timezone)`\n\n```ts\ntoinstant(input: absolutetime): temporal.instant;\ntoinstant(input: walltime, options: { timezone: string } & disambiguationoptions): temporal.instant;\nintimezone(input: timeinput, timezone: string): temporal.zoneddatetime;\n```\n\n`toinstant()` resolves wall clock values. `intimezone()` projects a value into a requested timezone.\n\n**example:**\n\n```ts\nimport { intimezone, parse, toinstant } from '@vielzeug/tempo';\n\nconst local = parse('2026 11 01t01:30:00', { as: 'plaindatetime' });\nconst instant = toinstant(local, { disambiguation: 'later', timezone: 'america/new_york' });\nintimezone(instant, 'europe/berlin');\n```\n\n \n\n### `shift(input, duration, options?)`\n\n```ts\nshift(input: temporal.zoneddatetime, duration: temporal.durationlike, options?: shiftoptions): temporal.zoneddatetime;\nshift(\n input: exclude<timeinput, temporal.zoneddatetime>,\n duration: temporal.durationlike,\n options: shiftoptions & { timezone: string },\n): temporal.zoneddatetime;\n```\n\nadds a duration through temporal calendar rules and returns a zoned value. non `zoneddatetime` inputs require `options.timezone`.\n\n**returns:** `temporal.zoneddatetime`.\n\n**example:**\n\n```ts\nimport { parse, shift } from '@vielzeug/tempo';\n\nconst before = parse('2026 03 08t01:30:00 05:00[america/new_york]', { as: 'zoneddatetime' });\nshift(before, { hours: 1 });\n```\n\n \n\n### `difference({ start, end, ...options })`\n\n```ts\ndifference(input: differenceinput): temporal.duration;\n```\n\nreturns duration from `start` to `end`.\n\n**example:**\n\n```ts\nimport { difference, parse } from '@vielzeug/tempo';\n\nconst start = parse('2026 03 21t10:00:00z', { as: 'instant' });\nconst end = parse('2026 03 21t12:00:00z', { as: 'instant' });\ndifference({ end, largestunit: 'hour', start });\n```\n\n## range and comparison\n\n### `contains({ value, start, end, ...options })`\n\n```ts\ncontains(input: containsinput): boolean;\n```\n\nreturns whether `value` lies in inclusive normalized bounds.\n\n### `clamp({ value, start, end, ...options })`\n\n```ts\nclamp(input: clampinput & { value: temporal.zoneddatetime }): temporal.zoneddatetime;\nclamp(input: clampinput): temporal.instant;\n```\n\nreturns the nearest bound when `value` falls outside the range. returns a `zoneddatetime` when `value` is one, otherwise an `instant`.\n\n### `isbefore(a, b, options?)` / `isafter(a, b, options?)` / `issame(a, b, options?)`\n\n```ts\nisbefore(a: timeinput, b: timeinput, options?: compareoptions): boolean;\nisafter(a: timeinput, b: timeinput, options?: compareoptions): boolean;\nissame(a: timeinput, b: timeinput, options?: compareoptions): boolean;\n```\n\ncompare absolute values or calendar boundaries when `unit` is supplied.\n\n### `startof(input, unit, options?)` / `endof(input, unit, options?)`\n\n```ts\nstartof(input: timeinput, unit: boundaryunit, options?: boundaryoptions): temporal.zoneddatetime;\nendof(input: timeinput, unit: boundaryunit, options?: boundaryoptions): temporal.zoneddatetime;\n```\n\nreturns the first or last nanosecond of the requested boundary unit.\n\n## formatting\n\n### `format(input, options?)`\n\n```ts\nformat(input: timeinput, options?: formatoptions): string;\n```\n\nformats a value through `intl.datetimeformat`.\n\n**example:**\n\n```ts\nimport { format, parse } from '@vielzeug/tempo';\n\nformat(parse('2026 03 21t10:15:30z', { as: 'instant' }), {\n locale: 'en gb',\n pattern: 'short',\n timezone: 'utc',\n});\n```\n\n### `formatinstant()` / `formatzoned()` / `formatrelative()` / `formatduration()`\n\n```ts\nformatinstant(input: timeinput, options?: timezoneoptions): string;\nformatzoned(input: timeinput, options?: timezoneoptions): string;\nformatrelative(input: relativetimeinput, options?: relativeformatoptions): string;\nformatduration(input: string | temporal.durationlike, options?: durationformatoptions): string;\n```\n\n`formatinstant()` produces utc transport text (`timezone` needed for wall time input, ignored for `instant`). `formatzoned()` produces zoned iso text (`timezone` required for non `zoneddatetime` input). `formatduration()` falls back to english when `intl.durationformat` is unavailable.\n\n### `formatparts()` / `formatrange()` / `formatrangeparts()`\n\n```ts\nformatparts(input: timeinput, options?: formatoptions): intl.datetimeformatpart[];\nformatrange(start: timeinput, end: timeinput, options?: formatoptions): string;\nformatrangeparts(\n start: timeinput,\n end: timeinput,\n options?: formatoptions,\n): returntype<intl.datetimeformat['formatrangetoparts']>;\n```\n\nreturn `intl` parts or localized range strings using `formatoptions`.\n\n### `parseduration()` / `humanize()`\n\n```ts\nparseduration(input: string | temporal.durationlike): temporal.duration;\nhumanize(diff: timediffresult, options?: { locale?: intl.localesargument }): string;\n```\n\n`humanize()` localizes numbers only. unit names remain english.\n\n## classification and sequences\n\n### `classifyexpiry({ value, thresholds, relativeto?, timezone? })`\n\n```ts\nclassifyexpiry<k extends string>(input: classifyexpiryinput<k>): k | null;\n```\n\nclassifies an expiry against fixed elapsed time thresholds in milliseconds or larger units. months and years throw `tempoinvalidinputerror`.\n\n### `timediff(a, b?, options?)`\n\n```ts\ntimediff(a: timeinput, b?: timeinput, options?: timezoneoptions): timediffresult;\n```\n\nreturns absolute calendar difference in its largest meaningful unit.\n\n### `daterange()` / `recurrence()`\n\n```ts\ndaterange(start: timeinput, end: timeinput, step: temporal.durationlike, options?: timezoneoptions): generator<temporal.zoneddatetime>;\nrecurrence(start: timeinput, rule: recurrencerule, options?: timezoneoptions): generator<temporal.zoneddatetime>;\n```\n\nreturns lazy `zoneddatetime` sequences.\n\n## types\n\n```ts\ntype absolutetime = temporal.instant | temporal.zoneddatetime;\ntype walltime = temporal.plaindate | temporal.plaindatetime;\ntype timeinput = absolutetime | walltime;\ntype relativetimeinput = absolutetime;\ntype parseas = 'instant' | 'plaindate' | 'plaindatetime' | 'zoneddatetime';\ntype disambiguation = 'compatible' | 'earlier' | 'later' | 'reject';\ntype formatpattern = 'date only' | 'long' | 'medium' | 'short' | 'time only';\ntype tempounit = 'day' | 'hour' | 'microsecond' | 'millisecond' | 'minute' | 'month' | 'nanosecond' | 'second' | 'week' | 'year';\ntype calendarunit = extract<tempounit, 'day' | 'month' | 'week' | 'year'>;\ntype boundaryunit = exclude<tempounit, 'microsecond' | 'millisecond' | 'nanosecond' | 'second'>;\ntype weekstartday = 1 | 2 | 3 | 4 | 5 | 6 | 7;\ntype fixedduration = pick<temporal.durationlike, 'days' | 'hours' | 'microseconds' | 'milliseconds' | 'minutes' | 'nanoseconds' | 'seconds' | 'weeks'>;\ntype expirythresholds<k extends string> = record<k, fixedduration>;\ntype timediffunit = exclude<tempounit, 'microsecond' | 'nanosecond'>;\ntype timediffresult = { unit: timediffunit; value: number };\ntype recurrencerule =\n | { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number; count: number; until?: timeinput }\n | { frequency: 'daily' | 'monthly' | 'weekly' | 'yearly'; interval?: number; count?: number; until: timeinput };\n\ninterface timezoneoptions { timezone?: string }\ninterface disambiguationoptions { disambiguation?: disambiguation }\ninterface shiftoptions extends disambiguationoptions, timezoneoptions {}\ninterface differenceinput extends disambiguationoptions, timezoneoptions {\n start: timeinput;\n end: timeinput;\n largestunit?: temporal.datetimeunit;\n smallestunit?: temporal.datetimeunit;\n roundingincrement?: number;\n roundingmode?: temporal.roundingmode;\n}\ntype formatoptions =\n | { intl: intl.datetimeformatoptions; locale?: intl.localesargument; pattern?: never; timezone?: string }\n | { intl?: never; locale?: intl.localesargument; pattern?: formatpattern; timezone?: string };\ninterface relativeformatoptions {\n base?: relativetimeinput;\n locale?: intl.localesargument;\n numeric?: intl.relativetimeformatnumeric;\n style?: intl.relativetimeformatstyle;\n}\ninterface durationformatoptions {\n locale?: intl.localesargument;\n style?: 'digital' | 'long' | 'narrow' | 'short';\n}\ninterface boundaryoptions extends timezoneoptions { weekstartson?: weekstartday }\ninterface compareoptions extends timezoneoptions { unit?: boundaryunit; weekstartson?: weekstartday }\ninterface containsinput extends compareoptions { value: timeinput; start: timeinput; end: timeinput }\ninterface clampinput extends compareoptions { value: timeinput; start: timeinput; end: timeinput }\ninterface classifyexpiryinput<k extends string> extends timezoneoptions {\n value: timeinput;\n thresholds: expirythresholds<k>;\n relativeto?: temporal.instant;\n}\n```\n\n## errors\n\n| error | trigger | notable properties |\n| | | |\n| `tempoerror` | base tempo error | `instanceof tempoerror` narrows every subtype |\n| `tempoinvalidinputerror` | invalid parse, duration, or fixed threshold input | extends `tempoerror` |\n| `tempoinvalidtzerror` | invalid iana zone or offset | extends `tempoerror` |\n| `tempomissingtzerror` | wall time without required `timezone` | extends `tempoerror` |\n| `tempounsupportedinputerror` | non temporal input passed to conversion | extends `tempoerror` |\n",
1234
+ "usage": " \ntitle: tempo — usage guide\ndescription: parse explicit temporal values, resolve wall clock time, compare ranges, and format dates with tempo.\n \n\n[[toc]]\n\n## basic usage\n\nparse iso input with a declared target. convert plain values with `timezone` before treating them as an instant.\n\n```ts\nimport { format, intimezone, parse, shift, toinstant } from '@vielzeug/tempo';\n\nconst local = parse('2026 03 21t10:15:30', { as: 'plaindatetime' });\nconst instant = toinstant(local, { timezone: 'america/new_york' });\nconst reminder = shift(instant, { minutes: 15 }, { timezone: 'america/new_york' });\n\nformat(intimezone(reminder, 'america/new_york'), { locale: 'en us', pattern: 'short' });\n```\n\n## parse iso values\n\nchoose the value your boundary actually represents. tempo does not auto detect iso strings.\n\n```ts\nimport { parse } from '@vielzeug/tempo';\n\nconst occurredat = parse('2026 03 21t10:15:30z', { as: 'instant' });\nconst meeting = parse('2026 03 21t10:15:30+01:00[europe/berlin]', { as: 'zoneddatetime' });\nconst localstart = parse('2026 03 21t10:15:30', { as: 'plaindatetime' });\nconst birthday = parse('2026 03 21', { as: 'plaindate' });\n```\n\n## convert timezones\n\nuse `intimezone()` to project an absolute value. use `toinstant()` only when resolving a wall clock value.\n\n```ts\nimport { intimezone, parse, toinstant } from '@vielzeug/tempo';\n\nconst local = parse('2026 11 01t01:30:00', { as: 'plaindatetime' });\nconst firstoccurrence = toinstant(local, {\n disambiguation: 'earlier',\n timezone: 'america/new_york',\n});\n\nconst berlin = intimezone(firstoccurrence, 'europe/berlin');\n```\n\n## calculate and compare\n\nuse object inputs for operations with multiple time values.\n\n```ts\nimport { clamp, contains, difference, parse } from '@vielzeug/tempo';\n\nconst start = parse('2026 03 21t10:00:00z', { as: 'instant' });\nconst end = parse('2026 03 21t12:00:00z', { as: 'instant' });\nconst value = parse('2026 03 21t13:00:00z', { as: 'instant' });\n\nconst duration = difference({ end, largestunit: 'hour', start });\nconst isscheduled = contains({ end, start, value });\nconst bounded = clamp({ end, start, value });\n```\n\n## classify expiry\n\nuse fixed elapsed time thresholds in milliseconds or larger units. handle `null` as the unclassified state instead of adding a far future catch all.\n\n```ts\nimport { classifyexpiry, parse } from '@vielzeug/tempo';\n\nconst status = classifyexpiry({\n relativeto: parse('2026 06 01t00:00:00z', { as: 'instant' }),\n thresholds: {\n expired: { days: 0 },\n critical: { days: 3 },\n warning: { days: 14 },\n },\n value: parse('2026 06 04t00:00:00z', { as: 'instant' }),\n});\n\nconst label = status ?? 'safe';\n```\n\n## format values\n\nuse `format()` for ui, `formatinstant()` for transport, and `formatzoned()` for a zoned iso string.\n\n```ts\nimport { format, formatinstant, formatrelative, formatzoned, parse } from '@vielzeug/tempo';\n\nconst instant = parse('2026 03 21t10:15:30z', { as: 'instant' });\n\nformat(instant, { locale: 'en gb', pattern: 'short', timezone: 'utc' });\nformatinstant(instant);\nformatzoned(instant, { timezone: 'europe/berlin' });\nformatrelative(instant, { base: parse('2026 03 21t09:15:30z', { as: 'instant' }) });\n```\n\n## generate calendar sequences\n\nuse zoned inputs for date sequences so the timezone is inferred.\n\n```ts\nimport { daterange, parse, recurrence } from '@vielzeug/tempo';\n\nconst start = parse('2026 03 01t00:00:00[utc]', { as: 'zoneddatetime' });\nconst end = parse('2026 03 31t00:00:00[utc]', { as: 'zoneddatetime' });\n\nconst days = [...daterange(start, end, { days: 1 })];\nconst meetings = [...recurrence(start, { count: 4, frequency: 'weekly' })];\n```\n\n## testing\n\npin the reference instant for deterministic expiry tests.\n\n```ts\nimport { classifyexpiry, parse } from '@vielzeug/tempo';\n\nconst relativeto = parse('2026 06 01t00:00:00z', { as: 'instant' });\nconst value = parse('2026 05 31t00:00:00z', { as: 'instant' });\n\nclassifyexpiry({ relativeto, thresholds: { expired: { days: 0 } }, value });\n```\n\n## framework integration\n\npass iso strings through component props. parse and format at the rendering boundary.\n\n::: code group\n\n```tsx [react]\nimport { format, parse } from '@vielzeug/tempo';\n\nconst label = format(parse(iso, { as: 'instant' }), { locale: 'en us', pattern: 'medium', timezone: 'utc' });\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nimport { format, parse } from '@vielzeug/tempo';\n\nconst props = defineprops<{ iso: string }>();\nconst label = format(parse(props.iso, { as: 'instant' }), { locale: 'en us', pattern: 'medium', timezone: 'utc' });\n</script>\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { format, parse } from '@vielzeug/tempo';\n export let iso: string;\n $: label = format(parse(iso, { as: 'instant' }), { locale: 'en us', pattern: 'medium', timezone: 'utc' });\n</script>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with rune\n\nwrite stable utc timestamps to structured logs.\n\n```ts\nimport { formatinstant, nowinstant } from '@vielzeug/tempo';\n\nlogger.info({ timestamp: formatinstant(nowinstant()) }, 'server started');\n```\n\n### with vault\n\ncalculate an explicit instant before storing an expiring record.\n\n```ts\nimport { now, shift } from '@vielzeug/tempo';\n\nconst expiresat = shift(now({ timezone: 'utc' }), { minutes: 30 }).toinstant();\n```\n\n## best practices\n\n parse each string with its actual temporal meaning.\n pass `timezone` when converting a plain date or plain date time.\n use `disambiguation` for dst overlap and gap handling.\n pass named fields to `difference()`, `contains()`, `clamp()`, and `classifyexpiry()`.\n use fixed duration units for expiry thresholds.\n store instants for transport and database values.\n use `formatinstant()` for machine output and `format()` for user facing text.\n",
1235
+ "examples": " \ntitle: tempo — examples\ndescription: practical examples and recipes for tempo.\n \n\n## examples\n\n [dst safe arithmetic](./examples/dst safe arithmetic.md)\n [locale formatting](./examples/locale formatting.md)\n [timezone conversion](./examples/timezone conversion.md)\n [expiry classification](./examples/expiry classification.md)\n [date ranges and recurrence](./examples/date ranges and recurrence.md)\n"
1236
+ },
1237
+ "examples": [
1238
+ {
1239
+ "id": "meeting-duration",
1240
+ "text": "explicit parsing and timezone arithmetic import { classifyexpiry, contains, difference, format, intimezone, parse, shift, toinstant } from '@vielzeug/tempo'\n\nconst local = parse('2026 03 21t10:00:00', { as: 'plaindatetime' })\nconst start = toinstant(local, { timezone: 'america/new_york' })\nconst end = shift(start, { hours: 2 }, { timezone: 'america/new_york' }).toinstant()\nconst check = parse('2026 03 21t11:00:00z', { as: 'instant' })\n\nconsole.log('duration:', difference({ end, largestunit: 'hour', start }).tostring())\nconsole.log('contains check:', contains({ end, start, value: check }))\nconsole.log('new york:', format(intimezone(start, 'america/new_york'), { locale: 'en us', pattern: 'short' }))\nconsole.log('expiry:', classifyexpiry({ thresholds: { soon: { days: 3 } }, value: end }))"
1241
+ }
1242
+ ],
1243
+ "exports": "temporal parse now nowinstant isvalid toinstant intimezone shift difference contains clamp isbefore isafter issame startof endof format formatparts formatrange formatrangeparts formatinstant formatzoned formatrelative parseduration formatduration classifyexpiry timediff humanize daterange recurrence tempoerror tempoinvalidinputerror tempoinvalidtzerror tempomissingtzerror tempounsupportedinputerror",
1244
+ "keywords": "temporal date time timezone formatting arithmetic dst intl",
1245
+ "name": "@vielzeug/tempo",
1246
+ "related": "rune vault",
1247
+ "slug": "tempo",
1248
+ "source": "// tempo keeps this re export so all consumers share one temporal implementation and version.\nexport { temporal } from '@js temporal/polyfill';\nexport { intimezone, toinstant } from './_convert';\nexport { endof, startof } from './boundary';\nexport { classifyexpiry, timediff } from './classify';\nexport { clamp, contains, isafter, isbefore, issame } from './compare';\nexport { difference, isvalid, now, nowinstant, parse, shift } from './core';\nexport {\n tempoerror,\n tempoinvalidinputerror,\n tempoinvalidtzerror,\n tempomissingtzerror,\n tempounsupportedinputerror,\n} from './errors';\nexport {\n format,\n formatduration,\n formatinstant,\n formatparts,\n formatrange,\n formatrangeparts,\n formatrelative,\n formatzoned,\n humanize,\n parseduration,\n} from './format';\nexport { daterange, recurrence } from './range';\nexport type {\n absolutetime,\n boundaryoptions,\n boundaryunit,\n calendarunit,\n clampinput,\n classifyexpiryinput,\n compareoptions,\n containsinput,\n differenceinput,\n disambiguation,\n disambiguationoptions,\n durationformatoptions,\n expirythresholds,\n fixedduration,\n formatoptions,\n formatpattern,\n parseas,\n recurrencerule,\n relativeformatoptions,\n relativetimeinput,\n shiftoptions,\n tempounit,\n timediffresult,\n timediffunit,\n timeinput,\n timezoneoptions,\n walltime,\n weekstartday,\n} from './types';\n"
1249
+ },
1250
+ {
1251
+ "category": "storage",
1252
+ "description": "typed browser storage and opt in driver neutral sqlite with portable keys, ttl, observation, and transactions.",
1253
+ "docs": {
1254
+ "index": " \ntitle: vault — typed storage\ndescription: typed browser storage and opt in driver neutral sqlite with portable keys, ttl, observation, and transactions.\npackage: vault\ncategory: storage\nkeywords: [storage, indexeddb, localstorage, sessionstorage, sqlite, ttl, browser, node, deno]\nrelated: [courier, forge, ripple]\nexports: [table, ttl, scheduleexpiredprune, isexpired, creatememory, createlocalstorage, createsessionstorage, createindexeddb, createsqlite]\nenvironments: [browser, node, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"vault\" />\n\n## why vault?\n\nvault gives browser and sqlite persistence one typed schema while keeping backend guarantees explicit. use `vaultstore` for portable crud and observation; choose indexeddb or the opt in sqlite subpath when you need atomic transactions or lazy iteration.\n\n```ts\n// before\nlocalstorage.setitem('theme', json.stringify({ value: 'dark' }));\nconst theme = json.parse(localstorage.getitem('theme') ?? '{}').value;\n\n// after\nawait store.put('preferences', { id: 'theme', value: 'dark' });\nconst theme = await store.get('preferences', 'theme');\n```\n\n| feature | vault | raw web storage | dexie |\n| | | | |\n| bundle size | <packageinfo package=\"vault\" type=\"size\" /> | browser built in | extra dependency |\n| runtime dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed schema and keys | <ore icon name=\"check\" size=\"16\"></ore icon> | application defined | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| portable memory/web storage api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> | indexeddb only |\n| explicit atomic transactions | indexeddb capability | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| driver neutral sqlite | opt in subpath | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n\n<div class=\"decision callout\">\n\n**use vault when** you need typed browser persistence or application owned sqlite with one portable crud api and explicit storage capabilities.\n\n**consider raw web storage when** you only persist one or two unstructured values. **consider dexie when** you need a broader indexeddb ecosystem.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/vault\n```\n\n```sh [npm]\nnpm install @vielzeug/vault\n```\n\n```sh [yarn]\nyarn add @vielzeug/vault\n```\n\n:::\n\n## quick start\n\ndefine a schema, create a portable store, and dispose it with its owner.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<{ id: string; theme: 'dark' | 'light' }>('id') },\n});\n\ntry {\n await store.put('preferences', { id: 'theme', theme: 'dark' });\n console.log(await store.get('preferences', 'theme'));\n} finally {\n await store.dispose();\n}\n```\n\n## features\n\n<div class=\"features grid\">\n\n `table()` defines typed records with portable string or number keys.\n `/memory`, `/local storage`, and `/session storage` return portable `vaultstore` instances without loading other adapters.\n `observe()` emits current and changed table snapshots.\n `ttl` creates validated expiration durations.\n `/indexeddb` returns `indexeddbvaultstore` with `batch()` and `iterate()`.\n `createsqlite()` is an opt in, driver neutral subpath for node, bun, and deno sqlite drivers.\n `/indexeddb` also exports `definemigration()` for schema upgrades.\n `scheduleexpiredprune()` removes stale ttl entries on an owned schedule.\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 [forge](../forge/index.md) saves and restores form drafts through vault stores.\n [ripple](../ripple/index.md) owns application state that can persist through vault.\n [courier](../courier/index.md) can populate persistent cache data.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1255
+ "api": " \ntitle: vault — api reference\ndescription: reference for vault schemas, adapter entry points, storage capabilities, sqlite drivers, and errors.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `creatememory()` | in memory portable store | async api | import from `/memory` |\n| `createlocalstorage()` / `createsessionstorage()` | web storage backed portable stores | async api | available only where the corresponding web api exists |\n| `createindexeddb()` | browser transactions and cursor iteration | async api | import from `/indexeddb` |\n| `createsqlite()` | driver neutral sqlite store | async api over a synchronous driver | import from `/sqlite` |\n| `table()` | typed record schema | sync | the key field must be a string or finite number |\n| `ttl` | valid expiration durations | sync | durations must be positive |\n| `scheduleexpiredprune()` | periodic ttl cleanup | sync setup, async work | pass `disposalsignal` to auto cancel |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/vault` | adapter free schemas, ttl, errors, pruning, queries, and shared types |\n| `@vielzeug/vault/memory` | `creatememory` |\n| `@vielzeug/vault/local storage` | `createlocalstorage` |\n| `@vielzeug/vault/session storage` | `createsessionstorage` |\n| `@vielzeug/vault/indexeddb` | `createindexeddb`, `definemigration`, migrations, and indexeddb only types |\n| `@vielzeug/vault/sqlite` | `createsqlite`, the sqlite driver protocol types, and `transactioncontext` |\n\n## schemas and ttl\n\n### `table()`\n\n```ts\nfunction table<t extends object, key extends keyof t & string = keyof t & string>(\n key: key & (t[key] extends vaultkey ? unknown : never),\n options?: { defaultttl?: number; indexes?: readonly (keyof t & string)[] },\n): schemaentry<t, key>;\n```\n\ndefines a typed table and its primary key field.\n\n| parameter | description |\n| | |\n| `key` | a record field whose values are `string` or finite `number` keys |\n| `options.defaultttl` | per table default ttl in milliseconds |\n| `options.indexes` | indexeddb secondary index fields |\n\n**returns:** a `schemaentry` describing the table.\n\n```ts\nimport { table, ttl } from '@vielzeug/vault';\n\nconst users = table<{ id: number; email: string }>('id', {\n indexes: ['email'],\n defaultttl: ttl.days(7),\n});\n```\n\n \n\n### `ttl`\n\n```ts\nconst ttl: {\n days(n: number): number;\n hours(n: number): number;\n minutes(n: number): number;\n ms(n: number): number;\n seconds(n: number): number;\n};\n```\n\ncreates a finite, positive duration in milliseconds for writes and table defaults.\n\n**returns:** `number`.\n\n```ts\nimport { ttl } from '@vielzeug/vault';\n\nconst cachelifetime = ttl.minutes(5);\n```\n\n \n\n### `isexpired()`\n\n```ts\nfunction isexpired(expiresat: number | undefined): boolean;\n```\n\nreports whether an expiration timestamp has passed.\n\n**returns:** `true` when `expiresat` is defined and no later than the current time.\n\n```ts\nimport { isexpired } from '@vielzeug/vault';\n\nif (isexpired(record.expiresat)) console.log('expired');\n```\n\n## factories\n\nall factory options accept `schema`, plus optional `validators`, `logger`, and `onmetrics`. the root entry does not export any factory.\n\n### `creatememory()`\n\n```ts\nfunction creatememory<s extends anyschema>(options: {\n name?: string;\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates an in memory portable store. a `name` enables same origin `broadcastchannel` observation between memory stores when the platform provides it.\n\n| parameter | description |\n| | |\n| `schema` | tables created by `table()` |\n| `name` | optional shared memory store namespace |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { creatememory } from '@vielzeug/vault/memory';\n\nconst store = creatememory({ schema: { users: table<{ id: number; name: string }>('id') } });\n```\n\n \n\n### `createlocalstorage()`\n\n```ts\nfunction createlocalstorage<s extends anyschema>(options: {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates a namespaced `localstorage` store.\n\n| parameter | description |\n| | |\n| `name` | required storage namespace |\n| `onquotaexceeded` | handles a web storage quota error; returning `'ignore'` drops that write |\n| `schema` | tables created by `table()` |\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\nconst store = createlocalstorage({ name: 'app', schema: { settings: table<{ id: string }>('id') } });\n```\n\n \n\n### `createsessionstorage()`\n\n```ts\nfunction createsessionstorage<s extends anyschema>(options: {\n name: string;\n onquotaexceeded?: (table: keyof s, error: vaultquotaerror) => 'ignore' | 'throw';\n schema: s;\n} & baseadapteroptions<s>): vaultstore<s>;\n```\n\ncreates a namespaced `sessionstorage` store. its options and return type match `createlocalstorage()`.\n\n**returns:** `vaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createsessionstorage } from '@vielzeug/vault/session storage';\n\nconst store = createsessionstorage({ name: 'checkout', schema: { cart: table<{ id: string }>('id') } });\n```\n\n \n\n### `createindexeddb()`\n\n```ts\nfunction createindexeddb<s extends anyschema>(options: {\n migrate?: migrationfn;\n name: string;\n schema: s;\n version?: number;\n} & baseadapteroptions<s>): indexeddbvaultstore<s>;\n```\n\ncreates an indexeddb store with atomic batches, lazy cursor iteration, and optional schema migrations.\n\n| parameter | description |\n| | |\n| `name` | required database name |\n| `schema` | tables and indexeddb secondary indexes |\n| `version` | positive schema version; defaults to `1` |\n| `migrate` | synchronous upgrade callback for version changes |\n\n**returns:** `indexeddbvaultstore<s>`.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb } from '@vielzeug/vault/indexeddb';\n\nconst store = createindexeddb({ name: 'app', schema: { users: table<{ id: number }>('id') } });\n```\n\n \n\n### `createsqlite()`\n\n```ts\nfunction createsqlite<s extends anyschema>(options: sqlitevaultoptions<s>): sqlitevaultstore<s>;\n```\n\ncreates a namespaced sqlite store with atomic batches and keyset paginated iteration. it accepts an application provided positional parameter driver and never opens or imports a runtime driver.\n\n| parameter | description |\n| | |\n| `database` | caller provided `sqlitedatabase` connection |\n| `name` | namespace within the connection |\n| `schema`, `validators`, `logger`, `onmetrics` | shared factory options |\n| `closeondispose` | closes the connection during disposal; defaults to `false` |\n\n**returns:** `sqlitevaultstore<s>`.\n\n```ts\nimport { databasesync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createsqlite } from '@vielzeug/vault/sqlite';\n\nconst store = createsqlite({\n database: new databasesync(':memory:'),\n name: 'tests',\n schema: { users: table<{ id: number; name: string }>('id') },\n});\n```\n\nnode `databasesync`, bun `database`, and deno `jsr:@db/sqlite` `database` satisfy the protocol. values must be json compatible plain objects. during a `batch()` callback, calls on every vault store sharing that connection reject; use `tx.*` instead.\n\n## store capabilities\n\n### `vaultstore`\n\n```ts\ninterface vaultstore<s extends anyschema> {\n clear<k extends keyof s & string>(table: k): promise<void>;\n count<k extends keyof s & string>(table: k): promise<number>;\n delete<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n deletemany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<number>;\n entries<k extends keyof s & string>(table: k): promise<array<[keyof<s, k>, recordof<s, k>]>>;\n get<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<recordof<s, k> | undefined>;\n getall<k extends keyof s & string>(table: k): promise<recordof<s, k>[]>;\n getmany<k extends keyof s & string>(table: k, keys: keyof<s, k>[]): promise<array<recordof<s, k> | undefined>>;\n getordefault<k extends keyof s & string>(table: k, key: keyof<s, k>, defaultfn: () => recordof<s, k>, ttl?: number): promise<recordof<s, k>>;\n has<k extends keyof s & string>(table: k, key: keyof<s, k>): promise<boolean>;\n isempty<k extends keyof s & string>(table: k): promise<boolean>;\n keys<k extends keyof s & string>(table: k, filter?: (record: recordof<s, k>) => boolean): promise<keyof<s, k>[]>;\n put<k extends keyof s & string>(table: k, value: recordof<s, k>, ttl?: number): promise<void>;\n putall<k extends keyof s & string>(table: k, values: recordof<s, k>[], ttl?: number): promise<void>;\n query<k extends keyof s & string>(table: k): querybuilder<recordof<s, k>>;\n update<k extends keyof s & string>(table: k, key: keyof<s, k>, changes: partial<recordof<s, k>>, ttl?: number): promise<recordof<s, k> | undefined>;\n upsert<k extends keyof s & string>(table: k, key: keyof<s, k>, fn: (existing: recordof<s, k> | undefined) => recordof<s, k>, ttl?: number): promise<recordof<s, k>>;\n pruneexpired(): promise<record<keyof s & string, number>>;\n debug(): promise<debuginfo<s>>;\n observe<k extends keyof s & string>(table: k, listener: observer<recordof<s, k>>, options?: { immediate?: boolean; signal?: abortsignal }): unsubscribe;\n dispose(): promise<void>;\n readonly disposed: boolean;\n readonly disposalsignal: abortsignal;\n [symbol.asyncdispose](): promise<void>;\n}\n```\n\nthe portable store api is returned by every factory. `observe()` emits the current table snapshot by default and then emits after mutations.\n\n \n\n### `batch()`\n\n```ts\ninterface transactionalvaultstore<s extends anyschema> extends vaultstore<s> {\n batch<k extends keyof s & string, r>(\n tables: readonly k[],\n fn: (tx: transactioncontext<s, k>) => promise<r>,\n ): promise<r>;\n}\n```\n\nruns a scoped atomic callback. `indexeddbvaultstore` and `sqlitevaultstore` provide it.\n\n| parameter | description |\n| | |\n| `tables` | tables the transaction may access |\n| `fn` | async callback that uses only the supplied `tx` context |\n\n**returns:** the callback result after commit.\n\n```ts\nawait store.batch(['users'], async (tx) => {\n await tx.put('users', { id: 1, name: 'ada' });\n});\n```\n\n \n\n### `iterate()`\n\n```ts\ninterface iterablevaultstore<s extends anyschema> extends vaultstore<s> {\n iterate<k extends keyof s & string>(table: k): asynciterable<recordof<s, k>>;\n}\n```\n\nlazily yields table records. `indexeddbvaultstore` uses a cursor; `sqlitevaultstore` uses keyset pagination.\n\n**returns:** an `asynciterable` of records.\n\n```ts\nfor await (const user of store.iterate('users')) console.log(user);\n```\n\n## queries, pruning, and migrations\n\n### `querybuilder`\n\n```ts\ninterface querybuilder<t extends object, n extends t = t> {\n between(field: string, lower: number | string, upper: number | string): querybuilder<t, n>;\n count(): promise<number>;\n delete(): promise<number>;\n equals<k extends keyof t & string, v extends t[k]>(field: k, value: v): querybuilder<t & record<k, v>>;\n exists(): promise<boolean>;\n filter(fn: (value: n, index: number, array: n[]) => boolean): querybuilder<t, n>;\n first(): promise<n | undefined>;\n limit(n: number): querybuilder<t, n>;\n offset(n: number): querybuilder<t, n>;\n orderby<k extends keyof t>(field: k, direction?: 'asc' | 'desc'): querybuilder<t, n>;\n startswith(field: keyof t, prefix: string, options?: { ignorecase?: boolean }): querybuilder<t, n>;\n toarray(): promise<n[]>;\n}\n```\n\nbuilds a lazy table query. `count()` ignores `limit()`, `offset()`, and `orderby()` — it always returns the full filtered set size.\n\n```ts\nconst page = await store.query('users').startswith('name', 'a').orderby('name').limit(20).toarray();\n```\n\n \n\n### `scheduleexpiredprune()`\n\n```ts\nfunction scheduleexpiredprune<s extends anyschema>(\n adapter: pick<vaultstore<s>, 'pruneexpired'>,\n options: {\n interval: number;\n onerror?: (error: unknown) => void;\n signal?: abortsignal;\n },\n): () => void;\n```\n\nschedules `pruneexpired()` at a finite, positive interval. pass `signal: store.disposalsignal` to auto cancel when the store is torn down.\n\n**returns:** a stop function.\n\n```ts\nimport { scheduleexpiredprune, ttl } from '@vielzeug/vault';\n\nconst stop = scheduleexpiredprune(store, {\n interval: ttl.hours(1),\n signal: store.disposalsignal,\n});\nstop();\n```\n\n \n\n### `definemigration()`\n\n```ts\nfunction definemigration(steps: migrationstep[]): migrationfn;\n```\n\nbuilds an idempotent indexeddb migration callback from schema change steps.\n\n**returns:** an indexeddb `migrationfn`.\n\n```ts\nimport { definemigration } from '@vielzeug/vault/indexeddb';\n\nconst migrate = definemigration([{ field: 'email', table: 'users', type: 'addindex' }]);\n```\n\n## types\n\n```ts\ntype vaultkey = number | string;\ntype unsubscribe = () => void;\ntype observer<t> = (records: t[]) => void;\ntype anyschema = record<string, {\n defaultttl?: number;\n indexes?: readonly string[];\n key: string;\n}>;\ntype schemaentry<t extends object, key extends keyof t & string = keyof t & string> =\n t[key] extends vaultkey ? {\n defaultttl?: number;\n indexes?: readonly (keyof t & string)[];\n key: key;\n } : never;\ntype recordof<s extends anyschema, k extends keyof s> =\n s[k] extends schemaentry<infer r, infer _key> ? r : never;\ntype keyof<s extends anyschema, k extends keyof s> =\n extract<s[k] extends schemaentry<infer r, infer key> ? r[key] : never, vaultkey>;\n```\n\n```ts\ntype baseadapteroptions<s extends anyschema> = {\n logger?: vaultlogger;\n onmetrics?: (event: metricsevent) => void;\n schema: s;\n validators?: tablevalidators<s>;\n};\n\ntype vaultlogger = {\n error(message: string, context?: error | record<string, unknown>): void;\n};\n\ntype recordvalidator<t> = {\n parse(value: unknown): t;\n};\n\ntype tablevalidators<s extends anyschema> = {\n [k in keyof s]?: recordvalidator<recordof<s, k>>;\n};\n\ntype metricsevent = {\n duration: number;\n operation: 'batch' | 'clear' | 'count' | 'delete' | 'deletemany' | 'entries' | 'get' | 'getall' |\n 'getmany' | 'getordefault' | 'has' | 'isempty' | 'keys' | 'put' | 'putall' | 'query' |\n 'querydelete' | 'update' | 'upsert';\n table: string;\n};\n\ntype debugstats = { expiredcount: number; recordcount: number };\ntype debuginfo<s extends anyschema> = { tables: array<{ name: keyof s & string } & debugstats> };\n```\n\n```ts\ninterface indexeddbvaultstore<s extends anyschema>\n extends transactionalvaultstore<s>, iterablevaultstore<s> {}\n\ntype migrationcontext = {\n db: idbdatabase;\n newversion: number | null;\n oldversion: number;\n tx: idbtransaction;\n};\n\ntype migrationfn = (ctx: migrationcontext) => void;\n\ntype migrationstep =\n | { field: string; table: string; type: 'addindex' }\n | { field: string; table: string; type: 'removeindex' }\n | { name: string; type: 'addtable' }\n | { name: string; type: 'removetable' };\n```\n\nimport `migrationcontext`, `migrationfn`, and `migrationstep` from `@vielzeug/vault/indexeddb`.\n\n```ts\ntype sqliteparameter = null | number | string;\n\ninterface sqlitestatement {\n all(...parameters: sqliteparameter[]): readonly record<string, unknown>[];\n finalize?(): void;\n get(...parameters: sqliteparameter[]): record<string, unknown> | undefined;\n run(...parameters: sqliteparameter[]): unknown;\n}\n\ninterface sqlitedatabase {\n close?(): void;\n exec(sql: string): void;\n prepare(sql: string): sqlitestatement;\n}\n\ntype sqlitevaultoptions<s extends anyschema> = baseadapteroptions<s> & {\n closeondispose?: boolean;\n database: sqlitedatabase;\n name: string;\n};\n\ninterface sqlitevaultstore<s extends anyschema>\n extends transactionalvaultstore<s>, iterablevaultstore<s> {}\n```\n\n`transactioncontext` has the same crud, query, and ttl methods as `vaultstore`, narrowed to the tables declared in `batch()`. import it from `@vielzeug/vault/indexeddb` or `@vielzeug/vault/sqlite`.\n\n## errors\n\n| error | trigger |\n| | |\n| `vaulterror` | any vault originated validation, serialization, storage, or query error |\n| `vaultdisposederror` | an operation after the store or observer hub is disposed |\n| `vaultscopeerror` | an indexeddb transaction accesses a table outside its declared batch scope |\n| `vaultquotaerror` | a localstorage or sessionstorage write exceeds the browser quota |\n| `vaultmigrationerror` | an indexeddb migration callback throws |\n\nevery listed error extends `vaulterror`.\n",
1256
+ "usage": " \ntitle: vault — usage guide\ndescription: persist typed browser or sqlite data, observe table snapshots, and use atomic transactions.\n \n\n[[toc]]\n\n## basic usage\n\ncreate a portable store with one schema and write a typed row.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createlocalstorage } from '@vielzeug/vault/local storage';\n\ninterface preference {\n id: string;\n theme: 'dark' | 'light';\n}\n\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n\nawait store.put('preferences', { id: 'theme', theme: 'dark' });\nconsole.log(await store.get('preferences', 'theme'));\n```\n\n## create a portable store\n\nmemory, localstorage, and sessionstorage return `vaultstore`. they share portable string/number keys, crud methods, queries, ttl, and `observe()`. vault keeps record values and expiry metadata separate; the physical storage layout is adapter specific.\n\nthe root entry is adapter free. import `creatememory` from `@vielzeug/vault/memory`, `createlocalstorage` from `@vielzeug/vault/local storage`, or `createsessionstorage` from `@vielzeug/vault/session storage`. import each adapter from its focused subpath so unused backends stay out of the bundle.\n\nuse a new storage name when upgrading from vault 1. old key and envelope formats are not read by vault 2.\n\n```ts\nconst store = createlocalstorage({\n name: 'app v2',\n schema: { preferences: table<preference>('id') },\n});\n```\n\n## read and change records\n\nuse `update()` for an existing row and `upsert()` when the row may not exist.\n\n```ts\nconst updated = await store.update('preferences', 'theme', { theme: 'light' });\n\nawait store.upsert('preferences', 'locale', (current) => ({\n id: 'locale',\n theme: current?.theme ?? 'dark',\n}));\n\nconsole.log(updated);\n```\n\n`update()` returns `undefined` for a missing key. `upsert()` always writes the record returned by its callback.\n\n## query records\n\nbuild a query from a table, then finish it with a terminal method. `count()` ignores pagination, which makes it suitable for page controls.\n\n```ts\nconst query = store.query('preferences').startswith('id', 'theme');\nconst preferences = await query.orderby('id').limit(10).toarray();\nconst total = await query.count();\n\nconsole.log({ preferences, total });\n```\n\nmemory and web storage queries scan the table. indexeddb can use declared secondary indexes, while sqlite pushes primary key equality, range, and case sensitive prefix filters to the database.\n\n## use ttl and pruning\n\nuse `ttl.*` helpers for expiring rows. schedule pruning when stale rows can accumulate without reads.\n\n```ts\nimport { scheduleexpiredprune, ttl } from '@vielzeug/vault';\n\nawait store.put('preferences', { id: 'temporary', theme: 'dark' }, ttl.hours(1));\nconst stopprune = scheduleexpiredprune(store, {\n interval: ttl.hours(6),\n signal: store.disposalsignal,\n});\n\nstopprune();\n```\n\n## observe a table\n\nuse `observe()` for current and future snapshots. tie subscription lifetime to an `abortsignal` when a component or request owns it.\n\n```ts\nconst controller = new abortcontroller();\n\nstore.observe('preferences', (preferences) => {\n console.log(preferences);\n}, { signal: controller.signal });\n\ncontroller.abort();\n```\n\n## use indexeddb for browser transactions\n\nchoose indexeddb when browser storage needs multiple writes to commit together or cursor iteration.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb } from '@vielzeug/vault/indexeddb';\n\nconst db = createindexeddb({\n name: 'app v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n});\n\nawait db.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nonly await `tx.*` operations inside a batch callback. do not await timers, fetches, or other external asynchronous work; indexeddb can commit an inactive transaction.\n\n## use sqlite outside the browser\n\nimport sqlite from the opt in subpath so the browser root stays free of runtime drivers. vault never opens a connection or configures its sqlite process behavior for you.\n\n```ts\nimport { databasesync } from 'node:sqlite';\n\nimport { table } from '@vielzeug/vault';\nimport { createsqlite } from '@vielzeug/vault/sqlite';\n\nconst database = new databasesync('app.db', { timeout: 5_000 });\nconst store = createsqlite({\n database,\n name: 'app v2',\n schema: { events: table<{ id: number; type: string }>('id') },\n});\n\nawait store.batch(['events'], async (tx) => {\n await tx.put('events', { id: 1, type: 'opened' });\n await tx.put('events', { id: 2, type: 'saved' });\n});\n```\n\nnode's `node:sqlite` api is experimental. bun's `bun:sqlite` `database` satisfies the same positional `exec()` and `prepare()` contract; configure wal from your application when the deployment needs it. deno does not include sqlite, but `jsr:@db/sqlite`'s `database` satisfies the same contract when its ffi, filesystem, and environment permissions are granted.\n\nsqlite stores serialize all access through the injected connection. `batch()` starts `begin immediate` and rolls back callback failures. while its callback runs, calls on any store sharing that connection reject rather than waiting behind the transaction; use `tx.*` instead. the underlying drivers are synchronous, so move large scans and writes to a worker or isolate when event loop latency matters.\n\n## store sqlite values and observe changes\n\nsqlite accepts json compatible plain object records only. circular values, `bigint`, dates, class instances, functions, and non finite numbers are rejected before writing. number and string primary keys remain distinct.\n\n`observe()` sees mutations written through vault stores sharing the same injected connection after a commit. it cannot detect direct sql changes, writes from another process, or writes through another connection. the connection belongs to the caller by default; use `closeondispose: true` only when the store owns it.\n\n## handle indexeddb schema migrations\n\ndeclare indexeddb indexes in the schema. use `migrate` only for indexeddb version upgrades and mirror vault’s fixed `value.<field>` index path.\n\n```ts\nimport { table } from '@vielzeug/vault';\nimport { createindexeddb, type migrationfn } from '@vielzeug/vault/indexeddb';\n\nconst schema = { users: table<{ id: number; name: string }>('id', { indexes: ['name'] }) };\nconst migrate: migrationfn = ({ db, oldversion, tx }) => {\n if (oldversion < 2 && db.objectstorenames.contains('users')) {\n tx.objectstore('users').createindex('name', 'value.name');\n }\n};\n\ncreateindexeddb({ name: 'app v2', migrate, schema, version: 2 });\n```\n\n## framework integration\n\n::: code group\n\n```ts [react]\nimport { useeffect, usestate } from 'react';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function usetable<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n const [rows, setrows] = usestate<recordof<s, k>[]>([]);\n\n useeffect(() => store.observe(table, setrows), [store, table]);\n return rows;\n}\n```\n\n```ts [vue 3]\nimport { onunmounted, shallowref } from 'vue';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function usetable<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n const rows = shallowref<recordof<s, k>[]>([]);\n const stop = store.observe(table, (next) => (rows.value = next));\n\n onunmounted(stop);\n return rows;\n}\n```\n\n```ts [svelte]\nimport { readable } from 'svelte/store';\n\nimport type { anyschema, recordof, vaultstore } from '@vielzeug/vault';\n\nexport function tablestore<s extends anyschema, k extends keyof s & string>(store: vaultstore<s>, table: k) {\n return readable<recordof<s, k>[]>([], (set) => store.observe(table, set));\n}\n```\n\n:::\n\n## working with other vielzeug libraries\n\nuse forge’s vault helpers for explicit form draft persistence. keep ripple signals as application state and persist selected changes through vault writes.\n\n## best practices\n\n define one schema per storage namespace.\n use string or finite number primary keys only.\n choose a new namespace for vault 1 storage unless you migrate it yourself.\n use `observe()` for table snapshots.\n use indexeddb or sqlite for atomic work.\n keep external asynchronous work outside `batch()` callbacks.\n use `ttl.*` instead of raw durations.\n keep sqlite scans and writes off latency sensitive event loops, and dispose stores with their owner.\n dispose stores when their owner ends.\n",
1257
+ "examples": " \ntitle: vault — examples\ndescription: portable storage, observation, transactions, iteration, and sqlite.\n \n\n [crud](./examples/crud.md)\n [ttl](./examples/ttl.md)\n [querying](./examples/querying.md)\n [reactive observation](./examples/reactive.md)\n [indexeddb iteration](./examples/iterate.md)\n [indexeddb batch transactions](./examples/batch.md)\n [sqlite transactions and iteration](./examples/sqlite.md)\n [plugin validation](./examples/plugins.md)\n"
1258
+ },
1259
+ "examples": [
1260
+ {
1261
+ "id": "basic-setup",
1262
+ "text": "basic setup initialize vault import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n users: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'demo', schema })\n\nawait db.put('users', { id: 1, name: 'alice', email: 'alice@example.com' })\nawait db.put('users', { id: 2, name: 'bob', email: 'bob@example.com' })\n\nconsole.log('get user 1:', await db.get('users', 1))\nconsole.log('all users:', await db.getall('users'))\nconsole.log('count:', await db.query('users').count())"
1263
+ },
1264
+ {
1265
+ "id": "bulk-operations",
1266
+ "text": "bulk operations import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n items: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'bulk demo', schema })\n\nconst items = array.from({ length: 10 }, (_, index) => ({\n id: index + 1,\n value: +(math.random() * 1000).tofixed(2),\n}))\n\nawait db.putall('items', items)\nconsole.log('inserted', items.length, 'items')\n\n// getmany — fetch multiple by key in one call (missing keys return undefined)\nconst [first, missing, third] = await db.getmany('items', [1, 99, 3])\nconsole.log('getmany [1, 99, 3]:', first?.id, missing, third?.id)\n\n// deletemany — remove multiple by key, returns count deleted\nconst deleted = await db.deletemany('items', [1, 2, 3, 99])\nconsole.log('deletemany [1,2,3,99] deleted:', deleted) // 3 (99 did not exist)\n\n// query based delete for filter driven removal\nconst querydeleted = await db.query('items').filter((item) => item.id <= 6).delete()\nconsole.log('query deleted items with id ≤ 6:', querydeleted)\n\nconsole.log('remaining count:', await db.query('items').count())\nconsole.log('first remaining item:', await db.query('items').orderby('id', 'asc').first())"
1267
+ },
1268
+ {
1269
+ "id": "cache-first",
1270
+ "text": "cache first with getordefault import { table, ttl } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst db = createlocalstorage({ name: 'cache demo', schema: { cache: table('id') } })\n\nasync function getorcomputeconfig() {\n return db.getordefault('cache', 'config', () => ({\n id: 'config',\n data: 'computed value',\n fetchedat: date.now(),\n }), ttl.minutes(5))\n}\n\nconst first = await getorcomputeconfig()\nconst second = await getorcomputeconfig()\nconsole.log('same cached record:', first.fetchedat === second.fetchedat)"
1271
+ },
1272
+ {
1273
+ "id": "crud-operations",
1274
+ "text": "crud operations import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n users: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'demo', schema })\n\nawait db.put('users', { id: 1, name: 'alice', email: 'alice@example.com', age: 25 })\nawait db.put('users', { id: 2, name: 'bob', email: 'bob@example.com', age: 30 })\nconsole.log('created 2 users')\n\nconsole.log('get user 1:', await db.get('users', 1))\nconsole.log('count:', await db.count('users'))\nconsole.log('isempty before clear:', await db.isempty('users')) // false\n\nawait db.update('users', 1, { age: 26, name: 'alice smith' })\nconsole.log('updated user 1:', await db.get('users', 1))\n\nconsole.log('deleted user 2:', await db.delete('users', 2))\nconsole.log('remaining users:', await db.getall('users'))\n\nawait db.clear('users')\nconsole.log('isempty after clear:', await db.isempty('users')) // true"
1275
+ },
1276
+ {
1277
+ "id": "indexed-db",
1278
+ "text": "indexeddb — atomic batch & iterate() import { table, ttl } from '@vielzeug/vault'\nimport { createindexeddb } from '@vielzeug/vault/indexeddb'\n\nconst schema = {\n logs: table('id'),\n}\n\n// createindexeddb returns indexeddbvaultstore with transactions and cursor iteration\nconst db = createindexeddb({\n name: 'app logs',\n schema,\n version: 1,\n})\n\nawait db.putall('logs', [\n { id: 1, level: 'info', message: 'app started', ts: date.now() 3000 },\n { id: 2, level: 'warn', message: 'slow query detected', ts: date.now() 2000 },\n { id: 3, level: 'error', message: 'request failed', ts: date.now() 1000 },\n { id: 4, level: 'info', message: 'request succeeded', ts: date.now() },\n], ttl.hours(1))\n\n// batch() is atomic on indexeddb — all writes commit or none do\nawait db.batch(['logs'], async (tx) => {\n await tx.put('logs', { id: 5, level: 'info', message: 'batch committed', ts: date.now() })\n await tx.deletemany('logs', [1, 2]) // remove old entries in the same transaction\n})\n\n// iterate() — cursor based streaming, only on indexeddbvaultstore\n// the full table is never loaded into memory at once\nconst messages = []\nfor await (const entry of db.iterate('logs')) {\n messages.push(entry.message)\n}\nconsole.log('streamed via iterate():', messages)\n\nconst errors = await db.query('logs').equals('level', 'error').toarray()\nconsole.log('errors:', errors.map((e) => e.message))\nconsole.log('total logs:', await db.query('logs').count())\n\nconst info = await db.debug()\nfor (const t of info.tables) {\n console.log(t.name + ':', t.recordcount, 'live,', t.expiredcount, 'expired')\n}\n\nawait db.dispose()"
1279
+ },
1280
+ {
1281
+ "id": "prune-schedule",
1282
+ "text": "ttl — scheduleexpiredprune with disposalsignal import { scheduleexpiredprune, table, ttl } from '@vielzeug/vault'\nimport { creatememory } from '@vielzeug/vault/memory'\n\n// scheduleexpiredprune runs pruneexpired() on an interval.\n// pass disposalsignal to auto cancel when the store is torn down.\n\nconst schema = { sessions: table('token') }\nconst db = creatememory({ schema })\n\nconst stop = scheduleexpiredprune(db, {\n interval: ttl.minutes(15),\n signal: db.disposalsignal,\n onerror: (err) => console.error('[vault] prune failed:', err),\n})\n\n// write a session that expires in 1 ms\nawait db.put('sessions', { token: 'abc', user: 1 }, ttl.ms(1))\nawait db.put('sessions', { token: 'def', user: 2 }) // no ttl — permanent\n\nconsole.log('before prune:', await db.count('sessions')) // 2 (lazy eviction: both exist physically)\n\n// manual prune to demonstrate the api\nawait new promise((resolve) => settimeout(resolve, 5))\nconst pruned = await db.pruneexpired()\nconsole.log('pruned:', pruned.sessions) // 1 (the expired session)\nconsole.log('after prune:', await db.count('sessions')) // 1\n\n// stop() before dispose, or rely on disposalsignal auto cancel\nstop()\nawait db.dispose()"
1283
+ },
1284
+ {
1285
+ "id": "query-builder",
1286
+ "text": "query builder — filters, pagination, count import { table } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n products: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'shop', schema })\n\nawait db.putall('products', [\n { id: 1, name: 'laptop', price: 999, category: 'electronics', instock: true },\n { id: 2, name: 'mouse', price: 29, category: 'electronics', instock: true },\n { id: 3, name: 'desk', price: 299, category: 'furniture', instock: false },\n { id: 4, name: 'chair', price: 199, category: 'furniture', instock: true },\n { id: 5, name: 'monitor', price: 399, category: 'electronics', instock: true },\n])\n\nconst pagesize = 2\nconst pageindex = 0\n\n// build a base query — reuse it for both the page slice and the total count\nconst q = db\n .query('products')\n .equals('category', 'electronics')\n .filter((p) => p.instock)\n .orderby('price', 'asc')\n\n// count() ignores limit/offset/orderby — returns the full filtered set size\nconst page = await q.limit(pagesize).offset(pageindex * pagesize).toarray()\nconst total = await q.count()\n\nconsole.log('page:', page.map((p) => p.name))\nconsole.log('total matching:', total)\nconsole.log('page 1 of', math.ceil(total / pagesize))\n\n// startswith with case insensitive flag\nconst mice = await db.query('products').startswith('name', 'm', { ignorecase: true }).toarray()\nconsole.log('starts with m:', mice.map((p) => p.name))\n\n// predicate delete\nconst removed = await db.query('products').filter((p) => !p.instock).delete()\nconsole.log('removed out of stock:', removed)\n\n// first()\nconst cheapest = await db.query('products').orderby('price', 'asc').first()\nconsole.log('cheapest:', cheapest?.name, cheapest?.price)"
1287
+ },
1288
+ {
1289
+ "id": "reactive-observe",
1290
+ "text": "reactive — observe() import { table } from '@vielzeug/vault'\nimport { creatememory } from '@vielzeug/vault/memory'\n\nconst db = creatememory({ schema: { users: table('id') } })\nconst snapshots = []\nconst stop = db.observe('users', (users) => snapshots.push(users.map((user) => user.name)))\n\nawait promise.resolve()\nawait db.put('users', { id: 1, name: 'ada' })\nawait promise.resolve()\n\nconsole.log(snapshots) // [[], ['ada']]\nstop()\nawait db.dispose()"
1291
+ },
1292
+ {
1293
+ "id": "ttl-expiration",
1294
+ "text": "ttl & expiration import { table, ttl } from '@vielzeug/vault'\nimport { createlocalstorage } from '@vielzeug/vault/local storage'\n\nconst schema = {\n cache: table('id'),\n}\n\nconst db = createlocalstorage({ name: 'cache demo', schema })\n\n// ttl helpers produce finite, positive millisecond durations\nawait db.put('cache', { id: 'short', data: 'expires in 1 second' }, ttl.seconds(1))\nawait db.put('cache', { id: 'long', data: 'expires in 5 minutes' }, ttl.minutes(5))\nconsole.log('stored records with ttl')\nconsole.log('immediate read:', await db.get('cache', 'short'))\n\nawait new promise((resolve) => settimeout(resolve, 1500))\nconsole.log('after 1.5s:', await db.get('cache', 'short')) // expired — undefined\nconsole.log('long lived still here:', await db.get('cache', 'long'))\n\nconsole.log('ttl helpers:', {\n '100ms': ttl.ms(100),\n '5 minutes': ttl.minutes(5),\n '2 hours': ttl.hours(2),\n '7 days': ttl.days(7),\n})"
1295
+ }
1296
+ ],
1297
+ "exports": "table ttl scheduleexpiredprune isexpired creatememory createlocalstorage createsessionstorage createindexeddb createsqlite",
1298
+ "keywords": "storage indexeddb localstorage sessionstorage sqlite ttl browser node deno",
1299
+ "name": "@vielzeug/vault",
1300
+ "related": "courier forge ripple",
1301
+ "slug": "vault",
1302
+ "source": "export { vaultdisposederror, vaulterror, vaultmigrationerror, vaultquotaerror, vaultscopeerror } from './errors';\nexport { scheduleexpiredprune } from './prune';\nexport type { querybuilder } from './query';\nexport { isexpired, ttl } from './ttl';\nexport type {\n anyschema,\n baseadapteroptions,\n debuginfo,\n debugstats,\n iterablevaultstore,\n keyof,\n metricsevent,\n observer,\n recordof,\n recordvalidator,\n schemaentry,\n tablevalidators,\n transactionalvaultstore,\n unsubscribe,\n vaultkey,\n vaultlogger,\n vaultstore,\n} from './types';\nexport { table } from './types';\n"
1303
+ },
1304
+ {
1305
+ "category": "auth",
1306
+ "description": "typed authorization policies with wildcard matching, deterministic precedence, and decision tracing.",
1307
+ "docs": {
1308
+ "index": " \ntitle: ward — deterministic authorization for typescript\ndescription: typed authorization policies with wildcard matching, deterministic precedence, and decision tracing.\npackage: ward\ncategory: auth\nkeywords: [authorization, rbac, permissions, policy, roles, wildcard, predicates]\nrelated: [wayfinder, conduit, herald]\nexports: [createward, allow, deny, rulefor, owns, predicate, anonymous, wildcard, warderror, wardconfigerror, wardpredicateerror, normalizedwardrule, matchespattern, patterncovers]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"ward\" />\n\n## why ward?\n\nward keeps authorization policies declarative and decision ordering deterministic. define rules once, then explain or trace every permission decision without embedding role checks across handlers.\n\n```ts\n// before\nconst canupdate = user.roles.includes('editor') && post.authorid === user.id;\n\n// after\nimport { allow, createward, owns } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('editor', 'posts', ['update'], { when: owns('authorid') }),\n]);\n\nconst decision = ward.explain({ principal: user, resource: 'posts', action: 'update', data: post });\nconst canupdate = decision.allowed;\n```\n\n| feature | ward | casl | accesscontrol |\n| | | | |\n| bundle size | <packageinfo package=\"ward\" type=\"size\" /> | larger policy engine | larger policy engine |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| deterministic precedence | priority, specificity, deny, order | rule dependent | role grant dependent |\n| decision tracing | `trace()` candidates and winner | manual inspection | manual inspection |\n\n<div class=\"decision callout\">\n\n**use ward when** your application needs typed role/resource/action policies with explainable, deterministic outcomes.\n\n**consider framework specific authorization when** your application only needs one framework's built in route or component guard layer.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/ward\n```\n\n```sh [npm]\nnpm install @vielzeug/ward\n```\n\n```sh [yarn]\nyarn add @vielzeug/ward\n```\n\n:::\n\n## quick start\n\ncreate a small policy and handle both allowed and denied decisions at the request boundary.\n\n```ts\nimport { allow, createward } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n]);\n\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n});\n\nif (decision.allowed) console.log('update post');\nelse console.log(decision.reason);\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createward()` creates immutable typed policy instances. accepts `allow()`/`deny()` results directly — no spread needed.\n `allow()`, `deny()`, and `rulefor()` build role/resource/action rules.\n `wildcard` and `anonymous` model broad or unauthenticated access explicitly.\n `owns()` and `predicate` constrain rules with synchronous request data.\n `explain()`, `trace()`, and `detectconflicts()` make policy decisions diagnosable.\n `foruser()` creates a principal bound view for repeated checks.\n `checkall()` evaluates multiple resource/action pairs in one call.\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 [wayfinder](/wayfinder/) — route middleware can enforce ward decisions during navigation.\n [conduit](/conduit/) — inject a ward policy into application services.\n [herald](/herald/) — publish authorization outcomes as typed application events.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1309
+ "api": " \ntitle: ward — api reference\ndescription: complete api reference for @vielzeug/ward.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createward` | creates immutable policy | sync | rules cannot be mutated after creation |\n| `allow` / `deny` / `rulefor` | builds policy rules | sync | priority wins before specificity |\n| `ward.explain` | returns one decision | sync | pass resource data for predicate rules |\n| `ward.trace` | inspects decision candidates | sync | does not invoke the logger |\n| `ward.foruser` | binds a principal | sync | rebind when identity or roles change |\n| `ward.checkall` | batch permission checks | sync | pass resource data for predicate rules |\n| `ward.allowedactions` | filters known actions to allowed set | sync | does not invoke the logger |\n| `ward.rulesinscope` | lists rules matching a principal/resource | sync | pass data to evaluate predicates |\n| `ward.detectconflicts` | detects duplicate/shadowed rules | sync | o(n²) — use `maxconflicts` for large policies |\n| `predicate.owns` / `owns` | ownership predicate on resource data | sync | skipped for anonymous principals |\n| `predicate.and` / `or` / `not` | combine predicates | sync | all inputs must be synchronous |\n| `matchespattern` / `patterncovers` | test resource pattern coverage | sync | `'*'` is the only wildcard |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/ward` | rules, factory, predicates, pattern helpers, errors, and public types |\n| `@vielzeug/ward/devtools` | `debugward()` diagnostic factory |\n\n## core factory\n\n### `createward(rules, options?)`\n\n```ts\ncreateward<taction extends string = string, tdata = unknown>(\n rules: readonly (wardrule<taction, tdata> | readonly wardrule<taction, tdata>[])[] = [],\n options?: wardoptions<taction, tdata>,\n): ward<taction, tdata>;\n```\n\ncreates an immutable ward instance. `rules` accepts a flat mix of single rules and rule arrays — `allow()`/`deny()`/`rulefor()` results can be passed directly without spread. validates `logger`, `onconflict`, and `maxconflicts` options before compiling rules; invalid values throw `wardconfigerror`.\n\n**parameters:**\n\n| name | type | description |\n| | | |\n| `rules` | `readonly (wardrule \\| readonly wardrule[])[]` | rule list. single rules and rule arrays can be mixed. |\n| `options.logger` | `(ctx: wardloggercontext) => void` | called for `explain()` and `checkall()` decisions. |\n| `options.onconflict` | `(conflict: wardconflict) => void` | called synchronously per conflict at creation time. |\n| `options.strict` | `boolean` | throws `wardconfigerror` on the first conflict. |\n| `options.maxconflicts` | `number` | caps the number of conflicts returned by `detectconflicts()`. |\n\n**returns:** `ward<taction, tdata>` — an immutable policy instance.\n\n**example:**\n\n```ts\nimport { allow, createward, deny, wildcard } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', wildcard, [wildcard], { priority: 100 }),\n]);\n```\n\n \n\n## rule builders\n\n### `allow(role, resource, actions, options?)`\n\n```ts\nallow<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'allow'`. reads naturally: \"allow editor to read/update posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `deny(role, resource, actions, options?)`\n\n```ts\ndeny<taction extends string = string, tdata = unknown>(\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\ncreates one `wardrule` per action with `effect: 'deny'`. reads naturally: \"deny blocked from reading posts\".\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n### `rulefor(effect, role, resource, actions, options?)`\n\n```ts\nrulefor<taction extends string = string, tdata = unknown>(\n effect: 'allow' | 'deny',\n role: string | readonly string[],\n resource: string | typeof wildcard,\n actions: readonly (taction | typeof wildcard)[],\n options?: { priority?: number; when?: wardpredicate<tdata> },\n): wardrule<taction, tdata>[];\n```\n\nlow level factory. prefer `allow()` or `deny()` for ergonomic rule authoring.\n\n**returns:** `wardrule[]` — one rule per action.\n\n \n\n## ward methods\n\n### `checkall(principal, checks)`\n\n```ts\ncheckall(\n principal: principal,\n checks: readonly wardcheck<taction, tdata>[],\n): warddecisionresult<taction, tdata>[];\n```\n\nevaluates multiple resource/action pairs for one principal. invokes the logger for each decision.\n\n**returns:** `warddecisionresult[]` — each entry carries `action`, `resource`, and the decision.\n\n \n\n### `explain(input)`\n\n```ts\nexplain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n```\n\n`warddecisioninput`:\n\n```ts\n{\n principal: principal;\n resource: string;\n action: taction;\n data?: tdata;\n}\n```\n\nreturns one decision. invokes the logger.\n\n**returns:** `warddecision` — `{ allowed: true; rule }` or `{ allowed: false; reason: 'explicit deny'; rule }` or `{ allowed: false; reason: 'no matching rule' }`.\n\n \n\n### `trace(input)`\n\n```ts\ntrace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n```\n\nsame request shape as `explain()`. returns winner + candidate list. does not fire the logger.\n\n**returns:** `wardtrace` — `{ candidates: wardtracecandidate[]; decision: warddecision }`.\n\n \n\n### `allowedactions(input)`\n\n```ts\nallowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n knownactions: readonly taction[];\n data?: tdata;\n}\n```\n\nfilters the provided `knownactions` list to those the principal may perform. does not invoke the logger.\n\n**returns:** `taction[]` — the subset of `knownactions` that `explain()` would allow.\n\n \n\n### `rulesinscope(input)`\n\n```ts\nrulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n```\n\ninput shape:\n\n```ts\n{\n principal: principal;\n resource: string;\n data?: tdata;\n}\n```\n\nlists rules matching the principal/resource pair. pass `data` to evaluate predicate gated matches; without it, predicate rules are skipped.\n\n**returns:** `readonlyarray<readonly<normalizedwardrule>>` — rules in their normalized form (`role` always array, `priority` always number).\n\n \n\n### `detectconflicts()`\n\n```ts\ndetectconflicts(): readonly wardconflict<taction, tdata>[];\n```\n\nlazily computes and caches duplicate/shadowed rule conflicts. o(n²) — use `maxconflicts` for large policies.\n\n**returns:** `readonly wardconflict[]` — `{ kind: 'duplicate'; indexa; indexb; rulea; ruleb }` or `{ kind: 'shadowed'; shadowedindex; shadowedrule; shadowingindex; shadowingrule }`.\n\n \n\n### `foruser(principal)`\n\n```ts\nforuser(principal: userprincipal): boundward<taction, tdata>;\n```\n\nreturns a principal bound view. `userprincipal` (not nullable — use `null` directly with `explain()` for anonymous).\n\n**returns:** `boundward` — same methods without the `principal` argument.\n\n \n\n## `boundward` methods\n\n```ts\ntype boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n```\n\nbound input shapes remove `principal`:\n\n```ts\n{ resource: string; action: taction; data?: tdata } // explain/trace\n{ resource: string; knownactions: readonly taction[]; data?: tdata } // allowedactions\n{ resource: string; data?: tdata } // rulesinscope\n```\n\n \n\n## predicate helpers\n\n### `predicate.owns(attributekey)`\n\n```ts\npredicate.owns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\nreturns a `wardpredicate` that checks whether `data[attributekey]` matches `principal.id`. skipped for anonymous principals — pairing `owns` with an `anonymous` role rule produces a rule that can never match.\n\n**returns:** `wardpredicate<tdata>`.\n\n \n\n### `predicate.and(...predicates)`\n\n```ts\npredicate.and<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nall predicates must return `true`.\n\n \n\n### `predicate.or(...predicates)`\n\n```ts\npredicate.or<tdata = unknown>(...preds: wardpredicate<tdata>[]): wardpredicate<tdata>;\n```\n\nat least one predicate must return `true`.\n\n \n\n### `predicate.not(predicate)`\n\n```ts\npredicate.not<tdata = unknown>(pred: wardpredicate<tdata>): wardpredicate<tdata>;\n```\n\ninverts the given predicate.\n\n \n\n### `owns(attributekey)` (alias)\n\n```ts\nowns<tdata = unknown>(\n attributekey: [keyof tdata] extends [never] ? string : keyof tdata & string,\n): wardpredicate<tdata>;\n```\n\ntop level re export of `predicate.owns`.\n\npredicates run synchronously. returning a promise throws `wardpredicateerror`.\n\n \n\n## pattern helpers\n\n### `matchespattern(pattern, value): boolean`\n\n```ts\nmatchespattern(pattern: string, value: string): boolean;\n```\n\ntests whether `value` matches a `'*'` wildcard `pattern`. `'*'` matches any value; an exact string matches only itself.\n\n \n\n### `patterncovers(broad, narrow): boolean`\n\n```ts\npatterncovers(broad: string, narrow: string): boolean;\n```\n\ntests whether the `broad` pattern covers the `narrow` pattern. `'*'` covers everything; an exact string covers only itself.\n\n \n\n## devtools\n\n### `debugward(rules, options?)`\n\nsub path import: `@vielzeug/ward/devtools`.\n\n```ts\nimport { debugward } from '@vielzeug/ward/devtools';\n```\n\ndiagnostic factory for development inspection.\n\n \n\n## types\n\n```ts\nexport type userprincipal = {\n attributes?: record<string, unknown>;\n id: string;\n roles: readonly string[];\n};\n\nexport type principal = userprincipal | null;\n\nexport type rulecontext<tdata = unknown> = {\n data?: tdata;\n principal: userprincipal;\n};\n\nexport type wardpredicate<tdata = unknown> = (ctx: rulecontext<tdata>) => boolean;\n\nexport type wardrule<taction extends string = string, tdata = unknown> = {\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority?: number;\n resource: string | typeof wildcard;\n role: string | readonly string[];\n when?: wardpredicate<tdata>;\n};\n\nexport type normalizedwardrule<taction extends string = string, tdata = unknown> = readonly<{\n action: taction | typeof wildcard;\n effect: 'allow' | 'deny';\n priority: number;\n resource: string | typeof wildcard;\n role: readonly string[];\n when?: wardpredicate<tdata>;\n}>;\n\nexport type warddecision<taction extends string = string, tdata = unknown> =\n | { allowed: true; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'explicit deny'; rule: readonly<normalizedwardrule<taction, tdata>> }\n | { allowed: false; reason: 'no matching rule' };\n\nexport type wardcheck<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type warddecisionresult<taction extends string = string, tdata = unknown> = warddecision<taction, tdata> & {\n action: taction;\n resource: string;\n};\n\nexport type warddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n principal: principal;\n resource: string;\n};\n\nexport type wardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type boundwarddecisioninput<taction extends string = string, tdata = unknown> = {\n action: taction;\n data?: tdata;\n resource: string;\n};\n\nexport type boundwardallowedactionsinput<taction extends string = string, tdata = unknown> = {\n data?: tdata;\n knownactions: readonly taction[];\n resource: string;\n};\n\nexport type boundwardrulesinscopeinput<tdata = unknown> = {\n data?: tdata;\n resource: string;\n};\n\nexport type conflictkind = 'duplicate' | 'shadowed';\n\nexport type wardconflict<taction extends string = string, tdata = unknown> =\n | {\n indexa: number;\n indexb: number;\n kind: 'duplicate';\n rulea: readonly<normalizedwardrule<taction, tdata>>;\n ruleb: readonly<normalizedwardrule<taction, tdata>>;\n }\n | {\n kind: 'shadowed';\n shadowedindex: number;\n shadowedrule: readonly<normalizedwardrule<taction, tdata>>;\n shadowingindex: number;\n shadowingrule: readonly<normalizedwardrule<taction, tdata>>;\n };\n\nexport type wardtracecandidate<taction extends string = string, tdata = unknown> = {\n index: number;\n priority: number;\n rule: readonly<normalizedwardrule<taction, tdata>>;\n score: number;\n won: boolean;\n};\n\nexport type wardtrace<taction extends string = string, tdata = unknown> = {\n candidates: wardtracecandidate<taction, tdata>[];\n decision: warddecision<taction, tdata>;\n};\n\nexport type ward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: wardallowedactionsinput<taction, tdata>): taction[];\n checkall(principal: principal, checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n detectconflicts(): readonly wardconflict<taction, tdata>[];\n explain(input: warddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n foruser(principal: userprincipal): boundward<taction, tdata>;\n rulesinscope(input: wardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: warddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type boundward<taction extends string = string, tdata = unknown> = {\n allowedactions(input: boundwardallowedactionsinput<taction, tdata>): taction[];\n checkall(checks: readonly wardcheck<taction, tdata>[]): warddecisionresult<taction, tdata>[];\n explain(input: boundwarddecisioninput<taction, tdata>): warddecision<taction, tdata>;\n rulesinscope(input: boundwardrulesinscopeinput<tdata>): readonlyarray<readonly<normalizedwardrule<taction, tdata>>>;\n trace(input: boundwarddecisioninput<taction, tdata>): wardtrace<taction, tdata>;\n};\n\nexport type wardloggercontext<taction extends string = string, tdata = unknown> = warddecision<taction, tdata> & {\n action: taction;\n data?: tdata;\n principal: principal;\n resource: string;\n};\n\nexport type wardoptions<taction extends string = string, tdata = unknown> = {\n logger?: (context: wardloggercontext<taction, tdata>) => void;\n maxconflicts?: number;\n onconflict?: (conflict: wardconflict<taction, tdata>) => void;\n strict?: boolean;\n};\n```\n\n`warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, and `wardconflict` reference `normalizedwardrule` (always array `role`, always number `priority`).\n\n`ward`, `boundward`, `warddecision`, `warddecisionresult`, `wardtrace`, `wardtracecandidate`, `wardconflict`,\n`normalizedwardrule`, `wardoptions`, `wardcheck`, `wardallowedactionsinput`, `wardrulesinscopeinput`, `rulecontext`,\n`wardloggercontext`, `wardpredicate`, and `conflictkind` are exported from the root entry point.\n\n## errors\n\n `warderror` is the base error class; use `warderror.is(value)` for narrowing.\n `wardconfigerror` reports malformed rules, invalid `createward` options (`logger`, `onconflict`, `maxconflicts`), invalid principals, and strict conflict initialization.\n `wardpredicateerror` reports a throwing synchronous predicate and includes its `ruleindex` and cause.\n",
1310
+ "usage": " \ntitle: ward — usage guide\ndescription: build deterministic authorization policies with immutable rule sets, wildcard support, and runtime predicates.\n \n\n[[toc]]\n\n## basic usage\n\n```ts\nimport { wildcard, allow, createward, deny } from '@vielzeug/ward';\n\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', 'posts', [wildcard], { priority: 100 }),\n]);\n```\n\n`allow()`, `deny()`, and `rulefor()` return `wardrule[]` (one rule per action). pass them directly to `createward` — no spread needed. rules are immutable after creation. create a new ward to update policy.\n\n## explain a decision\n\n```ts\nconst decision = ward.explain({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n action: 'update',\n data: { authorid: 'u1' },\n});\n\nif (decision.allowed) {\n console.log(decision.rule);\n} else {\n console.log(decision.reason); // 'no matching rule' | 'explicit deny'\n}\n```\n\n## batch decisions\n\n```ts\nconst results = ward.checkall({ id: 'u1', roles: ['editor'] }, [\n { resource: 'posts', action: 'read' },\n { resource: 'posts', action: 'update', data: { authorid: 'u1' } },\n]);\n```\n\n## bound ward (`foruser`)\n\n```ts\nconst bound = ward.foruser({ id: 'u1', roles: ['editor'] });\n\nbound.explain({ resource: 'posts', action: 'read' });\nbound.trace({ resource: 'posts', action: 'update', data: { authorid: 'u1' } });\nbound.rulesinscope({ resource: 'posts' });\nbound.allowedactions({ resource: 'posts', knownactions: ['read', 'update', 'delete'] as const });\n```\n\n`foruser()` snapshots the principal. re bind when roles/identity change.\n\n## allowed actions\n\n`allowedactions()` evaluates a provided action set:\n\n```ts\nconst actions = ward.allowedactions({\n principal: { id: 'u1', roles: ['admin'] },\n resource: 'posts',\n knownactions: ['read', 'update', 'delete'] as const,\n});\n```\n\nit does not fire the logger.\n\n## rule introspection\n\n```ts\nconst scoped = ward.rulesinscope({\n principal: { id: 'u1', roles: ['editor'] },\n resource: 'posts',\n});\n```\n\nuse optional `data` to filter predicate gated matches.\n\n## trace candidates\n\n```ts\nconst trace = ward.trace({\n principal: { id: 'u1', roles: ['editor', 'blocked'] },\n resource: 'posts',\n action: 'read',\n});\n\ntrace.candidates.foreach((c) => {\n console.log(c.index, c.priority, c.score, c.won);\n});\n```\n\n`trace()` does not fire the logger.\n\n## predicate helpers\n\n```ts\nimport { owns, predicate } from '@vielzeug/ward';\n\nconst isowner = owns('authorid');\nconst canedit = predicate.and(isowner, ({ principal }) => principal.id !== '');\n```\n\nasync predicates are rejected at runtime with `wardpredicateerror`.\n\n## request guards\n\nuse `explain()` directly at request boundaries. extract the principal from your framework's request object and pass it to ward:\n\n```ts\nconst principal = await extractprincipal(req);\nconst decision = ward.explain({ principal, resource: 'posts', action: 'read' });\n\nif (!decision.allowed) {\n return res.status(403).json({ error: decision.reason });\n}\n```\n\n## testing\n\ntest policy outcomes through `explain()` so each test captures an allowed, explicit deny, or no match result.\n\n```ts\nimport { expect, it } from 'vitest';\n\nit('denies an action with no matching rule', () => {\n expect(\n ward.explain({ principal: { id: 'u1', roles: ['viewer'] }, resource: 'posts', action: 'delete' }).allowed,\n ).tobe(false);\n});\n```\n\n## framework integration\n\nkeep ward independent from rendering frameworks. obtain a current principal from framework state, bind it with `foruser()`, and rebind whenever identity or roles change.\n\n::: code group\n\n```tsx [react]\nconst actions = ward.foruser(user).allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n```\n\n```vue [vue 3]\n<script setup lang=\"ts\">\nconst actions = ward\n .foruser(user.value)\n .allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n</script>\n```\n\n```ts [svelte]\nconst actions = ward.foruser(user).allowedactions({ resource: 'posts', knownactions: ['read', 'update'] as const });\n```\n\n:::\n\n## working with other vielzeug libraries\n\n### with wayfinder\n\nenforce ward decisions in wayfinder route guards by calling `explain()` inside the guard callback:\n\n```ts\nconst decision = ward.explain({ principal, resource: route.meta.resource, action: 'read' });\n\nif (!decision.allowed) return '/forbidden';\n```\n\n### with conduit\n\ninject a ward instance into conduit managed services so authorization checks share a single compiled policy:\n\n```ts\nconst ward = createward(rules);\ncontainer.register('ward', ward);\n```\n\n## best practices\n\n model default deny by adding only explicit allow rules.\n keep predicates synchronous and provide required resource data.\n assign priority deliberately before relying on specificity.\n rebind `foruser()` when identity or roles change.\n use `trace()` and `detectconflicts()` to diagnose policy behavior.\n enforce authorization again at request and mutation boundaries.\n",
1311
+ "examples": " \ntitle: ward — examples\ndescription: practical examples and recipes for ward.\n \n\n## examples\n\n [blog roles](./examples/blog roles.md)\n [multi role rules](./examples/multi role rules.md)\n [wildcard action](./examples/wildcard action.md)\n [priority and overrides](./examples/inheritance and overrides.md)\n [bound guard in ui layer](./examples/bound guard in ui layer.md)\n [rule specificity](./examples/disabling wildcard fallback.md)\n [logger for auditing](./examples/logger for auditing.md)\n [fresh ward per test](./examples/snapshot restore for test isolation.md)\n [conflict detection](./examples/conflict detection.md)\n [trace a decision](./examples/trace decision.md)\n"
1312
+ },
1313
+ "examples": [
1314
+ {
1315
+ "id": "basic-rules",
1316
+ "text": "basic rules import { anonymous, wildcard, allow, createward, deny } from '@vielzeug/ward'\n\n// role based access control with wildcard and anonymous support\nconst ward = createward([\n allow(wildcard, 'posts', ['read']),\n allow('editor', 'posts', ['update']),\n deny('blocked', wildcard, [wildcard]),\n allow(anonymous, 'posts', ['read']),\n])\n\nconst viewer = { id: 'u1', roles: ['viewer'] }\nconst editor = { id: 'u2', roles: ['editor'] }\nconst blocked = { id: 'u3', roles: ['blocked'] }\n\nconst explain = (p: typeof viewer | null, action: string) =>\n ward.explain({ action, principal: p, resource: 'posts' }).allowed\n\nconsole.log('viewer read: ', explain(viewer, 'read')) // true\nconsole.log('viewer update:', explain(viewer, 'update')) // false\nconsole.log('editor update:', explain(editor, 'update')) // true\nconsole.log('blocked read: ', explain(blocked, 'read')) // false\nconsole.log('anon read: ', explain(null, 'read')) // true"
1317
+ },
1318
+ {
1319
+ "id": "basic-setup",
1320
+ "text": "basic setup — multi role rules import { anonymous, allow, createward } from '@vielzeug/ward'\n\n// role accepts a string or an array of strings (or semantics)\nconst ward = createward([\n allow(['viewer', 'editor', 'admin'], 'posts', ['read']),\n allow(['editor', 'admin'], 'posts', ['update']),\n allow('admin', 'posts', ['delete']),\n allow(anonymous, 'posts', ['read']),\n])\n\nconst viewer = { id: '1', roles: ['viewer'] }\nconst editor = { id: '2', roles: ['editor'] }\nconst admin = { id: '3', roles: ['admin'] }\n\nconst can = (p: typeof viewer | null, action: string) =>\n ward.explain({ action, principal: p, resource: 'posts' }).allowed\n\nconsole.log('viewer can read:', can(viewer, 'read')) // true\nconsole.log('viewer can update:', can(viewer, 'update')) // false\nconsole.log('editor can update:', can(editor, 'update')) // true\nconsole.log('admin can delete:', can(admin, 'delete')) // true\nconsole.log('anonymous can read:', can(null, 'read')) // true"
1321
+ },
1322
+ {
1323
+ "id": "batch-decisions",
1324
+ "text": "batch decisions import { allow, createward, deny } from '@vielzeug/ward'\n\n// checkall returns warddecisionresult[] — each entry carries resource + action\nconst ward = createward([\n allow('editor', 'posts', ['read', 'update']),\n deny('editor', 'posts', ['delete']),\n])\n\nconst editor = { id: 'u1', roles: ['editor'] }\n\nconst results = ward.checkall(editor, [\n { resource: 'posts', action: 'read' },\n { resource: 'posts', action: 'update' },\n { resource: 'posts', action: 'delete' },\n { resource: 'comments', action: 'read' },\n])\n\nfor (const r of results) {\n const status = r.allowed ? '✅ allow' : `❌ ${r.reason}`\n console.log(`${r.resource}:${r.action.padend(8)} ${status}`)\n}"
1325
+ },
1326
+ {
1327
+ "id": "bound-view",
1328
+ "text": "bound view import { allow, createward, deny, predicate } from '@vielzeug/ward'\n\n// principal bound view: capture user once, check many times\nconst ward = createward([\n allow('editor', 'posts', ['read', 'update']),\n // delete: allow only when user owns the post (higher priority wins)\n allow('editor', 'posts', ['delete'], { when: predicate.owns('authorid'), priority: 1 }),\n deny('editor', 'posts', ['delete'], { priority: 0 }),\n])\n\nconst user = ward.foruser({ id: 'alice', roles: ['editor'] })\n\nconsole.log('read: ', user.explain({ action: 'read', resource: 'posts' }).allowed)\nconsole.log('update: ', user.explain({ action: 'update', resource: 'posts' }).allowed)\n\n// delete requires ownership\nconst mypost = { authorid: 'alice' }\nconst otherpost = { authorid: 'bob' }\nconsole.log('delete own: ', user.explain({ action: 'delete', data: mypost, resource: 'posts' }).allowed)\nconsole.log('delete other: ', user.explain({ action: 'delete', data: otherpost, resource: 'posts' }).allowed)\n\n// allowedactions — enumerate what alice can do\nconst actions = user.allowedactions({ data: mypost, knownactions: ['read', 'update', 'delete'], resource: 'posts' })\nconsole.log('allowed: ', actions)"
1329
+ },
1330
+ {
1331
+ "id": "conflict-detection",
1332
+ "text": "conflict detection // detect rule conflicts — duplicate and shadowed rules — at startup\nimport { createward } from '@vielzeug/ward'\n\nconst ward = createward(\n [\n // rule 0: viewer can read posts\n { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },\n // rule 1: duplicate — same (role, resource, action), different effect.\n // one of these can never fire.\n { role: 'viewer', resource: 'posts', action: 'read', effect: 'deny' },\n // rule 2+3: shadowed — the wildcard action allow at higher priority\n // will always win over this specific deny.\n { role: 'admin', resource: 'posts', action: '*', effect: 'allow', priority: 10 },\n { role: 'admin', resource: 'posts', action: 'delete', effect: 'deny', priority: 5 },\n ],\n {\n onconflict: (c) => {\n if (c.kind === 'duplicate') {\n console.log(`[conflict] duplicate: rule[${c.indexa}] always wins over rule[${c.indexb}]`)\n } else {\n console.log(`[conflict] shadowed: rule[${c.shadowedindex}] always overridden by rule[${c.shadowingindex}]`)\n }\n },\n },\n)\n\nconst conflicts = ward.detectconflicts()\nconsole.log('total conflicts detected:', conflicts.length)\n\nconflicts.foreach((c) => {\n if (c.kind === 'duplicate') {\n console.log(` duplicate: rule[${c.indexa}] (${c.rulea.effect}) vs rule[${c.indexb}] (${c.ruleb.effect})`)\n } else {\n console.log(` shadowed: rule[${c.shadowedindex}] (${c.shadowedrule.effect}) by rule[${c.shadowingindex}] (${c.shadowingrule.effect})`)\n }\n})"
1333
+ },
1334
+ {
1335
+ "id": "dynamic-permissions",
1336
+ "text": "dynamic permissions — ownership rules import { allow, createward, predicate } from '@vielzeug/ward'\n\nconst ward = createward([\n allow('user', 'posts', ['update'], { when: predicate.owns('authorid') }),\n])\n\nconst user1 = { id: 'user1', roles: ['user'] }\nconst user2 = { id: 'user2', roles: ['user'] }\nconst post = { id: 'post1', authorid: 'user1', title: 'my post' }\n\nconsole.log('author can update: ', ward.explain({ action: 'update', data: post, principal: user1, resource: 'posts' }).allowed)\nconsole.log('non author can update: ', ward.explain({ action: 'update', data: post, principal: user2, resource: 'posts' }).allowed)"
1337
+ },
1338
+ {
1339
+ "id": "multi-role-rules",
1340
+ "text": "multi role rules import { anonymous, createward } from '@vielzeug/ward'\n\n// a single rule can cover multiple roles with array syntax.\n// semantics are or: the principal must hold at least one of the listed roles.\nconst ward = createward([\n // everyone (including anonymous) can read public content\n { role: [anonymous, 'user', 'moderator', 'admin'], resource: 'articles', action: 'read', effect: 'allow' },\n // registered users and above can comment\n { role: ['user', 'moderator', 'admin'], resource: 'articles', action: 'comment', effect: 'allow' },\n // moderators and admins can remove content\n { role: ['moderator', 'admin'], resource: 'articles', action: 'delete', effect: 'allow' },\n // only admins can pin articles\n { role: 'admin', resource: 'articles', action: 'pin', effect: 'allow' },\n])\n\nconst guest = null\nconst user = { id: '1', roles: ['user'] }\nconst moderator = { id: '2', roles: ['moderator'] }\nconst admin = { id: '3', roles: ['admin'] }\n\nconst actions = ['read', 'comment', 'delete', 'pin'] as const\n\nfor (const [label, principal] of [['guest', guest], ['user', user], ['moderator', moderator], ['admin', admin]] as const) {\n const allowed = ward.allowedactions({ knownactions: actions, principal, resource: 'articles' })\n console.log(`${label} can:`, allowed)\n}"
1341
+ },
1342
+ {
1343
+ "id": "permission-checks",
1344
+ "text": "permission checks import { allow, createward, deny } from '@vielzeug/ward'\n\nconst ward = createward([\n allow('editor', 'articles', ['read', 'create', 'update']),\n deny('editor', 'articles', ['delete']),\n allow('viewer', 'articles', ['read']),\n])\n\nconst editor = { id: '1', roles: ['editor'] }\nconst viewer = { id: '2', roles: ['viewer'] }\n\nconsole.log('editor can read: ', ward.explain({ action: 'read', principal: editor, resource: 'articles' }).allowed)\nconsole.log('editor can delete: ', ward.explain({ action: 'delete', principal: editor, resource: 'articles' }).allowed)\nconsole.log('viewer can create: ', ward.explain({ action: 'create', principal: viewer, resource: 'articles' }).allowed)\n\n// full decision object with deny reason\nconst decision = ward.explain({ action: 'delete', principal: editor, resource: 'articles' })\nif (!decision.allowed) console.log('deny reason:', decision.reason)"
1345
+ },
1346
+ {
1347
+ "id": "permission-management",
1348
+ "text": "introspection and batch decisions import { createward } from '@vielzeug/ward'\n\nconst ward = createward([\n { role: 'user', resource: 'comments', action: 'read', effect: 'allow' },\n { role: 'moderator', resource: 'comments', action: 'delete', effect: 'allow' },\n { role: 'banned', resource: 'comments', action: 'delete', effect: 'deny', priority: 100 },\n])\n\nconst moderator = { id: 'm1', roles: ['moderator'] }\nconst bannedmoderator = { id: 'm2', roles: ['moderator', 'banned'] }\n\nconsole.log('rules in scope for moderator:', ward.rulesinscope({ principal: moderator, resource: 'comments' }))\nconsole.log('single decision:', ward.explain({ action: 'delete', principal: bannedmoderator, resource: 'comments' }))\nconsole.log('batch decisions:', ward.checkall(bannedmoderator, [\n { resource: 'comments', action: 'read' },\n { resource: 'comments', action: 'delete' },\n]))"
1349
+ },
1350
+ {
1351
+ "id": "role-hierarchy",
1352
+ "text": "bound multi role access import { allow, createward } from '@vielzeug/ward'\n\nconst ward = createward([\n allow('editor', 'posts', ['read']),\n allow('moderator', 'posts', ['delete']),\n])\n\nconst user = { id: '42', roles: ['editor', 'moderator'] }\nconst bound = ward.foruser(user)\n\nconsole.log('can read posts: ', bound.explain({ action: 'read', resource: 'posts' }).allowed)\nconsole.log('can delete posts: ', bound.explain({ action: 'delete', resource: 'posts' }).allowed)\nconsole.log('allowed actions: ', bound.allowedactions({ knownactions: ['read', 'delete', 'update'], resource: 'posts' }))"
1353
+ },
1354
+ {
1355
+ "id": "rule-factories",
1356
+ "text": "rule factories & predicates import { allow, createward, deny, predicate } from '@vielzeug/ward'\n\n// rule factories with ownership predicate\nconst ward = createward([\n allow('viewer', 'posts', ['read']),\n allow('editor', 'posts', ['read', 'update']),\n // ownership predicate — update only your own posts\n allow('editor', 'posts', ['update'], { when: predicate.owns('authorid') }),\n deny('blocked', 'posts', ['read', 'update']),\n])\n\nconst editor = { id: 'u1', roles: ['editor'] }\n\n// read: allowed (no predicate required)\nconsole.log('read: ', ward.explain({ action: 'read', principal: editor, resource: 'posts' }).allowed)\n\n// update with own post\nconst mypost = { authorid: 'u1' }\nconsole.log('update own: ', ward.explain({ action: 'update', data: mypost, principal: editor, resource: 'posts' }).allowed)\n\n// update someone else's post\nconst otherpost = { authorid: 'u2' }\nconsole.log('update other:', ward.explain({ action: 'update', data: otherpost, principal: editor, resource: 'posts' }).allowed)"
1357
+ },
1358
+ {
1359
+ "id": "trace-decision",
1360
+ "text": "trace — inspect matching candidates // inspect all matching rule candidates and why a particular rule won\nimport { wildcard, createward } from '@vielzeug/ward'\n\nconst ward = createward([\n { role: wildcard, resource: 'posts', action: 'read', effect: 'allow', priority: 0 },\n { role: 'editor', resource: 'posts', action: 'read', effect: 'allow', priority: 0 },\n { role: 'blocked', resource: 'posts', action: 'read', effect: 'deny', priority: 5 },\n])\n\nconst { decision, candidates } = ward.trace({\n action: 'read',\n principal: { id: 'u1', roles: ['editor', 'blocked'] },\n resource: 'posts',\n})\n\ncandidates.foreach(({ index, rule, priority, score, won }) => {\n console.log(\n won ? '[winner]' : '[ ]',\n `rule[${index}]`,\n `effect=${rule.effect}`,\n `role=${rule.role}`,\n `priority=${priority}`,\n `score=${score}`,\n )\n})\n\nconsole.log('decision:', decision.allowed ? 'allow' : `deny (${decision.reason})`)"
1361
+ },
1362
+ {
1363
+ "id": "wildcard-permissions",
1364
+ "text": "wildcard rules import { wildcard, allow, createward } from '@vielzeug/ward'\n\nconst ward = createward([\n allow('admin', wildcard, [wildcard]),\n allow('user', 'posts', ['read']),\n])\n\nconst admin = { id: '1', roles: ['admin'] }\nconst user = { id: '2', roles: ['user'] }\n\nconst can = (p: typeof admin, resource: string, action: string) =>\n ward.explain({ action, principal: p, resource }).allowed\n\nconsole.log('admin can delete users:', can(admin, 'users', 'delete'))\nconsole.log('user can read posts:', can(user, 'posts', 'read'))\nconsole.log('user can delete posts:', can(user, 'posts', 'delete'))\nconsole.log('known actions for admin:', ward.allowedactions({ knownactions: ['read', 'delete', 'archive'], principal: admin, resource: 'users' }))"
1365
+ }
1366
+ ],
1367
+ "exports": "createward allow deny rulefor owns predicate anonymous wildcard warderror wardconfigerror wardpredicateerror normalizedwardrule matchespattern patterncovers",
1368
+ "keywords": "authorization rbac permissions policy roles wildcard predicates",
1369
+ "name": "@vielzeug/ward",
1370
+ "related": "wayfinder conduit herald",
1371
+ "slug": "ward",
1372
+ "source": "export { allow, deny, owns, predicate, rulefor } from './builder';\nexport { anonymous, wildcard } from './constants';\nexport { wardconfigerror, warderror, wardpredicateerror } from './errors';\nexport { createward } from './factory';\nexport { matchespattern, patterncovers } from './resource';\nexport type {\n boundward,\n boundwardallowedactionsinput,\n boundwarddecisioninput,\n boundwardrulesinscopeinput,\n conflictkind,\n normalizedwardrule,\n principal,\n rulecontext,\n userprincipal,\n ward,\n wardallowedactionsinput,\n wardcheck,\n wardconflict,\n warddecision,\n warddecisioninput,\n warddecisionresult,\n wardloggercontext,\n wardoptions,\n wardpredicate,\n wardrule,\n wardrulesinscopeinput,\n wardtrace,\n wardtracecandidate,\n} from './types';\n"
1373
+ },
1374
+ {
1375
+ "category": "routing",
1376
+ "description": "framework agnostic client side router with typed params, async data loading, middleware, leave guards, and view transitions support.",
1377
+ "docs": {
1378
+ "index": " \ntitle: wayfinder — client side router for typescript\ndescription: framework agnostic client side router with typed params, async data loading, middleware, leave guards, and view transitions support.\npackage: wayfinder\ncategory: routing\nkeywords: [router, client side, middleware, guards, navigation, history, spa, typed routes]\nrelated: [ripple, ward, herald]\nexports: [createrouter, createbrowserhistory, creatememoryhistory, redirectto, wayfindererror, wayfinderapierror, wayfinderdisposederror, wayfinderredirectlooperror, wayfinderrouteerror, debugrouter]\nenvironments: [browser, node, ssr, deno]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"wayfinder\" />\n\n## why wayfinder?\n\nmanaging navigation by hand means scattered `popstate` listeners, duplicated path checks, and no shared abstraction for loading data or blocking navigation. wayfinder moves all of that into one declarative table.\n\n```ts\n// before — manual navigation with popstate\nwindow.addeventlistener('popstate', () => {\n const path = window.location.pathname;\n if (path === '/') renderhome();\n else if (path.startswith('/dashboard')) renderdashboard();\n else rendernotfound();\n});\ndocument.queryselectorall('a[data route]').foreach((a) => {\n a.addeventlistener('click', (e) => {\n e.preventdefault();\n history.pushstate({}, '', (e.currenttarget as htmlanchorelement).href);\n dispatchevent(new popstateevent('popstate'));\n });\n});\n\n// after — with wayfinder\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard' },\n },\n notfound: { component: notfoundpage },\n});\n\nrouter.subscribe((state) => {\n render(state.matches.at( 1)?.component);\n});\n```\n\n<div class=\"decision callout\">\n\n**use wayfinder when** you need named navigation, route level data loading with cancellation, middleware, or leave guards in a framework agnostic setup.\n\n**consider a framework's built in router when** you are deep in a single framework ecosystem (react router, vue router) and want first class component binding with no adapter layer.\n\n</div>\n\n| feature | wayfinder | page.js | navigo |\n| | | | |\n| bundle size | <packageinfo package=\"wayfinder\" type=\"size\" /> | ~1 kb | ~5 kb |\n| history mode | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| memory history (tests / non browser) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| typed path params | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| named navigation | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | partial |\n| middleware | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n| data loaders with abortsignal | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| lazy route loading | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| declarative redirects | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| search param validation | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| error in state | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| history state in context | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| leave guards | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| hover prefetching (`preload()`) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| scroll restoration | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| view transition api | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| zero dependencies | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> |\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/wayfinder\n```\n\n```sh [npm]\nnpm install @vielzeug/wayfinder\n```\n\n```sh [yarn]\nyarn add @vielzeug/wayfinder\n```\n\n:::\n\n## quick start\n\ncreate a memory backed router, wait for initial routing, then navigate by name.\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n settings: { path: '/settings' },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getsnapshot().location.pathname); // /settings\nrouter.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n `createrouter()` — compiles named, nested route tables.\n `navigate()` — commits route changes after middleware reaches its terminal stage.\n `ready` — signals that initial routing has settled.\n `data()` — receives cancellation through `abortsignal` and can stream async generator updates.\n `beforeleave()` — blocks route exits before history changes.\n `match()` / `load()` — inspect routes synchronously or load route data without navigation.\n `preload()` — warms route data for a later matching navigation.\n `creatememoryhistory()` — runs routers in tests and non browser environments.\n `debugrouter()` — logs navigation state from `@vielzeug/wayfinder/devtools`.\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/) — reactive signals; sync router state to a signal for framework agnostic reactivity\n [ward](/ward/) — permission guards; use inside wayfinder middleware to protect routes\n [herald](/herald/) — event bus; dispatch route change events to decouple navigation side effects\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
1379
+ "api": " \ntitle: wayfinder — api reference\ndescription: complete api reference for wayfinder.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createrouter(options)` | create a router from a route table | sync | initial navigation starts asynchronously in the constructor |\n| `createbrowserhistory()` | create the default browser history driver | sync | — |\n| `creatememoryhistory(initialpath?)` | create an in memory history driver | sync | — |\n| `redirectto(target, options?)` | build redirect middleware | sync (returns fn) | does not call `next()` — always short circuits the chain |\n| `router.navigate(target, options?)` | navigate to a named route, raw path object, or string path | async | no op when destination equals current url unless `force: true` |\n| `router.getsnapshot()` | return the current immutable route state | sync | does not subscribe — call `subscribe()` to react to changes |\n| `router.subscribe(listener)` | register a listener for state changes | sync (returns unsub) | listener is **not** called immediately with current state |\n| `router.url(name, params?, query?)` | build a url for a named route | sync | throws if the route name is unknown |\n| `router.isactive(name, options?)` | check if a named route matches the current url | sync | compares against the current snapshot pathname, not `history.location` directly |\n| `router.match(pathname)` | inspect a pathname as a branch without side effects | sync | returns `null` for redirect routes |\n| `router.load(url, options?)` | load a url into a full state including data loaders | async | middleware is not executed; lazy modules are resolved as a side effect |\n| `router.ready` | await the initial navigation | async | rejects when initial loading fails |\n| `router.preload(name, params?, query?)` | eagerly run data loaders without navigating | async | pass `query` to match the navigation cache key; rejects with `wayfinderdisposederror` if the router is disposed |\n| `router.waitfor(name)` | wait for the router to settle on a named route | async | rejects immediately if `status === 'error'`; rejects with `wayfinderdisposederror` if disposed while pending |\n| `router.beforeleave(blocker, options?)` | register a global leave guard | sync (returns unsub) | scoped to specific routes via `options.routes` |\n| `router.dispose()` | remove listeners and shut down the router | sync | idempotent — safe to call multiple times |\n\n## package entry points\n\n| import | purpose |\n| | |\n| `@vielzeug/wayfinder` | main exports and types |\n| `@vielzeug/wayfinder/devtools` | `debugrouter` — navigation logger (dev only) |\n\n## `createrouter(options)`\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n base: '/app',\n routes: {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings', data: () => fetchsettings() },\n },\n },\n },\n notfound: { component: notfoundpage },\n});\n```\n\n| option | type | default | description |\n| | | | |\n| `base` | `string` | `'/'` | base path prefix for all routes |\n| `coercesearch` | `coercesearchfn` | — | global search param coercion applied to every route that does not define its own `coercesearch`. throwing falls back to raw strings and is reported via `onerror`. |\n| `history` | `historydriver` | `createbrowserhistory()` | history source used for reading locations and writing navigations |\n| `middleware` | `middleware[]` | `[]` | global middleware prepended to every route |\n| `notfound` | `{ component?, data?, meta?, middleware? }` | — | synthetic route used when no path matches. global middleware runs first, then `notfound.middleware` and `notfound.data`. `ctx.pathname` is the unmatched path. |\n| `onerror` | `(error, context: routererrorcontext) => void` | — | optional sink for non awaited/background router errors |\n| `routes` | `routetable` | required | declarative route table. object key order defines match precedence. |\n| `scroll` | `(to, from) => scrolldecision` | — | called after each navigation. return `'top'` to scroll to top, `'preserve'` to keep the current position, or `{ x, y }` for a specific position. |\n| `viewtransition` | `boolean` | `false` | wrap navigations in the view transition api when available |\n\n**returns:** `router`\n\n## route table\n\ndefine routes as a plain object where keys become route names. typescript will infer route params from literal `path` strings.\n\n```ts\nconst routes = {\n home: { path: '/' },\n userdetail: { path: '/users/:id' },\n files: { path: '/files/:rest*' },\n};\n```\n\nnested routes are declared with `children`, and child names become compound names with dot notation.\n\n## route definition\n\n```ts\nconst routes = {\n home: { path: '/' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireauth],\n children: {\n index: { index: true },\n settings: {\n path: 'settings',\n data: async () => fetchsettings(),\n },\n },\n },\n userdetail: {\n path: '/users/:id',\n meta: { section: 'users' },\n data: async ({ params }) => fetchuser(params.id),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\neach route definition supports these fields:\n\n| field | type | description |\n| | | |\n| `path` | `string` | wayfinder pattern. supports static paths, `:param`, `:param*`, and `*`. child paths are relative unless they start with `/`. |\n| `children` | `record<string, routedefinition>` | nested child routes. child names are appended to the parent route name. |\n| `index` | `boolean` | default child route that inherits the parent path. |\n| `component` | `unknown` | optional framework view payload exposed on the leaf `routematch`. |\n| `data` | `datafn` | data loader. runs after middleware; result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | `() => promise<{ data?, component?, meta? }>` | lazy load the route module. called once on first navigation; result overrides static fields in the hydration cache. |\n| `meta` | `unknown` | static metadata exposed on each `routematch` in the branch. |\n| `middleware` | `middleware[]` | optional route specific middleware |\n| `onerror` | `(error, context: datacontext) => maybepromise<unknown>` | per route error boundary for data loader failures. return value becomes `match.data` for degraded rendering. |\n| `redirect` | `navigationtarget` | declarative redirect. resolved before middleware runs; uses `replacestate` so the original url is never added to history. |\n| `coercesearch` | `(raw: queryparams) => resolvedqueryparams` | coerce raw url string values into typed values. return value replaces `ctx.query`. throwing leaves the parsed query unchanged. |\n\n## `createbrowserhistory()`\n\n```ts\nimport { createbrowserhistory } from '@vielzeug/wayfinder';\n\nconst history = createbrowserhistory();\n```\n\ncreate the default `historydriver` backed by the browser history api.\n\n## `creatememoryhistory(initialpath?)`\n\n```ts\nimport { creatememoryhistory } from '@vielzeug/wayfinder';\n\n// tests\nconst router = createrouter({\n history: creatememoryhistory('/dashboard'),\n routes,\n});\n\n// controlled non browser runtime\nconst router = createrouter({\n history: creatememoryhistory('/request path'),\n routes,\n});\n```\n\ncreate an in memory `historydriver`. no browser history globals required — suitable for unit tests and controlled non browser runtimes. the optional `initialpath` defaults to `'/'`.\n\n## `router`\n\n### lifecycle\n\n#### `router.dispose()`\n\nremove listeners, clear subscribers, and reject future router interaction. idempotent — safe to call multiple times.\n\n**returns:** `void`\n\n**throws:** never.\n\n \n\n#### `router.disposed`\n\n`boolean` — `true` after `dispose()` has been called.\n\n \n\n#### `router.disposalsignal`\n\n`abortsignal` that is aborted (with a `wayfinderdisposederror` reason) when the router is disposed. use this to tie external resource lifetimes to the router's lifetime.\n\n```ts\nsource.on('update', syncrouteparams, { signal: router.disposalsignal });\n```\n\n \n\n### navigation\n\n#### `router.navigate(target, options?)`\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\n```\n\n| option | type | default | description |\n| | | | |\n| `replace` | `boolean` | `false` | use `replacestate` instead of `pushstate` |\n| `state` | `unknown` | — | history state payload |\n| `viewtransition` | `boolean` | — | override the router level setting for this navigation |\n| `force` | `boolean` | `false` | re run even when the destination url is already current |\n\n**returns:** `promise<void>`\n\nhistory is written only after middleware reaches the terminal stage. returning from middleware without `next()` cancels the programmatic navigation without changing history or the route snapshot.\n\nnamed routes stay the primary api, but `navigate()` also accepts raw path objects or a plain string:\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n\n// plain string — most concise for direct paths\nawait router.navigate('/about');\nawait router.navigate('/search?q=hello');\n```\n\n \n\n### route helpers\n\n#### `router.url(name, params?, query?)`\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n```\n\nbuild a base aware url for a named route.\n\n**returns:** `string`\n\n#### `router.isactive(name, options?)`\n\n```ts\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\ncheck whether the current pathname matches a named route exactly or by prefix.\n\n**returns:** `boolean`\n\n#### `router.match(pathname)`\n\n```ts\nrouter.match('/app/dashboard/settings');\n// => [\n// { name: 'dashboard', ... },\n// { name: 'dashboard.settings', ... },\n// ]\n```\n\ninspect a pathname without running middleware, data loaders, or subscribers. strips the configured `base` automatically. returns the matched branch from root to leaf, or `null` for redirect routes and no match.\n\n**returns:** `routematchbranch | null`\n\n \n\n#### `router.load(url, options?)`\n\n```ts\n// ssr data prefetch\nconst state = await router.load('/users/42');\n\n// with cancellation\nconst controller = new abortcontroller();\nconst state = await router.load('/dashboard', { signal: controller.signal });\n```\n\nload a full url into a `routestate` including data loader results, without modifying router state or history. follows declarative redirects (up to five hops) and resolves lazy modules as a side effect. returns `null` for unmatched urls.\n\nmiddleware is **not** executed — `load` is a data only prefetch for ssr and pre rendering where middleware side effects are not wanted. if your data loaders depend on `ctx.locals` set by middleware, use `navigate()` instead.\n\nwhen a `data()` function throws, the returned state has `status: 'error'` and `error` set to the thrown value.\n\n**returns:** `promise<routestate | null>`\n\n \n\n#### `router.waitfor(name)`\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n\n// useful in tests with memory history:\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\nconst state = await router.waitfor('dashboard');\n```\n\nwaits for the router to reach `status: 'idle'` with the named route active in the matched branch. rejects immediately if `status === 'error'`. resolves immediately if the router is already idle on the target route. also rejects if `router.dispose()` is called while the promise is pending.\n\n> **note:** `waitfor` skips intermediate `'streaming'` states — it only resolves once the status reaches `'idle'`. it does not resolve while the route is still streaming partial data.\n\n**returns:** `promise<routestate>`\n\n \n\n#### `router.preload(name, params?, query?)`\n\n```ts\n// hover prefetch without query\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// hover prefetch with matching query to avoid a cache miss\nanchor.addeventlistener('mouseenter', () => {\n router.preload('search', undefined, { q: 'hello' });\n});\n```\n\neagerly runs the data loaders for a named route without navigating. useful for hover prefetch. concurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key.\n\npass the same `query` you intend to navigate with to ensure the preloaded result hits the cache. without `query`, the key is the bare path — a navigation with a query string will produce a cache miss and re run the loader.\n\nin flight preloads are aborted automatically via the router's disposal signal when `router.dispose()` is called. calling `preload()` on an already disposed router throws `wayfinderdisposederror` immediately, without running the data loader — consistent with `navigate()`, `subscribe()`, `beforeleave()`, and `waitfor()`.\n\n**returns:** `promise<void>`\n\n \n\n#### `router.beforeleave(blocker, options?)`\n\n```ts\n// guard unsaved changes forms\nconst remove = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`leave without saving? (going to ${destination.pathname})`);\n});\n\n// remove the guard when the form unmounts\nremove();\n```\n\nregister a global leave guard called before user triggered navigation attempts. return `true` to allow, `false` to cancel. multiple guards can be registered; navigation is blocked if any guard returns `false`.\n\nscope a guard to fire only when leaving specific routes using the `routes` option:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\nthe guard fires when the router is leaving any route whose name appears in the `routes` array (any node in the active branch, not just the leaf). declarative `redirect` routes bypass all leave guards.\n\n**returns:** `() => void`\n\n## `redirectto(target, options?)`\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\ncreates middleware that navigates to `target` and short circuits the middleware chain (does not call `next()`). useful for auth guards and route aliases in middleware.\n\nfor permanent declarative redirects (url aliases), use the `redirect` field on the route definition instead.\n\n> **note:** `redirectto()` internally calls `ctx.navigate()`, which runs `beforeleave` guards. if a guard blocks navigation, the redirect will not complete. declarative `redirect` on a route definition bypasses guards entirely.\n\n**returns:** `middleware`\n\n \n\n### state\n\n#### `router.ready`\n\na `promise<void>` for the constructor triggered navigation. it resolves after initial middleware, redirects, lazy modules, and data loaders settle. it resolves after a blocked or unmatched initial navigation, and rejects if initial navigation fails.\n\n```ts\nconst router = createrouter({ routes });\nawait router.ready;\n```\n\n \n\n#### `router.getsnapshot()`\n\nreturns the current immutable route state snapshot. use this to read state synchronously. compatible with react's `usesyncexternalstore`:\n\n```ts\nconst state = usesyncexternalstore(\n (cb) => router.subscribe(cb),\n () => router.getsnapshot(),\n);\n```\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query (queryparams) — always string values\nlocation.hash;\nlocation.historystate; // value passed to navigate({ ... }, { state: ... })\n\n// when status === 'error':\nconsole.error(error);\n```\n\n`error` is only set when `status === 'error'`. it holds the exact value thrown by the failing `data()` function.\n\n**returns:** `routestate`\n\n#### `router.subscribe(listener)`\n\n```ts\nconst unsubscribe = router.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nregister a listener for future state changes, including loading and streaming updates. the listener is **not** called with the current snapshot — call `router.getsnapshot()` when you subscribe if you need it.\n\n**returns:** `() => void`\n\n## types\n\n### `routecontext<params, troutes>`\n\ncontext passed to middleware and data loader functions.\n\n```ts\ntype routecontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n locals: record<string, unknown>;\n readonly matches: routematchbranch;\n readonly navigate: (\n target: namednavigationtarget<troutes> | rawnavigationtarget | string,\n options?: navigateoptions,\n ) => promise<void>;\n readonly params: params;\n readonly pathname: string;\n readonly query: resolvedqueryparams;\n};\n```\n\nread route metadata from the leaf match: `ctx.matches.at( 1)?.meta`.\n\n`ctx.locals` is mutable and shared across the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n`ctx.query` is the coerced query (after `coercesearch`). `router.getsnapshot().location.query` always contains raw string values from url parsing.\n\n### `datafn<params, troutes>`\n\n```ts\ntype datafn<params extends routeparams = routeparams, troutes extends routetable = routetable> = (\n context: datacontext<params, troutes>,\n) => datastream | maybepromise<unknown>;\n```\n\nreturn an `asyncgenerator` to stream partial results (see `datastream`).\n\n### `datacontext<params, troutes>`\n\n```ts\ntype datacontext<params extends routeparams = routeparams, troutes extends routetable = routetable> = routecontext<\n params,\n troutes\n> & {\n readonly signal: abortsignal;\n};\n```\n\n### `datastream<t>`\n\n```ts\ntype datastream<t = unknown> = asyncgenerator<t, t>;\n```\n\nreturn a `datastream` from a `data()` function to stream partial results. each `yield` updates `match.data` immediately with `match.status: 'streaming'`. the `return` value is the final settled data with `match.status: 'idle'`.\n\n```ts\ndata: async function* ({ signal }) {\n const items: item[] = [];\n for await (const batch of streambatches({ signal })) {\n items.push(...batch);\n yield items; // partial — status: 'streaming'\n }\n return items; // final — status: 'idle'\n},\n```\n\n### `middleware<troutes>`\n\n```ts\ntype middleware<troutes extends routetable = routetable> = (\n context: routecontext<routeparams, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\nmiddleware ordering is simple: global middleware first, then route middleware, then `data()`.\n\n### `untypednamednavigationtarget`\n\n```ts\ntype untypednamednavigationtarget = {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n};\n```\n\n### `navigationtarget`\n\n```ts\ntype navigationtarget =\n | {\n path: string;\n }\n | {\n hash?: string;\n name: string;\n params?: routeparams;\n query?: resolvedqueryparams;\n };\n```\n\n### `navigateoptions`\n\n```ts\ntype navigateoptions = {\n force?: boolean;\n replace?: boolean;\n state?: unknown;\n viewtransition?: boolean;\n};\n```\n\n### `routestate`\n\n```ts\ntype routestate = {\n /** the value thrown by a `data()` function. only set when `status === 'error'`. */\n readonly error?: unknown;\n readonly location: routelocation;\n readonly matches: readonly routematch[];\n readonly status: navigationstatus;\n};\n\ntype routelocation = {\n readonly hash: string;\n /** state stored on the history entry that triggered this navigation. */\n readonly historystate: unknown;\n readonly pathname: string;\n /** raw parsed query params — always string values from url parsing.\n * for coerced values (numbers, booleans), read `ctx.query` inside middleware or data loaders.\n */\n readonly query: queryparams;\n};\n```\n\n### `routematch`\n\n```ts\ntype routematch = {\n readonly component: unknown;\n readonly data: unknown;\n readonly meta: unknown;\n readonly name: string;\n readonly params: routeparams;\n readonly pathname: string;\n /** per node loading status. reflects individual loader state in nested layouts. */\n readonly status: navigationstatus;\n};\n```\n\n### `routematchbranch`\n\n```ts\ntype routematchbranch = readonly routematch[];\n```\n\n### `pathparams<t>`\n\n```ts\ntype userparams = pathparams<'/users/:id'>;\n// => { readonly id: string }\n\ntype fileparams = pathparams<'/files/:rest*'>;\n// => { readonly rest: string }\n```\n\n### `queryparams`\n\n```ts\ntype queryparams = record<string, string | string[]>;\n```\n\nrepresents parsed url query values before route level coercion.\n\n### `resolvedqueryparams`\n\n```ts\ntype resolvedqueryvalue = string | number | boolean;\ntype resolvedqueryparams = record<string, resolvedqueryvalue | resolvedqueryvalue[]>;\n```\n\nrepresents the query object after optional `coercesearch` normalization.\n\n### `navigationstatus`\n\n```ts\ntype navigationstatus = 'idle' | 'loading' | 'streaming' | 'error';\n```\n\ntop level status of the router. `'streaming'` means at least one active data loader is an async generator and has yielded at least one value but has not yet returned.\n\neach `routematch` also carries a `status: navigationstatus` for per node loading state in nested layouts.\n\n### `routemiddleware<path, troutes>`\n\n```ts\ntype routemiddleware<path extends string = string, troutes extends routetable = routetable> = (\n context: routecontext<pathparams<path>, troutes>,\n next: () => promise<void>,\n) => void | promise<void>;\n```\n\ntyped variant of `middleware` scoped to a route path. provides typed `ctx.params` matching the path pattern.\n\n```ts\nconst guard: routemiddleware<'/users/:id'> = (ctx, next) => {\n console.log(ctx.params.id); // string\n return next();\n};\n```\n\n### `coercesearchfn<q>`\n\n```ts\ntype coercesearchfn<q extends resolvedqueryparams = resolvedqueryparams> = (\n raw: queryparams,\n) => q;\n```\n\nfunction signature for both the per route `coercesearch` field and the global `routeroptions.coercesearch` option. receives raw url strings and returns typed values. throwing inside the function falls back to the original raw query.\n\n### `beforeleaveoptions<troutes>`\n\n```ts\ntype beforeleaveoptions<troutes extends routetable = routetable> = {\n /** route names that trigger this guard. omit for a global guard. */\n routes?: routename<troutes>[];\n};\n```\n\npassed as the second argument to `router.beforeleave()`. when `routes` is provided, the guard only fires when the router leaves a route whose name is in the array.\n\n### `beforeleaveblocker`\n\n```ts\n// return true to allow navigation, false to cancel.\ntype beforeleaveblocker = (destination: navigationdestination) => maybepromise<boolean>;\n```\n\n### `navigationdestination`\n\n```ts\ntype navigationdestination = {\n readonly name?: string; // route name if navigating to a named route\n readonly params: routeparams;\n readonly pathname: string;\n readonly query: queryparams;\n};\n```\n\npassed to every `beforeleave` blocker. use `destination.pathname` and `destination.query` to make context aware allow/block decisions.\n\n### `isactiveoptions`\n\n```ts\ntype isactiveoptions = {\n /** require an exact pathname match. defaults to prefix matching. */\n exact?: boolean;\n};\n```\n\n### `scrolldecision`\n\n```ts\ntype scrollposition = { x: number; y: number };\ntype scrolldecision = scrollposition | 'preserve' | 'top';\n```\n\n### `routererrorcontext`\n\n```ts\ntype routererrorcontext =\n | { routename: string; source: 'data loader' } // data() threw\n | { routename: string; source: 'middleware' } // middleware threw\n | { source: 'coerce search' | 'history listener' | 'initial navigation' | 'preload' };\n```\n\npassed to the `onerror` callback in `createrouter` options. the `routename` is present when the error originates from a named route's `data()` or `middleware`.\n\n### `historydriver`\n\n```ts\ninterface historydriver {\n readonly location: {\n readonly hash: string;\n readonly pathname: string;\n readonly search: string;\n readonly state: unknown;\n };\n /** navigate one entry back in history, equivalent to the browser back button. */\n back(): void;\n push(url: string, state?: unknown): void;\n replace(url: string, state?: unknown): void;\n /**\n * subscribe to backwards/forwards navigation (popstate equivalent).\n * `push()` and `replace()` are silent — they do not notify subscribers.\n * only `back()` (and browser popstate events) trigger notifications.\n * returns an unsubscribe function.\n */\n onpopstate(listener: () => void): () => void;\n}\n```\n\n### `routedefinition<path>`\n\n```ts\ntype routedefinition<path extends string = string> =\n | contentroutedefinition<path> // path + data/component/meta/middleware/coercesearch/lazy/onerror\n | redirectroutedefinition<path>; // path + redirect\n```\n\nthe union type for a single entry in the route table. use this to type externally defined route objects:\n\n```ts\nimport type { routedefinition } from '@vielzeug/wayfinder';\n\nconst userdetail: routedefinition<'/users/:id'> = {\n path: '/users/:id',\n data: async ({ params }) => fetchuser(params.id),\n};\n```\n\n### `routeroptions<troutes>`\n\nthe options object accepted by `createrouter()`. see the [`createrouter(options)`](#createrouter options) options table above for the full field reference.\n\n```ts\nimport type { routeroptions } from '@vielzeug/wayfinder';\n\nconst options: routeroptions<typeof routes> = {\n routes,\n base: '/app',\n};\n```\n\n### `unsubscribe`\n\n```ts\ntype unsubscribe = () => void;\n```\n\n## errors\n\n### `wayfindererror`\n\nbase class for every error wayfinder throws. catch this to handle any router originated error without enumerating subclasses.\n\n```ts\nimport { wayfindererror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfindererror) {\n // any router originated error — check e.name or `instanceof` a subclass for detail\n }\n}\n```\n\n### `wayfinderdisposederror`\n\nthrown when `navigate()`, `subscribe()`, `beforeleave()`, `waitfor()`, or `preload()` is called after `dispose()`. also used as the `abortsignal.reason` on `disposalsignal`.\n\n```ts\nimport { wayfinderdisposederror } from '@vielzeug/wayfinder';\n\ntry {\n await router.navigate({ name: 'home' });\n} catch (e) {\n if (e instanceof wayfinderdisposederror) {\n // router was disposed\n }\n}\n```\n\n### `wayfinderrouteerror`\n\nthrown for malformed route definitions — at `createrouter()` time for config errors, or when a `url()`/`navigate()` call references an unknown route name or a missing path param.\n\n### `wayfinderredirectlooperror`\n\nthrown when a chain of declarative `redirect`s (or a mix of declarative redirects and `ctx.navigate()` calls inside route middleware) exceeds 5 hops.\n\n### `wayfinderapierror`\n\nthrown on middleware misuse — currently only when a middleware function calls its `next()` more than once.\n\n### runtime error messages\n\n| message | class | when |\n| | | |\n| `router is disposed` | `wayfinderdisposederror` | calling a guarded method (see above) after `dispose()` |\n| `unknown route name: x. available routes: y` | `wayfinderrouteerror` | navigating to, resolving, or building a url for an unregistered route |\n| `route \"x\" cannot define both index and path` | `wayfinderrouteerror` | a route sets `index: true` and `path` at the same time |\n| `route \"x\" must define path or set index: true` | `wayfinderrouteerror` | a route defines neither `index: true` nor `path` |\n| `duplicate route name: \"x\"` | `wayfinderrouteerror` | two routes resolve to the same compound name during `createrouter()` |\n| `missing path param: x` | `wayfinderrouteerror` | `url()`/`navigate()`/`preload()` omits a param the path pattern requires |\n| `invalid param name \":x\" in path \"y\"` | `wayfinderrouteerror` | a param name contains non word characters (e.g., `:user id`) |\n| `wildcard \"*\" must be the final segment in path: x` | `wayfinderrouteerror` | a `*` segment appears before the last segment |\n| `wildcard param must be final segment in path: x` | `wayfinderrouteerror` | a `:param*` greedy param appears before the last segment |\n| `redirect loop detected` | `wayfinderredirectlooperror` | a declarative `redirect` chain (or mixed redirect + `navigate()`) exceeds 5 hops |\n| `next() called multiple times` | `wayfinderapierror` | middleware calls its `next()` callback more than once |\n\n## pattern rules\n\n| pattern | example | meaning |\n| | | |\n| `/about` | `/about` | exact static path |\n| `/users/:id` | `/users/42` | single named param |\n| `/users/:userid/posts/:postid` | `/users/1/posts/2` | multiple named params |\n| `/docs/*` | `/docs/guide/intro` | wildcard suffix without a named capture |\n| `/files/:rest*` | `/files/a/b/c` | wildcard suffix captured as one named param |\n| `*` | anything | global catch all |\n\n## `debugrouter(options)` <badge type=\"tip\" text=\"@vielzeug/wayfinder/devtools\" />\n\n```ts\nimport { debugrouter } from '@vielzeug/wayfinder/devtools';\n\nconst router = debugrouter({ routes });\n// [wayfinder:nav] idle / [home] ← logged when initial navigation settles\n// [wayfinder:nav] loading /dashboard\n// [wayfinder:nav] idle /dashboard [dashboard.index]\n```\n\nwraps `createrouter()` and attaches a `subscribe` listener that logs every navigation state change to `console.debug`. returns the same `router` instance — all methods are identical to `createrouter()`. the first logged entry appears when the initial navigation completes (not synchronously at construction).\n\nimport from the dedicated sub path so the `console.debug` reference is tree shaken from production bundles when not imported.\n\n### `debugrouteroptions`\n\nextends `routeroptions` with one additional field:\n\n| option | type | default | description |\n| | | | |\n| `label` | `string` | `'nav'` | label used in log prefixes. produces `[wayfinder:<label>]`. useful when running multiple routers simultaneously. |\n\n```ts\n// multi router setup — distinguish logs by label:\nconst main = debugrouter({ routes, label: 'main' });\nconst modal = debugrouter({ routes: modalroutes, label: 'modal' });\n// [wayfinder:main] idle /dashboard\n// [wayfinder:modal] loading /confirm\n```\n\n| log format | when |\n| | |\n| `[wayfinder:nav] idle /path [routename]` | navigation settled |\n| `[wayfinder:nav] loading /path` | data loaders in flight |\n| `[wayfinder:nav] streaming /path [routename]` | streaming loader emitting partial data |\n| `[wayfinder:nav] error /path [routename] <error>` | navigation error |\n\n## design notes\n\n wayfinder no longer exposes imperative registration methods like `on()`, `group()`, or `use()`.\n wayfinder names come from the route table object keys.\n `data()` is the terminal action. its return value becomes `match.data`. there is no separate `handler` step.\n for unmatched urls, use the `notfound` router option rather than `path: '*'` in the route table.\n error handling is middleware that wraps `await next()`. the thrown error is also stored on `router.getsnapshot().error`.\n declarative `redirect` on a route definition is for permanent alias redirects. the `redirectto()` middleware helper is for conditional guards.\n `lazy` factories are called at most once per `routerecord`. the loaded `data`/`component`/`meta` are stored in the router's internal hydration cache. `handler` is not accepted in the lazy resolved module.\n `onerror` in a route definition is a per route data loader boundary. if `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n",
1380
+ "usage": " \ntitle: wayfinder — usage guide\ndescription: router setup, middleware, data loading, nested routes, and state patterns for wayfinder.\n \n\n[[toc]]\n\n::: tip new to wayfinder?\nstart with the [overview](./index.md), then use this page for the day to day api.\n:::\n\n## basic usage\n\ncreate a deterministic router with memory history, wait for startup, and navigate by route name.\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n settings: {\n data: async () => ({ section: 'settings' }),\n path: '/settings',\n },\n },\n});\n\nawait router.ready;\nawait router.navigate({ name: 'settings' });\nconsole.log(router.getsnapshot().matches.at( 1)?.data);\nrouter.dispose();\n```\n\n`routes` is required. route keys become names, and object key order controls match precedence.\n\n## define routes\n\neach route can provide these fields:\n\n| field | purpose |\n| | |\n| `path` | match pattern |\n| `children` | nested child routes |\n| `index` | default child route that inherits the parent path |\n| `component` | optional view payload exposed on `match.component` |\n| `data` | abortable route data function. result available as `match.data`. supports streaming via `asyncgenerator`. |\n| `lazy` | lazy load the module. called once; result fills `data`, `component`, and `meta`. |\n| `meta` | static metadata exposed on `match.meta` |\n| `middleware` | route specific middleware |\n| `onerror` | per route error boundary. called when this route's `data()` throws; its return value becomes `match.data`. |\n| `redirect` | declarative permanent redirect. resolved before middleware runs. |\n| `coercesearch` | coerce raw url search strings into typed values. return value replaces `ctx.query`. throw to leave the raw query unchanged. |\n\nuse wildcard routes for fallback behavior:\n\n```ts\nconst routes = {\n docs: { path: '/docs/*' },\n};\n```\n\nfor a catch all not found page, use the `notfound` option in router options instead of a `path: '*'` route:\n\n```ts\nconst router = createrouter({\n routes,\n notfound: {\n component: notfoundpage,\n data: async ({ pathname }) => ({ requestedpath: pathname }),\n },\n});\n```\n\nalternatively, `path: '*'` still works as a named route when you need to navigate to it explicitly.\n\nnested routes compose naturally and create compound route names:\n\n```ts\nconst routes = {\n dashboard: {\n path: '/dashboard',\n children: {\n index: { index: true },\n settings: { path: 'settings' },\n },\n },\n};\n\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n## route context\n\nmiddleware and data loaders receive a `routecontext`:\n\n```ts\nuserdetail: {\n path: '/users/:id',\n middleware: [\n (ctx, next) => {\n ctx.params.id; // typed to path params\n ctx.query.tab; // resolved query (after coercesearch)\n ctx.pathname;\n ctx.hash;\n ctx.historystate; // value from navigate({ ... }, { state: ... })\n ctx.locals; // mutable bag shared across the middleware chain\n ctx.navigate; // programmatic navigation\n return next();\n },\n ],\n data: async (ctx) => {\n ctx.signal; // abortsignal — cancelled when navigation is superseded\n return fetchuser(ctx.params.id, { signal: ctx.signal });\n },\n}\n```\n\n`ctx.locals` is mutable and shared through the entire middleware chain for one navigation. use it to pass values from middleware to data loaders.\n\n## middleware\n\nmiddleware wraps the navigation using the familiar `async (ctx, next) => { ... }` shape.\n\n```ts\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n\nconst loadcurrentuser = async (ctx, next) => {\n ctx.locals.user = await fetchcurrentuser();\n await next();\n};\n```\n\norder is fixed and simple:\n\n```text\nglobal middleware\n ↓\nroute middleware\n ↓\ndata()\n```\n\n### guards\n\nuse middleware for auth checks, redirects, analytics, and boundaries.\n\n```ts\nconst requireauth = async (ctx, next) => {\n if (!session.currentuser) {\n await ctx.navigate({ name: 'login' }, { replace: true });\n return; // do not call next()\n }\n ctx.locals.user = session.currentuser;\n await next();\n};\n```\n\nfor unconditional redirects, use the `redirectto()` helper:\n\n```ts\nimport { redirectto } from '@vielzeug/wayfinder';\n\nconst requireauth = redirectto({ name: 'login' }, { replace: true });\n```\n\nfor permanent url aliases, use the declarative `redirect` field instead of middleware:\n\n```ts\nconst routes = {\n profile: { path: '/profile', redirect: { name: 'userdetail' } },\n userdetail: { path: '/users/:id' },\n};\n```\n\n> **note:** `redirectto()` calls `ctx.navigate()` internally, so `beforeleave` guards will run and can block it. declarative `redirect` on a route definition bypasses all leave guards.\n\n### leave guards\n\nregister a global leave guard with `router.beforeleave()`. return `false` to cancel navigation.\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm(`discard changes? (navigating to ${destination.pathname})`);\n});\n\n// remove when no longer needed:\nremoveguard();\n```\n\nscope a guard to fire only when leaving specific routes:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\ndeclarative `redirect` routes bypass all leave guards.\n\n### data loading\n\nuse `data()` for route local data acquisition. it receives the same route context plus an `abortsignal`.\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n },\n};\n```\n\naccess the result via the matched branch:\n\n```ts\nrouter.subscribe((state) => {\n const user = state.matches.at( 1)?.data;\n renderuser(user);\n});\n```\n\n#### per route error boundaries\n\nuse `onerror` to handle data loader failures per route. the returned value becomes `match.data`, allowing the route to render a degraded state:\n\n```ts\nconst routes = {\n userdetail: {\n path: '/users/:id',\n data: async ({ params, signal }) => fetchuser(params.id, { signal }),\n onerror: (error) => ({ error, user: null }),\n },\n};\n```\n\nif `onerror` itself throws, the router falls through to `status: 'error'` as usual.\n\n#### streaming data loaders\n\nreturn an `asyncgenerator` from `data()` to stream partial results. each `yield` updates `match.status` to `'streaming'` and `match.data` to the yielded value. the `return` value is the final settled data.\n\n```ts\nconst routes = {\n feed: {\n path: '/feed',\n data: async function* ({ signal }) {\n const items: feeditem[] = [];\n for await (const batch of streamfeedbatches({ signal })) {\n items.push(...batch);\n yield items; // stream partial results\n }\n return items; // final settled value\n },\n },\n};\n```\n\nduring streaming, `state.status` is `'streaming'` and each `match.status` reflects the loading state of that individual branch node.\n\n### lazy routes\n\ndefer loading a route module until first navigation. the factory is called at most once.\n\n```ts\nconst routes = {\n settings: {\n path: '/settings',\n lazy: () => import('./pages/settings'),\n },\n};\n```\n\nthe resolved object may contain `data`, `component`, and/or `meta`. any present field overwrites the static definition.\n\n### search param validation\n\nvalidate and coerce `ctx.query` per route. the function receives raw url strings (`queryparams`). throw to leave the parsed query unchanged.\n\n```ts\nconst routes = {\n search: {\n path: '/search',\n coercesearch: (raw) => ({\n q: string(raw.q ?? ''),\n page: math.max(1, number(raw.page ?? 1)),\n }),\n data: async ({ query }) => searchposts(query.q, query.page),\n },\n};\n```\n\nto apply the same coercion to every route, set `coercesearch` on the router options instead. per route `coercesearch` takes precedence over the global one.\n\n```ts\nconst router = createrouter({\n coercesearch: (raw) => ({ page: number(raw.page ?? 1) }),\n routes,\n});\n```\n\n### error boundaries\n\nwrap `await next()` in middleware for route wide error handling. the thrown error is also stored on `router.getsnapshot().error`.\n\n```ts\nconst boundary = async (ctx, next) => {\n try {\n await next();\n } catch (error) {\n reportrouteerror(ctx.pathname, error);\n await ctx.navigate({ path: '/error' }, { replace: true });\n }\n};\n\nconst router = createrouter({\n middleware: [boundary],\n routes,\n});\n\n// check after navigation:\nconst { status, error } = router.getsnapshot();\nif (status === 'error') {\n console.error(error);\n}\n```\n\n## navigation\n\n### named navigation\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { replace: true });\nawait router.navigate({ name: 'search', query: { q: 'wayfinder' }, hash: 'results' });\nawait router.navigate({ name: 'dashboard.settings' });\n```\n\n### raw path targets\n\n```ts\nawait router.navigate({ path: '/marketing?utm_source=campaign' });\nawait router.navigate({ path: '/checkout#payment' }, { replace: true });\n```\n\nuse these when a destination does not belong in the route table. the same `navigate()` method covers named routes and raw path targets.\n\n### history state\n\nattach arbitrary state to a history entry and read it back via `ctx.historystate` or `router.getsnapshot().location.historystate`.\n\n```ts\nawait router.navigate({ name: 'userdetail', params: { id: '42' } }, { state: { from: 'search' } });\n\n// in data():\ndata: async (ctx) => {\n console.log(ctx.historystate); // { from: 'search' }\n return fetchuser(ctx.params.id);\n},\n```\n\n### same url deduplication\n\n```ts\nawait router.navigate({ name: 'dashboard' });\nawait router.navigate({ name: 'dashboard' }); // no op\nawait router.navigate({ name: 'dashboard' }, { force: true }); // re runs\n```\n\n### prefetching\n\neagerly run data loaders without navigating — useful for hover prefetch:\n\n```ts\n// preload a parameterised route\nanchor.addeventlistener('mouseenter', () => {\n router.preload('userdetail', { id: '42' });\n});\n\n// preload with a query string to avoid a cache miss on navigation\nsearchinput.addeventlistener('focus', () => {\n router.preload('search', undefined, { q: searchinput.value });\n});\n```\n\nconcurrent calls for the same `name + params + query` combination are deduplicated. results are consumed on the next navigation to the same route with the same cache key. pass the same `query` you intend to navigate with — without it, the preload key is the bare path and any navigation with a query string will re run the loaders.\n\nin flight preloads are aborted automatically when `router.dispose()` is called.\n\n### leave guards\n\nguard navigation until the user confirms — useful for unsaved changes forms:\n\n```ts\nconst removeguard = router.beforeleave(async (destination) => {\n if (!form.isdirty) return true;\n return confirm('discard changes?');\n});\n\n// remove when the component unmounts:\nremoveguard();\n```\n\nscope a guard to a specific route so it only fires when leaving that route:\n\n```ts\nrouter.beforeleave(async () => confirm('discard changes?'), { routes: ['editor'] });\n```\n\n## urls and active state\n\n```ts\nrouter.url('userdetail', { id: '42' });\nrouter.url('userdetail', { id: '42' }, { tab: 'profile' });\n\nrouter.isactive('userdetail');\nrouter.isactive('users');\nrouter.isactive('users', { exact: true });\n```\n\n`isactive(name)` reads the current router snapshot and is useful for parent navigation items.\n\n## match a path without navigating\n\n```ts\nconst branch = router.match('/app/dashboard/settings');\n\nif (branch?.at( 1)?.name === 'dashboard.settings') {\n warmsettingspanel();\n}\n```\n\n`match()` strips the configured base automatically and returns the full matched branch (root to leaf). data loaders are not executed.\n\n## load a path for ssr\n\nuse `router.load(url)` to load a full route state including data loader results without modifying router state or history. this is useful for server side data prefetching.\n\n```ts\nconst state = await router.load('/users/42');\n\nif (state) {\n const data = state.matches.at( 1)?.data;\n // serialize and send to the client\n}\n```\n\npass an `abortsignal` via the options object to cancel in flight loaders:\n\n```ts\nconst controller = new abortcontroller();\nconst state = await router.load('/users/42', { signal: controller.signal });\n```\n\n`load()` follows declarative redirects (up to five hops) and resolves lazy modules as a side effect.\n\n## state and subscriptions\n\n```ts\nrouter.subscribe((state) => {\n const leaf = state.matches.at( 1);\n document.title = (leaf?.meta as { title?: string } | undefined)?.title ?? 'app';\n});\n```\n\nuse `router.getsnapshot()` to read the current state synchronously:\n\n```ts\nconst { location, matches, status, error } = router.getsnapshot();\n\nlocation.pathname;\nlocation.query; // raw parsed query strings (queryparams)\nlocation.hash;\nlocation.historystate; // state from the current history entry\n\nmatches; // matched branch from root to leaf\nstatus; // 'idle' | 'loading' | 'streaming' | 'error'\nerror; // only set when status === 'error'\n```\n\neach match node also carries its own `status`:\n\n```ts\nmatches.at( 1)?.status; // 'idle' | 'loading' | 'streaming' | 'error'\n```\n\nthis lets nested layouts show per slot loading indicators without polling the top level status.\n\nthe state object is immutable. a successful navigation replaces it with a new snapshot.\n\n### `waitfor(name)`\n\nwait for the router to reach `status: 'idle'` with a specific route active. useful in tests and lifecycle coordination:\n\n```ts\n// navigate and wait for data to settle\nawait router.navigate({ name: 'userdetail', params: { id: '42' } });\nconst state = await router.waitfor('userdetail');\nconst user = state.matches.at( 1)?.data;\n```\n\n`waitfor` rejects immediately if the router is already in `status: 'error'`, and also rejects if `router.dispose()` is called while the promise is pending. resolves immediately if the named route is already active and idle.\n\n## scroll restoration\n\nprovide a `scroll` callback to control scroll position after each navigation:\n\n```ts\nconst router = createrouter({\n routes,\n scroll: (to, from) => {\n // return 'top' to scroll to top\n // return { x, y } for a specific position\n // return 'preserve' to do nothing\n return 'top';\n },\n});\n```\n\nthe callback receives the incoming state and the previous state, making it possible to implement saved position restore:\n\n```ts\nconst scrollpositions = new map<string, { x: number; y: number }>();\n\nrouter.subscribe((state) => {\n scrollpositions.set(state.location.pathname, { x: window.scrollx, y: window.scrolly });\n});\n\nconst router = createrouter({\n routes,\n scroll: (to, _from) => scrollpositions.get(to.location.pathname) ?? 'top',\n});\n```\n\n## testing\n\nuse `creatememoryhistory` to test routers without a browser:\n\n```ts\nimport { creatememoryhistory, createrouter } from '@vielzeug/wayfinder';\n\nconst history = creatememoryhistory('/dashboard');\nconst router = createrouter({ history, routes });\n\n// use waitfor to avoid manual timing:\nconst state = await router.waitfor('dashboard');\nassert(state.location.pathname === '/dashboard');\n\nrouter.dispose();\n```\n\n## cleanup\n\n```ts\nrouter.dispose();\n```\n\nremove listeners, clear subscribers, and prevent future router usage.\n\n## framework integration\n\nroute exposes `getsnapshot()` and `subscribe()`, which map directly to each framework's external store primitives. create the router once at module scope and bind actions outside the component lifecycle so references stay stable.\n\n::: code group\n\n```tsx [react]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { usesyncexternalstore } from 'react';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// stable router actions are safe to destructure outside the hook.\nconst { getsnapshot, isactive, navigate, subscribe, url } = router;\n\nexport function userouter() {\n const state = usesyncexternalstore(subscribe, getsnapshot);\n return { isactive, navigate, state, url };\n}\n\n// routerview.tsx\nexport function routerview() {\n const { state } = userouter();\n const component = state.matches.at( 1)?.component as react.componenttype | undefined;\n return component ? <component /> : null;\n}\n```\n\n```ts [vue 3]\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { readonly, shallowref } from 'vue';\n\nconst router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n});\n\n// shallowref — no need to deep track immutable route state.\nconst state = shallowref(router.getsnapshot());\nrouter.subscribe((next) => {\n state.value = next;\n});\n\nexport function userouter() {\n const { isactive, navigate, url } = router;\n\n return { isactive, navigate, state: readonly(state), url };\n}\n```\n\n```svelte [svelte]\n<! router.ts >\n<script lang=\"ts\" context=\"module\">\n import { createrouter } from '@vielzeug/wayfinder';\n import { readable } from 'svelte/store';\n\n const router = createrouter({\n routes: {\n home: { component: homepage, path: '/' },\n settings: { component: settingspage, path: '/settings' },\n },\n notfound: { component: notfoundpage },\n });\n\n // readable injects the initial value; subscribe() drives updates.\n export const routerstate = readable(router.getsnapshot(), (set) => router.subscribe(set));\n export const { isactive, navigate, url } = router;\n</script>\n```\n\n:::\n\nfor full routerview and routerlink patterns, see [react integration](./examples/react integration.md), [vue integration](./examples/vue integration.md), and [svelte integration](./examples/svelte integration.md).\n\n## debug mode\n\nimport `debugrouter` from the dedicated sub path to create a router with navigation logging pre enabled. the sub path is tree shaken from production bundles when not imported.\n\n```ts\nimport { debugrouter } from '@vielzeug/wayfinder/devtools';\n\nconst router = debugrouter({\n routes: {\n home: { path: '/' },\n dashboard: { path: '/dashboard', data: () => fetchdashboard() },\n },\n});\n\n// logged once the initial navigation completes:\n// [wayfinder:nav] idle / [home]\n\n// on navigate({ name: 'dashboard' }):\n// [wayfinder:nav] loading /dashboard\n// [wayfinder:nav] idle /dashboard [dashboard]\n```\n\nthe router returned is identical to `createrouter()` — all methods (`navigate`, `subscribe`, `waitfor`, etc.) work the same way.\n\nerrors are logged with the error object appended:\n\n```ts\n// [wayfinder:nav] error /dashboard [dashboard] error: fetch failed\n```\n\nuse the `label` option when running multiple routers to distinguish their log output:\n\n```ts\nconst main = debugrouter({ routes, label: 'main' });\nconst modal = debugrouter({ routes: modalroutes, label: 'modal' });\n// [wayfinder:main] loading /products\n// [wayfinder:modal] loading /confirm\n```\n\ndebug logging has no effect on behavior and should not be enabled in production.\n\n::: tip unhandled router errors\nif a route's data loader throws and no `onerror` callback is set on the router, the error is surfaced via `console.error` in development and silenced in production (`__wayfinder_prod__` set). always provide an `onerror` callback in production to handle errors explicitly.\n:::\n\n## working with other vielzeug libraries\n\n### with ward\n\nuse ward inside wayfinder middleware to guard protected routes.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { createward } from '@vielzeug/ward';\n\ntype user = { id: string; roles: string[] };\n\nconst ward = createward([{ role: 'admin', resource: 'settings', action: 'view', effect: 'allow' }]);\n\nconst router = createrouter({\n middleware: [\n (ctx, next) => {\n const user: user = getsessionuser();\n if (!ward.can(user, 'settings', 'view')) return ctx.navigate({ path: '/login' }, { replace: true });\n return next();\n },\n ],\n routes: {\n settings: { path: '/settings' },\n },\n});\n```\n\n### with ripple\n\nsync router state to a ripple signal for reactive ui.\n\n```ts\nimport { createrouter } from '@vielzeug/wayfinder';\nimport { signal } from '@vielzeug/ripple';\n\nconst router = createrouter({\n /* ... */\n});\nconst currentroute = signal(router.getsnapshot().matches.at( 1)?.name ?? '');\n\nrouter.subscribe((state) => {\n currentroute.value = state.matches.at( 1)?.name ?? '';\n});\n```\n\n## best practices\n\n define the route table once at app startup and import it where needed.\n prefer named navigation (`router.navigate({ name: 'settings' })`) over raw paths.\n put auth and permission checks in middleware, not in data loaders.\n use `data()` loaders for route data and honor the provided `abortsignal`.\n use `onerror` on a route for degraded state rendering rather than a full redirect to an error page.\n use `notfound` in router options for the not found page rather than `path: '*'` in the route table.\n call `router.dispose()` when tearing down apps/tests to release listeners.\n use `creatememoryhistory()` for tests and non browser runtimes; avoid touching `window.history` directly.\n use `router.preload()` on hover for routes likely to be visited next.\n",
1381
+ "examples": " \ntitle: wayfinder — examples\ndescription: practical examples and recipes for wayfinder.\n \n\n## examples\n\n [route table basics](./examples/route table basics.md)\n [not found and error boundary](./examples/not found and error boundary.md)\n [auth and guards](./examples/auth and guards.md)\n [page titles from meta](./examples/page titles from meta.md)\n [same url deduplication](./examples/same url deduplication.md)\n [base path deployment](./examples/base path deployment.md)\n [raw path targets](./examples/raw path targets.md)\n [view transitions](./examples/view transitions.md)\n [react integration](./examples/react integration.md)\n [vue integration](./examples/vue integration.md)\n [svelte integration](./examples/svelte integration.md)\n"
1382
+ },
1383
+ "examples": [
1384
+ {
1385
+ "id": "basic-routing",
1386
+ "text": "basic routing — route state and navigation import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// named routes, typed params, and subscribe() for reactive rendering.\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n about: { path: '/about', data: async () => ({ title: 'about us' }) },\n userdetail: { path: '/users/:id', data: async ({ params }) => ({ id: params.id, name: 'user ' + params.id }) },\n },\n notfound: {},\n})\n\n// react to every state change — the router notifies on navigate and load.\nrouter.subscribe((state) => {\n const leaf = state.matches.at( 1)\n if (state.status === 'idle') {\n console.log('route:', leaf?.name, '| data:', json.stringify(leaf?.data))\n }\n})\n\nawait router.ready\nconsole.log('initial pathname:', router.getsnapshot().location.pathname)\n\nawait router.navigate({ name: 'about' })\nawait router.navigate({ name: 'userdetail', params: { id: '42' } })\n\nconsole.log('current pathname:', router.getsnapshot().location.pathname)\nconsole.log('params:', router.getsnapshot().matches.at( 1)?.params)\n\nrouter.dispose()"
1387
+ },
1388
+ {
1389
+ "id": "debug-router",
1390
+ "text": "debug router — navigation logging import { creatememoryhistory } from '@vielzeug/wayfinder'\nimport { debugrouter } from '@vielzeug/wayfinder/devtools'\n\nconst router = debugrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n userdetail: { path: '/users/:id', data: async ({ params }) => ({ id: params.id }) },\n settings: { path: '/settings' },\n },\n})\n\nawait router.ready\nawait router.navigate({ name: 'userdetail', params: { id: '42' } })\nawait router.navigate({ name: 'settings' })\n\nconsole.log('active route:', router.getsnapshot().matches.at( 1)?.name)\nrouter.dispose()"
1391
+ },
1392
+ {
1393
+ "id": "middleware-auth",
1394
+ "text": "guards and redirects — auth flows import { creatememoryhistory, createrouter, redirectto } from '@vielzeug/wayfinder'\n\n// middleware runs before data(); use it for auth checks, redirects, and analytics.\nconst session = { currentuser: null }\n\nconst requireauth = async (ctx, next) => {\n if (!session.currentuser) {\n console.log('not authenticated — redirecting to /login')\n await ctx.navigate({ name: 'login' }, { replace: true })\n return // do not call next(); cancels navigation to the protected route\n }\n ctx.locals.user = session.currentuser\n await next()\n}\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n login: { path: '/login' },\n dashboard: {\n path: '/dashboard',\n middleware: [requireauth],\n data: (ctx) => ({ welcome: 'hello, ' + ctx.locals.user.name }),\n },\n // redirectto() is shorthand for an unconditional redirect middleware.\n legacy: { path: '/old dashboard', middleware: [redirectto({ name: 'dashboard' }, { replace: true })] },\n },\n})\n\nconsole.log(' unauthenticated ')\nawait router.navigate({ name: 'dashboard' })\nconsole.log('location after blocked nav:', router.getsnapshot().location.pathname)\n\nsession.currentuser = { name: 'alice' }\nconsole.log(' authenticated ')\nawait router.navigate({ name: 'dashboard' })\nconsole.log('data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\n\nconsole.log(' legacy redirect ')\nawait router.navigate({ path: '/old dashboard' })\nconsole.log('location after redirect:', router.getsnapshot().location.pathname)\n\nrouter.dispose()"
1395
+ },
1396
+ {
1397
+ "id": "middleware-chain",
1398
+ "text": "middleware chain — execution flow import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// execution order is always: global middleware → route middleware → data().\nconst logger = async (ctx, next) => {\n console.log('[global] entering', ctx.pathname)\n await next()\n console.log('[global] leaving', ctx.pathname)\n}\n\nconst loaduser = async (ctx, next) => {\n console.log('[route] loading user')\n ctx.locals.user = { id: 1, name: 'alice', role: 'admin' }\n await next()\n}\n\nconst requireadmin = async (ctx, next) => {\n console.log('[route] checking role:', ctx.locals.user?.role)\n if (ctx.locals.user?.role !== 'admin') {\n console.log('[route] permission denied — aborting navigation')\n return // do not call next(); cancels the navigation\n }\n await next()\n}\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n middleware: [logger],\n routes: {\n home: { path: '/' },\n admin: {\n path: '/admin',\n middleware: [loaduser, requireadmin],\n data: async (ctx) => {\n console.log('[data()] fetching panel data for', ctx.locals.user.name)\n return { loaded: true, user: ctx.locals.user.name }\n },\n },\n },\n})\n\nawait router.waitfor('home')\nconsole.log(' navigate to /admin ')\nawait router.navigate({ name: 'admin' })\nconsole.log('data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\n\nrouter.dispose()"
1399
+ },
1400
+ {
1401
+ "id": "named-routes",
1402
+ "text": "named routes — type safe navigation import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// route keys become type safe names; url() and navigate() reference them by name.\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n users: { path: '/users' },\n userdetail: { path: '/users/:id' },\n postcomment: { path: '/posts/:postid/comments/:commentid' },\n },\n})\n\n// build base aware urls without navigating.\nconsole.log('userdetail url:', router.url('userdetail', { id: '123' }))\nconsole.log('postcomment url:', router.url('postcomment', { postid: '10', commentid: '50' }))\nconsole.log('url with query:', router.url('users', undefined, { page: 2 }))\n\n// navigate by name — typescript will enforce the required params shape.\nawait router.navigate({ name: 'userdetail', params: { id: '42' } })\n\n// isactive() defaults to prefix matching — useful for parent nav items.\nconsole.log('userdetail isactive (prefix):', router.isactive('userdetail'))\nconsole.log('users isactive (prefix):', router.isactive('users'))\nconsole.log('users isactive (exact):', router.isactive('users', { exact: true }))\n\n// match() returns the matched branch without navigating or running data().\nconst branch = router.match('/users/99')\nconsole.log('matched name:', branch?.at( 1)?.name)\nconsole.log('matched params:', branch?.at( 1)?.params)\n\nrouter.dispose()"
1403
+ },
1404
+ {
1405
+ "id": "nested-routes",
1406
+ "text": "nested routes — children and index routes import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// child route names use dot notation: 'dashboard.settings', 'dashboard.index'.\nconst router = createrouter({\n history: creatememoryhistory('/dashboard'),\n routes: {\n dashboard: {\n path: '/dashboard',\n data: async () => ({ section: 'dashboard' }), // parent data runs for every child\n children: {\n index: { index: true }, // inherits parent path\n settings: { path: 'settings', data: async () => ({ view: 'settings' }) },\n audit: { path: 'audit', data: async () => ({ view: 'audit' }) },\n },\n },\n blogpost: {\n path: '/blog/posts/:id',\n data: async ({ params }) => ({ postid: params.id }),\n },\n },\n})\n\n// the initial match is dashboard.index (index: true).\nconst initial = await router.waitfor('dashboard.index')\nconsole.log('initial branch:', initial.matches.map((m) => m.name))\n\nawait router.navigate({ name: 'dashboard.settings' })\nconst snap = router.getsnapshot()\nconsole.log('settings branch:', snap.matches.map((m) => m.name))\nconsole.log('leaf data:', json.stringify(snap.matches.at( 1)?.data))\nconsole.log('audit url:', router.url('dashboard.audit'))\n\nawait router.navigate({ name: 'blogpost', params: { id: '123' } })\nconsole.log('blog data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\n\nrouter.dispose()"
1407
+ },
1408
+ {
1409
+ "id": "preload-and-dispose",
1410
+ "text": "preload cache and dispose import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\nlet fetchcount = 0\n\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n product: {\n path: '/products/:id',\n data: async ({ params }) => {\n fetchcount++\n return { id: params.id, name: 'product ' + params.id, fetchcount }\n },\n },\n search: {\n path: '/search',\n data: async ({ query }) => {\n fetchcount++\n return { results: ['a', 'b', 'c'], q: query.q, fetchcount }\n },\n },\n },\n})\n\n// ── preload with params (no query) ───────────────────────────────────────────\n// warm the data loader before navigation — simulates hover prefetch.\nawait router.preload('product', { id: '99' })\nconsole.log('fetches after product preload:', fetchcount) // 1\n\n// navigate — data loader is not called again (cache hit).\nawait router.navigate({ name: 'product', params: { id: '99' } })\nconsole.log('fetches after product navigate:', fetchcount) // still 1\n\n// ── preload with query param ──────────────────────────────────────────────────\n// pass the same query you intend to navigate with so the cache key matches.\nawait router.navigate({ path: '/' })\nawait router.preload('search', undefined, { q: 'hello' })\nconsole.log('fetches after search preload:', fetchcount) // 2\n\n// navigate with the same query — cache hit, no extra fetch.\nawait router.navigate({ name: 'search', query: { q: 'hello' } })\nconsole.log('fetches after search navigate:', fetchcount) // still 2\nconsole.log('search data:', router.getsnapshot().matches.at( 1)?.data)\n\n// ── lifecycle ─────────────────────────────────────────────────────────────────\nconsole.log('disposed before dispose():', router.disposed)\nrouter.dispose()\nconsole.log('disposed after dispose():', router.disposed)\nconsole.log('disposalsignal aborted:', router.disposalsignal.aborted)"
1411
+ },
1412
+ {
1413
+ "id": "query-params",
1414
+ "text": "query parameters — coercion and url state import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// coercesearch normalises raw url strings into typed values before data() runs.\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n search: {\n path: '/search',\n coercesearch: (raw) => ({\n page: math.max(1, number(raw.page ?? 1)),\n q: string(raw.q ?? ''),\n tags: array.isarray(raw.tags) ? raw.tags : raw.tags ? [raw.tags] : [],\n }),\n data: async ({ query }) => ({\n // ctx.query here is the coerced result, not raw url strings.\n results: `searched \"${query.q}\" page ${query.page} tags:${query.tags}`,\n }),\n },\n userposts: {\n path: '/users/:id/posts',\n data: async ({ params, query }) => ({\n userid: params.id,\n status: query.status ?? 'all',\n limit: number(query.limit ?? 10),\n }),\n },\n },\n})\n\nawait router.navigate({ name: 'search', query: { page: 2, q: 'wayfinder', tags: ['docs', 'routing'] } })\nconsole.log('search data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\n\nawait router.navigate({ name: 'userposts', params: { id: '42' }, query: { status: 'published', limit: 20 } })\nconsole.log('posts data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\n\n// raw url query is always string values; coerced values live in ctx.query.\nconst loc = router.getsnapshot().location\nconsole.log('raw location.query:', json.stringify(loc.query))\n\nrouter.dispose()"
1415
+ },
1416
+ {
1417
+ "id": "route-context",
1418
+ "text": "route context — full context access import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// routecontext is the full object available in middleware and data().\n// middleware receives ctx without a 'data' property; data() adds the signal.\nconst router = createrouter({\n history: creatememoryhistory('/'),\n routes: {\n home: { path: '/' },\n postdetail: {\n path: '/users/:userid/posts/:postid',\n meta: { title: 'post detail', breadcrumbs: ['home', 'users', 'posts'] },\n middleware: [\n async (ctx, next) => {\n // middleware can read params, query, hash, historystate, locals, navigate.\n ctx.locals.user = { id: number(ctx.params.userid), name: 'alice' }\n console.log('middleware | pathname:', ctx.pathname)\n console.log('middleware | params: ', json.stringify(ctx.params))\n console.log('middleware | query: ', json.stringify(ctx.query))\n console.log('middleware | state: ', json.stringify(ctx.historystate))\n await next()\n },\n ],\n data: async (ctx) => {\n // data() gets the same context plus an abortsignal for cancellation.\n console.log('data() | user from locals:', ctx.locals.user.name)\n console.log('data() | leaf meta:', json.stringify(ctx.matches.at( 1)?.meta))\n return { postid: ctx.params.postid, author: ctx.locals.user.name }\n },\n },\n },\n})\n\nawait router.navigate(\n { name: 'postdetail', params: { userid: '42', postid: '123' }, query: { tab: 'comments' } },\n { state: { from: 'feed' } },\n)\n\nconsole.log('snapshot data:', json.stringify(router.getsnapshot().matches.at( 1)?.data))\nrouter.dispose()"
1419
+ },
1420
+ {
1421
+ "id": "url-building",
1422
+ "text": "url building — path matching and active state import { creatememoryhistory, createrouter } from '@vielzeug/wayfinder'\n\n// url(), match(), and isactive() are synchronous and do not modify router state.\nconst router = createrouter({\n base: '/app',\n history: creatememoryhistory('/app/users/123'),\n routes: {\n users: { path: '/users' },\n user: { path: '/users/:id' },\n comment: { path: '/posts/:postid/comments/:commentid' },\n search: { path: '/search' },\n },\n})\n\n// wait for the initial navigation to settle before reading active state.\nawait router.waitfor('user')\n\nconsole.log(' url() ')\nconsole.log('user: ', router.url('user', { id: '42' }))\nconsole.log('search: ', router.url('search', undefined, { q: 'typescript', page: 2 }))\nconsole.log('comment:', router.url('comment', { postid: '10', commentid: '25' }))\n\nconsole.log(' match() ')\nconst branch = router.match('/app/users/99')\nconsole.log('matched:', branch?.map((n) => n.name + ' params=' + json.stringify(n.params)))\nconsole.log('no match:', router.match('/app/does not exist'))\n\nconsole.log(' isactive() ')\nconsole.log('user (prefix):', router.isactive('user'))\nconsole.log('users (prefix):', router.isactive('users')) // true — /users prefix matches /users/123\nconsole.log('users (exact):', router.isactive('users', { exact: true })) // false\n\nrouter.dispose()"
1423
+ }
1424
+ ],
1425
+ "exports": "createrouter createbrowserhistory creatememoryhistory redirectto wayfindererror wayfinderapierror wayfinderdisposederror wayfinderredirectlooperror wayfinderrouteerror debugrouter",
1426
+ "keywords": "router client side middleware guards navigation history spa typed routes",
1427
+ "name": "@vielzeug/wayfinder",
1428
+ "related": "ripple ward herald",
1429
+ "slug": "wayfinder",
1430
+ "source": "export {\n wayfinderapierror,\n wayfinderdisposederror,\n wayfindererror,\n wayfinderredirectlooperror,\n wayfinderrouteerror,\n} from './errors';\nexport { createbrowserhistory, creatememoryhistory } from './history';\nexport { redirectto } from './middleware';\nexport type { router } from './router';\nexport { createrouter } from './router';\nexport type {\n beforeleaveblocker,\n beforeleaveoptions,\n coercesearchfn,\n datacontext,\n datafn,\n datastream,\n historydriver,\n isactiveoptions,\n maybepromise,\n middleware,\n namednavigationtarget,\n navigateoptions,\n navigationdestination,\n navigationstatus,\n navigationtarget,\n pathparams,\n queryparams,\n rawnavigationtarget,\n resolvedqueryparams,\n resolvedqueryvalue,\n routecontext,\n routedefinition,\n routelocation,\n routematch,\n routematchbranch,\n routemiddleware,\n routename,\n routeparams,\n routepathbyname,\n routererrorcontext,\n routeroptions,\n routestate,\n routetable,\n scrolldecision,\n scrollposition,\n unsubscribe,\n untypednamednavigationtarget,\n} from './types';\n"
1431
+ }
1432
+ ]