@vielzeug/codex 26.10.2 → 26.10.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/data/catalog.json +49 -44
- package/data/llms-full.txt +148 -86
- package/data/llms.txt +1 -1
- package/data/manifest.json +1 -1
- package/data/packages/prism.json +51 -48
- package/data/packages/tandem.json +6 -4
- package/data/packages/tavern.json +15 -12
- package/data/search.json +14 -14
- package/package.json +2 -2
package/data/search.json
CHANGED
|
@@ -938,9 +938,9 @@
|
|
|
938
938
|
"category": "ui",
|
|
939
939
|
"description": "responsive svg charts with explicit updates, keyboard and screen reader support, and css theming.",
|
|
940
940
|
"docs": {
|
|
941
|
-
"index": " \ntitle: 'prism: responsive svg data visualization'\ndescription: responsive svg charts with explicit updates, keyboard and screen reader support, and css theming.\npackage: prism\ncategory: ui\nkeywords: [chart, svg, visualization, responsive, line chart, bar chart, area chart, pie chart, radar chart, sparkline, accessibility, typescript]\nrelated: [refine, orbit]\nexports:\n [\n createlinechart,\n createbarchart,\n createareachart,\n createpiechart,\n createradarchart,\n createsparkline,\n settheme,\n prismerror,\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 create or mutate the chart.js instance when data changes\n});\n\n// after: prism, responsive svg with an explicit update boundary\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'users by day' },\n series: [\n {\n data: [\n { key: 1, value: 12 },\n { key: 2, value: 40 },\n { key: 3, value: 28 },\n ],\n name: 'users',\n },\n ],\n tooltip: true,\n});\n\nchart.update([\n {\n data: [\n { key: 1, value: 12 },\n { key: 2, value: 40 },\n { key: 3, value: 28 },\n { key: 4, value: 65 },\n ],\n name: 'users',\n },\n]);\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| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> (orbit) | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> (modular) |\n| renderer | svg | canvas | canvas | svg/canvas |\n| data updates | explicit `update()` | instance mutation | series mutation | 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| framework neutral | <ore icon name=\"check\" size=\"16\"></ore icon> | <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| 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| keyboard navigation | <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, framework neutral charts with explicit updates and svg output that can be styled with css. it fits dashboards, admin panels, and data heavy applications.\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 '@vielzeug/prism/theme';\n\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'revenue by month' },\n series: [\n {\n color: '#3b82f6',\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 25 },\n { key: 3, value: 18 },\n { key: 4, value: 32 },\n ],\n name: 'revenue',\n },\n ],\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 chart data explicitly\nchart.update([\n {\n color: '#3b82f6',\n data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, { key: 5, value: 28 }],\n name: 'revenue',\n },\n]);\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 a gradient or solid fill\n **`createpiechart(container, config)`**: pie, donut, or semi circle donut chart; labels that do not fit their slice are hidden\n **`createradarchart(container, config)`**: radar chart for comparing values across 3+ axes, with per axis ranges\n **`createsparkline(container, config)`**: minimal inline line, area, bar, or stack chart\n **`linearscale`, `timescale`, `bandscale`**: standalone scales with nice tick generation\n **`charthandle.update(data)`**: replace chart data synchronously without coupling to a state library\n **`settheme(theme)` / `resettheme()`**: apply or clear custom colors, font, and grid tokens at runtime\n **series comparison**: hover or keyboard focus reports every series at the active key in the tooltip, `onhover`, and screen reader announcements\n **keyboard navigation**: arrow keys, `enter`, and `escape` on every labelled chart except sparklines\n **css custom properties**: full theme control via ` prism *` tokens, with dark mode built in\n **responsive**: auto resizes via `resizeobserver`\n
|
|
942
|
-
"api": " \ntitle: 'prism: api reference'\ndescription: complete chart, scale, theme, handle, configuration, and error contracts for @vielzeug/prism.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlinechart()` | render a line chart | sync | line keys must be numbers or dates |\n| `createareachart()` | render an area chart | sync | line keys must be numbers or dates |\n| `createbarchart()` | render grouped or stacked bars | sync | stacked negative values are clamped to zero |\n| `createpiechart()` | render pie, donut, or semi circle slices | sync | event callbacks use slice/index arguments |\n| `createradarchart()` | compare values across 3+ axes, such as hero stats | sync | events report a whole axis, not one series |\n| `createsparkline()` | render an inline line, area, bar, or stack | sync | omitted `a11y` makes the chart decorative |\n| `linearscale()` | create a numeric scale | sync | `nice` defaults to `true` |\n| `timescale()` | create a date scale | sync | invalid dates produce an invalid domain |\n| `bandscale()` | create a categorical scale | sync | unknown categories map to `0` and warn in development |\n| `settheme()` | set prism css custom properties | sync | values apply to `document.documentelement` |\n| `resettheme()` | remove prism theme overrides | sync | removes only properties managed by `settheme()` |\n| `seriescolor()` | resolve a series palette color | sync | palette indexes wrap after eight colors |\n| `debugchart()` | log chart lifecycle events | sync | import from `@vielzeug/prism/devtools` |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/prism` | chart factories, scales, theme helpers, errors, and public types |\n| `@vielzeug/prism/theme` | default css custom properties and dark mode values |\n| `@vielzeug/prism/devtools` | optional `debugchart()` lifecycle logging |\n\n## chart factories\n\n### `createlinechart()`\n\n```ts\nfunction createlinechart(\n container: htmlelement,\n config: linechartconfig,\n): charthandle<lineseriesconfig[]>;\n```\n\nrenders a line chart and returns a handle that replaces the complete series array through `update()`.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `linechartconfig` | initial series, axes, interaction, accessibility, and transition settings |\n\n**returns:** `charthandle<lineseriesconfig[]>`.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n a11y: { arialabel: 'revenue by month' },\n series: [{ data: [{ key: 1, value: 10 }], name: 'revenue' }],\n});\n\nchart.update([{ data: [{ key: 2, value: 20 }], name: 'revenue' }]);\n```\n\n \n\n### `createareachart()`\n\n```ts\nfunction createareachart(\n container: htmlelement,\n config: areachartconfig,\n): charthandle<areaseriesconfig[]>;\n```\n\nrenders an area chart and returns a handle that replaces the complete series array through `update()`.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `areachartconfig` | initial series, axes, interaction, accessibility, and transition settings |\n\n**returns:** `charthandle<areaseriesconfig[]>`.\n\n```ts\nimport { createareachart } from '@vielzeug/prism';\n\nconst chart = createareachart(container, {\n series: [{ data: [{ key: 1, value: 10 }], name: 'revenue' }],\n});\n```\n\n \n\n### `createbarchart()`\n\n```ts\nfunction createbarchart(\n container: htmlelement,\n config: barchartconfig,\n): charthandle<barseriesconfig[]>;\n```\n\nrenders grouped, stacked, grouped horizontal, or stacked horizontal bars.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `barchartconfig` | initial series, variant, axes, accessibility, and interaction settings |\n\n**returns:** `charthandle<barseriesconfig[]>`.\n\n```ts\nimport { createbarchart } from '@vielzeug/prism';\n\nconst chart = createbarchart(container, {\n series: [{ data: [{ key: 'open', value: 12 }], name: 'tasks' }],\n variant: 'grouped',\n});\n```\n\n \n\n### `createpiechart()`\n\n```ts\nfunction createpiechart(\n container: htmlelement,\n config: piechartconfig,\n): charthandle<piesliceconfig[]>;\n```\n\nrenders pie, donut, or semi circle slices.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `piechartconfig` | initial slices, variant, geometry, accessibility, and interaction settings |\n\n**returns:** `charthandle<piesliceconfig[]>`.\n\n```ts\nimport { createpiechart } from '@vielzeug/prism';\n\nconst chart = createpiechart(container, {\n data: [{ label: 'complete', value: 72 }, { label: 'remaining', value: 28 }],\n variant: 'donut',\n});\n```\n\n \n\n### `createradarchart()`\n\n```ts\nfunction createradarchart(\n container: htmlelement,\n config: radarchartconfig,\n): charthandle<radarseriesconfig[]>;\n```\n\nrenders one closed shape per series across three or more axes. `update()` replaces the series; the axes are fixed at creation.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `radarchartconfig` | axes, series, scale, grid, appearance, accessibility, and interaction settings |\n\n| field | type | default | description |\n| | | | |\n| `axes` | `radaraxisconfig[]` | n/a | ordered axes, drawn clockwise; needs at least 3 |\n| `series` | `radarseriesconfig[]` | n/a | values keyed by `axis.key` |\n| `domain` | `[number, number]` | `[0, nice max]` | shared value range; `axis.min`/`axis.max` override it per axis |\n| `grid` | `boolean \\| radargridconfig` | `true` | rings, bands, and level labels; `false` hides the grid |\n| `fill` | `'gradient' \\| 'none' \\| 'solid'` | `'solid'` | shape fill; `gradient` deepens toward the rim |\n| `curve` | `'linear' \\| 'rounded'` | `'linear'` | straight edges or a smooth closed curve |\n| `showpoints` | `boolean` | `true` | vertex dots; a missing value shows a hollow dot at the centre |\n| `showvalues` | `boolean` | `false` | print each value beside its vertex |\n| `startangle` | `number` | `0` | degrees clockwise from 12 o'clock for the first axis |\n| `onhover` | `(event: radarevent \\| null) => void` | n/a | nearest axis under the pointer or keyboard focus |\n| `onclick` | `(event: radarevent) => void` | n/a | axis clicked, or activated with enter/space |\n\n**returns:** `charthandle<radarseriesconfig[]>`.\n\n```ts\nimport { createradarchart } from '@vielzeug/prism';\n\nconst chart = createradarchart(container, {\n a11y: { arialabel: 'hero stats' },\n axes: [\n { key: 'str', label: 'strength' },\n { key: 'agi', label: 'agility' },\n { key: 'end', label: 'endurance' },\n ],\n domain: [0, 10],\n series: [{ data: [{ key: 'str', value: 8 }, { key: 'agi', value: 6 }, { key: 'end', value: 9 }], name: 'adam' }],\n});\n```\n\nhovering or arrowing onto an axis highlights its spoke and reports every series' value on it. without a tooltip, the chart announces the values through its own `role=\"status\"` region. a value for an unknown axis, duplicate axis keys, or fewer than 3 axes warns in development.\n\n \n\n### `createsparkline()`\n\n```ts\nfunction createsparkline(\n container: htmlelement,\n config: sparklineconfig,\n): charthandle<number[] | stacksegment[]>;\n```\n\nrenders a compact chart without axes, margin, or legend.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg |\n| `config` | `sparklineconfig` | initial values, variant, appearance, accessibility, and callbacks |\n\n**returns:** `charthandle<number[] | stacksegment[]>`.\n\n```ts\nimport { createsparkline } from '@vielzeug/prism';\n\nconst chart = createsparkline(container, { data: [2, 5, 3, 8], variant: 'area' });\nchart.update([2, 5, 3, 8, 13]);\n```\n\n## scale factories\n\n### `linearscale()`\n\n```ts\nfunction linearscale(config: linearscaleconfig): scale<number>;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `[number, number]` | n/a | input extent |\n| `range` | `[number, number]` | n/a | output extent |\n| `clamp` | `boolean` | `false` | clamp mapped and inverted values |\n| `nice` | `boolean` | `true` | expand the domain to rounded boundaries |\n\n**returns:** `scale<number>`.\n\n```ts\nimport { linearscale } from '@vielzeug/prism';\n\nconst scale = linearscale({ domain: [0, 100], range: [0, 500] });\nscale.map(50); // 250\n```\n\n \n\n### `timescale()`\n\n```ts\nfunction timescale(config: timescaleconfig): scale<date>;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `[date, date]` | n/a | input date extent |\n| `range` | `[number, number]` | n/a | output extent |\n| `nice` | `boolean` | `true` | expand the domain to rounded interval boundaries |\n\n**returns:** `scale<date>`.\n\n```ts\nimport { timescale } from '@vielzeug/prism';\n\nconst scale = timescale({\n domain: [new date('2026 01 01'), new date('2026 12 31')],\n range: [0, 500],\n});\n```\n\n \n\n### `bandscale()`\n\n```ts\nfunction bandscale(config: bandscaleconfig): bandscale;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `string[]` | n/a | ordered categories |\n| `range` | `[number, number]` | n/a | output extent |\n| `padding` | `number` | `0.1` | inner gap ratio |\n| `paddingouter` | `number` | `padding` | outer gap ratio |\n\n**returns:** `bandscale`.\n\n```ts\nimport { bandscale } from '@vielzeug/prism';\n\nconst scale = bandscale({ domain: ['a', 'b'], range: [0, 200] });\n```\n\n## theme utilities\n\n### `settheme()`\n\n```ts\nfunction settheme(theme: prismtheme): void;\n```\n\nsets prism color, font, and grid custom properties on `document.documentelement`.\n\n### `resettheme()`\n\n```ts\nfunction resettheme(): void;\n```\n\nremoves every custom property managed by `settheme()`.\n\n### `seriescolor()`\n\n```ts\nfunction seriescolor(index: number, override?: string): string;\n```\n\nreturns `override` when provided; otherwise returns `var( prism color n)` with an eight color wrap.\n\n## types\n\n### core types\n\n```ts\ninterface charthandle<tdata = unknown> {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly el: svgsvgelement;\n update(data: tdata): void;\n dispose(): void;\n [symbol.dispose](): void;\n}\n\ninterface chartmargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\ninterface chartdimensions {\n width: number;\n height: number;\n margin: chartmargin;\n}\n\ntype charta11y =\n | { readonly decorative: true }\n | { readonly arialabel: string; readonly decorative?: false };\n```\n\n`update()` renders synchronously and throws `prismrendererror` after disposal. omitting `a11y` makes the svg decorative with `aria hidden=\"true\"`.\n\n \n\n### data types\n\n```ts\ninterface datum<tkey extends date | number | string = date | number | string> {\n key: tkey;\n value: number;\n meta?: record<string, unknown>;\n dash?: string; // stroke dasharray for this datum's marks, e.g. '5 5'\n opacity?: number; // opacity multiplier 0 1 for this datum's marks, e.g. 0.4\n}\n\ntype continuousdatum = datum<date | number>;\n\ninterface series<tdatum extends datum = datum> {\n name: string;\n data: tdatum[];\n color?: string;\n id?: string; // stable data series id on the series group; defaults to series <index>\n}\n\ninterface chartevent {\n datum: datum;\n series: series;\n originalevent: event;\n values?: seriesvalue[]; // every series at the same key (line, area, bar)\n}\n\ninterface seriesvalue {\n datum: datum | undefined;\n series: series;\n}\n```\n\nline and area series use `continuousdatum`; bar series accept the complete `datum` key range. on line, area, and bar charts, `datum`/`series` name the series nearest the pointer, and `values` lists every series at that key (a missing value is `undefined`). `originalevent` is a `mouseevent`, or a `keyboardevent` for keyboard navigation.\n\n#### per datum presentation\n\n`dash` and `opacity` style an individual datum's marks without splitting the series — the usual case is a forecast tail where points past \"today\" are faded or dashed. they are styling only: interaction, tooltip, and accessibility behavior never change. support is per chart type:\n\n| chart | `dash` | `opacity` |\n| | | |\n| bar | `stroke dasharray` on the datum's `rect`, plus a 1px series color stroke so the dash is visible on the fill. | element `opacity` on the datum's `rect`. |\n| line | the segment from this datum to the next (chart.js style segments). the last datum's `dash` has no segment and is ignored. | that same segment, plus this datum's own point marker. |\n| area | the top line's segment from this datum to the next (last datum ignored). the fill keeps the series level treatment. | same segment on the top line. |\n| sparkline, pie, radar | ignored. | ignored. |\n\nconsecutive datums that share one `dash`/`opacity` render as a single path run. absent fields keep the series/theme default, and a series with no presentation fields renders byte identically to a chart that never used them. see [forecast / emphasis styling](./usage.md#forecast emphasis styling).\n\n#### stable series identity\n\n`series.id` is rendered as `data series id` on the chart's series group (`.prism line series`, `.prism bar series`, `.prism area series`, `.prism radar series`) so tooltips, tests, and external css can address a series without positional assumptions. characters outside `[a za z0 9_ ]` are replaced with ` `. it defaults to `series <index>`, which only stays stable while the series order does — set `id` to survive reordering.\n\n \n\n### chart configurations\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 tooltip?: boolean | tooltipconfig;\n transition?: transitionconfig;\n xaxis?: xaxisconfig;\n yaxis?: yaxisconfig;\n}\n\ninterface linechartconfig extends basechartconfig {\n crosshair?: boolean | crosshairconfig;\n rightyaxis?: omit<yaxisconfig, 'position' | 'grid'>;\n series: lineseriesconfig[];\n}\n\ninterface areachartconfig extends basechartconfig {\n crosshair?: boolean | crosshairconfig;\n series: areaseriesconfig[];\n}\n\ninterface barchartconfig extends basechartconfig {\n series: barseriesconfig[];\n variant?: barvariant;\n}\n\ninterface piechartconfig\n extends omit<basechartconfig, 'margin' | 'onclick' | 'onhover' | 'xaxis' | 'yaxis'> {\n data: piesliceconfig[];\n variant?: pievariant;\n innerradius?: number;\n cornerradius?: number;\n padpixels?: number;\n onclick?: (slice: piesliceconfig, index: number) => void;\n onhover?: (slice: piesliceconfig | null, index: number | null) => void;\n}\n\ninterface radarchartconfig\n extends omit<basechartconfig, 'margin' | 'onclick' | 'onhover' | 'xaxis' | 'yaxis'> {\n axes: radaraxisconfig[];\n series: radarseriesconfig[];\n curve?: 'linear' | 'rounded';\n domain?: [number, number];\n fill?: 'gradient' | 'none' | 'solid';\n grid?: boolean | radargridconfig;\n showpoints?: boolean;\n showvalues?: boolean;\n startangle?: number;\n onclick?: (event: radarevent) => void;\n onhover?: (event: radarevent | null) => void;\n}\n\ninterface sparklineconfig {\n data: number[] | stacksegment[];\n variant?: sparklinevariant;\n a11y?: charta11y;\n color?: string;\n cornerradius?: number;\n curve?: 'linear' | 'monotone' | 'step';\n fillopacity?: number; // overrides prism spark fill opacity (area variant)\n onclick?: (index: number, value: number) => void;\n onhover?: (index: number | null, value: number | null) => void;\n padpixels?: number;\n showendpoint?: boolean; // default true: dot on the latest value (line, area)\n strokewidth?: number;\n transition?: transitionconfig;\n}\n```\n\n \n\n### series and slice configurations\n\n```ts\ninterface lineseriesconfig extends series<continuousdatum> {\n yaxis?: 'left' | 'right'; // defaults to 'left'\n curve?: 'linear' | 'monotone' | 'step';\n pointradius?: number;\n showpoints?: boolean;\n strokewidth?: number; // overrides prism line width\n}\n\ninterface areaseriesconfig extends series<continuousdatum> {\n curve?: 'linear' | 'monotone' | 'step';\n fill?: 'gradient' | 'solid'; // default 'gradient', fading toward the baseline\n fillopacity?: number; // overrides prism area opacity\n showline?: boolean;\n}\n\ninterface barseriesconfig extends series {\n borderradius?: number; // overrides prism bar radius; stacked bars stay square\n}\n\ninterface piesliceconfig {\n value: number;\n color?: string;\n label?: string;\n}\n\ninterface radaraxisconfig {\n key: string;\n label: string;\n min?: number;\n max?: number;\n format?: (value: number) => string;\n}\n\ntype radarseriesconfig = series<datum<string>>;\n\ninterface radargridconfig {\n bands?: boolean; // default true\n labels?: boolean; // default false; drawn only when every axis shares one domain\n levels?: number; // default 4\n shape?: 'circle' | 'polygon'; // default 'polygon'\n}\n\ninterface radarevent {\n axis: radaraxisconfig;\n index: number;\n values: radaraxisvalue[];\n originalevent: event;\n}\n\ninterface radaraxisvalue {\n datum: datum<string> | undefined;\n series: radarseriesconfig;\n}\n\ninterface stacksegment {\n value: number;\n color?: string;\n label?: string;\n}\n```\n\na custom radar `tooltip.render` receives `datum.meta` with `axis`, `values`, and the default `text`.\n\n \n\n### axis, interaction, and transition configurations\n\n```ts\ntype horizontalaxisposition = 'bottom' | 'top';\ntype verticalaxisposition = 'left' | 'right';\ntype axisposition = horizontalaxisposition | verticalaxisposition;\n\ninterface axisconfig<tposition extends axisposition = axisposition> {\n grid?: boolean | gridconfig;\n label?: string;\n position?: tposition;\n tickcount?: number;\n tickformat?: (value: date | number | string) => string;\n}\n\ntype xaxisconfig = axisconfig<horizontalaxisposition>;\ntype yaxisconfig = axisconfig<verticalaxisposition>;\n\ninterface gridconfig {\n color?: string;\n dash?: string;\n}\n\ninterface tooltipconfig {\n offset?: number;\n render?: (datum: datum, series: series) => node | string;\n}\n\ninterface crosshairconfig {\n horizontal?: boolean;\n snap?: boolean;\n vertical?: boolean;\n}\n\ninterface legendconfig {\n position?: legendposition;\n}\n\ntype legendposition = 'bottom' | 'left' | 'right' | 'top';\n\ninterface transitionconfig {\n duration?: number;\n easing?: easingfn | 'ease in' | 'ease in out' | 'ease out' | 'linear';\n preference?: 'always' | 'never' | 'system';\n stagger?: number;\n}\n\ntype easingfn = (t: number) => number;\n```\n\ntooltip strings are assigned through `textcontent`. return a `node` for structured content.\n\n \n\n### scale types\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\ninterface bandscale {\n readonly domain: readonly string[];\n readonly range: readonly [number, number];\n map(value: string): number;\n ticks(count?: number): string[];\n bandwidth(): number;\n gap(): number;\n}\n\ninterface linearscaleconfig {\n domain: [number, number];\n range: [number, number];\n clamp?: boolean;\n nice?: boolean;\n}\n\ninterface timescaleconfig {\n domain: [date, date];\n range: [number, number];\n nice?: boolean;\n}\n\ninterface bandscaleconfig {\n domain: string[];\n range: [number, number];\n padding?: number;\n paddingouter?: number;\n}\n```\n\n \n\n### remaining unions and theme type\n\n```ts\ntype barvariant = 'grouped' | 'grouped horizontal' | 'stacked' | 'stacked horizontal';\ntype pievariant = 'donut' | 'pie' | 'semi';\ntype sparklinevariant = 'area' | 'bar' | 'line' | 'stack';\n\ninterface prismtheme {\n colors?: string[];\n fontfamily?: string;\n gridcolor?: string;\n gridopacity?: number;\n}\n```\n\n## devtools\n\n### `debugchart()`\n\n```ts\nfunction debugchart<t extends charthandle>(handle: t, options?: debugchartoptions): t;\n\ninterface debugchartoptions {\n label?: string; // log prefix; defaults to 'chart', producing [prism:chart]\n}\n```\n\nlogs mount, resize, and disposal events to `console.debug` and returns the same handle. import it and `debugchartoptions` from `@vielzeug/prism/devtools`.\n\n## errors\n\n### `prismerror`\n\nbase class for every prism originated error. it supports standard `erroroptions` cause chaining.\n\n### `prismrendererror`\n\nextends `prismerror`. thrown for invalid containers, failed initial renders, and attempts to update disposed charts.\n",
|
|
943
|
-
"usage": " \ntitle: 'prism: usage guide'\ndescription: concepts, update patterns, and best practices for responsive svg charts with @vielzeug/prism.\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## updating data\n\nchart factories render synchronously and return a typed handle. call `update()` with the same data shape used by the factory when application state changes.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n series: [{ data: [{ key: 1, value: 10 }], name: 'live' }],\n});\n\nchart.update([\n {\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 20 },\n ],\n name: 'live',\n },\n]);\n```\n\n`update()` is state library neutral. call it from a framework effect, store subscription, event handler, or request callback. it throws after the chart is disposed instead of silently retaining detached state.\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### independent value axes\n\nassign a line series to `yaxis: 'right'` when its values need a different range. both axes share the same x scale, while tooltips retain the original values.\n\n```ts\nconst chart = createlinechart(container, {\n series: [\n { name: 'health', data: [{ key: 0, value: 30 }, { key: 1, value: 20 }] },\n { name: 'damage', yaxis: 'right', data: [{ key: 0, value: 0 }, { key: 1, value: 300 }] },\n ],\n yaxis: { label: 'health' },\n rightyaxis: { label: 'damage' },\n tooltip: true,\n});\n```\n\nthe primary axis is on the left when right axis series are present. grid configuration belongs to the primary axis. dual axis curve crossings do not imply equal numeric values. preserve each marker's axis assignment when calling `update()`.\n\n### time based x axis\n\nwhen data points use `date` objects for `key`, prism automatically applies a time scale. the domain spans exactly the first to the last date, so the series fills the plot width:\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```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\narea fills use a vertical gradient that fades toward the baseline. set `fill: 'solid'` for a flat fill:\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 fill: 'gradient', // 'gradient' (default) | 'solid'\n showline: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n});\n```\n\n`fillopacity` overrides the theme opacity for one series.\n\n## forecast / emphasis styling\n\na single series often mixes points that should look different — actuals plus a\nforecast tail, or a run that crossed a budget. rather than split the series,\ncarry `dash` and `opacity` on the individual datums. they are styling only:\ntooltips, hover, keyboard, and screen reader behavior never change.\n\nfor a forecast tail, map a parallel `types` array onto the datums:\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst types = ['real', 'real', 'forecast', 'forecast'];\n\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'cost',\n data: values.map((value, i) => ({\n key: months[i],\n value,\n ...(types[i] === 'forecast' ? { dash: '5 5', opacity: 0.4 } : {}),\n })),\n },\n ],\n});\n```\n\non a line chart, a datum's `dash` styles the segment from that point to the\nnext, so the forecast run renders dashed while the actuals stay solid; its\n`opacity` fades that segment and the point marker. the last datum's `dash` has\nno segment to paint and is ignored. on a bar chart, both fields apply to that\ndatum's own bar — a `dash` adds a 1px series color stroke so the dash reads on\nthe filled rect. on an area chart they style the top line only; the fill keeps\nits series level treatment.\n\nthe same mapping fades bars:\n\n```ts\nimport { createbarchart } from '@vielzeug/prism';\n\nconst chart = createbarchart(container, {\n series: [\n {\n name: 'cost',\n data: values.map((value, i) => ({\n key: months[i],\n value,\n ...(types[i] === 'forecast' ? { dash: '5 5', opacity: 0.4 } : {}),\n })),\n },\n ],\n});\n```\n\nbecause the fields live on the data, a normal `update(newseries)` re applies\nthem; removing the fields on the next update clears the styling. a series with\nno `dash`/`opacity` anywhere renders exactly as it did before these fields\nexisted.\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. a label that does not fit inside its slice is not drawn; the value stays available through the tooltip, legend, and keyboard announcements:\n\n```ts\n{ value: 42, label: 'direct' }\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### updating data\n\n```ts\nconst chart = createpiechart(container, {\n data: [\n { label: 'a', value: 40 },\n { label: 'b', value: 60 },\n ],\n variant: 'donut',\n});\n\nchart.update([\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## radar charts\n\n`createradarchart` compares several values on one shape, such as a hero's stats. each series supplies values keyed by `axis.key`:\n\n```ts\nimport { createradarchart } from '@vielzeug/prism';\n\nconst axes = [\n { key: 'str', label: 'strength' },\n { key: 'agi', label: 'agility' },\n { key: 'int', label: 'intellect' },\n { key: 'end', label: 'endurance' },\n { key: 'spd', label: 'speed', max: 5 },\n];\n\nconst chart = createradarchart(container, {\n a11y: { arialabel: 'hero stats' },\n axes,\n domain: [0, 10],\n fill: 'gradient',\n showvalues: true,\n series: [\n {\n name: 'adam',\n data: [\n { key: 'str', value: 8 },\n { key: 'agi', value: 6 },\n { key: 'int', value: 4 },\n { key: 'end', value: 9 },\n { key: 'spd', value: 3 },\n ],\n },\n ],\n});\n```\n\n### scales\n\nevery axis shares `domain` (or a nice range around the data when omitted). give an axis its own `min`/`max` when its stat uses a different range: above, speed runs 0 to 5 while the rest run 0 to 10, so each still fills the chart.\n\n### appearance\n\n| option | values | use it for |\n| | | |\n| `fill` | `'solid'`, `'gradient'`, `'none'` | `gradient` for a single hero; `solid` or `none` when overlaying several |\n| `curve` | `'linear'`, `'rounded'` | rounded reads softer; linear keeps exact vertices |\n| `grid.shape` | `'polygon'`, `'circle'` | polygon matches the axes; circle suits many axes |\n| `grid.levels` / `grid.bands` | number / boolean | ring count and alternate shading |\n| `grid.labels` | boolean | level values; drawn only when every axis shares one domain |\n| `showvalues` | boolean | value beside each vertex, formatted by `axis.format` |\n\n### comparing series\n\npass several series with `legend: true`. hovering one shape dims the others, and hovering an axis reports every series' value on it:\n\n```ts\ncreateradarchart(container, {\n axes,\n legend: true,\n series: [adam, eve],\n tooltip: true, // \"agility: adam 6, eve 9\"\n onhover: (event) => event && console.log(event.axis.label, event.values),\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' | 'stack' (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`**: gradient 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\nline and area sparklines mark the latest value with a dot. set `showendpoint: false` to hide it.\n\n### updating data\n\n```ts\nconst spark = createsparkline(container, {\n data: [12, 18, 14, 22],\n variant: 'area',\n});\n\nspark.update([12, 18, 14, 22, 30]);\n```\n\n### event hooks\n\nsparklines use simplified hooks: index based rather than full `chartevent`. with a hook set, a marker follows the hovered value:\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:** sparklines have no keyboard navigation. label one with `a11y.arialabel` only when the trend carries meaning, and state the key value in surrounding text.\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\ncategory axes show every label when the labels fit and thin them out only when they would overlap. set `tickcount` to choose the density yourself.\n\n## tooltips\n\nenable with `tooltip: true`. on line, area, bar, and radar charts the default tooltip compares every series at the active key: a title, then a colour swatch, name, and value per series. provide a custom `render` function to replace it. strings are rendered as text. return a dom node for structured content:\n\n```ts\n{\n tooltip: {\n offset: 12,\n render: (datum, series) => {\n const content = document.createelement('strong');\n content.textcontent = `${series.name}: ${datum.value.tolocalestring()}`;\n return content;\n },\n },\n}\n```\n\nprism never injects tooltip strings as html, so custom rendering does not require a sanitizer.\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 datum of the series nearest the pointer\n `series`: the corresponding `series` config\n `values`: every series at the same key (line, area, and bar charts)\n `originalevent`: the raw `mouseevent` or, for keyboard navigation, `keyboardevent`\n\n> **pie and radar events differ**: pie hooks receive `(slice: piesliceconfig, index: number)`, and radar hooks receive a `radarevent` describing an axis. see [`piechartconfig`](./api.md#chart configurations) and [`createradarchart()`](./api.md#createradarchart).\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, // ms delay between bars (bar charts) or series (radar charts)\n },\n}\n```\n\nall chart types use `requestanimationframe` based interpolation and skip animation when the user prefers reduced motion (`preference: 'system'`, the default). bar and radar charts apply `stagger` on entry; line, area, and pie charts ignore it.\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}` | colorblind safe palette | series color palette |\n| ` prism bg` | `transparent` | chart background |\n| ` prism axis color` | `hsl(215deg 16% 47%)` | axis lines and ticks |\n| ` prism grid color` | `hsl(220deg 10% 75%)` | grid lines |\n| ` prism text color` | `hsl(215deg 25% 27%)` | axis labels and text |\n| ` prism font family` | `system ui` | chart font |\n| ` prism font size` | `0.75rem` | label font size |\n| ` prism tooltip bg` | `hsl(222deg 47% 11%)` | tooltip background |\n| ` prism tooltip color` | `hsl(210deg 40% 98%)` | tooltip text |\n| ` prism tooltip radius` | `0.375rem` | tooltip border radius |\n| ` prism crosshair color` | `hsl(215deg 16% 47%)` | crosshair line |\n| ` prism crosshair dash` | `4 2` | crosshair dash pattern |\n\nthe palette follows wong (2011) so adjacent series stay distinguishable under common color vision deficiencies. dark mode applies automatically through `prefers color scheme` or an `html.dark` class.\n\nradar charts add their own tokens, each defaulting to a shared one:\n\n| token | default | description |\n| | | |\n| ` prism radar grid color` | ` prism grid color` | rings |\n| ` prism radar grid opacity` | `0.7` | ring and spoke opacity |\n| ` prism radar band fill` | ` prism grid color` | alternate ring shading |\n| ` prism radar band opacity` | `0.14` | shading opacity |\n| ` prism radar spoke color` | ` prism radar grid color` | spokes |\n| ` prism radar fill opacity` | ` prism area opacity` | solid shape fill |\n| ` prism radar stroke width` | ` prism line width` | shape outline |\n| ` prism radar point radius` | ` prism point radius` | vertex dots |\n| ` prism radar point radius active` | ` prism point radius hover` | dots on the active axis |\n| ` prism radar label color` / ` size` | secondary text tokens | axis labels |\n| ` prism radar value color` / ` size` | text tokens | vertex values |\n| ` prism radar dim opacity` | ` prism dim opacity` | other shapes while one is hovered |\n\nshared interaction and chart tokens:\n\n| token | default | description |\n| | | |\n| ` prism dim opacity` | `0.35` | series or slices outside the active one |\n| ` prism active point radius` | ` prism point radius hover` | points marked at the active key |\n| ` prism point ring` | `canvas` | ring separating active and end points from the line |\n| ` prism focus color` | ` prism color 1` | focus outline of a keyboard focused chart |\n| ` prism bar radius` | `4px` | bar corner radius (stacked bars stay square) |\n| ` prism bar band fill` / ` opacity` | grid colour / `0.18` | shaded active category |\n| ` prism bar dim opacity` | `0.55` | bars outside the active category |\n| ` prism area gradient start` / ` end` | `0.45` / `0.02` | gradient fill opacity at the line and the baseline |\n| ` prism spark gradient start` / ` end` | `0.35` / `0` | sparkline gradient fill |\n\nexplicit config such as `strokewidth`, `fillopacity`, or `borderradius` always wins over these tokens.\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 in flight transitions\n disconnects the `resizeobserver`\n removes the svg element, tooltip, and legend from the dom\n restores container styles changed for tooltip positioning\n is idempotent: safe to call multiple times\n\ncall `update()` only while the handle is active. updating a disposed chart throws `prismrendererror`.\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## accessibility\n\nlabel informative charts with `a11y: { arialabel }`. a labelled chart renders with `role=\"img\"`, is focusable, and supports keyboard navigation. charts without `a11y` are decorative and render with `aria hidden=\"true\"`.\n\n```ts\ncreatelinechart(container, {\n a11y: { arialabel: 'revenue by month' },\n series: [...],\n});\n```\n\nmark a chart decorative explicitly when surrounding text already states its meaning:\n\n```ts\ncreatesparkline(container, {\n a11y: { decorative: true },\n data: [...],\n});\n```\n\n### keyboard and announcements\n\nevery labelled chart except sparklines shares one keyboard model:\n\n| chart | arrow keys move between | `home` / `end` | announced |\n| | | | |\n| line, area | x positions | first / last position | `1: adam 3, eve 6` |\n| bar | categories | first / last category | `str: adam 8, eve 5` |\n| pie | slices | first / last slice | `wins: 15 (75%)` |\n| radar | axes (wrapping) | not supported | `agility: adam 6, eve 9` |\n\nthe first arrow press focuses the first item. `enter` or `space` fires `onclick` for the focused item, and `escape` clears it. the tooltip speaks the text when one is enabled; otherwise the chart's own `role=\"status\"` region (`.prism live`) does, so each value is announced once.\n\n### hover feedback\n\nthe active key is shown the same way on every chart. line and area charts mark each series' point and dim the series farther from the pointer. bar charts shade the category band and dim the other categories. pie charts dim the other slices. radar charts highlight the active spoke and its points, and dim the other shapes while one is hovered.\n\n## framework integration\n\nprism has no framework adapter. connect the same three operations to your framework's lifecycle: create after mount, update when state changes, and dispose before unmount.\n\n```ts\nimport { createlinechart, type charthandle, type continuousdatum, type lineseriesconfig } from '@vielzeug/prism';\n\nlet chart: charthandle<lineseriesconfig[]> | undefined;\n\nexport function mountchart(container: htmlelement, data: continuousdatum[]): void {\n chart = createlinechart(container, { series: [{ data, name: 'series' }] });\n}\n\nexport function updatechart(data: continuousdatum[]): void {\n chart?.update([{ data, name: 'series' }]);\n}\n\nexport function unmountchart(): void {\n chart?.dispose();\n chart = undefined;\n}\n```\n\n## working with other vielzeug libraries\n\n### with ripple\n\nkeep state ownership in ripple and connect it to prism through an explicit subscription.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([{ key: 1, value: 10 }]);\nconst toseries = () => [{ data: data.value, name: 'series' }];\nconst chart = createlinechart(container, { series: toseries() });\nconst unsubscribe = data.subscribe(() => chart.update(toseries()));\n\n// cleanup both owners together.\nunsubscribe();\nchart.dispose();\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 { createbarchart } from '@vielzeug/prism';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({\n load: async ({ page, pagesize, signal }) => {\n const result = await api.stats.list({ page, pagesize }, { signal });\n return { items: result.data, totalitems: result.total };\n },\n});\nconst toseries = (state) => [\n {\n data: state.items.map((item) => ({ key: item.label, value: item.count })),\n name: 'series',\n },\n];\nconst chart = createbarchart(container, { series: toseries(source.state) });\nconst unsubscribe = source.subscribe((state) => chart.update(toseries(state)));\nvoid source.reload().catch(() => undefined);\n```\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 transitions, disconnect resize observation, and remove dom nodes.\n call `chart.update(data)` from your application state boundary; prism does not require or own a state library.\n set `a11y: { arialabel: '…' }` on every chart that conveys meaningful data: unlabelled charts are hidden from assistive technology and receive no keyboard navigation.\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",
|
|
941
|
+
"index": " \ntitle: 'prism: responsive svg data visualization'\ndescription: responsive svg charts with explicit updates, keyboard and screen reader support, and css theming.\npackage: prism\ncategory: ui\nkeywords: [chart, svg, visualization, responsive, line chart, bar chart, area chart, pie chart, radar chart, sparkline, accessibility, typescript]\nrelated: [refine, orbit]\nexports:\n [\n createlinechart,\n createbarchart,\n createareachart,\n createpiechart,\n createradarchart,\n createsparkline,\n settheme,\n prismerror,\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 create or mutate the chart.js instance when data changes\n});\n\n// after: prism, responsive svg with an explicit update boundary\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'users by day' },\n series: [\n {\n data: [\n { key: 1, value: 12 },\n { key: 2, value: 40 },\n { key: 3, value: 28 },\n ],\n name: 'users',\n },\n ],\n tooltip: true,\n});\n\nchart.update([\n {\n data: [\n { key: 1, value: 12 },\n { key: 2, value: 40 },\n { key: 3, value: 28 },\n { key: 4, value: 65 },\n ],\n name: 'users',\n },\n]);\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| zero dependencies | <ore icon name=\"x\" size=\"16\"></ore icon> (orbit) | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon> (modular) |\n| renderer | svg | canvas | canvas | svg/canvas |\n| data updates | explicit `update()` | instance mutation | series mutation | 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| framework neutral | <ore icon name=\"check\" size=\"16\"></ore icon> | <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| 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| keyboard navigation | <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, framework neutral charts with explicit updates and svg output that can be styled with css. it fits dashboards, admin panels, and data heavy applications.\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 '@vielzeug/prism/theme';\n\nconst chart = createlinechart(document.getelementbyid('chart')!, {\n a11y: { arialabel: 'revenue by month' },\n series: [\n {\n color: '#3b82f6',\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 25 },\n { key: 3, value: 18 },\n { key: 4, value: 32 },\n ],\n name: 'revenue',\n },\n ],\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 chart data explicitly\nchart.update([\n {\n color: '#3b82f6',\n data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, { key: 5, value: 28 }],\n name: 'revenue',\n },\n]);\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 a gradient or solid fill\n **`createpiechart(container, config)`**: pie, donut, or semi circle donut chart; labels that do not fit their slice are hidden\n **`createradarchart(container, config)`**: radar chart for comparing values across 3+ axes, with per axis ranges\n **`createsparkline(container, config)`**: minimal inline line, area, bar, or stack chart\n **`linearscale`, `timescale`, `bandscale`**: standalone scales with nice tick generation\n **`charthandle.update(data)`**: replace chart data synchronously without coupling to a state library\n **`settheme(theme)` / `resettheme()`**: apply or clear custom colors, font, and grid tokens at runtime\n **series comparison**: hover or keyboard focus reports every series at the active key in the tooltip, `onhover`, and screen reader announcements\n **keyboard navigation**: arrow keys, `enter`, and `escape` on every labelled chart except sparklines\n **css custom properties**: full theme control via ` prism *` tokens, with dark mode built in\n **responsive**: auto resizes via `resizeobserver`\n **`charthandle.tap()`**: observe resize and dispose events outside the render path, with zero cost when untapped\n **`symbol.dispose`**: explicit resource management following the tc39 proposal\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 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",
|
|
942
|
+
"api": " \ntitle: 'prism: api reference'\ndescription: complete chart, scale, theme, handle, configuration, and error contracts for @vielzeug/prism.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createlinechart()` | render a line chart | sync | line keys must be numbers or dates |\n| `createareachart()` | render an area chart | sync | line keys must be numbers or dates |\n| `createbarchart()` | render grouped or stacked bars | sync | stacked negative values are clamped to zero |\n| `createpiechart()` | render pie, donut, or semi circle slices | sync | `onhover` receives `null` when the pointer leaves |\n| `createradarchart()` | compare values across 3+ axes, such as hero stats | sync | events report a whole axis, not one series |\n| `createsparkline()` | render an inline line, area, bar, or stack | sync | omitted `a11y` makes the chart decorative |\n| `linearscale()` | create a numeric scale | sync | `nice` defaults to `true` |\n| `timescale()` | create a date scale | sync | invalid dates produce an invalid domain |\n| `bandscale()` | create a categorical scale | sync | unknown categories map to `0` and warn in development |\n| `settheme()` | set prism css custom properties | sync | values apply to `document.documentelement` |\n| `resettheme()` | remove prism theme overrides | sync | removes only properties managed by `settheme()` |\n| `seriescolor()` | resolve a series palette color | sync | palette indexes wrap after eight colors |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/prism` | chart factories, scales, theme helpers, errors, and public types |\n| `@vielzeug/prism/theme` | default css custom properties and dark mode values |\n\n## chart factories\n\n### `createlinechart()`\n\n```ts\nfunction createlinechart(\n container: htmlelement,\n config: linechartconfig,\n): charthandle<lineseriesconfig[]>;\n```\n\nrenders a line chart and returns a handle that replaces the complete series array through `update()`.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `linechartconfig` | initial series, axes, interaction, accessibility, and transition settings |\n\n**returns:** `charthandle<lineseriesconfig[]>`.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n a11y: { arialabel: 'revenue by month' },\n series: [{ data: [{ key: 1, value: 10 }], name: 'revenue' }],\n});\n\nchart.update([{ data: [{ key: 2, value: 20 }], name: 'revenue' }]);\n```\n\n \n\n### `createareachart()`\n\n```ts\nfunction createareachart(\n container: htmlelement,\n config: areachartconfig,\n): charthandle<areaseriesconfig[]>;\n```\n\nrenders an area chart and returns a handle that replaces the complete series array through `update()`.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `areachartconfig` | initial series, axes, interaction, accessibility, and transition settings |\n\n**returns:** `charthandle<areaseriesconfig[]>`.\n\n```ts\nimport { createareachart } from '@vielzeug/prism';\n\nconst chart = createareachart(container, {\n series: [{ data: [{ key: 1, value: 10 }], name: 'revenue' }],\n});\n```\n\n \n\n### `createbarchart()`\n\n```ts\nfunction createbarchart(\n container: htmlelement,\n config: barchartconfig,\n): charthandle<barseriesconfig[]>;\n```\n\nrenders grouped, stacked, grouped horizontal, or stacked horizontal bars.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `barchartconfig` | initial series, variant, axes, accessibility, and interaction settings |\n\n**returns:** `charthandle<barseriesconfig[]>`.\n\n```ts\nimport { createbarchart } from '@vielzeug/prism';\n\nconst chart = createbarchart(container, {\n series: [{ data: [{ key: 'open', value: 12 }], name: 'tasks' }],\n variant: 'grouped',\n});\n```\n\n \n\n### `createpiechart()`\n\n```ts\nfunction createpiechart(\n container: htmlelement,\n config: piechartconfig,\n): charthandle<piesliceconfig[]>;\n```\n\nrenders pie, donut, or semi circle slices.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `piechartconfig` | initial slices, variant, geometry, accessibility, and interaction settings |\n\n**returns:** `charthandle<piesliceconfig[]>`.\n\n```ts\nimport { createpiechart } from '@vielzeug/prism';\n\nconst chart = createpiechart(container, {\n data: [{ label: 'complete', value: 72 }, { label: 'remaining', value: 28 }],\n variant: 'donut',\n});\n```\n\n \n\n### `createradarchart()`\n\n```ts\nfunction createradarchart(\n container: htmlelement,\n config: radarchartconfig,\n): charthandle<radarseriesconfig[]>;\n```\n\nrenders one closed shape per series across three or more axes. `update()` replaces the series; the axes are fixed at creation.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg and optional overlays |\n| `config` | `radarchartconfig` | axes, series, scale, grid, appearance, accessibility, and interaction settings |\n\n| field | type | default | description |\n| | | | |\n| `axes` | `radaraxisconfig[]` | n/a | ordered axes, drawn clockwise; needs at least 3 |\n| `series` | `radarseriesconfig[]` | n/a | values keyed by `axis.key` |\n| `domain` | `[number, number]` | `[0, nice max]` | shared value range; `axis.min`/`axis.max` override it per axis |\n| `grid` | `boolean \\| radargridconfig` | `true` | rings, bands, and level labels; `false` hides the grid |\n| `fill` | `'gradient' \\| 'none' \\| 'solid'` | `'solid'` | shape fill; `gradient` deepens toward the rim |\n| `curve` | `'linear' \\| 'rounded'` | `'linear'` | straight edges or a smooth closed curve |\n| `showpoints` | `boolean` | `true` | vertex dots; a missing value shows a hollow dot at the centre |\n| `showvalues` | `boolean` | `false` | print each value beside its vertex |\n| `startangle` | `number` | `0` | degrees clockwise from 12 o'clock for the first axis |\n| `onhover` | `(event: radarevent \\| null) => void` | n/a | nearest axis under the pointer or keyboard focus |\n| `onclick` | `(event: radarevent) => void` | n/a | axis clicked, or activated with enter/space |\n\n**returns:** `charthandle<radarseriesconfig[]>`.\n\n```ts\nimport { createradarchart } from '@vielzeug/prism';\n\nconst chart = createradarchart(container, {\n a11y: { arialabel: 'hero stats' },\n axes: [\n { key: 'str', label: 'strength' },\n { key: 'agi', label: 'agility' },\n { key: 'end', label: 'endurance' },\n ],\n domain: [0, 10],\n series: [{ data: [{ key: 'str', value: 8 }, { key: 'agi', value: 6 }, { key: 'end', value: 9 }], name: 'adam' }],\n});\n```\n\nhovering or arrowing onto an axis highlights its spoke and reports every series' value on it. without a tooltip, the chart announces the values through its own `role=\"status\"` region. a value for an unknown axis, duplicate axis keys, or fewer than 3 axes warns in development.\n\n \n\n### `createsparkline()`\n\n```ts\nfunction createsparkline(\n container: htmlelement,\n config: sparklineconfig,\n): charthandle<number[] | stacksegment[]>;\n```\n\nrenders a compact chart without axes, margin, or legend.\n\n| parameter | type | description |\n| | | |\n| `container` | `htmlelement` | host that receives the svg |\n| `config` | `sparklineconfig` | initial values, variant, appearance, accessibility, and callbacks |\n\n**returns:** `charthandle<number[] | stacksegment[]>`.\n\n```ts\nimport { createsparkline } from '@vielzeug/prism';\n\nconst chart = createsparkline(container, { data: [2, 5, 3, 8], variant: 'area' });\nchart.update([2, 5, 3, 8, 13]);\n```\n\n## scale factories\n\n### `linearscale()`\n\n```ts\nfunction linearscale(config: linearscaleconfig): scale<number>;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `[number, number]` | n/a | input extent |\n| `range` | `[number, number]` | n/a | output extent |\n| `clamp` | `boolean` | `false` | clamp mapped and inverted values |\n| `nice` | `boolean` | `true` | expand the domain to rounded boundaries |\n\n**returns:** `scale<number>`.\n\n```ts\nimport { linearscale } from '@vielzeug/prism';\n\nconst scale = linearscale({ domain: [0, 100], range: [0, 500] });\nscale.map(50); // 250\n```\n\n \n\n### `timescale()`\n\n```ts\nfunction timescale(config: timescaleconfig): scale<date>;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `[date, date]` | n/a | input date extent |\n| `range` | `[number, number]` | n/a | output extent |\n| `nice` | `boolean` | `true` | expand the domain to rounded interval boundaries |\n\n**returns:** `scale<date>`.\n\n```ts\nimport { timescale } from '@vielzeug/prism';\n\nconst scale = timescale({\n domain: [new date('2026 01 01'), new date('2026 12 31')],\n range: [0, 500],\n});\n```\n\n \n\n### `bandscale()`\n\n```ts\nfunction bandscale(config: bandscaleconfig): bandscale;\n```\n\n| field | type | default | description |\n| | | | |\n| `domain` | `string[]` | n/a | ordered categories |\n| `range` | `[number, number]` | n/a | output extent |\n| `padding` | `number` | `0.1` | inner gap ratio |\n| `paddingouter` | `number` | `padding` | outer gap ratio |\n\n**returns:** `bandscale`.\n\n```ts\nimport { bandscale } from '@vielzeug/prism';\n\nconst scale = bandscale({ domain: ['a', 'b'], range: [0, 200] });\n```\n\n## theme utilities\n\n### `settheme()`\n\n```ts\nfunction settheme(theme: prismtheme): void;\n```\n\nsets prism color, font, and grid custom properties on `document.documentelement`.\n\n### `resettheme()`\n\n```ts\nfunction resettheme(): void;\n```\n\nremoves every custom property managed by `settheme()`.\n\n### `seriescolor()`\n\n```ts\nfunction seriescolor(index: number, override?: string): string;\n```\n\nreturns `override` when provided; otherwise returns `var( prism color n)` with an eight color wrap.\n\n## types\n\n### core types\n\n```ts\ninterface charthandle<tdata = unknown> {\n readonly disposalsignal: abortsignal;\n readonly disposed: boolean;\n readonly el: svgsvgelement;\n tap(handler: (event: prismevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n update(data: tdata): void;\n dispose(): void;\n [symbol.dispose](): void;\n}\n\ntype prismevent =\n | { readonly height: number; readonly type: 'resize'; readonly width: number }\n | { readonly type: 'dispose' };\n\ninterface chartmargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\ninterface chartdimensions {\n width: number;\n height: number;\n margin: chartmargin;\n}\n\ntype charta11y =\n | { readonly decorative: true }\n | { readonly arialabel: string; readonly decorative?: false };\n```\n\n`update()` renders synchronously and throws `prismrendererror` after disposal. omitting `a11y` makes the svg decorative with `aria hidden=\"true\"`.\n\n`tap()` observes runtime behavior outside the render path: it receives `resize` and `dispose` events (see `prismevent`). it returns an unsubscribe function; pass `options.signal` to detach automatically. handler errors are swallowed, and tapping a disposed handle returns a no op unsubscribe.\n\n \n\n### data types\n\n```ts\ninterface datum<tkey extends date | number | string = date | number | string> {\n key: tkey;\n value: number;\n meta?: record<string, unknown>;\n dash?: string; // stroke dasharray for this datum's marks, e.g. '5 5'\n opacity?: number; // opacity multiplier 0 1 for this datum's marks, e.g. 0.4\n}\n\ntype continuousdatum = datum<date | number>;\n\ninterface series<tdatum extends datum = datum> {\n name: string;\n data: tdatum[];\n color?: string;\n id?: string; // stable data series id on the series group; defaults to series <index>\n}\n\ninterface chartevent {\n datum: datum;\n series: series;\n originalevent: event;\n values?: seriesvalue[]; // every series at the same key (line, area, bar)\n}\n\ninterface seriesvalue {\n datum: datum | undefined;\n series: series;\n}\n```\n\nline and area series use `continuousdatum`; bar series accept the complete `datum` key range. on line, area, and bar charts, `datum`/`series` name the series nearest the pointer, and `values` lists every series at that key (a missing value is `undefined`). `originalevent` is a `mouseevent`, or a `keyboardevent` for keyboard navigation.\n\n#### per datum presentation\n\n`dash` and `opacity` style an individual datum's marks without splitting the series — the usual case is a forecast tail where points past \"today\" are faded or dashed. they are styling only: interaction, tooltip, and accessibility behavior never change. support is per chart type:\n\n| chart | `dash` | `opacity` |\n| | | |\n| bar | `stroke dasharray` on the datum's `rect`, plus a 1px series color stroke so the dash is visible on the fill. | element `opacity` on the datum's `rect`. |\n| line | the segment from this datum to the next (chart.js style segments). the last datum's `dash` has no segment and is ignored. | that same segment, plus this datum's own point marker. |\n| area | the top line's segment from this datum to the next (last datum ignored). the fill keeps the series level treatment. | same segment on the top line. |\n| sparkline, pie, radar | ignored. | ignored. |\n\nconsecutive datums that share one `dash`/`opacity` render as a single path run. absent fields keep the series/theme default, and a series with no presentation fields renders byte identically to a chart that never used them. see [forecast / emphasis styling](./usage.md#forecast emphasis styling).\n\n#### stable series identity\n\n`series.id` is rendered as `data series id` on the chart's series group (`.prism line series`, `.prism bar series`, `.prism area series`, `.prism radar series`) so tooltips, tests, and external css can address a series without positional assumptions. characters outside `[a za z0 9_ ]` are replaced with ` `. it defaults to `series <index>`, which only stays stable while the series order does — set `id` to survive reordering.\n\n \n\n### chart configurations\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 tooltip?: boolean | tooltipconfig;\n transition?: transitionconfig;\n xaxis?: xaxisconfig;\n yaxis?: yaxisconfig;\n}\n\ninterface linechartconfig extends basechartconfig {\n crosshair?: boolean | crosshairconfig;\n rightyaxis?: omit<yaxisconfig, 'position' | 'grid'>;\n series: lineseriesconfig[];\n}\n\ninterface areachartconfig extends basechartconfig {\n crosshair?: boolean | crosshairconfig;\n series: areaseriesconfig[];\n}\n\ninterface barchartconfig extends basechartconfig {\n series: barseriesconfig[];\n variant?: barvariant;\n}\n\ninterface piechartconfig\n extends omit<basechartconfig, 'margin' | 'onclick' | 'onhover' | 'xaxis' | 'yaxis'> {\n data: piesliceconfig[];\n variant?: pievariant;\n innerradius?: number;\n cornerradius?: number;\n padpixels?: number;\n onclick?: (event: pieevent) => void;\n onhover?: (event: pieevent | null) => void;\n}\n\ninterface radarchartconfig\n extends omit<basechartconfig, 'margin' | 'onclick' | 'onhover' | 'xaxis' | 'yaxis'> {\n axes: radaraxisconfig[];\n series: radarseriesconfig[];\n curve?: 'linear' | 'rounded';\n domain?: [number, number];\n fill?: 'gradient' | 'none' | 'solid';\n grid?: boolean | radargridconfig;\n showpoints?: boolean;\n showvalues?: boolean;\n startangle?: number;\n onclick?: (event: radarevent) => void;\n onhover?: (event: radarevent | null) => void;\n}\n\ninterface sparklineconfig {\n data: number[] | stacksegment[];\n variant?: sparklinevariant;\n a11y?: charta11y;\n color?: string;\n cornerradius?: number;\n curve?: 'linear' | 'monotone' | 'step';\n fillopacity?: number; // overrides prism spark fill opacity (area variant)\n onclick?: (event: sparklineevent) => void;\n onhover?: (event: sparklineevent | null) => void;\n padpixels?: number;\n showendpoint?: boolean; // default true: dot on the latest value (line, area)\n strokewidth?: number;\n transition?: transitionconfig;\n}\n```\n\n \n\n### series and slice configurations\n\n```ts\ninterface lineseriesconfig extends series<continuousdatum> {\n yaxis?: 'left' | 'right'; // defaults to 'left'\n curve?: 'linear' | 'monotone' | 'step';\n pointradius?: number;\n showpoints?: boolean;\n strokewidth?: number; // overrides prism line width\n}\n\ninterface areaseriesconfig extends series<continuousdatum> {\n curve?: 'linear' | 'monotone' | 'step';\n fill?: 'gradient' | 'solid'; // default 'gradient', fading toward the baseline\n fillopacity?: number; // overrides prism area opacity\n showline?: boolean;\n}\n\ninterface barseriesconfig extends series {\n borderradius?: number; // overrides prism bar radius; stacked bars stay square\n}\n\ninterface piesliceconfig {\n value: number;\n color?: string;\n label?: string;\n}\n\ninterface pieevent {\n index: number;\n originalevent: event;\n slice: piesliceconfig;\n}\n\ninterface radaraxisconfig {\n key: string;\n label: string;\n min?: number;\n max?: number;\n format?: (value: number) => string;\n}\n\ntype radarseriesconfig = series<datum<string>>;\n\ninterface radargridconfig {\n bands?: boolean; // default true\n labels?: boolean; // default false; drawn only when every axis shares one domain\n levels?: number; // default 4\n shape?: 'circle' | 'polygon'; // default 'polygon'\n}\n\ninterface radarevent {\n axis: radaraxisconfig;\n index: number;\n values: radaraxisvalue[];\n originalevent: event;\n}\n\ninterface radaraxisvalue {\n datum: datum<string> | undefined;\n series: radarseriesconfig;\n}\n\ninterface stacksegment {\n value: number;\n color?: string;\n label?: string;\n}\n\ninterface sparklineevent {\n index: number;\n originalevent: event;\n value: number;\n}\n```\n\na custom radar `tooltip.render` receives `datum.meta` with `axis`, `values`, and the default `text`.\n\n \n\n### axis, interaction, and transition configurations\n\n```ts\ntype horizontalaxisposition = 'bottom' | 'top';\ntype verticalaxisposition = 'left' | 'right';\ntype axisposition = horizontalaxisposition | verticalaxisposition;\n\ninterface axisconfig<tposition extends axisposition = axisposition> {\n grid?: boolean | gridconfig;\n label?: string;\n position?: tposition;\n tickcount?: number;\n tickformat?: (value: date | number | string) => string;\n}\n\ntype xaxisconfig = axisconfig<horizontalaxisposition>;\ntype yaxisconfig = axisconfig<verticalaxisposition>;\n\ninterface gridconfig {\n color?: string;\n dash?: string;\n}\n\ninterface tooltipconfig {\n offset?: number;\n render?: (datum: datum, series: series) => node | string;\n}\n\ninterface crosshairconfig {\n horizontal?: boolean;\n snap?: boolean;\n vertical?: boolean;\n}\n\ninterface legendconfig {\n position?: legendposition;\n}\n\ntype legendposition = 'bottom' | 'left' | 'right' | 'top';\n\ninterface transitionconfig {\n duration?: number;\n easing?: easingfn | 'ease in' | 'ease in out' | 'ease out' | 'linear';\n preference?: 'always' | 'never' | 'system';\n stagger?: number;\n}\n\ntype easingfn = (t: number) => number;\n```\n\ntooltip strings are assigned through `textcontent`. return a `node` for structured content.\n\n \n\n### scale types\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\ninterface bandscale {\n readonly domain: readonly string[];\n readonly range: readonly [number, number];\n map(value: string): number;\n ticks(count?: number): string[];\n bandwidth(): number;\n gap(): number;\n}\n\ninterface linearscaleconfig {\n domain: [number, number];\n range: [number, number];\n clamp?: boolean;\n nice?: boolean;\n}\n\ninterface timescaleconfig {\n domain: [date, date];\n range: [number, number];\n nice?: boolean;\n}\n\ninterface bandscaleconfig {\n domain: string[];\n range: [number, number];\n padding?: number;\n paddingouter?: number;\n}\n```\n\n \n\n### remaining unions and theme type\n\n```ts\ntype barvariant = 'grouped' | 'grouped horizontal' | 'stacked' | 'stacked horizontal';\ntype pievariant = 'donut' | 'pie' | 'semi';\ntype sparklinevariant = 'area' | 'bar' | 'line' | 'stack';\n\ninterface prismtheme {\n colors?: string[];\n fontfamily?: string;\n gridcolor?: string;\n gridopacity?: number;\n}\n```\n\n## errors\n\n### `prismerror`\n\nbase class for every prism originated error. it supports standard `erroroptions` cause chaining.\n\n### `prismrendererror`\n\nextends `prismerror`. thrown for invalid containers, failed initial renders, and attempts to update disposed charts.\n",
|
|
943
|
+
"usage": " \ntitle: 'prism: usage guide'\ndescription: concepts, update patterns, and best practices for responsive svg charts with @vielzeug/prism.\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## updating data\n\nchart factories render synchronously and return a typed handle. call `update()` with the same data shape used by the factory when application state changes.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n series: [{ data: [{ key: 1, value: 10 }], name: 'live' }],\n});\n\nchart.update([\n {\n data: [\n { key: 1, value: 10 },\n { key: 2, value: 20 },\n ],\n name: 'live',\n },\n]);\n```\n\n`update()` is state library neutral. call it from a framework effect, store subscription, event handler, or request callback. it throws after the chart is disposed instead of silently retaining detached state.\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### independent value axes\n\nassign a line series to `yaxis: 'right'` when its values need a different range. both axes share the same x scale, while tooltips retain the original values.\n\n```ts\nconst chart = createlinechart(container, {\n series: [\n { name: 'health', data: [{ key: 0, value: 30 }, { key: 1, value: 20 }] },\n { name: 'damage', yaxis: 'right', data: [{ key: 0, value: 0 }, { key: 1, value: 300 }] },\n ],\n yaxis: { label: 'health' },\n rightyaxis: { label: 'damage' },\n tooltip: true,\n});\n```\n\nthe primary axis is on the left when right axis series are present. grid configuration belongs to the primary axis. dual axis curve crossings do not imply equal numeric values. preserve each marker's axis assignment when calling `update()`.\n\n### time based x axis\n\nwhen data points use `date` objects for `key`, prism automatically applies a time scale. the domain spans exactly the first to the last date, so the series fills the plot width:\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```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\narea fills use a vertical gradient that fades toward the baseline. set `fill: 'solid'` for a flat fill:\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 fill: 'gradient', // 'gradient' (default) | 'solid'\n showline: true,\n },\n ],\n xaxis: { position: 'bottom' },\n yaxis: { position: 'left', grid: true },\n crosshair: true,\n});\n```\n\n`fillopacity` overrides the theme opacity for one series.\n\n## forecast / emphasis styling\n\na single series often mixes points that should look different — actuals plus a\nforecast tail, or a run that crossed a budget. rather than split the series,\ncarry `dash` and `opacity` on the individual datums. they are styling only:\ntooltips, hover, keyboard, and screen reader behavior never change.\n\nfor a forecast tail, map a parallel `types` array onto the datums:\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst types = ['real', 'real', 'forecast', 'forecast'];\n\nconst chart = createlinechart(container, {\n series: [\n {\n name: 'cost',\n data: values.map((value, i) => ({\n key: months[i],\n value,\n ...(types[i] === 'forecast' ? { dash: '5 5', opacity: 0.4 } : {}),\n })),\n },\n ],\n});\n```\n\non a line chart, a datum's `dash` styles the segment from that point to the\nnext, so the forecast run renders dashed while the actuals stay solid; its\n`opacity` fades that segment and the point marker. the last datum's `dash` has\nno segment to paint and is ignored. on a bar chart, both fields apply to that\ndatum's own bar — a `dash` adds a 1px series color stroke so the dash reads on\nthe filled rect. on an area chart they style the top line only; the fill keeps\nits series level treatment.\n\nthe same mapping fades bars:\n\n```ts\nimport { createbarchart } from '@vielzeug/prism';\n\nconst chart = createbarchart(container, {\n series: [\n {\n name: 'cost',\n data: values.map((value, i) => ({\n key: months[i],\n value,\n ...(types[i] === 'forecast' ? { dash: '5 5', opacity: 0.4 } : {}),\n })),\n },\n ],\n});\n```\n\nbecause the fields live on the data, a normal `update(newseries)` re applies\nthem; removing the fields on the next update clears the styling. a series with\nno `dash`/`opacity` anywhere renders exactly as it did before these fields\nexisted.\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. a label that does not fit inside its slice is not drawn; the value stays available through the tooltip, legend, and keyboard announcements:\n\n```ts\n{ value: 42, label: 'direct' }\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### updating data\n\n```ts\nconst chart = createpiechart(container, {\n data: [\n { label: 'a', value: 40 },\n { label: 'b', value: 60 },\n ],\n variant: 'donut',\n});\n\nchart.update([\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: (event) => {\n // event is pieevent | null (null on mouseleave)\n if (event) console.log(event.index, event.slice.label, event.slice.value);\n },\n onclick: (event) => {\n console.log('clicked', event.index, event.slice.label);\n },\n});\n```\n\n`pieevent` provides `index`, the hovered/clicked `slice` (`piesliceconfig`), and the `originalevent` (`mouseevent` or `keyboardevent`).\n\n## radar charts\n\n`createradarchart` compares several values on one shape, such as a hero's stats. each series supplies values keyed by `axis.key`:\n\n```ts\nimport { createradarchart } from '@vielzeug/prism';\n\nconst axes = [\n { key: 'str', label: 'strength' },\n { key: 'agi', label: 'agility' },\n { key: 'int', label: 'intellect' },\n { key: 'end', label: 'endurance' },\n { key: 'spd', label: 'speed', max: 5 },\n];\n\nconst chart = createradarchart(container, {\n a11y: { arialabel: 'hero stats' },\n axes,\n domain: [0, 10],\n fill: 'gradient',\n showvalues: true,\n series: [\n {\n name: 'adam',\n data: [\n { key: 'str', value: 8 },\n { key: 'agi', value: 6 },\n { key: 'int', value: 4 },\n { key: 'end', value: 9 },\n { key: 'spd', value: 3 },\n ],\n },\n ],\n});\n```\n\n### scales\n\nevery axis shares `domain` (or a nice range around the data when omitted). give an axis its own `min`/`max` when its stat uses a different range: above, speed runs 0 to 5 while the rest run 0 to 10, so each still fills the chart.\n\n### appearance\n\n| option | values | use it for |\n| | | |\n| `fill` | `'solid'`, `'gradient'`, `'none'` | `gradient` for a single hero; `solid` or `none` when overlaying several |\n| `curve` | `'linear'`, `'rounded'` | rounded reads softer; linear keeps exact vertices |\n| `grid.shape` | `'polygon'`, `'circle'` | polygon matches the axes; circle suits many axes |\n| `grid.levels` / `grid.bands` | number / boolean | ring count and alternate shading |\n| `grid.labels` | boolean | level values; drawn only when every axis shares one domain |\n| `showvalues` | boolean | value beside each vertex, formatted by `axis.format` |\n\n### comparing series\n\npass several series with `legend: true`. hovering one shape dims the others, and hovering an axis reports every series' value on it:\n\n```ts\ncreateradarchart(container, {\n axes,\n legend: true,\n series: [adam, eve],\n tooltip: true, // \"agility: adam 6, eve 9\"\n onhover: (event) => event && console.log(event.axis.label, event.values),\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' | 'stack' (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`**: gradient 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\nline and area sparklines mark the latest value with a dot. set `showendpoint: false` to hide it.\n\n### updating data\n\n```ts\nconst spark = createsparkline(container, {\n data: [12, 18, 14, 22],\n variant: 'area',\n});\n\nspark.update([12, 18, 14, 22, 30]);\n```\n\n### event hooks\n\nsparklines use simplified hooks: `sparklineevent` rather than full `chartevent`. with a hook set, a marker follows the hovered value:\n\n```ts\nconst spark = createsparkline(container, {\n data: [10, 20, 30],\n onhover: (event) => {\n // event is sparklineevent | null (null on mouseleave)\n if (event) console.log(`hovering point ${event.index}: ${event.value}`);\n },\n onclick: (event) => {\n console.log(`clicked point ${event.index}: ${event.value}`);\n },\n});\n```\n\n`sparklineevent` provides `index`, the hovered/clicked `value`, and the `originalevent`.\n\n> **note:** sparklines have no keyboard navigation. label one with `a11y.arialabel` only when the trend carries meaning, and state the key value in surrounding text.\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\ncategory axes show every label when the labels fit and thin them out only when they would overlap. set `tickcount` to choose the density yourself.\n\n## tooltips\n\nenable with `tooltip: true`. on line, area, bar, and radar charts the default tooltip compares every series at the active key: a title, then a colour swatch, name, and value per series. provide a custom `render` function to replace it. strings are rendered as text. return a dom node for structured content:\n\n```ts\n{\n tooltip: {\n offset: 12,\n render: (datum, series) => {\n const content = document.createelement('strong');\n content.textcontent = `${series.name}: ${datum.value.tolocalestring()}`;\n return content;\n },\n },\n}\n```\n\nprism never injects tooltip strings as html, so custom rendering does not require a sanitizer.\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 datum of the series nearest the pointer\n `series`: the corresponding `series` config\n `values`: every series at the same key (line, area, and bar charts)\n `originalevent`: the raw `mouseevent` or, for keyboard navigation, `keyboardevent`\n\n> **pie, radar, and sparkline events differ**: pie hooks receive a `pieevent` (`index`, `slice`, `originalevent`), sparkline hooks a `sparklineevent` (`index`, `value`, `originalevent`), and radar hooks a `radarevent` describing an axis. see [`piechartconfig`](./api.md#chart configurations) and [`createradarchart()`](./api.md#createradarchart).\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, // ms delay between bars (bar charts) or series (radar charts)\n },\n}\n```\n\nall chart types use `requestanimationframe` based interpolation and skip animation when the user prefers reduced motion (`preference: 'system'`, the default). bar and radar charts apply `stagger` on entry; line, area, and pie charts ignore it.\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}` | colorblind safe palette | series color palette |\n| ` prism bg` | `transparent` | chart background |\n| ` prism axis color` | `hsl(215deg 16% 47%)` | axis lines and ticks |\n| ` prism grid color` | `hsl(220deg 10% 75%)` | grid lines |\n| ` prism text color` | `hsl(215deg 25% 27%)` | axis labels and text |\n| ` prism font family` | `system ui` | chart font |\n| ` prism font size` | `0.75rem` | label font size |\n| ` prism tooltip bg` | `hsl(222deg 47% 11%)` | tooltip background |\n| ` prism tooltip color` | `hsl(210deg 40% 98%)` | tooltip text |\n| ` prism tooltip radius` | `0.375rem` | tooltip border radius |\n| ` prism crosshair color` | `hsl(215deg 16% 47%)` | crosshair line |\n| ` prism crosshair dash` | `4 2` | crosshair dash pattern |\n\nthe palette follows wong (2011) so adjacent series stay distinguishable under common color vision deficiencies. dark mode applies automatically through `prefers color scheme` or an `html.dark` class.\n\nradar charts add their own tokens, each defaulting to a shared one:\n\n| token | default | description |\n| | | |\n| ` prism radar grid color` | ` prism grid color` | rings |\n| ` prism radar grid opacity` | `0.7` | ring and spoke opacity |\n| ` prism radar band fill` | ` prism grid color` | alternate ring shading |\n| ` prism radar band opacity` | `0.14` | shading opacity |\n| ` prism radar spoke color` | ` prism radar grid color` | spokes |\n| ` prism radar fill opacity` | ` prism area opacity` | solid shape fill |\n| ` prism radar stroke width` | ` prism line width` | shape outline |\n| ` prism radar point radius` | ` prism point radius` | vertex dots |\n| ` prism radar point radius active` | ` prism point radius hover` | dots on the active axis |\n| ` prism radar label color` / ` size` | secondary text tokens | axis labels |\n| ` prism radar value color` / ` size` | text tokens | vertex values |\n| ` prism radar dim opacity` | ` prism dim opacity` | other shapes while one is hovered |\n\nshared interaction and chart tokens:\n\n| token | default | description |\n| | | |\n| ` prism dim opacity` | `0.35` | series or slices outside the active one |\n| ` prism active point radius` | ` prism point radius hover` | points marked at the active key |\n| ` prism point ring` | `canvas` | ring separating active and end points from the line |\n| ` prism focus color` | ` prism color 1` | focus outline of a keyboard focused chart |\n| ` prism bar radius` | `4px` | bar corner radius (stacked bars stay square) |\n| ` prism bar band fill` / ` opacity` | grid colour / `0.18` | shaded active category |\n| ` prism bar dim opacity` | `0.55` | bars outside the active category |\n| ` prism area gradient start` / ` end` | `0.45` / `0.02` | gradient fill opacity at the line and the baseline |\n| ` prism spark gradient start` / ` end` | `0.35` / `0` | sparkline gradient fill |\n\nexplicit config such as `strokewidth`, `fillopacity`, or `borderradius` always wins over these tokens.\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 in flight transitions\n disconnects the `resizeobserver`\n removes the svg element, tooltip, and legend from the dom\n restores container styles changed for tooltip positioning\n is idempotent: safe to call multiple times\n\ncall `update()` only while the handle is active. updating a disposed chart throws `prismrendererror`.\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## runtime observation\n\nevery handle exposes `tap()` to observe runtime behavior — resize and dispose — outside the render path. it reuses the chart's own `resizeobserver`, so observing costs nothing extra, and handler errors are swallowed: observation never affects chart behavior.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\n\nconst chart = createlinechart(container, {\n series: [{ data: [{ key: 1, value: 10 }], name: 'revenue' }],\n});\nconst controller = new abortcontroller();\nconst unsubscribe = chart.tap(\n (event) => {\n if (event.type === 'resize') console.log('resized', event.width, event.height);\n if (event.type === 'dispose') console.log('disposed');\n },\n { signal: controller.signal },\n);\n```\n\n`tap()` returns an unsubscribe function; pass `options.signal` to detach automatically instead. the `resize` event carries the new `width`/`height` and fires after the chart re laid out; `dispose` fires once, before teardown completes. tapping a disposed handle returns a no op unsubscribe. this is separate from prism's internal validation warnings, which run automatically in development and are never part of the public api.\n\n## accessibility\n\nlabel informative charts with `a11y: { arialabel }`. a labelled chart renders with `role=\"img\"`, is focusable, and supports keyboard navigation. charts without `a11y` are decorative and render with `aria hidden=\"true\"`.\n\n```ts\ncreatelinechart(container, {\n a11y: { arialabel: 'revenue by month' },\n series: [...],\n});\n```\n\nmark a chart decorative explicitly when surrounding text already states its meaning:\n\n```ts\ncreatesparkline(container, {\n a11y: { decorative: true },\n data: [...],\n});\n```\n\n### keyboard and announcements\n\nevery labelled chart except sparklines shares one keyboard model:\n\n| chart | arrow keys move between | `home` / `end` | announced |\n| | | | |\n| line, area | x positions | first / last position | `1: adam 3, eve 6` |\n| bar | categories | first / last category | `str: adam 8, eve 5` |\n| pie | slices | first / last slice | `wins: 15 (75%)` |\n| radar | axes (wrapping) | not supported | `agility: adam 6, eve 9` |\n\nthe first arrow press focuses the first item. `enter` or `space` fires `onclick` for the focused item, and `escape` clears it. the tooltip speaks the text when one is enabled; otherwise the chart's own `role=\"status\"` region (`.prism live`) does, so each value is announced once.\n\n### hover feedback\n\nthe active key is shown the same way on every chart. line and area charts mark each series' point and dim the series farther from the pointer. bar charts shade the category band and dim the other categories. pie charts dim the other slices. radar charts highlight the active spoke and its points, and dim the other shapes while one is hovered.\n\n## framework integration\n\nprism has no framework adapter. connect the same three operations to your framework's lifecycle: create after mount, update when state changes, and dispose before unmount.\n\n```ts\nimport { createlinechart, type charthandle, type continuousdatum, type lineseriesconfig } from '@vielzeug/prism';\n\nlet chart: charthandle<lineseriesconfig[]> | undefined;\n\nexport function mountchart(container: htmlelement, data: continuousdatum[]): void {\n chart = createlinechart(container, { series: [{ data, name: 'series' }] });\n}\n\nexport function updatechart(data: continuousdatum[]): void {\n chart?.update([{ data, name: 'series' }]);\n}\n\nexport function unmountchart(): void {\n chart?.dispose();\n chart = undefined;\n}\n```\n\n## working with other vielzeug libraries\n\n### with ripple\n\nkeep state ownership in ripple and connect it to prism through an explicit subscription.\n\n```ts\nimport { createlinechart } from '@vielzeug/prism';\nimport { signal } from '@vielzeug/ripple';\n\nconst data = signal([{ key: 1, value: 10 }]);\nconst toseries = () => [{ data: data.value, name: 'series' }];\nconst chart = createlinechart(container, { series: toseries() });\nconst unsubscribe = data.subscribe(() => chart.update(toseries()));\n\n// cleanup both owners together.\nunsubscribe();\nchart.dispose();\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 { createbarchart } from '@vielzeug/prism';\nimport { createpagesource } from '@vielzeug/sourcerer';\n\nconst source = createpagesource({\n load: async ({ page, pagesize, signal }) => {\n const result = await api.stats.list({ page, pagesize }, { signal });\n return { items: result.data, totalitems: result.total };\n },\n});\nconst toseries = (state) => [\n {\n data: state.items.map((item) => ({ key: item.label, value: item.count })),\n name: 'series',\n },\n];\nconst chart = createbarchart(container, { series: toseries(source.state) });\nconst unsubscribe = source.subscribe((state) => chart.update(toseries(state)));\nvoid source.reload().catch(() => undefined);\n```\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 transitions, disconnect resize observation, and remove dom nodes.\n call `chart.update(data)` from your application state boundary; prism does not require or own a state library.\n set `a11y: { arialabel: '…' }` on every chart that conveys meaningful data: unlabelled charts are hidden from assistive technology and receive no keyboard navigation.\n observe lifecycle with `chart.tap(handler)` rather than polling `chart.disposed` or wrapping the container: it costs nothing when untapped and detaches via `signal`.\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",
|
|
944
944
|
"examples": " \ntitle: 'prism: examples'\ndescription: focused recipes for creating, updating, interacting with, and theming prism charts.\n \n\n [create a line chart](./examples/line chart.md)\n [plot a time series](./examples/time series.md)\n [compare categories with a bar chart](./examples/bar chart.md)\n [render compact charts](./examples/compact charts.md)\n [compare hero stats with a radar chart](./examples/radar chart.md)\n [update chart data](./examples/update data.md)\n [sync a details panel with hover](./examples/hover details.md)\n [render a custom tooltip](./examples/custom tooltip.md)\n [match charts to your design system](./examples/theming.md)\n"
|
|
945
945
|
},
|
|
946
946
|
"examples": [],
|
|
@@ -949,7 +949,7 @@
|
|
|
949
949
|
"name": "@vielzeug/prism",
|
|
950
950
|
"related": "refine orbit",
|
|
951
951
|
"slug": "prism",
|
|
952
|
-
"source": "// public api: all exports for @vielzeug/prism\n\nexport type { easingfn } from './animation/easing';\n// chart factories\nexport { createareachart } from './charts/area';\nexport { createbarchart } from './charts/bar';\nexport { createlinechart } from './charts/line';\nexport { createpiechart } from './charts/pie';\nexport { createradarchart } from './charts/radar';\nexport { createsparkline } from './charts/sparkline';\n// error classes\nexport { prismerror, prismrendererror } from './errors';\n// scale factories\nexport { bandscale } from './scales/band';\nexport { linearscale } from './scales/linear';\nexport { timescale } from './scales/time';\nexport type { bandscaleconfig, linearscaleconfig, timescaleconfig } from './scales/types';\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 continuousdatum,\n crosshairconfig,\n datum,\n gridconfig,\n horizontalaxisposition,\n legendconfig,\n legendposition,\n linechartconfig,\n lineseriesconfig,\n piechartconfig,\n piesliceconfig,\n pievariant,\n prismtheme,\n radaraxisconfig,\n radaraxisvalue,\n radarchartconfig,\n radarevent,\n radargridconfig,\n radarseriesconfig,\n scale,\n series,\n seriesvalue,\n sparklineconfig,\n sparklinevariant,\n stacksegment,\n tooltipconfig,\n transitionconfig,\n verticalaxisposition,\n xaxisconfig,\n yaxisconfig,\n} from './types';\n"
|
|
952
|
+
"source": "// public api: all exports for @vielzeug/prism\n\nexport type { easingfn } from './animation/easing';\n// chart factories\nexport { createareachart } from './charts/area';\nexport { createbarchart } from './charts/bar';\nexport { createlinechart } from './charts/line';\nexport { createpiechart } from './charts/pie';\nexport { createradarchart } from './charts/radar';\nexport { createsparkline } from './charts/sparkline';\n// error classes\nexport { prismerror, prismrendererror } from './errors';\n// scale factories\nexport { bandscale } from './scales/band';\nexport { linearscale } from './scales/linear';\nexport { timescale } from './scales/time';\nexport type { bandscaleconfig, linearscaleconfig, timescaleconfig } from './scales/types';\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 continuousdatum,\n crosshairconfig,\n datum,\n gridconfig,\n horizontalaxisposition,\n legendconfig,\n legendposition,\n linechartconfig,\n lineseriesconfig,\n piechartconfig,\n pieevent,\n piesliceconfig,\n pievariant,\n prismevent,\n prismtheme,\n radaraxisconfig,\n radaraxisvalue,\n radarchartconfig,\n radarevent,\n radargridconfig,\n radarseriesconfig,\n scale,\n series,\n seriesvalue,\n sparklineconfig,\n sparklineevent,\n sparklinevariant,\n stacksegment,\n tooltipconfig,\n transitionconfig,\n verticalaxisposition,\n xaxisconfig,\n yaxisconfig,\n} from './types';\n"
|
|
953
953
|
},
|
|
954
954
|
{
|
|
955
955
|
"category": "websockets",
|
|
@@ -1395,9 +1395,9 @@
|
|
|
1395
1395
|
"category": "sync",
|
|
1396
1396
|
"description": "offline first sync engine with rev baselines, tombstoned deletions, and idle batched pushes for browser applications.",
|
|
1397
1397
|
"docs": {
|
|
1398
|
-
"index": " \ntitle: 'tandem: offline first sync engine'\ndescription: offline first sync engine with rev baselines, tombstoned deletions, and idle batched pushes for browser applications.\npackage: tandem\ncategory: sync\nkeywords: [sync, offline, conflict, tombstone, rev, replication, last write wins]\nrelated: [vault, postmaster, sentinel, ripple]\nexports: [createsync, syncport, syncgateway, synchandle, tandemevent]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"tandem\" />\n\n## why tandem?\n\na local first app keeps its records in indexeddb but must still reach a server: batch bursts of edits into one upload, propagate deletions that a plain pull would resurrect, and reconcile two devices that edited the same record while offline. tandem drives that round trip against any backend, tracking a per record write counter so a push carries only what actually changed.\n\n```ts\n// before\n// hand rolled: debounce each save, post the whole table, hope deletions survive.\nlet timer;\nfunction onsave(record) {\n cleartimeout(timer);\n timer = settimeout(() => fetch('/sync', { method: 'post', body: json.stringify(allrecords()) }), 3000);\n}\n\n// after\nimport { createsync } from '@vielzeug/tandem';\n\nconst sync = createsync({ gateway, port, idledelayms: 3000 });\nsync.changed(); // a local edit happened: the engine batches and pushes dirty records\n```\n\n| feature | tandem | rxsync | powersync |\n| | | | |\n| bundle size | <packageinfo package=\"tandem\" type=\"size\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| backend agnostic (bring your `port`) | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| storage agnostic (bring your `gateway`) | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> |\n| tombstoned deletions + rev conflicts | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> |\n\n<div class=\"decision callout\">\n\n**use `tandem` when** you already own a local store and a backend and want the sync scheduler: dirty tracking, batching, hide flows, and conflict reconcile: without adopting a database or a server.\n\n**consider `powersync` when** you want a managed sync service with its own sqlite client and server component rather than a scheduler over your existing storage.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/tandem\n```\n\n```sh [npm]\nnpm install @vielzeug/tandem\n```\n\n```sh [yarn]\nyarn add @vielzeug/tandem\n```\n\n:::\n\n## quick start\n\nimplement the two seams: a `syncport` for the server and a `syncgateway` for local storage: then start the scheduler. records are opaque to tandem; it reads only `id` and `rev`, and every rev comparison is the engine's: the gateway upserts, validates, and tombstones.\n\n```ts\nimport { createsync, type syncgateway, type syncport } from '@vielzeug/tandem';\n\nconst port: syncport = {\n pull: (since) => fetch(`/sync?since=${since ?? ''}`).then((r) => r.json()),\n push: async (records, deletions) => {\n const response = await fetch('/sync', { method: 'post', body: json.stringify({ records, deletions }) });\n if (!response.ok) throw new error(await response.text()); // divergence → the engine pulls to reconcile\n },\n};\n\nconst gateway: syncgateway = {\n records: () => localrecords.map((record) => ({ entity: 'docs', record })),\n pendingdeletions: () => readtombstones(),\n applyrecords: (pulled) => upsert(pulled), // the engine pre filters; you validate untrusted records\n applydeletions: (deletions) => removelocal(deletions),\n cleardeletions: (deletions) => droptombstones(deletions),\n loadstate: () => readsyncstate(),\n savestate: (state) => writesyncstate(state),\n};\n\nconst sync = createsync({ gateway, port });\nsync.tap((event) => console.debug('sync', event)); // observe pushes, pulls, warnings\nmystore.subscribe(() => sync.changed()); // a local write happened\n\n// on account switch or teardown: after flushing this account's work:\nawait sync.flush();\nsync.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createsync()`**: drives a port against a gateway with a serialized push queue.\n **`syncport`**: the server seam: `pull(since)` and `push(records, deletions)`.\n **`syncgateway`**: the storage seam: upserts, tombstones, and the persisted baseline: the engine owns every rev comparison.\n **`rev` baselines**: a record is dirty exactly when its rev is above the last synced one, across reloads.\n **`changed()`**: wire it to your write path; the engine debounces a burst into one push.\n **`flush()`**: a full cycle on demand: pull remote changes, then push dirty records; rejects on failure.\n **hide flows**: flushes with `keepalive` on `pagehide`/`hidden`, runs a full cycle on return to the foreground.\n **`tap()`**: typed `tandemevent`s for pushes, pulls, invalid records, and warnings.\n **lifecycle owned**: `dispose()`, `disposed`, `disposalsignal`, and `[symbol.dispose]`.\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 [vault](/vault/): the typed storage core most gateways wrap; its indexeddb adapter is a natural `records()`/`applyrecords()` backend.\n [postmaster](/postmaster/): the durable outbox half of offline sync; pair it with tandem for at least once mutations alongside state sync.\n [sentinel](/sentinel/): subscribable browser state; its network signal can gate when you call `flush()`.\n [ripple](/ripple/): signals and effects; `store.subscribe(() => sync.changed())` is the usual wiring.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1399
|
-
"api": " \ntitle: 'tandem: api reference'\ndescription: factory, port and gateway contracts, events, and types for tandem.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createsync()` | scheduler driving a port against a gateway | sync (queues async work) | boot runs pull then push; dispose before switching accounts |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/tandem` | `createsync`, port/gateway contracts, events, and types |\n\n## factories\n\n### `createsync()`\n\n```ts\nfunction createsync<trecord extends syncrecordbase = syncrecordbase>(\n options: syncoptions<trecord>,\n): synchandle;\n```\n\nstarts a sync scheduler: it loads the persisted baseline, pulls remote changes, pushes anything dirty, then flushes on `changed()`, on tab hide, and on foreground return.\n\n| parameter | type | description |\n| | | |\n| `options.gateway` | `syncgateway<trecord>` | the device's storage seam |\n| `options.idledelayms` | `number \\| undefined` | quiet period before a flush (default `3000`) |\n| `options.port` | `syncport<trecord>` | the server seam |\n\n**returns** a `synchandle`. ambient flushes never reject: they surface failures as `warning` events through `tap()`. an explicit `flush()` rejects when its cycle fails.\n\n**example**\n\n```ts\nimport { createsync } from '@vielzeug/tandem';\n\nconst sync = createsync({ gateway, idledelayms: 1500, port });\nsync.changed();\nawait sync.flush();\nsync.dispose();\n```\n\n#### `synchandle` methods\n\n| member | description |\n| | |\n| `changed()` | notes a local change and (re)starts the idle flush timer |\n| `dispose()` | stops the scheduler, clears the timer, removes document/window listeners |\n| `disposed` | `true` once disposed |\n| `disposalsignal` | `abortsignal` aborted on dispose |\n| `flush()` | runs a full cycle: pull, then push; resolves when it completes, rejects when it fails, and
|
|
1400
|
-
"usage": " \ntitle: 'tandem: usage guide'\ndescription: 'how to drive offline sync with @vielzeug/tandem: ports, gateways, rev baselines, tombstones, and reconciliation.'\n \n\n[[toc]]\n\n## basic usage\n\ntandem sits between your storage and your server. you implement two interfaces and call `createsync`; the engine handles batching, dirty tracking, hide flows, and conflict reconcile.\n\n```ts\nimport { createsync, type syncgateway, type syncport } from '@vielzeug/tandem';\n\ninterface doc {\n id: string;\n rev: number;\n title: string;\n}\n\nconst port: syncport<doc> = {\n async pull(since) {\n return fetch(`/sync/pull?since=${since ?? ''}`).then((response) => response.json());\n },\n async push(records, deletions, options) {\n const response = await fetch('/sync/push', {\n body: json.stringify({ records, deletions }),\n headers: { 'content type': 'application/json' },\n keepalive: options?.keepalive,\n method: 'post',\n });\n if (!response.ok) throw new error(`server diverged: ${await response.text()}`);\n },\n};\n\nconst gateway: syncgateway<doc> = {\n records: () => docs.map((record) => ({ entity: 'docs', record })),\n pendingdeletions: () => readtombstones(),\n async applyrecords(pulled) {\n // the engine already filtered these to records the server is ahead on;\n // validate the untrusted remotes and upsert the rest.\n const skipped: string[] = [];\n for (const { record } of pulled) {\n if (!isvalid(record)) skipped.push(record.id);\n else upsert(record);\n }\n return skipped;\n },\n applydeletions: (deletions) => promise.all(deletions.map((d) => remove(d.id))),\n cleardeletions: (deletions) => promise.all(deletions.map((d) => droptombstone(d.id))),\n loadstate: () => readrow('syncstate'),\n savestate: (state) => writerow('syncstate', state),\n};\n\nconst sync = createsync<doc>({ gateway, port });\n```\n\n## track dirty records with revs\n\ntandem never diffs record contents. each record carries a `rev` counting its committed writes, and the gateway persists a baseline of the newest rev the server has seen. a record is dirty exactly when its current rev is above that baseline.\n\n```ts\n// your write path bumps the rev on every committed change.\nfunction savedoc(id: string, patch: partial<doc>): void {\n const current = docs.find((doc) => doc.id === id)!;\n upsert({ ...current, ...patch, rev: current.rev + 1 });\n sync.changed(); // schedule a flush\n}\n```\n\nbecause the baseline is persisted, dirtiness survives a reload: a record edited while offline is still above the baseline when the app restarts, and the boot sequence pushes it. the engine never asks your gateway to compare revs: ahead, dirty, and deletion precedence are all its decisions.\n\n## propagate deletions with tombstones\n\na plain pull cannot carry a deletion: the record is gone. record a tombstone when you delete locally; tandem pushes it and clears it only once the server acknowledges.\n\n```ts\nfunction deletedoc(id: string): void {\n remove(id);\n writetombstone({ deletedat: new date().toisostring(), entity: 'docs', id });\n sync.changed();\n}\n```\n\na remote deletion loses to a local record with unpushed changes: if your local rev is above the baseline, tandem keeps your edit and pushes it instead of applying the deletion. the guard runs both ways: a pulled record whose id is locally tombstoned, or deleted in the same pull, never applies, so a backend without an incremental cursor cannot resurrect your deletions.\n\n## batch changes and flush\n\ncall `changed()` on every local write. the engine debounces a burst into one push after `idledelayms` (default 3000), and serializes pushes so two never overlap.\n\n```ts\nconst sync = createsync({ gateway, idledelayms: 1500, port });\n\n// force a full cycle now: on a save button, or before navigating away.\ntry {\n await sync.flush();\n} catch {\n // the cycle failed: edits stay local and dirty; the next cycle retries them\n}\n```\n\n`flush()` pulls remote changes and then pushes dirty records, so it is also the wiring point for a returning network connection. it rejects when either half fails; the records stay dirty and the next cycle retries them.
|
|
1398
|
+
"index": " \ntitle: 'tandem: offline first sync engine'\ndescription: offline first sync engine with rev baselines, tombstoned deletions, and idle batched pushes for browser applications.\npackage: tandem\ncategory: sync\nkeywords: [sync, offline, conflict, tombstone, rev, replication, last write wins]\nrelated: [vault, postmaster, sentinel, ripple]\nexports: [createsync, syncport, syncgateway, synchandle, tandemevent, tandemerror, tandemdisposederror]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"tandem\" />\n\n## why tandem?\n\na local first app keeps its records in indexeddb but must still reach a server: batch bursts of edits into one upload, propagate deletions that a plain pull would resurrect, and reconcile two devices that edited the same record while offline. tandem drives that round trip against any backend, tracking a per record write counter so a push carries only what actually changed.\n\n```ts\n// before\n// hand rolled: debounce each save, post the whole table, hope deletions survive.\nlet timer;\nfunction onsave(record) {\n cleartimeout(timer);\n timer = settimeout(() => fetch('/sync', { method: 'post', body: json.stringify(allrecords()) }), 3000);\n}\n\n// after\nimport { createsync } from '@vielzeug/tandem';\n\nconst sync = createsync({ gateway, port, idledelayms: 3000 });\nsync.changed(); // a local edit happened: the engine batches and pushes dirty records\n```\n\n| feature | tandem | rxsync | powersync |\n| | | | |\n| bundle size | <packageinfo package=\"tandem\" type=\"size\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| zero runtime dependencies | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| backend agnostic (bring your `port`) | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> |\n| storage agnostic (bring your `gateway`) | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"x\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> |\n| tombstoned deletions + rev conflicts | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> | <ore icon name=\"check\" size=\"16\" /> |\n\n<div class=\"decision callout\">\n\n**use `tandem` when** you already own a local store and a backend and want the sync scheduler: dirty tracking, batching, hide flows, and conflict reconcile: without adopting a database or a server.\n\n**consider `powersync` when** you want a managed sync service with its own sqlite client and server component rather than a scheduler over your existing storage.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/tandem\n```\n\n```sh [npm]\nnpm install @vielzeug/tandem\n```\n\n```sh [yarn]\nyarn add @vielzeug/tandem\n```\n\n:::\n\n## quick start\n\nimplement the two seams: a `syncport` for the server and a `syncgateway` for local storage: then start the scheduler. records are opaque to tandem; it reads only `id` and `rev`, and every rev comparison is the engine's: the gateway upserts, validates, and tombstones.\n\n```ts\nimport { createsync, type syncgateway, type syncport } from '@vielzeug/tandem';\n\nconst port: syncport = {\n pull: (since) => fetch(`/sync?since=${since ?? ''}`).then((r) => r.json()),\n push: async (records, deletions) => {\n const response = await fetch('/sync', { method: 'post', body: json.stringify({ records, deletions }) });\n if (!response.ok) throw new error(await response.text()); // divergence → the engine pulls to reconcile\n },\n};\n\nconst gateway: syncgateway = {\n records: () => localrecords.map((record) => ({ entity: 'docs', record })),\n pendingdeletions: () => readtombstones(),\n applyrecords: (pulled) => upsert(pulled), // the engine pre filters; you validate untrusted records\n applydeletions: (deletions) => removelocal(deletions),\n cleardeletions: (deletions) => droptombstones(deletions),\n loadstate: () => readsyncstate(),\n savestate: (state) => writesyncstate(state),\n};\n\nconst sync = createsync({ gateway, port });\nsync.tap((event) => console.debug('sync', event)); // observe pushes, pulls, warnings\nmystore.subscribe(() => sync.changed()); // a local write happened\n\n// on account switch or teardown: after flushing this account's work:\nawait sync.flush();\nsync.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`createsync()`**: drives a port against a gateway with a serialized push queue.\n **`syncport`**: the server seam: `pull(since)` and `push(records, deletions)`.\n **`syncgateway`**: the storage seam: upserts, tombstones, and the persisted baseline: the engine owns every rev comparison.\n **`rev` baselines**: a record is dirty exactly when its rev is above the last synced one, across reloads.\n **`changed()`**: wire it to your write path; the engine debounces a burst into one push.\n **`flush()`**: a full cycle on demand: pull remote changes, then push dirty records; rejects on failure.\n **hide flows**: flushes with `keepalive` on `pagehide`/`hidden`, runs a full cycle on return to the foreground.\n **`tap()`**: typed `tandemevent`s for pushes, pulls, invalid records, and warnings.\n **lifecycle owned**: `dispose()`, `disposed`, `disposalsignal`, and `[symbol.dispose]`; post dispose `changed()`/`flush()` fail with `tandemdisposederror`.\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 [vault](/vault/): the typed storage core most gateways wrap; its indexeddb adapter is a natural `records()`/`applyrecords()` backend.\n [postmaster](/postmaster/): the durable outbox half of offline sync; pair it with tandem for at least once mutations alongside state sync.\n [sentinel](/sentinel/): subscribable browser state; its network signal can gate when you call `flush()`.\n [ripple](/ripple/): signals and effects; `store.subscribe(() => sync.changed())` is the usual wiring.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1399
|
+
"api": " \ntitle: 'tandem: api reference'\ndescription: factory, port and gateway contracts, events, and types for tandem.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution mode | common gotcha |\n| | | | |\n| `createsync()` | scheduler driving a port against a gateway | sync (queues async work) | boot runs pull then push; dispose before switching accounts |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/tandem` | `createsync`, `tandemerror`, `tandemdisposederror`, port/gateway contracts, events, and types |\n\n## factories\n\n### `createsync()`\n\n```ts\nfunction createsync<trecord extends syncrecordbase = syncrecordbase>(\n options: syncoptions<trecord>,\n): synchandle;\n```\n\nstarts a sync scheduler: it loads the persisted baseline, pulls remote changes, pushes anything dirty, then flushes on `changed()`, on tab hide, and on foreground return.\n\n| parameter | type | description |\n| | | |\n| `options.gateway` | `syncgateway<trecord>` | the device's storage seam |\n| `options.idledelayms` | `number \\| undefined` | quiet period before a flush (default `3000`) |\n| `options.port` | `syncport<trecord>` | the server seam |\n\n**returns** a `synchandle`. ambient flushes never reject: they surface failures as `warning` events through `tap()`. an explicit `flush()` rejects when its cycle fails.\n\n**example**\n\n```ts\nimport { createsync } from '@vielzeug/tandem';\n\nconst sync = createsync({ gateway, idledelayms: 1500, port });\nsync.changed();\nawait sync.flush();\nsync.dispose();\n```\n\n#### `synchandle` methods\n\n| member | description |\n| | |\n| `changed()` | notes a local change and (re)starts the idle flush timer; throws `tandemdisposederror` after `dispose` |\n| `dispose()` | stops the scheduler, clears the timer, removes document/window listeners |\n| `disposed` | `true` once disposed |\n| `disposalsignal` | `abortsignal` aborted on dispose |\n| `flush()` | runs a full cycle: pull, then push; resolves when it completes, rejects when it fails, and rejects with `tandemdisposederror` after `dispose` |\n| `tap(handler, options?)` | observes `tandemevent`s; returns an unsubscribe function |\n| `[symbol.dispose]()` | calls `dispose()` for `using` scopes |\n\n## contracts\n\n### `syncport`\n\nthe server seam. `pull(since)` returns remote records, remote deletions, and the next cursor. `push(records, deletions, options)` sends local changes; reject it when the server diverged so the engine pulls to reconcile. `options.keepalive` marks a flush that must survive the tab closing.\n\n### `syncgateway`\n\nthe storage seam. the engine owns every rev comparison: it calls your gateway with the decisions already made.\n\n| member | contract |\n| | |\n| `records()` | this device's own records, remote mirrors excluded; sync or async, so a disk backed gateway can read from storage instead of holding a mirror |\n| `pendingdeletions()` | tombstones the server has not acknowledged |\n| `applyrecords(records)` | upsert the given records: already filtered to what the server is ahead on; return the ids that failed validation |\n| `applydeletions(deletions)` | remove records deleted remotely |\n| `cleardeletions(deletions)` | drop the given tombstones a successful push carried |\n| `loadstate()` | the persisted `syncstate`, or `null` on a fresh device |\n| `savestate(state)` | persist the baseline after a completed pull or push |\n\n## events\n\n### `tandemevent`\n\n```ts\ntype tandemevent =\n | { readonly records: number; readonly type: 'pull' }\n | { readonly records: number; readonly type: 'push' }\n | { readonly message: string; readonly skipped: readonly string[]; readonly type: 'invalid' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' }\n | { readonly type: 'dispose' };\n```\n\n`invalid` reports record ids `applyrecords` refused; `warning` reports a rejected push or transport failure and carries the original cause in `error` beside its `message`; `pull` and `push` report the record counts applied and pushed. `dispose` fires once, before tappers are cleared.\n\n## types\n\n### `syncrecordbase`\n\n```ts\ninterface syncrecordbase {\n readonly id: string;\n readonly rev: number;\n}\n```\n\nthe only shape tandem reads from a record: identity and a write counter. `rev` is required: the dirty model is a rev comparison, so every synced record carries one from birth.\n\n### `syncenvelope`\n\n```ts\ninterface syncenvelope<trecord extends syncrecordbase = syncrecordbase> {\n readonly entity: string;\n readonly record: trecord;\n}\n```\n\na record paired with the entity (table or kind) it belongs to.\n\n### `syncdeletion`\n\n```ts\ninterface syncdeletion {\n readonly deletedat?: string;\n readonly entity: string;\n readonly id: string;\n}\n```\n\na tombstone: a deletion that travels to the server on the next push. `deletedat` is set by whoever recorded the deletion and the engine never reads it: include it when your server wants the timestamp, omit it otherwise.\n\n### `syncstate`\n\n```ts\ninterface syncstate {\n cursor: string | null;\n revs: record<string, number>;\n}\n```\n\nthe persisted baseline. `revs` maps `\"${entity}:${id}\"` to the newest rev the server has seen.\n\n### `syncpullresult`\n\n```ts\ninterface syncpullresult<trecord extends syncrecordbase = syncrecordbase> {\n readonly cursor: string | null;\n readonly deletions: readonly syncdeletion[];\n readonly records: readonly syncenvelope<trecord>[];\n}\n```\n\n### `syncoptions`\n\n```ts\ninterface syncoptions<trecord extends syncrecordbase = syncrecordbase> {\n gateway: syncgateway<trecord>;\n idledelayms?: number;\n port: syncport<trecord>;\n}\n```\n\n## errors\n\n### `tandemerror`\n\n```ts\nclass tandemerror extends error {}\n```\n\nbase class for every error tandem throws; `instanceof tandemerror` catches them all in one branch.\n\n### `tandemdisposederror`\n\n```ts\nclass tandemdisposederror extends tandemerror {}\n```\n\nthrown by `changed()` and rejected by `flush()` after `dispose()`. a scheduler that stops silently is a sync engine's worst failure: the device keeps editing while nothing uploads, so a leaked handle fails loudly instead.\n\nbeyond disposal, tandem defines no error classes. a rejected `push` is expected input, not a failure: the engine pulls to reconcile and reports it as a `warning` event. `flush()` additionally rejects with the underlying port or gateway error; dirty records survive the failure and retry on the next cycle.\n",
|
|
1400
|
+
"usage": " \ntitle: 'tandem: usage guide'\ndescription: 'how to drive offline sync with @vielzeug/tandem: ports, gateways, rev baselines, tombstones, and reconciliation.'\n \n\n[[toc]]\n\n## basic usage\n\ntandem sits between your storage and your server. you implement two interfaces and call `createsync`; the engine handles batching, dirty tracking, hide flows, and conflict reconcile.\n\n```ts\nimport { createsync, type syncgateway, type syncport } from '@vielzeug/tandem';\n\ninterface doc {\n id: string;\n rev: number;\n title: string;\n}\n\nconst port: syncport<doc> = {\n async pull(since) {\n return fetch(`/sync/pull?since=${since ?? ''}`).then((response) => response.json());\n },\n async push(records, deletions, options) {\n const response = await fetch('/sync/push', {\n body: json.stringify({ records, deletions }),\n headers: { 'content type': 'application/json' },\n keepalive: options?.keepalive,\n method: 'post',\n });\n if (!response.ok) throw new error(`server diverged: ${await response.text()}`);\n },\n};\n\nconst gateway: syncgateway<doc> = {\n records: () => docs.map((record) => ({ entity: 'docs', record })),\n pendingdeletions: () => readtombstones(),\n async applyrecords(pulled) {\n // the engine already filtered these to records the server is ahead on;\n // validate the untrusted remotes and upsert the rest.\n const skipped: string[] = [];\n for (const { record } of pulled) {\n if (!isvalid(record)) skipped.push(record.id);\n else upsert(record);\n }\n return skipped;\n },\n applydeletions: (deletions) => promise.all(deletions.map((d) => remove(d.id))),\n cleardeletions: (deletions) => promise.all(deletions.map((d) => droptombstone(d.id))),\n loadstate: () => readrow('syncstate'),\n savestate: (state) => writerow('syncstate', state),\n};\n\nconst sync = createsync<doc>({ gateway, port });\n```\n\n## track dirty records with revs\n\ntandem never diffs record contents. each record carries a `rev` counting its committed writes, and the gateway persists a baseline of the newest rev the server has seen. a record is dirty exactly when its current rev is above that baseline.\n\n```ts\n// your write path bumps the rev on every committed change.\nfunction savedoc(id: string, patch: partial<doc>): void {\n const current = docs.find((doc) => doc.id === id)!;\n upsert({ ...current, ...patch, rev: current.rev + 1 });\n sync.changed(); // schedule a flush\n}\n```\n\nbecause the baseline is persisted, dirtiness survives a reload: a record edited while offline is still above the baseline when the app restarts, and the boot sequence pushes it. the engine never asks your gateway to compare revs: ahead, dirty, and deletion precedence are all its decisions.\n\n## propagate deletions with tombstones\n\na plain pull cannot carry a deletion: the record is gone. record a tombstone when you delete locally; tandem pushes it and clears it only once the server acknowledges.\n\n```ts\nfunction deletedoc(id: string): void {\n remove(id);\n writetombstone({ deletedat: new date().toisostring(), entity: 'docs', id });\n sync.changed();\n}\n```\n\na remote deletion loses to a local record with unpushed changes: if your local rev is above the baseline, tandem keeps your edit and pushes it instead of applying the deletion. the guard runs both ways: a pulled record whose id is locally tombstoned, or deleted in the same pull, never applies, so a backend without an incremental cursor cannot resurrect your deletions.\n\n## batch changes and flush\n\ncall `changed()` on every local write. the engine debounces a burst into one push after `idledelayms` (default 3000), and serializes pushes so two never overlap.\n\n```ts\nconst sync = createsync({ gateway, idledelayms: 1500, port });\n\n// force a full cycle now: on a save button, or before navigating away.\ntry {\n await sync.flush();\n} catch {\n // the cycle failed: edits stay local and dirty; the next cycle retries them\n}\n```\n\n`flush()` pulls remote changes and then pushes dirty records, so it is also the wiring point for a returning network connection. it rejects when either half fails; the records stay dirty and the next cycle retries them. after `dispose()`, `flush()` rejects with `tandemdisposederror` and `changed()` throws it: a leaked scheduler fails loudly instead of silently stopping. if your reactive wiring can fire after disposal, unsubscribe from your store before disposing the handle, or guard with `sync.disposed`.\n\ntandem also flushes on its own: it pushes with `keepalive: true` when the tab hides or closes, and runs a full cycle when the tab returns to the foreground. serve the keepalive push with `navigator.sendbeacon` or a `keepalive` fetch so it survives the tab closing.\n\n## reconcile a rejected push\n\nwhen the server is ahead of the lineage you pushed, reject the push. tandem immediately pulls to reconcile, then the next flush retries with the merged state. ambient flushes (debounce, hide) report the failure as a `warning` event; `flush()` additionally rejects with the port's error.\n\n```ts\nasync push(records, deletions) {\n const response = await fetch('/sync/push', { body: json.stringify({ records, deletions }), method: 'post' });\n if (response.status === 409) throw new error('server ahead'); // triggers a reconcile pull\n}\n```\n\n## observe with tap()\n\n`tap()` is the observation seam: pushes, pulls, refused records, and warnings. handler errors are swallowed, so an observer never breaks sync.\n\n```ts\nconst unsubscribe = sync.tap((event) => {\n switch (event.type) {\n case 'push':\n case 'pull':\n console.debug(`sync ${event.type}: ${event.records} records`);\n break;\n case 'invalid':\n console.warn(event.message, event.skipped);\n break;\n case 'warning':\n console.warn(event.message, event.error); // push rejected, network failure, etc.\n break;\n }\n});\n```\n\n## lifecycle and disposal\n\ndispose before switching accounts: the baseline is per account state and a running scheduler would push into the wrong port.\n\n```ts\nconst sync = createsync({ gateway, port });\n// ...\nsync.dispose(); // stops the scheduler, clears the timer, removes document/window listeners\n```\n\n`disposed` and `disposalsignal` report the state; `[symbol.dispose]` lets you scope it with `using`.\n\n```ts\n{\n using sync = createsync({ gateway, port });\n sync.changed();\n} // disposed here\n```\n\n## testing\n\ndrive the engine against in memory doubles. a `syncport` double records pushes and returns scripted pulls; a `syncgateway` double is a record array plus a tombstone list plus a state row.\n\n```ts\nimport { createsync } from '@vielzeug/tandem';\n\nconst pushes: unknown[][] = [];\nconst port: syncport = {\n pull: async () => ({ cursor: null, deletions: [], records: [] }),\n push: async (records, deletions) => void pushes.push([records, deletions]),\n};\n\nconst sync = createsync({ gateway: fakegateway, idledelayms: 10, port });\nawait vi.waitfor(() => expect(pushes.length).tobe(1)); // startup push\nsync.dispose();\n```\n\n## framework integration\n\ntandem is framework neutral. wire `changed()` to your reactive layer and `dispose()` to the component or scope lifecycle.\n\n::: code group\n\n```ts [vue 3]\nimport { onscopedispose, watch } from 'vue';\n\nconst sync = createsync({ gateway, port });\nwatch(store.records, () => sync.changed(), { deep: true });\nonscopedispose(() => sync.dispose());\n```\n\n```tsx [react]\nimport { useeffect, useref } from 'react';\n\nconst syncref = useref(createsync({ gateway, port }));\nuseeffect(() => {\n const sync = syncref.current;\n return () => sync.dispose();\n}, []);\n```\n\n```svelte [svelte]\n<script lang=\"ts\">\n import { ondestroy } from 'svelte';\n const sync = createsync({ gateway, port });\n $effect(() => { $records; sync.changed(); });\n ondestroy(() => sync.dispose());\n</script>\n```\n\n:::\n\n## working with other vielzeug libraries\n\n **vault**: the usual `gateway` backend. its indexeddb adapter stores records and the sync state row; `records()` reads them from storage (it may be async), `applyrecords()` writes through the codec.\n **postmaster**: complementary, not overlapping. postmaster guarantees at least once delivery of discrete mutations; tandem keeps whole record state in step. apps that need both run them side by side.\n **sentinel**: subscribe to `createnetwork()` and call `sync.flush()` when the connection returns; the cycle pulls and pushes, so remote changes land without waiting for the next foreground return.\n **ripple**: `store.subscribe(() => sync.changed())` is the idiomatic wiring for a signal backed store.\n\n## best practices\n\n bump `rev` on every committed write: the whole dirty model depends on it.\n validate remote records in `applyrecords()` and return the ids you refuse; never trust a pulled record.\n keep `records()` free of remote session mirrors: it must return only this device's own records.\n dispose on account switch; a stale scheduler pushes into the wrong port.\n serve `keepalive` pushes with `sendbeacon` or a `keepalive` fetch so they survive tab close.\n reject a push on divergence instead of silently overwriting; let the reconcile pull merge.\n persist the sync state row per account, alongside the records it describes.\n call `flush()` before actions that assume both sides are current, such as a share or export, and handle its rejection.\n",
|
|
1401
1401
|
"examples": " \ntitle: 'tandem: examples'\ndescription: offline sync recipes for two device editing, deletions, and account switching.\n \n\n## examples\n\n [sync a vault store](./examples/sync a vault store.md)\n [keep deletions from resurrecting](./examples/keep deletions from resurrecting.md)\n [switch accounts safely](./examples/switch accounts safely.md)\n"
|
|
1402
1402
|
},
|
|
1403
1403
|
"examples": [
|
|
@@ -1406,29 +1406,29 @@
|
|
|
1406
1406
|
"text": "createsync batched pushes import { createsync } from '@vielzeug/tandem'\n\n// two in memory doubles stand in for the device store and the server.\nlet docs = [{ id: 'a', rev: 0, title: 'first doc' }]\nlet tombstones = []\nlet storedstate = null\nconst server = []\n\nconst gateway = {\n records: () => docs.map((record) => ({ entity: 'docs', record })),\n pendingdeletions: async () => [...tombstones],\n async applyrecords(pulled) {\n // the engine already filtered these to records the server is ahead on.\n for (const { record } of pulled) {\n docs = [...docs.filter((d) => d.id !== record.id), record]\n }\n return []\n },\n async applydeletions(deletions) {\n for (const { id } of deletions) docs = docs.filter((d) => d.id !== id)\n },\n async cleardeletions(deletions) {\n tombstones = tombstones.filter((t) => !deletions.some((d) => d.id === t.id))\n },\n loadstate: async () => storedstate,\n savestate: async (state) => { storedstate = structuredclone(state) },\n}\n\nconst port = {\n pull: async () => ({ cursor: null, deletions: [], records: [] }),\n push: async (records, deletions) => {\n server.push({ records: [...records], deletions: [...deletions] })\n },\n}\n\nconst sync = createsync({ gateway, idledelayms: 20, port })\nsync.tap((event) => console.log('event:', event.type))\n\nawait new promise((r) => settimeout(r, 50)) // boot: load baseline, pull, push the seed\nconsole.log('after boot:', server.length) // 1\n\n// a burst of edits becomes one push: bump the rev, then announce the change.\nfor (let i = 1; i <= 3; i++) {\n docs = docs.map((d) => (d.id === 'a' ? { ...d, rev: d.rev + 1, title: `v${i}` } : d))\n sync.changed()\n}\n\nawait new promise((r) => settimeout(r, 120))\nconsole.log('pushes:', server.length) // 2: one flush for three edits\nconsole.log('pushed rev:', server.at( 1).records[0].record.rev) // 3: one upload carries all of them\nsync.dispose()"
|
|
1407
1407
|
}
|
|
1408
1408
|
],
|
|
1409
|
-
"exports": "createsync syncport syncgateway synchandle tandemevent",
|
|
1409
|
+
"exports": "createsync syncport syncgateway synchandle tandemevent tandemerror tandemdisposederror",
|
|
1410
1410
|
"keywords": "sync offline conflict tombstone rev replication last write wins",
|
|
1411
1411
|
"name": "@vielzeug/tandem",
|
|
1412
1412
|
"related": "vault postmaster sentinel ripple",
|
|
1413
1413
|
"slug": "tandem",
|
|
1414
|
-
"source": "// public root surface of @vielzeug/tandem. every public export goes through this file.\nexport { createsync } from './tandem';\nexport type {\n syncdeletion,\n syncenvelope,\n syncgateway,\n synchandle,\n syncoptions,\n syncport,\n syncpullresult,\n syncrecordbase,\n syncstate,\n tandemevent,\n} from './types';\n"
|
|
1414
|
+
"source": "// public root surface of @vielzeug/tandem. every public export goes through this file.\n\nexport { tandemdisposederror, tandemerror } from './errors';\nexport { createsync } from './tandem';\nexport type {\n syncdeletion,\n syncenvelope,\n syncgateway,\n synchandle,\n syncoptions,\n syncport,\n syncpullresult,\n syncrecordbase,\n syncstate,\n tandemevent,\n} from './types';\n"
|
|
1415
1415
|
},
|
|
1416
1416
|
{
|
|
1417
1417
|
"category": "utilities",
|
|
1418
1418
|
"description": "table sessions over mesh: host owned state replication with guest command forwarding",
|
|
1419
1419
|
"docs": {
|
|
1420
|
-
"index": " \ntitle: tavern\ndescription: \"table sessions over mesh: host owned state replication with guest command forwarding\"\npackage: tavern\ncategory: utilities\nkeywords: [session, host, guest, replication, mesh, commands, snapshots, collaboration, peer to peer]\nexports: [hosttavern, jointavern, tavernhost, tavernguest, taverncommands, tavernnotices, tavernerror, tavernpairingerror]\nrelated: [mesh, ledger, herald]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"tavern\" />\n\n## why tavern?\n\npeer to peer apps share one arrangement problem: the canonical state of something: a document, a dashboard, a record: lives on exactly one device, while every other participant should see it and be able to change it from their own. tavern turns that arrangement into a session: **one host owns the canonical state, guests mirror it, and every guest command is forwarded through the host's own command table**, so remote actions follow exactly the same code path as local ones.\n\n```ts\n// before: hand rolled session orchestration: pairing, protocol validation,\n// command routing, snapshot broadcasting, notice relaying, presence tracking.\nconst host = createmeshhost({ /* ... */ });\nhost.on('command', ({ peerid, payload }) => {\n const command = myschema.safeparse(payload);\n if (!command.success) { host.send(peerid, 'rejected', { /* ... */ }); return; }\n try { applysubjectcommand(command.data.name, subject, command.data.args); }\n catch (error) { host.send(peerid, 'rejected', { /* ... */ }); }\n});\nbus.on('subject:updated', () => { host.broadcast('snapshot', snapshotfor(subject)); });\n// ... 40+ more lines of plumbing\n\n// after: tavern owns the protocol, the validation, the broadcasting.\nconst host = hosttavern({\n commands: { apply: (name, args) => store.apply(name, args), has: (name) => name in store },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => store.onupdated('doc 1', listener),\n onremoved: (listener) => store.onremoved('doc 1', listener),\n snapshot: () => store.read('doc 1'),\n },\n});\n```\n\n| feature | tavern | raw mesh | socket.io rooms |\n| | | | |\n| bundle size | <packageinfo package=\"tavern\" type=\"size\" /> | <packageinfo package=\"mesh\" type=\"size\" /> | n/a |\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| host authoritative commands | <ore icon name=\"check\" size=\"16\"></ore icon>: same table as local ui | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: wire it yourself | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: wire it yourself |\n| snapshot replication | <ore icon name=\"check\" size=\"16\"></ore icon>: microtask coalesced | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| out of band pairing (qr / copy paste) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon>: needs a server |\n| notice relaying | <ore icon name=\"check\" size=\"16\"></ore icon>: consumer defined wire shape | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: server mediated |\n\n<div class=\"decision callout\">\n\n**use tavern when** one device should own the canonical state of something: a shared document, a live dashboard, a record under review, and every other device should mirror it and send commands back through the host's own command table, which stays the single validation path.\n\n**consider raw `@vielzeug/mesh` when** you need p2p messaging without the host authoritative replication model: chat, whiteboards, file transfer, or custom topologies.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/tavern\n```\n\n```sh [npm]\nnpm install @vielzeug/tavern\n```\n\n```sh [yarn]\nyarn add @vielzeug/tavern\n```\n\n:::\n\n## quick start\n\n```ts\nimport { hosttavern, jointavern } from '@vielzeug/tavern';\n\n// host: pick a subject, wire the command table and snapshot access.\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\n\n// produce a single use invitation; deliver it out of band (qr, copy/paste).\nconst invitation = await host.createinvitationtext();\n\n// guest: consume the invitation, produce an answer for the host.\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => parseandmount(snapshot),\n name: 'alex',\n});\nawait host.acceptanswertext(answertext);\n\n// the guest's commands run through the host's own table.\nguest.sendcommand('doc 1', 'rename', ['quarterly report']);\n\n// cleanup: hosting ends for every guest; onended fires exactly once on both sides.\nhost.dispose();\nguest.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`hosttavern`**: host one subject: pairing, protocol validation, command application, coalesced snapshot broadcasts\n **`jointavern`**: join as a guest: consume the invitation, produce the answer, mirror the host's snapshots\n **`taverncommands`**: the host's command table: the same object the host's own ui calls, so remote actions cannot bypass validation\n **`tavernnotices`**: consumer defined notice serialization: catalog keys cross the wire, each client translates locally\n **coalesced snapshots**: bursts of local changes ship one snapshot per microtask, not one per change\n **`onended` fires exactly once**: on both handles: channel drops, subject removal, and explicit disposal share a single idempotent cleanup path\n **typed errors**: `tavernpairingerror` covers every unusable pasted code with the mesh failure chained as `cause`\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 [mesh](/mesh/): the p2p transport underneath: webrtc pairing, data channels, qr optimized codecs.\n [ledger](/ledger/): undo/redo history for the commands the host applies.\n [herald](/herald/): typed event bus for the local notice stream tavern relays.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1421
|
-
"api": " \ntitle: 'tavern: api reference'\ndescription: public api of @vielzeug/tavern.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `hosttavern()` | host a subject: pairing, command validation, snapshot broadcast | sync factory | disposing ends hosting for every guest |\n| `jointavern()` | join a session: consume invitation, produce answer | async | the session lives only after the host accepts and the first snapshot mounts |\n| `tavernhost` | host handle: invitation, answer acceptance, kick, notice relay | n/a | `dispose()` is idempotent |\n| `tavernguest` | guest handle: command forwarding, disposal | n/a | `dispose()` fires `onended` exactly once |\n| `taverncommands` | the host's command table | n/a | unknown names and foreign subject ids reject without applying |\n| `tavernsubjects` | snapshot reading, change and removal subscriptions | n/a | `snapshot` returning `null` skips the broadcast |\n| `tavernnotices` | wire serialization of local notices | n/a | `towire` returning `null` skips that notice |\n| `tavernerror` | base error for every tavern failure | n/a | n/a |\n| `tavernpairingerror` | the consumer pasted the wrong kind of pairing code | n/a | n/a |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/tavern` | complete public tavern api |\n\n## `hosttavern(options)`\n\ncreates a mesh host for one subject. guest commands are validated against the wire shape (tavern's own protocol), checked against `commands.has` and the `subjectid`, then applied through `commands.apply`: throwing rejects the guest with the error's message. local changes (via `subjects.onchanged`) re broadcast `subjects.snapshot()`: coalesced on a microtask; `subjects.onremoved` ends hosting. peers are reported through `onpeerschanged`, `onpeerjoined`, and `onpeerleft`; hosting ending: from removal or `dispose()`: fires `onended` exactly once. `relaynotice` serializes through `notices.towire` and broadcasts to every guest.\n\n**returns:** `tavernhost`: the host handle.\n\n| option | type | description |\n| | | |\n| `commands` | `taverncommands` | the host's command table: the same object the host's own ui calls |\n| `notices?` | `tavernnotices` | notice relay; omit to disable notice broadcasting |\n| `onended?` | `() => void` | hosting ended: the subject was removed or `dispose()` ran. fires exactly once |\n| `onpeerschanged?` | `(peers: meshpeer[]) => void` | the full peer list, whenever it changes |\n| `onpeerjoined?` | `(peer: meshpeer) => void` | a peer joined |\n| `onpeerleft?` | `(peer: meshpeer) => void` | a peer left |\n| `onwarning?` | `(message: string) => void` | a transport level warning |\n| `rtc?` | `meshrtcfactory` | webrtc factory: the injection point for tests and non browser runtimes |\n| `subjectid` | `string` | the id of the hosted subject: guest commands targeting anything else reject |\n| `subjects` | `tavernsubjects` | snapshot reading, change and removal subscriptions |\n\n**example**\n\n```ts\nimport { hosttavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\n```\n\n### `tavernhost` members\n\n| member | returns | description |\n| | | |\n| `acceptanswertext(text)` | `promise<meshpeer>` | consumes a guest's answer code; resolves with the peer once the channel opens. throws `tavernpairingerror` for any unusable code: wrong kind, malformed, expired: with the underlying error as `cause` |\n| `createinvitationtext()` | `promise<string>` | produces a single use invitation code, qr compact when the environment allows |\n| `disposalsignal` | `abortsignal` | aborted when hosting ends |\n| `dispose()` | `void` | stops hosting: closes every channel and detaches all subscriptions. idempotent; fires `onended` exactly once |\n| `disposed` | `boolean` | whether hosting has ended |\n| `kick(peerid)` | `void` | disconnects a peer; the guest observes a disconnect |\n| `relaynotice(notice)` | `void` | relays one local notice to every guest; the serializer decides what crosses |\n| `[symbol.dispose]()` | `void` | delegates to `dispose()`. enables `using` declarations |\n\n \n\n## `jointavern(options)`\n\nconsumes an invitation and returns `{ answertext, guest }`. the guest mounts snapshots through `mount(snapshot)`: the first successful mount fires `onjoined`; wire notices are relayed through `notices.fromwire`; rejections through `onrejected`. channel failures before the first mount fire `onfailed` (once); afterwards, a disconnect or `guest.dispose()` fires `onended` with the mounted subject exactly once. `guest.sendcommand` forwards a command to the host with the subject id the consumer routes by. an unusable invitation: wrong kind, malformed, expired: rejects with `tavernpairingerror` and leaves no node or subscriptions behind.\n\n**returns:** `promise<{ answertext: string; guest: tavernguest }>`: the answer code to show back and the guest handle.\n\n| option | type | description |\n| | | |\n| `invitationtext` | `string` | the invitation code the host produced |\n| `mount` | `(snapshot: unknown) => mounted \\| null` | mounts a received snapshot; the mounted value, or `null` to ignore |\n| `name` | `string` | the guest's display name on the channel |\n| `notices?` | `tavernnotices` | notice relay; wire notices re emit locally through `fromwire` |\n| `onended?` | `(subject: mounted) => void` | the channel ended after joining; the mounted value is passed for unmounting |\n| `onfailed?` | `(reason: string) => void` | the host never accepted the answer in time |\n| `onjoined?` | `(subject: mounted) => void` | the mounted value once the first snapshot arrives |\n| `onrejected?` | `(message: string) => void` | the host rejected a forwarded command |\n| `onwarning?` | `(message: string) => void` | a transport level warning |\n| `rtc?` | `meshrtcfactory` | webrtc factory: the injection point for tests and non browser runtimes |\n\n**example**\n\n```ts\nimport { jointavern } from '@vielzeug/tavern';\n\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => sanitizeandmount(snapshot),\n name: 'alex',\n onended: (subject) => unmountmirror(subject.id),\n onjoined: (subject) => mountmirror(subject),\n});\n\nguest.sendcommand('doc 1', 'rename', ['new name']);\nguest.dispose();\n```\n\n### `tavernguest` members\n\n| member | returns | description |\n| | | |\n| `disposalsignal` | `abortsignal` | aborted when the session ends |\n| `dispose()` | `void` | leaves the session and drops the channel. fires `onended` exactly once |\n| `disposed` | `boolean` | whether the session has ended: the channel dropped or `dispose()` ran |\n| `sendcommand(subjectid, name, args)` | `void` | forwards a command to the host. throws `tavernerror` if the session has ended |\n| `[symbol.dispose]()` | `void` | delegates to `dispose()`. enables `using` declarations |\n\n \n\n## types\n\n```ts\n/** what the host can do on behalf of a guest. */\ninterface taverncommands {\n /** whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n /** applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n}\n\n/**\n * how the host reads and watches the subject it is sharing. all three close over\n * whatever subject state the consumer owns: the host routes by `subjectid`,\n * not by a subject object.\n */\ninterface tavernsubjects {\n /** reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n /** subscribes to local changes of the hosted subject: the re broadcast trigger. */\n onchanged(listener: () => void): () => void;\n /** subscribes to the hosted subject's removal: hosting ends when it fires. */\n onremoved(listener: () => void): () => void;\n}\n\n/**\n * notice relay between host and guests. notices cross the wire as opaque\n * values: each client translates locally.\n */\ninterface tavernnotices {\n /** serializes a local notice for the wire; null skips this one. */\n towire(notice: unknown): unknown | null;\n /** re emits a received wire notice locally. */\n fromwire(wire: unknown): void;\n}\n\n/** options for `hosttavern`. */\ninterface tavernhostoptions {\n commands: taverncommands;\n notices?: tavernnotices;\n /** hosting ended: the subject was removed or `dispose()` ran. fires exactly once. */\n onended?(): void;\n onpeerschanged?(peers: meshpeer[]): void;\n onpeerjoined?(peer: meshpeer): void;\n onpeerleft?(peer: meshpeer): void;\n onwarning?(message: string): void;\n rtc?: meshrtcfactory;\n subjectid: string;\n subjects: tavernsubjects;\n}\n\n/** options for `jointavern`. */\ninterface tavernguestoptions<mounted> {\n invitationtext: string;\n /** mounts a received snapshot; the mounted value, or null to ignore. */\n mount(snapshot: unknown): mounted | null;\n name: string;\n notices?: tavernnotices;\n onended?(subject: mounted): void;\n onfailed?(reason: string): void;\n onjoined?(subject: mounted): void;\n onrejected?(message: string): void;\n onwarning?(message: string): void;\n rtc?: meshrtcfactory;\n}\n\n/** host handle returned by `hosttavern`. */\ninterface tavernhost {\n acceptanswertext(text: string): promise<meshpeer>;\n createinvitationtext(): promise<string>;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n kick(peerid: string): void;\n relaynotice(notice: unknown): void;\n [symbol.dispose](): void;\n}\n\n/** guest handle returned by `jointavern`. */\ninterface tavernguest {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n sendcommand(subjectid: string, name: string, args: readonly unknown[]): void;\n [symbol.dispose](): void;\n}\n```\n\n## errors\n\n| error | triggered by | notable properties |\n| | | |\n| `tavernerror` | base class for every tavern failure: catch this to handle all tavern errors in one branch | n/a |\n| `tavernpairingerror` | a pairing code the consumer pasted could not be used: wrong kind, malformed, expired, or refused by the host. the mesh level failure is chained as `cause` | `cause` |\n\n`tavernpairingerror` extends `tavernerror`. every user input pairing mistake: a garbage code, the wrong code kind, an expired invitation, a refused answer: surfaces as `tavernpairingerror` from `acceptanswertext` and `jointavern`; transport level failures (timeouts, connection errors) propagate unchanged. `tavernguest.sendcommand` throws `tavernerror` when the session has already ended.\n",
|
|
1422
|
-
"usage": " \ntitle: 'tavern: usage guide'\ndescription: how to use @vielzeug/tavern.\n \n\n[[toc]]\n\n## basic usage\n\nhost one subject per session; guests join through the invitation/answer pair. both sides share one command table, so remote actions run the same code as local ones.\n\n```ts\nimport { hosttavern, jointavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: { apply: (name, args) => mystore.apply(name, args), has: (name) => name in mystore },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\nconst invitation = await host.createinvitationtext();\n\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => parseandmount(snapshot),\n name: 'alex',\n});\nawait host.acceptanswertext(answertext);\n\nguest.sendcommand('doc 1', 'rename', ['quarterly report']);\n```\n\n## host a subject\n\nthe host validates every guest command through the command table and broadcasts the snapshot on every local change: coalesced on a microtask, so bursts of changes ship one snapshot, not many. `onended` fires exactly once when hosting ends, whether the subject was removed or the host was disposed: reset your session ui there instead of intercepting your own `onremoved` seam.\n\n```ts\nimport { hosttavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n onended: () => updatesessionui(), // hosting ended: subject removed or disposed\n
|
|
1420
|
+
"index": " \ntitle: tavern\ndescription: \"table sessions over mesh: host owned state replication with guest command forwarding\"\npackage: tavern\ncategory: utilities\nkeywords: [session, host, guest, replication, mesh, commands, snapshots, collaboration, peer to peer]\nexports: [hosttavern, jointavern, tavernhost, tavernguest, tavernhostevent, tavernguestevent, taverncommands, tavernnotices, tavernerror, tavernpairingerror, taverndisposederror]\nrelated: [mesh, ledger, herald]\nenvironments: [browser, node]\n \n\n<! markdownlint disable md025 md033 md060 >\n\n<packagehero package=\"tavern\" />\n\n## why tavern?\n\npeer to peer apps share one arrangement problem: the canonical state of something: a document, a dashboard, a record: lives on exactly one device, while every other participant should see it and be able to change it from their own. tavern turns that arrangement into a session: **one host owns the canonical state, guests mirror it, and every guest command is forwarded through the host's own command table**, so remote actions follow exactly the same code path as local ones.\n\n```ts\n// before: hand rolled session orchestration: pairing, protocol validation,\n// command routing, snapshot broadcasting, notice relaying, presence tracking.\nconst host = createmeshhost({ /* ... */ });\nhost.on('command', ({ peerid, payload }) => {\n const command = myschema.safeparse(payload);\n if (!command.success) { host.send(peerid, 'rejected', { /* ... */ }); return; }\n try { applysubjectcommand(command.data.name, subject, command.data.args); }\n catch (error) { host.send(peerid, 'rejected', { /* ... */ }); }\n});\nbus.on('subject:updated', () => { host.broadcast('snapshot', snapshotfor(subject)); });\n// ... 40+ more lines of plumbing\n\n// after: tavern owns the protocol, the validation, the broadcasting.\nconst host = hosttavern({\n commands: { apply: (name, args) => store.apply(name, args), has: (name) => name in store },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => store.onupdated('doc 1', listener),\n onremoved: (listener) => store.onremoved('doc 1', listener),\n snapshot: () => store.read('doc 1'),\n },\n});\n```\n\n| feature | tavern | raw mesh | socket.io rooms |\n| | | | |\n| bundle size | <packageinfo package=\"tavern\" type=\"size\" /> | <packageinfo package=\"mesh\" type=\"size\" /> | n/a |\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| host authoritative commands | <ore icon name=\"check\" size=\"16\"></ore icon>: same table as local ui | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: wire it yourself | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: wire it yourself |\n| snapshot replication | <ore icon name=\"check\" size=\"16\"></ore icon>: microtask coalesced | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon> |\n| out of band pairing (qr / copy paste) | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"check\" size=\"16\"></ore icon> | <ore icon name=\"x\" size=\"16\"></ore icon>: needs a server |\n| notice relaying | <ore icon name=\"check\" size=\"16\"></ore icon>: consumer defined wire shape | <ore icon name=\"x\" size=\"16\"></ore icon> | <ore icon name=\"triangle alert\" size=\"16\"></ore icon>: server mediated |\n\n<div class=\"decision callout\">\n\n**use tavern when** one device should own the canonical state of something: a shared document, a live dashboard, a record under review, and every other device should mirror it and send commands back through the host's own command table, which stays the single validation path.\n\n**consider raw `@vielzeug/mesh` when** you need p2p messaging without the host authoritative replication model: chat, whiteboards, file transfer, or custom topologies.\n\n</div>\n\n## installation\n\n::: code group\n\n```sh [pnpm]\npnpm add @vielzeug/tavern\n```\n\n```sh [npm]\nnpm install @vielzeug/tavern\n```\n\n```sh [yarn]\nyarn add @vielzeug/tavern\n```\n\n:::\n\n## quick start\n\n```ts\nimport { hosttavern, jointavern } from '@vielzeug/tavern';\n\n// host: pick a subject, wire the command table and snapshot access.\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\n\n// produce a single use invitation; deliver it out of band (qr, copy/paste).\nconst invitation = await host.createinvitationtext();\n\n// guest: consume the invitation, produce an answer for the host.\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => parseandmount(snapshot),\n name: 'alex',\n});\nawait host.acceptanswertext(answertext);\n\n// the guest's commands run through the host's own table.\nguest.sendcommand('doc 1', 'rename', ['quarterly report']);\n\n// cleanup: hosting ends for every guest; onended fires exactly once on both sides.\nhost.dispose();\nguest.dispose();\n```\n\n## features\n\n<div class=\"features grid\">\n\n **`hosttavern`**: host one subject: pairing, protocol validation, command application, coalesced snapshot broadcasts\n **`jointavern`**: join as a guest: consume the invitation, produce the answer, mirror the host's snapshots\n **`taverncommands`**: the host's command table: the same object the host's own ui calls, so remote actions cannot bypass validation\n **`tavernnotices`**: consumer defined notice serialization: catalog keys cross the wire, each client translates locally\n **coalesced snapshots**: bursts of local changes ship one snapshot per microtask, not one per change\n **`onended` fires exactly once**: on both handles: channel drops, subject removal, and explicit disposal share a single idempotent cleanup path\n **typed errors**: `tavernpairingerror` covers every unusable pasted code with the mesh failure chained as `cause`\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 [mesh](/mesh/): the p2p transport underneath: webrtc pairing, data channels, qr optimized codecs.\n [ledger](/ledger/): undo/redo history for the commands the host applies.\n [herald](/herald/): typed event bus for the local notice stream tavern relays.\n\n</div>\n\n<! markdownlint enable md025 md033 md060 >\n",
|
|
1421
|
+
"api": " \ntitle: 'tavern: api reference'\ndescription: public api of @vielzeug/tavern.\n \n\n[[toc]]\n\n## api overview\n\n| symbol | purpose | execution | common gotcha |\n| | | | |\n| `hosttavern()` | host a subject: pairing, command validation, snapshot broadcast | sync factory | disposing ends hosting for every guest |\n| `jointavern()` | join a session: consume invitation, produce answer | async | the session lives only after the host accepts and the first snapshot mounts |\n| `tavernhost` | host handle: invitation, answer acceptance, kick, notice relay, presence tap | n/a | `dispose()` is idempotent; control plane methods throw `taverndisposederror` after |\n| `tavernguest` | guest handle: command forwarding, rejection tap, disposal | n/a | `dispose()` fires `onended` exactly once |\n| `tavernhostevent` | host observability union for `tap()`: presence and warnings | n/a | lifecycle transitions are callbacks, not events |\n| `tavernguestevent` | guest observability union for `tap()`: rejections and warnings | n/a | `onjoined`/`onfailed` fire before the handle exists, so they stay callbacks |\n| `taverncommands` | the host's command table | n/a | unknown names and foreign subject ids reject without applying |\n| `tavernsubjects` | snapshot reading, change and removal subscriptions | n/a | `snapshot` returning `null` skips the broadcast |\n| `tavernnotices` | wire serialization of local notices | n/a | `towire` returning `null` skips that notice |\n| `tavernerror` | base error for every tavern failure | n/a | n/a |\n| `tavernpairingerror` | the consumer pasted the wrong kind of pairing code | n/a | n/a |\n| `taverndisposederror` | a stopped host or ended guest was used again | n/a | n/a |\n\n## package entry point\n\n| import | purpose |\n| | |\n| `@vielzeug/tavern` | complete public tavern api |\n\n## `hosttavern(options)`\n\ncreates a mesh host for one subject. guest commands are validated against the wire shape (tavern's own protocol), checked against `commands.has` and the `subjectid`, then applied through `commands.apply`: throwing rejects the guest with the error's message. local changes (via `subjects.onchanged`) re broadcast `subjects.snapshot()`: coalesced on a microtask; `subjects.onremoved` ends hosting. presence and transport warnings are reported through `tap()` as `tavernhostevent`s, and the live peer list is the `peers` getter; hosting ending: from removal or `dispose()`: fires `onended` exactly once. `relaynotice` serializes through `notices.towire` and broadcasts to every guest.\n\n**returns:** `tavernhost`: the host handle.\n\n| option | type | description |\n| | | |\n| `commands` | `taverncommands` | the host's command table: the same object the host's own ui calls |\n| `notices?` | `tavernnotices` | notice relay; omit to disable notice broadcasting |\n| `onended?` | `() => void` | hosting ended: the subject was removed or `dispose()` ran. fires exactly once |\n| `rtc?` | `meshrtcfactory` | webrtc factory: the injection point for tests and non browser runtimes |\n| `subjectid` | `string` | the id of the hosted subject: guest commands targeting anything else reject |\n| `subjects` | `tavernsubjects` | snapshot reading, change and removal subscriptions |\n\n**example**\n\n```ts\nimport { hosttavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\n```\n\n### `tavernhost` members\n\n| member | returns | description |\n| | | |\n| `acceptanswertext(text)` | `promise<meshpeer>` | consumes a guest's answer code; resolves with the peer once the channel opens. throws `tavernpairingerror` for any unusable code: wrong kind, malformed, expired: with the underlying error as `cause`. throws `taverndisposederror` after `dispose()` |\n| `createinvitationtext()` | `promise<string>` | produces a single use invitation code, qr compact when the environment allows. throws `taverndisposederror` after `dispose()` |\n| `disposalsignal` | `abortsignal` | aborted when hosting ends |\n| `dispose()` | `void` | stops hosting: closes every channel and detaches all subscriptions. idempotent; fires `onended` exactly once |\n| `disposed` | `boolean` | whether hosting has ended |\n| `kick(peerid)` | `void` | disconnects a peer; the guest observes a disconnect. throws `taverndisposederror` after `dispose()` |\n| `peers` | `readonly meshpeer[]` | the guests currently connected; current inside a `peer joined`/`peer left` tap event |\n| `relaynotice(notice)` | `void` | relays one local notice to every guest; the serializer decides what crosses. a notice relayed after `dispose()` is dropped: ephemeral ui state with no audience left |\n| `tap(handler, options?)` | `() => void` | observes `tavernhostevent`s; returns an unsubscribe function |\n| `[symbol.dispose]()` | `void` | delegates to `dispose()`. enables `using` declarations |\n\n \n\n## `jointavern(options)`\n\nconsumes an invitation and returns `{ answertext, guest }`. the guest mounts snapshots through `mount(snapshot)`: the first successful mount fires `onjoined`; wire notices are relayed through `notices.fromwire`; host rejections and transport warnings arrive through `tap()` as `tavernguestevent`s. channel failures before the first mount fire `onfailed` (once); afterwards, a disconnect or `guest.dispose()` fires `onended` with the mounted subject exactly once. `guest.sendcommand` forwards a command to the host with the subject id the consumer routes by. an unusable invitation: wrong kind, malformed, expired: rejects with `tavernpairingerror` and leaves no node or subscriptions behind.\n\n**returns:** `promise<{ answertext: string; guest: tavernguest }>`: the answer code to show back and the guest handle.\n\n| option | type | description |\n| | | |\n| `invitationtext` | `string` | the invitation code the host produced |\n| `mount` | `(snapshot: unknown) => mounted \\| null` | mounts a received snapshot; the mounted value, or `null` to ignore |\n| `name` | `string` | the guest's display name on the channel |\n| `notices?` | `tavernnotices` | notice relay; wire notices re emit locally through `fromwire` |\n| `onended?` | `(subject: mounted) => void` | the channel ended after joining; the mounted value is passed for unmounting |\n| `onfailed?` | `(reason: string) => void` | the host never accepted the answer in time |\n| `onjoined?` | `(subject: mounted) => void` | the mounted value once the first snapshot arrives |\n| `rtc?` | `meshrtcfactory` | webrtc factory: the injection point for tests and non browser runtimes |\n\n**example**\n\n```ts\nimport { jointavern } from '@vielzeug/tavern';\n\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => sanitizeandmount(snapshot),\n name: 'alex',\n onended: (subject) => unmountmirror(subject.id),\n onjoined: (subject) => mountmirror(subject),\n});\nguest.tap((event) => {\n if (event.type === 'rejected') console.warn(event.commandid, event.message);\n});\n\nguest.sendcommand('doc 1', 'rename', ['new name']);\nguest.dispose();\n```\n\n### `tavernguest` members\n\n| member | returns | description |\n| | | |\n| `disposalsignal` | `abortsignal` | aborted when the session ends |\n| `dispose()` | `void` | leaves the session and drops the channel. fires `onended` exactly once |\n| `disposed` | `boolean` | whether the session has ended: the channel dropped or `dispose()` ran |\n| `sendcommand(subjectid, name, args)` | `void` | forwards a command to the host. throws `taverndisposederror` if the session has ended |\n| `tap(handler, options?)` | `() => void` | observes `tavernguestevent`s; returns an unsubscribe function |\n| `[symbol.dispose]()` | `void` | delegates to `dispose()`. enables `using` declarations |\n\n \n\n## types\n\n```ts\n/** what the host can do on behalf of a guest. */\ninterface taverncommands {\n /** whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n /** applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n}\n\n/**\n * how the host reads and watches the subject it is sharing. all three close over\n * whatever subject state the consumer owns: the host routes by `subjectid`,\n * not by a subject object.\n */\ninterface tavernsubjects {\n /** reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n /** subscribes to local changes of the hosted subject: the re broadcast trigger. */\n onchanged(listener: () => void): () => void;\n /** subscribes to the hosted subject's removal: hosting ends when it fires. */\n onremoved(listener: () => void): () => void;\n}\n\n/**\n * notice relay between host and guests. notices cross the wire as opaque\n * values: each client translates locally.\n */\ninterface tavernnotices {\n /** serializes a local notice for the wire; null skips this one. */\n towire(notice: unknown): unknown | null;\n /** re emits a received wire notice locally. */\n fromwire(wire: unknown): void;\n}\n\n/** notable host moments reported through `tavernhost.tap`. */\ntype tavernhostevent =\n | { readonly peer: meshpeer; readonly type: 'peer joined' }\n | { readonly peer: meshpeer; readonly reason?: string; readonly type: 'peer left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** notable guest moments reported through `tavernguest.tap`. */\ntype tavernguestevent =\n | { readonly commandid: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** options for `hosttavern`. */\ninterface tavernhostoptions {\n commands: taverncommands;\n notices?: tavernnotices;\n /** hosting ended: the subject was removed or `dispose()` ran. fires exactly once. */\n onended?(): void;\n rtc?: meshrtcfactory;\n subjectid: string;\n subjects: tavernsubjects;\n}\n\n/** options for `jointavern`. */\ninterface tavernguestoptions<mounted> {\n invitationtext: string;\n /** mounts a received snapshot; the mounted value, or null to ignore. */\n mount(snapshot: unknown): mounted | null;\n name: string;\n notices?: tavernnotices;\n onended?(subject: mounted): void;\n onfailed?(reason: string): void;\n onjoined?(subject: mounted): void;\n rtc?: meshrtcfactory;\n}\n\n/** host handle returned by `hosttavern`. */\ninterface tavernhost {\n acceptanswertext(text: string): promise<meshpeer>;\n createinvitationtext(): promise<string>;\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n kick(peerid: string): void;\n readonly peers: readonly meshpeer[];\n relaynotice(notice: unknown): void;\n tap(handler: (event: tavernhostevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n [symbol.dispose](): void;\n}\n\n/** guest handle returned by `jointavern`. */\ninterface tavernguest {\n readonly disposalsignal: abortsignal;\n dispose(): void;\n readonly disposed: boolean;\n sendcommand(subjectid: string, name: string, args: readonly unknown[]): void;\n tap(handler: (event: tavernguestevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n [symbol.dispose](): void;\n}\n```\n\n## errors\n\n| error | triggered by | notable properties |\n| | | |\n| `tavernerror` | base class for every tavern failure: catch this to handle all tavern errors in one branch | n/a |\n| `tavernpairingerror` | a pairing code the consumer pasted could not be used: wrong kind, malformed, expired, or refused by the host. the mesh level failure is chained as `cause` | `cause` |\n| `taverndisposederror` | a stopped host (`kick`, `createinvitationtext`, `acceptanswertext`) or an ended guest (`sendcommand`) was used again | n/a |\n\n`tavernpairingerror` and `taverndisposederror` extend `tavernerror`. every user input pairing mistake: a garbage code, the wrong code kind, an expired invitation, a refused answer: surfaces as `tavernpairingerror` from `acceptanswertext` and `jointavern`; transport level failures (timeouts, connection errors) propagate unchanged. post dispose use fails loudly with `taverndisposederror`, mirroring mesh inside the tavern error hierarchy.\n",
|
|
1422
|
+
"usage": " \ntitle: 'tavern: usage guide'\ndescription: how to use @vielzeug/tavern.\n \n\n[[toc]]\n\n## basic usage\n\nhost one subject per session; guests join through the invitation/answer pair. both sides share one command table, so remote actions run the same code as local ones.\n\n```ts\nimport { hosttavern, jointavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: { apply: (name, args) => mystore.apply(name, args), has: (name) => name in mystore },\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\nconst invitation = await host.createinvitationtext();\n\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => parseandmount(snapshot),\n name: 'alex',\n});\nawait host.acceptanswertext(answertext);\n\nguest.sendcommand('doc 1', 'rename', ['quarterly report']);\n```\n\n## host a subject\n\nthe host validates every guest command through the command table and broadcasts the snapshot on every local change: coalesced on a microtask, so bursts of changes ship one snapshot, not many. `onended` fires exactly once when hosting ends, whether the subject was removed or the host was disposed: reset your session ui there instead of intercepting your own `onremoved` seam.\n\n```ts\nimport { hosttavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n commands: {\n apply: (name, args) => mystore.apply(name, args),\n has: (name) => name in mystore,\n },\n onended: () => updatesessionui(), // hosting ended: subject removed or disposed\n subjectid: 'doc 1',\n subjects: {\n onchanged: (listener) => mystore.onupdated('doc 1', listener),\n onremoved: (listener) => mystore.onremoved('doc 1', listener),\n snapshot: () => mystore.read('doc 1'),\n },\n});\n\n// presence and transport warnings arrive through tap(); the peer list is a getter.\nhost.tap((event) => {\n if (event.type === 'peer joined') toast(`${event.peer.name ?? 'a guest'} joined`);\n if (event.type === 'peer left') toast(`${event.peer.name ?? 'a guest'} left`);\n if (event.type === 'warning') log.warn(event.message, event.error);\n updatepeerlist(host.peers); // always current inside a presence event\n});\n\nconst invitation = await host.createinvitationtext();\n// show the invitation (qr or copy/paste); the guest returns an answer code.\nawait host.acceptanswertext(answercode);\n\n// kick a peer; the guest observes a disconnect and fires onended.\nhost.kick(peerid);\n\nhost.dispose(); // stop hosting: every guest disconnects\n```\n\n## join as a guest\n\njoining consumes the invitation and produces the answer code to show back. the session goes live once the host accepts the answer and the first snapshot arrives.\n\n```ts\nimport { jointavern } from '@vielzeug/tavern';\n\nconst { answertext, guest } = await jointavern({\n invitationtext: invitation,\n mount: (snapshot) => sanitizeandmount(snapshot),\n name: 'alex',\n onended: (subject) => unmountmirror(subject.id),\n onfailed: (reason) => showerror(reason),\n onjoined: (subject) => mountmirror(subject),\n});\nguest.tap((event) => {\n if (event.type === 'rejected') showtoast(event.message); // event.commandid names the command it answers\n if (event.type === 'warning') log.warn(event.message, event.error);\n});\n\nguest.sendcommand('doc 1', 'rename', ['new name']);\nguest.dispose(); // leave; onended fires with the mounted subject exactly once\n```\n\nthe snapshot arrives as parsed json: `mount` receives `unknown` and should validate before trusting it. returning `null` ignores the snapshot; returning the mounted value fires `onjoined` (first time only) and passes it to `onended` when the session ends.\n\n## relay notices\n\ngive both sides a notice seam: catalog keys rather than prose, so each client translates locally, and the host relays its own notices to every guest.\n\n```ts\nimport type { tavernnotices } from '@vielzeug/tavern';\n\nconst notices: tavernnotices = {\n fromwire: (wire) => bus.emit('notify', wire),\n towire: (notice) => (islocalnotice(notice) ? towireshape(notice) : null),\n};\n\n// host side: forward every local notice through the session.\nhost.relaynotice(localnotice);\n```\n\n**echo guard**: a tab that both hosts and guests must prevent re broadcasting a wire originated notice back to its own guests. one boolean set during `fromwire` and checked in `towire` is sufficient: the consumer owns this because only they know their local event system.\n\n## observe with tap()\n\nboth handles expose `tap()` for observability: presence and warnings on the host, rejections and warnings on the guest. handler errors are swallowed, and the subscription detaches when its signal aborts or the session ends. lifecycle transitions stay callbacks (`onended`, `onjoined`, `onfailed`) because they fire while `jointavern` is still resolving, before you can hold the handle.\n\n```ts\nimport type { tavernguestevent, tavernhostevent } from '@vielzeug/tavern';\n\nhost.tap((event: tavernhostevent) => {\n if (event.type === 'warning') log.warn(event.message, event.error); // the original error, not just text\n});\nguest.tap((event: tavernguestevent) => {\n if (event.type === 'rejected') log.info(`command ${event.commandid} rejected: ${event.message}`);\n});\n```\n\n## handle pairing mistakes\n\nevery user input pairing failure surfaces as `tavernpairingerror` from both `acceptanswertext` and `jointavern`: a garbage code, the wrong code kind, an expired invitation, or an answer the host refuses. catch it to show a helpful message instead of a raw error; the underlying mesh failure is chained as `cause` when you need it.\n\n```ts\nimport { hosttavern, tavernpairingerror } from '@vielzeug/tavern';\n\ntry {\n await host.acceptanswertext(pastedcode);\n} catch (error) {\n if (error instanceof tavernpairingerror) {\n showtoast(error.message); // \"that code is an invitation, not a guest answer.\"\n } else {\n throw error;\n }\n}\n```\n\n## working with other vielzeug libraries\n\ntavern builds on `@vielzeug/mesh` for the webrtc transport, pairing codes, and qr optimized codecs. you do not interact with mesh directly: tavern owns the protocol, but the `rtc` option accepts a `meshrtcfactory` for testing or non browser runtimes, and the peers carried by tap events and the {@link tavernhost.peers} getter are mesh types.\n\n```ts\nimport type { meshrtcfactory } from '@vielzeug/mesh';\nimport { hosttavern } from '@vielzeug/tavern';\n\nconst host = hosttavern({\n // ... commands, subjects\n rtc: mytestrtcfactory, // inject an in memory webrtc fake\n subjectid: 'doc 1',\n});\n```\n\npair `@vielzeug/ledger` on the host to give the commands tavern applies an undo history: commands applied through the table land in the ledger like local ones.\n\n## best practices\n\n keep the command table the same object the host's own ui calls, so guest actions cannot bypass local validation.\n return `null` from `snapshot` while the subject is missing: the broadcast simply skips.\n reset host side session ui in `onended`: it fires exactly once for subject removal and explicit disposal alike, so you never need to double wire your own `onremoved` seam.\n treat notices as catalog keys, not prose: each client translates locally.\n guard against notice echo if a tab can both host and guest: one boolean in `towire` is sufficient.\n dispose guests and hosts when their screen unmounts; channels also clean up on disconnect through `onended`, which fires exactly once on both sides.\n catch `tavernpairingerror` separately from other errors: it means the pasted code could not be used, not that something is broken.\n",
|
|
1423
1423
|
"examples": " \ntitle: 'tavern: examples'\ndescription: practical examples and recipes for @vielzeug/tavern.\n \n\n## examples\n\n [share state across devices](./examples/share state across devices.md)\n"
|
|
1424
1424
|
},
|
|
1425
1425
|
"examples": [],
|
|
1426
|
-
"exports": "hosttavern jointavern tavernhost tavernguest taverncommands tavernnotices tavernerror tavernpairingerror",
|
|
1426
|
+
"exports": "hosttavern jointavern tavernhost tavernguest tavernhostevent tavernguestevent taverncommands tavernnotices tavernerror tavernpairingerror taverndisposederror",
|
|
1427
1427
|
"keywords": "session host guest replication mesh commands snapshots collaboration peer to peer",
|
|
1428
1428
|
"name": "@vielzeug/tavern",
|
|
1429
1429
|
"related": "mesh ledger herald",
|
|
1430
1430
|
"slug": "tavern",
|
|
1431
|
-
"source": "import {\n createmeshguest,\n createmeshhost,\n type meshanswer,\n type meshinvitation,\n meshpairingerror,\n type meshpeer,\n type meshprotocol,\n type meshrtcfactory,\n meshqrcodec,\n} from '@vielzeug/mesh';\n\nimport { tavernerror, tavernpairingerror } from './errors';\n\nexport { tavernerror, tavernpairingerror };\n\n/**\n * reclassifies a mesh pairing failure as the tavern level user mistake it is:\n * the code the consumer pasted could not be used. the message is preserved and\n * the original error is chained as `cause`.\n */\nfunction topairingerror(error: unknown): tavernpairingerror {\n const message = error instanceof error ? error.message : 'that code could not be read.';\n return new tavernpairingerror(message, { cause: error });\n}\n\n/**\n * table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests\n * mirror it and forward every command through the host's command table, so guest actions follow\n * exactly the same code path as host actions.\n *\n * the consumer supplies the seams, which commands exist, how subject state is read and mounted,\n * how notices cross between the wire and the local ui. tavern owns the pairing, the protocol,\n * command validation, snapshot broadcasting, notice relaying, and presence.\n */\n\n/** what the host can do on behalf of a guest. */\nexport interface taverncommands {\n /** applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n /** whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n}\n\n/**\n * how the host reads and watches the subject it is sharing. all three close over whatever\n * subject state the consumer owns: the host routes by `subjectid`, not by a subject object.\n */\nexport interface tavernsubjects {\n /** subscribes to local changes of the hosted subject: the re broadcast trigger. */\n onchanged(listener: () => void): () => void;\n /** subscribes to the hosted subject's removal: hosting ends when it fires. */\n onremoved(listener: () => void): () => void;\n /** reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}\n\n/**\n * notice relay between host and guests. notices cross the wire as opaque values: each client\n * translates locally.\n *\n * **echo guard**: a tab that both hosts and guests must guard against re broadcasting a\n * wire originated notice back to its own guests: `fromwire` emits locally, the host's local\n * subscription picks it up, and without a guard it would loop. the consumer owns this guard\n * because only they know their local event system; one boolean set during `fromwire` and\n * checked in `towire` is sufficient.\n */\nexport interface tavernnotices {\n /** re emits a received wire notice locally. */\n fromwire(wire: unknown): void;\n /** serializes a local notice for the wire; null skips this one. */\n towire(notice: unknown): unknown | null;\n}\n\ninterface wireprotocol extends meshprotocol {\n toguest: {\n notice: unknown;\n rejected: { commandid: string; message: string };\n snapshot: unknown;\n };\n tohost: {\n command: { args: unknown[]; id: string; name: string; subjectid: string };\n };\n}\n\n/** snapshots can be large; the default mesh frame cap is too tight for whole documents. */\nconst default_max_message_bytes = 512 * 1024;\n\nconst commandid = (): string => `cmd ${math.random().tostring(36).slice(2, 10)}${date.now().tostring(36)}`;\n\n/** runtime guard for the wire command shape: the protocol is tavern's, so tavern validates it. */\nfunction parsewirecommand(raw: unknown): { args: unknown[]; id: string; name: string; subjectid: string } | null {\n if (typeof raw !== 'object' || raw === null) return null;\n const { args, id, name, subjectid } = raw as record<string, unknown>;\n if (typeof id !== 'string' || typeof name !== 'string' || typeof subjectid !== 'string' || !array.isarray(args)) {\n return null;\n }\n return { args, id, name, subjectid };\n}\n\n// \n// host\n// \n\nexport interface tavernhostoptions {\n commands: taverncommands;\n notices?: tavernnotices;\n /** hosting ended: the subject was removed or the host was disposed. fires exactly once. */\n onended?(): void;\n onpeerjoined?(peer: meshpeer): void;\n onpeerleft?(peer: meshpeer): void;\n onpeerschanged?(peers: meshpeer[]): void;\n onwarning?(message: string): void;\n rtc?: meshrtcfactory;\n /** the id of the subject being hosted: guest commands targeting anything else reject. */\n subjectid: string;\n subjects: tavernsubjects;\n}\n\nexport interface tavernhost {\n /** consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptanswertext(text: string): promise<meshpeer>;\n /** produces a single use invitation code, qr compact when the environment allows. */\n createinvitationtext(): promise<string>;\n /** `abortsignal` aborted when hosting ends. */\n readonly disposalsignal: abortsignal;\n /** stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** whether hosting has ended: the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerid: string): void;\n /** relays one local notice to every guest; the serializer decides what crosses. */\n relaynotice(notice: unknown): void;\n /** delegates to `dispose()`. enables `using` declarations. */\n [symbol.dispose](): void;\n}\n\n/**\n * starts hosting one subject. every guest command is validated and applied through the\n * command table; local changes re broadcast the snapshot (coalesced on a microtask); removal\n * ends the session.\n */\nexport function hosttavern(options: tavernhostoptions): tavernhost {\n const host = createmeshhost<wireprotocol>({\n maxmessagebytes: default_max_message_bytes,\n rtc: options.rtc,\n });\n let closed = false;\n let flushscheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n options.onended?.();\n };\n\n /** reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readsnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n options.onwarning?.(error instanceof error ? error.message : 'reading the snapshot failed.');\n return null;\n }\n };\n\n /** coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastsnapshot = (): void => {\n if (closed || flushscheduled) return;\n flushscheduled = true;\n queuemicrotask(() => {\n flushscheduled = false;\n if (closed) return;\n const snapshot = readsnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * applies a guest command through the command table: the same table the host's own ui\n * calls, so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleguestcommand = (peerid: string, raw: unknown): void => {\n const command = parsewirecommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectid !== options.subjectid) {\n host.send(peerid, 'rejected', {\n commandid: command?.id ?? '',\n message: 'the host rejected an unknown or out of session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerid, 'rejected', {\n commandid: command.id,\n message: error instanceof error ? error.message : 'the host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerid, payload }) => handleguestcommand(peerid, payload)),\n options.subjects.onchanged(broadcastsnapshot),\n options.subjects.onremoved(close),\n host.tap((event) => {\n if (event.type === 'peer joined' || event.type === 'peer left') {\n options.onpeerschanged?.([...host.peers.values()]);\n }\n if (event.type === 'peer joined') {\n const snapshot = readsnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n options.onpeerjoined?.(event.peer);\n }\n if (event.type === 'peer left') options.onpeerleft?.(event.peer);\n if (event.type === 'error') options.onwarning?.(event.error.message);\n }),\n ];\n\n return {\n acceptanswertext: async (text: string): promise<meshpeer> => {\n let decoded: meshinvitation | meshanswer;\n try {\n decoded = await meshqrcodec.decode(text);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new tavernpairingerror('that code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptanswer(decoded);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n },\n createinvitationtext: async () => meshqrcodec.encode(await host.createinvitation()),\n get disposalsignal() {\n return host.disposalsignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerid: string): void => {\n host.kick(peerid);\n },\n relaynotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.towire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n [symbol.dispose]() {\n close();\n },\n };\n}\n\n// \n// guest\n// \n\nexport interface tavernguestoptions<mounted> {\n /** the invitation code the host produced. */\n invitationtext: string;\n /**\n * mounts a received snapshot; the mounted value, or null to ignore. the snapshot arrives\n * as parsed json: validate before trusting it.\n */\n mount(snapshot: unknown): mounted | null;\n /** the guest's display name on the channel. */\n name: string;\n notices?: tavernnotices;\n /** the channel ended after joining; the mounted value is passed for unmounting. */\n onended?(subject: mounted): void;\n /** the host never accepted the answer in time. */\n onfailed?(reason: string): void;\n /** the mounted value once the first snapshot arrives. */\n onjoined?(subject: mounted): void;\n /** the host rejected a forwarded command. */\n onrejected?(message: string): void;\n onwarning?(message: string): void;\n rtc?: meshrtcfactory;\n}\n\nexport interface tavernguest {\n /** `abortsignal` aborted when the session ends. */\n readonly disposalsignal: abortsignal;\n /** leaves the session and drops the channel. */\n dispose(): void;\n /** whether the session has ended: the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * forwards a command to the host. the subject id is the consumer's routing key: the host\n * rejects commands that do not name the subject it is hosting.\n */\n sendcommand(subjectid: string, name: string, args: readonly unknown[]): void;\n /** delegates to `dispose()`. enables `using` declarations. */\n [symbol.dispose](): void;\n}\n\n/**\n * joins a session as a guest: consumes the host's invitation and returns the answer code to\n * show back. the session goes live once the host accepts the answer and the first snapshot\n * mounts; disposing the guest (or the channel dropping) ends it exactly once.\n */\nexport async function jointavern<mounted>(\n options: tavernguestoptions<mounted>,\n): promise<{ answertext: string; guest: tavernguest }> {\n let decoded: meshinvitation | meshanswer;\n try {\n decoded = await meshqrcodec.decode(options.invitationtext);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new tavernpairingerror('that code is not a session invitation.');\n }\n\n const guest = createmeshguest<wireprotocol>({\n maxmessagebytes: default_max_message_bytes,\n rtc: options.rtc,\n });\n let mounted: mounted | null = null;\n let ended = false;\n\n /** ends the session exactly once: from the channel dropping or an explicit dispose. */\n const endsession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n if (subject !== null) options.onended?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onjoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromwire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n options.onrejected?.(payload.message || 'the host rejected the action.');\n }),\n guest.tap((event) => {\n if (event.type !== 'status change' && event.type !== 'peer status change') return;\n if (event.type === 'peer status change' && event.peerid !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onfailed?.('the host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endsession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') options.onwarning?.(event.error.message);\n }),\n ];\n\n try {\n const answer = await guest.acceptinvitation(decoded, { name: options.name });\n return {\n answertext: await meshqrcodec.encode(answer),\n guest: {\n get disposalsignal() {\n return guest.disposalsignal;\n },\n dispose: endsession,\n get disposed() {\n return ended;\n },\n sendcommand: (subjectid: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new tavernerror('the session has ended.');\n guest.send('command', { args: [...args], id: commandid(), name, subjectid });\n },\n [symbol.dispose]() {\n endsession();\n },\n },\n };\n } catch (error) {\n // a failed join must not leave the node and its subscriptions behind.\n endsession();\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n}\n"
|
|
1431
|
+
"source": "import {\n createmeshguest,\n createmeshhost,\n type meshanswer,\n type meshinvitation,\n meshpairingerror,\n type meshpeer,\n type meshprotocol,\n type meshrtcfactory,\n meshqrcodec,\n} from '@vielzeug/mesh';\n\nimport { createtappers } from './_tappers';\nimport { taverndisposederror, tavernerror, tavernpairingerror } from './errors';\n\nexport { taverndisposederror, tavernerror, tavernpairingerror };\n\n/**\n * reclassifies a mesh pairing failure as the tavern level user mistake it is:\n * the code the consumer pasted could not be used. the message is preserved and\n * the original error is chained as `cause`.\n */\nfunction topairingerror(error: unknown): tavernpairingerror {\n const message = error instanceof error ? error.message : 'that code could not be read.';\n return new tavernpairingerror(message, { cause: error });\n}\n\n/**\n * table sessions over `@vielzeug/mesh`: one host owns the canonical state of a subject, guests\n * mirror it and forward every command through the host's command table, so guest actions follow\n * exactly the same code path as host actions.\n *\n * the consumer supplies the seams, which commands exist, how subject state is read and mounted,\n * how notices cross between the wire and the local ui. tavern owns the pairing, the protocol,\n * command validation, snapshot broadcasting, notice relaying, and presence.\n */\n\n/** what the host can do on behalf of a guest. */\nexport interface taverncommands {\n /** applies the command; throwing rejects the guest with the error's message. */\n apply(name: string, args: readonly unknown[]): unknown;\n /** whether a command name is known; unknown names reject without applying. */\n has(name: string): boolean;\n}\n\n/**\n * how the host reads and watches the subject it is sharing. all three close over whatever\n * subject state the consumer owns: the host routes by `subjectid`, not by a subject object.\n */\nexport interface tavernsubjects {\n /** subscribes to local changes of the hosted subject: the re broadcast trigger. */\n onchanged(listener: () => void): () => void;\n /** subscribes to the hosted subject's removal: hosting ends when it fires. */\n onremoved(listener: () => void): () => void;\n /** reads the snapshot to broadcast; null while the subject is missing. */\n snapshot(): unknown;\n}\n\n/**\n * notice relay between host and guests. notices cross the wire as opaque values: each client\n * translates locally.\n *\n * **echo guard**: a tab that both hosts and guests must guard against re broadcasting a\n * wire originated notice back to its own guests: `fromwire` emits locally, the host's local\n * subscription picks it up, and without a guard it would loop. the consumer owns this guard\n * because only they know their local event system; one boolean set during `fromwire` and\n * checked in `towire` is sufficient.\n */\nexport interface tavernnotices {\n /** re emits a received wire notice locally. */\n fromwire(wire: unknown): void;\n /** serializes a local notice for the wire; null skips this one. */\n towire(notice: unknown): unknown | null;\n}\n\ninterface wireprotocol extends meshprotocol {\n toguest: {\n notice: unknown;\n rejected: { commandid: string; message: string };\n snapshot: unknown;\n };\n tohost: {\n command: { args: unknown[]; id: string; name: string; subjectid: string };\n };\n}\n\n/** notable host moments reported through {@link tavernhost.tap}. */\nexport type tavernhostevent =\n | { readonly peer: meshpeer; readonly type: 'peer joined' }\n | { readonly peer: meshpeer; readonly reason?: string; readonly type: 'peer left' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** notable guest moments reported through {@link tavernguest.tap}. */\nexport type tavernguestevent =\n | { readonly commandid: string; readonly message: string; readonly type: 'rejected' }\n | { readonly error: unknown; readonly message: string; readonly type: 'warning' };\n\n/** snapshots can be large; the default mesh frame cap is too tight for whole documents. */\nconst default_max_message_bytes = 512 * 1024;\n\nconst commandid = (): string => `cmd ${math.random().tostring(36).slice(2, 10)}${date.now().tostring(36)}`;\n\n/** runtime guard for the wire command shape: the protocol is tavern's, so tavern validates it. */\nfunction parsewirecommand(raw: unknown): { args: unknown[]; id: string; name: string; subjectid: string } | null {\n if (typeof raw !== 'object' || raw === null) return null;\n const { args, id, name, subjectid } = raw as record<string, unknown>;\n if (typeof id !== 'string' || typeof name !== 'string' || typeof subjectid !== 'string' || !array.isarray(args)) {\n return null;\n }\n return { args, id, name, subjectid };\n}\n\n// \n// host\n// \n\nexport interface tavernhostoptions {\n commands: taverncommands;\n notices?: tavernnotices;\n /**\n * hosting ended: the subject was removed or the host was disposed. fires exactly\n * once. this is a lifecycle transition, not observability: presence, transport\n * warnings, and the peer list all belong to {@link tavernhost.tap} and\n * {@link tavernhost.peers}.\n */\n onended?(): void;\n rtc?: meshrtcfactory;\n /** the id of the subject being hosted: guest commands targeting anything else reject. */\n subjectid: string;\n subjects: tavernsubjects;\n}\n\nexport interface tavernhost {\n /** consumes a guest's answer code; resolves with the peer once the channel is open. */\n acceptanswertext(text: string): promise<meshpeer>;\n /** produces a single use invitation code, qr compact when the environment allows. */\n createinvitationtext(): promise<string>;\n /** `abortsignal` aborted when hosting ends. */\n readonly disposalsignal: abortsignal;\n /** stops hosting: closes every channel and detaches all subscriptions. */\n dispose(): void;\n /** whether hosting has ended: the subject was removed or `dispose()` ran. */\n readonly disposed: boolean;\n kick(peerid: string): void;\n /** the guests currently connected: read it from a `peer joined`/`peer left` tap event. */\n readonly peers: readonly meshpeer[];\n /** relays one local notice to every guest; the serializer decides what crosses.\n * a notice relayed after hosting ended is dropped, not thrown: it is ephemeral\n * ui state with no audience left, unlike the control plane methods, which fail\n * with `taverndisposederror` so a leaked handle surfaces. */\n relaynotice(notice: unknown): void;\n /** subscribes to {@link tavernhostevent}s. handler errors are swallowed; the\n * subscription detaches when `signal` aborts or hosting ends. */\n tap(handler: (event: tavernhostevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n /** delegates to `dispose()`. enables `using` declarations. */\n [symbol.dispose](): void;\n}\n\n/**\n * starts hosting one subject. every guest command is validated and applied through the\n * command table; local changes re broadcast the snapshot (coalesced on a microtask); removal\n * ends the session.\n */\nexport function hosttavern(options: tavernhostoptions): tavernhost {\n const host = createmeshhost<wireprotocol>({\n maxmessagebytes: default_max_message_bytes,\n rtc: options.rtc,\n });\n const tappers = createtappers<tavernhostevent>();\n let closed = false;\n let flushscheduled = false;\n\n const close = (): void => {\n if (closed) return;\n closed = true;\n for (const off of detach.splice(0)) off();\n host.dispose();\n tappers.stop();\n options.onended?.();\n };\n const disposederror = (): taverndisposederror => new taverndisposederror('this host has stopped.');\n const warn = (error: unknown): void => {\n tappers.emit({ error, message: error instanceof error ? error.message : string(error), type: 'warning' });\n };\n\n /** reads the snapshot for a broadcast; a throwing read warns instead of crashing the host. */\n const readsnapshot = (): unknown => {\n try {\n return options.subjects.snapshot();\n } catch (error) {\n warn(error);\n return null;\n }\n };\n\n /** coalesces broadcasts on a microtask so bursts of local changes ship one snapshot. */\n const broadcastsnapshot = (): void => {\n if (closed || flushscheduled) return;\n flushscheduled = true;\n queuemicrotask(() => {\n flushscheduled = false;\n if (closed) return;\n const snapshot = readsnapshot();\n if (snapshot !== null && snapshot !== undefined) host.broadcast('snapshot', snapshot);\n });\n };\n\n /**\n * applies a guest command through the command table: the same table the host's own ui\n * calls, so an unknown name, a foreign subject id or a rule violation is all the host\n * can ever reject with.\n */\n const handleguestcommand = (peerid: string, raw: unknown): void => {\n const command = parsewirecommand(raw);\n if (!command || !options.commands.has(command.name) || command.subjectid !== options.subjectid) {\n host.send(peerid, 'rejected', {\n commandid: command?.id ?? '',\n message: 'the host rejected an unknown or out of session command.',\n });\n return;\n }\n try {\n options.commands.apply(command.name, command.args);\n } catch (error) {\n host.send(peerid, 'rejected', {\n commandid: command.id,\n message: error instanceof error ? error.message : 'the host could not apply the command.',\n });\n }\n };\n\n const detach = [\n host.on('command', ({ peerid, payload }) => handleguestcommand(peerid, payload)),\n options.subjects.onchanged(broadcastsnapshot),\n options.subjects.onremoved(close),\n host.tap((event) => {\n if (event.type === 'peer joined') {\n const snapshot = readsnapshot();\n if (snapshot !== null && snapshot !== undefined) host.send(event.peer.id, 'snapshot', snapshot);\n tappers.emit({ peer: event.peer, type: 'peer joined' });\n }\n if (event.type === 'peer left') tappers.emit({ peer: event.peer, reason: event.reason, type: 'peer left' });\n if (event.type === 'error') warn(event.error);\n }),\n ];\n\n return {\n acceptanswertext: async (text: string): promise<meshpeer> => {\n if (closed) throw disposederror();\n let decoded: meshinvitation | meshanswer;\n try {\n decoded = await meshqrcodec.decode(text);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n if (!('proof' in decoded)) {\n throw new tavernpairingerror('that code is an invitation, not a guest answer.');\n }\n try {\n return await host.acceptanswer(decoded);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n },\n createinvitationtext: async () => {\n if (closed) throw disposederror();\n return meshqrcodec.encode(await host.createinvitation());\n },\n get disposalsignal() {\n return host.disposalsignal;\n },\n dispose: close,\n get disposed() {\n return closed;\n },\n kick: (peerid: string): void => {\n if (closed) throw disposederror();\n host.kick(peerid);\n },\n get peers(): readonly meshpeer[] {\n return [...host.peers.values()];\n },\n relaynotice: (notice: unknown): void => {\n if (closed) return;\n const wire = options.notices ? options.notices.towire(notice) : null;\n if (wire !== null && wire !== undefined) host.broadcast('notice', wire);\n },\n tap: tappers.tap,\n [symbol.dispose]() {\n close();\n },\n };\n}\n\n// \n// guest\n// \n\nexport interface tavernguestoptions<mounted> {\n /** the invitation code the host produced. */\n invitationtext: string;\n /**\n * mounts a received snapshot; the mounted value, or null to ignore. the snapshot arrives\n * as parsed json: validate before trusting it.\n */\n mount(snapshot: unknown): mounted | null;\n /** the guest's display name on the channel. */\n name: string;\n notices?: tavernnotices;\n /**\n * the channel ended after joining; the mounted value is passed for unmounting.\n * lifecycle, not observability: rejections and transport warnings belong to\n * {@link tavernguest.tap}. `onjoined` and `onfailed` stay callbacks for the same\n * reason: they fire while {@link jointavern} is still resolving, before the\n * consumer can hold the handle and register a tapper.\n */\n onended?(subject: mounted): void;\n /** the host never accepted the answer in time. */\n onfailed?(reason: string): void;\n /** the mounted value once the first snapshot arrives. */\n onjoined?(subject: mounted): void;\n rtc?: meshrtcfactory;\n}\n\nexport interface tavernguest {\n /** `abortsignal` aborted when the session ends. */\n readonly disposalsignal: abortsignal;\n /** leaves the session and drops the channel. */\n dispose(): void;\n /** whether the session has ended: the channel dropped or `dispose()` ran. */\n readonly disposed: boolean;\n /**\n * forwards a command to the host. the subject id is the consumer's routing key: the host\n * rejects commands that do not name the subject it is hosting.\n * throws `taverndisposederror` after the session has ended.\n */\n sendcommand(subjectid: string, name: string, args: readonly unknown[]): void;\n /** subscribes to {@link tavernguestevent}s. handler errors are swallowed; the\n * subscription detaches when `signal` aborts or the session ends. */\n tap(handler: (event: tavernguestevent) => void, options?: { readonly signal?: abortsignal }): () => void;\n /** delegates to `dispose()`. enables `using` declarations. */\n [symbol.dispose](): void;\n}\n\n/**\n * joins a session as a guest: consumes the host's invitation and returns the answer code to\n * show back. the session goes live once the host accepts the answer and the first snapshot\n * mounts; disposing the guest (or the channel dropping) ends it exactly once.\n */\nexport async function jointavern<mounted>(\n options: tavernguestoptions<mounted>,\n): promise<{ answertext: string; guest: tavernguest }> {\n let decoded: meshinvitation | meshanswer;\n try {\n decoded = await meshqrcodec.decode(options.invitationtext);\n } catch (error) {\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n if (!('secret' in decoded)) {\n throw new tavernpairingerror('that code is not a session invitation.');\n }\n\n const guest = createmeshguest<wireprotocol>({\n maxmessagebytes: default_max_message_bytes,\n rtc: options.rtc,\n });\n const tappers = createtappers<tavernguestevent>();\n let mounted: mounted | null = null;\n let ended = false;\n\n /** ends the session exactly once: from the channel dropping or an explicit dispose. */\n const endsession = (): void => {\n if (ended) return;\n ended = true;\n const subject = mounted;\n mounted = null;\n for (const off of detach.splice(0)) off();\n guest.dispose();\n tappers.stop();\n if (subject !== null) options.onended?.(subject);\n };\n\n const detach = [\n guest.on('snapshot', ({ payload }) => {\n const subject = options.mount(payload);\n if (subject === null || ended) return;\n if (mounted === null) {\n mounted = subject;\n options.onjoined?.(subject);\n }\n }),\n guest.on('notice', ({ payload }) => {\n options.notices?.fromwire(payload);\n }),\n guest.on('rejected', ({ payload }) => {\n tappers.emit({\n commandid: payload.commandid,\n message: payload.message || 'the host rejected the action.',\n type: 'rejected',\n });\n }),\n guest.tap((event) => {\n if (event.type !== 'status change' && event.type !== 'peer status change') return;\n if (event.type === 'peer status change' && event.peerid !== guest.host?.id) return;\n if (event.status === 'failed' && mounted === null && !ended) {\n options.onfailed?.('the host did not accept the answer in time.');\n }\n if (event.status === 'disconnected' || event.status === 'failed') endsession();\n }),\n guest.tap((event) => {\n if (event.type === 'error') {\n tappers.emit({ error: event.error, message: event.error.message, type: 'warning' });\n }\n }),\n ];\n\n try {\n const answer = await guest.acceptinvitation(decoded, { name: options.name });\n return {\n answertext: await meshqrcodec.encode(answer),\n guest: {\n get disposalsignal() {\n return guest.disposalsignal;\n },\n dispose: endsession,\n get disposed() {\n return ended;\n },\n sendcommand: (subjectid: string, name: string, args: readonly unknown[]): void => {\n if (ended) throw new taverndisposederror('the session has ended.');\n guest.send('command', { args: [...args], id: commandid(), name, subjectid });\n },\n tap: tappers.tap,\n [symbol.dispose]() {\n endsession();\n },\n },\n };\n } catch (error) {\n // a failed join must not leave the node and its subscriptions behind.\n endsession();\n throw error instanceof meshpairingerror ? topairingerror(error) : error;\n }\n}\n"
|
|
1432
1432
|
},
|
|
1433
1433
|
{
|
|
1434
1434
|
"category": "time",
|