@vielzeug/codex 26.10.4 → 26.10.6

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.
@@ -1,6 +1,1301 @@
1
1
  # Vielzeug: Full Documentation
2
2
 
3
- > Complete documentation for 41 packages. Version: 26.10.3
3
+ > Complete documentation for 42 packages. Version: 26.10.6
4
+
5
+ ---
6
+
7
+ ## @vielzeug/archive
8
+
9
+ **Category:** utilities
10
+
11
+ ### Overview
12
+
13
+ ## Why Archive?
14
+
15
+ You can share content entries with ranked browsing, filters, likes and account
16
+ erasure on Cloudflare Workers + D1. `defineArchiveCollection` supplies your JSON
17
+ payload schema and indexing rules; the Worker supplies sessions, guards,
18
+ idempotent publishing, cursors and rate limits.
19
+
20
+ ```ts
21
+ // Before: each deployment repeats validation, indexing and route plumbing.
22
+
23
+ // After: the deployment is one collection definition plus one factory call.
24
+ import { defineArchiveCollection } from '@vielzeug/archive';
25
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
26
+ import { s } from '@vielzeug/spell';
27
+
28
+ const notes = defineArchiveCollection({
29
+ name: 'notes', contentVersion: 1,
30
+ payloadSchema: s.object({ title: s.string().min(1).max(128) }),
31
+ index: ({ title }) => ({ title, filter: null, tags: [] }),
32
+ });
33
+ export default createArchiveWorker([notes]);
34
+ ```
35
+
36
+ | Feature | Archive | Hand-rolled Worker | BaaS (Firebase/Supabase) |
37
+ | ------------------------------------ | -------------------------------------------------------- | ------------------------------------ | ------------------------------------- |
38
+ | Bundle size (core) | | Depends on deployment code | Depends on selected SDK |
39
+ | Zero dependencies | No — Spell and jose | Depends on implementation | No — client SDK |
40
+ | Content knowledge in the core | none — one collection seam | baked in | rules everywhere |
41
+ | Canonical fresh-database D1 schema | in the package | Deployment-owned | Not a D1 backend |
42
+ | Idempotent publish w/ tombstones | built-in | rebuild it | rebuild it |
43
+ | GDPR erasure / export endpoints | built-in | rebuild it | partial |
44
+ | Strict DTO re-validation on read | every entry | optional | optional |
45
+
46
+ **Use Archive when** an app needs a shareable, ranked, liked community space for
47
+ content entries on Cloudflare Workers + D1, and can express its content as one strict
48
+ payload schema.
49
+ **Consider alternatives when** you need rich queries beyond filter/tag/search, live
50
+ collaboration, or a backend you do not control — Archive is deliberately one narrow,
51
+ hardened surface.
52
+
53
+ ## Installation
54
+
55
+ ```sh [pnpm]
56
+ pnpm add @vielzeug/archive @vielzeug/spell
57
+ ```
58
+
59
+ ```sh [npm]
60
+ npm install @vielzeug/archive @vielzeug/spell
61
+ ```
62
+
63
+ ```sh [yarn]
64
+ yarn add @vielzeug/archive @vielzeug/spell
65
+ ```
66
+
67
+ ## Quick Start
68
+
69
+ A deployment is one collection definition plus one factory call. The definition
70
+ brings the payload schema and what to index; everything else — the emitted-entry
71
+ envelope, the content hash, the wire contract, the routes — is composed for you.
72
+
73
+ ```ts
74
+ import { defineArchiveCollection } from '@vielzeug/archive';
75
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
76
+ import { s } from '@vielzeug/spell';
77
+
78
+ // The collection's payload: one strict schema is the whole content contract.
79
+ const payloadSchema = s.object({
80
+ cuisine: s.enum(['italian', 'japanese']),
81
+ ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),
82
+ name: s.string().trim().min(1).max(32),
83
+ });
84
+
85
+ const recipesCollection = defineArchiveCollection({
86
+ canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),
87
+ contentVersion: 1,
88
+ filters: ['italian', 'japanese'],
89
+ index: (payload) => ({
90
+ filter: payload.cuisine,
91
+ tags: [payload.ingredients.length ({ title, filter: null, tags: [] }),
92
+ });
93
+ const response = await fetch('/api/v1/collections/notes/entries?sort=liked');
94
+ if (!response.ok) throw new Error('Archive unavailable');
95
+ const parsed = notes.contract.page.parse(await response.json());
96
+ console.log(parsed.entries);
97
+ ```
98
+
99
+ ## Features
100
+
101
+ - `defineArchiveCollection` — the one content seam: payload schema, canonicalization, filter/tag/title derivation, union-typed vocabularies
102
+ - `createArchiveWorker` — the whole routes/auth/limits/SQL stack from registered collections
103
+ - `collection.contract` — entry, page, publish/update input schemas shared by client and Worker
104
+ - `ARCHIVE_LIMITS` + `archiveHealthSchema` — the application bounds, advertised by health and validated on both sides
105
+ - `archiveAuthHealthSchema` + `AUTH_SCHEMA_VERSION` — typed issuer readiness checked against the issuer DDL stamp
106
+ - `archiveErrorResponseSchema` — the one error envelope, code-typed across the wire
107
+ - `createAuthWorker` — scanner-safe confirmation, live session-version enforcement, durable budgets, metadata export and coordinated erasure
108
+ - `AUTH_ISSUER_SERVICE` — required uncached issuer check after local catalog JWT verification
109
+ - `ArchiveDerivationError` — distinguishes faulty collection code from invalid submitted content
110
+ - Moderation — reader reports, the operator soft-hide and the report queue, keyed on a verified `roles` claim
111
+ - `worker.tap()` / `issuer.tap()` — typed sanitized runtime events; neither core logs by default
112
+ - `migrations/` — canonical fresh-database DDL with the storage stamp health reads back; no upgrade framework
113
+
114
+ ## Documentation
115
+
116
+ - [Usage Guide](./usage.md) — stand up a collection, deploy the Worker, wire the client.
117
+ - [API Reference](./api.md) — every export, all three entry points, the full route table.
118
+ - [Examples](./examples.md) — recipes from first deployment to GDPR erasure.
119
+ - [Release Status](./migration.md) — unreleased package and fresh-database scope.
120
+
121
+ ## See Also
122
+
123
+ - [Spell](/spell/) — every payload schema here is a spell schema; the core composes envelopes from it.
124
+ - [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume these routes.
125
+ - [Rune](/rune/) — the structured logger a deployment points `worker.tap()` events at.
126
+
127
+ ### API Reference
128
+
129
+ ## API Overview
130
+
131
+ | Symbol | Purpose | Execution mode | Common gotcha |
132
+ | --- | --- | --- | --- |
133
+ | `defineArchiveCollection` | Compose validation, indexing, identity and wire schemas | Sync | Invalid content derives to `null`; callback faults throw |
134
+ | `createArchiveWorker` | Build the catalog Worker | Sync | Collection names must be unique |
135
+ | `createAuthWorker` | Build the passwordless issuer | Sync | `AUTH_ARCHIVE` is required |
136
+ | `worker.tap`, `issuer.tap` | Observe sanitized runtime outcomes | Sync | Neither core logs by default; observer exceptions are swallowed |
137
+ | `ArchiveDerivationError` | Identify a collection implementation/runtime fault | Sync | Faults become sanitized 500s, not content-validation 422s |
138
+ | `ARCHIVE_LIMITS` | Shared application bounds | Sync | Page bounds do not cap total exports |
139
+ | `archiveErrorResponseSchema`, `archiveAuthHealthSchema` | Validate error and issuer-health responses | Sync | Unknown error codes and health states reject |
140
+ | `ArchiveError`, `ArchiveDefinitionError` | Public base and invalid-definition errors | Sync | Thrown locally, not transmitted as error objects |
141
+ | `sessionCookieValue` | Compose shared session-cookie attributes | Sync | Deployments must choose `secure` from their environment |
142
+ | `isWellFormedText` | Check control characters and lone surrogates | Sync | Does not trim or impose a length bound |
143
+
144
+ ## Package Entry Point
145
+
146
+ | Import | Purpose |
147
+ | --- | --- |
148
+ | `@vielzeug/archive` | Browser-safe collections, constants, schemas and errors |
149
+ | `@vielzeug/archive/worker` | Catalog factory, issuer-session service binding and event types |
150
+ | `@vielzeug/archive/auth` | Issuer factory, operator lifetime and binding types |
151
+
152
+ ## Collections
153
+
154
+ ### `defineArchiveCollection`
155
+
156
+ Creates a frozen collection and its contract; malformed names, versions or vocabularies throw `ArchiveDefinitionError`.
157
+
158
+ ```ts
159
+ function defineArchiveCollection(definition: ArchiveCollectionDefinition): ArchiveCollection;
160
+ ```
161
+
162
+ | Parameter | Contract |
163
+ | --- | --- |
164
+ | `name` | Lowercase `[a-z0-9-]{1,32}` |
165
+ | `contentVersion` | Positive safe integer |
166
+ | `payloadSchema` | Spell schema whose output is a stable JSON object |
167
+ | `filters`, `tags` | Optional readonly arrays; omitted means empty vocabulary |
168
+ | `canonicalize` | Optional pure, idempotent, schema-valid normalization |
169
+ | `index` | Derive `filter`, `tags` and searchable `title` without mutating the payload |
170
+
171
+ **Returns:** `ArchiveCollection`.
172
+
173
+ **Example:** define and derive one collection without Worker bindings.
174
+
175
+ ```ts
176
+ import { defineArchiveCollection } from '@vielzeug/archive';
177
+ import { s } from '@vielzeug/spell';
178
+
179
+ const recipes = defineArchiveCollection({
180
+ name: 'recipes',
181
+ contentVersion: 1,
182
+ payloadSchema: s.object({ name: s.string().min(1).max(32) }),
183
+ index: (payload) => ({ filter: null, tags: [], title: payload.name }),
184
+ });
185
+ const derived = await recipes.derive({ name: 'Rice' });
186
+ if (derived === null) throw new Error('Invalid recipe');
187
+ console.log(derived.contentHash);
188
+ ```
189
+
190
+ | Method / property | Result |
191
+ | --- | --- |
192
+ | `derive(raw)` | `Promise` |
193
+ | `contract.entry` | Public entry schema |
194
+ | `contract.ownedEntry`, `ownedEntries` | Owner view with `clientKey` and `hidden` |
195
+ | `contract.page` | Entries and forward/backward cursors |
196
+ | `contract.publishInput`, `updateInput` | Publish and revision-guarded update request schemas |
197
+ | `filters`, `tags` | Frozen vocabulary snapshots |
198
+
199
+ `derive` returns `null` for invalid JSON objects, schema-invalid input, invalid titles, too many tags and oversized canonical payloads. Unexpected schema, callback or hashing failures throw `ArchiveDerivationError`. Non-idempotent or schema-invalid canonicalization, unstable schema transformations, index mutation and out-of-vocabulary projections are also derivation faults. Routes map `null` to 422 `invalid-payload` and faults to a sanitized 500 `internal`.
200
+ Schema output that is lossy or no longer JSON is a derivation fault, not invalid
201
+ submitted content. Invalid index-result shapes also throw.
202
+
203
+ Const generics preserve the content-version literal and infer filter/tag unions from vocabulary arrays without `as const`. `NoInfer` prevents `index` from widening those unions; omitted arrays infer `never`, permitting only `filter: null` and empty tags.
204
+
205
+ Objects serialize with recursively sorted keys; arrays preserve order unless your canonicalizer changes it. SHA-256 hashes the core-owned `{contentVersion, payload}` envelope. Response schemas require stored payloads to remain stable under parsing; a response-validation fault has stage `response`.
206
+
207
+ `filter`, tags and searchable title are persisted projections, not computed during reads. Changing `index` or its vocabulary does not update stored rows. The active definition and persisted dataset must remain consistent; Archive has no projection-update or historical-schema API.
208
+
209
+ ## Worker and Maintenance
210
+
211
+ ### `createArchiveWorker`
212
+
213
+ Returns the catalog request/scheduled handler and its observation side channel.
214
+
215
+ ```ts
216
+ function createArchiveWorker(
217
+ collections: readonly ArchiveCollection[],
218
+ ): ArchiveWorkerHandler;
219
+ ```
220
+
221
+ **Returns:** `ArchiveWorkerHandler`; duplicate names throw `ArchiveDefinitionError` at registration.
222
+
223
+ **Example:** export a deployable Worker using the package's structural bindings.
224
+
225
+ ```ts
226
+ import { defineArchiveCollection } from '@vielzeug/archive';
227
+ import { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';
228
+ import { s } from '@vielzeug/spell';
229
+
230
+ const notes = defineArchiveCollection({
231
+ name: 'notes', contentVersion: 1,
232
+ payloadSchema: s.object({ title: s.string().min(1).max(128) }),
233
+ index: ({ title }) => ({ title, filter: null, tags: [] }),
234
+ });
235
+ export default createArchiveWorker([notes]);
236
+ ```
237
+
238
+ | Method | Contract |
239
+ | --- | --- |
240
+ | `fetch(request, env, ctx)` | Returns `Promise` |
241
+ | `scheduled(controller, env, ctx)` | Sweeps expired retry keys and revocation tombstones; emits failures and rethrows |
242
+ | `tap(handler, { signal? }?)` | Returns unsubscribe; a pre-aborted signal registers nothing |
243
+
244
+ `AUTH_ISSUER_SERVICE` is a required `fetch`-shaped service binding to the issuer.
245
+ The catalog verifies the signed JWT locally, then asks the issuer to validate its
246
+ current account session version. It does not cache successful issuer verdicts.
247
+ If the service exchange fails or exceeds its fixed 5000 ms timeout (body decoding
248
+ included), the catalog returns `503 auth-unavailable`; the timeout does not cancel
249
+ work already started by the issuer.
250
+
251
+ Only anonymous entry lists can use the 30-second edge cache. Any `Cookie` or `Authorization` header disables caching, even if invalid or empty. Detail, metadata, private and error responses are `no-store`. Public reads do not verify sessions.
252
+
253
+ ---
254
+
255
+ ### `worker.tap`
256
+
257
+ Observes typed request, scheduled and cache-failure events without affecting request behavior.
258
+
259
+ ```ts
260
+ tap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
261
+ ```
262
+
263
+ **Returns:** an unsubscribe function; abort detaches the observer. Diagnostics contain categories and trusted configuration field names, never raw errors, SQL, subjects, aliases, tokens or URLs.
264
+
265
+ **Example:** observe a Worker and detach explicitly.
266
+
267
+ ```ts
268
+ import { createArchiveWorker, type ArchiveWorkerEnv } from '@vielzeug/archive/worker';
269
+
270
+ const worker = createArchiveWorker([]);
271
+ const stop = worker.tap((event) => console.log(JSON.stringify(event)));
272
+ stop();
273
+ ```
274
+
275
+ ---
276
+
277
+ ## Identity Issuer
278
+
279
+ ### `createAuthWorker`
280
+
281
+ Returns the passwordless email issuer with account export and coordinated erasure.
282
+
283
+ ```ts
284
+ function createAuthWorker(): AuthWorkerHandler;
285
+ ```
286
+
287
+ **Returns:** `AuthWorkerHandler`.
288
+
289
+ **Example:** export an issuer configured through deployment bindings.
290
+
291
+ ```ts
292
+ import { createAuthWorker, type AuthWorkerEnv } from '@vielzeug/archive/auth';
293
+
294
+ export default createAuthWorker();
295
+ ```
296
+
297
+ | Method | Contract |
298
+ | --- | --- |
299
+ | `fetch(request, env, ctx?)` | Returns `Promise` |
300
+ | `scheduled(controller, env, ctx?)` | Removes expired links, closed send windows and unverified unreferenced accounts |
301
+ | `tap(handler, { signal? }?)` | Observes typed `AuthEvent`; returns unsubscribe; pre-aborted signals are inert |
302
+
303
+ Magic links expire after 15 minutes and work once. The durable per-email budget allows three sends per hour; consumption, expiration and send failure do not refund it. Ordinary sessions last at most 30 days. Operator sessions use `OPERATOR_SESSION_MAX_AGE_SECONDS` (900 seconds). `OPERATOR_EMAILS` controls newly minted role claims.
304
+
305
+ The issuer-global admission budget is durable D1 state in `auth_sign_in_budget`:
306
+ 20 requests per 60-second window before account creation or email delivery.
307
+ This supplements, rather than replaces, the durable per-email budget. No external
308
+ rate-limiter binding is required.
309
+ Admission spends are not refunded after later database or email failures.
310
+ Request emails are trimmed and lowercased before validation. `SENDER_ADDRESS`
311
+ must already be canonical; `OPERATOR_EMAILS` entries are trimmed and lowercased
312
+ before validation.
313
+
314
+ Sign-in requests create pending account rows with `lastSignInAt: null`. Successful
315
+ redemption atomically claims the one-use link and stamps the account's sign-in
316
+ time. The sweep deletes expired links and closed per-user send windows, then
317
+ unverified accounts with neither remaining links nor send-budget references.
318
+ Verified accounts are retained until explicit account deletion.
319
+
320
+ `@vielzeug/archive/auth` exports `OPERATOR_SESSION_MAX_AGE_SECONDS = 900` alongside
321
+ the factory and issuer binding types.
322
+
323
+ `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` optionally publishes retired public RS256 keys and verifies sessions minted before rotation; private members reject configuration. The package has no demo sign-in routes.
324
+
325
+ Opening a magic link performs a safe GET that displays a click-confirmation page,
326
+ not redemption. Only a marked POST from an exact allowed origin or
327
+ `PUBLIC_ORIGIN` consumes `{ token }` and returns 204 with the session cookie. The
328
+ confirmation page navigates to `/signed-in` after success; the POST is not a
329
+ redirect. Confirmation responses are `no-store` with no-referrer and restrictive
330
+ CSP; the page removes the token from the address bar and loads no external assets.
331
+ GET displays confirmation for any syntactically valid token, including a used
332
+ token; only POST establishes whether the link is still redeemable. POST accepts
333
+ exactly `{ token }` and rejects query parameters.
334
+
335
+ The issuer never logs directly. `tap` emits sanitized request and scheduled
336
+ outcomes; observer failures are swallowed, abort detaches the observer and failed
337
+ scheduled work emits then rethrows. `AuthDiagnostic` reports only a category.
338
+
339
+ ## HTTP Routes
340
+
341
+ Every route below is mounted under `/api/v1`. Mutation guards require an exact allowed `Origin` and `x-archive-request: 1`. Registered publishers pin `x-archive-publisher` to the identity captured when preparing private requests. The header is a freshness precondition, not authority.
342
+
343
+ | Route | Contract |
344
+ | --- | --- |
345
+ | `GET /health` | Public catalog readiness; 200 `ok` or 503 `degraded` |
346
+ | `POST /sign-out` | Catalog-local sign-out; `{ ok: true }` and expired session cookie, without issuer access |
347
+ | `GET /collections/:collection` | Collection metadata |
348
+ | `GET /collections/:collection/entries` | `author`, `filter`, `tag`, `search`, `sort`, `cursor`, `limit`; bidirectional entry page |
349
+ | `POST /collections/:collection/entries` | `{ clientKey, payload }`; 201 create or 200 replay |
350
+ | `GET /collections/:collection/entries/:id` | Public entry; always `no-store` |
351
+ | `PUT /collections/:collection/entries/:id` | Owner-only `{ expectedRevision, payload }` |
352
+ | `DELETE /collections/:collection/entries/:id` | Owner or operator removal; 204 |
353
+ | `PUT` / `DELETE /collections/:collection/entries/:id/like` | Like / unlike visible entries only; guarded write and response; like-state response |
354
+ | `POST /collections/:collection/entries/:id/report` | `{ reason }`; idempotent per entry/reporter; 204 |
355
+ | `PUT` / `DELETE /collections/:collection/entries/:id/hidden` | Operator hide / unhide; 204 |
356
+ | `GET /collections/:collection/reports` | Operator page; `status=unresolved` (default) or `resolved`, `cursor`, `limit` |
357
+ | `PUT /collections/:collection/reports/:id/resolved` | Operator resolves report by its unique report ID; idempotent 204 |
358
+ | `DELETE /publishers/:id` | Operator erases Archive profile; not issuer account |
359
+ | `GET /moderation-log` | Operator forward page; `cursor`, `limit` |
360
+ | `GET` / `POST` / `PUT /me` | Identity, register with optional `{ name }`, rename with required `{ name }` |
361
+ | `DELETE /me` | Profile-only cascade; 204; does not clear cookie |
362
+ | `DELETE /me/account` | Revoke issuer/subject and atomically erase profile; 204; coordinated issuer-erasure seam |
363
+ | `GET /me/publications?collection=…` | Owner entries including hidden state and retry keys |
364
+ | `GET /me/likes` | Forward page `{ ids, cursor }`; `cursor`, `limit` |
365
+ | `GET /me/likes?ids=…` | Subset lookup `{ ids }`; at most `likeStateIds` input IDs |
366
+ | `GET /me/reports` | Own forward report page across collections; `cursor`, `limit` |
367
+ | `GET /auth/health` | Public issuer readiness; typed `AuthHealth`, 200 `ok` or 503 `degraded`; no email or peer probe |
368
+ | `GET /auth/session` | Internal issuer-session check for the catalog service binding; exact `{ ok: true }`, `no-store`, no PII; 401 for an invalid, stale or absent-account session |
369
+ | `POST /auth/sign-out-all` | Requires marker, exact origin, session and captured `ACCOUNT_HEADER`; atomically increments `session_version`, deletes outstanding links, preserves account and catalog data; 200 `{ ok: true }` with an expired cookie only on durable success |
370
+ | `GET /auth/jwks.json` | Active and configured retired public verification keys |
371
+ | `POST /auth/sign-in` | `{ email }` (trimmed and lowercased); 202 `{ ok: true }` |
372
+ | `GET /auth/verify?token=…` | Safe no-store click-confirmation page; no token consumption or session change |
373
+ | `POST /auth/verify` | JSON `{ token }`, marker and exact allowed origin or `PUBLIC_ORIGIN`; consume once; 204 with session cookie |
374
+ | `GET /auth/account` | `{ id, email, createdAt, lastSignInAt }`; no tokens or budget data |
375
+ | `DELETE /auth/account` | Requires `ACCOUNT_HEADER` with the captured issuer account UUID; catalog `/me/account` via `AUTH_ARCHIVE`, require exact 204, then issuer user and cookie clear; 204 |
376
+
377
+ Forward pages are newest first with opaque context-bound keyset cursors. Keep the same query context when following a cursor; end at `cursor: null`. `maxPageSize` bounds each page, not the total number of liked IDs, reports or audit actions you can export. The subset-like query is not a page and cannot combine `ids` with `cursor` or `limit`.
378
+
379
+ Full erasure creates an issuer/subject revocation tombstone for 30 days plus ten
380
+ seconds and deletes the profile in the same transaction. Catalog authentication
381
+ rejects that subject with 401 on ordinary private routes; registration also checks
382
+ revocation in SQL to prevent resurrection races. Only `DELETE /me/account` may
383
+ bypass the issuer session lookup, and only to retry erasure with the original
384
+ signed session of the same principal while its existing revocation tombstone is
385
+ valid; JWT signature and claims are still verified. It is not a general
386
+ stale-session exception: initial erasure validates the issuer session. The
387
+ issuer-facing `DELETE /auth/account` always checks the current session version
388
+ before calling the catalog, so retrying it after completed erasure returns 401
389
+ because the account is absent.
390
+ Direct profile-only `DELETE /me` allows later registration using the same session.
391
+ The sweep removes expired tombstones. Re-enrollment through the reference issuer
392
+ creates a fresh account UUID subject; an external issuer must issue a new subject
393
+ to re-enroll during the revocation window.
394
+
395
+ The issuer stores `auth_users.session_version` as a safe non-negative integer,
396
+ defaulting to `0`. Every session JWT carries the current value as the required
397
+ `sessionVersion` claim. Each ordinary issuer-authenticated route compares that
398
+ claim to the current account row; a mismatch is unauthenticated. `POST
399
+ /api/v1/auth/sign-out-all` checks the captured issuer account UUID against the
400
+ verified subject before atomically incrementing the version and deleting pending
401
+ links. A changed account answers 409 `account-changed`; a failed request does not
402
+ clear the cookie. This revokes all sessions, including the caller's, after a
403
+ successful response. Requests authorized before revocation may finish; there is
404
+ no cross-database transactional or in-flight cancellation guarantee.
405
+
406
+ The catalog requires `AUTH_ISSUER_SERVICE`, a service binding to the issuer. After
407
+ local JWT signature/claims verification, it requests
408
+ `GET /api/v1/auth/session` through that binding on every private request. Only a
409
+ strictly validated 401 `unauthenticated` remains a 401. Malformed, unexpected,
410
+ provider and 5xx responses become 503 `auth-unavailable`; there is no authority
411
+ cache. Both service directions use a fixed 5000 ms timeout that includes body
412
+ decoding. The issuer's `/api/v1/auth/session` is an internal
413
+ service contract, not a frontend session API.
414
+
415
+ Issuer readiness is independent of catalog readiness. `GET
416
+ /api/v1/auth/health` returns `archiveAuthHealthSchema` as `AuthHealth`; its
417
+ read-only probe checks configuration, a signing and verification key proof,
418
+ binding shapes, the singleton `auth_schema_meta` stamp against
419
+ `AUTH_SCHEMA_VERSION`, and readability of expected tables, columns and triggers.
420
+ It does not write, send email or probe the catalog service. Responses are
421
+ sanitized.
422
+ Configuration failure, database failure and schema mismatch return HTTP 503
423
+ with `status: 'degraded'` and the corresponding reason.
424
+
425
+ Effective hide, unhide, operator removal and publisher erasure append audit rows atomically with the state transition. Concurrent idempotent no-ops append nothing. Audit rows contain opaque IDs, not names or content, and have no foreign keys so they outlive erasure. Resolving a report sets `resolvedAt`; it is not one of the four audit action kinds.
426
+
427
+ ## Constants and Schemas
428
+
429
+ | Export | Value or parsed output |
430
+ | --- | --- |
431
+ | `ARCHIVE_CONTRACT_VERSION`, `ARCHIVE_SCHEMA_VERSION` | Both debut at `1`; wire and storage versions are independent |
432
+ | `AUTH_SCHEMA_VERSION` | `1`; issuer DDL stamp checked by `/auth/health` |
433
+ | `ARCHIVE_COLLECTION_NAME_PATTERN` | `/^[a-z0-9-]{1,32}$/` |
434
+ | `ARCHIVE_OPERATOR_ROLE` | `'archive-operator'` |
435
+ | `SESSION_COOKIE_NAME`, `SESSION_COOKIE_PATH` | `'archive_session'`, `'/api/v1'` |
436
+ | `SESSION_MAX_AGE_SECONDS` | `2592000` |
437
+ | `OPERATOR_SESSION_MAX_AGE_SECONDS` | `900`; shared root contract, also re-exported by `/auth` |
438
+ | `ACCOUNT_HEADER` | `'X-Archive-Account'`; required issuer-account UUID on coordinated erasure |
439
+ | `PUBLISHER_HEADER`, `REQUEST_MARKER_HEADER` | `'x-archive-publisher'`, `'x-archive-request'` |
440
+ | `ILL_FORMED_TEXT`, `isWellFormedText(value: string): boolean` | Reject control characters and lone UTF-16 surrogates |
441
+ | `uuidSchema`, `isoTimestampSchema`, `hex64Schema` | UUID, ISO timestamp, lowercase SHA-256 hex strings |
442
+ | `archiveAliasSchema` | Trimmed, well-formed alias of at most 24 characters |
443
+ | `archiveAuthorSchema` | `{ id: string; name: string }` |
444
+ | `archiveIdentitySchema` | `ArchiveIdentity` |
445
+ | `archiveAccountSchema` | `ArchiveAccount`; issuer metadata, excluding private fields |
446
+ | `archiveLimitsSchema` | All numeric `ARCHIVE_LIMITS` fields |
447
+ | `archiveHealthSchema` | `ArchiveHealth` |
448
+ | `archiveAuthHealthSchema` | `AuthHealth` |
449
+ | `archiveErrorResponseSchema` | `ArchiveErrorResponse` |
450
+ | `archiveOkSchema` | `{ ok: true }` |
451
+ | `archiveCollectionMetaSchema` | `ArchiveCollectionMeta` |
452
+ | `archiveLikedIdsSchema` | `{ ids: string[] }` subset response |
453
+ | `archiveLikesPageSchema` | `ArchiveLikesPage` |
454
+ | `archiveLikeStateSchema` | `{ id: string; likeCount: number; liked: boolean }` |
455
+ | `archiveReportsSchema`, `archiveOwnReportsSchema` | `ArchiveReports`, `ArchiveOwnReports` |
456
+ | `archiveModerationLogSchema` | `ArchiveModerationLog` |
457
+ | `archiveSortSchema` | `ArchiveSort` |
458
+ | `ARCHIVE_ERROR_CODES` | Values of `ArchiveErrorCode` below |
459
+ | `ARCHIVE_MODERATION_ACTIONS` | `['hide', 'unhide', 'remove', 'erase-publisher']` |
460
+
461
+ ### `sessionCookieValue`
462
+
463
+ Composes a session cookie using the shared security attributes.
464
+
465
+ ```ts
466
+ function sessionCookieValue(token: string, options: { maxAgeSeconds: number; secure: boolean }): string;
467
+ ```
468
+
469
+ | Parameter | Contract |
470
+ | --- | --- |
471
+ | `token` | Session token, or empty string when clearing |
472
+ | `options.maxAgeSeconds` | Cookie lifetime; `0` clears it |
473
+ | `options.secure` | Include `Secure` for production HTTPS |
474
+
475
+ **Returns:** the `Set-Cookie` value with `HttpOnly`, `SameSite=Strict`, the shared path and optional `Secure`.
476
+
477
+ **Example:** clear the production cookie.
478
+
479
+ ```ts
480
+ import { sessionCookieValue } from '@vielzeug/archive';
481
+
482
+ const expiredCookie = sessionCookieValue('', { maxAgeSeconds: 0, secure: true });
483
+ console.log(expiredCookie);
484
+ ```
485
+
486
+ ---
487
+
488
+ ### `isWellFormedText`
489
+
490
+ Checks text for control characters and lone UTF-16 surrogates.
491
+
492
+ ```ts
493
+ function isWellFormedText(value: string): boolean;
494
+ ```
495
+
496
+ **Returns:** `true` when `ILL_FORMED_TEXT` does not match; it does not trim or impose bounds.
497
+
498
+ **Example:** validate text independently of the alias schema.
499
+
500
+ ```ts
501
+ import { isWellFormedText } from '@vielzeug/archive';
502
+
503
+ console.log(isWellFormedText('Rice'), isWellFormedText('\u0000'));
504
+ ```
505
+
506
+ ### `ARCHIVE_LIMITS`
507
+
508
+ `ARCHIVE_LIMITS` is frozen with these values:
509
+
510
+ ```ts
511
+ const limits = {
512
+ aliasChars: 24, cursorChars: 1024, idempotencyRetentionMs: 172800000,
513
+ likeStateIds: 50, maxPageSize: 50, maxPublications: 50, pageSize: 10,
514
+ payloadBytes: 8192, publicCacheSeconds: 30, reportReasonChars: 256,
515
+ requestBytes: 12288, searchChars: 64, searchTerms: 6, tagsPerEntry: 16,
516
+ titleChars: 128, vocabChars: 64,
517
+ };
518
+ ```
519
+
520
+ ## Types
521
+
522
+ The following definitions use `Schema` from `@vielzeug/spell`. Schema-derived aliases show their complete output shape.
523
+
524
+ ```ts
525
+ interface ArchiveIndexing {
526
+ filter: F | null;
527
+ tags: readonly T[];
528
+ title: string;
529
+ }
530
+ interface ArchiveCollectionDefinition {
531
+ canonicalize?(payload: P): P;
532
+ contentVersion: C;
533
+ filters?: readonly F[];
534
+ index(payload: P): ArchiveIndexing, NoInfer>;
535
+ name: string;
536
+ payloadSchema: Schema;
537
+ tags?: readonly T[];
538
+ }
539
+ interface ArchiveDerivation {
540
+ canonicalJson: string;
541
+ contentHash: string;
542
+ contentVersion: number;
543
+ filter: string | null;
544
+ tags: readonly string[];
545
+ title: string;
546
+ }
547
+ interface ArchiveEntry {
548
+ author: { id: string; name: string };
549
+ contentHash: string;
550
+ contentVersion: C;
551
+ id: string;
552
+ likeCount: number;
553
+ payload: P;
554
+ publishedAt: string;
555
+ revision: number;
556
+ schemaVersion: typeof ARCHIVE_CONTRACT_VERSION;
557
+ updatedAt: string;
558
+ }
559
+ interface ArchiveOwnedEntry extends ArchiveEntry {
560
+ clientKey: string;
561
+ hidden: boolean;
562
+ }
563
+ interface ArchiveContract {
564
+ entry: Schema>;
565
+ ownedEntries: Schema[] }>;
566
+ ownedEntry: Schema>;
567
+ page: Schema>>;
568
+ publishInput: Schema;
569
+ updateInput: Schema;
570
+ }
571
+ interface ArchiveCollection {
572
+ readonly contentVersion: C;
573
+ readonly contract: ArchiveContract;
574
+ readonly derive: (raw: unknown) => Promise;
575
+ readonly filters: readonly string[];
576
+ readonly name: string;
577
+ readonly tags: readonly string[];
578
+ }
579
+ interface ArchivePage {
580
+ cursor: string | null;
581
+ entries: readonly T[];
582
+ previous: string | null;
583
+ }
584
+ type ArchiveSort = 'liked' | 'recent';
585
+ type ArchiveDerivationStage = 'schema' | 'canonicalize' | 'index' | 'hash' | 'response';
586
+ type ArchiveIdentity = { id: string; name: string; operator: boolean } | null;
587
+ type ArchiveAccount = {
588
+ id: string; email: string; createdAt: string; lastSignInAt: string | null;
589
+ };
590
+ type ArchiveCollectionMeta = { filters: string[]; name: string; tags: string[] };
591
+ type ArchiveLikesPage = { cursor: string | null; ids: string[] };
592
+ type ArchiveErrorCode =
593
+ | 'account-changed' | 'auth-unavailable' | 'bad-request' | 'conflict' | 'forbidden' | 'gone'
594
+ | 'internal' | 'invalid-payload' | 'likes-disabled' | 'method-not-allowed'
595
+ | 'not-found' | 'publisher-changed' | 'publishing-disabled' | 'quota'
596
+ | 'rate-limited' | 'record-unavailable' | 'timeout' | 'too-large'
597
+ | 'unauthenticated' | 'unavailable' | 'unsupported-media';
598
+ type ArchiveErrorResponse = {
599
+ error: { code: ArchiveErrorCode; message: string; requestId: string };
600
+ };
601
+ type ArchiveHealth = {
602
+ dbSchemaVersion: number | null;
603
+ features: { likes: boolean; publishing: boolean };
604
+ limits: {
605
+ aliasChars: number; cursorChars: number; idempotencyRetentionMs: number;
606
+ likeStateIds: number; maxPageSize: number; maxPublications: number;
607
+ pageSize: number; payloadBytes: number; publicCacheSeconds: number;
608
+ reportReasonChars: number; requestBytes: number; searchChars: number;
609
+ searchTerms: number; tagsPerEntry: number; titleChars: number; vocabChars: number;
610
+ };
611
+ schemaVersion: 1;
612
+ status: 'ok' | 'degraded';
613
+ reason: 'database-unavailable' | 'schema-mismatch' | null;
614
+ };
615
+ type AuthHealth = {
616
+ dbSchemaVersion: number | null;
617
+ schemaVersion: 1;
618
+ status: 'ok' | 'degraded';
619
+ reason: 'configuration-unavailable' | 'database-unavailable' | 'schema-mismatch' | null;
620
+ };
621
+ type ArchiveReports = {
622
+ cursor: string | null;
623
+ reports: {
624
+ id: string; createdAt: string; entryId: string; entryTitle: string;
625
+ reason: string; reporter: { id: string; name: string }; resolvedAt: string | null;
626
+ }[];
627
+ };
628
+ type ArchiveOwnReports = {
629
+ cursor: string | null;
630
+ reports: {
631
+ collection: string; createdAt: string; entryId: string; id: string;
632
+ reason: string; resolvedAt: string | null;
633
+ }[];
634
+ };
635
+ type ArchiveModerationAction = 'hide' | 'unhide' | 'remove' | 'erase-publisher';
636
+ type ArchiveModerationLog = {
637
+ cursor: string | null;
638
+ actions: {
639
+ id: string; action: ArchiveModerationAction; collection: string | null;
640
+ createdAt: string; operatorId: string; targetId: string;
641
+ }[];
642
+ };
643
+ ```
644
+
645
+ Worker subpath types model only the binding methods the catalog calls.
646
+
647
+ ```ts
648
+ interface D1Statement {
649
+ all(): Promise;
650
+ bind(...params: (string | number | null)[]): D1Statement;
651
+ first(): Promise;
652
+ run(): Promise;
653
+ }
654
+ interface D1Like {
655
+ batch(statements: D1Statement[]): Promise;
656
+ prepare(sql: string): D1Statement;
657
+ }
658
+ interface RateLimiterBinding {
659
+ limit(input: { key: string }): Promise;
660
+ }
661
+ interface ArchiveWorkerEnv {
662
+ AUTH_ISSUER_SERVICE: AuthIssuerServiceBinding;
663
+ ALLOWED_ORIGINS: string;
664
+ APP_ENV: string;
665
+ DB: D1Like;
666
+ JWT_AUDIENCE: string;
667
+ JWT_ISSUER: string;
668
+ JWT_JWKS_URL: string;
669
+ LIKES_ENABLED: string;
670
+ PUBLISHING_ENABLED: string;
671
+ RATE_LIMIT_KEY_SECRET: string;
672
+ READ_LIMIT: RateLimiterBinding;
673
+ WRITE_LIMIT: RateLimiterBinding;
674
+ }
675
+ interface AuthIssuerServiceBinding {
676
+ fetch(request: Request): Promise;
677
+ }
678
+ interface ArchiveExecutionContext {
679
+ waitUntil(promise: Promise): void;
680
+ }
681
+ type ArchiveDiagnostic =
682
+ | { readonly kind: 'configuration'; readonly field: string }
683
+ | { readonly kind: 'database' | 'record' | 'schema' | 'internal' | 'cache' | 'collection' };
684
+ interface ArchiveRequestEvent {
685
+ code: ArchiveErrorCode | 'ok';
686
+ diagnostic?: ArchiveDiagnostic;
687
+ ms: number;
688
+ requestId: string;
689
+ route: string;
690
+ status: number;
691
+ type: 'request';
692
+ }
693
+ type ArchiveScheduledEvent =
694
+ | { removed: number; type: 'scheduled'; status: 'ok' }
695
+ | { type: 'scheduled'; status: 'failed'; diagnostic: ArchiveDiagnostic };
696
+ type ArchiveEvent =
697
+ | ArchiveRequestEvent
698
+ | ArchiveScheduledEvent
699
+ | { readonly type: 'cache-error'; readonly diagnostic: ArchiveDiagnostic };
700
+ interface ArchiveWorkerHandler {
701
+ fetch(request: Request, env: E, ctx: ArchiveExecutionContext): Promise;
702
+ scheduled?(controller: unknown, env: E, ctx: ArchiveExecutionContext): void | Promise;
703
+ tap(handler: (event: ArchiveEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
704
+ }
705
+ ```
706
+
707
+ Scheduled `removed` counts the combined expired retry-key and revocation rows.
708
+
709
+ Auth subpath types describe the sibling issuer and its required service binding.
710
+
711
+ ```ts
712
+ interface SendEmail {
713
+ send(message: { from: string; html?: string; subject: string; text: string; to: string }): Promise;
714
+ }
715
+ interface AuthArchiveBinding {
716
+ fetch(request: Request): Promise;
717
+ }
718
+ interface AuthWorkerEnv {
719
+ ALLOWED_ORIGINS: string;
720
+ APP_ENV: string;
721
+ APP_NAME: string;
722
+ AUDIENCE: string;
723
+ AUTH_ARCHIVE: AuthArchiveBinding;
724
+ AUTH_DB: D1Like;
725
+ AUTH_EMAIL: SendEmail;
726
+ AUTH_ISSUER: string;
727
+ AUTH_JWT_PREVIOUS_PUBLIC_JWKS?: string;
728
+ AUTH_JWT_PRIVATE_JWK: string;
729
+ OPERATOR_EMAILS: string;
730
+ PUBLIC_ORIGIN: string;
731
+ SENDER_ADDRESS: string;
732
+ }
733
+ interface AuthWorkerHandler {
734
+ fetch(request: Request, env: E, ctx?: ArchiveExecutionContext): Promise;
735
+ scheduled(controller: unknown, env: E, ctx?: ArchiveExecutionContext): Promise;
736
+ tap(handler: (event: AuthEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
737
+ }
738
+ type AuthDiagnostic = {
739
+ readonly kind: 'configuration' | 'database' | 'email' | 'catalog' | 'internal';
740
+ };
741
+ type AuthEvent =
742
+ | {
743
+ readonly type: 'request';
744
+ readonly requestId: string;
745
+ readonly route:
746
+ | '/auth/account'
747
+ | '/auth/health'
748
+ | '/auth/jwks.json'
749
+ | '/auth/session'
750
+ | '/auth/sign-in'
751
+ | '/auth/sign-out-all'
752
+ | '/auth/verify'
753
+ | 'unmatched';
754
+ readonly status: number;
755
+ readonly code: ArchiveErrorCode | 'ok';
756
+ readonly ms: number;
757
+ readonly diagnostic?: AuthDiagnostic;
758
+ }
759
+ | { readonly type: 'scheduled'; readonly status: 'ok'; readonly removed: number }
760
+ | { readonly type: 'scheduled'; readonly status: 'failed'; readonly diagnostic: AuthDiagnostic };
761
+ ```
762
+
763
+ Issuer scheduled `removed` counts expired token rows, closed per-user send windows
764
+ and deleted unverified accounts. The singleton admission row is retained and its
765
+ window resets atomically on a later sign-in request.
766
+
767
+ ## Errors
768
+
769
+ | Exported class | Trigger | Properties |
770
+ | --- | --- | --- |
771
+ | `ArchiveError` | Base for public package errors | `name`, `message`, optional `cause` |
772
+ | `ArchiveDefinitionError` | Invalid collection definition or duplicate registered collection names | Extends `ArchiveError` |
773
+ | `ArchiveDerivationError` | Unexpected schema/canonicalization/index/hash/response fault or violated collection invariant | Extends `ArchiveError`; readonly `stage: ArchiveDerivationStage`; optional `cause` |
774
+
775
+ `ArchiveError` and `ArchiveDefinitionError` accept `(message: string, opts?: ErrorOptions)`.
776
+ `ArchiveDerivationError` accepts `(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions)`.
777
+ Its message and cause are local diagnostics, not response content. Worker request
778
+ failures use the wire envelope, not these thrown classes. Internal `ApiError` and
779
+ `AuthOperationError` are not exported.
780
+
781
+ Stored entries that fail the current wire schema return 503 `record-unavailable`.
782
+ Unexpected exceptions in the collection's response-payload callbacks instead
783
+ throw `ArchiveDerivationError` with stage `response` and become sanitized 500s.
784
+
785
+ The complete public error signatures are:
786
+
787
+ ```ts
788
+ class ArchiveError extends Error {
789
+ constructor(message: string, opts?: ErrorOptions);
790
+ }
791
+ class ArchiveDefinitionError extends ArchiveError {}
792
+ class ArchiveDerivationError extends ArchiveError {
793
+ readonly stage: ArchiveDerivationStage;
794
+ constructor(stage: ArchiveDerivationStage, message: string, opts?: ErrorOptions);
795
+ }
796
+ ```
797
+
798
+ Catalog rate-limit responses use `Retry-After: 60`; configure catalog limiter periods to 60 seconds. Issuer admission/send-budget responses instead carry seconds until their durable window reopens. Health's degraded response is the health DTO, not an error envelope: schema mismatch reports the observed version; database unavailability reports `dbSchemaVersion: null`.
799
+
800
+ ### Usage Guide
801
+
802
+ ## Basic Usage
803
+
804
+ A deployment is a collection definition plus one factory call. Put the definition in a
805
+ module both sides import — the browser needs the composed schemas, the Worker needs
806
+ the collection itself.
807
+
808
+ ```ts
809
+ // recipes-collection.ts — the one content contract, shared by both sides.
810
+ import { defineArchiveCollection } from '@vielzeug/archive';
811
+ import { s } from '@vielzeug/spell';
812
+
813
+ const payloadSchema = s.object({
814
+ cuisine: s.enum(['italian', 'japanese']),
815
+ ingredients: s.array(s.string().trim().min(1).max(64)).min(1).max(20),
816
+ name: s.string().trim().min(1).max(32),
817
+ });
818
+
819
+ export const recipesCollection = defineArchiveCollection({
820
+ // Ingredient order is not identity: the canonical form sorts, so reordered
821
+ // content hashes identically and updates as a no-op.
822
+ canonicalize: (payload) => ({ ...payload, ingredients: [...payload.ingredients].sort() }),
823
+ contentVersion: 1,
824
+ filters: ['italian', 'japanese'],
825
+ index: (payload) => ({
826
+ filter: payload.cuisine,
827
+ tags: [payload.ingredients.length console.log(JSON.stringify(event)));
828
+
829
+ export default worker;
830
+ ```
831
+
832
+ The browser imports the same collection for its schemas — never the worker subpath:
833
+
834
+ ```ts
835
+ import { recipesCollection } from './recipes-collection';
836
+ import { archiveIdentitySchema, PUBLISHER_HEADER } from '@vielzeug/archive';
837
+
838
+ const response = await fetch('/api/v1/collections/recipes/entries');
839
+ if (!response.ok) throw new Error('Archive unavailable');
840
+ const page = recipesCollection.contract.page.parse(await response.json());
841
+ const meResponse = await fetch('/api/v1/me', { credentials: 'same-origin' });
842
+ if (!meResponse.ok) throw new Error('Identity unavailable');
843
+ const me = archiveIdentitySchema.parse(await meResponse.json());
844
+ if (me === null) throw new Error('Register a publisher first');
845
+ const mineResponse = await fetch('/api/v1/me/publications?collection=recipes', {
846
+ credentials: 'same-origin', headers: { [PUBLISHER_HEADER]: me.id },
847
+ });
848
+ if (!mineResponse.ok) throw new Error('Own publications unavailable');
849
+ const owned = recipesCollection.contract.ownedEntries.parse(await mineResponse.json());
850
+ console.log(page.entries, owned.entries);
851
+ ```
852
+
853
+ ## The collection contract
854
+
855
+ `defineArchiveCollection` is the only place content knowledge lives. The definition is
856
+ validated at registration — a bad `name`, `contentVersion`, or any vocabulary value
857
+ (well-formed text, 1..`vocabChars` chars) throws there, never mid-request — and every
858
+ ordinary invalid content returns `null` → `422 invalid-payload`. Collection
859
+ implementation faults instead throw `ArchiveDerivationError` → sanitized
860
+ `500 internal`:
861
+
862
+ - **Parse** — accept stable JSON objects only; schema outputs must remain JSON.
863
+ - **Canonicalize** — optional; a result that leaves the payload schema throws, so a
864
+ broken normalizer can never store invalid content.
865
+ - **Index** — the returned `filter` must be declared in `filters`, every `tag` in
866
+ `tags` (duplicates collapse), and the `title` is trimmed and bounded
867
+ (`titleChars`, well-formed text). A derivation outside the declared vocabulary
868
+ would store a row no list query can reach — it throws instead.
869
+ - **Size** — the canonical JSON must fit `payloadBytes`.
870
+ - **Identity** — the content hash is SHA-256 over `{contentVersion, payload}`:
871
+ identical content hashes identically, a real edit always re-identifies, and the
872
+ core owns the envelope so collections cannot drift it.
873
+
874
+ Declare vocabularies with readonly arrays (`['fire', 'ice']`) for automatic
875
+ literal-union inference. Const generics retain the version literal and vocabulary
876
+ unions; `NoInfer` prevents the index callback from widening them. Omitted arrays
877
+ mean empty vocabularies: return `null` for
878
+ the filter and no tags. Object keys are recursively sorted by the core; schema
879
+ transformations must stabilize and `canonicalize` must be pure and idempotent.
880
+ Non-JSON input returns `null`. Unexpected callback, schema or hashing failures,
881
+ unstable transformations, non-idempotent canonicalization and index mutation throw
882
+ `ArchiveDerivationError`; its `stage` identifies the failing phase.
883
+ Lossy or non-JSON schema output and invalid index-result shapes also throw.
884
+
885
+ `contentVersion` participates in content identity. Request handling validates
886
+ only the current collection definition; it does not interpret historical schemas.
887
+ Archive is unreleased and provides no content-upgrade APIs.
888
+
889
+ Filters, tags and searchable titles are persisted data derived at publish/update
890
+ time. Editing `index` or its vocabularies does not recompute existing projections.
891
+ Keep the active definition and dataset consistent. For an unreleased development
892
+ deployment, deliberately reset and reseed a fresh database when you change payload
893
+ or projection semantics; the package does not supply a migration framework.
894
+ Resetting destroys existing data. Perform it only as an explicit, deliberate
895
+ development operation, not automatically during startup or deployment.
896
+
897
+ **Payloads carry decisions, not content.** The payload schema should accept
898
+ user-authored choices expressed as IDs plus user-written text — never the content's
899
+ own copyrighted expression (card text, artwork, rules), never more than the game's
900
+ own identifiers. For games with paid or DLC content this is the whole answer:
901
+ ownership resolves on each viewer's device against their own library, the service
902
+ never learns what anyone owns, and the strict schema is the enforcement seam —
903
+ use a strict schema that rejects unknown fields, so a collection that only models IDs cannot
904
+ accidentally start shipping paid content. For example, published game builds can
905
+ reference cards, equipment and potions by ID plus a user-written name. The client
906
+ renders pieces the viewer does not own as unavailable. Keep the browser-safe
907
+ publication contract in a shared domain module consumed by both the frontend and
908
+ its backend, without importing frontend application code into the Worker.
909
+
910
+ ## The Worker and its environment
911
+
912
+ `createArchiveWorker(collections)` returns the full handler: anonymous public reads,
913
+ cookie-authenticated mutations behind exact-origin and request-marker guards, local
914
+ RS256/JWKS verification followed by an uncached issuer session-version check,
915
+ per-IP read limits and per-subject write limits keyed by peppered HMACs, the
916
+ 30-second anonymous cache on public entry lists only, operator moderation, and a
917
+ scheduled expiry sweep for retired publish retry keys and revocation tombstones.
918
+ Health sits outside the rate-limit and session pipelines: probes never burn the
919
+ anonymous read budget or call the issuer.
920
+
921
+ The Worker reads its environment fail-closed — every binding must be present and
922
+ well-formed, or the route answers 503 without naming what is missing:
923
+
924
+ | Variable | Meaning |
925
+ | ------------------------ | ---------------------------------------------------------------- |
926
+ | `APP_ENV` | `local` or `production` (transport rules and the pepper policy) |
927
+ | `ALLOWED_ORIGINS` | Exact canonical origins; never wildcards |
928
+ | `JWT_ISSUER` / `JWT_AUDIENCE` / `JWT_JWKS_URL` | Your identity provider |
929
+ | `AUTH_ISSUER_SERVICE` | Required issuer service binding for live session-version checks |
930
+ | `LIKES_ENABLED` / `PUBLISHING_ENABLED` | Feature flags |
931
+ | `RATE_LIMIT_KEY_SECRET` | HMAC pepper. Required ≥ 32 chars in production; local may fall back |
932
+ | `DB` / `READ_LIMIT` / `WRITE_LIMIT` | D1 + rate-limit bindings |
933
+
934
+ Both limiter bindings must run 60-second windows (`simple` limiters:
935
+ `period: 60`): the platform binding does not expose its period at runtime, so the
936
+ Worker's 429 `Retry-After` header is fixed at 60 seconds — a longer window would
937
+ make the header lie.
938
+
939
+ Generated Env types (wrangler's `worker-configuration.d.ts`) satisfy the package's
940
+ structural `ArchiveWorkerEnv` — no `@cloudflare/workers-types` dependency, no casts.
941
+
942
+ ## The auth issuer
943
+
944
+ `createAuthWorker()` from `@vielzeug/archive/auth` is the reference passwordless
945
+ identity issuer a deployment runs beside the catalog Worker: one POST with an email
946
+ sends a single-use magic link, and explicitly confirming it sets the `archive_session`
947
+ cookie. The catalog verifies the JWT locally, then calls the issuer through
948
+ `AUTH_ISSUER_SERVICE` to check the account's current session version. Requests create
949
+ pending account rows; explicit link redemption atomically stamps the account's
950
+ sign-in time and claims the token. The issuer's D1 schema ships at
951
+ `node_modules/@vielzeug/archive/migrations/auth/`; point the issuer's
952
+ `migrations_dir` there. The catalog's `JWT_ISSUER` and `JWT_JWKS_URL` must identify
953
+ the issuer, and its audience must match the issuer's `AUDIENCE`.
954
+
955
+ | Variable | Meaning |
956
+ | --- | --- |
957
+ | `APP_NAME` | The app's display name: the magic-link email's subject and copy name it |
958
+ | `ALLOWED_ORIGINS` | Exact origins allowed to trigger unsafe routes (the app origin(s)) |
959
+ | `APP_ENV` | `local` or `production` — production requires HTTPS and sets Secure cookies |
960
+ | `AUDIENCE` / `AUTH_ISSUER` | The claims every minted session carries; the catalog verifies the same pair |
961
+ | `AUTH_DB` / `AUTH_EMAIL` | The issuer's D1 and send-email bindings |
962
+ | `AUTH_ARCHIVE` | Required service binding to the catalog Worker; deletion calls its profile-erasure route |
963
+ | `AUTH_JWT_PRIVATE_JWK` | The RS256 private key as a JWK JSON string: a wrangler secret in production |
964
+ | `AUTH_JWT_PREVIOUS_PUBLIC_JWKS` | Optional retired public RS256 JWKS; publish during rotation until old sessions expire |
965
+ | `OPERATOR_EMAILS` | Comma-separated emails whose newly minted sessions carry the operator role |
966
+ | `PUBLIC_ORIGIN` | App origin for links, confirmation POST origin checks and client navigation after sign-in |
967
+ | `SENDER_ADDRESS` | The address magic links come from: a domain onboarded to Cloudflare Email Service |
968
+
969
+ Under `wrangler dev` the send-email binding is simulated — the link prints to the
970
+ issuer's console, so that console is the inbox. Demo-only Alice/Bob routes are
971
+ deployment wrappers, not package APIs.
972
+
973
+ The issuer first admits sign-in requests through its singleton durable
974
+ `auth_sign_in_budget` table: 20 requests per 60 seconds before creating accounts
975
+ or delivering email. No rate-limiter binding is required. It then
976
+ permits three sign-in emails per email in an hourly durable window.
977
+ Later database or delivery failures do not refund global admission.
978
+ Using or expiring a link never refunds sends; failed delivery also spends the
979
+ allowance. Links expire after 15 minutes. Operator sessions expire after 900
980
+ seconds, ordinary sessions after at most 30 days. Retain retired public keys during
981
+ rotation; the active private key signs every new session. Sign-in input trims and
982
+ lowercases email before validation. `SENDER_ADDRESS` must already be canonical;
983
+ `OPERATOR_EMAILS` entries are trimmed and lowercased before validation.
984
+
985
+ The scheduled sweep deletes expired links and closed per-user send windows, then
986
+ pending accounts only when neither a link nor send-budget row still references
987
+ them. Failed delivery deletes the unusable token but retains the spent allowance;
988
+ it does not immediately remove the pending account. Verified accounts stay until
989
+ explicit erasure. The singleton admission row remains and resets its window on a
990
+ later admitted request.
991
+
992
+ Opening `GET /auth/verify?token=…` renders a no-store confirmation page and does
993
+ not consume the link or change the session, so email scanners cannot sign in by
994
+ fetching it. A deliberate click sends `POST /auth/verify` with JSON `{ token }`,
995
+ the request marker and an exact allowed origin or `PUBLIC_ORIGIN`.
996
+ Success is 204 with Set-Cookie; the page then navigates to `/signed-in`. The
997
+ redemption update and account sign-in stamp are trigger-backed and atomic.
998
+ The page removes the token from the address bar, loads no external assets, and
999
+ sets a restrictive CSP and `Referrer-Policy: no-referrer`.
1000
+
1001
+ `GET /auth/account` exports account metadata only. For full erasure, call
1002
+ `DELETE /auth/account` with `ACCOUNT_HEADER` (`X-Archive-Account`) set to the
1003
+ issuer account UUID captured from `GET /auth/account` before confirmation.
1004
+ The issuer rejects missing or invalid UUIDs with 400 and a different signed-in
1005
+ account with 409 `account-changed`, before any erasure. This guard applies even
1006
+ before publisher registration and must stay pinned across retries.
1007
+ The issuer forwards the caller's session and publisher
1008
+ precondition through `AUTH_ARCHIVE` to `DELETE /me/account`, requires exactly 204,
1009
+ then deletes its
1010
+ own user and clears the cookie. Failure preserves the session for retry. Direct
1011
+ `DELETE /me` removes only the Archive profile and leaves the cookie intact.
1012
+
1013
+ Several game frontends can share one issuer and catalog. Keep the cookie
1014
+ host-only on the central API host, use `credentials: 'include'` from same-site
1015
+ HTTPS game subdomains, and allow each exact frontend origin in both Workers.
1016
+ Do not share cookies through a parent-domain `Domain` attribute. The
1017
+ [multi-collection recipe](./examples/multi-collection-deployment.md) describes
1018
+ the topology, session revalidation and platform-wide account boundary.
1019
+
1020
+ `DELETE /me/account` atomically revokes the issuer/subject and cascades profile
1021
+ deletion. Its tombstone lasts 30 days plus ten seconds; old sessions cannot read
1022
+ private data or register again, including a racing registration. The dedicated
1023
+ account-erasure route alone may bypass the issuer lookup, and only to retry with
1024
+ the same principal's original signed session while its existing tombstone is
1025
+ valid; signature and claims are still verified, and initial erasure validates the
1026
+ issuer. The issuer's `DELETE /auth/account` checks the current version before
1027
+ starting erasure; after completed erasure a retry there returns 401. A fresh
1028
+ issuer account gets a new UUID subject. External issuers must use a new subject to
1029
+ re-enroll during the revocation window. Profile-only deletion does not revoke the
1030
+ subject and permits re-registration with existing credentials.
1031
+
1032
+ Each JWT also carries the issuer account's required safe-integer `sessionVersion`.
1033
+ The issuer compares it with `auth_users.session_version` on every ordinary
1034
+ authenticated route. A JWT with an old version is unauthenticated even before its
1035
+ expiry; the catalog asks the issuer on every private request and does not cache a
1036
+ successful verdict. A valid local JWT with an unavailable, malformed, 5xx or
1037
+ timed-out (fixed 5000 ms, including body decoding) issuer response becomes
1038
+ `503 auth-unavailable`, not anonymous identity.
1039
+
1040
+ ## The database
1041
+
1042
+ The package ships the canonical catalog migration:
1043
+ `node_modules/@vielzeug/archive/migrations/0001_community_archive.sql` — the whole schema,
1044
+ moderation included, as one designed file. The runtime SQL in the package is
1045
+ written against exactly this schema, and the package's own integration suite runs
1046
+ against the same file. Point wrangler at the directory:
1047
+
1048
+ ```jsonc
1049
+ // wrangler.jsonc — the deployment owns its D1 database, not its DDL.
1050
+ {
1051
+ "d1_databases": [
1052
+ {
1053
+ "binding": "DB",
1054
+ "database_name": "my-app",
1055
+ "migrations_dir": "./node_modules/@vielzeug/archive/migrations"
1056
+ }
1057
+ ]
1058
+ }
1059
+ ```
1060
+
1061
+ The issuer points to
1062
+ `node_modules/@vielzeug/archive/migrations/auth/0001_auth.sql`. Both files are
1063
+ fresh-database initialization definitions, not upgrades. The issuer DDL initializes
1064
+ `auth_users.session_version` to `0` and stamps `auth_schema_meta.applied` as
1065
+ `AUTH_SCHEMA_VERSION` (`1`). Neither file automatically deletes or resets data.
1066
+ While Archive is unreleased, its
1067
+ canonical initial schemas are edited directly. Reset and reseed an unreleased
1068
+ development database deliberately when its schema or collection semantics no
1069
+ longer match; reapplying the initial file does not update an existing dataset.
1070
+ The initialization file also stamps the storage schema
1071
+ (`archive_schema_meta`) at `ARCHIVE_SCHEMA_VERSION`, and health reads it back: a
1072
+ database with a mismatched stamp answers `status: 'degraded'` with the
1073
+ observed `dbSchemaVersion` and HTTP 503 (`reason: 'schema-mismatch'`).
1074
+ An unavailable database produces HTTP 503 with `dbSchemaVersion: null` and
1075
+ `reason: 'database-unavailable'`. The debut storage stamp is `1`. A unit
1076
+ test pins the shipped DDL's `CHECK` bounds to `ARCHIVE_LIMITS`, so the two can never
1077
+ drift silently. The stamp does not detect changes in your collection's payload
1078
+ schema, vocabulary or indexing function; you own that dataset consistency.
1079
+
1080
+ ## Routes and wire names
1081
+
1082
+ ```
1083
+ GET /api/v1/health
1084
+ POST /api/v1/sign-out
1085
+ GET /api/v1/auth/health
1086
+ GET /api/v1/auth/session (internal issuer check)
1087
+ POST /api/v1/auth/sign-out-all (revoke every session)
1088
+ GET /api/v1/collections/:collection (self-description)
1089
+ GET /api/v1/collections/:collection/entries (author, filter, tag, search, sort, cursor, limit)
1090
+ POST /api/v1/collections/:collection/entries
1091
+ GET /api/v1/collections/:collection/entries/:id
1092
+ PUT /api/v1/collections/:collection/entries/:id (expectedRevision)
1093
+ DELETE /api/v1/collections/:collection/entries/:id
1094
+ PUT /api/v1/collections/:collection/entries/:id/like
1095
+ DELETE /api/v1/collections/:collection/entries/:id/like
1096
+ POST /api/v1/collections/:collection/entries/:id/report (reason)
1097
+ PUT /api/v1/collections/:collection/entries/:id/hidden (operator)
1098
+ DELETE /api/v1/collections/:collection/entries/:id/hidden (operator)
1099
+ GET /api/v1/collections/:collection/reports (operator: status, cursor, limit)
1100
+ PUT /api/v1/collections/:collection/reports/:id/resolved (operator: report ID)
1101
+ DELETE /api/v1/publishers/:id (operator: erase any publisher)
1102
+ GET /api/v1/moderation-log (operator: cursor, limit)
1103
+ GET /api/v1/me POST /me PUT /me DELETE /me
1104
+ GET /api/v1/me/publications?collection=:collection GET /api/v1/me/likes[?ids=…]
1105
+ GET /api/v1/me/reports (cursor, limit)
1106
+ GET /api/v1/auth/account
1107
+ GET /api/v1/auth/jwks.json
1108
+ POST /api/v1/auth/sign-in (email)
1109
+ DELETE /api/v1/auth/account (coordinated full erasure)
1110
+ DELETE /api/v1/me/account (issuer erasure seam; revokes subject)
1111
+ GET /api/v1/auth/verify?token=… (confirmation only)
1112
+ POST /api/v1/auth/verify (guarded {token} redemption)
1113
+ ```
1114
+
1115
+ Publishing is bounded by `maxPublications` live entries per `(collection, owner)`.
1116
+ The quota check runs inside the guarded insert.
1117
+
1118
+ `/me/likes` without `ids`, `/me/reports`, operator reports and `/moderation-log`
1119
+ use forward keyset pages: follow `cursor` until `null`, preserving query context.
1120
+ `limit` defaults to `pageSize` and is at most `maxPageSize`; there is no total
1121
+ export bound. `/me/likes?ids=…` remains a subset lookup returning `{ ids }`, not a
1122
+ page, and cannot combine `ids` with `cursor` or `limit`.
1123
+
1124
+ Shared wire names live in the package — `archive_session` cookie,
1125
+ `x-archive-publisher` and `x-archive-request` headers, `ARCHIVE_ERROR_CODES`, and
1126
+ `ARCHIVE_LIMITS` — so a client and the Worker cannot drift. Parse failures with the
1127
+ collection's composed schemas; parse errors with `archiveErrorResponseSchema`, the
1128
+ sign-out body with `archiveOkSchema`, a collection's self-description with
1129
+ `archiveCollectionMetaSchema` and the operator queue with `archiveReportsSchema`.
1130
+ Every entry's `schemaVersion` is the archive contract's version, not the
1131
+ collection's; the collection's own format version lives in its hash envelope and in
1132
+ each entry's `contentVersion`.
1133
+
1134
+ Only anonymous entry lists carry a 30-second edge-cache contract
1135
+ (`publicCacheSeconds`). Detail is always `no-store`. Any Cookie or Authorization
1136
+ header disables caching, regardless of validity; metadata, private and error
1137
+ responses also use `no-store`.
1138
+
1139
+ Implement UI ownership checks in your application by comparing the current
1140
+ publisher ID to the entry's `author.id`; there is no package action-policy helper.
1141
+ Server mutations remain authoritative.
1142
+
1143
+ `POST /api/v1/sign-out` is deliberately local: it clears only this browser's
1144
+ cookie and does not contact the issuer or revoke copied sessions. To revoke every
1145
+ session, post to `/api/v1/auth/sign-out-all` with the mutation marker, an exact
1146
+ allowed `Origin`, and the issuer account UUID captured from
1147
+ `GET /api/v1/auth/account` in `X-Archive-Account`. The issuer returns 409
1148
+ `account-changed` if the active subject differs. On success it atomically increments
1149
+ `session_version`, deletes outstanding links, preserves account and catalog data,
1150
+ and returns `{ ok: true }` with an expired cookie. Requests authorized before
1151
+ revocation may finish; there is no cross-database cancellation guarantee.
1152
+
1153
+ `GET /api/v1/auth/health` is public and read-only. It validates issuer
1154
+ configuration, key signing/verification, binding shapes, the `auth_schema_meta`
1155
+ stamp and expected tables, columns and triggers, returns `AuthHealth` through
1156
+ `archiveAuthHealthSchema`, and answers 503 `degraded` for configuration,
1157
+ database or schema failures. It never writes, sends email or probes the catalog.
1158
+
1159
+ ## Moderation
1160
+
1161
+ Operator authority is the session token's optional `roles` claim carrying
1162
+ `ARCHIVE_OPERATOR_ROLE` (`'archive-operator'`) — minted by your issuer, verified with
1163
+ the same signature, audience and issuer checks as any session, and never a separate
1164
+ credential. Moderation routes additionally require a registered publisher row: one
1165
+ accountable identity per action.
1166
+
1167
+ - **Report** — any registered publisher reports a visible entry with a bounded
1168
+ free-text reason (`reportReasonChars`). Idempotent per (entry, reporter): the same
1169
+ report twice is the same 204. Missing or hidden entries answer 404 — there is
1170
+ nothing left to report.
1171
+ - **Hide** — an operator removes an entry from every public surface (list, detail,
1172
+ likes) with a soft-hide: the row, its likes and its retry key stay intact. The
1173
+ owner still sees the entry in `/me/publications` with `hidden: true`, edits and
1174
+ key replay are frozen (403), removal stays allowed, and unhiding restores
1175
+ everything. Already hidden or already visible answer the same 204.
1176
+ - **Queue** — the operator pages newest reports with entry title and reporter
1177
+ identity; `status` is `unresolved` (default) or `resolved`. Each report has its
1178
+ own `id` and nullable `resolvedAt`. Resolve it with
1179
+ `PUT /collections/:collection/reports/:id/resolved`; repeating is a 204 no-op.
1180
+ - **Log** — every effective moderation action (hide, unhide, removal, publisher
1181
+ erasure) appends one accountability row in the same transaction that applies it,
1182
+ and `GET /api/v1/moderation-log` pages the newest actions to
1183
+ operators. Rows carry only ids, the action kind and the time — no names (they
1184
+ change), no content — and no foreign keys: the audit must outlive the erasures it
1185
+ records. Idempotent repeats (hiding an already-hidden entry, erasing an
1186
+ already-erased publisher) answer the same 204 with no new row, including
1187
+ concurrent repeats: audit insertion tests the effective state inside the batch.
1188
+
1189
+ Like and unlike both require a visible entry. Their SQL guards visibility during
1190
+ the mutation, and the response checks it again; hiding or deleting an entry
1191
+ cannot turn a racing unlike into a successful response for unavailable content.
1192
+
1193
+ ## Observability
1194
+
1195
+ The core never logs. `worker.tap(handler, { signal? })` emits one `ArchiveEvent` per
1196
+ request — `{ type: 'request', code, ms, requestId, route, status, diagnostic? }` —
1197
+ and successful sweep — `{ type: 'scheduled', status: 'ok', removed }`. Failed
1198
+ sweeps emit `{ type: 'scheduled', status: 'failed', diagnostic }` and rethrow so
1199
+ the runtime sees failure. Cache failures emit `{ type: 'cache-error', diagnostic }`.
1200
+ The Worker swallows observer errors so
1201
+ observability cannot affect the Worker. With no handlers registered there is zero
1202
+ work. Route is a finite class like `/collections/:collection/entries`, never a
1203
+ raw path; no event carries a URL, cookie, subject, alias or token. Diagnostics are
1204
+ typed sanitized categories, not raw exceptions. A pre-aborted tap signal is inert.
1205
+ The sweep's `removed` counts expired retry keys and revocation tombstones together.
1206
+
1207
+ The issuer also exposes `tap()` with `AuthEvent` from `@vielzeug/archive/auth`.
1208
+ It never logs directly. Events contain finite route labels, codes, counts,
1209
+ duration and sanitized `AuthDiagnostic` categories, never emails, tokens or raw
1210
+ exceptions. Scheduled failures emit then rethrow; pre-aborted taps are inert.
1211
+ Issuer sweep counts include expired tokens, closed per-user send windows and
1212
+ removed pending accounts; the singleton admission row is not removed.
1213
+
1214
+ ```ts
1215
+ import { createLogger } from '@vielzeug/rune';
1216
+ import { createArchiveWorker } from '@vielzeug/archive/worker';
1217
+ import { recipesCollection } from './recipes-collection';
1218
+
1219
+ const log = createLogger({ namespace: 'recipes' });
1220
+ const worker = createArchiveWorker([recipesCollection]);
1221
+ worker.tap((event) => log.debug(event, `archive:${event.type}`));
1222
+ export default worker;
1223
+ ```
1224
+
1225
+ ## Testing
1226
+
1227
+ The package proves itself against real workerd + local D1:
1228
+
1229
+ ```sh
1230
+ pnpm vitest run packages/archive/src/__tests__/ # fast unit suite
1231
+ pnpm --filter @vielzeug/archive test:integration # real workerd + local D1 + issuer coverage
1232
+ pnpm --filter @vielzeug/archive test:types # strict types over src and the integration suite
1233
+ ```
1234
+
1235
+ Prove your own collection the way the reference deployment does: a slim deployment
1236
+ suite that publishes over your real vocabulary and asserts the derivation, plus the
1237
+ package's generic suite for the core behavior. A collection with an `index` that can
1238
+ drift out of vocabulary should unit-test `derive` directly: ordinary invalid input
1239
+ returns `null`, while implementation faults throw `ArchiveDerivationError`.
1240
+ Direct DDL tests exercise the shipped schema rather than a duplicate schema.
1241
+
1242
+ ## Framework Integration
1243
+
1244
+ The root entry is plain TypeScript with no framework coupling; Vue, React and Svelte
1245
+ apps all consume the composed schemas the same way. A Vue 3 composable that pages a
1246
+ collection:
1247
+
1248
+ ```ts
1249
+ // useRecipes.ts — one collection's typed client in any component tree.
1250
+ import { recipesCollection } from './recipes-collection';
1251
+ import { ref, shallowRef } from 'vue';
1252
+
1253
+ export function useRecipes() {
1254
+ const entries = shallowRef[]>([]);
1255
+ const cursor = ref(null);
1256
+ async function load(sort: 'liked' | 'recent') {
1257
+ const params = new URLSearchParams({ sort });
1258
+ if (cursor.value !== null) params.set('cursor', cursor.value);
1259
+ const response = await fetch(`/api/v1/collections/recipes/entries?${params}`);
1260
+ if (!response.ok) throw new Error('The archive is unavailable.');
1261
+ const page = recipesCollection.contract.page.parse(await response.json());
1262
+ entries.value = [...entries.value, ...page.entries];
1263
+ cursor.value = page.cursor;
1264
+ }
1265
+ return { cursor, entries, load };
1266
+ }
1267
+ ```
1268
+
1269
+ ## Working with Other Vielzeug Libraries
1270
+
1271
+ - [Spell](/spell/) — the payload schema a collection declares; the core composes the
1272
+ envelope schemas from it, so the wire and the types cannot drift.
1273
+ - [Courier](/courier/) — the HTTP client shape an app's frontend mirrors to consume
1274
+ these routes: strict response schemas over one typed port.
1275
+ - [Rune](/rune/) — the structured logger a deployment points `tap()` events at.
1276
+
1277
+ ## Best Practices
1278
+
1279
+ - Compose client schemas from `collection.contract`; never hand-write the envelope.
1280
+ - Declare exactly the vocabulary your `index` derives, with union types where you
1281
+ have them; a value outside it rejects loudly.
1282
+ - Keep `canonicalize` pure and schema-true: reordered lists in, canonical form out.
1283
+ - Initialize fresh databases with the shipped DDL and deliberately reset/reseed
1284
+ unreleased development data when collection or storage semantics change.
1285
+ - Treat `ARCHIVE_ERROR_CODES` and `archiveErrorResponseSchema` as one tested contract.
1286
+ - Pin the expected-publisher header from the identity you captured at sign-in, never from storage alone.
1287
+ - Grant the operator role claim only through your issuer, and only to accounts that
1288
+ are also registered publishers.
1289
+ - Wire a sanitized `tap()` sink and register the rate-limit pepper as a production secret.
1290
+
1291
+ ### Examples
1292
+
1293
+ - [Stand Up a Content Archive](./examples/stand-up-a-content-archive.md)
1294
+ - [Content Identity and Versions](./examples/content-identity-and-versions.md)
1295
+ - [Multi-Collection Deployment](./examples/multi-collection-deployment.md)
1296
+ - [Moderate Content](./examples/moderate-content.md)
1297
+ - [Client Error Mapping](./examples/client-error-mapping.md)
1298
+ - [GDPR Erasure and Export](./examples/gdpr-erasure-and-export.md)
4
1299
 
5
1300
  ---
6
1301
 
@@ -119,6 +1414,8 @@ console.log(byRole, health, profile);
119
1414
  | `allocate` | Lossless proportional distribution | Sync | Available from `/math` |
120
1415
  | `isEqual` | Structural equality | Sync | Root export |
121
1416
  | `hash` | Deterministic serialization for cache keys | Sync | Available from `/object` |
1417
+ | `compressBytes` / `decompressBytes` | Native byte compression with bounded decompression | Async | Requires native compression streams; decoding requires an output limit |
1418
+ | `pack` / `unpack` | Versioned, compressed, URL-safe payload envelope | Async | Both sides bound the payload with `maxBytes`; compression falls back to raw |
122
1419
 
123
1420
  ## Package Entry Points
124
1421
 
@@ -128,6 +1425,7 @@ console.log(byRole, health, profile);
128
1425
  | `@vielzeug/arsenal/array` | Immutable transforms, set operations, sorting, fuzzy search |
129
1426
  | `@vielzeug/arsenal/async` | Retry, cancellation, task pool, timing |
130
1427
  | `@vielzeug/arsenal/cache` | In-memory cache and memoization |
1428
+ | `@vielzeug/arsenal/encoding` | Base64url and native byte compression |
131
1429
  | `@vielzeug/arsenal/function` | Assertions, composition, timing, teardown |
132
1430
  | `@vielzeug/arsenal/guards` | Type guards, predicate combinators, equality guards |
133
1431
  | `@vielzeug/arsenal/math` | Ranges, aggregation, statistics, interpolation, exact allocation |
@@ -245,12 +1543,107 @@ base64UrlToBytes(code); // throws on characters outside the alphabet
245
1543
  utf8Bytes('Hunter äöü'); // 11: byte length of a UTF-8 string
246
1544
  ```
247
1545
 
248
- ## Object
249
-
250
- ### tryParseJson
1546
+ ### compressBytes
251
1547
 
252
1548
  ```ts
253
- type JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };
1549
+ compressBytes(bytes: Uint8Array, options?: CompressionOptions): Promise
1550
+ ```
1551
+
1552
+ Compresses bytes using the native `CompressionStream` API without modifying the input.
1553
+
1554
+ | Option | Type | Default | Meaning |
1555
+ | --- | --- | --- | --- |
1556
+ | `format` | `CompressionFormat` | `'deflate-raw'` | Compression format; the receiver must use the same format |
1557
+
1558
+ **Returns:** Compressed bytes. Rejects with `ArsenalSerializationError` when the format or capability is unavailable, or stream processing fails. Does not fall back to another format.
1559
+
1560
+ **Example:** Encode compressed bytes for a URL.
1561
+
1562
+ ```ts
1563
+ import { bytesToBase64Url, compressBytes } from '@vielzeug/arsenal';
1564
+
1565
+ const bytes = new TextEncoder().encode(JSON.stringify({ name: 'Hunters' }));
1566
+ const code = bytesToBase64Url(await compressBytes(bytes));
1567
+ ```
1568
+
1569
+ ---
1570
+
1571
+ ### decompressBytes
1572
+
1573
+ ```ts
1574
+ decompressBytes(bytes: Uint8Array, options: DecompressionOptions): Promise
1575
+ ```
1576
+
1577
+ Decompresses bytes using the native `DecompressionStream` API, enforcing the output limit while reading chunks.
1578
+
1579
+ | Option | Type | Default | Meaning |
1580
+ | --- | --- | --- | --- |
1581
+ | `format` | `CompressionFormat` | `'deflate-raw'` | Format used by the sender |
1582
+ | `maxOutputBytes` | `number` | Required | Non-negative safe integer; maximum decoded byte length |
1583
+
1584
+ **Returns:** Decompressed bytes. Rejects with `ArsenalSerializationError` for invalid limits, corrupt or truncated data, unavailable formats/capabilities, or output exceeding the limit. A failed stream is canceled and its reader lock released. Bound encoded input separately before calling this function.
1585
+
1586
+ **Example:** Decode text within an application-defined byte limit.
1587
+
1588
+ ```ts
1589
+ import { compressBytes, decompressBytes } from '@vielzeug/arsenal';
1590
+
1591
+ const compressed = await compressBytes(new TextEncoder().encode('Hunters'));
1592
+ const bytes = await decompressBytes(compressed, { maxOutputBytes: 8192 });
1593
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1594
+ ```
1595
+
1596
+ ---
1597
+
1598
+ ### pack
1599
+
1600
+ ```ts
1601
+ pack(payload: unknown, options: ShareCodeOptions): Promise
1602
+ ```
1603
+
1604
+ Serializes `payload` as JSON and encodes it into a compact share code: `prefix` + mode marker + base64url bytes, compressed with raw DEFLATE when the compressed form is shorter and the runtime supports the streams.
1605
+
1606
+ | Option | Type | Meaning |
1607
+ | --- | --- | --- |
1608
+ | `maxBytes` | `number` | Positive safe integer; maximum serialized payload size |
1609
+ | `prefix` | `string` | 1–8 alphanumeric characters identifying the code family and version |
1610
+
1611
+ **Returns:** The shorter of the raw (`prefix` + `r`) and compressed (`prefix` + `d`) representations. Throws `ShareCodeError` (`too-large`, `invalid`) for oversized payloads and misconfigured options.
1612
+
1613
+ **Example:** Encode a shareable build as a code small enough for a URL.
1614
+
1615
+ ```ts
1616
+ import { pack } from '@vielzeug/arsenal';
1617
+
1618
+ const code = await pack({ cards: ['slash', 'guard'] }, { maxBytes: 8192, prefix: 'ab1' });
1619
+ ```
1620
+
1621
+ ---
1622
+
1623
+ ### unpack
1624
+
1625
+ ```ts
1626
+ unpack(code: string, options: ShareCodeOptions): Promise
1627
+ ```
1628
+
1629
+ Decodes a share code produced by `pack` with the same options. Both sides are bounded: input longer than `maxBytes` could carry is refused up front, streamed decompression stops at `maxBytes`, and non-canonical base64url is rejected by round-trip.
1630
+
1631
+ **Returns:** The parsed JSON payload. Throws `ShareCodeError` (`invalid` for foreign or malformed codes, `too-large` for budget overruns, `compression-unavailable` when a compressed code arrives on a runtime without `DecompressionStream`). Underlying compression failures surface as `ArsenalSerializationError`.
1632
+
1633
+ **Example:** Decode untrusted input within the byte budget.
1634
+
1635
+ ```ts
1636
+ import { unpack } from '@vielzeug/arsenal';
1637
+
1638
+ const payload = await unpack(code, { maxBytes: 8192, prefix: 'ab1' });
1639
+ ```
1640
+
1641
+ ## Object
1642
+
1643
+ ### tryParseJson
1644
+
1645
+ ```ts
1646
+ type JsonParseResult = { ok: true; value: unknown } | { error: SyntaxError; ok: false };
254
1647
 
255
1648
  tryParseJson(text: string): JsonParseResult
256
1649
  ```
@@ -327,13 +1720,22 @@ type CacheOptions = {
327
1720
  now?: () => number;
328
1721
  ttlMs?: number;
329
1722
  };
1723
+
1724
+ interface CompressionOptions {
1725
+ format?: CompressionFormat;
1726
+ }
1727
+
1728
+ interface DecompressionOptions extends CompressionOptions {
1729
+ maxOutputBytes: number;
1730
+ }
330
1731
  ```
331
1732
 
332
1733
  ## Errors
333
1734
 
334
1735
  - `RangeError`: invalid numeric bounds, capacity, concurrency, or retry count.
335
1736
  - `TypeError`: invalid value types, unsupported comparison, or required path missing.
336
- - `ArsenalSerializationError`: memo or hash cannot serialize supplied input.
1737
+ - `ArsenalSerializationError`: memo or hash cannot serialize supplied input; compression fails, is unavailable, receives an invalid output limit, or exceeds that limit. Stream failures retain the original error as `cause`.
1738
+ - `ShareCodeError`: a share code cannot be encoded or decoded; the `reason` field is `invalid`, `too-large`, or `compression-unavailable`.
337
1739
 
338
1740
  ### Usage Guide
339
1741
 
@@ -500,6 +1902,27 @@ const payload = JSON.parse(base64UrlToText(code));
500
1902
 
501
1903
  Decoding throws on characters outside the base64url alphabet, so corrupted input fails loudly instead of decoding to garbage.
502
1904
 
1905
+ ## Compress Share Payloads
1906
+
1907
+ Compress UTF-8 bytes before Base64url encoding. Set a decoded-byte limit before accepting compressed input from another device.
1908
+
1909
+ ```ts
1910
+ import { base64UrlToBytes, bytesToBase64Url, compressBytes, decompressBytes } from '@vielzeug/arsenal';
1911
+
1912
+ const payload = JSON.stringify({ name: 'Hunters', notes: 'An expedition through Alborea. '.repeat(20) });
1913
+ const compressed = await compressBytes(new TextEncoder().encode(payload));
1914
+ const code = bytesToBase64Url(compressed);
1915
+ const bytes = await decompressBytes(base64UrlToBytes(code), { maxOutputBytes: 8192 });
1916
+ const restored = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
1917
+ console.log(restored === payload);
1918
+ ```
1919
+
1920
+ Both helpers default to `deflate-raw`. You can explicitly select `gzip` or `deflate`; use the same format at both ends. The helpers reject when native streams do not support the selected format. They do not choose a fallback or define a wire protocol.
1921
+
1922
+ For a complete versioned envelope over these primitives — prefix, compression mode marker, byte budgets on both sides — see [`pack` / `unpack`](./api.md#pack).
1923
+
1924
+ Compression can enlarge small inputs. Compare the final encoded strings, including any format marker, before choosing a representation. Bound input size before decoding Base64url and validate decompressed JSON against your schema. The decompression limit bounds retained output, not native stream working memory.
1925
+
503
1926
  ## Working with Other Vielzeug Libraries
504
1927
 
505
1928
  Use Spell after `tryParseJson` for typed external data. Use Vault instead of `cache` when data must survive reloads or process restart.
@@ -533,6 +1956,7 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
533
1956
  - [Array utilities](./examples/array.md)
534
1957
  - [Async utilities](./examples/async.md)
535
1958
  - [Cache utilities](./examples/cache.md)
1959
+ - [Encoding utilities](./examples/encoding.md)
536
1960
  - [Function utilities](./examples/function.md)
537
1961
  - [Guards / Typed predicates](./examples/typed.md)
538
1962
  - [Math utilities](./examples/math.md)
@@ -555,6 +1979,7 @@ const settings = parsed.ok ? Settings.parse(parsed.value) : { theme: 'system' };
555
1979
  - taskPool - Bounded concurrent work (id: `async-queue`)
556
1980
  - retry - Retry failed operations (id: `async-retry`)
557
1981
  - waitFor - Poll until condition is true or timeout/abort fires (id: `async-waitFor`)
1982
+ - compressBytes - Bounded share payload compression (id: `encoding-compression`)
558
1983
  - debounce - Trailing (default) and leading-edge options (id: `function-debounce`)
559
1984
  - memo - LRU cache with size tracking and invalidation (id: `function-memo`)
560
1985
  - pipe - Left-to-right function composition (id: `function-pipe`)
@@ -3207,7 +4632,13 @@ Sends one HTTP request. `config.method` defaults to `GET`; pass any method (incl
3207
4632
  | `timeout` | `number` | Integer milliseconds from 1 to 2,147,483,647, or `Infinity`; overrides default |
3208
4633
  | `fetchInit` | `Omit` | Raw fetch options for advanced use |
3209
4634
 
3210
- **Returns:** Parsed response body (or `undefined` for empty bodies).
4635
+ **Returns:** Parsed response body (or `undefined` for empty bodies). With
4636
+ `responseType: 'raw'` the resolved value is the `Response` itself; a `204`/`205`
4637
+ answer whose transport carried an unexpected empty body stream is normalized to
4638
+ `body: null` — the stray stream is cancelled and headers, status, statusText,
4639
+ URL, redirect and type metadata are preserved (on the response and its clones).
4640
+ A null-body raw response owns no pending resource, so no consumption is required
4641
+ to release the request.
3211
4642
 
3212
4643
  ```ts
3213
4644
  const created = await courier.request('/users', {
@@ -3487,7 +4918,7 @@ Concurrent callers share physical work but own their own signal and timeout. One
3487
4918
 
3488
4919
  ## Response Parsing
3489
4920
 
3490
- Courier parses successful 2xx response bodies automatically based on `content-type`, unless you set `responseType` explicitly (`'json'`, `'text'`, `'blob'`, `'arrayBuffer'`, or `'raw'` for the `Response` itself). Empty JSON bodies return `undefined` instead of throwing. Raw response bodies remain owned by Courier until consumed or cancelled, so `cancelAll()` and `dispose()` still abort them. Raw responses cannot be combined with `schema`. Always consume or cancel the body to release request ownership, and set `timeout: Infinity` for intentionally long-lived streams. Courier preserves URL, redirect, and type metadata on tracked responses and their clones.
4921
+ Courier parses successful 2xx response bodies automatically based on `content-type`, unless you set `responseType` explicitly (`'json'`, `'text'`, `'blob'`, `'arrayBuffer'`, or `'raw'` for the `Response` itself). Empty JSON bodies return `undefined` instead of throwing. Raw response bodies remain owned by Courier until consumed or cancelled, so `cancelAll()` and `dispose()` still abort them. Raw responses cannot be combined with `schema`. Always consume or cancel the body to release request ownership, and set `timeout: Infinity` for intentionally long-lived streams. Courier preserves URL, redirect, and type metadata on tracked responses and their clones. A `204`/`205` raw answer is normalized to `body: null` — a stray empty transport stream is cancelled for you — so a null-body raw response never carries pending body ownership and needs no consumption.
3491
4922
 
3492
4923
  ```ts
3493
4924
  const buffer = await courier.request('/files/{id}', {
@@ -3670,6 +5101,7 @@ There is no `use()` method. To change middleware, create a new client. This keep
3670
5101
  ## Examples
3671
5102
 
3672
5103
  - [Authentication](./examples/authentication.md)
5104
+ - [Service Discovery and Custom Fetch](./examples/service-discovery-and-custom-fetch.md)
3673
5105
  - [CRUD Operations](./examples/crud-operations.md)
3674
5106
  - [Disposal](./examples/disposal.md)
3675
5107
  - [Error Handling Patterns](./examples/error-handling-patterns.md)
@@ -16403,64 +17835,60 @@ The IndexedDB adapter is built on Vault. Use Vault directly for unrelated storag
16403
17835
 
16404
17836
  ## Why Prism?
16405
17837
 
16406
- Charting 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:
17838
+ Prism renders charts as SVG in a host element and keeps application state outside the chart. You can replace chart data explicitly, respond to container resizing, and style the output with CSS custom properties.
16407
17839
 
16408
17840
  ```ts
16409
- // Before: Chart.js, imperative setup with a canvas you can't CSS-theme
16410
- import Chart from 'chart.js/auto';
16411
- const ctx = document.getElementById('myChart') as HTMLCanvasElement;
16412
- new Chart(ctx, {
16413
- type: 'line',
16414
- data: { labels, datasets: [{ data: values }] },
16415
- // re-create or mutate the Chart.js instance when data changes
16416
- });
16417
-
16418
- // After: Prism, responsive SVG with an explicit update boundary
16419
17841
  import { createLineChart } from '@vielzeug/prism';
16420
17842
 
16421
- const chart = createLineChart(document.getElementById('chart')!, {
16422
- a11y: { ariaLabel: 'Users by day' },
16423
- series: [
16424
- {
16425
- data: [
16426
- { key: 1, value: 12 },
16427
- { key: 2, value: 40 },
16428
- { key: 3, value: 28 },
16429
- ],
16430
- name: 'Users',
16431
- },
16432
- ],
16433
- tooltip: true,
17843
+ const host = document.createElement('div');
17844
+ host.style.cssText = 'width:640px;height:320px';
17845
+ document.body.append(host);
17846
+
17847
+ // Before: replace a hand-drawn SVG each time values change.
17848
+ function drawLine(values: number[]) {
17849
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
17850
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
17851
+ const low = Math.min(...values);
17852
+ const span = Math.max(...values) - low || 1;
17853
+ const points = values.map((value, index) => {
17854
+ const x = (index / Math.max(1, values.length - 1)) * 640;
17855
+ const y = 320 - ((value - low) / span) * 320;
17856
+ return `${index === 0 ? 'M' : 'L'}${x},${y}`;
17857
+ });
17858
+ svg.setAttribute('viewBox', '0 0 640 320');
17859
+ path.setAttribute('d', points.join(' '));
17860
+ path.setAttribute('fill', 'none');
17861
+ path.setAttribute('stroke', 'currentColor');
17862
+ svg.append(path);
17863
+ host.replaceChildren(svg);
17864
+ }
17865
+ drawLine([10, 25, 18]);
17866
+ drawLine([10, 25, 18, 32]);
17867
+
17868
+ // After: keep the chart instance and replace its series data.
17869
+ const chart = createLineChart(host, {
17870
+ a11y: { ariaLabel: 'Revenue by month' },
17871
+ series: [{ data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }], name: 'Revenue' }],
16434
17872
  });
16435
-
16436
- chart.update([
16437
- {
16438
- data: [
16439
- { key: 1, value: 12 },
16440
- { key: 2, value: 40 },
16441
- { key: 3, value: 28 },
16442
- { key: 4, value: 65 },
16443
- ],
16444
- name: 'Users',
16445
- },
16446
- ]);
17873
+ chart.update([{
17874
+ data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }],
17875
+ name: 'Revenue',
17876
+ }]);
17877
+ chart.dispose();
16447
17878
  ```
16448
17879
 
16449
- | Feature | Prism | Chart.js | Lightweight Charts | D3 |
16450
- | ------------------ | -------------------------------------------- | ---------------------------------------- | -------------------------------------------- | -------------------------------------------- |
16451
- | Bundle size | | ~60 kB | ~45 kB | ~30 kB (core) |
16452
- | Zero dependencies | (Orbit) | | | (modular) |
16453
- | Renderer | SVG | Canvas | Canvas | SVG/Canvas |
16454
- | Data updates | Explicit `update()` | Instance mutation | Series mutation | Manual |
16455
- | CSS themeable | | | Limited | |
16456
- | Framework-neutral | | | | |
16457
- | Accessible SVG | | | | Manual |
16458
- | Keyboard navigation | | | | Manual |
16459
- | TypeScript-first | | Partial | | Types available |
17880
+ | Feature | Prism | Hand-written SVG |
17881
+ | --- | --- | --- |
17882
+ | Bundle size | | No chart package |
17883
+ | Zero dependencies | (uses Orbit) | |
17884
+ | Data updates | `ChartHandle.update()` replaces chart data | You implement the update path |
17885
+ | Responsive sizing | `ResizeObserver` recalculates the chart layout | You implement the resize behavior |
17886
+ | Chart interactions | Built-in pointer and keyboard interactions on supported charts | You implement them |
17887
+ | SVG accessibility | Informative charts support an accessible name and keyboard focus | You implement the accessible behavior |
16460
17888
 
16461
- **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.
17889
+ **Use Prism when** you need chart factories, responsive SVG output, and an explicit data-update boundary without adopting a charting framework.
16462
17890
 
16463
- **Consider alternatives when** you need 50+ chart types (ECharts), financial trading charts (Lightweight Charts), or low-level visualization grammar (D3).
17891
+ **Consider hand-written SVG when** you need a single specialized graphic and prefer to own its scales, resizing, labels, and interactions directly.
16464
17892
 
16465
17893
  ## Installation
16466
17894
 
@@ -16478,63 +17906,48 @@ yarn add @vielzeug/prism
16478
17906
 
16479
17907
  ## Quick Start
16480
17908
 
17909
+ Create and size a host element before creating the chart. Import `theme.css` once to load Prism's default styles and CSS custom properties.
17910
+
16481
17911
  ```ts
16482
17912
  import { createLineChart } from '@vielzeug/prism';
16483
17913
  import '@vielzeug/prism/theme.css';
16484
17914
 
16485
- const chart = createLineChart(document.getElementById('chart')!, {
17915
+ const host = document.createElement('div');
17916
+ host.style.cssText = 'width:640px;height:320px';
17917
+ document.body.append(host);
17918
+
17919
+ const chart = createLineChart(host, {
16486
17920
  a11y: { ariaLabel: 'Revenue by month' },
16487
- series: [
16488
- {
16489
- color: '#3b82f6',
16490
- data: [
16491
- { key: 1, value: 10 },
16492
- { key: 2, value: 25 },
16493
- { key: 3, value: 18 },
16494
- { key: 4, value: 32 },
16495
- ],
16496
- name: 'Revenue',
16497
- },
16498
- ],
16499
- xAxis: { position: 'bottom' },
16500
- yAxis: { position: 'left', grid: true },
17921
+ series: [{
17922
+ data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }],
17923
+ name: 'Revenue',
17924
+ }],
16501
17925
  tooltip: true,
16502
- crosshair: true,
16503
- onHover: (event) => console.log(event?.datum),
16504
17926
  });
16505
17927
 
16506
- // Update chart data explicitly
16507
- chart.update([
16508
- {
16509
- color: '#3b82f6',
16510
- data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }, { key: 5, value: 28 }],
16511
- name: 'Revenue',
16512
- },
16513
- ]);
17928
+ chart.update([{
17929
+ data: [{ key: 1, value: 10 }, { key: 2, value: 25 }, { key: 3, value: 18 }, { key: 4, value: 32 }],
17930
+ name: 'Revenue',
17931
+ }]);
16514
17932
 
16515
- // Cleanup when done
16516
17933
  chart.dispose();
16517
17934
  ```
16518
17935
 
16519
17936
  ## Features
16520
17937
 
16521
- - **`createLineChart(container, config)`**: line chart with linear, monotone, or step interpolation
16522
- - **`createBarChart(container, config)`**: bar chart with four layout variants: grouped, stacked, grouped-horizontal, stacked-horizontal
16523
- - **`createAreaChart(container, config)`**: filled area with a gradient or solid fill
16524
- - **`createPieChart(container, config)`**: pie, donut, or semi-circle donut chart; labels that do not fit their slice are hidden
16525
- - **`createRadarChart(container, config)`**: radar chart for comparing values across 3+ axes, with per-axis ranges
16526
- - **`createSparkline(container, config)`**: minimal inline line, area, bar, or stack chart
16527
- - **`linearScale`, `timeScale`, `bandScale`**: standalone scales with nice tick generation
16528
- - **`ChartHandle.update(data)`**: replace chart data synchronously without coupling to a state library
16529
- - **`setTheme(theme, { scope })` / `resetTheme({ scope })`**: apply or clear colors, font, grid, axis, text, and tooltip tokens globally or on one subtree at runtime
16530
- - **Axes on by default**: line, area, and bar charts render both axes with gridlines on the value axis; `xAxis: false` / `yAxis: false` opt out
16531
- - **Animations on by default**: every chart animates entry and updates through one rAF loop, honors reduced motion, and opts out with `transition: false`
16532
- - **Series comparison**: hover or keyboard focus reports every series at the active key in the tooltip, `onHover`, and screen-reader announcements
16533
- - **Keyboard navigation**: arrow keys, `Enter`, and `Escape` on every labelled chart except sparklines
16534
- - **CSS custom properties**: full theme control via `--prism-*` tokens, with dark mode built in
16535
- - **Responsive**: auto-resizes via `ResizeObserver`
16536
- - **`ChartHandle.tap()`**: observe resize and dispose events outside the render path, with zero cost when untapped
16537
- - **`Symbol.dispose`**: explicit resource management following the TC39 proposal
17938
+ - **`createLineChart()` / `createAreaChart()`**: plot continuous numeric or date-keyed series
17939
+ - **`createBarChart()`**: compare categories with grouped, stacked, or horizontal bars
17940
+ - **`createScatterChart()`**: plot independent numeric `(x, y)` points
17941
+ - **`createPieChart()`**: render pie, donut, or semi-circle slices
17942
+ - **`createRadarChart()`**: compare series across fixed, labelled axes
17943
+ - **`createSparkline()`**: render compact line, area, bar, or stacked values without axes
17944
+ - **`ChartHandle.update(data)`**: replace chart data without handing state ownership to Prism
17945
+ - **`ChartHandle.dispose()` / `Symbol.dispose`**: release the SVG, observer, overlays, and chart-owned animation
17946
+ - **`ChartHandle.tap()`**: observe resize and disposal events
17947
+ - **`setTheme()` / `resetTheme()`**: set supported palette, typography, axis, grid, and tooltip tokens globally or on one element
17948
+ - **CSS custom properties**: style the chart palette, marks, axes, labels, tooltip, legend, and motion
17949
+ - **Resize observation**: recalculate SVG layout when the host element changes size
17950
+ - **Accessible interaction**: labelled interactive charts expose keyboard navigation and polite value announcements; sparklines are pointer-only without value announcements
16538
17951
 
16539
17952
  ## Documentation
16540
17953
 
@@ -16545,37 +17958,44 @@ chart.dispose();
16545
17958
 
16546
17959
  ## See Also
16547
17960
 
16548
- - [Refine](/refine/): accessible web components that pair well with Prism for dashboards
16549
- - [Orbit](/orbit/): floating element positioning for chart tooltips and popovers
17961
+ - [Refine](/refine/): web components and theme tokens for building controls and layouts around chart hosts
17962
+ - [Orbit](/orbit/): floating-element positioning used internally by Prism's built-in tooltip
16550
17963
 
16551
17964
  ### API Reference
16552
17965
 
16553
17966
  ## API Overview
16554
17967
 
17968
+ All chart factories, scale utilities, theme helpers, and error classes are synchronous. Charts render into an HTML element and return a `ChartHandle`; they do not return a promise.
17969
+
16555
17970
  | Symbol | Purpose | Execution mode | Common gotcha |
16556
17971
  | --- | --- | --- | --- |
16557
- | `createLineChart()` | Render a line chart | Sync | Line keys must be numbers or dates |
16558
- | `createAreaChart()` | Render an area chart | Sync | Line keys must be numbers or dates |
16559
- | `createBarChart()` | Render grouped or stacked bars | Sync | Stacked negative values are clamped to zero |
16560
- | `createPieChart()` | Render pie, donut, or semi-circle slices | Sync | `onHover` receives `null` when the pointer leaves |
16561
- | `createRadarChart()` | Compare values across 3+ axes, such as hero stats | Sync | Events report a whole axis, not one series |
16562
- | `createSparkline()` | Render an inline line, area, bar, or stack | Sync | Omitted `a11y` makes the chart decorative |
16563
- | `linearScale()` | Create a numeric scale | Sync | `nice` defaults to `true` |
16564
- | `timeScale()` | Create a date scale | Sync | Invalid dates produce an invalid domain |
16565
- | `bandScale()` | Create a categorical scale | Sync | Unknown categories map to `0` and warn in development |
16566
- | `setTheme()` | Set Prism CSS custom properties | Sync | Writes to `document.documentElement` unless `scope` is given |
16567
- | `resetTheme()` | Remove Prism theme overrides | Sync | Clears only properties `setTheme()` wrote, on the same target |
16568
- | `seriesColor()` | Resolve a series palette color | Sync | Palette indexes wrap after eight colors |
17972
+ | `createLineChart()` | Render one or more continuous line series | Sync | `key` values must be numbers or dates |
17973
+ | `createAreaChart()` | Render continuous series with filled areas | Sync | Fills use a gradient by default |
17974
+ | `createScatterChart()` | Plot independent `(x, y)` points | Sync | `key` is the x-value; the y-domain is fitted to data |
17975
+ | `createBarChart()` | Render categorical bars | Sync | Negative values in stacked variants are clamped to zero |
17976
+ | `createPieChart()` | Render pie, donut, or semi-circle slices | Sync | `variant` is fixed at creation; `update()` replaces slices |
17977
+ | `createRadarChart()` | Compare series across fixed axes | Sync | Requires at least three axes to draw |
17978
+ | `createSparkline()` | Render a compact line, area, bar, or stack | Sync | Has no axes, legend, or keyboard navigation |
17979
+ | `linearScale()` | Create a numeric scale | Sync | `nice` defaults to `true`; set `nice: false` to preserve the domain |
17980
+ | `timeScale()` | Create a date scale | Sync | `nice` defaults to `true` |
17981
+ | `bandScale()` | Create an ordered categorical scale | Sync | Unknown categories map to `0` and warn in development |
17982
+ | `setTheme()` | Set Prism CSS custom properties | Sync | Writes inline styles to `document.documentElement` by default |
17983
+ | `resetTheme()` | Remove theme overrides set by `setTheme()` | Sync | Only clears the selected target |
17984
+ | `seriesColor()` | Resolve a palette CSS value | Sync | Palette positions wrap after eight |
17985
+ | `PrismError` | Base class for Prism errors | Sync | Use `instanceof` to catch any Prism error |
17986
+ | `PrismRenderError` | Report an invalid chart container or failed chart operation | Sync | Also thrown by `update()` after disposal |
16569
17987
 
16570
17988
  ## Package Entry Point
16571
17989
 
16572
17990
  | Import | Purpose |
16573
17991
  | --- | --- |
16574
- | `@vielzeug/prism` | Chart factories, scales, theme helpers, errors, and public types |
16575
- | `@vielzeug/prism/theme.css` | Default CSS custom properties and dark-mode values |
17992
+ | `@vielzeug/prism` | Chart factories, scale and theme functions, error classes, and public types |
17993
+ | `@vielzeug/prism/theme.css` | Default chart styles, CSS custom properties, dark-mode values, and reduced-motion rules |
16576
17994
 
16577
17995
  ## Chart Factories
16578
17996
 
17997
+ Each factory appends an SVG to `container`. A chart with no measurable container size uses a temporary `600 × 300` SVG size and emits a development warning; `ResizeObserver` applies later size changes. Unless `a11y` is informative, the SVG is hidden from the accessibility tree.
17998
+
16579
17999
  ### `createLineChart()`
16580
18000
 
16581
18001
  ```ts
@@ -16585,28 +18005,32 @@ function createLineChart(
16585
18005
  ): ChartHandle;
16586
18006
  ```
16587
18007
 
16588
- Renders a line chart and returns a handle that replaces the complete series array through `update()`.
18008
+ Renders line series and returns a handle whose `update()` argument replaces the complete series array. Axes render by default; the value axis has grid lines by default. Pass `false` to `xAxis` or `yAxis` to suppress that axis.
18009
+
18010
+ Series use linear interpolation by default, omit point markers by default, and use a point radius of 3 when markers are enabled. `yAxis` defaults to `'left'`; crosshair, tooltip, and legend are disabled unless configured.
16589
18011
 
16590
18012
  | Parameter | Type | Description |
16591
18013
  | --- | --- | --- |
16592
- | `container` | `HTMLElement` | Host that receives the SVG and optional overlays |
16593
- | `config` | `LineChartConfig` | Initial series, axes, interaction, accessibility, and transition settings |
18014
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18015
+ | `config` | `LineChartConfig` | Series, axes, interaction, accessibility, and motion settings |
16594
18016
 
16595
18017
  **Returns:** `ChartHandle`.
16596
18018
 
16597
18019
  ```ts
16598
18020
  import { createLineChart } from '@vielzeug/prism';
16599
18021
 
16600
- const chart = createLineChart(container, {
18022
+ const host = document.createElement('div');
18023
+ host.style.cssText = 'width:640px;height:320px';
18024
+ document.body.append(host);
18025
+
18026
+ const chart = createLineChart(host, {
16601
18027
  a11y: { ariaLabel: 'Revenue by month' },
16602
- series: [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }],
18028
+ series: [{ data: [{ key: 1, value: 10 }, { key: 2, value: 15 }], name: 'Revenue' }],
16603
18029
  });
16604
-
16605
- chart.update([{ data: [{ key: 2, value: 20 }], name: 'Revenue' }]);
18030
+ chart.update([{ data: [{ key: 1, value: 10 }, { key: 2, value: 15 }, { key: 3, value: 18 }], name: 'Revenue' }]);
18031
+ chart.dispose();
16606
18032
  ```
16607
18033
 
16608
- ---
16609
-
16610
18034
  ### `createAreaChart()`
16611
18035
 
16612
18036
  ```ts
@@ -16616,24 +18040,67 @@ function createAreaChart(
16616
18040
  ): ChartHandle;
16617
18041
  ```
16618
18042
 
16619
- Renders an area chart and returns a handle that replaces the complete series array through `update()`.
18043
+ Renders continuous area series with a gradient fill by default. `update()` replaces the complete series array.
18044
+
18045
+ Area series use linear interpolation by default and show their top line unless `showLine: false` is set. Axes render by default, with grid lines on the value axis.
16620
18046
 
16621
18047
  | Parameter | Type | Description |
16622
18048
  | --- | --- | --- |
16623
- | `container` | `HTMLElement` | Host that receives the SVG and optional overlays |
16624
- | `config` | `AreaChartConfig` | Initial series, axes, interaction, accessibility, and transition settings |
18049
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18050
+ | `config` | `AreaChartConfig` | Series, axes, interaction, accessibility, and motion settings |
16625
18051
 
16626
18052
  **Returns:** `ChartHandle`.
16627
18053
 
16628
18054
  ```ts
16629
18055
  import { createAreaChart } from '@vielzeug/prism';
16630
18056
 
16631
- const chart = createAreaChart(container, {
16632
- series: [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }],
18057
+ const host = document.createElement('div');
18058
+ host.style.cssText = 'width:640px;height:320px';
18059
+ document.body.append(host);
18060
+
18061
+ const chart = createAreaChart(host, {
18062
+ a11y: { ariaLabel: 'Visitors over time' },
18063
+ series: [{ data: [{ key: 1, value: 10 }, { key: 2, value: 18 }], name: 'Visitors' }],
16633
18064
  });
18065
+ chart.dispose();
16634
18066
  ```
16635
18067
 
16636
- ---
18068
+ ### `createScatterChart()`
18069
+
18070
+ ```ts
18071
+ function createScatterChart(
18072
+ container: HTMLElement,
18073
+ config: ScatterChartConfig,
18074
+ ): ChartHandle;
18075
+ ```
18076
+
18077
+ Plots each datum independently: `key` is the numeric or date x-value and `value` is the y-value. Points are not joined. `update()` replaces the complete series array.
18078
+
18079
+ Axes render by default, with grid lines on the value axis. The value scale is fitted to the observed data rather than forced to include zero. Tooltip, crosshair, and legend are disabled unless configured.
18080
+
18081
+ | Parameter | Type | Description |
18082
+ | --- | --- | --- |
18083
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18084
+ | `config` | `ScatterChartConfig` | Independent points, axes, interaction, accessibility, and motion settings |
18085
+
18086
+ **Returns:** `ChartHandle`.
18087
+
18088
+ ```ts
18089
+ import { createScatterChart } from '@vielzeug/prism';
18090
+
18091
+ const host = document.createElement('div');
18092
+ host.style.cssText = 'width:640px;height:320px';
18093
+ document.body.append(host);
18094
+
18095
+ const chart = createScatterChart(host, {
18096
+ a11y: { ariaLabel: 'Height against weight' },
18097
+ series: [{ data: [{ key: 160, value: 55 }, { key: 175, value: 70 }], name: 'Cohort A' }],
18098
+ tooltip: true,
18099
+ });
18100
+ chart.dispose();
18101
+ ```
18102
+
18103
+ Scatter interaction reports the datum and series of the point nearest the pointer in two dimensions, without a `values` array. Keyboard navigation visits points across series in x order. Tooltip `titleFormat` receives the x-value and `valueFormat` the y-value. On mount, points grow from radius zero at their final positions; updates slide existing points and grow new ones.
16637
18104
 
16638
18105
  ### `createBarChart()`
16639
18106
 
@@ -16644,25 +18111,32 @@ function createBarChart(
16644
18111
  ): ChartHandle;
16645
18112
  ```
16646
18113
 
16647
- Renders grouped, stacked, grouped-horizontal, or stacked-horizontal bars.
18114
+ Renders grouped, stacked, grouped-horizontal, or stacked-horizontal categories. `update()` replaces the complete series array; the variant is fixed at creation. Axes render by default, with grid lines on the value axis.
18115
+
18116
+ The default variant is `grouped`; tooltip and legend are disabled unless configured.
16648
18117
 
16649
18118
  | Parameter | Type | Description |
16650
18119
  | --- | --- | --- |
16651
- | `container` | `HTMLElement` | Host that receives the SVG and optional overlays |
16652
- | `config` | `BarChartConfig` | Initial series, variant, axes, accessibility, and interaction settings |
18120
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18121
+ | `config` | `BarChartConfig` | Series, layout variant, axes, interaction, accessibility, and motion settings |
16653
18122
 
16654
18123
  **Returns:** `ChartHandle`.
16655
18124
 
16656
18125
  ```ts
16657
18126
  import { createBarChart } from '@vielzeug/prism';
16658
18127
 
16659
- const chart = createBarChart(container, {
16660
- series: [{ data: [{ key: 'Open', value: 12 }], name: 'Tasks' }],
16661
- variant: 'grouped',
18128
+ const host = document.createElement('div');
18129
+ host.style.cssText = 'width:640px;height:320px';
18130
+ document.body.append(host);
18131
+
18132
+ const chart = createBarChart(host, {
18133
+ a11y: { ariaLabel: 'Tasks by status' },
18134
+ series: [{ data: [{ key: 'Open', value: 12 }, { key: 'Done', value: 8 }], name: 'Tasks' }],
16662
18135
  });
18136
+ chart.dispose();
16663
18137
  ```
16664
18138
 
16665
- ---
18139
+ For stacked variants, negative values are treated as zero and produce a development warning.
16666
18140
 
16667
18141
  ### `createPieChart()`
16668
18142
 
@@ -16673,26 +18147,30 @@ function createPieChart(
16673
18147
  ): ChartHandle;
16674
18148
  ```
16675
18149
 
16676
- Renders pie, donut, or semi-circle slices.
18150
+ Renders pie, donut, or semi-circle slices. The default variant is `pie`; `update()` replaces the slice array. The default inner radius is zero for pies and 55% of the outer radius for donut and semi variants. Pie slices default to no padding or rounded corners; donut and semi slices default to 8px padding and corner radius.
16677
18151
 
16678
18152
  | Parameter | Type | Description |
16679
18153
  | --- | --- | --- |
16680
- | `container` | `HTMLElement` | Host that receives the SVG and optional overlays |
16681
- | `config` | `PieChartConfig` | Initial slices, variant, geometry, accessibility, and interaction settings |
18154
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18155
+ | `config` | `PieChartConfig` | Slices, shape, geometry, legend, tooltip, and interaction settings |
16682
18156
 
16683
18157
  **Returns:** `ChartHandle`.
16684
18158
 
16685
18159
  ```ts
16686
18160
  import { createPieChart } from '@vielzeug/prism';
16687
18161
 
16688
- const chart = createPieChart(container, {
16689
- data: [{ label: 'Complete', value: 72 }, { label: 'Remaining', value: 28 }],
18162
+ const host = document.createElement('div');
18163
+ host.style.cssText = 'width:320px;height:240px';
18164
+ document.body.append(host);
18165
+
18166
+ const chart = createPieChart(host, {
18167
+ a11y: { ariaLabel: 'Orders by channel' },
18168
+ data: [{ label: 'Direct', value: 72 }, { label: 'Referral', value: 28 }],
16690
18169
  variant: 'donut',
16691
18170
  });
18171
+ chart.dispose();
16692
18172
  ```
16693
18173
 
16694
- ---
16695
-
16696
18174
  ### `createRadarChart()`
16697
18175
 
16698
18176
  ```ts
@@ -16702,47 +18180,32 @@ function createRadarChart(
16702
18180
  ): ChartHandle;
16703
18181
  ```
16704
18182
 
16705
- Renders one closed shape per series across three or more axes. `update()` replaces the series; the axes are fixed at creation.
18183
+ Renders one shape per series across the configured axes. `update()` replaces series data but not axes or other configuration. Fewer than three axes warn in development and draw no chart. Tooltip and legend are disabled unless configured.
16706
18184
 
16707
18185
  | Parameter | Type | Description |
16708
18186
  | --- | --- | --- |
16709
- | `container` | `HTMLElement` | Host that receives the SVG and optional overlays |
16710
- | `config` | `RadarChartConfig` | Axes, series, scale, grid, appearance, accessibility, and interaction settings |
16711
-
16712
- | Field | Type | Default | Description |
16713
- | --- | --- | --- | --- |
16714
- | `axes` | `RadarAxisConfig[]` | N/A | Ordered axes, drawn clockwise; needs at least 3 |
16715
- | `series` | `RadarSeriesConfig[]` | N/A | Values keyed by `axis.key` |
16716
- | `domain` | `[number, number]` | `[0, nice max]` | Shared value range; `axis.min`/`axis.max` override it per axis |
16717
- | `grid` | `boolean \| RadarGridConfig` | `true` | Rings, bands, and level labels; `false` hides the grid |
16718
- | `fill` | `'gradient' \| 'none' \| 'solid'` | `'solid'` | Shape fill; `gradient` deepens toward the rim |
16719
- | `curve` | `'linear' \| 'rounded'` | `'linear'` | Straight edges or a smooth closed curve |
16720
- | `showPoints` | `boolean` | `true` | Vertex dots; a missing value shows a hollow dot at the centre |
16721
- | `showValues` | `boolean` | `false` | Print each value beside its vertex |
16722
- | `startAngle` | `number` | `0` | Degrees clockwise from 12 o'clock for the first axis |
16723
- | `onHover` | `(event: RadarEvent \| null) => void` | N/A | Nearest axis under the pointer or keyboard focus |
16724
- | `onClick` | `(event: RadarEvent) => void` | N/A | Axis clicked, or activated with Enter/Space |
18187
+ | `container` | `HTMLElement` | Host that receives the chart SVG and optional overlays |
18188
+ | `config` | `RadarChartConfig` | Fixed axes, series, scale, grid, appearance, and interaction settings |
16725
18189
 
16726
18190
  **Returns:** `ChartHandle`.
16727
18191
 
16728
18192
  ```ts
16729
18193
  import { createRadarChart } from '@vielzeug/prism';
16730
18194
 
16731
- const chart = createRadarChart(container, {
18195
+ const host = document.createElement('div');
18196
+ host.style.cssText = 'width:400px;height:320px';
18197
+ document.body.append(host);
18198
+
18199
+ const chart = createRadarChart(host, {
16732
18200
  a11y: { ariaLabel: 'Hero stats' },
16733
- axes: [
16734
- { key: 'str', label: 'Strength' },
16735
- { key: 'agi', label: 'Agility' },
16736
- { key: 'end', label: 'Endurance' },
16737
- ],
18201
+ axes: [{ key: 'str', label: 'Strength' }, { key: 'agi', label: 'Agility' }, { key: 'end', label: 'Endurance' }],
16738
18202
  domain: [0, 10],
16739
18203
  series: [{ data: [{ key: 'str', value: 8 }, { key: 'agi', value: 6 }, { key: 'end', value: 9 }], name: 'Adam' }],
16740
18204
  });
18205
+ chart.dispose();
16741
18206
  ```
16742
18207
 
16743
- Hovering 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.
16744
-
16745
- ---
18208
+ Radar defaults are a solid fill, linear curves, visible points, a four-level polygon grid with bands, no grid labels, and a first-axis angle of zero degrees. Grid labels render only when all axes share a domain.
16746
18209
 
16747
18210
  ### `createSparkline()`
16748
18211
 
@@ -16753,22 +18216,29 @@ function createSparkline(
16753
18216
  ): ChartHandle;
16754
18217
  ```
16755
18218
 
16756
- Renders a compact chart without axes, margin, or legend.
18219
+ Renders compact data without axes, chart margins, or a legend. `update()` replaces the number or stack-segment array. The default variant is `line`; only line and area variants draw the endpoint marker by default.
16757
18220
 
16758
18221
  | Parameter | Type | Description |
16759
18222
  | --- | --- | --- |
16760
- | `container` | `HTMLElement` | Host that receives the SVG |
16761
- | `config` | `SparklineConfig` | Initial values, variant, appearance, accessibility, and callbacks |
18223
+ | `container` | `HTMLElement` | Host that receives the chart SVG |
18224
+ | `config` | `SparklineConfig` | Values, variant, appearance, accessibility, and pointer callbacks |
16762
18225
 
16763
18226
  **Returns:** `ChartHandle`.
16764
18227
 
16765
18228
  ```ts
16766
18229
  import { createSparkline } from '@vielzeug/prism';
16767
18230
 
16768
- const chart = createSparkline(container, { data: [2, 5, 3, 8], variant: 'area' });
18231
+ const host = document.createElement('div');
18232
+ host.style.cssText = 'width:240px;height:48px';
18233
+ document.body.append(host);
18234
+
18235
+ const chart = createSparkline(host, { data: [2, 5, 3, 8], variant: 'area' });
16769
18236
  chart.update([2, 5, 3, 8, 13]);
18237
+ chart.dispose();
16770
18238
  ```
16771
18239
 
18240
+ `StackSegment[]` is for `variant: 'stack'`. Pointer callbacks are available for numeric line, area, and bar sparklines; sparklines do not support keyboard navigation.
18241
+
16772
18242
  ## Scale Factories
16773
18243
 
16774
18244
  ### `linearScale()`
@@ -16777,14 +18247,16 @@ chart.update([2, 5, 3, 8, 13]);
16777
18247
  function linearScale(config: LinearScaleConfig): Scale;
16778
18248
  ```
16779
18249
 
16780
- | Field | Type | Default | Description |
18250
+ Maps a numeric domain to a numeric range. `nice` defaults to `true`; `clamp` defaults to `false` and applies to `map()`, not `invert()`.
18251
+
18252
+ | Parameter | Type | Default | Description |
16781
18253
  | --- | --- | --- | --- |
16782
- | `domain` | `[number, number]` | N/A | Input extent |
16783
- | `range` | `[number, number]` | N/A | Output extent |
16784
- | `clamp` | `boolean` | `false` | Clamp mapped and inverted values |
16785
- | `nice` | `boolean` | `true` | Expand the domain to rounded boundaries |
18254
+ | `config.domain` | `[number, number]` | Required | Input extent |
18255
+ | `config.range` | `[number, number]` | Required | Output extent |
18256
+ | `config.clamp` | `boolean` | `false` | Clamp mapped values to the range |
18257
+ | `config.nice` | `boolean` | `true` | Expand the domain to rounded boundaries |
16786
18258
 
16787
- **Returns:** `Scale`.
18259
+ **Returns:** `Scale`, with `map`, `invert`, `ticks`, `domain`, and `range`.
16788
18260
 
16789
18261
  ```ts
16790
18262
  import { linearScale } from '@vielzeug/prism';
@@ -16793,21 +18265,21 @@ const scale = linearScale({ domain: [0, 100], range: [0, 500] });
16793
18265
  scale.map(50); // 250
16794
18266
  ```
16795
18267
 
16796
- ---
16797
-
16798
18268
  ### `timeScale()`
16799
18269
 
16800
18270
  ```ts
16801
18271
  function timeScale(config: TimeScaleConfig): Scale;
16802
18272
  ```
16803
18273
 
16804
- | Field | Type | Default | Description |
18274
+ Maps date values to a numeric range. `nice` defaults to `true` and rounds the domain to a time interval.
18275
+
18276
+ | Parameter | Type | Default | Description |
16805
18277
  | --- | --- | --- | --- |
16806
- | `domain` | `[Date, Date]` | N/A | Input date extent |
16807
- | `range` | `[number, number]` | N/A | Output extent |
16808
- | `nice` | `boolean` | `true` | Expand the domain to rounded interval boundaries |
18278
+ | `config.domain` | `[Date, Date]` | Required | Input date extent |
18279
+ | `config.range` | `[number, number]` | Required | Output extent |
18280
+ | `config.nice` | `boolean` | `true` | Expand the domain to time-interval boundaries |
16809
18281
 
16810
- **Returns:** `Scale`.
18282
+ **Returns:** `Scale`, with `map`, `invert`, `ticks`, `domain`, and `range`.
16811
18283
 
16812
18284
  ```ts
16813
18285
  import { timeScale } from '@vielzeug/prism';
@@ -16816,45 +18288,59 @@ const scale = timeScale({
16816
18288
  domain: [new Date('2026-01-01'), new Date('2026-12-31')],
16817
18289
  range: [0, 500],
16818
18290
  });
18291
+ scale.map(new Date('2026-07-02'));
16819
18292
  ```
16820
18293
 
16821
- ---
16822
-
16823
18294
  ### `bandScale()`
16824
18295
 
16825
18296
  ```ts
16826
18297
  function bandScale(config: BandScaleConfig): BandScale;
16827
18298
  ```
16828
18299
 
16829
- | Field | Type | Default | Description |
18300
+ Maps ordered string categories to the start of each band. `padding` defaults to `0.1`; `paddingOuter` defaults to the same value. Mapping an unknown category returns `0` and warns in development.
18301
+
18302
+ | Parameter | Type | Default | Description |
16830
18303
  | --- | --- | --- | --- |
16831
- | `domain` | `string[]` | N/A | Ordered categories |
16832
- | `range` | `[number, number]` | N/A | Output extent |
16833
- | `padding` | `number` | `0.1` | Inner gap ratio |
16834
- | `paddingOuter` | `number` | `padding` | Outer gap ratio |
18304
+ | `config.domain` | `string[]` | Required | Ordered categories |
18305
+ | `config.range` | `[number, number]` | Required | Output extent |
18306
+ | `config.padding` | `number` | `0.1` | Inner gap as a ratio of bandwidth |
18307
+ | `config.paddingOuter` | `number` | `padding` | Outer gap as a ratio of bandwidth |
16835
18308
 
16836
- **Returns:** `BandScale`.
18309
+ **Returns:** `BandScale`, with `map`, `ticks`, `bandwidth`, `gap`, `domain`, and `range`.
16837
18310
 
16838
18311
  ```ts
16839
18312
  import { bandScale } from '@vielzeug/prism';
16840
18313
 
16841
18314
  const scale = bandScale({ domain: ['A', 'B'], range: [0, 200] });
18315
+ scale.bandwidth();
16842
18316
  ```
16843
18317
 
16844
18318
  ## Theme Utilities
16845
18319
 
18320
+ `theme.css` supplies the base CSS values and styles. `setTheme()` writes inline custom properties, which take precedence over stylesheet declarations. For other visual controls, set CSS custom properties directly.
18321
+
16846
18322
  ### `setTheme()`
16847
18323
 
16848
18324
  ```ts
16849
18325
  function setTheme(theme: PrismTheme, options?: ThemeScope): void;
16850
18326
  ```
16851
18327
 
16852
- Sets Prism theme custom properties as inline styles on `options.scope` when given, otherwise on `document.documentElement`. Inline values outrank every stylesheet rule, including dark-mode selectors.
18328
+ Sets the provided theme values on `options.scope`, or on `document.documentElement` when omitted. Omitted scalar keys remain unchanged. Providing `colors` replaces all eight palette slots, removing unused slots.
16853
18329
 
16854
18330
  | Parameter | Type | Description |
16855
18331
  | --- | --- | --- |
16856
- | `theme` | `PrismTheme` | Tokens to write; omitted keys are left untouched |
16857
- | `options.scope` | `HTMLElement` | Subtree to theme instead of `:root`; charts inside it resolve their `var(--prism-*)` lookups against these values |
18332
+ | `theme` | `PrismTheme` | Palette and supported axis, grid, text, font, and tooltip properties |
18333
+ | `options.scope` | `HTMLElement` | Element that scopes the inline custom properties |
18334
+
18335
+ **Returns:** `void`.
18336
+
18337
+ ```ts
18338
+ import { setTheme } from '@vielzeug/prism';
18339
+
18340
+ const host = document.createElement('div');
18341
+ document.body.append(host);
18342
+ setTheme({ colors: ['#2563eb', '#16a34a'], textColor: '#111827' }, { scope: host });
18343
+ ```
16858
18344
 
16859
18345
  ### `resetTheme()`
16860
18346
 
@@ -16862,7 +18348,21 @@ Sets Prism theme custom properties as inline styles on `options.scope` when give
16862
18348
  function resetTheme(options?: ThemeScope): void;
16863
18349
  ```
16864
18350
 
16865
- Removes every custom property `setTheme()` can set from `options.scope` (default `documentElement`). Scoped and global calls are independent targets.
18351
+ Removes every custom property managed by `setTheme()` from the given scope or the document root. It does not remove CSS rules or change any other custom properties.
18352
+
18353
+ | Parameter | Type | Description |
18354
+ | --- | --- | --- |
18355
+ | `options` | `ThemeScope` | Optional target; defaults to `document.documentElement` |
18356
+
18357
+ **Returns:** `void`.
18358
+
18359
+ ```ts
18360
+ import { resetTheme, setTheme } from '@vielzeug/prism';
18361
+
18362
+ const scope = document.createElement('div');
18363
+ setTheme({ textColor: '#111827' }, { scope });
18364
+ resetTheme({ scope });
18365
+ ```
16866
18366
 
16867
18367
  ### `seriesColor()`
16868
18368
 
@@ -16870,1383 +18370,993 @@ Removes every custom property `setTheme()` can set from `options.scope` (default
16870
18370
  function seriesColor(index: number, override?: string): string;
16871
18371
  ```
16872
18372
 
16873
- Returns `override` when provided; otherwise returns `var(--prism-color-N)` with an eight-color wrap.
18373
+ Returns `override` when it is provided; otherwise returns a `var(--prism-color-N)` reference. The palette has eight positions and the index wraps around after the eighth.
16874
18374
 
16875
- ## Types
18375
+ | Parameter | Type | Description |
18376
+ | --- | --- | --- |
18377
+ | `index` | `number` | Zero-based palette position |
18378
+ | `override` | `string` | Optional color returned instead of a CSS variable |
16876
18379
 
16877
- ### Core types
18380
+ **Returns:** `string`.
16878
18381
 
16879
18382
  ```ts
16880
- interface ChartHandle {
16881
- readonly disposalSignal: AbortSignal;
16882
- readonly disposed: boolean;
16883
- readonly el: SVGSVGElement;
16884
- tap(handler: (event: PrismEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
16885
- update(data: TData): void;
16886
- dispose(): void;
16887
- [Symbol.dispose](): void;
16888
- }
18383
+ import { seriesColor } from '@vielzeug/prism';
16889
18384
 
16890
- type PrismEvent =
16891
- | { readonly height: number; readonly type: 'resize'; readonly width: number }
16892
- | { readonly type: 'dispose' };
18385
+ seriesColor(0); // 'var(--prism-color-1)'
18386
+ seriesColor(1, '#2563eb'); // '#2563eb'
18387
+ ```
16893
18388
 
16894
- interface ChartMargin {
16895
- top: number;
16896
- right: number;
16897
- bottom: number;
16898
- left: number;
16899
- }
18389
+ ## Chart Handle
16900
18390
 
16901
- interface ChartDimensions {
16902
- width: number;
16903
- height: number;
16904
- margin: ChartMargin;
16905
- }
18391
+ `ChartHandle` is returned by every chart factory. `update()` synchronously rebuilds the chart from its new data; enabled motion may animate the resulting SVG. `dispose()` is idempotent, removes the chart SVG, observers, tooltip and legend, cancels chart-owned animation, aborts `disposalSignal`, and emits one `dispose` tap event. `update()` after disposal throws `PrismRenderError`.
16906
18392
 
16907
- type ChartA11y =
16908
- | { readonly decorative: true }
16909
- | { readonly ariaLabel: string; readonly decorative?: false };
16910
- ```
18393
+ `tap()` observes `resize` and `dispose` events outside the render path. It returns an unsubscribe function; an optional abort signal removes the subscription. Observer-handler exceptions are swallowed so they cannot affect chart behavior. A tap added after disposal returns a no-op unsubscribe function.
16911
18394
 
16912
- `update()` renders synchronously and throws `PrismRenderError` after disposal. Omitting `a11y` makes the SVG decorative with `aria-hidden="true"`.
18395
+ `[Symbol.dispose]()` delegates to `dispose()`, allowing explicit resource management where supported.
16913
18396
 
16914
- `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.
18397
+ All chart handles expose the same lifecycle members; interactive cartesian and pie/radar charts use a `.prism-live` status region when no tooltip is enabled. A tooltip is itself a polite status region. Sparklines have neither a tooltip nor a live value announcer.
16915
18398
 
16916
- ---
18399
+ | Method or property | Contract |
18400
+ | --- | --- |
18401
+ | `el` | The chart's root `SVGSVGElement` |
18402
+ | `update(data)` | Replace chart data and render; throws `PrismRenderError` after disposal |
18403
+ | `dispose()` | Idempotently release chart-owned DOM, observation, and motion |
18404
+ | `disposed` | Whether the handle has been disposed |
18405
+ | `disposalSignal` | `AbortSignal` aborted during disposal |
18406
+ | `tap(handler, options?)` | Observe resize/dispose events; returns an unsubscribe function |
18407
+ | `[Symbol.dispose]()` | Calls `dispose()` |
18408
+
18409
+ `PrismEvent` reports a changed SVG content size as `{ type: 'resize', width, height }`; the `dispose` event is emitted once before teardown completes.
18410
+
18411
+ ## Types
18412
+
18413
+ The following are all types and interfaces exported from `@vielzeug/prism`. Definitions mirror the package entry-point contracts.
16917
18414
 
16918
- ### Data types
18415
+ ### Animation Types
16919
18416
 
16920
18417
  ```ts
16921
- interface Datum {
16922
- key: TKey;
16923
- value: number;
16924
- meta?: Record;
16925
- dash?: string; // stroke-dasharray for this datum's marks, e.g. '5 5'
16926
- opacity?: number; // opacity multiplier 0-1 for this datum's marks, e.g. 0.4
18418
+ export type EasingFn = (t: number) => number;
18419
+ export type EasingName = 'back-out' | 'ease-in' | 'ease-in-out' | 'ease-out' | 'expo-out' | 'linear';
18420
+
18421
+ export interface TransitionConfig {
18422
+ /** Milliseconds per animated lane. Defaults to 300 (420 for a line entrance, 400 for radar). */
18423
+ duration?: number;
18424
+ easing?: EasingName | ((t: number) => number);
18425
+ /** `system` skips animation when reduced motion is preferred. */
18426
+ preference?: 'always' | 'never' | 'system';
18427
+ /** Per-bar delay on bar charts, per-entering-point delay (x order) on scatter charts, or per-series delay on radar charts. Bar and scatter charts cap the total added delay at about 400 ms. */
18428
+ stagger?: number;
16927
18429
  }
16928
18430
 
16929
- type ContinuousDatum = Datum;
18431
+ export type TransitionOption = TransitionConfig | boolean;
18432
+ ```
16930
18433
 
16931
- interface Series {
16932
- name: string;
16933
- data: TDatum[];
16934
- color?: string;
16935
- id?: string; // stable data-series-id on the series group; defaults to series-
16936
- }
18434
+ ### Theme Types
16937
18435
 
16938
- interface ChartEvent {
16939
- datum: Datum;
16940
- series: Series;
16941
- originalEvent: Event;
16942
- values?: SeriesValue[]; // every series at the same key (line, area, bar)
18436
+ ```ts
18437
+ export interface PrismTheme {
18438
+ axisColor?: string;
18439
+ colors?: string[];
18440
+ fontFamily?: string;
18441
+ gridColor?: string;
18442
+ gridOpacity?: number;
18443
+ textColor?: string;
18444
+ textColorSecondary?: string;
18445
+ tooltipBg?: string;
18446
+ tooltipBorder?: string;
18447
+ tooltipColor?: string;
16943
18448
  }
16944
18449
 
16945
- interface SeriesValue {
16946
- datum: Datum | undefined;
16947
- series: Series;
18450
+ export interface ThemeScope {
18451
+ readonly scope?: HTMLElement;
16948
18452
  }
16949
18453
  ```
16950
18454
 
16951
- Line 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.
18455
+ `PrismTheme.colors` writes up to eight palette slots. The scalar fields map as follows:
16952
18456
 
16953
- #### Per-datum presentation
16954
-
16955
- `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:
18457
+ | Field | CSS custom property |
18458
+ | --- | --- |
18459
+ | `axisColor` | `--prism-axis-color` |
18460
+ | `fontFamily` | `--prism-font-family` |
18461
+ | `gridColor` | `--prism-grid-color` |
18462
+ | `gridOpacity` | `--prism-grid-opacity` |
18463
+ | `textColor` | `--prism-text-color` |
18464
+ | `textColorSecondary` | `--prism-text-color-secondary` |
18465
+ | `tooltipBg` | `--prism-tooltip-bg` |
18466
+ | `tooltipBorder` | `--prism-tooltip-border` |
18467
+ | `tooltipColor` | `--prism-tooltip-color` |
16956
18468
 
16957
- | Chart | `dash` | `opacity` |
16958
- | --- | --- | --- |
16959
- | 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`. |
16960
- | 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. |
16961
- | 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. |
16962
- | Sparkline, pie, radar | Ignored. | Ignored. |
18469
+ ### Core and Data Types
16963
18470
 
16964
- Consecutive 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).
18471
+ ```ts
18472
+ export interface ChartMargin {
18473
+ bottom: number;
18474
+ left: number;
18475
+ right: number;
18476
+ top: number;
18477
+ }
16965
18478
 
16966
- #### Stable series identity
18479
+ export interface ChartDimensions {
18480
+ height: number;
18481
+ margin: ChartMargin;
18482
+ width: number;
18483
+ }
16967
18484
 
16968
- `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-`, which only stays stable while the series order does — set `id` to survive reordering.
16969
-
16970
- ---
16971
-
16972
- ### Chart configurations
16973
-
16974
- ```ts
16975
- interface BaseChartConfig {
16976
- a11y?: ChartA11y;
16977
- legend?: boolean | LegendConfig;
16978
- margin?: Partial;
16979
- onClick?: (event: ChartEvent) => void;
16980
- onHover?: (event: ChartEvent | null) => void;
16981
- tooltip?: boolean | TooltipConfig;
16982
- transition?: TransitionOption; // motion is on by default; false renders synchronously
16983
- // Omitted renders the axis with defaults (value axis with gridlines); false suppresses it.
16984
- xAxis?: XAxisConfig | false;
16985
- yAxis?: YAxisConfig | false;
16986
- }
16987
-
16988
- interface LineChartConfig extends BaseChartConfig {
16989
- crosshair?: boolean | CrosshairConfig;
16990
- rightYAxis?: Omit;
16991
- series: LineSeriesConfig[];
16992
- }
16993
-
16994
- interface AreaChartConfig extends BaseChartConfig {
16995
- crosshair?: boolean | CrosshairConfig;
16996
- series: AreaSeriesConfig[];
16997
- }
16998
-
16999
- interface BarChartConfig extends BaseChartConfig {
17000
- series: BarSeriesConfig[];
17001
- variant?: BarVariant;
17002
- }
17003
-
17004
- interface PieChartConfig
17005
- extends Omit {
17006
- data: PieSliceConfig[];
17007
- variant?: PieVariant;
17008
- innerRadius?: number;
17009
- cornerRadius?: number;
17010
- padPixels?: number;
17011
- onClick?: (event: PieEvent) => void;
17012
- onHover?: (event: PieEvent | null) => void;
17013
- }
17014
-
17015
- interface RadarChartConfig
17016
- extends Omit {
17017
- axes: RadarAxisConfig[];
17018
- series: RadarSeriesConfig[];
17019
- curve?: 'linear' | 'rounded';
17020
- domain?: [number, number];
17021
- fill?: 'gradient' | 'none' | 'solid';
17022
- grid?: boolean | RadarGridConfig;
17023
- showPoints?: boolean;
17024
- showValues?: boolean;
17025
- startAngle?: number;
17026
- onClick?: (event: RadarEvent) => void;
17027
- onHover?: (event: RadarEvent | null) => void;
17028
- }
17029
-
17030
- interface SparklineConfig {
17031
- data: number[] | StackSegment[];
17032
- variant?: SparklineVariant;
17033
- a11y?: ChartA11y;
17034
- color?: string;
17035
- cornerRadius?: number;
17036
- curve?: 'linear' | 'monotone' | 'step';
17037
- fillOpacity?: number; // overrides --prism-spark-fill-opacity (area variant)
17038
- onClick?: (event: SparklineEvent) => void;
17039
- onHover?: (event: SparklineEvent | null) => void;
17040
- padPixels?: number;
17041
- showEndPoint?: boolean; // default true: dot on the latest value (line, area)
17042
- strokeWidth?: number;
17043
- transition?: TransitionOption; // motion is on by default; false renders synchronously
17044
- }
17045
- ```
17046
-
17047
- ---
17048
-
17049
- ### Series and slice configurations
17050
-
17051
- ```ts
17052
- interface LineSeriesConfig extends Series {
17053
- yAxis?: 'left' | 'right'; // defaults to 'left'
17054
- curve?: 'linear' | 'monotone' | 'step';
17055
- pointRadius?: number;
17056
- showPoints?: boolean;
17057
- strokeWidth?: number; // overrides --prism-line-width
17058
- }
18485
+ export type PrismEvent =
18486
+ | { readonly height: number; readonly type: 'resize'; readonly width: number }
18487
+ | { readonly type: 'dispose' };
17059
18488
 
17060
- interface AreaSeriesConfig extends Series {
17061
- curve?: 'linear' | 'monotone' | 'step';
17062
- fill?: 'gradient' | 'solid'; // default 'gradient', fading toward the baseline
17063
- fillOpacity?: number; // overrides --prism-area-opacity
17064
- showLine?: boolean;
18489
+ export interface ChartHandle {
18490
+ readonly disposalSignal: AbortSignal;
18491
+ dispose(): void;
18492
+ readonly disposed: boolean;
18493
+ readonly el: SVGSVGElement;
18494
+ tap(handler: (event: PrismEvent) => void, options?: { readonly signal?: AbortSignal }): () => void;
18495
+ update(data: TData): void;
18496
+ [Symbol.dispose](): void;
17065
18497
  }
17066
18498
 
17067
- interface BarSeriesConfig extends Series {
17068
- borderRadius?: number; // overrides --prism-bar-radius; stacked bars stay square
17069
- }
18499
+ export type ChartA11y =
18500
+ | { readonly decorative: true }
18501
+ | { readonly ariaLabel: string; readonly decorative?: false };
17070
18502
 
17071
- interface PieSliceConfig {
18503
+ export interface Datum {
18504
+ dash?: string;
18505
+ key: TKey;
18506
+ meta?: Record;
18507
+ opacity?: number;
17072
18508
  value: number;
17073
- color?: string;
17074
- label?: string;
17075
18509
  }
17076
18510
 
17077
- interface PieEvent {
17078
- index: number;
17079
- originalEvent: Event;
17080
- slice: PieSliceConfig;
17081
- }
18511
+ export type ContinuousDatum = Datum;
17082
18512
 
17083
- interface RadarAxisConfig {
17084
- key: string;
17085
- label: string;
17086
- min?: number;
17087
- max?: number;
17088
- format?: (value: number) => string;
17089
- }
17090
-
17091
- type RadarSeriesConfig = Series>;
17092
-
17093
- interface RadarGridConfig {
17094
- bands?: boolean; // default true
17095
- labels?: boolean; // default false; drawn only when every axis shares one domain
17096
- levels?: number; // default 4
17097
- shape?: 'circle' | 'polygon'; // default 'polygon'
18513
+ export interface Series {
18514
+ color?: string;
18515
+ data: TDatum[];
18516
+ id?: string;
18517
+ name: string;
17098
18518
  }
17099
18519
 
17100
- interface RadarEvent {
17101
- axis: RadarAxisConfig;
17102
- index: number;
17103
- values: RadarAxisValue[];
18520
+ export interface ChartEvent {
18521
+ datum: Datum;
17104
18522
  originalEvent: Event;
18523
+ series: Series;
18524
+ values?: SeriesValue[];
17105
18525
  }
17106
18526
 
17107
- interface RadarAxisValue {
18527
+ export interface SeriesValue {
17108
18528
  datum: Datum | undefined;
17109
- series: RadarSeriesConfig;
17110
- }
17111
-
17112
- interface StackSegment {
17113
- value: number;
17114
- color?: string;
17115
- label?: string;
17116
- }
17117
-
17118
- interface SparklineEvent {
17119
- index: number;
17120
- originalEvent: Event;
17121
- value: number;
18529
+ series: Series;
17122
18530
  }
17123
18531
  ```
17124
18532
 
17125
- A custom radar `tooltip.render` receives `datum.meta` with `axis`, `values`, and the default `text`.
17126
-
17127
- ---
17128
-
17129
- ### Axis, interaction, and transition configurations
17130
-
17131
- ```ts
17132
- type HorizontalAxisPosition = 'bottom' | 'top';
17133
- type VerticalAxisPosition = 'left' | 'right';
17134
- type AxisPosition = HorizontalAxisPosition | VerticalAxisPosition;
17135
-
17136
- interface AxisConfig {
17137
- grid?: boolean | GridConfig;
17138
- label?: string;
17139
- position?: TPosition;
17140
- tickCount?: number;
17141
- tickFormat?: (value: Date | number | string) => string;
17142
- }
17143
-
17144
- type XAxisConfig = AxisConfig;
17145
- type YAxisConfig = AxisConfig;
17146
-
17147
- interface GridConfig {
17148
- color?: string;
17149
- dash?: string;
17150
- }
17151
-
17152
- interface TooltipConfig {
17153
- offset?: number;
17154
- render?: (datum: Datum, series: Series) => Node | string;
17155
- }
18533
+ `Datum.dash` and `Datum.opacity` affect rendered marks only on the chart types that support them. On bars, `dash` adds a 1px series-color stroke so the dash is visible against the fill. `Series.id` becomes a `data-series-id` attribute on a series group; characters outside `[A-Za-z0-9_-]` are replaced with `-`. When omitted, the id is based on the series index.
17156
18534
 
17157
- interface CrosshairConfig {
17158
- horizontal?: boolean;
17159
- snap?: boolean;
17160
- vertical?: boolean;
17161
- }
17162
-
17163
- interface LegendConfig {
17164
- position?: LegendPosition;
17165
- }
17166
-
17167
- type LegendPosition = 'bottom' | 'left' | 'right' | 'top';
17168
-
17169
- interface TransitionConfig {
17170
- duration?: number; // ms per lane; default 300
17171
- easing?: EasingName | EasingFn;
17172
- preference?: 'always' | 'never' | 'system'; // 'system' (default) skips reduced-motion
17173
- stagger?: number; // ms per bar (bar) or series (radar); ignored elsewhere
17174
- }
17175
-
17176
- // Motion is on by default. `false` renders every update synchronously,
17177
- // `true` uses the chart defaults, or pass a config to tune them.
17178
- type TransitionOption = TransitionConfig | boolean;
17179
-
17180
- type EasingName = 'ease-in' | 'ease-in-out' | 'ease-out' | 'back-out' | 'expo-out' | 'linear';
17181
-
17182
- type EasingFn = (t: number) => number;
17183
- ```
18535
+ | Chart | `dash` | `opacity` |
18536
+ | --- | --- | --- |
18537
+ | Bar | Strokes that datum's bar | Applies to that bar |
18538
+ | Line | Applies to the segment from this datum to the next | Applies to that segment and its point marker |
18539
+ | Area | Applies to the top-line segment from this datum to the next | Applies to that segment |
18540
+ | Scatter | Ignored | Applies to that point |
18541
+ | Pie, radar, sparkline | Ignored | Ignored |
17184
18542
 
17185
- Tooltip strings are assigned through `textContent`. Return a `Node` for structured content.
18543
+ For line and area charts, the last datum has no following segment, so its `dash` value is ignored. The chart factories group adjacent segments with the same presentation values into path runs.
17186
18544
 
17187
- ---
18545
+ For line, area, and bar charts, `ChartEvent.values` lists each series at the active key in series order. Its `datum` is `undefined` when that series has no matching key. `originalEvent` is a pointer event or a keyboard event for keyboard activation. Scatter events identify only the nearest independent point and omit `values`.
17188
18546
 
17189
- ### Scale types
18547
+ ### Scale Types
17190
18548
 
17191
18549
  ```ts
17192
- interface Scale {
18550
+ export interface Scale {
17193
18551
  readonly domain: readonly [T, T];
17194
- readonly range: readonly [number, number];
17195
- map(value: T): number;
17196
18552
  invert(pixel: number): T;
18553
+ map(value: T): number;
18554
+ readonly range: readonly [number, number];
17197
18555
  ticks(count?: number): T[];
17198
18556
  }
17199
18557
 
17200
- interface BandScale {
18558
+ export interface BandScale {
18559
+ bandwidth(): number;
17201
18560
  readonly domain: readonly string[];
17202
- readonly range: readonly [number, number];
18561
+ gap(): number;
17203
18562
  map(value: string): number;
18563
+ readonly range: readonly [number, number];
17204
18564
  ticks(count?: number): string[];
17205
- bandwidth(): number;
17206
- gap(): number;
17207
18565
  }
17208
18566
 
17209
- interface LinearScaleConfig {
17210
- domain: [number, number];
17211
- range: [number, number];
18567
+ export interface LinearScaleConfig {
17212
18568
  clamp?: boolean;
18569
+ domain: [number, number];
17213
18570
  nice?: boolean;
18571
+ range: [number, number];
17214
18572
  }
17215
18573
 
17216
- interface TimeScaleConfig {
18574
+ export interface TimeScaleConfig {
17217
18575
  domain: [Date, Date];
17218
- range: [number, number];
17219
18576
  nice?: boolean;
18577
+ range: [number, number];
17220
18578
  }
17221
18579
 
17222
- interface BandScaleConfig {
18580
+ export interface BandScaleConfig {
17223
18581
  domain: string[];
17224
- range: [number, number];
17225
18582
  padding?: number;
17226
18583
  paddingOuter?: number;
18584
+ range: [number, number];
17227
18585
  }
17228
18586
  ```
17229
18587
 
17230
- ---
17231
-
17232
- ### Remaining unions and theme type
18588
+ ### Axis and Interaction Types
17233
18589
 
17234
18590
  ```ts
17235
- type BarVariant = 'grouped' | 'grouped-horizontal' | 'stacked' | 'stacked-horizontal';
17236
- type PieVariant = 'donut' | 'pie' | 'semi';
17237
- type SparklineVariant = 'area' | 'bar' | 'line' | 'stack';
18591
+ export type HorizontalAxisPosition = 'bottom' | 'top';
18592
+ export type VerticalAxisPosition = 'left' | 'right';
18593
+ export type AxisPosition = HorizontalAxisPosition | VerticalAxisPosition;
17238
18594
 
17239
- interface PrismTheme {
17240
- axisColor?: string; // --prism-axis-color
17241
- colors?: string[];
17242
- fontFamily?: string;
17243
- gridColor?: string;
17244
- gridOpacity?: number;
17245
- textColor?: string; // --prism-text-color
17246
- textColorSecondary?: string; // --prism-text-color-secondary
17247
- tooltipBg?: string; // --prism-tooltip-bg
17248
- tooltipBorder?: string; // --prism-tooltip-border
17249
- tooltipColor?: string; // --prism-tooltip-color
18595
+ export interface GridConfig {
18596
+ color?: string;
18597
+ dash?: string;
17250
18598
  }
17251
18599
 
17252
- interface ThemeScope {
17253
- readonly scope?: HTMLElement;
18600
+ export interface AxisConfig {
18601
+ grid?: GridConfig | boolean;
18602
+ label?: string;
18603
+ position?: TPosition;
18604
+ tickCount?: number;
18605
+ tickFormat?: (value: Date | number | string) => string;
18606
+ tickValues?: ReadonlyArray;
17254
18607
  }
17255
- ```
17256
-
17257
- ## Errors
17258
-
17259
- ### `PrismError`
17260
-
17261
- Base class for every Prism-originated error. It supports standard `ErrorOptions` cause chaining.
17262
-
17263
- ### `PrismRenderError`
17264
18608
 
17265
- Extends `PrismError`. Thrown for invalid containers, failed initial renders, and attempts to update disposed charts.
18609
+ export type XAxisConfig = AxisConfig;
18610
+ export type YAxisConfig = AxisConfig;
17266
18611
 
17267
- ### Usage Guide
17268
-
17269
- ## Basic Usage
17270
-
17271
- Every chart needs a container element with defined dimensions and the theme CSS:
17272
-
17273
- ```ts
17274
- import { createLineChart } from '@vielzeug/prism';
17275
- import '@vielzeug/prism/theme.css';
17276
-
17277
- const container = document.querySelector('#chart')!;
17278
- const chart = createLineChart(container, {
17279
- series: [
17280
- {
17281
- name: 'Revenue',
17282
- data: [
17283
- { key: 1, value: 10 },
17284
- { key: 2, value: 16 },
17285
- ],
17286
- },
17287
- ],
17288
- });
17289
-
17290
- chart.dispose();
17291
- ```
17292
-
17293
- ```html
17294
-
17295
- ```
17296
-
17297
- Prism 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.
17298
-
17299
- ## Updating Data
17300
-
17301
- Chart factories render synchronously and return a typed handle. Call `update()` with the same data shape used by the factory when application state changes.
17302
-
17303
- ```ts
17304
- import { createLineChart } from '@vielzeug/prism';
17305
-
17306
- const chart = createLineChart(container, {
17307
- series: [{ data: [{ key: 1, value: 10 }], name: 'Live' }],
17308
- });
17309
-
17310
- chart.update([
17311
- {
17312
- data: [
17313
- { key: 1, value: 10 },
17314
- { key: 2, value: 20 },
17315
- ],
17316
- name: 'Live',
17317
- },
17318
- ]);
17319
- ```
17320
-
17321
- `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.
17322
-
17323
- ## Line Charts
17324
-
17325
- ```ts
17326
- import { createLineChart } from '@vielzeug/prism';
17327
-
17328
- const chart = createLineChart(container, {
17329
- series: [
17330
- {
17331
- name: 'Revenue',
17332
- data: [
17333
- { key: 1, value: 100 },
17334
- { key: 2, value: 150 },
17335
- { key: 3, value: 130 },
17336
- ],
17337
- color: '#3b82f6',
17338
- curve: 'monotone', // 'linear' | 'monotone' | 'step'
17339
- strokeWidth: 2,
17340
- showPoints: true,
17341
- pointRadius: 4,
17342
- },
17343
- ],
17344
- xAxis: { position: 'bottom' },
17345
- yAxis: { position: 'left', grid: true },
17346
- tooltip: true,
17347
- crosshair: true,
17348
- });
17349
- ```
17350
-
17351
- ### Multiple Series
17352
-
17353
- ```ts
17354
- const chart = createLineChart(container, {
17355
- series: [
17356
- { name: 'Revenue', data: revenueData, color: '#3b82f6' },
17357
- { name: 'Expenses', data: expenseData, color: '#ef4444' },
17358
- ],
17359
- xAxis: { position: 'bottom' },
17360
- yAxis: { position: 'left', grid: true },
17361
- });
17362
- ```
17363
-
17364
- ### Independent Value Axes
17365
-
17366
- Assign 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.
17367
-
17368
- ```ts
17369
- const chart = createLineChart(container, {
17370
- series: [
17371
- { name: 'Health', data: [{ key: 0, value: 30 }, { key: 1, value: 20 }] },
17372
- { name: 'Damage', yAxis: 'right', data: [{ key: 0, value: 0 }, { key: 1, value: 300 }] },
17373
- ],
17374
- yAxis: { label: 'Health' },
17375
- rightYAxis: { label: 'Damage' },
17376
- tooltip: true,
17377
- });
17378
- ```
17379
-
17380
- The 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()`.
17381
-
17382
- ### Time-based X Axis
17383
-
17384
- When 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:
17385
-
17386
- ```ts
17387
- const chart = createLineChart(container, {
17388
- series: [
17389
- {
17390
- name: 'Signups',
17391
- data: [
17392
- { key: new Date('2024-01-01'), value: 50 },
17393
- { key: new Date('2024-02-01'), value: 80 },
17394
- { key: new Date('2024-03-01'), value: 120 },
17395
- ],
17396
- },
17397
- ],
17398
- xAxis: { position: 'bottom', tickFormat: (d) => (d as Date).toLocaleDateString() },
17399
- yAxis: { position: 'left' },
17400
- });
17401
- ```
18612
+ export interface TooltipConfig {
18613
+ offset?: number;
18614
+ render?: (datum: Datum, series: Series) => Node | string;
18615
+ titleFormat?: (key: Date | number | string) => string;
18616
+ valueFormat?: (value: number, datum: Datum, series: Series) => string;
18617
+ }
17402
18618
 
17403
- ## Bar Charts
18619
+ export interface CrosshairConfig {
18620
+ horizontal?: boolean;
18621
+ snap?: boolean;
18622
+ vertical?: boolean;
18623
+ }
17404
18624
 
17405
- ```ts
17406
- import { createBarChart } from '@vielzeug/prism';
18625
+ export type LegendPosition = 'bottom' | 'left' | 'right' | 'top';
17407
18626
 
17408
- const chart = createBarChart(container, {
17409
- series: [
17410
- {
17411
- name: 'Sales',
17412
- data: [
17413
- { key: 'Q1', value: 200 },
17414
- { key: 'Q2', value: 350 },
17415
- { key: 'Q3', value: 280 },
17416
- { key: 'Q4', value: 400 },
17417
- ],
17418
- borderRadius: 4,
17419
- },
17420
- ],
17421
- xAxis: { position: 'bottom' },
17422
- yAxis: { position: 'left', grid: true },
17423
- tooltip: true,
17424
- });
18627
+ export interface LegendConfig {
18628
+ position?: LegendPosition;
18629
+ }
17425
18630
  ```
17426
18631
 
17427
- ### Variants
18632
+ The crosshair defaults to a vertical line that snaps to the nearest datum; the horizontal line is hidden by default. With `snap: false`, only the guide follows the pointer—tooltip and callback data still refer to the nearest datum.
17428
18633
 
17429
- Select the bar layout with `variant`:
18634
+ `tickValues` renders exactly the given ticks instead of scale-generated ones, and gridlines follow the same list. Values must match the axis's scale kind (numbers on a linear axis, `Date`s on a time axis, category strings on a band axis); an off-scale value warns in development. Without it, ticks come from the scale as before.
17430
18635
 
17431
- | Value | Layout |
17432
- | ---------------------- | -------------------------- |
17433
- | `'grouped'` | Vertical grouped (default) |
17434
- | `'stacked'` | Vertical stacked |
17435
- | `'grouped-horizontal'` | Horizontal grouped |
17436
- | `'stacked-horizontal'` | Horizontal stacked |
18636
+ Numeric tick labels at a magnitude of 1e5 or above default to compact notation (`123K`, `1.23M`) so long numbers fit the default value-axis margin; pass `tickFormat: String` for raw labels. Supplying a partial `yAxis` (or the value axis of a horizontal bar chart) merges over the default, so `{ tickFormat }` alone keeps the default grid lines.
17437
18637
 
17438
- ```ts
17439
- const chart = createBarChart(container, {
17440
- variant: 'stacked',
17441
- series: [
17442
- { name: 'Mobile', data: mobileData, color: '#3b82f6', borderRadius: 0 },
17443
- { name: 'Desktop', data: desktopData, color: '#10b981', borderRadius: 0 },
17444
- ],
17445
- xAxis: { position: 'bottom' },
17446
- yAxis: { position: 'left', grid: true },
17447
- tooltip: true,
17448
- legend: true,
17449
- });
17450
- ```
18638
+ `titleFormat` and `valueFormat` shape the default comparison tooltip on line, area, and bar charts—the title from the focused key and each row's value—and the same formatting is applied to the tooltip's spoken summary. `render` still replaces the whole tooltip body and takes precedence. Tooltip strings are assigned as text, not parsed as HTML. For a radar chart, `tooltip.render` receives a datum whose `meta` contains the active axis, all series values, and the default announcement text.
17451
18639
 
17452
- For horizontal layouts, categories appear on the Y axis and values on the X axis:
18640
+ ### Chart Configuration Types
17453
18641
 
17454
18642
  ```ts
17455
- const chart = createBarChart(container, {
17456
- variant: 'grouped-horizontal',
17457
- series: [{ name: 'Revenue', data, color: '#3b82f6' }],
17458
- xAxis: { position: 'bottom', grid: true },
17459
- yAxis: { position: 'left' },
17460
- });
17461
- ```
17462
-
17463
- ### Grouped Bars
17464
-
17465
- Multiple series with `variant: 'grouped'` (default) render side-by-side:
17466
-
17467
- ```ts
17468
- const chart = createBarChart(container, {
17469
- series: [
17470
- { name: '2023', data: lastYearData, color: '#94a3b8' },
17471
- { name: '2024', data: thisYearData, color: '#3b82f6' },
17472
- ],
17473
- });
17474
- ```
17475
-
17476
- ## Area Charts
17477
-
17478
- Area fills use a vertical gradient that fades toward the baseline. Set `fill: 'solid'` for a flat fill:
18643
+ export interface BaseChartConfig {
18644
+ a11y?: ChartA11y;
18645
+ legend?: LegendConfig | boolean;
18646
+ margin?: Partial;
18647
+ onClick?: (event: ChartEvent) => void;
18648
+ onHover?: (event: ChartEvent | null) => void;
18649
+ tooltip?: TooltipConfig | boolean;
18650
+ transition?: TransitionOption;
18651
+ xAxis?: XAxisConfig | false;
18652
+ yAxis?: YAxisConfig | false;
18653
+ }
17479
18654
 
17480
- ```ts
17481
- import { createAreaChart } from '@vielzeug/prism';
18655
+ export interface LineSeriesConfig extends Series {
18656
+ curve?: 'linear' | 'monotone' | 'step';
18657
+ pointRadius?: number;
18658
+ showPoints?: boolean;
18659
+ strokeWidth?: number;
18660
+ yAxis?: 'left' | 'right';
18661
+ }
17482
18662
 
17483
- const chart = createAreaChart(container, {
17484
- series: [
17485
- {
17486
- name: 'Users',
17487
- data: userData,
17488
- curve: 'monotone',
17489
- fill: 'gradient', // 'gradient' (default) | 'solid'
17490
- showLine: true,
17491
- },
17492
- ],
17493
- xAxis: { position: 'bottom' },
17494
- yAxis: { position: 'left', grid: true },
17495
- crosshair: true,
17496
- });
17497
- ```
18663
+ export interface LineChartConfig extends BaseChartConfig {
18664
+ crosshair?: CrosshairConfig | boolean;
18665
+ rightYAxis?: Omit;
18666
+ series: LineSeriesConfig[];
18667
+ }
17498
18668
 
17499
- `fillOpacity` overrides the theme opacity for one series.
18669
+ export interface AreaSeriesConfig extends Series {
18670
+ curve?: 'linear' | 'monotone' | 'step';
18671
+ fill?: 'gradient' | 'solid';
18672
+ fillOpacity?: number;
18673
+ showLine?: boolean;
18674
+ }
17500
18675
 
17501
- ## Forecast / emphasis styling
18676
+ export interface AreaChartConfig extends BaseChartConfig {
18677
+ crosshair?: CrosshairConfig | boolean;
18678
+ series: AreaSeriesConfig[];
18679
+ }
17502
18680
 
17503
- A single series often mixes points that should look different — actuals plus a
17504
- forecast tail, or a run that crossed a budget. Rather than split the series,
17505
- carry `dash` and `opacity` on the individual datums. They are styling-only:
17506
- tooltips, hover, keyboard, and screen-reader behavior never change.
18681
+ export interface ScatterSeriesConfig extends Series {
18682
+ pointRadius?: number;
18683
+ }
17507
18684
 
17508
- For a forecast tail, map a parallel `types` array onto the datums:
18685
+ export interface ScatterChartConfig extends BaseChartConfig {
18686
+ crosshair?: CrosshairConfig | boolean;
18687
+ series: ScatterSeriesConfig[];
18688
+ }
17509
18689
 
17510
- ```ts
17511
- import { createLineChart } from '@vielzeug/prism';
18690
+ export interface BarSeriesConfig extends Series {
18691
+ borderRadius?: number;
18692
+ }
17512
18693
 
17513
- const types = ['REAL', 'REAL', 'FORECAST', 'FORECAST'];
18694
+ export type BarVariant = 'grouped' | 'grouped-horizontal' | 'stacked' | 'stacked-horizontal';
17514
18695
 
17515
- const chart = createLineChart(container, {
17516
- series: [
17517
- {
17518
- name: 'Cost',
17519
- data: values.map((value, i) => ({
17520
- key: months[i],
17521
- value,
17522
- ...(types[i] === 'FORECAST' ? { dash: '5 5', opacity: 0.4 } : {}),
17523
- })),
17524
- },
17525
- ],
17526
- });
17527
- ```
18696
+ export interface BarChartConfig extends BaseChartConfig {
18697
+ series: BarSeriesConfig[];
18698
+ variant?: BarVariant;
18699
+ }
17528
18700
 
17529
- On a line chart, a datum's `dash` styles the segment from that point to the
17530
- next, so the forecast run renders dashed while the actuals stay solid; its
17531
- `opacity` fades that segment and the point marker. The last datum's `dash` has
17532
- no segment to paint and is ignored. On a bar chart, both fields apply to that
17533
- datum's own bar — a `dash` adds a 1px series-color stroke so the dash reads on
17534
- the filled rect. On an area chart they style the top line only; the fill keeps
17535
- its series-level treatment.
18701
+ export type PieVariant = 'donut' | 'pie' | 'semi';
17536
18702
 
17537
- The same mapping fades bars:
18703
+ export interface PieSliceConfig {
18704
+ color?: string;
18705
+ label?: string;
18706
+ value: number;
18707
+ }
17538
18708
 
17539
- ```ts
17540
- import { createBarChart } from '@vielzeug/prism';
18709
+ export interface PieEvent {
18710
+ index: number;
18711
+ originalEvent: Event;
18712
+ slice: PieSliceConfig;
18713
+ }
17541
18714
 
17542
- const chart = createBarChart(container, {
17543
- series: [
17544
- {
17545
- name: 'Cost',
17546
- data: values.map((value, i) => ({
17547
- key: months[i],
17548
- value,
17549
- ...(types[i] === 'FORECAST' ? { dash: '5 5', opacity: 0.4 } : {}),
17550
- })),
17551
- },
17552
- ],
17553
- });
17554
- ```
18715
+ export interface PieChartConfig extends Omit {
18716
+ cornerRadius?: number;
18717
+ data: PieSliceConfig[];
18718
+ innerRadius?: number;
18719
+ onClick?: (event: PieEvent) => void;
18720
+ onHover?: (event: PieEvent | null) => void;
18721
+ padPixels?: number;
18722
+ variant?: PieVariant;
18723
+ }
17555
18724
 
17556
- Because the fields live on the data, a normal `update(newSeries)` re-applies
17557
- them; removing the fields on the next update clears the styling. A series with
17558
- no `dash`/`opacity` anywhere renders exactly as it did before these fields
17559
- existed.
18725
+ export interface RadarAxisConfig {
18726
+ format?: (value: number) => string;
18727
+ key: string;
18728
+ label: string;
18729
+ max?: number;
18730
+ min?: number;
18731
+ }
17560
18732
 
17561
- ## Pie, Donut, and Semi-circle Charts
18733
+ export type RadarSeriesConfig = Series>;
17562
18734
 
17563
- All three variants use `createPieChart` with the `variant` field:
18735
+ export interface RadarGridConfig {
18736
+ bands?: boolean;
18737
+ labels?: boolean;
18738
+ levels?: number;
18739
+ shape?: 'circle' | 'polygon';
18740
+ }
17564
18741
 
17565
- ```ts
17566
- import { createPieChart } from '@vielzeug/prism';
18742
+ export interface RadarAxisValue {
18743
+ datum: Datum | undefined;
18744
+ series: RadarSeriesConfig;
18745
+ }
17567
18746
 
17568
- const chart = createPieChart(container, {
17569
- data: [
17570
- { label: 'Direct', value: 42, color: '#3b82f6' },
17571
- { label: 'Organic', value: 28, color: '#10b981' },
17572
- { label: 'Referral', value: 18, color: '#f59e0b' },
17573
- { label: 'Social', value: 12, color: '#8b5cf6' },
17574
- ],
17575
- variant: 'donut', // 'pie' | 'donut' | 'semi'
17576
- tooltip: true,
17577
- transition: { duration: 400, easing: 'ease-out' },
17578
- });
17579
- ```
18747
+ export interface RadarEvent {
18748
+ axis: RadarAxisConfig;
18749
+ index: number;
18750
+ originalEvent: Event;
18751
+ values: RadarAxisValue[];
18752
+ }
17580
18753
 
17581
- ### Variants
18754
+ export interface RadarChartConfig extends Omit {
18755
+ axes: RadarAxisConfig[];
18756
+ curve?: 'linear' | 'rounded';
18757
+ domain?: [number, number];
18758
+ fill?: 'gradient' | 'none' | 'solid';
18759
+ grid?: RadarGridConfig | boolean;
18760
+ onClick?: (event: RadarEvent) => void;
18761
+ onHover?: (event: RadarEvent | null) => void;
18762
+ series: RadarSeriesConfig[];
18763
+ showPoints?: boolean;
18764
+ showValues?: boolean;
18765
+ startAngle?: number;
18766
+ }
17582
18767
 
17583
- | Value | Shape |
17584
- | --------- | ------------------------------------------------------- |
17585
- | `'pie'` | Full circle, no hole |
17586
- | `'donut'` | Full circle with inner hole (~55% of outer by default) |
17587
- | `'semi'` | Top-half semicircle with inner hole: useful for gauges |
18768
+ export type SparklineVariant = 'area' | 'bar' | 'line' | 'stack';
17588
18769
 
17589
- ### Inner Radius
18770
+ export interface StackSegment {
18771
+ color?: string;
18772
+ label?: string;
18773
+ value: number;
18774
+ }
17590
18775
 
17591
- `innerRadius` overrides the automatic calculation:
18776
+ export interface SparklineEvent {
18777
+ index: number;
18778
+ originalEvent: Event;
18779
+ value: number;
18780
+ }
17592
18781
 
17593
- ```ts
17594
- createPieChart(container, {
17595
- data,
17596
- variant: 'donut',
17597
- innerRadius: 60, // explicit pixels
17598
- });
18782
+ export interface SparklineConfig {
18783
+ a11y?: ChartA11y;
18784
+ color?: string;
18785
+ cornerRadius?: number;
18786
+ curve?: 'linear' | 'monotone' | 'step';
18787
+ data: number[] | StackSegment[];
18788
+ fillOpacity?: number;
18789
+ onClick?: (event: SparklineEvent) => void;
18790
+ onHover?: (event: SparklineEvent | null) => void;
18791
+ padPixels?: number;
18792
+ showEndPoint?: boolean;
18793
+ strokeWidth?: number;
18794
+ transition?: TransitionOption;
18795
+ variant?: SparklineVariant;
18796
+ }
17599
18797
  ```
17600
18798
 
17601
- ### Slice Labels
18799
+ ## Errors
17602
18800
 
17603
- Set `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:
18801
+ ### `PrismError`
17604
18802
 
17605
18803
  ```ts
17606
- { value: 42, label: 'Direct' }
17607
- ```
17608
-
17609
- Style labels via CSS:
17610
-
17611
- ```css
17612
- :root {
17613
- --prism-pie-label-color: #fff;
17614
- --prism-pie-label-size: 11px;
18804
+ class PrismError extends Error {
18805
+ constructor(message: string, opts?: ErrorOptions);
17615
18806
  }
17616
18807
  ```
17617
18808
 
17618
- ### Updating Data
17619
-
17620
- ```ts
17621
- const chart = createPieChart(container, {
17622
- data: [
17623
- { label: 'A', value: 40 },
17624
- { label: 'B', value: 60 },
17625
- ],
17626
- variant: 'donut',
17627
- });
17628
-
17629
- chart.update([
17630
- { label: 'A', value: 55 },
17631
- { label: 'B', value: 45 },
17632
- ]);
17633
- ```
18809
+ Base class for Prism-originated errors. Its `name` is set to the concrete error class name, and it supports the standard `ErrorOptions` including `cause`.
17634
18810
 
17635
- ### Event Hooks
18811
+ ### `PrismRenderError`
17636
18812
 
17637
18813
  ```ts
17638
- createPieChart(container, {
17639
- data,
17640
- onHover: (event) => {
17641
- // event is PieEvent | null (null on mouseleave)
17642
- if (event) console.log(event.index, event.slice.label, event.slice.value);
17643
- },
17644
- onClick: (event) => {
17645
- console.log('clicked', event.index, event.slice.label);
17646
- },
17647
- });
18814
+ class PrismRenderError extends PrismError {}
17648
18815
  ```
17649
18816
 
17650
- `PieEvent` provides `index`, the hovered/clicked `slice` (`PieSliceConfig`), and the `originalEvent` (`MouseEvent` or `KeyboardEvent`).
18817
+ Thrown when a chart cannot be rendered because its container is not a DOM element, when initial rendering fails (the original error is available as `cause`), or when `update()` is called after disposal. Empty or malformed chart data may instead produce a development warning or an empty rendering, depending on the chart.
17651
18818
 
17652
- ## Radar Charts
18819
+ ### Usage Guide
17653
18820
 
17654
- `createRadarChart` compares several values on one shape, such as a hero's stats. Each series supplies values keyed by `axis.key`:
18821
+ ## Basic Usage
18822
+
18823
+ Create a host element with non-zero dimensions, then pass it to a chart factory. This example creates the host in JavaScript so the snippet can run as written.
17655
18824
 
17656
18825
  ```ts
17657
- import { createRadarChart } from '@vielzeug/prism';
18826
+ import { createLineChart } from '@vielzeug/prism';
18827
+ import '@vielzeug/prism/theme.css';
17658
18828
 
17659
- const axes = [
17660
- { key: 'str', label: 'Strength' },
17661
- { key: 'agi', label: 'Agility' },
17662
- { key: 'int', label: 'Intellect' },
17663
- { key: 'end', label: 'Endurance' },
17664
- { key: 'spd', label: 'Speed', max: 5 },
17665
- ];
18829
+ const container = document.createElement('div');
18830
+ container.style.cssText = 'width:640px;height:320px';
18831
+ document.body.append(container);
17666
18832
 
17667
- const chart = createRadarChart(container, {
17668
- a11y: { ariaLabel: 'Hero stats' },
17669
- axes,
17670
- domain: [0, 10],
17671
- fill: 'gradient',
17672
- showValues: true,
18833
+ const chart = createLineChart(container, {
18834
+ a11y: { ariaLabel: 'Revenue by month' },
17673
18835
  series: [
17674
18836
  {
17675
- name: 'Adam',
18837
+ name: 'Revenue',
17676
18838
  data: [
17677
- { key: 'str', value: 8 },
17678
- { key: 'agi', value: 6 },
17679
- { key: 'int', value: 4 },
17680
- { key: 'end', value: 9 },
17681
- { key: 'spd', value: 3 },
18839
+ { key: 1, value: 10 },
18840
+ { key: 2, value: 16 },
17682
18841
  ],
17683
18842
  },
17684
18843
  ],
18844
+ tooltip: true,
17685
18845
  });
18846
+
18847
+ chart.dispose();
17686
18848
  ```
17687
18849
 
17688
- ### Scales
18850
+ Each factory appends an SVG to its container and returns a `ChartHandle`. Prism observes the container with `ResizeObserver`; if it has zero dimensions on creation, Prism uses a temporary `600 × 300` size and warns in development. Give the host its layout before creating the chart.
17689
18851
 
17690
- Every 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.
18852
+ ## Updating Data
17691
18853
 
17692
- ### Appearance
18854
+ Keep application state outside Prism and pass the next complete data value to `update()`. The update method replaces chart data synchronously; the default transition may animate the resulting SVG.
17693
18855
 
17694
- | Option | Values | Use it for |
17695
- | --- | --- | --- |
17696
- | `fill` | `'solid'`, `'gradient'`, `'none'` | `gradient` for a single hero; `solid` or `none` when overlaying several |
17697
- | `curve` | `'linear'`, `'rounded'` | Rounded reads softer; linear keeps exact vertices |
17698
- | `grid.shape` | `'polygon'`, `'circle'` | Polygon matches the axes; circle suits many axes |
17699
- | `grid.levels` / `grid.bands` | number / boolean | Ring count and alternate shading |
17700
- | `grid.labels` | boolean | Level values; drawn only when every axis shares one domain |
17701
- | `showValues` | boolean | Value beside each vertex, formatted by `axis.format` |
18856
+ ```ts
18857
+ import { createLineChart } from '@vielzeug/prism';
17702
18858
 
17703
- ### Comparing Series
18859
+ const container = document.createElement('div');
18860
+ container.style.cssText = 'width:640px;height:320px';
18861
+ document.body.append(container);
17704
18862
 
17705
- Pass several series with `legend: true`. Hovering one shape dims the others, and hovering an axis reports every series' value on it:
18863
+ let series = [{ data: [{ key: 1, value: 10 }], name: 'Live' }];
18864
+ const chart = createLineChart(container, { series });
17706
18865
 
17707
- ```ts
17708
- createRadarChart(container, {
17709
- axes,
17710
- legend: true,
17711
- series: [adam, eve],
17712
- tooltip: true, // "Agility: Adam 6, Eve 9"
17713
- onHover: (event) => event && console.log(event.axis.label, event.values),
17714
- });
18866
+ series = [{ data: [{ key: 1, value: 10 }, { key: 2, value: 20 }], name: 'Live' }];
18867
+ chart.update(series);
18868
+ chart.dispose();
17715
18869
  ```
17716
18870
 
17717
- ## Sparklines
18871
+ `update()` accepts the type shown by that factory's handle: a complete series array for line, area, scatter, bar, and radar charts; a slice array for pie charts; and a number or stack-segment array for sparklines. Chart options such as axes, variant, callbacks, and accessibility are set at creation. For example, radar axes and a pie chart's variant do not change through `update()`.
18872
+
18873
+ ## Line Charts
17718
18874
 
17719
- Sparklines are minimal inline charts with no axes, no legend, and no margin: designed to live inline with text or inside table cells.
18875
+ Line and area series use number or `Date` keys. All series in one chart must use the same key kind. Use `curve` to choose interpolation, and enable markers with `showPoints`.
17720
18876
 
17721
18877
  ```ts
17722
- import { createSparkline } from '@vielzeug/prism';
18878
+ import { createLineChart } from '@vielzeug/prism';
17723
18879
 
17724
- const spark = createSparkline(container, {
17725
- data: [12, 18, 14, 22, 19, 28],
17726
- variant: 'line', // 'line' | 'area' | 'bar' | 'stack' (default: 'line')
17727
- color: '#3b82f6',
17728
- curve: 'monotone',
17729
- strokeWidth: 1.5,
18880
+ const container = document.createElement('div');
18881
+ container.style.cssText = 'width:640px;height:320px';
18882
+ document.body.append(container);
18883
+
18884
+ const chart = createLineChart(container, {
18885
+ a11y: { ariaLabel: 'Revenue and expenses' },
18886
+ series: [
18887
+ {
18888
+ curve: 'monotone',
18889
+ data: [{ key: 1, value: 100 }, { key: 2, value: 150 }, { key: 3, value: 130 }],
18890
+ name: 'Revenue',
18891
+ showPoints: true,
18892
+ },
18893
+ {
18894
+ color: '#c2410c',
18895
+ data: [{ key: 1, value: 70 }, { key: 2, value: 95 }, { key: 3, value: 110 }],
18896
+ name: 'Expenses',
18897
+ },
18898
+ ],
18899
+ tooltip: true,
18900
+ xAxis: { label: 'Month' },
18901
+ yAxis: { grid: true, label: 'Dollars' },
17730
18902
  });
17731
18903
 
17732
- spark.dispose();
18904
+ chart.dispose();
17733
18905
  ```
17734
18906
 
17735
- ### Variants
18907
+ ### Independent Value Axes
17736
18908
 
17737
- - **`line`**: simple polyline path (default)
17738
- - **`area`**: gradient-filled area + line overlay
17739
- - **`bar`**: vertical bar for each data point
17740
- - **`stack`**: horizontal proportional segments; use `StackSegment[]` for `data` with per-segment colors
18909
+ Assign a line series to the right axis when it uses a different value range. Both axes share the x-scale; `rightYAxis` configures the second axis and cannot configure its position or grid.
17741
18910
 
17742
- Line and area sparklines mark the latest value with a dot. Set `showEndPoint: false` to hide it.
18911
+ ```ts
18912
+ import { createLineChart } from '@vielzeug/prism';
17743
18913
 
17744
- ### Updating Data
18914
+ const container = document.createElement('div');
18915
+ container.style.cssText = 'width:640px;height:320px';
18916
+ document.body.append(container);
17745
18917
 
17746
- ```ts
17747
- const spark = createSparkline(container, {
17748
- data: [12, 18, 14, 22],
17749
- variant: 'area',
18918
+ const chart = createLineChart(container, {
18919
+ a11y: { ariaLabel: 'Health and damage by level' },
18920
+ rightYAxis: { label: 'Damage' },
18921
+ series: [
18922
+ { data: [{ key: 1, value: 30 }, { key: 2, value: 20 }], name: 'Health' },
18923
+ { data: [{ key: 1, value: 100 }, { key: 2, value: 300 }], name: 'Damage', yAxis: 'right' },
18924
+ ],
18925
+ yAxis: { grid: true, label: 'Health' },
17750
18926
  });
17751
18927
 
17752
- spark.update([12, 18, 14, 22, 30]);
18928
+ chart.dispose();
17753
18929
  ```
17754
18930
 
17755
- ### Event Hooks
18931
+ When any series uses the right axis, the primary value axis is placed on the left. Grid lines belong to the primary `yAxis`. The independent numeric scales mean that curve crossings do not imply equal values.
18932
+
18933
+ ### Time-Based X Axis
17756
18934
 
17757
- Sparklines use simplified hooks: `SparklineEvent` rather than full `ChartEvent`. With a hook set, a marker follows the hovered value:
18935
+ Use `Date` values for every key when plotting time. Line and area charts use a time scale whose domain fits the data instead of rounding outward to tick boundaries.
17758
18936
 
17759
18937
  ```ts
17760
- const spark = createSparkline(container, {
17761
- data: [10, 20, 30],
17762
- onHover: (event) => {
17763
- // event is SparklineEvent | null (null on mouseleave)
17764
- if (event) console.log(`Hovering point ${event.index}: ${event.value}`);
17765
- },
17766
- onClick: (event) => {
17767
- console.log(`Clicked point ${event.index}: ${event.value}`);
17768
- },
17769
- });
17770
- ```
18938
+ import { createLineChart } from '@vielzeug/prism';
17771
18939
 
17772
- `SparklineEvent` provides `index`, the hovered/clicked `value`, and the `originalEvent`.
18940
+ const container = document.createElement('div');
18941
+ container.style.cssText = 'width:640px;height:320px';
18942
+ document.body.append(container);
17773
18943
 
17774
- > **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.
18944
+ const formatter = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric' });
18945
+ const chart = createLineChart(container, {
18946
+ a11y: { ariaLabel: 'Signups over time' },
18947
+ series: [{
18948
+ data: [
18949
+ { key: new Date('2026-09-01'), value: 50 },
18950
+ { key: new Date('2026-09-08'), value: 80 },
18951
+ { key: new Date('2026-09-15'), value: 120 },
18952
+ ],
18953
+ name: 'Signups',
18954
+ }],
18955
+ xAxis: { tickFormat: (value) => value instanceof Date ? formatter.format(value) : String(value) },
18956
+ });
17775
18957
 
17776
- ## Axes and Grid
18958
+ chart.dispose();
18959
+ ```
17777
18960
 
17778
- Both axes render by default: the value axis (y on vertical charts, x on horizontal ones) carries gridlines, the category axis does not. Set `xAxis: false` or `yAxis: false` to suppress one. An explicit config is used verbatim:
18961
+ `tickFormat` formats axis labels only. Tooltip content and accessibility announcements use the underlying keys and values. To render an exact set of ticks instead of scale-generated ones, pass `tickValues`; gridlines follow the same list. Values must match the axis's scale kind, and an off-scale value warns in development:
17779
18962
 
17780
18963
  ```ts
17781
- {
17782
- xAxis: {
17783
- position: 'bottom', // 'top' | 'bottom'
17784
- tickCount: 5,
17785
- tickFormat: (v) => `$${v}`,
17786
- label: 'Month',
17787
- grid: true, // or { color: '#ddd', dash: '4 2' }
17788
- },
17789
- yAxis: {
17790
- position: 'left', // 'left' | 'right'
17791
- grid: { color: '#f0f0f0' },
17792
- label: 'Revenue ($)',
17793
- },
17794
- }
18964
+ // Force a tick on the last month, which nice-stepped ticks can skip.
18965
+ const chart = createLineChart(container, {
18966
+ series: [{ data: [{ key: 0, value: 90 }, { key: 11, value: 128 }], name: 'Costs' }],
18967
+ xAxis: { tickValues: [0, 6, 11] },
18968
+ });
17795
18969
  ```
17796
18970
 
17797
- Category axes show every label when the labels fit and thin them out only when they would overlap. Set `tickCount` to choose the density yourself.
18971
+ Numeric tick labels at a magnitude of `1e5` or above default to compact notation (`123K`, `1.23M`) so long numbers fit the default value-axis margin; pass `tickFormat: String` for raw labels. Supplying a partial value-axis config merges over its default, so `yAxis: { tickFormat }` alone keeps the default grid lines.
17798
18972
 
17799
- ## Tooltips
18973
+ ## Bar Charts
17800
18974
 
17801
- Enable 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:
18975
+ Bar chart keys are string categories. The default `grouped` variant puts series side by side; the horizontal variants put categories on the y-axis and values on the x-axis.
17802
18976
 
17803
18977
  ```ts
17804
- {
17805
- tooltip: {
17806
- offset: 12,
17807
- render: (datum, series) => {
17808
- const content = document.createElement('strong');
17809
- content.textContent = `${series.name}: ${datum.value.toLocaleString()}`;
17810
- return content;
17811
- },
17812
- },
17813
- }
18978
+ import { createBarChart } from '@vielzeug/prism';
18979
+
18980
+ const container = document.createElement('div');
18981
+ container.style.cssText = 'width:640px;height:320px';
18982
+ document.body.append(container);
18983
+
18984
+ const chart = createBarChart(container, {
18985
+ a11y: { ariaLabel: 'Sales by quarter' },
18986
+ series: [{
18987
+ data: [{ key: 'Q1', value: 200 }, { key: 'Q2', value: 350 }, { key: 'Q3', value: 280 }],
18988
+ name: 'Sales',
18989
+ }],
18990
+ tooltip: true,
18991
+ });
18992
+
18993
+ chart.dispose();
17814
18994
  ```
17815
18995
 
17816
- Prism never injects tooltip strings as HTML, so custom rendering does not require a sanitizer.
18996
+ Choose `grouped`, `stacked`, `grouped-horizontal`, or `stacked-horizontal` in `variant`. Negative values in a stacked chart are clamped to zero and produce a development warning. Bars in stacked variants remain square; `borderRadius` applies to grouped bars.
17817
18997
 
17818
- The tooltip element is scoped inside the chart container (not `document.body`) and is removed automatically on `dispose()`.
18998
+ Axes render by default, with grid lines on the value axis. For horizontal variants, `xAxis` is the value axis and `yAxis` is the category axis. Configure `grid` on the value axis.
17819
18999
 
17820
- ## Crosshair
19000
+ ## Area Charts
17821
19001
 
17822
- A vertical guide that snaps to the nearest data point:
19002
+ Area series use a gradient fill by default. Set `fill: 'solid'` for a flat fill, or set `fillOpacity` to override the theme opacity for that series.
17823
19003
 
17824
19004
  ```ts
17825
- {
19005
+ import { createAreaChart } from '@vielzeug/prism';
19006
+
19007
+ const container = document.createElement('div');
19008
+ container.style.cssText = 'width:640px;height:320px';
19009
+ document.body.append(container);
19010
+
19011
+ const chart = createAreaChart(container, {
19012
+ a11y: { ariaLabel: 'Visitors over time' },
17826
19013
  crosshair: true,
17827
- // or configure:
17828
- crosshair: { vertical: true, horizontal: true, snap: true },
17829
- }
19014
+ series: [{
19015
+ curve: 'monotone',
19016
+ data: [{ key: 1, value: 12 }, { key: 2, value: 18 }, { key: 3, value: 15 }],
19017
+ fill: 'solid',
19018
+ fillOpacity: 0.2,
19019
+ name: 'Visitors',
19020
+ showLine: true,
19021
+ }],
19022
+ });
19023
+
19024
+ chart.dispose();
17830
19025
  ```
17831
19026
 
17832
- ## Legend
19027
+ ## Scatter Charts
17833
19028
 
17834
- Enable with `legend: true` (defaults to `bottom`) or configure position:
19029
+ Scatter charts treat every datum as an independent point: `key` is the numeric or `Date` x-value and `value` is the y-value. The scales fit the observed values rather than forcing the y-domain to include zero. Set `pointRadius` on a series to change its marker size (default `3`).
17835
19030
 
17836
19031
  ```ts
17837
- {
17838
- legend: true,
17839
- // or:
17840
- legend: { position: 'top' }, // 'top' | 'bottom' | 'left' | 'right'
17841
- }
17842
- ```
19032
+ import { createScatterChart } from '@vielzeug/prism';
17843
19033
 
17844
- The legend renders as a `div` placed outside the SVG. Each item shows a color swatch and the series `name`. Customize via CSS:
19034
+ const container = document.createElement('div');
19035
+ container.style.cssText = 'width:640px;height:320px';
19036
+ document.body.append(container);
17845
19037
 
17846
- ```css
17847
- :root {
17848
- --prism-legend-gap: 1rem;
17849
- --prism-legend-dot-size: 0.5rem;
17850
- --prism-legend-font-size: 0.75rem;
17851
- }
19038
+ const chart = createScatterChart(container, {
19039
+ a11y: { ariaLabel: 'Height against weight' },
19040
+ series: [{
19041
+ data: [{ key: 160, value: 55 }, { key: 175, value: 70 }, { key: 190, value: 88 }],
19042
+ name: 'Cohort A',
19043
+ pointRadius: 5,
19044
+ }],
19045
+ tooltip: true,
19046
+ });
19047
+
19048
+ chart.dispose();
17852
19049
  ```
17853
19050
 
17854
- ## Event Hooks
19051
+ Scatter callbacks identify the nearest point in two dimensions and do not provide cross-series `values`, because points do not share a category key. Keyboard navigation visits every point across series in x order. With a tooltip, `titleFormat` formats the x-value and `valueFormat` the y-value, in both the visible row and the spoken summary.
19052
+
19053
+ ## Forecast and Emphasis Styling
17855
19054
 
17856
- All charts expose `onClick` and `onHover` callbacks on the config:
19055
+ Add `dash` or `opacity` to individual `Datum` objects when one part of a series needs distinct styling. These fields do not change interaction, tooltip, or accessibility behavior.
17857
19056
 
17858
19057
  ```ts
19058
+ import { createLineChart } from '@vielzeug/prism';
19059
+
19060
+ const container = document.createElement('div');
19061
+ container.style.cssText = 'width:640px;height:320px';
19062
+ document.body.append(container);
19063
+
19064
+ const values = [10, 13, 15, 18];
17859
19065
  const chart = createLineChart(container, {
17860
- series: [{ name: 'Revenue', data }],
17861
- onHover: (event) => {
17862
- // event is ChartEvent | null (null on mouseleave)
17863
- if (event) console.log(event.datum, event.series);
17864
- },
17865
- onClick: (event) => {
17866
- console.log('clicked', event.datum);
17867
- },
19066
+ series: [{
19067
+ data: values.map((value, index) => ({
19068
+ key: index + 1,
19069
+ value,
19070
+ ...(index >= 2 ? { dash: '5 5', opacity: 0.4 } : {}),
19071
+ })),
19072
+ name: 'Forecast',
19073
+ showPoints: true,
19074
+ }],
17868
19075
  });
17869
- ```
17870
19076
 
17871
- `ChartEvent` provides:
19077
+ chart.dispose();
19078
+ ```
17872
19079
 
17873
- - `datum`: the datum of the series nearest the pointer
17874
- - `series`: the corresponding `Series` config
17875
- - `values`: every series at the same key (line, area, and bar charts)
17876
- - `originalEvent`: the raw `MouseEvent` or, for keyboard navigation, `KeyboardEvent`
19080
+ For line and area charts, each datum's `dash` styles the segment from that datum to the next; the final datum has no outgoing segment. Line opacity also fades the datum's marker. Area styling affects the top line, not the fill. Bar opacity and dashes apply to the individual bar; scatter opacity applies to its point and scatter dashes are ignored. Pie, radar, and sparkline ignore these two fields.
17877
19081
 
17878
- > **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).
19082
+ ## Pie, Donut, and Semi Charts
17879
19083
 
17880
- ## Animations
19084
+ Use `createPieChart()` for all three shapes. `variant` is fixed when the chart is created; `update()` replaces the slice data.
17881
19085
 
17882
- Every chart animates by default: bars grow from the baseline, line and area
17883
- series rise out of the plot, pie slices sweep in and then morph between values,
17884
- radar polygons reshape, and sparklines tween. Motion runs through one
17885
- `requestAnimationFrame` loop and is skipped when the user prefers reduced motion
17886
- (`preference: 'system'`, the default).
19086
+ ```ts
19087
+ import { createPieChart } from '@vielzeug/prism';
17887
19088
 
17888
- Tune it with `transition`, or turn it off entirely:
19089
+ const container = document.createElement('div');
19090
+ container.style.cssText = 'width:320px;height:240px';
19091
+ document.body.append(container);
17889
19092
 
17890
- ```ts
17891
- {
17892
- transition: {
17893
- duration: 400,
17894
- easing: 'ease-out',
17895
- stagger: 30, // ms delay between bars (bar charts) or series (radar charts)
17896
- },
17897
- }
17898
- ```
19093
+ const chart = createPieChart(container, {
19094
+ a11y: { ariaLabel: 'Orders by channel' },
19095
+ data: [
19096
+ { label: 'Direct', value: 42 },
19097
+ { label: 'Organic', value: 28 },
19098
+ { label: 'Referral', value: 18 },
19099
+ { label: 'Social', value: 12 },
19100
+ ],
19101
+ legend: true,
19102
+ tooltip: true,
19103
+ variant: 'donut',
19104
+ });
17899
19105
 
17900
- ```ts
17901
- { transition: false } // every update renders synchronously
17902
- { transition: true } // the defaults, stated explicitly
19106
+ chart.dispose();
17903
19107
  ```
17904
19108
 
17905
- Named easings are `'linear'`, `'ease-in'`, `'ease-out'`, `'ease-in-out'`,
17906
- `'expo-out'` (a hard decelerate), and `'back-out'` (a small overshoot); pass a
17907
- `(t: number) => number` for anything else. `stagger` applies to bar-chart bars
17908
- and radar-chart series and is ignored elsewhere — bar charts cap the total
17909
- staggered delay near 400 ms so a long category axis still finishes promptly.
19109
+ The `pie` default has no inner radius. `donut` and `semi` default to an inner radius of 55% of the outer radius. `innerRadius` overrides that size in pixels. Slice labels are omitted when they do not fit; keep a tooltip or legend when small slices need identifying.
17910
19110
 
17911
- ## Theming
19111
+ ## Radar Charts
17912
19112
 
17913
- Import the default theme:
19113
+ Define at least three axes when creating a radar chart. Series data uses string keys that match `RadarAxisConfig.key`; `update()` replaces series but keeps axes and other configuration fixed.
17914
19114
 
17915
19115
  ```ts
17916
- import '@vielzeug/prism/theme.css';
17917
- ```
17918
-
17919
- ### Programmatic Theme with `setTheme`
17920
-
17921
- Call `setTheme` once at app startup to apply custom tokens programmatically:
19116
+ import { createRadarChart } from '@vielzeug/prism';
17922
19117
 
17923
- ```ts
17924
- import { setTheme } from '@vielzeug/prism';
19118
+ const container = document.createElement('div');
19119
+ container.style.cssText = 'width:400px;height:320px';
19120
+ document.body.append(container);
17925
19121
 
17926
- setTheme({
17927
- axisColor: '#94a3b8', // sets --prism-axis-color
17928
- colors: ['#6366f1', '#22d3ee', '#f59e0b', '#10b981'], // replaces --prism-color-1 through -4
17929
- fontFamily: 'Inter, system-ui, sans-serif', // sets --prism-font-family
17930
- gridColor: '#e2e8f0', // sets --prism-grid-color
17931
- gridOpacity: 0.6, // sets --prism-grid-opacity
17932
- textColor: '#0f172a', // sets --prism-text-color
17933
- tooltipBg: '#0f172a', // sets --prism-tooltip-bg
19122
+ const axes = [
19123
+ { key: 'strength', label: 'Strength' },
19124
+ { key: 'agility', label: 'Agility' },
19125
+ { key: 'endurance', label: 'Endurance' },
19126
+ ];
19127
+ const chart = createRadarChart(container, {
19128
+ a11y: { ariaLabel: 'Hero stats' },
19129
+ axes,
19130
+ domain: [0, 10],
19131
+ series: [{
19132
+ data: [{ key: 'strength', value: 8 }, { key: 'agility', value: 6 }, { key: 'endurance', value: 9 }],
19133
+ name: 'Adam',
19134
+ }],
19135
+ tooltip: true,
17934
19136
  });
17935
- ```
17936
-
17937
- `setTheme` writes inline custom properties, so it takes precedence over stylesheet 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:
17938
19137
 
17939
- ```ts
17940
- import { resetTheme } from '@vielzeug/prism';
17941
-
17942
- resetTheme();
19138
+ chart.dispose();
17943
19139
  ```
17944
19140
 
17945
- ### Theming One Subtree (component libraries)
17946
-
17947
- Pass `scope` to write the tokens onto one element instead of `document.documentElement`. Charts render inside it, so their `var(--prism-*)` lookups resolve against the scoped values without touching `:root` — the hook for apps whose theme lives in a JS palette object (MUI, Chakra, Backstage):
19141
+ The default shared domain starts at zero and uses a nice maximum; set `domain` to compare series on a fixed range. Per-axis `min` and `max` override the chart domain for that axis. Grid rings, bands, curves, fills, vertex dots, and values are configurable; see `RadarChartConfig` and `RadarGridConfig` in the API reference.
17948
19142
 
17949
- ```tsx
17950
- import { useEffect, useRef } from 'react';
17951
- import { useTheme } from '@mui/material/styles';
17952
- import { createBarChart, resetTheme, setTheme, type BarSeriesConfig } from '@vielzeug/prism';
17953
- import '@vielzeug/prism/theme.css';
19143
+ ## Sparklines
17954
19144
 
17955
- function CostsChart({ series }: { series: BarSeriesConfig[] }) {
17956
- const theme = useTheme();
17957
- const containerRef = useRef(null);
19145
+ Sparklines omit axes, margins, and legends. Choose `line`, `area`, or `bar` for numeric arrays, and `stack` for an array of `StackSegment` objects.
17958
19146
 
17959
- useEffect(() => {
17960
- const scope = containerRef.current;
19147
+ ```ts
19148
+ import { createSparkline } from '@vielzeug/prism';
17961
19149
 
17962
- if (!scope) return;
19150
+ const container = document.createElement('div');
19151
+ container.style.cssText = 'width:240px;height:48px';
19152
+ document.body.append(container);
17963
19153
 
17964
- // Scoped tokens win over :root inside this subtree and never leak out of it.
17965
- setTheme(
17966
- { axisColor: theme.palette.divider, textColor: theme.palette.text.primary },
17967
- { scope },
17968
- );
19154
+ const chart = createSparkline(container, {
19155
+ a11y: { ariaLabel: 'Weekly orders' },
19156
+ data: [10, 14, 12, 18, 22],
19157
+ variant: 'area',
19158
+ });
17969
19159
 
17970
- const chart = createBarChart(scope, { series });
19160
+ chart.update([10, 14, 12, 18, 22, 25]);
19161
+ chart.dispose();
19162
+ ```
17971
19163
 
17972
- return () => {
17973
- chart.dispose();
17974
- resetTheme({ scope });
17975
- };
17976
- }, [series, theme]);
19164
+ For a stacked sparkline, each segment can set `value`, `color`, and `label`. Sparklines do not offer keyboard navigation, a tooltip, or a live value announcer. Add a visible value or summary when the trend conveys important information.
17977
19165
 
17978
- return ;
17979
- }
17980
- ```
19166
+ ## Interaction
17981
19167
 
17982
- A scoped call and a global call are independent: `resetTheme({ scope })` clears only what `setTheme({ scope })` wrote.
19168
+ Set `tooltip: true` to show Prism's default tooltip, or pass a `TooltipConfig` to format its content. `titleFormat` and `valueFormat` shape the default comparison tooltip on line, area, and bar charts—the title from the focused key and each row's value—without rebuilding its markup, and the same formatting is applied to the tooltip's spoken summary. A `render` callback receives the active `Datum` and `Series` and replaces the whole body, taking precedence over the formatters. Returned strings are inserted as text; return a DOM `Node` for structured content.
17983
19169
 
17984
- ### Custom Theme (CSS)
19170
+ ```ts
19171
+ import { createLineChart } from '@vielzeug/prism';
17985
19172
 
17986
- ```css
17987
- :root {
17988
- --prism-color-1: #6366f1;
17989
- --prism-color-2: #22c55e;
17990
- --prism-axis-color: #71717a;
17991
- --prism-grid-color: #f4f4f5;
17992
- --prism-text-color: #18181b;
17993
- --prism-tooltip-bg: #27272a;
17994
- --prism-font-family: 'Inter', system-ui, sans-serif;
17995
- }
17996
- ```
19173
+ const container = document.createElement('div');
19174
+ container.style.cssText = 'width:640px;height:320px';
19175
+ document.body.append(container);
17997
19176
 
17998
- ### Scoped Themes
19177
+ const compact = new Intl.NumberFormat(undefined, { notation: 'compact' });
19178
+ const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
17999
19179
 
18000
- Apply tokens to a specific container:
19180
+ const chart = createLineChart(container, {
19181
+ series: [{ data: [{ key: 11, value: 128000 }], name: 'EC2' }],
19182
+ tooltip: {
19183
+ titleFormat: (key) => months[Number(key)] ?? String(key),
19184
+ valueFormat: (value) => compact.format(value),
19185
+ },
19186
+ });
18001
19187
 
18002
- ```css
18003
- .dark-dashboard {
18004
- --prism-axis-color: #64748b;
18005
- --prism-grid-color: #334155;
18006
- --prism-text-color: #e2e8f0;
18007
- }
19188
+ chart.dispose();
18008
19189
  ```
18009
19190
 
18010
- ### Available Tokens
18011
-
18012
- | Token | Default | Description |
18013
- | ------------------------- | -------------------------- | ---------------------- |
18014
- | `--prism-color-{1-8}` | Colorblind-safe palette | Series color palette |
18015
- | `--prism-bg` | `transparent` | Chart background |
18016
- | `--prism-axis-color` | `hsl(215deg 16% 47%)` | Axis lines and ticks |
18017
- | `--prism-grid-color` | `hsl(220deg 10% 75%)` | Grid lines |
18018
- | `--prism-text-color` | `hsl(215deg 25% 27%)` | Axis labels and text |
18019
- | `--prism-font-family` | `system-ui` | Chart font |
18020
- | `--prism-font-size` | `0.75rem` | Label font size |
18021
- | `--prism-tooltip-bg` | `hsl(222deg 47% 11%)` | Tooltip background |
18022
- | `--prism-tooltip-color` | `hsl(210deg 40% 98%)` | Tooltip text |
18023
- | `--prism-tooltip-radius` | `0.375rem` | Tooltip border radius |
18024
- | `--prism-crosshair-color` | `hsl(215deg 16% 47%)` | Crosshair line |
18025
- | `--prism-crosshair-dash` | `4 2` | Crosshair dash pattern |
18026
-
18027
- The palette follows Wong (2011) so adjacent series stay distinguishable under common color-vision deficiencies. Dark mode applies through `prefers-color-scheme`, an `html.dark` class, or `data-prism-theme="dark"` on the chart container or any ancestor — the last is the hook for apps that switch theme in JS without owning ``.
18028
-
18029
- Radar charts add their own tokens, each defaulting to a shared one:
18030
-
18031
- | Token | Default | Description |
18032
- | --- | --- | --- |
18033
- | `--prism-radar-grid-color` | `--prism-grid-color` | Rings |
18034
- | `--prism-radar-grid-opacity` | `0.7` | Ring and spoke opacity |
18035
- | `--prism-radar-band-fill` | `--prism-grid-color` | Alternate ring shading |
18036
- | `--prism-radar-band-opacity` | `0.14` | Shading opacity |
18037
- | `--prism-radar-spoke-color` | `--prism-radar-grid-color` | Spokes |
18038
- | `--prism-radar-fill-opacity` | `--prism-area-opacity` | Solid shape fill |
18039
- | `--prism-radar-stroke-width` | `--prism-line-width` | Shape outline |
18040
- | `--prism-radar-point-radius` | `--prism-point-radius` | Vertex dots |
18041
- | `--prism-radar-point-radius-active` | `--prism-point-radius-hover` | Dots on the active axis |
18042
- | `--prism-radar-label-color` / `-size` | Secondary text tokens | Axis labels |
18043
- | `--prism-radar-value-color` / `-size` | Text tokens | Vertex values |
18044
- | `--prism-radar-dim-opacity` | `--prism-dim-opacity` | Other shapes while one is hovered |
18045
-
18046
- Shared interaction and chart tokens:
18047
-
18048
- | Token | Default | Description |
18049
- | --- | --- | --- |
18050
- | `--prism-dim-opacity` | `0.35` | Series or slices outside the active one |
18051
- | `--prism-active-point-radius` | `--prism-point-radius-hover` | Points marked at the active key |
18052
- | `--prism-point-ring` | `Canvas` | Ring separating active and end points from the line |
18053
- | `--prism-focus-color` | `--prism-color-1` | Focus outline of a keyboard-focused chart |
18054
- | `--prism-bar-radius` | `4px` | Bar corner radius (stacked bars stay square) |
18055
- | `--prism-bar-band-fill` / `-opacity` | Grid colour / `0.18` | Shaded active category |
18056
- | `--prism-bar-dim-opacity` | `0.55` | Bars outside the active category |
18057
- | `--prism-area-gradient-start` / `-end` | `0.45` / `0.02` | Gradient fill opacity at the line and the baseline |
18058
- | `--prism-spark-gradient-start` / `-end` | `0.35` / `0` | Sparkline gradient fill |
19191
+ `onHover` receives an event while a point, category, slice, or radar axis is active, then `null` when the interaction clears. `onClick` receives the selected event. For line, area, and bar charts, `ChartEvent.values` contains each series' datum at that shared key; a missing datum is `undefined`. Scatter events are `ChartEvent` objects without `values`; pie, radar, and sparkline use their chart-specific event types.
18059
19192
 
18060
- Explicit config such as `strokeWidth`, `fillOpacity`, or `borderRadius` always wins over these tokens.
19193
+ `crosshair` is available on line, area, and scatter charts. By default it shows a vertical guide and snaps the line to the nearest datum. Set `horizontal: true` to add a horizontal guide or `vertical: false` to hide the vertical one. Set `snap: false` to make the guide follow the pointer; tooltip and callback data still come from the nearest datum.
18061
19194
 
18062
- ## Scales (Standalone)
19195
+ ### Keyboard and Announcements
18063
19196
 
18064
- Scales can be used independently for custom visualizations:
19197
+ Set `a11y: { ariaLabel }` on an informative chart. Prism gives the SVG `role="img"`, an accessible name, and keyboard focus. Arrow keys move among chart data; `Home` and `End` move to the first and last item where supported; `Enter` or `Space` activates an item when an `onClick` callback is configured; `Escape` clears the active selection. Sparklines do not support keyboard navigation.
18065
19198
 
18066
- ```ts
18067
- import { linearScale, timeScale, bandScale } from '@vielzeug/prism';
19199
+ When `a11y` is omitted, or set to `{ decorative: true }`, the SVG is marked `aria-hidden="true"` and does not enter the keyboard tab order. Only mark charts decorative when their information is available elsewhere. Interactive cartesian and pie/radar charts use the tooltip as a polite live region when enabled; otherwise they announce active values in `.prism-live`. Sparklines have no live value announcements, so provide an equivalent visible value or summary.
18068
19200
 
18069
- const y = linearScale({ domain: [0, 100], range: [300, 0] });
18070
- y.map(50); // → 150
18071
- y.invert(150); // → 50
18072
- y.ticks(5); // → [0, 20, 40, 60, 80, 100]
19201
+ ## Animation
18073
19202
 
18074
- const x = bandScale({ domain: ['A', 'B', 'C'], range: [0, 300] });
18075
- x.map('B'); // → pixel left edge of band B
18076
- x.bandwidth(); // → width of each band
18077
- ```
19203
+ Chart motion is enabled by default, with a 300 ms duration per animated lane (radar uses 400 ms). Pass `transition: false` to render updates synchronously, or pass `true` to state the default explicitly.
18078
19204
 
18079
- ## Lifecycle and Cleanup
19205
+ ```ts
19206
+ import { createBarChart } from '@vielzeug/prism';
18080
19207
 
18081
- Every chart returns a `ChartHandle`. Always call `dispose()` when removing a chart:
19208
+ const container = document.createElement('div');
19209
+ container.style.cssText = 'width:640px;height:320px';
19210
+ document.body.append(container);
18082
19211
 
18083
- ```ts
18084
- const chart = createLineChart(container, config);
19212
+ const chart = createBarChart(container, {
19213
+ series: [{ data: [{ key: 'A', value: 4 }, { key: 'B', value: 7 }], name: 'Count' }],
19214
+ transition: { duration: 500, easing: 'ease-out', preference: 'system' },
19215
+ });
18085
19216
 
18086
- // When done:
18087
19217
  chart.dispose();
18088
-
18089
- // Or with TC39 explicit resource management:
18090
- {
18091
- using chart = createLineChart(container, config);
18092
- // auto-disposed at block end
18093
- }
18094
19218
  ```
18095
19219
 
18096
- Calling `dispose()`:
18097
-
18098
- - Cancels in-flight transitions
18099
- - Disconnects the `ResizeObserver`
18100
- - Removes the SVG element, tooltip, and legend from the DOM
18101
- - Restores container styles changed for tooltip positioning
18102
- - Is idempotent: safe to call multiple times
19220
+ Columns grow together from their baseline by default over 300 ms. Lines trace their final curve over 420 ms, uncovering point markers along the way without fading or shifting the series. Line data updates retain the default 300 ms geometry transition; an explicit `duration` overrides both timings.
18103
19221
 
18104
- Call `update()` only while the handle is active. Updating a disposed chart throws `PrismRenderError`.
19222
+ `preference: 'system'` (the default) disables JavaScript motion when the user prefers reduced motion. Use `'always'` to ignore that preference or `'never'` to disable motion. `stagger` sets an optional delay between bars, entering scatter points (in x order), or radar series; bar and scatter charts cap the effective delay so many marks do not add unbounded time. Named easing values are `ease-in`, `ease-in-out`, `ease-out`, `back-out`, `expo-out`, and `linear`; you can also provide an easing function.
18105
19223
 
18106
- ## Responsive Behavior
19224
+ ## Responsive Layout and Lifecycle
18107
19225
 
18108
- Charts resize automatically when the container dimensions change. Prism uses `ResizeObserver` internally: no manual `resize()` call is needed.
19226
+ Size the host with CSS. Prism observes its content box and updates the SVG dimensions and chart layout when it changes. Bar, line, and scatter resize redraws apply immediately rather than animating between layout sizes; entrance animations and explicit data-update transitions remain enabled. The legend is rendered as container chrome outside the SVG and included in resize calculations.
18109
19227
 
18110
- ## Runtime Observation
19228
+ Call `dispose()` when the view no longer owns the chart. Disposal is idempotent: it removes the SVG, tooltip, legend, and resize observation, stops in-flight chart motion, and aborts `disposalSignal`. `chart[Symbol.dispose]()` calls the same cleanup method.
18111
19229
 
18112
- Every 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.
19230
+ Use `tap()` when external code needs resize or dispose notifications rather than observing the chart DOM itself:
18113
19231
 
18114
19232
  ```ts
18115
19233
  import { createLineChart } from '@vielzeug/prism';
18116
19234
 
19235
+ const container = document.createElement('div');
19236
+ container.style.cssText = 'width:640px;height:320px';
19237
+ document.body.append(container);
19238
+
18117
19239
  const chart = createLineChart(container, {
18118
19240
  series: [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }],
18119
19241
  });
18120
- const controller = new AbortController();
18121
- const unsubscribe = chart.tap(
18122
- (event) => {
18123
- if (event.type === 'resize') console.log('resized', event.width, event.height);
18124
- if (event.type === 'dispose') console.log('disposed');
18125
- },
18126
- { signal: controller.signal },
18127
- );
19242
+ const stopObserving = chart.tap((event) => {
19243
+ if (event.type === 'resize') console.log(event.width, event.height);
19244
+ if (event.type === 'dispose') console.log('chart disposed');
19245
+ });
19246
+
19247
+ stopObserving();
19248
+ chart.dispose();
18128
19249
  ```
18129
19250
 
18130
- `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.
19251
+ `tap()` returns an unsubscribe function and accepts `{ signal }` for automatic unsubscription. A listener exception is swallowed by the observer so it cannot interrupt chart rendering.
18131
19252
 
18132
- ## Accessibility
19253
+ ## Theming
18133
19254
 
18134
- Label 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"`.
19255
+ Import the stylesheet once to load Prism's structural styles, default tokens, dark-mode rules, and reduced-motion CSS. You can override any CSS custom property on an ancestor of the chart.
18135
19256
 
18136
- ```ts
18137
- createLineChart(container, {
18138
- a11y: { ariaLabel: 'Revenue by month' },
18139
- series: [...],
18140
- });
19257
+ ```css
19258
+ .dashboard-chart {
19259
+ --prism-color-1: #2563eb;
19260
+ --prism-color-2: #16a34a;
19261
+ --prism-grid-color: #94a3b8;
19262
+ --prism-line-width: 3;
19263
+ --prism-text-color: #172033;
19264
+ --prism-tooltip-bg: #111827;
19265
+ }
18141
19266
  ```
18142
19267
 
18143
- Mark a chart decorative explicitly when surrounding text already states its meaning:
19268
+ `setTheme()` provides a typed subset of theme properties. A scoped call writes inline values on the provided element; omit `scope` to write them to `document.documentElement`. Inline values override stylesheet tokens until reset.
18144
19269
 
18145
19270
  ```ts
18146
- createSparkline(container, {
18147
- a11y: { decorative: true },
18148
- data: [...],
18149
- });
19271
+ import { resetTheme, setTheme } from '@vielzeug/prism';
19272
+
19273
+ const container = document.createElement('div');
19274
+ container.style.cssText = 'width:640px;height:320px';
19275
+ document.body.append(container);
19276
+
19277
+ setTheme({ colors: ['#2563eb', '#16a34a'], fontFamily: 'Inter, sans-serif' }, { scope: container });
19278
+ // Call resetTheme({ scope: container }) when this scoped theme is no longer needed.
19279
+ resetTheme({ scope: container });
18150
19280
  ```
18151
19281
 
18152
- ### Keyboard and announcements
19282
+ ### Available Tokens
18153
19283
 
18154
- Every labelled chart except sparklines shares one keyboard model:
19284
+ The stylesheet defines defaults for the following token groups. Pie-label color and size are read with fallback values by the renderer but are not declared in the base token block. Set a token on the chart container or an ancestor to scope an override.
18155
19285
 
18156
- | Chart | Arrow keys move between | `Home` / `End` | Announced |
18157
- | --- | --- | --- | --- |
18158
- | Line, area | x positions | First / last position | `1: Adam 3, Eve 6` |
18159
- | Bar | categories | First / last category | `Str: Adam 8, Eve 5` |
18160
- | Pie | slices | First / last slice | `Wins: 15 (75%)` |
18161
- | Radar | axes (wrapping) | Not supported | `Agility: Adam 6, Eve 9` |
19286
+ | Group | CSS custom properties |
19287
+ | --- | --- |
19288
+ | Palette and surfaces | `--prism-color-1` through `--prism-color-8`, `--prism-bg`, `--prism-border-radius` |
19289
+ | Axes and grid | `--prism-axis-color`, `--prism-axis-width`, `--prism-grid-color`, `--prism-grid-width`, `--prism-grid-dash`, `--prism-grid-opacity` |
19290
+ | Text | `--prism-font-family`, `--prism-font-size`, `--prism-font-size-label`, `--prism-font-size-title`, `--prism-font-weight`, `--prism-font-weight-title`, `--prism-text-color`, `--prism-text-color-secondary` |
19291
+ | Tooltip | `--prism-tooltip-bg`, `--prism-tooltip-color`, `--prism-tooltip-border`, `--prism-tooltip-radius`, `--prism-tooltip-padding`, `--prism-tooltip-shadow`, `--prism-tooltip-font-size` |
19292
+ | Crosshair | `--prism-crosshair-color`, `--prism-crosshair-width`, `--prism-crosshair-dash`, `--prism-crosshair-opacity` |
19293
+ | Points | `--prism-point-radius`, `--prism-point-radius-hover`, `--prism-point-stroke-width`, `--prism-point-stroke` |
19294
+ | Bars | `--prism-bar-radius`, `--prism-bar-hover-opacity`, `--prism-bar-gap`, `--prism-bar-band-fill`, `--prism-bar-band-opacity`, `--prism-bar-dim-opacity` |
19295
+ | Areas and lines | `--prism-area-opacity`, `--prism-area-stroke-width`, `--prism-area-gradient-start`, `--prism-area-gradient-end`, `--prism-line-width`, `--prism-line-width-hover` |
19296
+ | Interaction | `--prism-dim-opacity`, `--prism-active-point-radius`, `--prism-point-ring`, `--prism-focus-color` |
19297
+ | Sparklines | `--prism-spark-fill-opacity`, `--prism-spark-gradient-start`, `--prism-spark-gradient-end` |
19298
+ | Radar | `--prism-radar-grid-color`, `--prism-radar-grid-opacity`, `--prism-radar-band-fill`, `--prism-radar-band-opacity`, `--prism-radar-spoke-color`, `--prism-radar-fill-opacity`, `--prism-radar-stroke-width`, `--prism-radar-point-radius`, `--prism-radar-point-radius-active`, `--prism-radar-label-color`, `--prism-radar-label-size`, `--prism-radar-value-color`, `--prism-radar-value-size`, `--prism-radar-dim-opacity` |
19299
+ | Motion | `--prism-duration-fast`, `--prism-duration`, `--prism-duration-slow`, `--prism-ease`, `--prism-ease-spring`, `--prism-transition`, `--prism-transition-fast` |
19300
+ | Legend and pie labels | `--prism-legend-gap`, `--prism-legend-dot-size`, `--prism-legend-font-size`, `--prism-pie-label-color`, `--prism-pie-label-size` |
19301
+
19302
+ The default palette and structural colors adapt to `prefers-color-scheme: dark`, `html.dark`, or `[data-prism-theme='dark']` on an ancestor. Scoped inline values set with `setTheme()` take precedence in either mode. The stylesheet also provides `prefers-reduced-motion` overrides for CSS transitions.
18162
19303
 
18163
- The 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.
19304
+ ## Errors and Diagnostics
18164
19305
 
18165
- ### Hover feedback
19306
+ Prism throws `PrismRenderError` when the container is not a DOM element, when initial rendering fails, or when `update()` is called on a disposed chart. A wrapped initial-render error exposes its cause. Use `PrismError` to catch all Prism-originated errors.
18166
19307
 
18167
- The 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.
19308
+ Empty or malformed data is not always an exception. Depending on the chart, Prism may draw nothing or emit a development-only `console.warn`; examples include an unknown `bandScale` category, negative values in stacked bars, and radar axes with fewer than three entries or duplicate keys. Correct the input rather than relying on a warning for validation.
18168
19309
 
18169
19310
  ## Framework Integration
18170
19311
 
18171
- Prism 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.
19312
+ Prism has no React, Vue, or Svelte adapter. In any framework, give Prism a host element after it is mounted, call `update()` when data changes, and call `dispose()` before the host is removed. This plain DOM pattern shows the ownership boundary without depending on a framework-specific lifecycle API:
18172
19313
 
18173
19314
  ```ts
18174
- import { createLineChart, type ChartHandle, type ContinuousDatum, type LineSeriesConfig } from '@vielzeug/prism';
18175
-
18176
- let chart: ChartHandle | undefined;
18177
-
18178
- export function mountChart(container: HTMLElement, data: ContinuousDatum[]): void {
18179
- chart = createLineChart(container, { series: [{ data, name: 'Series' }] });
18180
- }
19315
+ import { createLineChart, type LineSeriesConfig } from '@vielzeug/prism';
18181
19316
 
18182
- export function updateChart(data: ContinuousDatum[]): void {
18183
- chart?.update([{ data, name: 'Series' }]);
18184
- }
19317
+ function mountChart(host: HTMLElement, series: LineSeriesConfig[]) {
19318
+ const chart = createLineChart(host, { a11y: { ariaLabel: 'Revenue' }, series });
18185
19319
 
18186
- export function unmountChart(): void {
18187
- chart?.dispose();
18188
- chart = undefined;
19320
+ return {
19321
+ update(next: LineSeriesConfig[]) {
19322
+ chart.update(next);
19323
+ },
19324
+ dispose() {
19325
+ chart.dispose();
19326
+ },
19327
+ };
18189
19328
  }
18190
- ```
18191
-
18192
- ## Working with Other Vielzeug Libraries
18193
-
18194
- ### With Ripple
18195
19329
 
18196
- Keep state ownership in Ripple and connect it to Prism through an explicit subscription.
18197
-
18198
- ```ts
18199
- import { createLineChart } from '@vielzeug/prism';
18200
- import { signal } from '@vielzeug/ripple';
18201
-
18202
- const data = signal([{ key: 1, value: 10 }]);
18203
- const toSeries = () => [{ data: data.value, name: 'Series' }];
18204
- const chart = createLineChart(container, { series: toSeries() });
18205
- const unsubscribe = data.subscribe(() => chart.update(toSeries()));
18206
-
18207
- // Cleanup both owners together.
18208
- unsubscribe();
18209
- chart.dispose();
19330
+ const host = document.createElement('div');
19331
+ host.style.cssText = 'width:640px;height:320px';
19332
+ document.body.append(host);
19333
+ const view = mountChart(host, [{ data: [{ key: 1, value: 10 }], name: 'Revenue' }]);
19334
+ view.update([{ data: [{ key: 1, value: 10 }, { key: 2, value: 15 }], name: 'Revenue' }]);
19335
+ view.dispose();
18210
19336
  ```
18211
19337
 
18212
- ### With Sourcerer
18213
-
18214
- Bind chart data to a Sourcerer remote source so charts update whenever the list refreshes.
19338
+ The application owns its state and host; Prism owns only the SVG, observer, and optional overlay elements it creates.
18215
19339
 
18216
- ```ts
18217
- import { createBarChart } from '@vielzeug/prism';
18218
- import { createPageSource } from '@vielzeug/sourcerer';
19340
+ ## Working with Other Vielzeug Libraries
18219
19341
 
18220
- const source = createPageSource({
18221
- load: async ({ page, pageSize, signal }) => {
18222
- const result = await api.stats.list({ page, pageSize }, { signal });
18223
- return { items: result.data, totalItems: result.total };
18224
- },
18225
- });
18226
- const toSeries = (state) => [
18227
- {
18228
- data: state.items.map((item) => ({ key: item.label, value: item.count })),
18229
- name: 'Series',
18230
- },
18231
- ];
18232
- const chart = createBarChart(container, { series: toSeries(source.state) });
18233
- const unsubscribe = source.subscribe((state) => chart.update(toSeries(state)));
18234
- void source.reload().catch(() => undefined);
18235
- ```
19342
+ Use Refine components for dashboard controls and keep Prism in a sized host element. Map Refine theme tokens to Prism CSS custom properties or call `setTheme()` with a scoped element; Prism does not require a Refine adapter. Prism uses Orbit internally to place tooltips, so chart users do not need to create Orbit floating elements for the built-in tooltip.
18236
19343
 
18237
19344
  ## Best Practices
18238
19345
 
18239
- - Ensure the container element has explicit dimensions before calling a chart factory: `ResizeObserver` needs a non-zero layout size to trigger the first render.
18240
- - Call `chart.dispose()` in your framework's unmount/cleanup phase to cancel transitions, disconnect resize observation, and remove DOM nodes.
18241
- - Call `chart.update(data)` from your application state boundary; Prism does not require or own a state library.
18242
- - Set `a11y: { ariaLabel: '…' }` on every chart that conveys meaningful data: unlabelled charts are hidden from assistive technology and receive no keyboard navigation.
18243
- - Observe lifecycle with `chart.tap(handler)` rather than polling `chart.disposed` or wrapping the container: it costs nothing when untapped and detaches via `signal`.
18244
- - For SSR, skip chart creation server-side: Prism depends on DOM APIs and `ResizeObserver`. Render charts only after hydration in a `onMounted`/`useEffect` callback.
19346
+ - Give the host a non-zero size before creating a chart.
19347
+ - Set `a11y.ariaLabel` when a chart communicates information.
19348
+ - Use `Date` or number keys consistently across each line or area chart.
19349
+ - Pass the complete new data value to `update()`.
19350
+ - Keep chart options fixed after creation; recreate the chart to change them.
19351
+ - Dispose the handle when its host leaves the owning view.
19352
+ - Use `transition: false` for deterministic synchronous rendering.
19353
+ - Prefer CSS custom properties for reusable theme overrides.
18245
19354
 
18246
19355
  ### Examples
18247
19356
 
18248
19357
  - [Create a line chart](./examples/line-chart.md)
18249
19358
  - [Plot a time series](./examples/time-series.md)
19359
+ - [Plot a scatter chart](./examples/scatter-chart.md)
18250
19360
  - [Compare categories with a bar chart](./examples/bar-chart.md)
18251
19361
  - [Render compact charts](./examples/compact-charts.md)
18252
19362
  - [Compare hero stats with a radar chart](./examples/radar-chart.md)
@@ -19282,7 +20392,7 @@ Component registration happens through side-effect imports such as `@vielzeug/re
19282
20392
 
19283
20393
  **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-cookie-banner`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
19284
20394
 
19285
- **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-counter`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-input`, `ore-message-composer`, `ore-number-input`, `ore-otp-input`, `ore-qr-scanner`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
20395
+ **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-counter`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-input`, `ore-message-composer`, `ore-number-input`, `ore-otp-input`, `ore-qr-scanner`, `ore-query-bar`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
19286
20396
 
19287
20397
  **Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
19288
20398
 
@@ -19388,7 +20498,7 @@ import type { OreButtonProps } from '@vielzeug/refine/button';
19388
20498
  | Content | `avatar`, `avatar-group`, `breadcrumb`, `card`, `carousel`, `chat-message`, `code-window`, `copy-command`, `icon`, `keyboard-key`, `list`, `list-item`, `marquee`, `pagination`, `separator`, `speech-player`, `stats`, `step`, `stepper`, `table`, `text` |
19389
20499
  | Disclosure | `accordion`, `accordion-item`, `tabs`, `tab-item`, `tab-panel` |
19390
20500
  | Feedback | `alert`, `async`, `badge`, `chip`, `cookie-banner`, `password-strength`, `progress`, `skeleton`, `toast`, `typing-indicator` |
19391
- | Inputs | `button`, `button-group`, `calendar`, `checkbox`, `checkbox-group`, `combobox`, `counter`, `datagrid`, `date-picker`, `file-input`, `input`, `message-composer`, `number-input`, `otp-input`, `radio`, `radio-group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time-picker` |
20501
+ | Inputs | `button`, `button-group`, `calendar`, `checkbox`, `checkbox-group`, `combobox`, `counter`, `datagrid`, `date-picker`, `file-input`, `input`, `message-composer`, `number-input`, `otp-input`, `query-bar`, `radio`, `radio-group`, `rating`, `select`, `slider`, `switch`, `textarea`, `time-picker` |
19392
20502
  | Layout | `box`, `grid`, `grid-item`, `navbar`, `sidebar` |
19393
20503
  | Overlays | `command-palette`, `dialog`, `drawer`, `menu`, `navigation-menu`, `popover`, `tooltip` |
19394
20504
 
@@ -29322,6 +30432,7 @@ useEffect(() => {
29322
30432
  ## Examples
29323
30433
 
29324
30434
  - [Sync a Vault Store](./examples/sync-a-vault-store.md)
30435
+ - [Make Store Commands Undoable](./examples/make-store-commands-undoable.md)
29325
30436
  - [Keep Deletions from Resurrecting](./examples/keep-deletions-from-resurrecting.md)
29326
30437
  - [Switch Accounts Safely](./examples/switch-accounts-safely.md)
29327
30438