@vielzeug/codex 1.0.2 → 1.0.4

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,6 @@
1
1
  # Vielzeug — Full Documentation
2
2
 
3
- > Complete documentation for all 30 Vielzeug packages. Version: 1.0.2
3
+ > Complete documentation for all 31 Vielzeug packages. Version: 1.0.3
4
4
 
5
5
  ---
6
6
 
@@ -1211,6 +1211,569 @@ const price = currency({ amount: 123456n, currency: 'USD' }); // $1,234.56
1211
1211
  - Type checking utilities (id: `typed-is`)
1212
1212
 
1213
1213
 
1214
+ ---
1215
+
1216
+ ## @vielzeug/assay
1217
+
1218
+ **Category:** testing
1219
+ **Keywords:** testing, dom, events, queries, waitfor, custom-elements, jsdom
1220
+ **Key exports:** within, query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText, queryInShadow, queryAllInShadow, queryPart, getSlotted, fire (+7 more)
1221
+ **Related:** ore, refine
1222
+
1223
+ ### Overview
1224
+
1225
+ ## Why Assay?
1226
+
1227
+ Testing DOM-level code (custom elements, vanilla event handlers, framework-rendered output) usually means hand-rolling `dispatchEvent` boilerplate and ad-hoc polling loops, or pulling in a full testing-library dependency tied to one framework's rendering model. Assay extracts just the generic, framework-agnostic pieces — scoped queries, event dispatch, async waiting — as a standalone, zero-dependency package.
1228
+
1229
+ ```ts
1230
+ // Before — hand-rolled event dispatch and polling in every test file
1231
+ const btn = panel.querySelector('button.submit')!;
1232
+
1233
+ btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
1234
+
1235
+ const deadline = Date.now() + 1000;
1236
+ while (Date.now() setTimeout(r, 50));
1237
+ }
1238
+
1239
+ // After
1240
+ import { fire, waitFor, within } from '@vielzeug/assay';
1241
+
1242
+ const { query, queryByText } = within(panel);
1243
+
1244
+ fire.click(query('button.submit')!);
1245
+ await waitFor(() => queryByText('Saved') !== null);
1246
+ ```
1247
+
1248
+ | Feature | Assay | @testing-library/dom |
1249
+ | ----------------------- | ------------------------------------------------ | ---------------------------------------- |
1250
+ | Bundle size | | ~15 kB |
1251
+ | Framework-agnostic | | |
1252
+ | Scoped queries | `within()` | |
1253
+ | Low-level event dispatch| `fire.*` | Partial (`fireEvent`) |
1254
+ | Deterministic `waitFor` | | |
1255
+ | Zero dependencies | | |
1256
+
1257
+ **Use Assay when** you're testing custom elements or vanilla DOM code and want scoped queries, synchronous event dispatch, and async waiting without adopting a framework-specific testing library.
1258
+
1259
+ **Consider alternatives when** you're already standardized on `@testing-library/dom` (or a framework-specific wrapper around it) and don't need to avoid that dependency.
1260
+
1261
+ ## Installation
1262
+
1263
+ Assay is a testing dependency — install it alongside your test runner.
1264
+
1265
+ ```sh [pnpm]
1266
+ pnpm add -D @vielzeug/assay
1267
+ ```
1268
+
1269
+ ```sh [npm]
1270
+ npm install -D @vielzeug/assay
1271
+ ```
1272
+
1273
+ ```sh [yarn]
1274
+ yarn add -D @vielzeug/assay
1275
+ ```
1276
+
1277
+ ## Quick Start
1278
+
1279
+ ```ts
1280
+ import { fire, waitFor, within } from '@vielzeug/assay';
1281
+
1282
+ const panel = document.querySelector('.panel')!;
1283
+ const { query, queryByText } = within(panel);
1284
+
1285
+ fire.click(query('button.submit')!);
1286
+
1287
+ await waitFor(() => queryByText('Saved') !== null);
1288
+ ```
1289
+
1290
+ ## Features
1291
+
1292
+ - `within(element)` — scoped `query`/`queryAll`/`queryByText`/`queryAllByText`/`queryByTestId`/`queryAllByTestId` for any element or shadow root, each also available as a free function (`query(root, selector)`, etc.) for when you already have a root.
1293
+ - `queryInShadow`/`queryAllInShadow`/`queryPart` — shadow-DOM-aware queries that return `null`/`[]` instead of throwing when the host has no shadow root.
1294
+ - `getSlotted(host, slotName?)` — light-DOM children assigned to a named slot, or every slotted child.
1295
+ - `fire.*` — low-level synchronous DOM event dispatchers (`click`, `input`, `keyDown`, `pointerDown`/`pointerMove`/`pointerCancel`, `custom`, and more), each with sensible `bubbles`/`cancelable` defaults and a consistent `boolean` return value — no exceptions.
1296
+ - `waitFor(fn, options?)` — polls until a callback returns truthy or a bare `expect()` call doesn't throw; always rejects with `AssayTimeoutError` on timeout, with the original failure preserved as `.cause`.
1297
+ - `waitForEvent(element, name, timeout?)` — resolves with the next matching event, or rejects with `AssayTimeoutError`.
1298
+ - `nextTick()`/`wait(ms?)` — microtask and macrotask timing helpers for reactive updates and debounced code.
1299
+ - `AssayError` / `AssayTimeoutError` — a single error hierarchy for every timeout this package raises.
1300
+ - No DOM-framework coupling — works with vanilla custom elements, `@vielzeug/ore` components, or any other DOM output.
1301
+
1302
+ ## Documentation
1303
+
1304
+ - [Usage Guide](./usage.md)
1305
+ - [API Reference](./api.md)
1306
+
1307
+ ## See Also
1308
+
1309
+ - [Ore](/ore/) — web-component authoring library whose `@vielzeug/ore/testing` sub-path re-exports Assay's query/event/wait primitives
1310
+ - [Refine](/refine/) — accessible component library built on Ore, testable with the same Assay primitives
1311
+
1312
+ ### API Reference
1313
+
1314
+ ## API Overview
1315
+
1316
+ | Symbol | Purpose | Execution mode | Common gotcha |
1317
+ | -------------------- | ----------------------------------------------------- | -------------- | -------------- |
1318
+ | `within(element)` | Scoped query helpers for one element/subtree | Sync | Returns a fresh `QueryScope` per call — cheap, but don't cache it across DOM mutations you care about |
1319
+ | `query()` / `queryAll()` | Free-function equivalents of `within(root).query`/`.queryAll` | Sync | Use these when you already have a root and don't need the rest of `QueryScope` |
1320
+ | `queryByTestId()` / `queryAllByTestId()` | Match a `data-testid` attribute | Sync | Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` |
1321
+ | `queryByText()` | First element matching trimmed text content | Sync | Matches exact trimmed text, not substrings |
1322
+ | `queryAllByText()` | Every element matching trimmed text content | Sync | Same exact-match caveat as `queryByText()` |
1323
+ | `queryInShadow()` / `queryAllInShadow()` | Query inside a host's shadow root | Sync | Returns `null`/`[]` (not a throw) when the host has no shadow root |
1324
+ | `queryPart()` | Query a shadow-DOM element by its `part` attribute | Sync | Shorthand for `queryInShadow(host, '[part="x"]')` |
1325
+ | `getSlotted()` | Light-DOM children assigned to a slot | Sync | Only direct children (`:scope >`) — doesn't recurse into further-nested slots |
1326
+ | `fire.*` | Dispatch a DOM event synchronously | Sync | Doesn't wait for anything — pair with `waitFor()`/`await` for async reactions |
1327
+ | `createPointerEvent()` | Build a `PointerEvent`, falling back to `MouseEvent` | Sync | Only needed if you're constructing an event by hand instead of using `fire.pointer*` |
1328
+ | `waitFor()` | Poll until a callback returns truthy or resolves | Async | Always rejects with `AssayTimeoutError` on timeout — the original failure is `.cause`, never the thrown error's own type |
1329
+ | `waitForEvent()` | Resolve on the next matching event | Async | Rejects with `AssayTimeoutError`, not a plain `Error`, on timeout |
1330
+ | `nextTick()` | Resolve after one microtask tick | Async | Doesn't wait for `setTimeout`-scheduled work — use `wait()` for that |
1331
+ | `wait()` | Resolve after a fixed millisecond delay | Async | A fixed delay, not a condition — prefer `waitFor()` when you can express a condition instead |
1332
+
1333
+ ## Package Entry Point
1334
+
1335
+ | Import | Purpose |
1336
+ | -------------------- | ------------------------------------------------------------------- |
1337
+ | `@vielzeug/assay` | The entire public API — queries, event dispatch, async waiting |
1338
+
1339
+ ## Query Helpers
1340
+
1341
+ ### `within(element)`
1342
+
1343
+ Creates query helpers scoped to a single element or shadow root.
1344
+
1345
+ **Parameters**
1346
+
1347
+ | Name | Type | Description |
1348
+ | --------- | ---------- | ------------------------------------- |
1349
+ | `element` | `Element` | The root to scope every query to |
1350
+
1351
+ **Returns:** `QueryScope`
1352
+
1353
+ **Example**
1354
+
1355
+ ```ts
1356
+ import { within } from '@vielzeug/assay';
1357
+
1358
+ const { query, queryAll, queryByTestId, queryAllByTestId, queryByText, queryAllByText } = within(panel);
1359
+ ```
1360
+
1361
+ `QueryScope` methods:
1362
+
1363
+ | Method | Returns |
1364
+ | ---------------------------------- | ----------------- |
1365
+ | `query(selector)` | `Element \| null` |
1366
+ | `queryAll(selector)` | `Element[]` |
1367
+ | `queryByText(text, selector?)` | `Element \| null` |
1368
+ | `queryAllByText(text, selector?)` | `Element[]` |
1369
+ | `queryByTestId(testId)` | `Element \| null` |
1370
+ | `queryAllByTestId(testId)` | `Element[]` |
1371
+
1372
+ `selector` defaults to `'*'` for the text-matching methods.
1373
+
1374
+ ---
1375
+
1376
+ ### `query(root, selector)` / `queryAll(root, selector)`
1377
+
1378
+ Free-function equivalents of `within(root).query`/`.queryAll` — a thin wrapper over `root.querySelector(All)`, exported directly so every `QueryScope` method has both a scoped and unscoped form.
1379
+
1380
+ **Returns:** `Element | null` / `Element[]`
1381
+
1382
+ ---
1383
+
1384
+ ### `queryByTestId(root, testId)` / `queryAllByTestId(root, testId)`
1385
+
1386
+ Free-function equivalents of `within(root).queryByTestId`/`.queryAllByTestId` — matches a `data-testid` attribute.
1387
+
1388
+ **Returns:** `Element | null` / `Element[]`
1389
+
1390
+ ---
1391
+
1392
+ ### `queryByText(root, text, selector)`
1393
+
1394
+ The unscoped function `within()` is built on — useful when you already have a root and don't need the rest of `QueryScope`.
1395
+
1396
+ **Parameters**
1397
+
1398
+ | Name | Type | Description |
1399
+ | ---------- | ---------------------- | ---------------------------------------------- |
1400
+ | `root` | `Element \| ShadowRoot`| Subtree to search |
1401
+ | `text` | `string` | Exact text to match against trimmed `textContent` |
1402
+ | `selector` | `string` | CSS selector narrowing candidate elements |
1403
+
1404
+ **Returns:** `Element | null` — the first matching element, or `null`.
1405
+
1406
+ ---
1407
+
1408
+ ### `queryAllByText(root, text, selector)`
1409
+
1410
+ Same matching rules as `queryByText`, returning every match.
1411
+
1412
+ **Returns:** `Element[]`
1413
+
1414
+ ---
1415
+
1416
+ ### `queryInShadow(host, selector)` / `queryAllInShadow(host, selector)`
1417
+
1418
+ Query inside `host.shadowRoot`. Returns `null` (or `[]` for the `All` variant) instead of throwing when `host` has no shadow root — safe to call without an `if (host.shadowRoot)` guard.
1419
+
1420
+ **Returns:** `Element | null` / `Element[]`
1421
+
1422
+ ---
1423
+
1424
+ ### `queryPart(host, part)`
1425
+
1426
+ Shorthand for `queryInShadow(host, '[part="' + part + '"]')`.
1427
+
1428
+ **Returns:** `Element | null`
1429
+
1430
+ ---
1431
+
1432
+ ### `getSlotted(host, slotName?)`
1433
+
1434
+ Returns the light-DOM children assigned to a named slot, or every slotted child (`:not([slot])`) when `slotName` is omitted.
1435
+
1436
+ **Returns:** `Element[]`
1437
+
1438
+ ## Event Dispatch
1439
+
1440
+ ### `fire`
1441
+
1442
+ An object of synchronous event dispatchers. Every method calls `element.dispatchEvent(...)` with an appropriately-typed `Event` subclass and sensible defaults, and returns `dispatchEvent`'s own boolean result.
1443
+
1444
+ ```ts
1445
+ import { fire } from '@vielzeug/assay';
1446
+
1447
+ fire.click(el, opts?: PointerEventInit);
1448
+ fire.blur(el, opts?: FocusEventInit);
1449
+ fire.change(el, opts?: EventInit);
1450
+ fire.custom(el, name, opts?: CustomEventInit);
1451
+ fire.event(el, event: Event);
1452
+ fire.focus(el, name?, opts?: FocusEventInit);
1453
+ fire.input(el, opts?: EventInit);
1454
+ fire.keyboard(el, type, opts?: KeyboardEventInit);
1455
+ fire.keyDown(el, opts?: KeyboardEventInit);
1456
+ fire.keyUp(el, opts?: KeyboardEventInit);
1457
+ fire.mouse(el, type, opts?: MouseEventInit);
1458
+ fire.pointerCancel(el, opts?: PointerEventInit);
1459
+ fire.pointerDown(el, opts?: PointerEventInit);
1460
+ fire.pointerEnter(el, opts?: PointerEventInit);
1461
+ fire.pointerLeave(el, opts?: PointerEventInit);
1462
+ fire.pointerMove(el, opts?: PointerEventInit);
1463
+ fire.pointerUp(el, opts?: PointerEventInit);
1464
+ fire.submit(el, opts?: EventInit);
1465
+ fire.touch(el, type, opts?: EventInit);
1466
+ ```
1467
+
1468
+ `fire.touch` falls back to `CustomEvent` in environments without a `TouchEvent` constructor. `fire.pointer*` methods route through `createPointerEvent()`, so they fall back to `MouseEvent` the same way.
1469
+
1470
+ ---
1471
+
1472
+ ### `createPointerEvent(type, init?)`
1473
+
1474
+ Builds a `PointerEvent`, or a `MouseEvent` when `PointerEvent` isn't available in the current environment.
1475
+
1476
+ **Returns:** `Event`
1477
+
1478
+ ## Async Waiting
1479
+
1480
+ ### `waitFor(fn, options?)`
1481
+
1482
+ Polls `fn` until it returns truthy, returns `undefined` (a bare `expect()` call that didn't throw), or the timeout elapses.
1483
+
1484
+ **Parameters**
1485
+
1486
+ | Name | Type | Default | Description |
1487
+ | ------------------ | ------------------- | ------- | ---------------------------------------------- |
1488
+ | `fn` | `() => unknown` | — | Condition to poll |
1489
+ | `options.timeout` | `number` | `1000` | Maximum wait time in ms |
1490
+ | `options.interval` | `number` | `50` | Delay between polling attempts in ms |
1491
+ | `options.message` | `string` | — | Prefixed in front of the default timing summary in the timeout error — never replaces it |
1492
+
1493
+ **Returns:** `Promise`
1494
+
1495
+ **Throws:** `AssayTimeoutError` on timeout, unconditionally — regardless of whether the last polling attempt returned a falsy value or threw. The original failure (a thrown error, or its message) is preserved as `.cause` and folded into the timeout message; `fn`'s thrown error itself is never mutated.
1496
+
1497
+ **Example**
1498
+
1499
+ ```ts
1500
+ await waitFor(() => queryByText('Saved') !== null);
1501
+ await waitFor(() => expect(spy).toHaveBeenCalled(), { timeout: 2000 });
1502
+ ```
1503
+
1504
+ ---
1505
+
1506
+ ### `waitForEvent(element, name, timeout?)`
1507
+
1508
+ Resolves with the next event of the given name.
1509
+
1510
+ **Parameters**
1511
+
1512
+ | Name | Type | Default | Description |
1513
+ | --------- | --------- | ------- | --------------------------------- |
1514
+ | `element` | `Element` | — | Element to listen on |
1515
+ | `name` | `string` | — | Event name |
1516
+ | `timeout` | `number` | `1000` | Maximum wait time in ms |
1517
+
1518
+ **Returns:** `Promise` — the event instance, typed as `T extends Event` (defaults to `Event`).
1519
+
1520
+ ---
1521
+
1522
+ ### `nextTick()`
1523
+
1524
+ Resolves after one microtask tick (`queueMicrotask`) — for waiting on reactivity (signal effects, promise-chain continuations) without moving into the macrotask queue.
1525
+
1526
+ **Returns:** `Promise`
1527
+
1528
+ ---
1529
+
1530
+ ### `wait(ms?)`
1531
+
1532
+ Resolves after `ms` milliseconds (default `0`).
1533
+
1534
+ **Returns:** `Promise`
1535
+
1536
+ ## Types
1537
+
1538
+ ```ts
1539
+ interface QueryScope {
1540
+ query(selector: string): E | null;
1541
+ queryAll(selector: string): E[];
1542
+ queryByText(text: string, selector?: string): E | null;
1543
+ queryAllByText(text: string, selector?: string): E[];
1544
+ queryByTestId(testId: string): E | null;
1545
+ queryAllByTestId(testId: string): E[];
1546
+ }
1547
+
1548
+ interface WaitOptions {
1549
+ timeout?: number;
1550
+ interval?: number;
1551
+ message?: string;
1552
+ }
1553
+ ```
1554
+
1555
+ ## Errors
1556
+
1557
+ | Error | Thrown by | Notable properties |
1558
+ | -------------------- | ------------------------------ | -------------------- |
1559
+ | `AssayError` | Base class for every Assay error — use `instanceof AssayError` to catch any of them | `AssayError.is(err)` static type guard |
1560
+ | `AssayTimeoutError` | `waitFor()`, `waitForEvent()` when the timeout elapses | Extends `AssayError` |
1561
+
1562
+ ### Usage Guide
1563
+
1564
+ ## Basic Usage
1565
+
1566
+ `within(element)` scopes queries to a subtree — useful for slotted content, shadow roots, or any container you don't want to re-select from `document` on every assertion.
1567
+
1568
+ ```ts
1569
+ import { within } from '@vielzeug/assay';
1570
+
1571
+ const panel = document.querySelector('.panel')!;
1572
+ const { query, queryAll, queryByText } = within(panel);
1573
+
1574
+ query('.title'); // Element | null, scoped to panel
1575
+ queryAll('.item'); // Element[], scoped to panel
1576
+ queryByText('Save'); // matches trimmed textContent
1577
+ ```
1578
+
1579
+ `queryByTestId`/`queryAllByTestId` match a `data-testid` attribute — useful when text content or CSS structure is likely to change but a stable test hook is worth keeping:
1580
+
1581
+ ```ts
1582
+ const { queryByTestId } = within(panel);
1583
+
1584
+ queryByTestId('save-button')?.click();
1585
+ ```
1586
+
1587
+ Every `QueryScope` method is also available as a free function taking the root as its first argument — reach for these when you already have a root in scope and don't need the rest of `within()`'s object:
1588
+
1589
+ ```ts
1590
+ import { query, queryAll, queryByTestId, queryAllByTestId } from '@vielzeug/assay';
1591
+
1592
+ query(panel, '.title');
1593
+ queryAll(panel, '.item');
1594
+ queryByTestId(panel, 'save-button');
1595
+ queryAllByTestId(panel, 'item-row');
1596
+ ```
1597
+
1598
+ For a custom element's shadow DOM, use `queryInShadow`/`queryAllInShadow`/`queryPart` instead — they return `null`/`[]` rather than throwing when the host has no shadow root (e.g. a `shadow: false` component), so you don't need an `if (host.shadowRoot)` guard at every call site:
1599
+
1600
+ ```ts
1601
+ import { queryAllInShadow, queryInShadow, queryPart } from '@vielzeug/assay';
1602
+
1603
+ queryInShadow(customEl, '.internal-label');
1604
+ queryAllInShadow(customEl, '.option');
1605
+ queryPart(customEl, 'trigger'); // shorthand for queryInShadow(host, '[part="trigger"]')
1606
+ ```
1607
+
1608
+ `getSlotted()` reads the *light*-DOM children a host projects into a named slot (or every slotted child with no argument) — the complement to the shadow-DOM helpers above:
1609
+
1610
+ ```ts
1611
+ import { getSlotted } from '@vielzeug/assay';
1612
+
1613
+ const slides = getSlotted(carousel); // default slot
1614
+ const actions = getSlotted(dialog, 'footer'); // named slot
1615
+ ```
1616
+
1617
+ ## Firing Events
1618
+
1619
+ `fire.*` dispatches real DOM events synchronously — no framework-specific event simulation layer, just `dispatchEvent` with sensible defaults (`bubbles: true`, `cancelable: true` where that matches the real event's behavior). Every method returns the same `boolean` `dispatchEvent` itself returns (`false` when a listener called `preventDefault()`), with no exceptions — useful for asserting a handler actually intercepted the event:
1620
+
1621
+ ```ts
1622
+ import { fire } from '@vielzeug/assay';
1623
+
1624
+ fire.click(button);
1625
+ fire.input(textInput); // fires a plain 'input' Event
1626
+ fire.keyDown(textInput, { key: 'Enter' });
1627
+ fire.custom(el, 'value-change', { detail: { value: 42 } });
1628
+
1629
+ const notPrevented = fire.click(link); // false if a listener called preventDefault()
1630
+ ```
1631
+
1632
+ Pointer events fall back to `MouseEvent` in environments without a `PointerEvent` constructor (some older jsdom versions) — Assay logs a one-time `console.warn` the first time this happens, so an environment gap doesn't show up as a confusing, unrelated test failure instead:
1633
+
1634
+ ```ts
1635
+ fire.pointerDown(slider);
1636
+ fire.pointerMove(slider, { clientX: 120 });
1637
+ fire.pointerUp(slider);
1638
+ ```
1639
+
1640
+ Dispatch a pre-built event instance directly with `fire.event` when you need full control over the event's properties:
1641
+
1642
+ ```ts
1643
+ fire.event(el, new CustomEvent('ready', { bubbles: true, detail: { ok: true } }));
1644
+ ```
1645
+
1646
+ ## Waiting for Async Conditions
1647
+
1648
+ `waitFor()` polls a callback until it returns truthy (or doesn't throw, for a bare `expect()` call) — use it for anything that updates asynchronously (a reactive framework's next render, a debounced input handler, a `fetch` completing).
1649
+
1650
+ ```ts
1651
+ import { waitFor } from '@vielzeug/assay';
1652
+
1653
+ await waitFor(() => panel.querySelector('.status')?.textContent === 'Ready');
1654
+
1655
+ // Works with `expect()` assertions too — a thrown assertion error means "not yet",
1656
+ // and its message is folded into the AssayTimeoutError thrown if the timeout is reached
1657
+ // (see below — the assertion's own error type never escapes waitFor() directly).
1658
+ await waitFor(() => expect(callback).toHaveBeenCalled());
1659
+ ```
1660
+
1661
+ Tune the polling interval and timeout per call — the defaults (1000ms timeout, 50ms interval) suit most reactive UI updates:
1662
+
1663
+ ```ts
1664
+ await waitFor(() => queue.isEmpty(), { interval: 10, timeout: 5000 });
1665
+ ```
1666
+
1667
+ `waitForEvent()` resolves with the next matching event instead of polling — prefer it when you're waiting on something that's guaranteed to fire an event rather than settle into an observable DOM state:
1668
+
1669
+ ```ts
1670
+ import { fire, waitForEvent } from '@vielzeug/assay';
1671
+
1672
+ const promise = waitForEvent>(el, 'item-added');
1673
+
1674
+ fire.click(addButton);
1675
+
1676
+ const event = await promise;
1677
+ console.log(event.detail.id);
1678
+ ```
1679
+
1680
+ Both `waitFor()` and `waitForEvent()` reject with `AssayTimeoutError` on timeout — unconditionally, even if the last attempt inside `waitFor()` threw a different error type (an `expect()` assertion, say). Catch `AssayTimeoutError` specifically when you want to distinguish "the condition never became true" from other failures; the original failure is preserved on `.cause` if you need it:
1681
+
1682
+ ```ts
1683
+ import { AssayTimeoutError, waitForEvent } from '@vielzeug/assay';
1684
+
1685
+ try {
1686
+ await waitForEvent(el, 'never-fires', 100);
1687
+ } catch (err) {
1688
+ if (err instanceof AssayTimeoutError) {
1689
+ // expected — assert on the timeout itself, or inspect err.cause for the original failure
1690
+ }
1691
+ }
1692
+ ```
1693
+
1694
+ `nextTick()` and `wait()` cover the two other common timing needs: waiting for reactivity to settle, and waiting for a real macrotask delay.
1695
+
1696
+ ```ts
1697
+ import { nextTick, wait } from '@vielzeug/assay';
1698
+
1699
+ signal.value = 'updated';
1700
+ await nextTick(); // let a microtask-scheduled effect run
1701
+
1702
+ await wait(300); // wait out a debounce timer — prefer nextTick()/waitFor() where possible
1703
+ ```
1704
+
1705
+ ## Testing Custom Elements
1706
+
1707
+ Assay has no opinion about how your DOM was produced — it works the same way against a vanilla custom element, a framework-rendered component, or plain `document.createElement` output.
1708
+
1709
+ ```ts
1710
+ import { fire, waitFor, within } from '@vielzeug/assay';
1711
+
1712
+ customElements.define(
1713
+ 'my-counter',
1714
+ class extends HTMLElement {
1715
+ connectedCallback() {
1716
+ this.innerHTML = `+10`;
1717
+ this.querySelector('button')!.addEventListener('click', () => {
1718
+ const span = this.querySelector('.count')!;
1719
+
1720
+ span.textContent = String(Number(span.textContent) + 1);
1721
+ });
1722
+ }
1723
+ },
1724
+ );
1725
+
1726
+ const el = document.createElement('my-counter');
1727
+
1728
+ document.body.appendChild(el);
1729
+
1730
+ const { query } = within(el);
1731
+
1732
+ fire.click(query('button')!);
1733
+
1734
+ await waitFor(() => query('.count')?.textContent === '1');
1735
+
1736
+ el.remove();
1737
+ ```
1738
+
1739
+ ## Working with Other Vielzeug Libraries
1740
+
1741
+ `@vielzeug/ore`'s `./testing` sub-path re-exports Assay's `within`, `fire`, `createPointerEvent`, `waitFor`, and `waitForEvent` directly — if you're already testing Ore components, you don't need a separate Assay import:
1742
+
1743
+ ```ts
1744
+ import { fire, mount, waitFor } from '@vielzeug/ore/testing';
1745
+
1746
+ const { query } = await mount(() => html` {}}>Click`);
1747
+
1748
+ fire.click(query('button')!);
1749
+ ```
1750
+
1751
+ `@vielzeug/refine`'s `./testing` sub-path re-exports Assay's `queryInShadow`, `queryAllInShadow`, `queryPart`, `getSlotted`, `nextTick`, and `wait` the same way, alongside refine-specific helpers (ARIA attribute assertions, form-associated helpers) that stay in refine because they're specific to testing ore/refine component *contracts*, not generic DOM interaction:
1752
+
1753
+ ```ts
1754
+ import { getAriaState, getSlotted, nextTick } from '@vielzeug/refine/testing';
1755
+ ```
1756
+
1757
+ Import from `@vielzeug/assay` directly when testing DOM code that has nothing to do with Ore or Refine — a vanilla custom element, a framework-rendered component, or plain event-handler logic.
1758
+
1759
+ ## Best Practices
1760
+
1761
+ - Prefer `within(element)` over repeated `element.querySelector(...)` calls — it reads better at call sites with multiple assertions against the same subtree.
1762
+ - Use `queryByTestId` for elements whose text or structure is expected to change; use `queryByText` when the visible text itself is what you're asserting on.
1763
+ - Reach for `waitForEvent()` over `waitFor()` when the thing you're waiting on is guaranteed to dispatch an event — it resolves on the first matching event instead of polling.
1764
+ - Catch `AssayTimeoutError` specifically (not a bare `Error`) when a test needs to branch on "the condition never happened" versus any other failure.
1765
+ - Keep `fire.*` calls synchronous where the real user interaction would be — `fire.click()` doesn't await anything; `await` the assertion that follows it instead.
1766
+ - Remove elements you create directly with `document.createElement` (`el.remove()`) at the end of each test — Assay itself has no auto-cleanup registry, unlike `@vielzeug/ore/testing`'s `mount()`/`cleanup()`.
1767
+ - Prefer `queryInShadow`/`queryAllInShadow` over `host.shadowRoot!.querySelector(...)` — the non-null assertion breaks the moment a component is tested with `shadow: false`, while `queryInShadow` degrades to `null` instead of throwing.
1768
+
1769
+ ### Examples
1770
+
1771
+ ## Examples
1772
+
1773
+ - [Custom Element Interaction](./examples/custom-element-interaction.md)
1774
+ - [Waiting for Async Updates](./examples/waiting-for-async-updates.md)
1775
+
1776
+
1214
1777
  ---
1215
1778
 
1216
1779
  ## @vielzeug/clockwork
@@ -1687,6 +2250,7 @@ type InterpretOptions = {
1687
2250
  onDebug?: (event: DebugEvent) => void;
1688
2251
  persistence?: PersistenceAdapter;
1689
2252
  snapshot?: MachineSnapshot;
2253
+ validateHydratedContext?: boolean;
1690
2254
  traceLimit?: number;
1691
2255
  };
1692
2256
  ```
@@ -1699,6 +2263,7 @@ type InterpretOptions = {
1699
2263
  | `onDebug` | `undefined` | Callback for all debug events (guards, transitions, invokes, skips). Auto-enables a 50-entry trace buffer unless `traceLimit` is set. |
1700
2264
  | `persistence` | `undefined` | Save/load adapter for snapshot persistence |
1701
2265
  | `snapshot` | `undefined` | Snapshot to hydrate from on startup (takes priority over persistence) |
2266
+ | `validateHydratedContext`| `false` | When `true`, runs `validateContext` against hydrated context during startup; when `false`, hydrated context is trusted and only validated on transitions. |
1702
2267
  | `traceLimit` | auto (`50`/`0`) | Ring buffer capacity for `getTrace()`. Defaults to `50` when `onDebug` is set; `0` (disabled) otherwise. Set explicitly to override. |
1703
2268
 
1704
2269
  ---
@@ -1826,7 +2391,7 @@ interface MachineInstance {
1826
2391
  | `matches(...states)` | `boolean` | `true` if the current state is one of the given values or a descendant of any (e.g. `matches('loading')` matches `'loading.pending'`). Returns `false` when disposed. |
1827
2392
  | `dispose()` | `void` | Aborts active invokes, clears after-timers, and disposes reactive signals. Idempotent. Does **not** clear persisted state. Equivalent to `using m = createMachine(config).start()`. |
1828
2393
  | `send(event)` | `SendResult` | Dispatches the event. Returns a `SendResult` with `.status`: `'transitioned'`, `'queued'`, or `'rejected'` (also when the machine is already disposed). |
1829
- | `subscribe(fn)` | `() => void` | Subscribes to state/context changes. Returns an unsubscribe function. Fires only when state or context changes — **not** on the initial value. Use `getSnapshot()` to read the current state immediately. |
2394
+ | `subscribe(fn)` | `() => void` | Subscribes to state/context changes. Returns an unsubscribe function. Fires only when state or context changes — **not** on the initial value. Callback receives an isolated snapshot (`context` is cloned). Use `getSnapshot()` to read the current state immediately. |
1830
2395
  | `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations. |
1831
2396
 
1832
2397
  ---
@@ -1878,7 +2443,7 @@ type PersistenceAdapter = {
1878
2443
  };
1879
2444
  ```
1880
2445
 
1881
- `save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided.
2446
+ `save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided. Hydrated context is not validated at startup unless `validateHydratedContext: true` is set.
1882
2447
 
1883
2448
  ---
1884
2449
 
@@ -2472,7 +3037,9 @@ On startup, `createMachine().start()` checks `options.snapshot` first, then `per
2472
3037
 
2473
3038
  `m[Symbol.dispose]()` does **not** clear persisted state. The machine may be recreated (e.g. after HMR or component remount) and should resume from the last saved state. To reset persistence, call your adapter's storage API directly.
2474
3039
 
2475
- If context is loaded from untrusted sources (e.g. `localStorage`), run your `validateContext` guard before interpreting, or wrap `persistence.load()` with a try/catch and schema check.
3040
+ Hydrated context is trusted by default for backward compatibility. To enforce `validateContext` during hydration, pass `validateHydratedContext: true` to `.start(...)`.
3041
+
3042
+ If context is loaded from untrusted sources (e.g. `localStorage`), enable `validateHydratedContext` or validate inside `persistence.load()` before returning the snapshot.
2476
3043
 
2477
3044
  ## Interceptors
2478
3045
 
@@ -2547,6 +3114,7 @@ unsub(); // stop listening
2547
3114
  ```
2548
3115
 
2549
3116
  The callback fires only when `state` or `context` reference changes — not on every signal read.
3117
+ Each callback receives an isolated snapshot object; mutating the callback payload does not mutate machine state.
2550
3118
 
2551
3119
  ## Debugging and Tracing
2552
3120
 
@@ -7052,7 +7620,7 @@ Creates a query client with caching, deduplication, prefix invalidation, and rea
7052
7620
  | `getState` | `(key) => QueryState \| null` | Full state snapshot |
7053
7621
  | `observeMany` | `(keys: QueryKey[]) => SyncStore[]>` | Observe multiple keys as one combined store; updates on any key change |
7054
7622
  | `invalidate` | `(key) => void` | Evict or background-revalidate a key/prefix |
7055
- | `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to idle if active |
7623
+ | `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to `'loading'` if active |
7056
7624
  | `cancel` | `(key) => void` | Cancel an in-flight fetch; entry returns to `'loading'` or retains prior success data |
7057
7625
  | `clear` | `() => void` | Clear all entries; active subscribers see `'loading'` |
7058
7626
  | `refetchStale` | `() => void` | Manually revalidate all stale observed entries |
@@ -7113,7 +7681,7 @@ Creates a standalone, observable mutation handle.
7113
7681
  | `peek` | `() => MutationState` | Read current state snapshot |
7114
7682
  | `subscribe` | `(cb: () => void) => () => void` | Subscribe to state changes; returns unsubscribe fn |
7115
7683
  | `store` | `SyncStore>` (property) | Framework-friendly external store; stable reference |
7116
- | `reset` | `() => void` | Reset back to the idle state |
7684
+ | `reset` | `() => void` | Reset back to the `'loading'` baseline state |
7117
7685
  | `dispose` | `() => void` | Abort active run, clear observers, and mark as disposed |
7118
7686
  | `disposed` | `boolean` (getter) | Whether `dispose()` has been called |
7119
7687
  | `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations |
@@ -8285,7 +8853,7 @@ const state = qc.getState(['users', 1]);
8285
8853
  ```ts
8286
8854
  const store = qc.watchKey(['users', 1]);
8287
8855
 
8288
- const initial = store.peek(); // idle state if not yet fetched
8856
+ const initial = store.peek(); // loading baseline if not yet fetched
8289
8857
  const stop = store.subscribe(() => {
8290
8858
  console.log(store.peek());
8291
8859
  });
@@ -8324,7 +8892,7 @@ const store = qc.observe({
8324
8892
  });
8325
8893
 
8326
8894
  // Synchronously read the current state
8327
- console.log(store.peek().status); // 'idle' or 'pending'
8895
+ console.log(store.peek().status); // 'loading' (or 'success' / 'error' if already cached)
8328
8896
  console.log(store.peek().data); // placeholderData while fetching
8329
8897
 
8330
8898
  // Subscribe to future changes
@@ -8368,7 +8936,7 @@ function useUser(id: number) {
8368
8936
  For entries **without active subscribers**, invalidation evicts the cache entry immediately. For entries **with active subscribers**:
8369
8937
 
8370
8938
  - If the entry has a stored query function (registered via `fetch()`), it is background-revalidated.
8371
- - If the entry was only populated via `set()`, it resets to `idle`.
8939
+ - If the entry was only populated via `set()`, it resets to `'loading'`.
8372
8940
 
8373
8941
  Supports **prefix matching**: invalidating `['users']` affects `['users', 1]`, `['users', 2]`, and so on.
8374
8942
 
@@ -8379,7 +8947,7 @@ qc.invalidate(['users']);
8379
8947
 
8380
8948
  ### `cancel(key)`
8381
8949
 
8382
- Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'idle'`.
8950
+ Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'loading'`.
8383
8951
 
8384
8952
  ```ts
8385
8953
  qc.cancel(['users', 1]);
@@ -8387,7 +8955,7 @@ qc.cancel(['users', 1]);
8387
8955
 
8388
8956
  ### `clear()`
8389
8957
 
8390
- Clears every cache entry. Active subscribers are notified with an `'idle'` state.
8958
+ Clears every cache entry. Active subscribers are notified with a `'loading'` state.
8391
8959
 
8392
8960
  ```ts
8393
8961
  qc.clear();
@@ -8968,7 +9536,7 @@ effect(() => console.log('user:', userStore.value.user?.name));
8968
9536
 
8969
9537
  **Category:** ui-interaction
8970
9538
  **Keywords:** drag-drop, sortable, file-upload, drop-zone, dnd, reorder
8971
- **Key exports:** createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept
9539
+ **Key exports:** createDropZone, createSortable, createSortableScope, createTouchDragShim, applyReorder, matchesAccept
8972
9540
  **Related:** ore, scroll, refine
8973
9541
 
8974
9542
  ### Overview
@@ -9016,6 +9584,7 @@ const zone = createDropZone({
9016
9584
  | Sortable lists | | | |
9017
9585
  | Drag handles | | | |
9018
9586
  | `using` support | | | |
9587
+ | Touch support | Opt-in shim | | |
9019
9588
  | Zero dependencies | | | |
9020
9589
 
9021
9590
  **Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.
@@ -9088,6 +9657,7 @@ using sortable = createSortable({
9088
9657
  - **`sortable.revert()`** — register a revert function via `event.setRevert(fn)` inside `onReorder`; `sortable.revert()` invokes it and clears it for rolling back optimistic updates on server failure
9089
9658
  - **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally
9090
9659
  - **Explicit connected scopes** — lists only exchange items when they share a `createSortableScope()` instance
9660
+ - **Touch support via `createTouchDragShim()`** — bridges `touchstart`/`touchmove`/`touchend`/`touchcancel` into the same synthetic `DragEvent` sequence `createSortable`/`createDropZone` already listen for; one `document`-level instance covers the whole app
9091
9661
  - **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers
9092
9662
  - **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup
9093
9663
  - **Reactive-friendly options** — `disabled` is re-read on each event (reassign `options.disabled = true` to toggle); `accept` captures the array reference, so push/splice mutations are reflected without recreating the zone
@@ -9114,6 +9684,7 @@ using sortable = createSortable({
9114
9684
  | `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown |
9115
9685
  | `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |
9116
9686
  | `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |
9687
+ | `createTouchDragShim()` | Bridge touch gestures to synthetic DragEvents | Sync | Create once per app — it's a single `document`-level listener set |
9117
9688
  | `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |
9118
9689
  | `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |
9119
9690
  | `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |
@@ -9217,6 +9788,15 @@ declare function createSortableScope(): SortableScope;
9217
9788
 
9218
9789
  Creates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance.
9219
9790
 
9791
+ ### `TouchDragOptions`
9792
+
9793
+ ```ts
9794
+ interface TouchDragOptions {
9795
+ disabled?: boolean;
9796
+ draggableSelector?: string;
9797
+ }
9798
+ ```
9799
+
9220
9800
  ## `createDropZone()`
9221
9801
 
9222
9802
  ```ts
@@ -9323,7 +9903,7 @@ declare function createSortable(options: SortableOptions): Sortable;
9323
9903
 
9324
9904
  Makes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.
9325
9905
 
9326
- `createSortable` sets `draggable="true"` and `role="listitem"` on qualifying children and sets `role="list"` on the container at initialization. After DOM mutations, call `sortable.sync()` to re-apply sortable attributes explicitly.
9906
+ `createSortable` sets `draggable="true"`, `role="listitem"`, and `touch-action: none` (inline style) on qualifying children and sets `role="list"` on the container at initialization. After DOM mutations, call `sortable.sync()` to re-apply sortable attributes explicitly.
9327
9907
 
9328
9908
  - `element`: `HTMLElement`, required. The container whose children become sortable.
9329
9909
  - `getKey`: `(element: HTMLElement) => string`, required. Maps each item element to its stable string identity. Children for which `getKey` returns a falsy value are skipped.
@@ -9449,6 +10029,7 @@ Dnd reads and writes the following DOM attributes:
9449
10029
  - `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.
9450
10030
  - `data-dnd-handle`: internal marker set by `createSortable` and `sortable.sync()`, removed by `dispose()`. Lets Dnd clean up only the handle attributes it applied.
9451
10031
  - `aria-hidden="true"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.
10032
+ - `style.touchAction = 'none'` (inline style): set by `createSortable` and `sortable.sync()` on the item (or the handle, when `handle` is set), cleared by `dispose()`. Opts the element out of the browser's default touch gestures (scroll/pan/zoom) so a mobile browser never hijacks a drag gesture as a page scroll before `createTouchDragShim`'s own logic runs — see Usage's "Why draggable items get `touch-action: none`". No effect on mouse/pointer input.
9452
10033
 
9453
10034
  ## CSS Classes
9454
10035
 
@@ -9456,6 +10037,34 @@ Dnd reads and writes the following DOM attributes:
9456
10037
  | ----------------- | ---------------------------- | ------------------------------------------------------------- |
9457
10038
  | `dnd-placeholder` | `` inserted by sortable | While an item is being dragged, in the placeholder's position |
9458
10039
 
10040
+ ## `createTouchDragShim()`
10041
+
10042
+ ```ts
10043
+ declare function createTouchDragShim(options?: TouchDragOptions): Disposable;
10044
+ ```
10045
+
10046
+ Bridges touch gestures to the synthetic `DragEvent` sequence `createSortable()`/`createDropZone()` already listen for — `touchstart`/`touchmove`/`touchend`/`touchcancel` become `dragstart`/`dragover`/`drop`/`dragend` on the same `document`. HTML5 drag-and-drop has no native touch equivalent otherwise.
10047
+
10048
+ | Option | Type | Default | Description |
10049
+ | -------------------- | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
10050
+ | `disabled` | `boolean` | — | When `true`, touch gestures are ignored. Read live off the same options object on each `touchstart`, like `SortableOptions.disabled`. |
10051
+ | `draggableSelector` | `string` | `'[draggable="true"]'` | CSS selector identifying draggable elements under the touch point. The default matches what `createSortable`/`createDropZone` already set. |
10052
+ | `showDragPreview` | `boolean` | `true` | Renders a floating clone of the dragged element that follows the touch point for the whole gesture. A native mouse drag gets this for free from the browser's own drag image; this shim's `dragstart` is synthetic, so without it the dragged element would simply vanish (hidden by `createSortable`'s own `scheduleHide()`) with no visual feedback at all. Set to `false` to render fully custom feedback instead. |
10053
+
10054
+ **Returns:** `Disposable`
10055
+
10056
+ Notes:
10057
+
10058
+ - Listens at the `document` level — create one instance per app, not one per sortable/drop-zone.
10059
+ - Dispatched events carry a plain object as `dataTransfer` (`dropEffect`/`effectAllowed`/`getData`/`setData`/`setDragImage`), never a real `DataTransfer` — a genuine `DataTransfer` created outside an active native drag is permanently in the spec's "disabled mode", where `dropEffect` writes are silently ignored, which `createSortable`/`createDropZone`'s own commit-vs-cancel check would otherwise always read as a cancellation.
10060
+ - The floating preview is a `cloneNode(true)` of the dragged element — it only clones light-DOM content, so an item whose visible content lives inside a shadow root will preview as an empty shell.
10061
+
10062
+ ```ts
10063
+ import { createTouchDragShim } from '@vielzeug/dnd';
10064
+
10065
+ using touchDrag = createTouchDragShim();
10066
+ ```
10067
+
9459
10068
  ## `matchesAccept()`
9460
10069
 
9461
10070
  ```ts
@@ -9868,6 +10477,61 @@ try {
9868
10477
  }
9869
10478
  ```
9870
10479
 
10480
+ ## Touch Support
10481
+
10482
+ HTML5 drag-and-drop has no native touch story — touch devices never fire `dragstart`/`dragover`/`drop`. `createTouchDragShim` bridges `touchstart`/`touchmove`/`touchend`/`touchcancel` into that same synthetic `DragEvent` sequence at the `document` level, so `createSortable`/`createDropZone` work on touch with no per-instance wiring.
10483
+
10484
+ ```ts
10485
+ import { createTouchDragShim } from '@vielzeug/dnd';
10486
+
10487
+ // Call once at app startup — one instance covers the whole page.
10488
+ using touchDrag = createTouchDragShim();
10489
+ ```
10490
+
10491
+ ### Custom draggable selector
10492
+
10493
+ Defaults to `[draggable="true"]` — the attribute `createSortable`/`createDropZone` already set on managed elements. Override it if you're bridging touch to elements you manage draggability on yourself.
10494
+
10495
+ ```ts
10496
+ createTouchDragShim({ draggableSelector: '.my-drag-handle' });
10497
+ ```
10498
+
10499
+ ### Drag preview
10500
+
10501
+ A native mouse-driven drag gets a floating drag image for free — the browser snapshots the dragged element the moment `dragstart` fires and keeps that image under the cursor for the whole gesture. `createTouchDragShim`'s `dragstart` is a synthetic event, so no such snapshot ever exists; without a preview of its own, the dragged element would simply disappear (hidden by `createSortable`'s own scheduled hide) with no visual feedback until the drop. `createTouchDragShim` renders one automatically — a `cloneNode(true)` of the dragged element, positioned `fixed` and translated to follow the touch point — enabled by default.
10502
+
10503
+ ```ts
10504
+ // Opt out to render fully custom feedback instead (e.g. toggling a class from your own
10505
+ // dragstart/dragend listeners):
10506
+ createTouchDragShim({ showDragPreview: false });
10507
+ ```
10508
+
10509
+ Note the preview only clones light-DOM content — an item whose visible content lives inside a shadow root will preview as an empty shell.
10510
+
10511
+ ### Why draggable items get `touch-action: none`
10512
+
10513
+ `createSortable` sets `touch-action: none` on every element it marks as draggable (the item itself, or the handle when `handle` is set) — no configuration needed. Without it, a mobile browser can decide the very first bit of finger movement on a draggable item is a page scroll/pan — a decision made independently of, and before, `createTouchDragShim`'s own drag-start threshold and `preventDefault()` calls ever run — and hand the rest of the gesture to native scrolling. Once that happens the item never receives the `dragover` sequence needed to update the drop target, so the drop commits back to wherever it started, which looks identical to the drop simply reverting. This is most visible dragging between two containers that require any real finger travel (e.g. a Kanban column stacked below the source column on a narrow viewport) — a short in-place reorder rarely travels far enough to trigger the browser's scroll-intent heuristic, which is why this class of bug can pass casual same-container testing and only show up cross-container.
10514
+
10515
+ This has no effect on mouse/pointer input — `touch-action` is touch-only — so it's safe even for `createSortable` instances that never pair with `createTouchDragShim`.
10516
+
10517
+ ### Disabled state
10518
+
10519
+ ```ts
10520
+ const options = { disabled: false };
10521
+ const touchDrag = createTouchDragShim(options);
10522
+
10523
+ // options.disabled is read live on each touch event — mutate to toggle:
10524
+ options.disabled = true;
10525
+ ```
10526
+
10527
+ ### Cleanup
10528
+
10529
+ ```ts
10530
+ touchDrag.dispose();
10531
+ // or:
10532
+ using touchDrag = createTouchDragShim();
10533
+ ```
10534
+
9871
10535
  ## Framework Integration
9872
10536
 
9873
10537
  ```tsx [React]
@@ -9957,19 +10621,22 @@ Use Dnd in custom web components by attaching behavior in component lifecycle ho
9957
10621
 
9958
10622
  ```ts
9959
10623
  import { createSortable } from '@vielzeug/dnd';
9960
- import { define, onMounted, html } from '@vielzeug/ore';
10624
+ import { define, getHost, html, onMounted } from '@vielzeug/ore';
9961
10625
 
9962
10626
  define('task-list', {
9963
- setup(_props, { host }) {
10627
+ setup(_props) {
10628
+ const el = getHost();
10629
+
9964
10630
  onMounted(() => {
9965
10631
  const sortable = createSortable({
9966
- element: host.el,
10632
+ element: el,
9967
10633
  getKey: (el) => el.dataset.sortId!,
9968
10634
  onReorder: ({ ids }) => save(ids),
9969
10635
  });
9970
10636
  return () => sortable.dispose();
9971
10637
  });
9972
- return () => html``;
10638
+
10639
+ return html``;
9973
10640
  },
9974
10641
  });
9975
10642
  ```
@@ -9983,12 +10650,14 @@ define('task-list', {
9983
10650
  - Use `createSortableScope()` only when items should genuinely move between containers.
9984
10651
  - Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.
9985
10652
  - Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.
10653
+ - Call `createTouchDragShim()` once at app startup if you support touch devices — it's a single `document`-level bridge, not something to attach per `createSortable`/`createDropZone` instance.
9986
10654
 
9987
10655
  ### Examples
9988
10656
 
9989
10657
  ## Examples
9990
10658
 
9991
10659
  - [Sortable List](./examples/sortable-list.md)
10660
+ - [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)
9992
10661
  - [File Upload Drop Zone](./examples/file-upload-drop-zone.md)
9993
10662
  - [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)
9994
10663
  - [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)
@@ -12882,17 +13551,15 @@ if (!submission.ok && submission.type === 'validation') {
12882
13551
 
12883
13552
  - Typed field paths with compile-time value inference
12884
13553
  - Explicit validation API: `validate()`, `validate(name)`, and `validate(fields[])`
12885
- - Streaming validation with `validateStream()` — yields each field result as it resolves, read-only
12886
13554
  - Per-connection validation triggers via `connect()` with `ValidationModes` presets
12887
13555
  - `connect()` bindings own independent debounce timers; call `binding.dispose()` on unmount
12888
13556
  - `submit(handler)` — returns `{ ok: true, value }` or `{ ok: false, errors }`
12889
13557
  - Schema integration: pass any `safeParse`-compatible schema directly to `validator`
12890
- - `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths
12891
- - `subscribeScoped()` filtered subscription that only fires on changes within the scope's prefix
12892
- - `snapshot()` / `restore()` — capture and replay complete form state
13558
+ - `scope(prefix)` — memoized scoped sub-forms that share parent state with relative field paths; `subscribe()` on a scoped form filters to just that prefix
13559
+ - `history.snapshot()` / `history.restore()` capture and replay complete form state
12893
13560
  - `form.fields.remove(name)` — clean conditional field lifecycle
12894
13561
  - Full array helpers: `append`, `prepend`, `insert`, `remove`, `move`, `swap`, `replace`
12895
- - Explicit synchronous subscriptions: `subscribe`, `subscribeField`, and `subscribeScoped`
13562
+ - Explicit synchronous subscriptions: `subscribe` (form or scoped-form state) and `subscribeField` (one field)
12896
13563
  - Stable frozen snapshots for `form.state` and `form.field(name)` (external-store friendly)
12897
13564
  - Explicit touched and error controls: `touch`, `untouch`, `touchAll`, `untouchAll`, `setError`, `resetErrors`
12898
13565
  - Mutation batching with `batch(fn)` and dynamic field validators via `fields.setValidator`
@@ -12900,7 +13567,7 @@ if (!submission.ok && submission.type === 'validation') {
12900
13567
  - Browser-first utility: `toFormData`
12901
13568
  - Framework-agnostic core — wire into React, Vue, Svelte, or vanilla JS with `subscribe()`/`connect()`, no dedicated adapter package
12902
13569
  - `@vielzeug/forge/validators` adapter: `fieldValidator` and `composeValidators`
12903
- - `@vielzeug/forge/devtools`: opt-in `attachForgeDevtools()` for `console.debug` state-transition logging, tree-shaken from production
13570
+ - `@vielzeug/forge/devtools`: opt-in `debugForm()` for `console.debug` state-transition logging, tree-shaken from production
12904
13571
 
12905
13572
  ## Documentation
12906
13573
 
@@ -12924,13 +13591,12 @@ if (!submission.ok && submission.type === 'validation') {
12924
13591
  | `form.get()` / `form.set()` | Read/write field values by dot-path | Sync | `set()` after `dispose()` throws |
12925
13592
  | `form.field()` / `form.state` | Read field and form snapshots | Sync | Returns a stable frozen snapshot; re-read on each subscriber call |
12926
13593
  | `form.validate()` | Run validation — all fields, a subset, or a single field | Async | Each call re-runs validators from scratch |
12927
- | `form.validateStream()` | Streaming validation — yields each field result as it resolves | Async (iterator) | Read-only — does not write errors to form state |
12928
13594
  | `form.submit()` | Deterministic submit flow returning a `SubmitResult` | Async | Rejects if called while already submitting — guard with `form.isSubmitting` |
12929
13595
  | `form.connect()` | Live field binding with DOM event handlers and live getters | Sync | Do not destructure — live getters lose context; call `dispose()` on unmount |
12930
13596
  | `form.scope()` | Memoized scoped sub-form with relative field paths | Sync | Returns the same object for repeated calls with the same prefix; `state` is scoped — flags reflect only prefix fields |
12931
13597
  | `form.array()` | Array mutation helpers | Sync | Returns a cached helper — call once and reuse |
12932
- | `form.subscribe()` / `form.subscribeField()` / `form.subscribeScoped()` | Synchronous form and field subscriptions | Sync | Callbacks receive frozen snapshots |
12933
- | `form.snapshot()` / `form.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving |
13598
+ | `form.subscribe()` / `form.subscribeField()` | Synchronous form and field subscriptions | Sync | On a scoped form, `subscribe()` is already prefix-filtered — callbacks receive frozen snapshots |
13599
+ | `form.history.snapshot()` / `form.history.restore()` | Capture and replay complete form state | Sync | Useful for undo/redo and draft saving |
12934
13600
  | `form.batch()` | Group mutations into one notification | Sync | Nested `batch()` calls are safe — only the outermost flush notifies |
12935
13601
  | `form.touch()` / `form.touchAll()` | Mark fields touched | Sync | `touchAll()` marks every key currently in the store |
12936
13602
  | `form.setError()` / `form.clearError()` / `form.resetErrors()` | Manual error management | Sync | `setError()` bypasses validators; cleared on next `validate()` run for that field |
@@ -12947,7 +13613,7 @@ if (!submission.ok && submission.type === 'validation') {
12947
13613
  | ---------------------------- | ---------------------------------------------------------------------------------- |
12948
13614
  | `@vielzeug/forge` | `createForm`, `toFormData`, `ValidationModes`, `FORM_ERROR`, and all types |
12949
13615
  | `@vielzeug/forge/validators` | `fieldValidator`, `composeValidators` — schema and validator composition helpers |
12950
- | `@vielzeug/forge/devtools` | `attachForgeDevtools` — opt-in `console.debug` logging for form state transitions |
13616
+ | `@vielzeug/forge/devtools` | `debugForm` — opt-in `console.debug` logging for form state transitions |
12951
13617
 
12952
13618
  ## createForm()
12953
13619
 
@@ -13243,11 +13909,6 @@ subscribeField>(
13243
13909
  options?: SubscribeOptions,
13244
13910
  ): Unsubscribe
13245
13911
 
13246
- subscribeScoped(
13247
- listener: (state: FormState) => void,
13248
- options?: SubscribeOptions,
13249
- ): Unsubscribe
13250
-
13251
13912
  type SubscribeOptions = { sync?: boolean };
13252
13913
  type Unsubscribe = () => void;
13253
13914
  ```
@@ -13256,17 +13917,17 @@ Pass `{ sync: true }` to also receive the current snapshot immediately upon subs
13256
13917
 
13257
13918
  Subscriptions fire synchronously whenever the form mutates. Because state snapshots are stable (frozen, reference-equal between mutations), these integrate directly with React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
13258
13919
 
13259
- ### subscribeScoped
13920
+ ### subscribe on a scoped form
13260
13921
 
13261
- `subscribeScoped` is available on both root forms and scoped forms:
13922
+ `subscribe` behaves differently depending on which form object it's called on:
13262
13923
 
13263
13924
  - **On a scoped form** — filters `errors`, `touchedFields`, and `validatingFields` to paths within the scope's prefix (remapped to relative paths). The listener is **only called when the scoped projection changes** — mutations outside the scope are suppressed. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form.
13264
- - **On a root form** — behaves identically to `subscribe`; no filtering is applied.
13925
+ - **On a root form** — no filtering is applied; every mutation notifies the listener.
13265
13926
 
13266
13927
  ```ts
13267
13928
  const address = form.scope('address');
13268
13929
 
13269
- address.subscribeScoped((state) => {
13930
+ address.subscribe((state) => {
13270
13931
  // state.errors uses relative keys: { city: '...' } not { 'address.city': '...' }
13271
13932
  // only fires when an address.* field changes
13272
13933
  console.log(state.errors, state.touchedFields);
@@ -13293,11 +13954,13 @@ type ArrayField = {
13293
13954
 
13294
13955
  `append()` and `prepend()` initialize the field as a one-item array when its current value is `undefined` or `null`. If the field already holds a non-array, non-nullish value (e.g. written by `set()`), both are a no-op — they never overwrite an existing scalar with an array. `insert()`, `remove()`, `move()`, `swap()`, and `replace()` are all no-ops when the field's current value is not an array.
13295
13956
 
13296
- ## Snapshot / Restore
13957
+ ## History (Snapshot / Restore)
13297
13958
 
13298
13959
  ```ts
13299
- snapshot(): FormSnapshot
13300
- restore(snap: FormSnapshot): void
13960
+ history: {
13961
+ snapshot(): FormSnapshot;
13962
+ restore(snap: FormSnapshot): void;
13963
+ }
13301
13964
 
13302
13965
  type FormSnapshot = {
13303
13966
  readonly baseline: Partial, unknown>>;
@@ -13309,44 +13972,17 @@ type FormSnapshot = {
13309
13972
  };
13310
13973
  ```
13311
13974
 
13312
- - `snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
13313
- - `restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
13975
+ - `history.snapshot()` — captures the complete form state (values, baseline, errors, touched, dirty, submitCount) into a plain object.
13976
+ - `history.restore(snap)` — replaces all state with the snapshot. Aborts any in-flight validation.
13314
13977
 
13315
- Useful for undo/redo, draft saving, and "discard changes" flows:
13978
+ Namespaced off the main `Form` surface since it's a distinctly less common operation than reading/writing values — grouped the same way `fields` already groups dynamic-field-lifecycle operations. Useful for undo/redo, draft saving, and "discard changes" flows:
13316
13979
 
13317
13980
  ```ts
13318
- const draft = form.snapshot();
13981
+ const draft = form.history.snapshot();
13319
13982
 
13320
13983
  form.set('email', 'changed@example.com');
13321
13984
 
13322
- form.restore(draft); // reverts all changes
13323
- ```
13324
-
13325
- ## validateStream()
13326
-
13327
- ```ts
13328
- validateStream(signal?: AbortSignal): AsyncIterableIterator
13329
- ```
13330
-
13331
- Runs all field validators in parallel and yields each result as soon as its validator resolves. If a form-level validator is configured, all keys it returns are yielded last — including `field: '_form'` and any field-specific keys returned by the form validator.
13332
-
13333
- **Read-only**: `validateStream()` does not write to `fieldErrors` or trigger subscriber notifications. Use `validate()` when you want errors applied to form state.
13334
-
13335
- ```ts
13336
- for await (const { field, error } of form.validateStream()) {
13337
- if (error) showInlineError(field, error);
13338
- }
13339
- // After the loop: form.state.errors is unchanged
13340
- ```
13341
-
13342
- Pass an `AbortSignal` to cancel the stream:
13343
-
13344
- ```ts
13345
- const ctrl = new AbortController();
13346
- for await (const result of form.validateStream(ctrl.signal)) {
13347
- processResult(result);
13348
- }
13349
- ctrl.abort(); // cancels any remaining in-flight validators
13985
+ form.history.restore(draft); // reverts all changes
13350
13986
  ```
13351
13987
 
13352
13988
  ## Baseline and Value Management
@@ -13471,7 +14107,7 @@ const form = createForm({
13471
14107
  Opt-in `console.debug` logging for form state transitions. Not exported from the main `@vielzeug/forge` entry point — import from this sub-path so the logging code is tree-shaken from production bundles.
13472
14108
 
13473
14109
  ```ts
13474
- function attachForgeDevtools>(
14110
+ function debugForm>(
13475
14111
  form: Form,
13476
14112
  options?: ForgeDevtoolsOptions,
13477
14113
  ): Unsubscribe;
@@ -13487,10 +14123,10 @@ Logs one line per observable state transition: per-field `value`/`error`/`touche
13487
14123
 
13488
14124
  ```ts
13489
14125
  import { createForm } from '@vielzeug/forge';
13490
- import { attachForgeDevtools } from '@vielzeug/forge/devtools';
14126
+ import { debugForm } from '@vielzeug/forge/devtools';
13491
14127
 
13492
14128
  const form = createForm({ defaultValues: { email: '' } });
13493
- const detach = attachForgeDevtools(form, { label: 'signup' });
14129
+ const detach = debugForm(form, { label: 'signup' });
13494
14130
  // [forge:devtools:signup] field "email" value: "" → "a@b.com"
13495
14131
 
13496
14132
  detach(); // stop logging
@@ -13542,12 +14178,15 @@ type ForgeDevtoolsOptions
13542
14178
 
13543
14179
  // Utility types
13544
14180
  type DeepPartial
13545
- type FlatKeyOf
14181
+ type FlatKeyOf // capped at MAX_TYPED_PATH_DEPTH (5) — see below
13546
14182
  type TypeAtPath
13547
14183
  type ErrorKeyOf
13548
14184
  type ScopedValues
14185
+ const MAX_TYPED_PATH_DEPTH // = 5 — not user-configurable, see rationale below
13549
14186
  ```
13550
14187
 
14188
+ `FlatKeyOf` falls back to plain `string` for paths deeper than `MAX_TYPED_PATH_DEPTH` — nothing throws, you just lose autocomplete/type-checking on that specific path (a dev-only console warning fires once per deep key so this is discoverable, not silent). This is a fixed constant, not a per-form generic parameter: it exists purely to protect TypeScript compile time, which doesn't get safer by letting one form's type param regress it for everyone importing that form's module.
14189
+
13551
14190
  ## Errors
13552
14191
 
13553
14192
  Forge exports a small typed error hierarchy, all extending a common `ForgeError` base:
@@ -13629,6 +14268,12 @@ console.log(result.valid); // true only if no errors exist after this run
13629
14268
  console.log(result.errors); // full current error map after the run
13630
14269
  ```
13631
14270
 
14271
+ Validation race semantics:
14272
+
14273
+ - Forge aborts superseded runs instead of surfacing them as failures.
14274
+ - The newest validation run owns final field errors.
14275
+ - Aborted runs resolve without throwing in normal API usage.
14276
+
13632
14277
  Schema integration — pass a `safeParse`-compatible schema directly to `validator`:
13633
14278
 
13634
14279
  ```ts
@@ -13778,20 +14423,36 @@ await address.validate(); // validates only address.* fields; returns scoped err
13778
14423
  await address.submit((vals) => vals); // validates and submits only address.* fields
13779
14424
  ```
13780
14425
 
14426
+ ### Root vs Scoped Path Semantics
14427
+
14428
+ | Surface | Root form (`form`) | Scoped form (`form.scope('address')`) |
14429
+ | -------------------------- | ------------------ | ------------------------------------- |
14430
+ | `get` / `set` / `field` | absolute keys | relative keys |
14431
+ | `errors` in `state` | absolute keys | relative keys |
14432
+ | `touchedFields` in `state` | absolute keys | relative keys |
14433
+ | `validatingFields` in `state` | absolute keys | relative keys |
14434
+ | `validate(name)` input | absolute key | relative key |
14435
+ | `validate()` result keys | absolute keys | relative keys |
14436
+
14437
+ ### Recommended Scoped Patterns
14438
+
14439
+ 1. Call `const address = form.scope('address')` once per UI/module boundary and pass that around.
14440
+ 2. Use `address.validate()` / `address.submit()` instead of manually feeding `state.touchedFields` into `validate(fields[])`.
14441
+ 3. Use `address.subscribe(...)` for section UIs — on a scoped form `subscribe` is already prefix-filtered, so sibling/root mutations do not trigger redraws.
14442
+
13781
14443
  **Key characteristics:**
13782
14444
 
13783
14445
  - `dispose()` on a scoped form is a no-op — call `parentForm.dispose()` to tear down.
13784
- - `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. Use `scope.validate()` or `scope.submit()` for scoped validity checks; their results contain relative keys and a scoped `valid` flag.
13785
- - `touchedFields` in `state` contains full-prefixed paths. Prefer `scope.validate()` over `scope.validate([...state.touchedFields])` to avoid double-prefixing.
14446
+ - `scope.state` returns a **scoped projection**: `errors`, `touchedFields`, `validatingFields`, `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect only fields within the scope's prefix and use relative keys. `isSubmitting`, `isLoading`, and `submitCount` reflect the full form. Use `scope.validate()` or `scope.submit()` for scoped validity checks; their results also use relative keys.
13786
14447
 
13787
14448
  ### Scoped Subscriptions
13788
14449
 
13789
- `subscribeScoped` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed.
14450
+ On a scoped form, `subscribe()` delivers form state filtered to the scope's prefix. `errors`, `touchedFields`, and `validatingFields` use relative keys. `isDirty`, `isValid`, `isTouched`, and `isValidating` reflect **only the scoped fields**. The listener **only fires when the scoped projection changes** — mutations outside the prefix are suppressed. (On a root form, `subscribe()` behaves identically to today — no filtering applies.)
13790
14451
 
13791
14452
  ```ts
13792
14453
  const address = form.scope('address');
13793
14454
 
13794
- address.subscribeScoped((state) => {
14455
+ address.subscribe((state) => {
13795
14456
  // state.errors → { city: 'Required' } (not 'address.city')
13796
14457
  // state.isDirty → true only when an address.* field is dirty
13797
14458
  // does not fire when form.set('name', 'Alice') is called
@@ -13823,29 +14484,18 @@ Snapshot semantics:
13823
14484
  - Reference identity is preserved until a relevant mutation occurs.
13824
14485
  - These are directly compatible with external-store patterns such as React `useSyncExternalStore`, Vue `shallowRef`, and the Svelte store protocol.
13825
14486
 
13826
- ## Streaming Validation
13827
-
13828
- `validateStream()` runs all field validators in parallel and yields each result as it resolves. It is **read-only** — it does not write errors to form state. The form-level validator, if set, is yielded last with `field: '_form'`.
14487
+ ## History (Snapshot / Restore)
13829
14488
 
13830
- ```ts
13831
- for await (const { field, error } of form.validateStream()) {
13832
- if (error) showInlineError(field, error);
13833
- }
13834
- // form.state.errors is unchanged after the loop
13835
- ```
13836
-
13837
- ## Snapshots and Restore
13838
-
13839
- Capture and replay complete form state for undo/redo or "discard changes" flows:
14489
+ Capture and replay complete form state for undo/redo or "discard changes" flows, via the `history` namespace:
13840
14490
 
13841
14491
  ```ts
13842
- const draft = form.snapshot();
14492
+ const draft = form.history.snapshot();
13843
14493
 
13844
14494
  // ... user edits ...
13845
14495
  form.set('email', 'different@example.com');
13846
14496
 
13847
14497
  // Revert all changes, including errors, touched, dirty, and submitCount
13848
- form.restore(draft);
14498
+ form.history.restore(draft);
13849
14499
  ```
13850
14500
 
13851
14501
  ## Arrays
@@ -13900,12 +14550,12 @@ After `dispose()`, all mutating APIs throw.
13900
14550
 
13901
14551
  ## Debugging
13902
14552
 
13903
- Import `attachForgeDevtools()` from the dedicated `/devtools` sub-path to log per-field value/error/touched/dirty changes and submit/loading transitions via `console.debug`:
14553
+ Import `debugForm()` from the dedicated `/devtools` sub-path to log per-field value/error/touched/dirty changes and submit/loading transitions via `console.debug`:
13904
14554
 
13905
14555
  ```ts
13906
- import { attachForgeDevtools } from '@vielzeug/forge/devtools';
14556
+ import { debugForm } from '@vielzeug/forge/devtools';
13907
14557
 
13908
- const detach = attachForgeDevtools(form, { label: 'signup' });
14558
+ const detach = debugForm(form, { label: 'signup' });
13909
14559
  // later, e.g. on unmount:
13910
14560
  detach();
13911
14561
  ```
@@ -14091,7 +14741,6 @@ const formWithSchema = createForm({
14091
14741
  - Field & Form Validation (id: `form-validation`)
14092
14742
  - Schema Integration - safeParse Auto-detection (id: `schema-integration`)
14093
14743
  - Scoped Sub-Forms (scope) (id: `scoped-sub-forms`)
14094
- - Streaming Validation (validateStream) (id: `validate-stream`)
14095
14744
 
14096
14745
 
14097
14746
  ---
@@ -17578,7 +18227,7 @@ effect(() => {
17578
18227
 
17579
18228
  **Category:** i18n
17580
18229
  **Keywords:** internationalization, translations, pluralization, locale, i18n, l10n, async-loading
17581
- **Key exports:** createI18n, createFormatter, hydrateI18n, serializeI18n, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError (+1 more)
18230
+ **Key exports:** createI18n, createFormatter, validateCatalog, LinguaError, LinguaDisposedError, LinguaInvalidCountError, LinguaCountInVarsError, LinguaMissingLocaleError, LinguaInvalidLocaleError, LinguaNamespaceMissingError, LinguaRestoreError
17582
18231
  **Related:** ripple, wayfinder, courier
17583
18232
 
17584
18233
  ### Overview
@@ -17693,7 +18342,7 @@ i18n.getSupportedLocales();
17693
18342
  - Namespace lazy loading: `registerNamespace(ns, factory)` + `loadNamespace(ns, locale?)` — or use `extend(ns, factory, locale?)` as a convenience that does both; deduplicates per `ns + locale`; use for per-route or per-feature keys
17694
18343
  - Scoped translation helpers: `scope(prefix)` returns a `{ fmt, t, tp, has }` helper bound to a key prefix
17695
18344
  - Unified key existence check: `has(key)` returns `true` for leaf keys, branch keys, and pipe-plural base keys in the active fallback chain
17696
- - Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `serializeI18n()` guards
18345
+ - Loaded-locale predicate: `isLoaded(locale)` returns `true` when a catalog is fully resolved — safe for `getState()` guards
17697
18346
  - Registered-locale predicate: `isRegistered(locale)` distinguishes "never configured" from "async loader not yet called"
17698
18347
  - Instance disposal: `dispose()` clears all subscribers and catalog state — prevents memory leaks in route-scoped SPA instances
17699
18348
  - Typed error handling: every thrown/rejected error is `instanceof LinguaError`, with named subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, `LinguaNamespaceMissingError`, …) for specific `instanceof` branching
@@ -17702,6 +18351,7 @@ i18n.getSupportedLocales();
17702
18351
  - Deterministic fallback chain using active locale plus configured fallback locales
17703
18352
  - Separate missing handlers: `onMissingKey(key, locale)` and `onMissingVar(varName, key, locale)`
17704
18353
  - Formatting via `createFormatter(source)` — exported from the main entry alongside `createI18n`
18354
+ - Automatic dev-mode plural-form validation: every catalog registered or loaded is checked against CLDR rules for its locale, logged via `console.warn` — zero setup, and never bundled into production
17705
18355
 
17706
18356
  ## Documentation
17707
18357
 
@@ -17741,13 +18391,11 @@ i18n.getSupportedLocales();
17741
18391
  | `i18n.loadNamespace()` | Load a registered namespace for a locale | Async | Deduplicates concurrent and repeated calls; throws `LinguaNamespaceMissingError` if namespace not registered |
17742
18392
  | `i18n.isNamespaceLoaded()` | Check if a namespace is loaded for the active (or given) locale | Sync | Returns `false` if not registered or not yet loaded for this locale |
17743
18393
  | `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before |
17744
- | `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Equivalent to `serializeI18n(i18n)` preferred for public API access |
17745
- | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(state, i18n)` preferred for public API access; throws `LinguaRestoreError` if locale missing |
17746
- | `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
17747
- | `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
18394
+ | `i18n.getState()` | Extract a serializable snapshot of loaded catalogs + active locale | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
18395
+ | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
17748
18396
  | Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling |
17749
18397
  | `createFormatter()` | Create a standalone Intl formatter | Sync | Available from the main entry or `@vielzeug/lingua/format` — pass a getter `() => i18n.locale` to follow locale changes |
17750
- | `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` — not for production |
18398
+ | `validateCatalog()` | Check a catalog for missing CLDR plural forms and missing `{count}` interpolations | Sync | Import from `@vielzeug/lingua/validate` for CI enforcement `createI18n()` already runs the same check automatically in dev builds |
17751
18399
 
17752
18400
  ## Package Entry Points
17753
18401
 
@@ -17945,7 +18593,7 @@ Returns `true` if a namespace factory has been registered under `ns` via `regist
17945
18593
  getState(): I18nState
17946
18594
  ```
17947
18595
 
17948
- Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale. Equivalent to `serializeI18n(i18n)` but preferred because it is called directly on the instance without requiring an import.
18596
+ Extracts a serializable snapshot of all **fully loaded** catalogs and the active locale.
17949
18597
 
17950
18598
  **Warning:** Only fully resolved catalogs are included. Loader-only locales not yet preloaded are omitted. Use `i18n.isLoaded(locale)` to verify before calling.
17951
18599
 
@@ -17960,13 +18608,15 @@ const state = i18n.getState();
17960
18608
  restoreState(state: I18nState): void
17961
18609
  ```
17962
18610
 
17963
- Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(state, i18n)` but preferred because it is called directly on the instance.
18611
+ Hydrates this instance from an `I18nState` produced by `getState()`.
17964
18612
 
17965
18613
  - Replaces all catalogs with those from `state`.
17966
18614
  - Sets the active locale to `state.locale`.
17967
18615
  - Clears all namespace loaded-markers so that `extend()` / `loadNamespace()` can re-apply namespaces.
17968
18616
  - Notifies subscribers.
17969
18617
 
18618
+ Unlike `register()` and construction, this does **not** run the automatic dev-mode plural-form check (see [`validateCatalog()`](#validatecatalog)) — `state` is assumed to already have been registered, and therefore already checked, once on whatever system produced it.
18619
+
17970
18620
  Throws `LinguaRestoreError` if `state.locale` has no catalog in `state.catalogs`.
17971
18621
  Throws `LinguaDisposedError` if called on a disposed instance.
17972
18622
 
@@ -18090,13 +18740,13 @@ isLoaded(locale: Locale): boolean
18090
18740
 
18091
18741
  Returns `true` if the catalog for `locale` is fully resolved (i.e. not a pending async loader). Returns `false` for unregistered locales, pending loaders, and invalid locale tags — never throws.
18092
18742
 
18093
- Primary use case: guarding `serializeI18n()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded.
18743
+ Primary use case: guarding `getState()` in SSR to avoid silently omitting locales that were registered as async loaders but never preloaded.
18094
18744
 
18095
18745
  ```ts
18096
18746
  // SSR guard — ensure all locales are loaded before serialising
18097
18747
  const locales = i18n.getSupportedLocales();
18098
18748
  await Promise.all(locales.filter((l) => !i18n.isLoaded(l)).map((l) => i18n.preload(l)));
18099
- const state = serializeI18n(i18n); // now includes all locales
18749
+ const state = i18n.getState(); // now includes all locales
18100
18750
  ```
18101
18751
 
18102
18752
  ### `isRegistered()`
@@ -18118,7 +18768,7 @@ Use `isRegistered` + `isLoaded` together to distinguish the three states:
18118
18768
  ```ts
18119
18769
  if (!i18n.isRegistered('fr')) throw new Error('fr locale not configured');
18120
18770
  if (!i18n.isLoaded('fr')) await i18n.preload('fr');
18121
- const state = serializeI18n(i18n); // 'fr' guaranteed to be present
18771
+ const state = i18n.getState(); // 'fr' guaranteed to be present
18122
18772
  ```
18123
18773
 
18124
18774
  ### `disposalSignal`
@@ -18202,6 +18852,8 @@ Checks a flat or nested message catalog against CLDR plural rules for `locale`.
18202
18852
 
18203
18853
  Returns an empty array when there are no issues.
18204
18854
 
18855
+ **Automatic dev-mode checks:** `createI18n()` already calls this internally, in dev builds only, every time a catalog becomes fully available — at construction (`createI18n({ catalogs })`), via `register()`, or once an async loader resolves — logging any warning through `console.warn`. Call `validateCatalog()` directly only when you want CI to fail the build on a warning rather than just log it; the automatic check already covers everyday authoring feedback with zero setup. The automatic check loads `validate.ts`'s logic as a separate, lazily-fetched chunk — it's never part of your production bundle either way.
18856
+
18205
18857
  **Note:** A branch is treated as a plural branch when any of its child keys is a CLDR form (`zero`, `one`, `two`, `few`, `many`, `other`). A mixed-use branch (e.g. `{ count: 'x', one: 'y' }`) will also be flagged and may produce spurious warnings for non-CLDR sibling keys.
18206
18858
 
18207
18859
  `validateCatalog` also checks for a common authoring error: a form template for `other`, `two`, `few`, or `many` that does not contain `{count}`. Since `tp()` injects `count` automatically, omitting it from a non-singleton form is almost always a mistake. These warnings use `form: ':missing-count'` (e.g. `'other:missing-count'`). The `zero` and `one` forms are exempt — intentionally omitting `{count}` is normal there (e.g. `'No messages'`, `'One message'`).
@@ -18321,7 +18973,7 @@ type I18nState = {
18321
18973
  };
18322
18974
  ```
18323
18975
 
18324
- Produced by `getState()` / `serializeI18n()` and consumed by `restoreState()` / `hydrateI18n()`. Catalogs are stored as flat dot-notation maps.
18976
+ Produced by `getState()` and consumed by `restoreState()`. Catalogs are stored as flat dot-notation maps.
18325
18977
 
18326
18978
  ### `NamespaceFactory`
18327
18979
 
@@ -18480,50 +19132,25 @@ type ListFormatOptions = {
18480
19132
  };
18481
19133
  ```
18482
19134
 
18483
- ## serializeI18n
19135
+ ## SSR: `getState()` / `restoreState()`
18484
19136
 
18485
- ```ts
18486
- import { serializeI18n } from '@vielzeug/lingua';
18487
-
18488
- serializeI18n(i18n: I18n): I18nState
18489
- ```
18490
-
18491
- Serialises the current loaded catalogs and active locale into an `I18nState` object. Use this on the server before embedding state in the HTML response. Loader-only locales that have not been preloaded are silently omitted — call `isLoaded()` to verify all locales are resolved before calling `serializeI18n()`.
19137
+ No standalone functions — call these directly on an instance (see [`getState()`](#getstate) / [`restoreState()`](#restorestate) above).
18492
19138
 
18493
19139
  ```ts
18494
19140
  // Server
18495
19141
  const i18n = createI18n({ catalogs: { de: deMessages, en: enMessages }, locale: 'de' });
18496
- const state = serializeI18n(i18n);
19142
+ const state = i18n.getState();
18497
19143
  // Embed in the HTML response:
18498
19144
  // window.__I18N__ = ${JSON.stringify(state)}
18499
19145
  ```
18500
19146
 
18501
- ## hydrateI18n
18502
-
18503
- ```ts
18504
- import { hydrateI18n } from '@vielzeug/lingua';
18505
-
18506
- hydrateI18n(i18n: I18n, state: I18nState): void
18507
- ```
18508
-
18509
- Hydrates a client-side instance from server-serialised state. Replaces all catalogs and switches the active locale. Notifies subscribers once after hydration.
18510
-
18511
- Throws `LinguaRestoreError` if `state.locale` has no corresponding entry in `state.catalogs`.
18512
-
18513
19147
  ```ts
18514
19148
  // Client
18515
- const i18n = createI18n();
18516
- hydrateI18n(i18n, window.__I18N__);
19149
+ const client = createI18n();
19150
+ client.restoreState(window.__I18N__);
18517
19151
  // Catalogs from state are immediately available; no network request needed.
18518
19152
  ```
18519
19153
 
18520
- **Parameters:**
18521
-
18522
- | Parameter | Type | Description |
18523
- | --------- | ----------- | ------------------------------------------ |
18524
- | `i18n` | `I18n` | The instance to hydrate. |
18525
- | `state` | `I18nState` | State object produced by `serializeI18n()` |
18526
-
18527
19154
  ## Error Classes
18528
19155
 
18529
19156
  All errors thrown by the `@vielzeug/lingua` runtime extend `LinguaError`. Use `instanceof LinguaError` to catch any lingua error, or `instanceof` the specific subclass for precise handling.
@@ -18551,7 +19178,7 @@ try {
18551
19178
  | `LinguaMissingLocaleError` | `preload()` / `setLocale()` — locale has no registered source |
18552
19179
  | `LinguaInvalidLocaleError` | Any API receiving an invalid BCP 47 tag |
18553
19180
  | `LinguaNamespaceMissingError` | Namespace requested but not loaded for the current locale |
18554
- | `LinguaRestoreError` | `hydrateI18n()` / `restoreState()` — `state.locale` absent from `state.catalogs` |
19181
+ | `LinguaRestoreError` | `restoreState()` — `state.locale` absent from `state.catalogs` |
18555
19182
 
18556
19183
  ### Usage Guide
18557
19184
 
@@ -18715,7 +19342,9 @@ Without `onMissingKey`, missing keys return the key string. Without `onMissingVa
18715
19342
 
18716
19343
  ## Validating Catalogs
18717
19344
 
18718
- Use `validateCatalog()` during development or CI to detect plural branches that are missing CLDR forms for a target locale. Import it from the dedicated `@vielzeug/lingua/validate` entry never from the main entry or it will end up in your production bundle.
19345
+ `createI18n()` already runs this check automatically in dev builds — every time a catalog becomes available (construction, `register()`, or an async loader resolving), it's checked against CLDR plural rules and any issue is logged via `console.warn`. This costs nothing in production: the check runs behind the same dev-only gate as the rest of `@vielzeug/lingua`'s dev warnings, and the validation logic itself only loads as a separate on-demand chunk, never bundled into your app.
19346
+
19347
+ For CI enforcement (failing a build rather than just warning), call `validateCatalog()` directly. Import it from the dedicated `@vielzeug/lingua/validate` entry — never from the main entry or it will end up in your production bundle.
18719
19348
 
18720
19349
  ```ts
18721
19350
  import { validateCatalog } from '@vielzeug/lingua/validate';
@@ -18754,7 +19383,7 @@ Key characteristics:
18754
19383
 
18755
19384
  ## SSR Hydration
18756
19385
 
18757
- Prefer the instance methods `getState()` and `restoreState()` they are equivalent to `serializeI18n` / `hydrateI18n` but require no extra imports:
19386
+ Use the instance methods `getState()` on the server and `restoreState()` on the client:
18758
19387
 
18759
19388
  ```ts
18760
19389
  import { createI18n } from '@vielzeug/lingua';
@@ -18771,7 +19400,7 @@ i18n.restoreState(window.__I18N__);
18771
19400
  // Catalogs from state are immediately available; no network request needed.
18772
19401
  ```
18773
19402
 
18774
- `restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once. The free functions `serializeI18n()` and `hydrateI18n()` are equivalent alternatives.
19403
+ `restoreState()` replaces all catalogs, switches the active locale, clears namespace loaded-markers, and notifies subscribers once.
18775
19404
 
18776
19405
  **Warning:** `getState()` silently omits locales registered as async loaders but not yet preloaded. Use `isLoaded()` to guard:
18777
19406
 
@@ -18916,12 +19545,12 @@ router.subscribe(() => {
18916
19545
  - Keep translation keys flat or one level deep — deeply nested keys are harder to refactor.
18917
19546
  - Set `fallback` to a locale with 100% coverage so missing keys degrade gracefully.
18918
19547
  - Use `extend(ns, factory, locale?)` or `registerNamespace()` + `loadNamespace()` for per-route or per-feature key sets.
18919
- - Use `isLoaded(locale)` before `getState()` / `serializeI18n()` in SSR to avoid silently omitting async-loader locales.
19548
+ - Use `isLoaded(locale)` before `getState()` in SSR to avoid silently omitting async-loader locales.
18920
19549
  - Use `isRegistered(locale)` to check if a locale is configured; use `isLoaded(locale)` to check if it is ready.
18921
19550
  - Call `dispose()` on route-level or request-scoped `fork()` instances when they are no longer needed.
18922
19551
  - Use `{ signal }` in `subscribe()` for lifecycle-safe subscriptions; use the returned `Unsubscribe` otherwise.
18923
19552
  - Use `onMissingKey` and `onMissingVar` in development to surface authoring errors early; omit them in production.
18924
- - Import `validateCatalog` from `@vielzeug/lingua/validate` in CI only never in application code.
19553
+ - `createI18n()` already validates plural forms automatically in dev builds — only import `validateCatalog` from `@vielzeug/lingua/validate` directly if you want CI to fail the build on a warning.
18925
19554
  - Share one `i18n` instance per app entry point; avoid creating separate instances per component.
18926
19555
 
18927
19556
  ### Examples
@@ -18955,7 +19584,7 @@ router.subscribe(() => {
18955
19584
  - Pluralization Rules (id: `pluralization`)
18956
19585
  - Preload Pattern (id: `preload-pattern`)
18957
19586
  - scope(), has(), isLoaded(), extend() (id: `scope-bind`)
18958
- - serializeI18n() / hydrateI18n() — SSR hydration (id: `ssr-hydration`)
19587
+ - getState() / restoreState() — SSR hydration (id: `ssr-hydration`)
18959
19588
  - createFormatter() — standalone (no createI18n) (id: `standalone-formatter`)
18960
19589
  - Variable Interpolation (id: `variable-interpolation`)
18961
19590
 
@@ -20718,11 +21347,13 @@ function useFloat(referenceRef: { value: HTMLElement | null }, floatingRef: { va
20718
21347
  Use Orbit inside a Ore component to position tooltips and popovers reactively.
20719
21348
 
20720
21349
  ```ts
20721
- import { define, html } from '@vielzeug/ore';
21350
+ import { define, getHost, html, onMounted } from '@vielzeug/ore';
20722
21351
  import { flip, float, offset, shift } from '@vielzeug/orbit';
20723
21352
 
20724
21353
  define('x-tooltip', {
20725
- setup(_props, { el, onMounted }) {
21354
+ setup(_props) {
21355
+ const el = getHost();
21356
+
20726
21357
  onMounted(() => {
20727
21358
  const tooltipEl = el.querySelector('[role=tooltip]')!;
20728
21359
 
@@ -20783,7 +21414,7 @@ define('x-tooltip', {
20783
21414
 
20784
21415
  **Category:** ui-primitives
20785
21416
  **Keywords:** web-components, custom-elements, reactive, templates, signals, lifecycle
20786
- **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, useField, createFormContext, FORM_CONTEXT_KEY, createId (+5 more)
21417
+ **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+15 more)
20787
21418
  **Related:** ripple, refine, orbit
20788
21419
 
20789
21420
  ### Overview
@@ -20854,7 +21485,7 @@ yarn add @vielzeug/ore
20854
21485
 
20855
21486
  ```ts
20856
21487
  import { computed, signal } from '@vielzeug/ripple';
20857
- import { css, define, html, prop } from '@vielzeug/ore';
21488
+ import { bind, css, define, html, onMounted, prop } from '@vielzeug/ore';
20858
21489
 
20859
21490
  define('my-counter', {
20860
21491
  props: {
@@ -20869,7 +21500,7 @@ define('my-counter', {
20869
21500
  }
20870
21501
  `,
20871
21502
  ],
20872
- setup(props, { bind, onMounted }) {
21503
+ setup(props) {
20873
21504
  const count = signal(0);
20874
21505
  const doubled = computed(() => count.value * 2);
20875
21506
 
@@ -20890,12 +21521,13 @@ define('my-counter', {
20890
21521
  - Signal-first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly
20891
21522
  - Functional component authoring via `define(tag, { props, setup, styles, formAssociated })`
20892
21523
  - Props via `prop.*` helpers (`prop.string`, `prop.number`, `prop.bool`, `prop.oneOf`, `prop.json`, `prop.data`) or raw `PropDef` objects
20893
- - Setup returns an `HTMLResult` directly: `return html\`...\``
20894
- - Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `effect` — accessed through the setup context bag (`ctx`)
21524
+ - `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
21525
+ - Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
20895
21526
  - Directives: `each` (keyed reactive list rendering), `classMap`, `styleMap`, `when`, `model`, `raw`
20896
- - Host bindings via `ctx.bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element
20897
- - Reactive ARIA sync via `ctx.aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect
20898
- - Form-associated helpers: `useField()`, `createFormContext()` first-class public APIs for form-aware components
21527
+ - Host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element
21528
+ - Reactive ARIA sync via `aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect
21529
+ - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
21530
+ - Form-associated helpers (`@vielzeug/ore/forms`): `useField()`, `createFormContext()`
20899
21531
  - Observers (`@vielzeug/ore/observers`)
20900
21532
  - Testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderHook`, `fire`, `user`, `waitFor`, `cleanup`
20901
21533
  - Debug utilities (`@vielzeug/ore/devtools`) — `debugFlush()` for diagnosing update timing
@@ -20904,9 +21536,10 @@ define('my-counter', {
20904
21536
 
20905
21537
  | Import | Purpose |
20906
21538
  | --------------------------- | ----------------------------------------------------------------------------- |
20907
- | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context, forms) |
21539
+ | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context), plus the everyday template directives (`each`, `when`, `model`, `classMap`, `styleMap`) |
20908
21540
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) |
20909
- | `@vielzeug/ore/directives` | `each`, `when`, `model`, `live`, `raw`, `classMap`, `styleMap` |
21541
+ | `@vielzeug/ore/directives` | The advanced/niche directives (`live`, `raw`) and the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
21542
+ | `@vielzeug/ore/forms` | `useField`, `createFormContext`, `FORM_CONTEXT_KEY` |
20910
21543
  | `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` |
20911
21544
  | `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers |
20912
21545
 
@@ -20926,31 +21559,40 @@ define('my-counter', {
20926
21559
 
20927
21560
  ## API Overview
20928
21561
 
20929
- | Symbol | Purpose | Execution mode | Common gotcha |
20930
- | -------------------------- | ---------------------------------------------------- | -------------- | ---------------------------------------------------------------------- |
20931
- | `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use |
20932
- | `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives |
20933
- | `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals — read `.value` |
20934
- | `ctx.provide/inject` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` |
20935
- | `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount |
20936
- | `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree |
20937
- | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render |
20938
- | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
20939
- | `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` |
20940
- | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
20941
- | `ctx.onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
20942
- | `ctx.onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
20943
- | `ctx.onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
20944
- | `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition |
20945
- | `ctx.aria(target, config)` | Reactively sync ARIA attributes to any element | Setup only | Static values applied once; getter functions tracked as effects; auto-cleanup on disconnect |
21562
+ All symbols below (except `useField`/`createFormContext`, under `@vielzeug/ore/forms`) are plain functions imported from `@vielzeug/ore`. Lifecycle/context/binding functions (`onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect`, `bind`, `aria`, `provide`, `useEmit`, `useSlots`, `getHost`) resolve the active component through an implicit "current component" context — they work when called synchronously during `setup()`, or from any composable function `setup()` calls (transitively), but throw if called outside that window.
21563
+
21564
+ > `watchEffect` is not named `watch` — `@vielzeug/ripple` already exports a `watch(source, callback)` with different semantics (explicit source + old/new value pair), and the two are frequently imported in the same file.
21565
+
21566
+ | Symbol | Purpose | Execution mode | Common gotcha |
21567
+ | ---------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------------------- |
21568
+ | `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use |
21569
+ | `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives |
21570
+ | `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals read `.value` |
21571
+ | `provide()`/`inject()` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` |
21572
+ | `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount |
21573
+ | `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree |
21574
+ | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render |
21575
+ | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
21576
+ | `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` |
21577
+ | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
21578
+ | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
21579
+ | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
21580
+ | `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
21581
+ | `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition; `@vielzeug/ore/forms` |
21582
+ | `onFormReset(fn)` | Run work when the ancestor `` resets | Setup only | Fires every reset (not one-shot); only for `formAssociated: true` components |
21583
+ | `aria(target, config)` | Reactively sync ARIA attributes to any element | Setup only | Static values applied once; getter functions tracked as effects; auto-cleanup on disconnect |
21584
+ | `useEmit()` | Typed `emit()` bound to the current host | Setup only | Call once per component; returns `dispatchEvent`'s boolean (`false` if a listener called `preventDefault()`) |
21585
+ | `useSlots()`| Reactive slot presence/element signals | Setup only | Safe to call more than once — the underlying registry is created once |
21586
+ | `getHost()` | The current component's host element | Setup only | Prefer a higher-level helper (`bind`, `aria`, …) when one exists |
20946
21587
 
20947
21588
  ## Package Entry Points
20948
21589
 
20949
21590
  | Import | Purpose |
20950
21591
  | ---------------------------- | ------------------------------------------------------------------ |
20951
- | `@vielzeug/ore` | Core authoring/runtime API |
21592
+ | `@vielzeug/ore` | Core authoring/runtime API, including the everyday template directives (`each`, `when`, `classMap`, `styleMap`, `model`) |
20952
21593
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics |
20953
- | `@vielzeug/ore/directives` | Standalone directive imports (`each`, `when`, `classMap`, ) |
21594
+ | `@vielzeug/ore/directives` | The advanced/niche directives (`raw`, `live`) plus the custom-directive authoring API (`createDirectiveResult`, `createSpreadObject`) |
21595
+ | `@vielzeug/ore/forms` | Form-association helpers (`useField`, `createFormContext`) |
20954
21596
  | `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers |
20955
21597
  | `@vielzeug/ore/testing` | DOM-oriented test helpers |
20956
21598
 
@@ -20962,35 +21604,36 @@ define('my-counter', {
20962
21604
  define(tag: string, definition: ComponentDefinition): void;
20963
21605
  ```
20964
21606
 
20965
- The `setup()` function receives typed prop signals and a context bag:
21607
+ The `setup()` function receives only typed prop signals:
20966
21608
 
20967
21609
  ```ts
20968
- type SetupContextBag = {
20969
- aria: (target: Element, config: AriaConfig) => () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
20970
- bind: HostBindFn; // Apply reactive bindings to the host or any target element
20971
- el: HTMLElement; // The host element
20972
- emit: EmitFn; // Dispatch typed custom events
20973
- inject: (key: InjectionKey, fallback?: T) => T | undefined; // Resolve context from nearest ancestor
20974
- onCleanup: (fn: CleanupFn) => void; // Register teardown; called on disconnect
20975
- onElement: (ref, cb) => void; // Run callback when a ref resolves to an element
20976
- onEvent: (target, event, listener, options?) => void; // Scoped event listener; auto-removed on disconnect
20977
- onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback
20978
- provide: (key: InjectionKey, value: T) => void; // Register context on the host element
20979
- slots: ComponentSlots; // Reactive slot signals
20980
- watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect
20981
- };
21610
+ setup(props) {
21611
+ return html`${props.label}`;
21612
+ }
20982
21613
  ```
20983
21614
 
20984
- Lifecycle hooks (`onMounted`, `onCleanup`, `onEvent`, `onElement`, `watch`) are accessed exclusively through the setup context bag. They must be called synchronously during `setup()`.
20985
-
20986
- `setup()` returns an `HTMLResult` directly (not a function):
21615
+ Everything else lifecycle hooks, host bindings, context, slots, emit is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):
20987
21616
 
20988
21617
  ```ts
20989
- setup(props, ctx) {
20990
- return html`${props.label}`;
20991
- }
21618
+ import { define, html, onMounted, useEmit, useSlots } from '@vielzeug/ore';
21619
+
21620
+ define('my-card', {
21621
+ setup(_props) {
21622
+ const emit = useEmit();
21623
+ const slots = useSlots();
21624
+
21625
+ onMounted(() => console.log('mounted'));
21626
+
21627
+ // emit() returns dispatchEvent's boolean — false if a listener called preventDefault()
21628
+ const notCancelled = emit('close');
21629
+
21630
+ return html`${when(slots.has('header'), () => html``)}`;
21631
+ },
21632
+ });
20992
21633
  ```
20993
21634
 
21635
+ `useEmit()` and `useSlots()` are factory hooks — call them once per component to get a typed `emit`/`slots` bound to the current host. `useSlots()` is safe to call more than once (the underlying slot registry is created once per instance and reused).
21636
+
20994
21637
  ### ComponentDefinition
20995
21638
 
20996
21639
  ```ts
@@ -20999,26 +21642,12 @@ type ComponentDefinition = {
20999
21642
  loading?: () => HTMLResult; // Template shown while async setup is pending
21000
21643
  onError?: (error: OreLifecycleError, element: HTMLElement) => HTMLResult | void;
21001
21644
  props?: PropsDef;
21002
- setup: (
21003
- props: InferProps>,
21004
- ctx: SetupContextBag,
21005
- ) => HTMLResult | Promise;
21645
+ setup: (props: InferProps>) => HTMLResult | Promise;
21006
21646
  shadow?: Partial | false; // false = light DOM (no shadow root)
21007
21647
  styles?: (string | CSSStyleSheet | CSSResult)[];
21008
21648
  };
21009
21649
  ```
21010
21650
 
21011
- Pass `SlotNames` as a type parameter to `define()` to get typed `ctx.slots` access:
21012
-
21013
- ```ts
21014
- define, Record, 'header' | 'footer'>('my-card', {
21015
- setup(_props, { slots }) {
21016
- const hasHeader = slots.has('header'); // typed ✓
21017
- return html`...`;
21018
- },
21019
- });
21020
- ```
21021
-
21022
21651
  #### Async setup
21023
21652
 
21024
21653
  When `setup()` returns a `Promise`, `loading()` is rendered immediately. The real template replaces it once the promise resolves.
@@ -21037,10 +21666,12 @@ define('user-profile', {
21037
21666
 
21038
21667
  ## Runtime Helpers
21039
21668
 
21040
- `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch` are available on the setup context bag. Destructure them from the second argument to `setup()`.
21669
+ `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watchEffect` are plain functions imported from `@vielzeug/ore`. Call them directly during `setup()`.
21041
21670
 
21042
21671
  ```ts
21043
- setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
21672
+ import { html, onCleanup, onEvent, onMounted } from '@vielzeug/ore';
21673
+
21674
+ setup(props) {
21044
21675
  onMounted(() => {
21045
21676
  // DOM is ready; return a function for mount-scoped cleanup
21046
21677
  return () => { /* cleanup on unmount */ };
@@ -21054,20 +21685,18 @@ setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
21054
21685
  }
21055
21686
  ```
21056
21687
 
21057
- When writing composable helpers called from inside `setup()`, thread the hooks explicitly via function parameters rather than relying on a shared context:
21688
+ Because these resolve the active component through an implicit context (rather than a value threaded through parameters), composable helper functions can call them directly too no need to pass hooks in as options:
21058
21689
 
21059
21690
  ```ts
21060
- type MyHelperOptions = {
21061
- onCleanup: (fn: () => void) => void;
21062
- };
21691
+ import { onCleanup } from '@vielzeug/ore';
21063
21692
 
21064
- function useMyHelper(options: MyHelperOptions) {
21065
- options.onCleanup(() => { /* teardown */ });
21693
+ function useMyHelper() {
21694
+ onCleanup(() => { /* teardown */ });
21066
21695
  }
21067
21696
 
21068
21697
  // In setup:
21069
- setup(_props, { onCleanup }) {
21070
- useMyHelper({ onCleanup });
21698
+ setup(_props) {
21699
+ useMyHelper();
21071
21700
  return html`...`;
21072
21701
  }
21073
21702
  ```
@@ -21149,7 +21778,7 @@ Event bindings support dot-separated modifiers: `@click.prevent.stop=${handler}`
21149
21778
 
21150
21779
  ## Host Bindings
21151
21780
 
21152
- The setup context provides `bind: HostBindFn`:
21781
+ `bind(config, options?)` is a plain function imported from `@vielzeug/ore`:
21153
21782
 
21154
21783
  ```ts
21155
21784
  bind({
@@ -21175,9 +21804,9 @@ bind(
21175
21804
 
21176
21805
  Event listener options (`once`, `capture`, `passive`) are also accepted in the second argument. Cleanup is auto-registered with the component scope when called during setup.
21177
21806
 
21178
- ### ctx.aria()
21807
+ ### aria()
21179
21808
 
21180
- For reactive ARIA attribute syncing, use `ctx.aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim):
21809
+ For reactive ARIA attribute syncing, use `aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim):
21181
21810
 
21182
21811
  ```ts
21183
21812
  // Inside setup — cleanup auto-registered
@@ -21204,22 +21833,24 @@ Slot signals update reactively when assigned content changes, including when slo
21204
21833
  ## Context API
21205
21834
 
21206
21835
  - `createContext(description?)` — Create a typed injection key
21207
- - `ctx.provide(key, value)` — Provide a value to descendants; called via the setup context bag
21836
+ - `provide(key, value)` — Provide a value to descendants
21208
21837
  - `inject(key)` — Resolve from nearest ancestor; returns `undefined` if not found
21209
21838
  - `inject(key, fallback)` — Resolve with a fallback value
21210
21839
  - `injectStrict(key)` — Resolve or throw if absent
21211
21840
 
21212
- `ctx.provide()` and `inject()` must be called synchronously during `setup()`. Calling them outside a setup context throws `'Lifecycle hooks must be called synchronously during component setup'`. Context resolution walks the ancestor chain including shadow DOM boundaries.
21841
+ `provide()` and `inject()` must be called synchronously during `setup()`. Calling them outside a setup context throws `'Lifecycle hooks must be called synchronously during component setup'`. Context resolution walks the ancestor chain including shadow DOM boundaries. `inject()` resolves and caches its result once per consumer — provide a `Readable` (signal/computed) rather than a raw value if descendants need to observe later changes; re-calling `provide()` with a new raw value afterward is not seen by consumers that already resolved it (a dev-mode warning fires when a key is provided twice on the same element).
21213
21842
 
21214
21843
  ## Utilities
21215
21844
 
21216
21845
  - `ref()` — Create a `Signal` element reference. Set to the element via `ref=` in templates.
21217
21846
  - `createId(prefix = 'id')` — Generate a unique incremental string ID (e.g. `'id-1'`, `'id-2'`). Each call returns a new ID — it does not deduplicate by prefix.
21218
21847
  - `createStableId(prefix = 'id')` — Generate a unique ID that also embeds a short random tag shared across all IDs generated in the session (e.g. `'field-a3k21'`), reducing collision risk when multiple app instances run on the same page. Like `createId()`, every call returns a new ID.
21219
- - `resetIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs.
21848
+ - `resetStableIdCounter()` — Reset the `createStableId()` counter to 0. Call in test `beforeEach` for deterministic IDs. Scoped to `createStableId()` only — `createId()` has no public reset (it's for uniqueness, not cross-test determinism).
21220
21849
 
21221
21850
  ## Form-Associated API
21222
21851
 
21852
+ Import from `@vielzeug/ore/forms`.
21853
+
21223
21854
  ### `useField(options)`
21224
21855
 
21225
21856
  Wire a form-associated element to `ElementInternals`. Requires `formAssociated: true` on the component definition. The `disabled` state tracking via `internals.states` (CustomStateSet) is skipped with a dev warning if the API is unavailable in the current environment.
@@ -21234,7 +21865,12 @@ type FormFieldOptions = {
21234
21865
  * @default false
21235
21866
  */
21236
21867
  emptyStringForNull?: boolean;
21868
+ /** Called when the ancestor resets (see onFormReset) — restore local field state here. */
21869
+ onReset?: () => void;
21237
21870
  toFormValue?: (value: T) => File | FormData | string | null;
21871
+ /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
21872
+ validationMessage?: Readable;
21873
+ validity?: Readable;
21238
21874
  value: Signal | Readable;
21239
21875
  };
21240
21876
 
@@ -21247,11 +21883,23 @@ type FormFieldHandle = {
21247
21883
  };
21248
21884
  ```
21249
21885
 
21886
+ Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation (`checkValidity()`/`reportValidity()`, and ``'s submit blocking):
21887
+
21888
+ ```ts
21889
+ const isBlank = (v: string) => v.trim() === '';
21890
+
21891
+ useField({
21892
+ validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
21893
+ validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
21894
+ value,
21895
+ });
21896
+ ```
21897
+
21250
21898
  ### Form Context
21251
21899
 
21252
21900
  Coordinate form state across child field components:
21253
21901
 
21254
- - `createFormContext(options?)` — Create a `FormController`; call `ctx.provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
21902
+ - `createFormContext(options?)` — Create a `FormController`; call `provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
21255
21903
  - `FORM_CONTEXT_KEY` — the `InjectionKey` used to provide/inject the form context
21256
21904
 
21257
21905
  ```ts
@@ -21284,7 +21932,9 @@ Import from `@vielzeug/ore/testing`.
21284
21932
  | ------------------------ | ------------------------------------------------------------------------------------------ |
21285
21933
  | `mount(setup, options?)` | Mount a component and return a test fixture |
21286
21934
  | `cleanup()` | Remove all mounted elements and reset test state |
21287
- | `install(afterEach)` | Register auto-cleanup; pass `afterEach` from your test framework |
21935
+ | `install(afterEach)` | Register auto-cleanup and the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below); pass `afterEach` from your test framework |
21936
+ | `installFormInternalsPolyfill()` | Called automatically by `install()`. Call directly only if you need the polyfill without auto-cleanup |
21937
+ | `walkFlatTree(root, visit)` | Walks the flat tree (expanding `` via `assignedElements()`) — for finding slotted content across a shadow boundary that `querySelectorAll()` can't cross |
21288
21938
  | `flush(options?)` | Drain reactive updates and animation frames |
21289
21939
  | `FLUSH_DEEP` | Pre-built options for deep async chains (`maxTurns: 12`) |
21290
21940
  | `mock(tag, template?)` | Register a no-op stub custom element |
@@ -21297,6 +21947,8 @@ Import from `@vielzeug/ore/testing`.
21297
21947
 
21298
21948
  > **Test isolation:** `cleanup()` resets mounted elements, `live()` signal tracking, and the raw HTML sanitizer. Call it in `afterEach` to prevent state leaking between tests.
21299
21949
 
21950
+ > **Form-associated component testing:** jsdom implements none of the `ElementInternals` form-association API — `install()` polyfills `setFormValue`/`setValidity`/`checkValidity`/`reportValidity`/`validationMessage`/`states`, mixes `checkValidity`/`reportValidity`/`validity`/`validationMessage` onto the host element itself (real browsers do this for any `formAssociated: true` element), makes `FormData` collect a form-associated element's set value, and makes `.reset()` invoke `formResetCallback()`. Every patch is a guarded no-op when its target already exists, so it's safe to call `install()` even in a suite with no form-associated components — and safe for a downstream package (e.g. a component library built on `ore`) to rely on instead of hand-rolling its own copy.
21951
+
21300
21952
  #### `Fixture` interface
21301
21953
 
21302
21954
  ```ts
@@ -21321,11 +21973,11 @@ interface Fixture {
21321
21973
 
21322
21974
  #### `renderHook`
21323
21975
 
21324
- Useful for testing composable lifecycle hooks (`onMounted`, `effect`, `inject`, etc.) without a template:
21976
+ Useful for testing composable lifecycle hooks (`onMounted`, `watchEffect`, `inject`, etc.) without a template. `onMounted`/`onCleanup`/`watchEffect`/... work exactly as inside a real `setup()`, since they resolve the same implicit current-component context:
21325
21977
 
21326
21978
  ```ts
21327
21979
  // Without props
21328
- const { result, flush, dispose } = await renderHook((_props, { onMounted }) => {
21980
+ const { result, flush, dispose } = await renderHook(() => {
21329
21981
  const count = signal(0);
21330
21982
  onMounted(() => {
21331
21983
  count.value = 1;
@@ -21374,48 +22026,32 @@ type InferProps = {
21374
22026
  readonly [K in keyof D]-?: Readable>;
21375
22027
  };
21376
22028
 
21377
- type SetupContextBag = Record,
21378
- SlotNames extends string = string,
21379
- > = {
21380
- aria: (target: Element, config: AriaConfig) => () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
21381
- bind: (config: HostBindConfig, options?: BindOptions) => () => void; // Bindings for host or any target element
21382
- el: HTMLElement; // The host element
21383
- emit: EmitFn; // Dispatch typed custom events
21384
- inject: {
21385
- (key: InjectionKey): T | undefined;
21386
- (key: InjectionKey, fallback: T): T;
21387
- };
21388
- onCleanup: (fn: CleanupFn) => void; // Register teardown; called on disconnect
21389
- onElement: (ref: Readable, cb: (el: T) => CleanupFn | void) => () => void;
21390
- onEvent: {
21391
- (
21392
- target: EventTarget | null | undefined,
21393
- event: K,
21394
- listener: (e: HTMLElementEventMap[K]) => void,
21395
- options?: AddEventListenerOptions,
21396
- ): void;
21397
- (
21398
- target: EventTarget | null | undefined,
21399
- event: string,
21400
- listener: EventListener,
21401
- options?: AddEventListenerOptions,
21402
- ): void;
21403
- };
21404
- onMounted: (fn: OnMountedCallback) => void; // DOM-ready callback; runs after first render
21405
- provide: (key: InjectionKey, value: T) => void; // Register a context value on the host element
21406
- slots: ComponentSlots; // Reactive slot signals
21407
- watch: (fn: EffectCallback) => () => void; // Scoped reactive effect; auto-cleaned on disconnect
21408
- };
22029
+ // Runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.
22030
+ declare function onMounted(fn: OnMountedCallback): void; // DOM-ready callback; runs after first render
22031
+ declare function onCleanup(fn: CleanupFn): void; // Register teardown; called on disconnect
22032
+ declare function onElement(ref: Readable, cb: (el: T) => CleanupFn | void): () => void;
22033
+ declare function onEvent(
22034
+ target: EventTarget | null | undefined,
22035
+ event: string,
22036
+ listener: EventListener,
22037
+ options?: AddEventListenerOptions,
22038
+ ): void;
22039
+ declare function onFormReset(fn: () => void): void; // Runs on every ancestor reset; formAssociated only
22040
+ declare function watchEffect(fn: EffectCallback): () => void; // Scoped reactive effect; auto-cleaned on disconnect
22041
+ declare function bind(config: HostBindConfig, options?: BindOptions): () => void; // Bindings for host or any target element
22042
+ declare function aria(target: Element, config: AriaConfig): () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
22043
+ declare function provide(key: InjectionKey, value: T): void; // Register a context value on the host element
22044
+ declare function inject(key: InjectionKey, fallback?: T): T | undefined;
22045
+ declare function getHost(): HTMLElement; // The current component's host element
22046
+ declare function useEmit = Record>(): EmitFn;
22047
+ declare function useSlots(): ComponentSlots;
21409
22048
 
21410
22049
  type ComponentDefinition = {
21411
22050
  formAssociated?: boolean;
21412
22051
  loading?: () => HTMLResult; // Shown while async setup is pending
21413
22052
  onError?: (error: OreLifecycleError, el: HTMLElement) => HTMLResult | void;
21414
22053
  props?: PropsDef;
21415
- setup: (
21416
- props: InferProps>,
21417
- ctx: SetupContextBag,
21418
- ) => HTMLResult | Promise;
22054
+ setup: (props: InferProps>) => HTMLResult | Promise;
21419
22055
  shadow?: Partial | false; // false = light DOM
21420
22056
  styles?: (string | CSSStyleSheet | CSSResult)[];
21421
22057
  };
@@ -21478,15 +22114,19 @@ define('status-chip', {
21478
22114
  });
21479
22115
  ```
21480
22116
 
21481
- The setup context bag provides `el`, `aria`, `bind`, `emit`, `slots`, `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch`:
22117
+ Everything besides `props` lifecycle hooks, host bindings, context, slots, emit is a plain function imported from `@vielzeug/ore`, called directly from `setup()` (or a composable it calls):
21482
22118
 
21483
22119
  ```ts
22120
+ import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore';
22121
+
21484
22122
  define('my-widget', {
21485
- setup(_props, { el, bind, emit, slots }) {
21486
- // el the host HTMLElement
21487
- // bind host binding helper (attr, class, style, on)
21488
- // emit typed event emitter
21489
- // slots — reactive slot observation
22123
+ setup(_props) {
22124
+ const el = getHost(); // the host HTMLElement
22125
+ const emit = useEmit(); // typed event emitter
22126
+ const slots = useSlots(); // reactive slot observation
22127
+
22128
+ bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)
22129
+
21490
22130
  return html``;
21491
22131
  },
21492
22132
  });
@@ -21518,16 +22158,17 @@ batch(() => {
21518
22158
 
21519
22159
  ## onMounted and lifecycle
21520
22160
 
21521
- Use `ctx.onMounted()` for DOM-dependent initialization that must run after the template is mounted. Use `ctx.onElement(ref, cb)` for work tied to a specific DOM node. `ctx.onEvent()` attaches a listener that is automatically removed on disconnect.
22161
+ Use `onMounted()` for DOM-dependent initialization that must run after the template is mounted. Use `onElement(ref, cb)` for work tied to a specific DOM node. `onEvent()` attaches a listener that is automatically removed on disconnect.
21522
22162
 
21523
22163
  ```ts
21524
22164
  import { signal } from '@vielzeug/ripple';
21525
- import { define, html, ref } from '@vielzeug/ore';
22165
+ import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore';
21526
22166
 
21527
22167
  define('deferred-init', {
21528
- setup(_props, { slots, onMounted, onElement, onEvent }) {
22168
+ setup(_props) {
21529
22169
  const tabIndex = signal(0);
21530
22170
  const inputRef = ref();
22171
+ const slots = useSlots();
21531
22172
 
21532
22173
  onMounted(() => {
21533
22174
  const items = slots.elements('items').value;
@@ -21596,12 +22237,11 @@ define('profile-name', {
21596
22237
 
21597
22238
  ## directives
21598
22239
 
21599
- Ore includes `each`, `classMap`, `styleMap`, `when`, `live`, and `raw` import them from `@vielzeug/ore/directives`.
22240
+ Ore includes `each`, `classMap`, `styleMap`, `when`, and `model` — the everyday template directives most components need, imported directly from `@vielzeug/ore` alongside `define`/`html`. The advanced/niche ones (`live`, `raw`) live behind a separate `@vielzeug/ore/directives` import — see below.
21600
22241
 
21601
22242
  ```ts
21602
22243
  import { signal } from '@vielzeug/ripple';
21603
- import { classMap, each, styleMap, when } from '@vielzeug/ore/directives';
21604
- import { define, html } from '@vielzeug/ore';
22244
+ import { classMap, define, each, html, styleMap, when } from '@vielzeug/ore';
21605
22245
 
21606
22246
  define('task-list', {
21607
22247
  setup() {
@@ -21667,14 +22307,14 @@ define('live-search', {
21667
22307
 
21668
22308
  ## host bindings
21669
22309
 
21670
- The setup context provides `bind` for wiring the host element.
22310
+ `bind()` wires reactive attrs, classes, styles, and events to the host element.
21671
22311
 
21672
22312
  ```ts
21673
22313
  import { signal } from '@vielzeug/ripple';
21674
- import { define, html } from '@vielzeug/ore';
22314
+ import { bind, define, html } from '@vielzeug/ore';
21675
22315
 
21676
22316
  define('x-toggle', {
21677
- setup(_props, { bind }) {
22317
+ setup(_props) {
21678
22318
  const open = signal(false);
21679
22319
 
21680
22320
  bind({
@@ -21692,14 +22332,14 @@ The `bind` config supports `attr`, `class`, `style`, and `on` sections.
21692
22332
 
21693
22333
  ## ARIA bindings
21694
22334
 
21695
- Use `ctx.aria(target, config)` to reactively sync ARIA attributes to any element. Shorthand keys are normalised to `aria-*` automatically — `expanded` becomes `aria-expanded`, `role` is set verbatim.
22335
+ Use `aria(target, config)` to reactively sync ARIA attributes to any element. Shorthand keys are normalised to `aria-*` automatically — `expanded` becomes `aria-expanded`, `role` is set verbatim.
21696
22336
 
21697
22337
  ```ts
21698
22338
  import { signal } from '@vielzeug/ripple';
21699
- import { define, html } from '@vielzeug/ore';
22339
+ import { aria, bind, define, html, onMounted } from '@vielzeug/ore';
21700
22340
 
21701
22341
  define('x-disclosure', {
21702
- setup(_props, { aria, bind, onMounted }) {
22342
+ setup(_props) {
21703
22343
  const open = signal(false);
21704
22344
  const panelId = 'disclosure-panel';
21705
22345
 
@@ -21745,10 +22385,10 @@ Pass `{ target: el }` as a second argument to bind attributes, classes, styles,
21745
22385
 
21746
22386
  ```ts
21747
22387
  import { signal } from '@vielzeug/ripple';
21748
- import { define, html, ref } from '@vielzeug/ore';
22388
+ import { bind, define, html, onMounted, ref } from '@vielzeug/ore';
21749
22389
 
21750
22390
  define('button-wrapper', {
21751
- setup(_props, { bind, onMounted }) {
22391
+ setup(_props) {
21752
22392
  const visible = signal(false);
21753
22393
  const btnRef = ref();
21754
22394
 
@@ -21773,11 +22413,13 @@ define('button-wrapper', {
21773
22413
  ## slots and emits
21774
22414
 
21775
22415
  ```ts
21776
- import { when } from '@vielzeug/ore/directives';
21777
- import { define, html } from '@vielzeug/ore';
22416
+ import { define, html, useEmit, useSlots, when } from '@vielzeug/ore';
22417
+
22418
+ define('card-with-footer', {
22419
+ setup(_props) {
22420
+ const slots = useSlots();
22421
+ const emit = useEmit();
21778
22422
 
21779
- define, Record, 'header' | 'footer'>('card-with-footer', {
21780
- setup(_props, { slots, emit }) {
21781
22423
  return html`
21782
22424
 
21783
22425
 
@@ -21790,18 +22432,18 @@ define, Record, 'header' | 'footer'>('card-with-footer', {
21790
22432
  });
21791
22433
  ```
21792
22434
 
21793
- Pass `SlotNames` as a type parameter to `define()` to get typed `slots.has()` and `slots.elements()` calls.
22435
+ Pass a `SlotNames` type parameter to `useSlots()` to get typed `slots.has()` and `slots.elements()` calls.
21794
22436
 
21795
22437
  ## context provide/inject
21796
22438
 
21797
22439
  ```ts
21798
22440
  import { signal } from '@vielzeug/ripple';
21799
- import { createContext, define, html, injectStrict } from '@vielzeug/ore';
22441
+ import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore';
21800
22442
 
21801
22443
  const COUNT_CTX = createContext>>('count');
21802
22444
 
21803
22445
  define('count-provider', {
21804
- setup(_props, { provide }) {
22446
+ setup(_props) {
21805
22447
  const count = signal(0);
21806
22448
  provide(COUNT_CTX, count);
21807
22449
 
@@ -21822,7 +22464,8 @@ define('count-consumer', {
21822
22464
 
21823
22465
  ```ts
21824
22466
  import { signal } from '@vielzeug/ripple';
21825
- import { define, html, prop, useField } from '@vielzeug/ore';
22467
+ import { define, html, prop } from '@vielzeug/ore';
22468
+ import { useField } from '@vielzeug/ore/forms';
21826
22469
 
21827
22470
  define('rating-input', {
21828
22471
  formAssociated: true,
@@ -21862,15 +22505,15 @@ define('user-profile', {
21862
22505
 
21863
22506
  ## platform observers
21864
22507
 
21865
- Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `ctx.onMounted()`.
22508
+ Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `onMounted()`.
21866
22509
 
21867
22510
  ```ts
21868
22511
  import { effect } from '@vielzeug/ripple';
21869
- import { define, html, ref } from '@vielzeug/ore';
22512
+ import { define, html, onMounted, ref } from '@vielzeug/ore';
21870
22513
  import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/ore/observers';
21871
22514
 
21872
22515
  define('x-observed', {
21873
- setup(_props, { onMounted }) {
22516
+ setup(_props) {
21874
22517
  const boxRef = ref();
21875
22518
 
21876
22519
  onMounted(() => {
@@ -21988,10 +22631,11 @@ Use `@vielzeug/forge` for typed form state alongside Ore's `useField()` for form
21988
22631
  ```ts
21989
22632
  import { createForm } from '@vielzeug/forge';
21990
22633
  import { s } from '@vielzeug/spell';
21991
- import { createFormContext, define, html, FORM_CONTEXT_KEY } from '@vielzeug/ore';
22634
+ import { define, html, provide } from '@vielzeug/ore';
22635
+ import { createFormContext, FORM_CONTEXT_KEY } from '@vielzeug/ore/forms';
21992
22636
 
21993
22637
  define('signup-form', {
21994
- setup(_props, { provide }) {
22638
+ setup(_props) {
21995
22639
  const formCtx = createFormContext({
21996
22640
  onSubmit: async (e) => {
21997
22641
  e?.preventDefault();
@@ -22013,13 +22657,13 @@ define('signup-form', {
22013
22657
  ## Best Practices
22014
22658
 
22015
22659
  - Setup returns `html\`...\`` directly — not a function wrapping the template.
22016
- - Use `ctx.watch()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
22017
- - Use `ctx.onElement(ref, cb)` instead of `ctx.onMounted` when the work is tied to a single DOM node.
22018
- - Bind host attributes and classes via `ctx.bind()` rather than mutating the element directly.
22660
+ - Use `watchEffect()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
22661
+ - Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
22662
+ - Bind host attributes and classes via `bind()` rather than mutating the element directly.
22019
22663
  - Provide context at the nearest ancestor — avoid global context singletons.
22020
- - Call `ctx.onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
22664
+ - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
22021
22665
  - Use `live(signal)` for form inputs to prevent clobbering user-in-progress edits.
22022
- - Thread lifecycle hooks explicitly into composable helpers via function parameters do not rely on implicit module-level context.
22666
+ - Extract composable helper functions freely `onMounted`/`onCleanup`/`bind`/... resolve the active component through implicit context, so they work from any function called (transitively) during `setup()`, with no need to pass them in as parameters.
22023
22667
  - Test with `@vielzeug/ore/testing` helpers (`mount`, `flush`, `waitFor`) rather than direct DOM manipulation.
22024
22668
 
22025
22669
  ### Examples
@@ -25927,8 +26571,8 @@ When the buffer is full, the **oldest** frame is evicted to make room for the ne
25927
26571
 
25928
26572
  **Category:** ui-components
25929
26573
  **Keywords:** web-components, accessible, themeable, ui, components, design-system
25930
- **Key exports:** ore-accordion, ore-accordion-item, ore-alert, ore-async, ore-avatar, ore-avatar-group, ore-badge, ore-box, ore-breadcrumb, ore-breadcrumb-item, ore-button, ore-button-group (+48 more)
25931
- **Related:** ore, orbit, forge
26574
+ **Key exports:** ore-accordion, ore-accordion-item, ore-alert, ore-async, ore-avatar, ore-avatar-group, ore-badge, ore-box, ore-breadcrumb, ore-breadcrumb-item, ore-button, ore-button-group (+55 more)
26575
+ **Related:** ore, orbit, forge, keymap
25932
26576
 
25933
26577
  ### Overview
25934
26578
 
@@ -26034,17 +26678,17 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
26034
26678
 
26035
26679
  ### Components
26036
26680
 
26037
- **Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-icon`, `ore-pagination`, `ore-separator`, `ore-table`, `ore-text`
26681
+ **Content:** `ore-avatar`, `ore-avatar-group`, `ore-breadcrumb`, `ore-card`, `ore-carousel`, `ore-carousel-slide`, `ore-chat-message`, `ore-icon`, `ore-list`, `ore-list-item`, `ore-pagination`, `ore-separator`, `ore-table`, `ore-text`
26038
26682
 
26039
26683
  **Disclosure:** `ore-accordion`, `ore-accordion-item`, `ore-tabs`, `ore-tab-item`, `ore-tab-panel`
26040
26684
 
26041
- **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`
26685
+ **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
26042
26686
 
26043
- **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-form`, `ore-input`, `ore-number-input`, `ore-otp-input`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
26687
+ **Inputs:** `ore-button`, `ore-button-group`, `ore-calendar`, `ore-checkbox`, `ore-checkbox-group`, `ore-column`, `ore-combobox`, `ore-datagrid`, `ore-date-picker`, `ore-file-input`, `ore-form`, `ore-input`, `ore-message-composer`, `ore-number-input`, `ore-otp-input`, `ore-radio`, `ore-radio-group`, `ore-rating`, `ore-select`, `ore-slider`, `ore-switch`, `ore-textarea`, `ore-time-picker`
26044
26688
 
26045
26689
  **Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
26046
26690
 
26047
- **Overlay:** `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
26691
+ **Overlay:** `ore-command-palette`, `ore-command-palette-item`, `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
26048
26692
 
26049
26693
  ## Features
26050
26694
 
@@ -26072,6 +26716,7 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
26072
26716
  - [Ore](/ore/) — Web component runtime that powers Refine
26073
26717
  - [Orbit](/orbit/) — Floating UI positioning used in Refine's overlays
26074
26718
  - [Forge](/forge/) — Form state management for use with Refine inputs
26719
+ - [Keymap](/keymap/) — Keyboard shortcut manager that powers the command palette's global trigger
26075
26720
 
26076
26721
  ### API Reference
26077
26722
 
@@ -26131,11 +26776,13 @@ import '@vielzeug/refine/button-group';
26131
26776
  import '@vielzeug/refine/calendar';
26132
26777
  import '@vielzeug/refine/card';
26133
26778
  import '@vielzeug/refine/carousel';
26779
+ import '@vielzeug/refine/chat-message';
26134
26780
  import '@vielzeug/refine/checkbox';
26135
26781
  import '@vielzeug/refine/checkbox-group';
26136
26782
  import '@vielzeug/refine/chip';
26137
26783
  import '@vielzeug/refine/copy-command';
26138
26784
  import '@vielzeug/refine/combobox';
26785
+ import '@vielzeug/refine/command-palette';
26139
26786
  import '@vielzeug/refine/datagrid';
26140
26787
  import '@vielzeug/refine/date-picker';
26141
26788
  import '@vielzeug/refine/dialog';
@@ -26146,7 +26793,10 @@ import '@vielzeug/refine/grid';
26146
26793
  import '@vielzeug/refine/grid-item';
26147
26794
  import '@vielzeug/refine/icon';
26148
26795
  import '@vielzeug/refine/input';
26796
+ import '@vielzeug/refine/list';
26797
+ import '@vielzeug/refine/list-item';
26149
26798
  import '@vielzeug/refine/menu';
26799
+ import '@vielzeug/refine/message-composer';
26150
26800
  import '@vielzeug/refine/navbar';
26151
26801
  import '@vielzeug/refine/number-input';
26152
26802
  import '@vielzeug/refine/otp-input';
@@ -26172,6 +26822,7 @@ import '@vielzeug/refine/textarea';
26172
26822
  import '@vielzeug/refine/time-picker';
26173
26823
  import '@vielzeug/refine/toast';
26174
26824
  import '@vielzeug/refine/tooltip';
26825
+ import '@vielzeug/refine/typing-indicator';
26175
26826
  ```
26176
26827
 
26177
26828
  ## Shared Exported Symbols
@@ -26231,20 +26882,24 @@ Per-component API — attributes, events, slots, CSS custom properties:
26231
26882
  - [Progress](./components/progress.md)
26232
26883
  - [Skeleton](./components/skeleton.md)
26233
26884
  - [Toast](./components/toast.md)
26885
+ - [Typing Indicator](./components/typing-indicator.md)
26234
26886
 
26235
26887
  ### Content
26236
26888
  - [Avatar](./components/avatar.md)
26237
26889
  - [Breadcrumb](./components/breadcrumb.md)
26238
26890
  - [Card](./components/card.md)
26239
26891
  - [Carousel](./components/carousel.md)
26892
+ - [Chat Message](./components/chat-message.md)
26240
26893
  - [Copy Command](./components/copy-command.md)
26241
26894
  - [Icon](./components/icon.md)
26895
+ - [List (+ List Item)](./components/list.md)
26242
26896
  - [Pagination](./components/pagination.md)
26243
26897
  - [Separator](./components/separator.md)
26244
26898
  - [Table](./components/table.md)
26245
26899
  - [Text](./components/text.md)
26246
26900
 
26247
26901
  ### Overlay
26902
+ - [Command Palette](./components/command-palette.md)
26248
26903
  - [Dialog](./components/dialog.md)
26249
26904
  - [Drawer](./components/drawer.md)
26250
26905
  - [Menu](./components/menu.md)
@@ -26261,6 +26916,7 @@ Per-component API — attributes, events, slots, CSS custom properties:
26261
26916
  - [File Input](./components/file-input.md)
26262
26917
  - [Form](./components/form.md)
26263
26918
  - [Input](./components/input.md)
26919
+ - [Message Composer](./components/message-composer.md)
26264
26920
  - [Number Input](./components/number-input.md)
26265
26921
  - [OTP Input](./components/otp-input.md)
26266
26922
  - [Radio (+ Radio Group)](./components/radio.md)
@@ -26313,7 +26969,7 @@ createTextField(options: TextFieldOptions): TextFieldHandle
26313
26969
 
26314
26970
  Controller for `` and ``. Manages value sync, validation triggers, character counter, and event wiring.
26315
26971
 
26316
- Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `counter`.
26972
+ Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `reset()`, `counter`, `validity`/`validationMessage` (feed straight into `useField({ validity, validationMessage })`), `attachFormField(formField)` (call once the `useField()` handle exists, to wire up validation triggers and form `reset()` restoration).
26317
26973
 
26318
26974
  ### `createChoiceField(options)`
26319
26975
 
@@ -26323,7 +26979,9 @@ createChoiceField(options: ChoiceFieldOptions): ChoiceFieldHandle
26323
26979
 
26324
26980
  Controller for single and multi-select inputs. Normalises `string | string[]` values.
26325
26981
 
26326
- Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue`.
26982
+ Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue`, `reset()` (see below), `validity`/`validationMessage` (feed straight into `useField({ validity, validationMessage })`, `{ valueMissing: true }` while `required` and nothing selected), `attachFormField(formField)` (call once the `useField()` handle exists).
26983
+
26984
+ `reset()` has two states, not one: before the user ever changes the selection, it re-syncs from whatever `value` currently holds (same as `createTextField`'s `reset()` — e.g. an async-loaded default arriving after mount is still a legitimate target). Once the user changes the selection for the first time, it freezes to the value captured at field creation and stops tracking `value` — this matters for `ore-radio-group`/`ore-checkbox-group` specifically, which reflect the current selection back onto the host's `value`/`values` attribute for `:host([value])` styling, so past that point `value` itself changes on every selection and can't double as "the default to revert to" the way an uncontrolled ``'s `value` attribute can. `ore-select`/`ore-combobox` don't reflect their selection back onto `value` at all, so for them this distinction is moot in practice — but the primitive can't know that in advance, so it applies the same safe two-state rule uniformly.
26327
26985
 
26328
26986
  ### `createCheckable(options)`
26329
26987
 
@@ -26333,7 +26991,7 @@ createCheckable(options: CheckableOptions): CheckableHandle
26333
26991
 
26334
26992
  Controller for checkboxes and radios. Handles checked/indeterminate state, group delegation, and keyboard activation.
26335
26993
 
26336
- Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`.
26994
+ Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`, `reset()` (same two-state rule as `createChoiceField`'s — tracks `checked`/`indeterminate` live until the first `toggle()`, then freezes, since `checked` is reflected back onto the host attribute too), `validity`/`validationMessage` (`{ valueMissing: true }` while `required` and unchecked, indeterminate counts as unchecked), `attachFormField(formField)`.
26337
26995
 
26338
26996
  ### `createOverlayControl(options)`
26339
26997
 
@@ -26367,6 +27025,8 @@ createListControl(options: ListNavigationOptions): ListControl
26367
27025
 
26368
27026
  Keyboard-navigable list without open state. Supports vertical/horizontal/omni navigation, disabled-item skipping, looping, and typeahead. Navigation methods return the resolved index, or `-1` when no enabled item was found.
26369
27027
 
27028
+ Pass `direction` (`'ltr' | 'rtl'` or a getter) to mirror the default Left/Right arrow-key bindings for `'horizontal'`/`'both'` orientation, per WAI-ARIA APG (e.g. `direction: () => elementDirection(getHost())`). Has no effect when an explicit `keys` override is supplied.
27029
+
26370
27030
  ### Other Headless Exports
26371
27031
 
26372
27032
  | Export | Description |
@@ -26379,7 +27039,6 @@ Keyboard-navigable list without open state. Supports vertical/horizontal/omni na
26379
27039
  | `createDataGridControl()` | Data grid state (sorting, selection, column management, pagination)|
26380
27040
  | `createTypeahead()` | Standalone typeahead buffer with debounced reset |
26381
27041
  | `createDropdownPositioner()` | Floating dropdown positioner (wraps Orbit) |
26382
- | `createDialogFocusControl()` | Dialog-specific focus entry and restoration |
26383
27042
  | `createInteraction()` | Unified click/keyboard press handler for interactive elements |
26384
27043
  | `dispatchKeyboardAction()` | Low-level keymap dispatcher |
26385
27044
  | `createSelectionControl()` | Single/multi/none row-selection controller (used by the data grid) |
@@ -26681,7 +27340,7 @@ label.dispose();
26681
27340
  - **`.replace(fn)`** — derive next state from current via a function; same-reference return is a no-op
26682
27341
  - **`.reset()`** — restore the initial state baseline
26683
27342
  - **`.lens(path)`** — cached writable `Signal` for a property or dot-path; writes produce an immutable copy
26684
- - **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`
27343
+ - **`storeWithHistory(storeOrInit, options?)`** — store with explicit snapshot history; accepts an existing `Store` (not owned) or a plain object; call `.push()` / `.pushNamed(label)` to save checkpoints; `undo()`, `redo()`, `historyAt(i)` returns `HistoryEntry`; reactive `canUndo` / `canRedo`; import via `@vielzeug/ripple/history`
26685
27344
  - **`getDevToolsHook()`** — returns the currently installed DevTools hook, or `null`; install via `@vielzeug/ripple/devtools`
26686
27345
  - **Glitch-free propagation** — computed signals propagate in dependency order; effects always observe a consistent snapshot
26687
27346
  - **Infinite loop detection** — built-in guard against effect re-entry cycles (100 iterations default)
@@ -26725,7 +27384,7 @@ label.dispose();
26725
27384
  | `scope()` | Isolated cleanup context | Sync | Must call `scope.run()` to activate; `dispose()` is LIFO |
26726
27385
  | `debugEffect()` | Effect that logs changed sources before re-run | Sync | Sub-path only: `@vielzeug/ripple/devtools`; tree-shaken from production |
26727
27386
  | `store()` | Create object-like state container | Sync | Store is a branded signal; use `.patch()`, `.replace()`, `.reset()` |
26728
- | `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer |
27387
+ | `storeWithHistory()` | Store with snapshot-based undo/redo history | Sync | Sub-path only: `@vielzeug/ripple/history`; call `.push()` / `.pushNamed()` explicitly to record a checkpoint; `maxHistory` caps the buffer |
26729
27388
  | `installDevTools()` | Install DevTools observation hook | Sync | Sub-path only: `@vielzeug/ripple/devtools`; pass `null` to uninstall |
26730
27389
  | `getDevToolsHook()` | Return current DevTools hook | Sync | Returns `null` if none installed |
26731
27390
  | `isSignal()` | Type guard for any signal/computed/store | Sync | Uses an internal symbol marker, not duck-typing |
@@ -26738,6 +27397,7 @@ label.dispose();
26738
27397
  | --------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
26739
27398
  | `@vielzeug/ripple` | All core exports and types |
26740
27399
  | `@vielzeug/ripple/devtools` | `installDevTools`, `debugEffect`, and hook types (`RippleDevToolsHook`, `WriteEvent`, `NamedEvent`, `DisposeEvent`, `MutateEvent`) — dev-only, tree-shaken from prod |
27400
+ | `@vielzeug/ripple/history` | `storeWithHistory` and its types (`StoreWithHistory`, `HistoryEntry`) — tree-shaken unless imported |
26741
27401
  | `@vielzeug/ripple/ssr` | SSR tracking isolation helpers (`setTrackingProvider`, `createAsyncProvider`, `withProvider`, `runWithProvider`). Node.js only — do not import in browser builds. |
26742
27402
 
26743
27403
  ## Signal Primitives
@@ -27249,6 +27909,8 @@ Creates a reactive store for the given object state. Stores accept `effect()`, `
27249
27909
 
27250
27910
  ### `storeWithHistory`
27251
27911
 
27912
+ `storeWithHistory` is exported from `@vielzeug/ripple/history`, not the main entry point. This keeps it tree-shaken from bundles that never use it — for async commands or history over anything other than a `Store`, use `@vielzeug/ledger` instead.
27913
+
27252
27914
  ```ts
27253
27915
  function storeWithHistory(
27254
27916
  storeOrInitial: Store | T,
@@ -27263,6 +27925,8 @@ The initial state is saved as the first snapshot automatically. Snapshots are de
27263
27925
  **Ownership:** when called with an initial value (`T`), the adapter creates and owns the underlying store — `dispose()` also disposes it. When called with an existing `Store`, the adapter does **not** own it — `dispose()` leaves the store alive.
27264
27926
 
27265
27927
  ```ts
27928
+ import { storeWithHistory } from '@vielzeug/ripple/history';
27929
+
27266
27930
  const editor = storeWithHistory({ text: '' }, { maxHistory: 100 });
27267
27931
 
27268
27932
  editor.patch({ text: 'hello' }); // direct — StoreWithHistory extends Store
@@ -27891,6 +28555,20 @@ batch(() => {
27891
28555
 
27892
28556
  Nested `batch()` calls merge into the outermost — only one flush occurs.
27893
28557
 
28558
+ ## Error Handling
28559
+
28560
+ Ripple has no single error-handling mode — each primitive picks the surface that fits how it's normally consumed. This is one deliberate spectrum, not three unrelated designs:
28561
+
28562
+ | Primitive | Failure surface | Why |
28563
+ | ------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------------ |
28564
+ | `signal()` / `computed()` | Throws synchronously | Sync code — the caller is already in a position to catch |
28565
+ | `effect()` | Throws synchronously (rethrown from the run) | Same as above — no async gap between cause and observation |
28566
+ | `watch()` | Throws synchronously on an invalid callback return | Programmer-error guard, not a runtime failure mode |
28567
+ | `effectAsync()` | Routes to `onError` (default: rethrown via `queueMicrotask`) | There's no synchronous caller left to catch by the time the factory rejects — needs an explicit escape hatch |
28568
+ | `resource()` | Never throws — lands in `ResourceState.status === 'error'` | Failures are render state (show an error UI), not exceptions to unwind past |
28569
+
28570
+ Rule of thumb: if you're inside a synchronous callback, ripple throws (`RippleError` subtypes — see [Errors](#errors)). If the failure only exists after an `await`, ripple gives you a place to observe it instead of throwing into a call stack that's already gone — `onError` for `effectAsync()`, `ResourceState.error` for `resource()`.
28571
+
27894
28572
  ### Usage Guide
27895
28573
 
27896
28574
  ## Basic Usage
@@ -28309,7 +28987,7 @@ effect(() => {
28309
28987
  `storeWithHistory(initial, options?)` wraps a store with snapshot-based undo/redo. Mutations do **not** automatically push snapshots — call `.push()` (or `.pushNamed(label)`) explicitly after each logical change. History navigation with `undo()` and `redo()` never re-runs logic — it replays snapshots directly.
28310
28988
 
28311
28989
  ```ts
28312
- import { storeWithHistory } from '@vielzeug/ripple';
28990
+ import { storeWithHistory } from '@vielzeug/ripple/history';
28313
28991
 
28314
28992
  const editor = storeWithHistory({ text: '', cursor: 0 }, { maxHistory: 100 });
28315
28993
 
@@ -28932,7 +29610,6 @@ effect(() => {
28932
29610
  - Scope & onCleanup (id: `scope-cleanup`)
28933
29611
  - Scope — setup shorthand (id: `scope-setup`)
28934
29612
  - Store — patch, lens & computed (id: `store-basics`)
28935
- - Store History — Undo/Redo (id: `store-history`)
28936
29613
  - Store — fine-grained lens reactivity (id: `store-lenses`)
28937
29614
  - Store - Todo List (id: `store-todo-list`)
28938
29615
  - Watch, Lens & Map (id: `watch-and-subscribe`)
@@ -31276,7 +31953,7 @@ Scout builds a **trigram inverted index** at construction time. Query time is O(
31276
31953
  | ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
31277
31954
  | Bundle size | ~3 KB | | ~23 KB |
31278
31955
  | Zero dependencies | | `@vielzeug/ripple` peer (reactive layer only) | |
31279
- | Algorithm | Levenshtein | Trigram + Dice coefficient | Bitap |
31956
+ | Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |
31280
31957
  | Query time | O(n·m) | O(candidates) | O(n·m) |
31281
31958
  | Stateful index | | | |
31282
31959
  | Match highlighting | | | |
@@ -31388,7 +32065,7 @@ function createIndex(items: T[], options: ScoutIndexOptions): ScoutIndex
31388
32065
  | --- | --- | --- |
31389
32066
  | `items` | `T[]` | Initial corpus to index. |
31390
32067
  | `options.fields` | `ReadonlyArray>` | Fields to index. Required; at least one entry. |
31391
- | `options.threshold` | `number` | Min Dice score for a result (default `0.2`). |
32068
+ | `options.threshold` | `number` | Min overlap score for a result (default `0.2`). |
31392
32069
  | `options.limit` | `number` | Max results returned by `search()` (default `50`). |
31393
32070
  | `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |
31394
32071
 
@@ -31533,7 +32210,7 @@ function createReactiveSearch(
31533
32210
  | `items` | `T[]` | Initial corpus to index. |
31534
32211
  | `options.fields` | `ReadonlyArray>` | Fields to index. Required. |
31535
32212
  | `options.debounce` | `number` | Debounce ms (default `200`). |
31536
- | `options.threshold` | `number` | Min Dice score (default `0.2`). |
32213
+ | `options.threshold` | `number` | Min overlap score (default `0.2`). |
31537
32214
  | `options.limit` | `number` | Max results (default `50`). |
31538
32215
  | `options.minQueryLength` | `number` | Min chars before trigram scoring (default `3`). |
31539
32216
 
@@ -31903,12 +32580,18 @@ index.search('日本語'); // matches the first document
31903
32580
  Pass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality.
31904
32581
 
31905
32582
  ```ts
31906
- // At most 10 results, minimum Dice score 0.3
32583
+ // At most 10 results, minimum overlap score 0.3
31907
32584
  const results = index.search('widget', { limit: 10, threshold: 0.3 });
31908
32585
  ```
31909
32586
 
31910
32587
  Per-call options override the index-level defaults set in `createIndex`.
31911
32588
 
32589
+ Scores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*
32590
+ trigram set (almost always the query) found in the longer one. This is deliberate for the
32591
+ autocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix
32592
+ of a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how
32593
+ much of the query matched, not diluted by how much longer the target field happens to be.
32594
+
31912
32595
  ### Controlling short-query behaviour
31913
32596
 
31914
32597
  Queries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.
@@ -32561,6 +33244,7 @@ interface VirtualizerState {
32561
33244
  | `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |
32562
33245
  | `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |
32563
33246
  | `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |
33247
+ | `isAtEnd` | `(threshold?: number) => boolean` | `true` when within `threshold` px (default `0`) of the end — check before appending items to decide whether to auto-follow (chat "stick to bottom") |
32564
33248
  | `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |
32565
33249
  | `dispose` | `() => void` | Detach listeners; idempotent |
32566
33250
  | `disposed` | `boolean` | `true` after `dispose()` is called |
@@ -32696,9 +33380,34 @@ ctrl.dispose();
32696
33380
  | `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |
32697
33381
  | `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |
32698
33382
  | `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |
33383
+ | `stickToBottom` | `boolean \| StickToBottomOptions` | — | Auto-scroll to the end after `setItems()` whenever the list was already at (or near) the end — the chat "stick to bottom on new message" pattern |
32699
33384
 
32700
33385
  Without `getItemKey`, each `setItems()` call drops cached measurements.
32701
33386
 
33387
+ ### `StickToBottomOptions`
33388
+
33389
+ | Option | Type | Default | Description |
33390
+ | ----------- | --------- | ------- | --------------------------------------------------------------------------- |
33391
+ | `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |
33392
+ | `threshold` | `number` | `48` | Distance in pixels from the end still considered "at the end" |
33393
+
33394
+ `stickToBottom` fires on **any** `setItems()` call made while the list is at the end — not just when the item count grows. This also follows a streaming last item that grows in place (same array length, bigger content) without you needing to detect that case yourself. It never fires while the user has scrolled away from the end, so reading older messages is never interrupted.
33395
+
33396
+ ```ts
33397
+ const chat = createDomVirtualList({
33398
+ estimateSize: 48,
33399
+ getItemKey: (_, m) => m.id,
33400
+ listElement: listEl,
33401
+ render: renderMessages,
33402
+ scrollElement: scrollEl,
33403
+ stickToBottom: true, // or { threshold: 80 } for a larger "still at bottom" tolerance
33404
+ });
33405
+
33406
+ chat.setItems(messages); // scrolls to bottom on first load
33407
+ // … later, a new message arrives (or the last one grows while streaming) …
33408
+ chat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom
33409
+ ```
33410
+
32702
33411
  ### `DomVirtualListRenderArgs`
32703
33412
 
32704
33413
  ```ts
@@ -32734,6 +33443,9 @@ Extends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virt
32734
33443
  | `invalidate` | Clear measurements and rebuild from estimates |
32735
33444
  | `scrollToIndex` | Scroll to an item |
32736
33445
  | `scrollToOffset` | Scroll to a pixel offset |
33446
+ | `scrollToTop` | Scroll to offset `0` |
33447
+ | `scrollToBottom` | Scroll to the end of the list |
33448
+ | `isAtEnd` | `true` when within `threshold` px of the end |
32737
33449
  | `dispose` | Teardown; idempotent |
32738
33450
  | `disposed` | `true` after `dispose()` is called (live getter) |
32739
33451
  | `[Symbol.dispose]` | Delegates to `dispose()` |
@@ -33613,6 +34325,76 @@ virt.scrollToTop();
33613
34325
  virt.scrollToBottom({ behavior: 'smooth' });
33614
34326
  ```
33615
34327
 
34328
+ ### Chat "stick to bottom on new message"
34329
+
34330
+ `createDomVirtualList`'s `stickToBottom` option automates the common chat/log pattern: follow new messages while the user is at the bottom, but never yank them away from history they scrolled up to read.
34331
+
34332
+ ```ts
34333
+ import { createDomVirtualList } from '@vielzeug/scroll';
34334
+
34335
+ const chat = createDomVirtualList({
34336
+ estimateSize: 48,
34337
+ getItemKey: (_, m) => m.id,
34338
+ listElement: listEl,
34339
+ render: renderMessages,
34340
+ scrollElement: scrollEl,
34341
+ stickToBottom: true, // or { threshold: 80 } to widen the "still at bottom" tolerance
34342
+ });
34343
+
34344
+ chat.setItems(messages);
34345
+
34346
+ // New message arrives — follows only if the user hasn't scrolled up.
34347
+ socket.on('message', (msg) => {
34348
+ messages = [...messages, msg];
34349
+ chat.setItems(messages);
34350
+ });
34351
+ ```
34352
+
34353
+ It also follows a **streaming** last message that grows in place (tokens appended to the same message object, array length unchanged) — every `setItems()` call re-checks "was the list at the end before this update?", not just count changes. Build `isAtEnd()` from `createVirtualizer` directly for custom cases (e.g. showing a "jump to latest" button only while scrolled away):
34354
+
34355
+ ```ts
34356
+ const showJumpButton = !virt.isAtEnd();
34357
+ ```
34358
+
34359
+ ## Infinite Scroll — Loading More at the End
34360
+
34361
+ Use `isAtEnd(threshold)` to fetch the next page as the user nears the bottom. `isAtEnd()` reports scroll position only — it keeps returning `true` while a fetch is in flight — so guard it with your own `loading` flag to avoid firing the same request twice.
34362
+
34363
+ ```ts
34364
+ import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
34365
+
34366
+ let rows = await fetchPage(0);
34367
+ let loading = false;
34368
+
34369
+ let virt: Virtualizer;
34370
+ virt = createVirtualizer(scrollEl, {
34371
+ count: rows.length,
34372
+ estimateSize: 36,
34373
+ onChange: ({ items, totalSize }) => {
34374
+ listEl.style.height = `${totalSize}px`;
34375
+ listEl.innerHTML = '';
34376
+
34377
+ for (const item of items) {
34378
+ const el = document.createElement('div');
34379
+ el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
34380
+ el.textContent = rows[item.index]?.label ?? '';
34381
+ listEl.appendChild(el);
34382
+ }
34383
+
34384
+ if (!loading && virt.isAtEnd(200)) {
34385
+ loading = true;
34386
+ fetchPage(rows.length).then((nextRows) => {
34387
+ rows = [...rows, ...nextRows];
34388
+ virt.update({ count: rows.length });
34389
+ loading = false;
34390
+ });
34391
+ }
34392
+ },
34393
+ });
34394
+ ```
34395
+
34396
+ `isAtEnd(200)` fires once the viewport is within 200px of the bottom — tune the threshold to your row height and fetch latency. `loading` is the only guard needed: it's cleared once the new page lands, and `update({ count })` re-triggers `onChange`, which re-checks `isAtEnd()` against the new total on the next scroll.
34397
+
33616
34398
  ## Shared Measurement Cache
33617
34399
 
33618
34400
  When the same items are displayed across multiple virtualizer instances (e.g. a list and a detail panel that share row heights), pass a shared `MeasurementCache` created by `createMeasurementCache()`. Measurements recorded by one virtualizer are immediately available to all others using the same cache.
@@ -33699,7 +34481,7 @@ Scroll is rendering-layer agnostic. The pattern is always the same: create the v
33699
34481
 
33700
34482
  ```tsx [React]
33701
34483
  import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
33702
- import { useEffect, useRef } from 'react';
34484
+ import { useEffect, useLayoutEffect, useRef } from 'react';
33703
34485
 
33704
34486
  interface Row {
33705
34487
  id: number;
@@ -33734,7 +34516,10 @@ function VirtualList({ rows }: { rows: Row[] }) {
33734
34516
  return () => virt.dispose();
33735
34517
  }, []); // attach once
33736
34518
 
33737
- useEffect(() => {
34519
+ // useLayoutEffect, not useEffect: syncs count before paint. With useEffect,
34520
+ // the DOM (and anything reading `rows`) paints once with the new length before
34521
+ // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.
34522
+ useLayoutEffect(() => {
33738
34523
  virtRef.current?.update({ count: rows.length });
33739
34524
  }, [rows.length]);
33740
34525
 
@@ -33877,6 +34662,7 @@ class VirtualList extends LitElement {
33877
34662
  ### Pitfalls
33878
34663
 
33879
34664
  - **React:** Putting `rows` in the `useEffect` dependency array causes the virtualizer to be destroyed and recreated on every data update. Only include the scroll element reference. Call `virt.update({ count })` from a separate `useEffect` for data changes.
34665
+ - **React:** Use `useLayoutEffect`, not `useEffect`, for the `count`-sync effect. `useEffect` fires after paint — a new `count` can reach the DOM (e.g. via other state derived from `rows`) before `update({ count })` runs, rendering stale or out-of-bounds indices for one frame.
33880
34666
  - **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.
33881
34667
  - **Svelte:** In Svelte 5, `$effect` with `bind:this` runs after the DOM is painted. The `bind:this` variable is available when the `$effect` runs — no extra tick needed.
33882
34668
  - **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.
@@ -33972,7 +34758,7 @@ define('virtual-list', {
33972
34758
 
33973
34759
  **Category:** data
33974
34760
  **Keywords:** pagination, filtering, sorting, search, data-source, query, remote, local, cursor, infinite-scroll
33975
- **Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, applyQuery, SourceDisposedError, SourcererError, SourceTimeoutError, sourceState, itemRange (+39 more)
34761
+ **Key exports:** createLocalSource, createRemoteSource, createCursorSource, createInfiniteSource, deriveSource, mergeSource, SourcererDisposedError, SourcererError, SourcererTimeoutError, sourceState, itemRange, prefetchSource (+38 more)
33976
34762
  **Related:** courier, ripple, wayfinder
33977
34763
 
33978
34764
  ### Overview
@@ -34072,6 +34858,8 @@ console.log(source.current, source.meta.totalItems);
34072
34858
  | `createCursorSource()` | Server fetch | Cursor tokens | `patch()`, `ready()`, `queryKey` |
34073
34859
  | `createInfiniteSource()` | Server fetch | Append (`loadMore`) | `patch()`, `loadedPages`, `ready()`, `queryKey` |
34074
34860
 
34861
+ `@vielzeug/sourcerer/devtools`: opt-in `debugSource()` for `console.debug` state-transition logging across any source type, tree-shaken from production.
34862
+
34075
34863
  ## Documentation
34076
34864
 
34077
34865
  - [Usage Guide](./usage.md)
@@ -34090,16 +34878,15 @@ console.log(source.current, source.meta.totalItems);
34090
34878
 
34091
34879
  | Symbol | Purpose | Execution mode | Common gotcha |
34092
34880
  | -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- |
34093
- | `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` is fuzzy, not substring |
34881
+ | `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` JSON-stringifies each item for substring matching |
34094
34882
  | `createRemoteSource()` | Async server-backed collection with page navigation | Async | Fetches on creation; set `autoFetch: false` to delay |
34095
34883
  | `createCursorSource()` | Async collection navigated by cursor tokens | Async | `next()`/`prev()` are no-ops when the cursor is absent |
34096
34884
  | `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` |
34097
34885
  | `deriveSource()` | Create a reactive projection of another source | Sync | Derived source disposes automatically when parent disposes |
34098
34886
  | `mergeSource()` | Combine multiple sources into one `MergedSource` | Sync | No `meta` field — returned type is `MergedSource`, not `ReactiveSource` |
34099
- | `applyQuery()` | Apply a partial query patch to any source with `patch()` — fires one fetch | Async | Ignores `page` on Cursor/InfiniteSource — no page concept there |
34100
34887
  | `SourcererError` | Base error class for all sourcerer errors; carries `message`, `cause`, `context`, `attempt` | Class | Extends `Error`; access context via getters, not object spread |
34101
- | `SourceTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` |
34102
- | `SourceDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourceTimeoutError` if needed |
34888
+ | `SourcererTimeoutError` | Error thrown when `ready()` times out; has `timeoutMs` property | Class | Extends `SourcererError`; also caught by `instanceof SourcererError` |
34889
+ | `SourcererDisposedError` | Error thrown by `ready()` when the source is disposed | Class | Extends `SourcererError`; catch separately from `SourcererTimeoutError` if needed |
34103
34890
  | `sourceState()` | Derive a discriminated union (`loading`/`error`/`success`) from any source | Sync | Returns `'loading'` when `isSearchPending` is true too |
34104
34891
  | `itemRange()` | Compute 1-based display range from `SourceMeta` | Sync | Returns `{ start: 0, end: 0 }` when `totalItems === 0` |
34105
34892
  | `prefetchSource()` | SSR: fetch first page, return serialisable snapshot; source is disposed immediately | Async | **Throws `SourcererError`** if fetch fails |
@@ -34107,6 +34894,7 @@ console.log(source.current, source.meta.totalItems);
34107
34894
  | `filterContains()` | Preset predicate: case-insensitive substring match | Sync | Matches against a getter's string value |
34108
34895
  | `filterEquals()` | Preset predicate: strict equality match | Sync | Uses `Object.is` semantics |
34109
34896
  | `filterRange()` | Preset predicate: inclusive min/max range | Sync | Works with numbers and Dates |
34897
+ | `searchBy()` | Preset search builder: field-based matching for `LocalSourceConfig.searchFn` | Sync | Prefer this over default JSON-stringify search on large collections |
34110
34898
  | `sortBy()` | Preset comparator: sort by a getter value | Sync | Supports `'asc'` / `'desc'`; handles strings, numbers, Dates |
34111
34899
  | `encodeQuery()` | Serialize source query to URL params | Sync | Filter and sort are JSON-stringified |
34112
34900
  | `decodeQuery()` | Deserialize URL params (or `URLSearchParams`) to a source query | Sync | Malformed JSON is silently dropped by default |
@@ -34114,11 +34902,12 @@ console.log(source.current, source.meta.totalItems);
34114
34902
  | `SearchOptions` | Options bag for `search()` — only field is `immediate?: boolean` | Type | `search()` always returns `Promise`; debounced unless `{ immediate: true }` |
34115
34903
  | `DecodeQueryOptions` | Options for `decodeQuery()` — `defaultLimit` and `strict` | Type | `strict: true` throws on malformed JSON; default silently drops it |
34116
34904
 
34117
- ## Package Entry Point
34905
+ ## Package Entry Points
34118
34906
 
34119
- | Import | Purpose |
34120
- | --------------------- | ---------------------- |
34121
- | `@vielzeug/sourcerer` | Main exports and types |
34907
+ | Import | Purpose |
34908
+ | -------------------------------- | ------------------------------------------------------ |
34909
+ | `@vielzeug/sourcerer` | Main exports and types |
34910
+ | `@vielzeug/sourcerer/devtools` | Opt-in `debugSource()` — tree-shaken from production |
34122
34911
 
34123
34912
  ## Core Factories
34124
34913
 
@@ -34144,7 +34933,7 @@ type LocalSourceConfig = {
34144
34933
  };
34145
34934
  ```
34146
34935
 
34147
- The default `searchFn` performs a case-insensitive JSON substring match — i.e. it stringifies each item with `JSON.stringify` and checks if the query string appears anywhere in the result. Provide a custom `searchFn` for domain-specific relevance or exact field matching.
34936
+ The default `searchFn` performs a case-insensitive JSON substring match — it stringifies each item with `JSON.stringify` and checks if the query string appears anywhere in the result. For better performance and intent clarity, prefer `searchBy(...)` when searching known fields.
34148
34937
 
34149
34938
  `filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation.
34150
34939
 
@@ -34197,7 +34986,7 @@ type RemoteConfig = {
34197
34986
  ```
34198
34987
 
34199
34988
  `queryKey` defaults to a stable JSON serialization with recursively sorted keys.
34200
- `staleTime` compares the **query key** — navigating to a different page always fetches even within the stale window.
34989
+ `staleTime` compares the **query key** — navigating to a different page always fetches even within the stale window. If an `optimisticUpdate()` is active, `refresh()` bypasses `staleTime` to settle the optimistic state.
34201
34990
 
34202
34991
  **Returns:** `RemoteSource` — async server-backed source with page navigation and optimistic update support.
34203
34992
 
@@ -34316,7 +35105,7 @@ All methods return `Promise` unless noted.
34316
35105
  | `patch(changes)` | Apply one or more query changes atomically — a single recompute for any combination of `limit`, `page`, `search`, `filter`, `sort` |
34317
35106
  | `prev()` | Navigate to the previous page (no-op at first page) |
34318
35107
  | `query` | Current state as a `SourceQuery` (`limit`/`page`/`search` only — filter/sort aren't part of the query snapshot) — read-only snapshot; stable between changes |
34319
- | `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35108
+ | `ready(timeout?)` | Resolve when no async computation is pending and no debounce is scheduled; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34320
35109
  | `reset()` | Restore initial config and return to page 1 |
34321
35110
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
34322
35111
  | `setData(data)` | Replace the dataset and reset to page 1 |
@@ -34338,7 +35127,7 @@ All methods return `Promise` except `optimisticUpdate` and `subscribe`.
34338
35127
  | `patch(changes)` | Apply one or more query changes atomically — a single fetch for any combination of `limit`, `page`, `search`, `filter`, `sort` |
34339
35128
  | `prev()` | Previous page (no-op at first page) |
34340
35129
  | `query` | Current state as a `RemoteSourceQuery` — read-only snapshot; stable between changes |
34341
- | `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35130
+ | `ready(timeout?)` | Resolve when no requests are pending; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34342
35131
  | `refresh()` | Re-fetch the current query |
34343
35132
  | `reset()` | Restore initial config and refetch |
34344
35133
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await immediately |
@@ -34370,7 +35159,7 @@ optimisticUpdate(
34370
35159
  | `patch(changes)` | Apply `limit` and/or `search` atomically — a single fetch; resets cursor position |
34371
35160
  | `prev()` | Go back using `prevCursor` (no-op if none) |
34372
35161
  | `query` | Current state as a `CursorSourceQuery` — read-only snapshot; stable between changes |
34373
- | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35162
+ | `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34374
35163
  | `refresh()` | Re-fetch current cursor position |
34375
35164
  | `reset()` | Clear cursors and fetch from the start |
34376
35165
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default; pass `{ immediate: true }` to cancel debounce and await. Resets cursor position. |
@@ -34386,33 +35175,22 @@ optimisticUpdate(
34386
35175
  | `loadMore()` | Fetch the next page and append to `current` (no-op when `meta.hasMore === false`) |
34387
35176
  | `patch(changes)` | Apply `limit` and/or `search` atomically — **clears items immediately** and fetches from page 1 |
34388
35177
  | `query` | Current state as an `InfiniteSourceQuery` — read-only snapshot; stable between changes |
34389
- | `ready(timeout?)` | Resolve when idle; rejects with `SourceDisposedError` if already disposed; optional timeout rejects with `SourceTimeoutError` |
35178
+ | `ready(timeout?)` | Resolve when idle; rejects with `SourcererDisposedError` if already disposed; optional timeout rejects with `SourcererTimeoutError` |
34390
35179
  | `reset()` | Clear accumulated items **immediately** and fetch from page 1 |
34391
35180
  | `search(query, opts?)` | Always returns `Promise`. Debounced by default — **clears items immediately**; fetch fires after debounce. Pass `{ immediate: true }` to skip the window. |
34392
35181
  | `subscribe(listener)` | Subscribe; returns unsubscribe |
34393
35182
 
34394
35183
  ## Query Utilities
34395
35184
 
34396
- ### `applyQuery`
34397
-
34398
- ```ts
34399
- applyQuery): Promise }>(
34400
- source: T,
34401
- changes: Partial,
34402
- ): Promise
34403
- ```
34404
-
34405
- Applies a partial `SourceQuery` (`limit`/`page`/`search`) patch to any source that exposes a compatible `patch()` — delegates directly to `source.patch(changes)`. Fires a single fetch or recomputation for any combination of changed fields. No-op when `changes` is empty or all values are unchanged, per each source's own `patch()` implementation.
34406
-
34407
- `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation) — their `patch()` only reads `limit`/`search`, so a `page` field from `decodeQuery()` output is silently ignored on those two source types.
35185
+ Restoring URL-decoded state onto a source is a direct `source.patch(decodeQuery(...))` call — no separate wrapper function. `CursorSource` and `InfiniteSource` have no page-number concept (keyset/append navigation), so their `patch()` type only accepts `limit`/`search`; passing a `page` field from `decodeQuery()`'s output to either is a compile-time error, not a silent no-op.
34408
35186
 
34409
35187
  **Example:**
34410
35188
 
34411
35189
  ```ts
34412
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35190
+ import { decodeQuery } from '@vielzeug/sourcerer';
34413
35191
 
34414
35192
  const q = decodeQuery(new URLSearchParams(location.search));
34415
- await applyQuery(source, q);
35193
+ await source.patch(q);
34416
35194
  ```
34417
35195
 
34418
35196
  ---
@@ -34431,13 +35209,13 @@ class SourcererError extends Error {
34431
35209
  }
34432
35210
  ```
34433
35211
 
34434
- Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourceTimeoutError` and `SourceDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors.
35212
+ Base class for all sourcerer errors. Thrown (and stored as `meta.error`) when a fetch fails. `cause` is the original thrown value. `SourcererTimeoutError` and `SourcererDisposedError` both extend this class, so a single `instanceof SourcererError` check covers all sourcerer errors.
34435
35213
 
34436
- ### `SourceTimeoutError`
35214
+ ### `SourcererTimeoutError`
34437
35215
 
34438
35216
  ```ts
34439
- class SourceTimeoutError extends SourcererError {
34440
- readonly name = 'SourceTimeoutError';
35217
+ class SourcererTimeoutError extends SourcererError {
35218
+ readonly name = 'SourcererTimeoutError';
34441
35219
  readonly timeoutMs: number;
34442
35220
  // message: 'Source.ready() timed out after Nms'
34443
35221
  }
@@ -34445,11 +35223,11 @@ class SourceTimeoutError extends SourcererError {
34445
35223
 
34446
35224
  Thrown by `ready(timeout)` when the timeout expires before the source becomes idle. Also caught by `instanceof SourcererError`.
34447
35225
 
34448
- ### `SourceDisposedError`
35226
+ ### `SourcererDisposedError`
34449
35227
 
34450
35228
  ```ts
34451
- class SourceDisposedError extends SourcererError {
34452
- readonly name = 'SourceDisposedError';
35229
+ class SourcererDisposedError extends SourcererError {
35230
+ readonly name = 'SourcererDisposedError';
34453
35231
  // message: 'Source disposed while waiting for ready()'
34454
35232
  }
34455
35233
  ```
@@ -34629,11 +35407,34 @@ Not generic — `filter`/`sort` on the result are always typed `unknown`. Narrow
34629
35407
  **Example:**
34630
35408
 
34631
35409
  ```ts
34632
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35410
+ import { decodeQuery } from '@vielzeug/sourcerer';
34633
35411
 
34634
35412
  // Pass URLSearchParams directly — filter/sort come back as `unknown`, narrow before use
34635
35413
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
34636
- await applyQuery(source, query);
35414
+ await source.patch(query);
35415
+ ```
35416
+
35417
+ ## Devtools (`@vielzeug/sourcerer/devtools`)
35418
+
35419
+ ```ts
35420
+ debugSource>(
35421
+ source: { current: readonly unknown[]; meta: TMeta; subscribe(listener: () => void): () => void },
35422
+ options?: { label?: string },
35423
+ ): () => void
35424
+ ```
35425
+
35426
+ Attaches a `console.debug`-based observer to any source (or `deriveSource`/`mergeSource` result) — logs a line per changed `meta` field (works generically across `SourceMeta`/`CursorMeta`/`InfiniteMeta`) and `current`'s item count. Development-only by default; a no-op when `__SOURCERER_PROD__` is set. Import from the dedicated `/devtools` sub-path so it's tree-shaken from production bundles.
35427
+
35428
+ ```ts
35429
+ import { createRemoteSource } from '@vielzeug/sourcerer';
35430
+ import { debugSource } from '@vielzeug/sourcerer/devtools';
35431
+
35432
+ const source = createRemoteSource({ fetch: fetchUsers, limit: 20 });
35433
+ const detach = debugSource(source, { label: 'users' });
35434
+ // [sourcerer:devtools:users] meta.isLoading: false → true
35435
+ // [sourcerer:devtools:users] current: 0 → 20 items
35436
+
35437
+ detach(); // stop logging
34637
35438
  ```
34638
35439
 
34639
35440
  ## Types
@@ -34737,50 +35538,50 @@ type FetchEvent = Readonly;
34737
35538
 
34738
35539
  See [Error Utilities > `SourcererError`](#sourcererror) above.
34739
35540
 
34740
- ### `SourceTimeoutError`
35541
+ ### `SourcererTimeoutError`
34741
35542
 
34742
35543
  ```ts
34743
- class SourceTimeoutError extends SourcererError {
34744
- readonly name = 'SourceTimeoutError';
35544
+ class SourcererTimeoutError extends SourcererError {
35545
+ readonly name = 'SourcererTimeoutError';
34745
35546
  readonly timeoutMs: number;
34746
35547
  // message: 'Source.ready() timed out after Nms'
34747
35548
  }
34748
35549
  ```
34749
35550
 
34750
- Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourceTimeoutError` for typed catch blocks:
35551
+ Thrown by `ready(timeout)` when the source has not become idle within the specified `timeout` milliseconds. Check with `instanceof SourcererTimeoutError` for typed catch blocks:
34751
35552
 
34752
35553
  ```ts
34753
- import { SourceTimeoutError } from '@vielzeug/sourcerer';
35554
+ import { SourcererTimeoutError } from '@vielzeug/sourcerer';
34754
35555
 
34755
35556
  try {
34756
35557
  await source.ready(5000);
34757
35558
  } catch (err) {
34758
- if (err instanceof SourceTimeoutError) {
35559
+ if (err instanceof SourcererTimeoutError) {
34759
35560
  console.warn('Source did not load in time:', err.message);
34760
35561
  }
34761
35562
  }
34762
35563
  ```
34763
35564
 
34764
- ### `SourceDisposedError`
35565
+ ### `SourcererDisposedError`
34765
35566
 
34766
35567
  ```ts
34767
- class SourceDisposedError extends SourcererError {
34768
- readonly name = 'SourceDisposedError';
35568
+ class SourcererDisposedError extends SourcererError {
35569
+ readonly name = 'SourcererDisposedError';
34769
35570
  // message: 'Source disposed while waiting for ready()'
34770
35571
  }
34771
35572
  ```
34772
35573
 
34773
- Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourceDisposedError` to distinguish it from `SourceTimeoutError`:
35574
+ Thrown by `ready()` when `dispose()` is called on the source while a `ready()` call is still pending. Use `instanceof SourcererDisposedError` to distinguish it from `SourcererTimeoutError`:
34774
35575
 
34775
35576
  ```ts
34776
- import { SourceDisposedError, SourceTimeoutError } from '@vielzeug/sourcerer';
35577
+ import { SourcererDisposedError, SourcererTimeoutError } from '@vielzeug/sourcerer';
34777
35578
 
34778
35579
  try {
34779
35580
  await source.ready(5000);
34780
35581
  } catch (err) {
34781
- if (err instanceof SourceDisposedError) {
35582
+ if (err instanceof SourcererDisposedError) {
34782
35583
  // source was torn down — skip cleanup
34783
- } else if (err instanceof SourceTimeoutError) {
35584
+ } else if (err instanceof SourcererTimeoutError) {
34784
35585
  console.warn('timed out');
34785
35586
  }
34786
35587
  }
@@ -34830,13 +35631,23 @@ createLocalSource(data, {
34830
35631
  debounceMs: 300, // debounce delay for source.search() (default: 300)
34831
35632
  filter: (u) => u.active, // initial synchronous filter predicate
34832
35633
  sort: (a, b) => a.name.localeCompare(b.name), // initial sorter
34833
- searchFn: (items, query) => items.filter(/* custom match */), // override default search
35634
+ searchFn: searchBy([(u) => u.name, (u) => u.email]), // override default search
34834
35635
  // Async variants — enable Web Worker offloading via @vielzeug/familiar:
34835
35636
  filterAsync: async (items, signal) => items.filter(/* expensive filter */),
34836
35637
  sortAsync: async (items, signal) => [...items].sort(/* expensive sort */),
34837
35638
  });
34838
35639
  ```
34839
35640
 
35641
+ For large in-memory datasets, prefer the `searchBy(...)` preset over the default `JSON.stringify` search:
35642
+
35643
+ ```ts
35644
+ import { createLocalSource, searchBy } from '@vielzeug/sourcerer';
35645
+
35646
+ const source = createLocalSource(users, {
35647
+ searchFn: searchBy([(u) => u.name, (u) => u.email]),
35648
+ });
35649
+ ```
35650
+
34840
35651
  `filterAsync` and `sortAsync` run after their synchronous counterparts. They set `meta.isLoading = true` during computation and accept an `AbortSignal` — a new call aborts any running async computation.
34841
35652
 
34842
35653
  ### Mutations
@@ -34853,13 +35664,13 @@ await source.reset(); // restore initial filter/sort, reset to page 1
34853
35664
 
34854
35665
  ### Restoring from URL state
34855
35666
 
34856
- Use `applyQuery()` + `decodeQuery()` to restore URL-decoded state in a single atomic recompute.
35667
+ Use `decodeQuery()` + `source.patch()` to restore URL-decoded state in a single atomic recompute.
34857
35668
 
34858
35669
  ```ts
34859
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35670
+ import { decodeQuery } from '@vielzeug/sourcerer';
34860
35671
 
34861
35672
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 10 });
34862
- await applyQuery(source, query);
35673
+ await source.patch(query);
34863
35674
  ```
34864
35675
 
34865
35676
  ## Remote Source
@@ -34907,6 +35718,7 @@ createRemoteSource({
34907
35718
  ```
34908
35719
 
34909
35720
  `staleTime` compares the **query key** — navigating to a different page always fetches even when the previous result is still within the stale window.
35721
+ When an `optimisticUpdate()` is active, `refresh()` always fetches even within `staleTime` so the optimistic state can settle deterministically.
34910
35722
 
34911
35723
  ### The `fetch` callback
34912
35724
 
@@ -34941,13 +35753,13 @@ await source.refresh(); // re-fetch current query
34941
35753
 
34942
35754
  ### Restoring from URL state
34943
35755
 
34944
- `applyQuery()` is a no-op when `changes` is empty — safe to call on every page load.
35756
+ `source.patch()` is a no-op when `changes` is empty — safe to call on every page load.
34945
35757
 
34946
35758
  ```ts
34947
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35759
+ import { decodeQuery } from '@vielzeug/sourcerer';
34948
35760
 
34949
35761
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
34950
- await applyQuery(source, query);
35762
+ await source.patch(query);
34951
35763
  ```
34952
35764
 
34953
35765
  ### Optimistic updates
@@ -35050,13 +35862,13 @@ await source.reset(); // clear all, restart from page 1
35050
35862
 
35051
35863
  ### Restoring from URL state
35052
35864
 
35053
- Use `applyQuery()` + `decodeQuery()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed.
35865
+ Use `decodeQuery()` + `source.patch()` to restore `limit` and `search`. `patch()` clears accumulated items and refetches from page 1 if any value changed.
35054
35866
 
35055
35867
  ```ts
35056
- import { applyQuery, decodeQuery } from '@vielzeug/sourcerer';
35868
+ import { decodeQuery } from '@vielzeug/sourcerer';
35057
35869
 
35058
35870
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 20 });
35059
- await applyQuery(source, { limit: query.limit, search: query.search });
35871
+ await source.patch({ limit: query.limit, search: query.search });
35060
35872
  ```
35061
35873
 
35062
35874
  ## Error Handling
@@ -35165,7 +35977,7 @@ const params = encodeQuery(source.query);
35165
35977
 
35166
35978
  // Restore from URLSearchParams directly
35167
35979
  const query = decodeQuery(new URLSearchParams(location.search), { defaultLimit: 25 });
35168
- await applyQuery(source, query);
35980
+ await source.patch(query);
35169
35981
  ```
35170
35982
 
35171
35983
  `decodeQuery` is fault-tolerant by default — malformed `filter`/`sort` JSON is silently dropped. Pass `{ strict: true }` to throw instead.
@@ -35326,11 +36138,12 @@ effect(() => {
35326
36138
  - Pass the `AbortSignal` from the `fetch` callback to your HTTP client so superseded requests are cancelled.
35327
36139
  - Call `ready()` in server-side rendering or test setup — not in every render cycle.
35328
36140
  - Always call the unsubscribe function returned by `subscribe()` when the component is torn down.
35329
- - For URL sync, use `decodeQuery()` + `applyQuery()` rather than reconstructing source state from params manually.
36141
+ - For URL sync, use `decodeQuery()` + `source.patch()` rather than reconstructing source state from params manually.
35330
36142
  - Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards.
36143
+ - If you use `optimisticUpdate()`, call `refresh()` after mutation confirmation; it bypasses `staleTime` while optimistic state is active.
35331
36144
  - Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling.
35332
36145
  - When using `decodeQuery()`, validate the parsed `filter` and `sort` with a type guard before passing to the server — they are returned as-is without runtime validation.
35333
- - For infinite sources, pass `{ limit: query.limit, search: query.search }` to `applyQuery()` for URL state sync — `page` is not restorable since items accumulate across pages.
36146
+ - For infinite sources, pass `{ limit: query.limit, search: query.search }` to `source.patch()` for URL state sync — `page` isn't part of `InfiniteSource`'s `patch()` type at all, since items accumulate across pages rather than jumping to one.
35334
36147
 
35335
36148
  ### Examples
35336
36149
 
@@ -35357,7 +36170,7 @@ effect(() => {
35357
36170
  - LocalSource patch() with filter/sort (id: `local-source-patch`)
35358
36171
  - Presets (filterContains, filterEquals, filterRange, sortBy) (id: `presets`)
35359
36172
  - Remote Source (id: `remote-source`)
35360
- - sourceState & SourceTimeoutError (id: `source-state`)
36173
+ - sourceState & SourcererTimeoutError (id: `source-state`)
35361
36174
 
35362
36175
 
35363
36176
  ---
@@ -35366,7 +36179,7 @@ effect(() => {
35366
36179
 
35367
36180
  **Category:** validation
35368
36181
  **Keywords:** schema, validation, parsing, json-schema, locale, typescript, descriptors
35369
- **Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, setMessages, setLogger, resetMessages (+1 more)
36182
+ **Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, createParseContext, setMessages, setLogger (+4 more)
35370
36183
  **Related:** forge, courier, vault
35371
36184
 
35372
36185
  ### Overview
@@ -35501,7 +36314,9 @@ const user = User.parse(payload);
35501
36314
  | `Schema.parseAsync()` / `safeParseAsync()` | Validate including async `validate()` callbacks | Async | Required when any nested rule uses an async `validate()` callback. |
35502
36315
  | `descriptorToJsonSchema()` | Convert a `SchemaDescriptor` to JSON Schema | Sync setup | Uses `toDescriptor()` output, not custom transforms. |
35503
36316
  | `schemaToJsonSchema()` | Convert a `Schema` instance directly to JSON Schema | Sync setup | Calls `toDescriptor()` internally; same limitations apply. |
35504
- | `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger | Sync setup | `setMessages()` replaces the active message set each call. |
36317
+ | `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger globally | Sync setup | `setMessages()` replaces the active message set each call. |
36318
+ | `createParseContext()` | Create request-scoped message overrides for a parse call | Sync setup | Overrides apply only to calls that receive the returned context. |
36319
+ | `withMessages()` / `withLogger()` | Run a callback with temporary global message/logger overrides | Sync/async setup | Restores previous global state after the callback settles. |
35505
36320
  | `SpellValidationError` | Inspect validation failures | Sync/async failures | `format()` returns nested objects, `flatten()` returns path arrays. |
35506
36321
  | `prependIssuePath()` | Prefix a path segment to an array of issues | Sync | Use inside custom parsers that delegate to inner schemas. |
35507
36322
 
@@ -35518,7 +36333,7 @@ Use this table to scan every runtime export.
35518
36333
  | Category | Exports |
35519
36334
  | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35520
36335
  | Classes | `Schema`, `PipeSchema`, `SpellError`, `SpellValidationError` |
35521
- | Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages` |
36336
+ | Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages`, `createParseContext`, `withMessages`, `withLogger` |
35522
36337
  | Descriptor helpers | `descriptorToJsonSchema`, `schemaToJsonSchema` |
35523
36338
  | Pure validators | `hasMaxLength`, `hasMinLength`, `isArray`, `isBoolean`, `isDate`, `isInteger`, `isMultipleOf`, `isNegative`, `isNonNegative`, `isNullOrUndefined`, `isNumber`, `isPositive`, `isString`, `isInRange` |
35524
36339
  | String format validators | `isBase64`, `isBase64url`, `isCuid`, `isCuid2`, `isDuration`, `isEmail`, `isEmoji`, `isHex`, `isHexColor`, `isIp`, `isIsoDate`, `isIsoDateTime`, `isJwt`, `isNanoid`, `isNumeric`, `isSemver`, `isSlug`, `isTime`, `isUlid`, `isUrl`, `isUuid` |
@@ -35616,7 +36431,7 @@ Builder reference:
35616
36431
  | `s.or(a, b)` | `UnionSchema` | Alias for `s.union()` with exactly two schemas. |
35617
36432
  | `s.and(a, b)` | `IntersectSchema` | Alias for `s.intersect()` with two schemas. |
35618
36433
  | `s.intersect(...items)` | `IntersectSchema` | Merges compatible outputs deeply and safely. |
35619
- | `s.variant(key, map)` | `VariantSchema` | Discriminated object union. Async field validators on branch objects are silently skipped — use `s.object()` with `parseAsync()` directly if you need async branch-field rules. |
36434
+ | `s.variant(key, map)` | `VariantSchema` | Discriminated object union with async-aware branch parsing in `parseAsync()`. |
35620
36435
  | `s.lazy(getter)` | `LazySchema` | Recursive schema definitions. |
35621
36436
  | `s.instanceof(cls)` | `InstanceOfSchema` | Runtime class instance checks. |
35622
36437
 
@@ -36496,7 +37311,7 @@ const Signup = s.object({ confirm: s.string(), password: s.string() }).validate(
36496
37311
  });
36497
37312
  ```
36498
37313
 
36499
- Async rules work in the same method. Spell awaits them only in `parseAsync()` async callbacks passed to `validate()` are silently skipped in synchronous `parse()`.
37314
+ Async rules work in the same method. Spell awaits them in `parseAsync()`, including nested schemas (for example inside `s.variant(...)` branches). Async callbacks passed to `validate()` are still skipped in synchronous `parse()`.
36500
37315
 
36501
37316
  ```ts
36502
37317
  import { s } from '@vielzeug/spell';
@@ -36599,7 +37414,7 @@ Descriptors are serializable snapshots of the schema structure. Use `toDescripto
36599
37414
 
36600
37415
  ## Messages
36601
37416
 
36602
- Use `setMessages()` to replace the active validation message catalog. Each call replaces the current overrides — it does not accumulate.
37417
+ Use `setMessages()` to replace the active validation message catalog globally. Each call replaces the current overrides — it does not accumulate.
36603
37418
 
36604
37419
  ```ts
36605
37420
  import { resetMessages, setMessages } from '@vielzeug/spell';
@@ -36627,6 +37442,35 @@ setLogger(null); // silence
36627
37442
  setLogger((msg) => myLogger.warn(msg)); // redirect
36628
37443
  ```
36629
37444
 
37445
+ Use `createParseContext()` when you need request-scoped message overrides without mutating global state.
37446
+
37447
+ ```ts
37448
+ import { createParseContext, s } from '@vielzeug/spell';
37449
+
37450
+ const User = s.object({ email: s.string().email() });
37451
+
37452
+ User.safeParse(
37453
+ { email: 'ada@example.com', extra: true },
37454
+ createParseContext({ object: { invalidKeys: () => 'No unknown keys in this endpoint' } }),
37455
+ );
37456
+ ```
37457
+
37458
+ Use `withMessages()` / `withLogger()` to apply temporary global overrides inside a bounded sync or async callback.
37459
+
37460
+ ```ts
37461
+ import { s, withLogger, withMessages } from '@vielzeug/spell';
37462
+
37463
+ const Email = s.string().email();
37464
+
37465
+ await withMessages({ string: { email: () => 'Scoped email' } }, async () => {
37466
+ Email.safeParse('bad'); // issue message uses "Scoped email"
37467
+ });
37468
+
37469
+ withLogger((msg) => myLogger.warn(msg), () => {
37470
+ s.string().regex(/^a$/).regex(/^b$/);
37471
+ });
37472
+ ```
37473
+
36630
37474
  To integrate with `@vielzeug/lingua`, call `setMessages()` from your locale change callback:
36631
37475
 
36632
37476
  ```ts
@@ -40270,888 +41114,267 @@ const db = createIndexedDB({
40270
41114
  ## @vielzeug/ward
40271
41115
 
40272
41116
  **Category:** auth
40273
- **Keywords:** rbac, permissions, roles, access-control, authorization, wildcards, predicates
40274
- **Key exports:** createWard, allow, deny, ruleFor, predicate, owns, matchesPattern, patternCovers, guardRequest, guardRequestWith, WardPredicateError, WILDCARD (+20 more)
40275
- **Related:** rune, wayfinder, conduit
40276
41117
 
40277
41118
  ### Overview
40278
41119
 
40279
- ## Why Ward?
40280
-
40281
- Spreading authorization checks across route handlers, service methods, and UI components leads to inconsistent enforcement, no central place to audit permissions, and rules that drift as the codebase grows.
40282
-
40283
- ```ts
40284
- // Before — ad-hoc checks scattered across handlers
40285
- function deletePost(user: User, post: Post) {
40286
- if (user.role !== 'admin' && user.id !== post.authorId) {
40287
- throw new Error('Forbidden');
40288
- }
40289
- // no logging, no explain, no wildcard, no composition
40290
- }
40291
-
40292
- // After — Ward declarative rules with typed enforcement
40293
- import { allow, createWard, predicate } from '@vielzeug/ward';
40294
-
40295
- const ward = createWard([
40296
- ...allow('admin', '*', ['*']),
40297
- ...allow('author', 'post', ['delete', 'edit'], { when: predicate.owns('authorId') }),
40298
- ]);
40299
-
40300
- const guard = ward.forUser(currentUser);
40301
- guard.explain('post', 'delete', post); // WardDecision — auditable
40302
- guard.allowedActions('post', ['delete', 'edit'], post); // ['delete', 'edit'] or []
40303
- ```
40304
-
40305
- | Feature | Ward | CASL | AccessControl |
40306
- | --------------------------------- | ------------------------------------------------------ | ------------------------------------------ | --------------------------------------------------------------------- |
40307
- | Bundle size | | ~11 kB | ~7 kB |
40308
- | Typed rule contracts | | Partial | Partial |
40309
- | Deterministic deny precedence | | | |
40310
- | Rule predicates with request data | | | (manual patterns) |
40311
- | Wildcard action support | | | |
40312
- | Principal-bound API | (`forUser`) | Partial | |
40313
- | Explainable decisions | | Partial | |
40314
- | Zero dependencies | | | |
40315
-
40316
- **Use Ward when** you want predictable authorization decisions with typed rules and explicit introspection APIs.
40317
-
40318
- **Consider larger policy frameworks when** you need ecosystem-specific integrations or policy storage outside application code.
40319
-
40320
- ## Installation
40321
-
40322
- ```sh [pnpm]
40323
- pnpm add @vielzeug/ward
40324
- ```
40325
-
40326
- ```sh [npm]
40327
- npm install @vielzeug/ward
40328
- ```
40329
-
40330
- ```sh [yarn]
40331
- yarn add @vielzeug/ward
40332
- ```
41120
+ `@vielzeug/ward` is a zero-dependency authorization engine for role/resource/action policies.
40333
41121
 
40334
41122
  ## Quick Start
40335
41123
 
40336
41124
  ```ts
40337
- import { ANONYMOUS, WILDCARD, allow, createWard, deny, predicate } from '@vielzeug/ward';
41125
+ import { ANONYMOUS, WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
40338
41126
 
40339
41127
  const ward = createWard([
40340
- // Multi-role rule: viewer and editor can both read
40341
- ...allow(['viewer', 'editor'], 'posts', ['read']),
40342
- // Editor can update their own posts
40343
- ...allow('editor', 'posts', ['update'], { when: predicate.owns('authorId') }),
40344
- // High-priority deny overrides any allow rule for blocked principals
41128
+ ...allow([ANONYMOUS, 'viewer'], 'posts', ['read']),
41129
+ ...allow('editor', 'posts', ['update'], { when: owns('authorId') }),
40345
41130
  ...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }),
40346
- // Anonymous visitors can read posts
40347
- ...allow(ANONYMOUS, 'posts', ['read']),
40348
- ]);
40349
-
40350
- const editor = { id: 'u1', roles: ['editor'] };
40351
-
40352
- // Full decision — narrow on .allowed for type-safe access to .reason / .rule
40353
- const decision = ward.explain(editor, 'posts', 'update', { authorId: 'u2' });
40354
- if (!decision.allowed) console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
40355
-
40356
- // Decision trace — all candidates with index, score, priority, won (no logger fired)
40357
- const trace = ward.trace(editor, 'posts', 'read');
40358
- trace.candidates.forEach((c) => console.log(`Rule[${c.index}]`, c.rule.effect, c.score, c.won));
40359
-
40360
- // Detect policy conflicts at startup
40361
- const conflicts = ward.detectConflicts();
40362
- if (conflicts.length > 0) console.warn('Policy conflicts:', conflicts);
40363
-
40364
- const bound = ward.forUser(editor);
40365
-
40366
- bound.allowedActions('posts', ['read', 'update', 'delete']);
40367
- bound.explain('posts', 'update', { authorId: 'u2' });
40368
- bound.checkAll([
40369
- { resource: 'posts', action: 'read' },
40370
- { resource: 'posts', action: 'update', data: { authorId: 'u1' } },
40371
- ]);
40372
- bound.rulesInScope('posts');
40373
- ```
40374
-
40375
- ## Features
40376
-
40377
- - One rule primitive: `WardRule` passed directly to `createWard(rules)`
40378
- - **Rule factories**: `allow(role, resource, actions, opts?)` and `deny(...)` — readable, spreadable arrays
40379
- - **Grouped predicate namespace**: `predicate.owns()`, `predicate.and()`, `predicate.or()`, `predicate.not()`
40380
- - **Multi-role rules**: `role` accepts a string or an array of strings (OR semantics)
40381
- - Decision methods: `ward.explain(principal, resource, action, data?)` — full `WardDecision` object
40382
- - Batch decisions: `ward.checkAll(principal, checks)`
40383
- - Full decision trace: `ward.trace(principal, resource, action, data?)` — all candidates with `index`, `score`, `priority`, `won`; **does not fire the logger**
40384
- - Rule introspection: `ward.rulesInScope(principal, resource, data?)`
40385
- - Action enumeration: `ward.allowedActions(principal, resource, knownActions, data?)`
40386
- - Policy conflict detection: `ward.detectConflicts()` — lazy, cached, O(n²)
40387
- - Explicit wildcard support with `WILDCARD`
40388
- - Anonymous checks via `null` principal plus `ANONYMOUS` role rules
40389
- - Ownership helper via `owns(attributeKey)` or `predicate.owns(attributeKey)`
40390
- - Principal-bound API via `ward.forUser(principal)` — principal snapshotted at bind time
40391
- - Framework-agnostic guards: `guardRequest`, `guardRequestWith`
40392
- - **Debug logging** via `debugWard()` (`@vielzeug/ward/devtools`) — logs `explain` and `checkAll` decisions with `[ward:decision]` prefixes; tree-shaken from production bundles
40393
-
40394
- ## Documentation
40395
-
40396
- - [Usage Guide](./usage.md)
40397
- - [API Reference](./api.md)
40398
- - [Examples](./examples.md)
40399
-
40400
- ## See Also
40401
-
40402
- - [Wayfinder](../wayfinder/index.md) for route-level authorization middleware.
40403
- - [Rune](../rune/index.md) for structured audit logs of permission checks.
40404
- - [Herald](../herald/index.md) for event-driven permission workflows.
40405
-
40406
- ### API Reference
40407
-
40408
- ## API Overview
40409
-
40410
- | Symbol | Purpose | Execution | Common gotcha |
40411
- | ------------------------------------------------------------------------ | ---------------------------------------------------- | --------- | ------------------------------------------------------------------------------------ |
40412
- | `createWard(rules, options?)` | Create an immutable ward instance | Sync | Rules cannot be mutated after creation |
40413
- | `allow(role, resource, actions, options?)` | Create allow rules — returns `WardRule[]` | Sync | Spread into `createWard([ ...allow(...) ])` — returns an array |
40414
- | `deny(role, resource, actions, options?)` | Create deny rules — returns `WardRule[]` | Sync | Same spreading pattern as `allow` |
40415
- | `ruleFor(effect, role, resource, actions, options?)` | Low-level rule factory (effect as first arg) | Sync | Prefer `allow`/`deny` for readability |
40416
- | `predicate.owns(attributeKey)` | Ownership predicate — `data[key] === principal.id` | Sync | Returns `false` when `data` is absent, not an object, or key not an own property |
40417
- | `predicate.and(...preds)` | Combine predicates with AND | Sync | Zero arguments → always returns `true` (vacuously) |
40418
- | `predicate.or(...preds)` | Combine predicates with OR | Sync | Zero arguments → always returns `false` |
40419
- | `predicate.not(pred)` | Invert a predicate | Sync | — |
40420
- | `owns(attributeKey)` | Top-level alias for `predicate.owns` | Sync | Prefer `predicate.owns` when using other `predicate.*` helpers |
40421
- | `matchesPattern(pattern, value)` | Test a pattern against a concrete string | Sync | Works for both resources and actions (namespace wildcards) |
40422
- | `patternCovers(broad, narrow)` | Test whether one pattern statically covers another | Sync | Used by `detectConflicts`; exported for custom tooling |
40423
- | `ward.checkAll(principal, checks)` | Evaluate multiple decisions in one call | Sync | Returns `WardDecisionResult[]` — each entry includes originating `resource`+`action` |
40424
- | `ward.explain(principal, resource, action, data?)` | Full decision object with deny reason | Sync | `rule` only present on `'allow'` and `'explicit-deny'` variants; fires logger |
40425
- | `ward.trace(principal, resource, action, data?)` | Decision trace with all matching candidates | Sync | **Does not fire the logger** — use `explain` when logger output is needed |
40426
- | `ward.allowedActions(principal, resource, knownActions, data?)` | List allowed actions; no logger | Sync | Wildcard-action rules require a non-empty `knownActions` |
40427
- | `ward.rulesInScope(principal, resource, data?)` | Rules in scope for introspection; no logger | Sync | Without `data`, predicate rules appear unfiltered |
40428
- | `ward.detectConflicts()` | Lazily detect and cache policy conflicts | Sync | O(n²); predicate-gated rules excluded from static analysis |
40429
- | `ward.forUser(principal)` | Create a principal-bound ward view | Sync | Principal is deep-snapshotted at bind time |
40430
- | `guardRequest(ward, principal, resource, action, data?)` | Framework-agnostic sync guard — direct principal | Sync | Use `guardRequestWith` when the principal must be extracted from a request object |
40431
- | `guardRequestWith(ward, req, extractPrincipal, resource, action, data?)` | Framework-agnostic async guard — request + extractor | Async | Extractor may be async (e.g. JWT verification) |
40432
-
40433
- ## Package Entry Points
40434
-
40435
- | Import | Purpose |
40436
- | ------------------------- | ---------------------------------------- |
40437
- | `@vielzeug/ward` | Main exports and types |
40438
- | `@vielzeug/ward/devtools` | `debugWard` — decision logger (dev only) |
40439
-
40440
- ## Constants
40441
-
40442
- - `WILDCARD = '*'`
40443
- - `ANONYMOUS = 'anonymous'`
40444
-
40445
- `WILDCARD` can be used as role, resource, or action.
40446
-
40447
- ## WardRule Fields
40448
-
40449
- | Field | Type | Required | Description |
40450
- | ---------- | ----------------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
40451
- | `role` | `string \| readonly string[]` | | One role or an array of roles. A rule matches if the principal holds **any** of the listed roles (OR semantics). Use `WILDCARD` for all authenticated principals, `ANONYMOUS` for unauthenticated requests. |
40452
- | `resource` | `string` | | Resource identifier. Use `WILDCARD` to match any resource. |
40453
- | `action` | `string` | | Action identifier. Use `WILDCARD` to match any action. |
40454
- | `effect` | `'allow' \| 'deny'` | | Whether the rule grants or denies access. |
40455
- | `priority` | `number` | — | Higher value wins. Optional when authoring a rule (defaults to `0`); always present on rules returned from decisions/trace/conflicts. Must be a finite number. |
40456
- | `when` | `WardPredicate` | — | Runtime predicate evaluated only for authenticated principals. |
40457
-
40458
- ### Multi-Role Rules
40459
-
40460
- When `role` is an array, the rule matches if the principal holds **any** of the listed roles. This lets you consolidate rules that share identical permissions across several roles:
40461
-
40462
- ```ts
40463
- // Instead of three separate allow rules, write one:
40464
- const ward = createWard([
40465
- { role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' },
40466
- { role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' },
40467
- { role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' },
40468
41131
  ]);
40469
- ```
40470
-
40471
- `ANONYMOUS` works inside multi-role arrays too:
40472
-
40473
- ```ts
40474
- // Allows both unauthenticated visitors and authenticated viewers to read
40475
- { role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' }
40476
- ```
40477
-
40478
- For specificity scoring, a multi-role rule is treated as specific (score 1) unless the array contains `WILDCARD`.
40479
-
40480
- ## Core Functions
40481
-
40482
- ### `createWard()`
40483
-
40484
- ```ts
40485
- createWard(
40486
- rules?: readonly WardRule[],
40487
- options?: WardOptions,
40488
- ): Ward
40489
- ```
40490
41132
 
40491
- Creates an immutable ward instance with the given rules. All rules are compiled once at creation time — pass a new array to update the policy.
40492
-
40493
- **Parameters — `WardOptions`:**
40494
-
40495
- | Option | Type | Default | Description |
40496
- | -------------- | -------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------ |
40497
- | `logger` | `(context: WardLoggerContext) => void` | `undefined` | Called after every decision method (`explain`, `checkAll`, `trace`). Not called by `allowedActions` or `rulesInScope`. |
40498
- | `onConflict` | `(conflict: WardConflict) => void` | `undefined` | Called synchronously for each conflict detected at creation time. |
40499
- | `strict` | `boolean` | `false` | Throws immediately if any rule conflicts are detected. |
40500
- | `maxConflicts` | `number` | `Infinity` | Caps the number of conflicts returned by `detectConflicts()`. Set to `0` to disable conflict detection entirely. |
40501
-
40502
- **Winner selection** when multiple rules match:
40503
-
40504
- 1. Higher `priority` wins.
40505
- 2. On priority tie, higher specificity wins (`exact > ns:* > *`, applied independently to role, resource, and action).
40506
- 3. On specificity tie, `deny` beats `allow`.
40507
- 4. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins.
40508
-
40509
- **Returns:** `Ward`
40510
-
40511
- **Example:**
40512
-
40513
- ```ts
40514
- import { createWard, owns } from '@vielzeug/ward';
40515
-
40516
- const ward = createWard([
40517
- { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
40518
- { role: 'editor', resource: 'posts', action: 'update', effect: 'allow', when: owns('authorId') },
40519
- ]);
40520
- ```
40521
-
40522
- ## Ward Methods
40523
-
40524
- ### `checkAll()`
40525
-
40526
- ```ts
40527
- ward.checkAll(
40528
- principal: Principal,
40529
- checks: readonly WardCheck[],
40530
- ): WardDecisionResult[]
40531
- ```
40532
-
40533
- Evaluates each check independently and returns one `WardDecisionResult` per entry in the same order. Each result includes the originating `resource` and `action` fields, so callers do not need to zip the input array by index. Returns `[]` for an empty array without validating the principal.
40534
-
40535
- **Returns:** `WardDecisionResult[]`
40536
-
40537
- **Example:**
40538
-
40539
- ```ts
40540
- const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
40541
- { resource: 'posts', action: 'read' },
40542
- { resource: 'posts', action: 'update', data: { authorId: 'u1' } },
40543
- ]);
40544
- ```
40545
-
40546
- ---
40547
-
40548
- ### `allowedActions()`
40549
-
40550
- ```ts
40551
- ward.allowedActions(
40552
- principal: Principal,
40553
- resource: string,
40554
- knownActions: readonly TAction[],
40555
- data?: TData,
40556
- ): TAction[]
40557
- ```
40558
-
40559
- Returns the subset of `knownActions` that the principal is currently allowed to perform on `resource`. Evaluates wildcard-action rules against each entry in `knownActions`. Deduplicates the input list.
40560
-
40561
- `allowedActions` does **not** invoke the logger. Use `checkAll` if you need an auditable batch decision.
40562
-
40563
- **Returns:** `TAction[]`
40564
-
40565
- **Example:**
40566
-
40567
- ```ts
40568
- // Resolves wildcard-action rules against the provided list
40569
- const actions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete']);
41133
+ const principal = { id: 'u1', roles: ['editor'] };
40570
41134
 
40571
- // With runtime data for predicate-gated rules
40572
- const owned = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], {
40573
- authorId: 'u1',
41135
+ const decision = ward.explain({
41136
+ principal,
41137
+ resource: 'posts',
41138
+ action: 'update',
41139
+ data: { authorId: 'u2' },
40574
41140
  });
40575
41141
  ```
40576
41142
 
40577
- ---
40578
-
40579
- ### `explain()`
40580
-
40581
- ```ts
40582
- ward.explain(
40583
- principal: Principal,
40584
- resource: string,
40585
- action: TAction,
40586
- data?: TData,
40587
- ): WardDecision
40588
- ```
40589
-
40590
- Returns a full decision object including the winning rule (for allow and explicit deny). The returned `rule` object is **frozen** — mutations throw `TypeError`. Uses `'rule' in decision` to safely narrow across all three variants.
41143
+ `explain()` returns a discriminated decision (`allowed: true | false`) and optional matching `rule`.
40591
41144
 
40592
- **Returns:** `WardDecision`
41145
+ ## Bound View
40593
41146
 
40594
- **Example:**
41147
+ Use `forUser()` when checking many permissions for the same principal:
40595
41148
 
40596
41149
  ```ts
40597
- const decision = ward.explain({ id: 'u1', roles: ['editor'] }, 'posts', 'delete');
41150
+ const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
40598
41151
 
40599
- if (!decision.allowed) {
40600
- console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
40601
- if (decision.reason === 'explicit-deny') {
40602
- console.log(decision.rule.effect); // safe — rule is present
40603
- }
40604
- }
41152
+ bound.explain({ resource: 'posts', action: 'read' });
41153
+ bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
40605
41154
  ```
40606
41155
 
40607
- ---
40608
-
40609
- ### `trace()`
41156
+ ## Middleware Guards
40610
41157
 
40611
41158
  ```ts
40612
- ward.trace(
40613
- principal: Principal,
40614
- resource: string,
40615
- action: TAction,
40616
- data?: TData,
40617
- ): WardTrace
40618
- ```
40619
-
40620
- Returns the complete decision trace: every rule that matched before the winner was selected, plus the final `WardDecision`. Each candidate exposes `priority`, `score`, `rule`, and a `won` flag.
40621
-
40622
- `trace()` fires the logger with the same context as `explain()`. Switching from `explain` to `trace` for richer diagnostics does not silently drop audit records.
40623
-
40624
- **Returns:** `WardTrace`
40625
-
40626
- **Example:**
40627
-
40628
- ```ts
40629
- const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read');
41159
+ import { guardRequest, guardRequestWith } from '@vielzeug/ward';
40630
41160
 
40631
- candidates.forEach(({ rule, priority, score, won }) => {
40632
- console.log(rule.effect, priority, score, won ? '← winner' : '');
41161
+ const direct = guardRequest({
41162
+ ward,
41163
+ principal,
41164
+ resource: 'posts',
41165
+ action: 'read',
40633
41166
  });
40634
- ```
40635
-
40636
- ---
40637
-
40638
- ### `rulesInScope()`
40639
41167
 
40640
- ```ts
40641
- ward.rulesInScope(
40642
- principal: Principal,
40643
- resource: string,
40644
- data?: TData,
40645
- ): ReadonlyArray>>
40646
- ```
40647
-
40648
- Returns all rules matching the principal/resource combination regardless of action. When `data` is provided, predicate rules are also evaluated and excluded if they do not match. Without `data`, predicate-gated rules appear unfiltered. Does not invoke the logger.
40649
-
40650
- **Returns:** `ReadonlyArray>>`
40651
-
40652
- **Example:**
40653
-
40654
- ```ts
40655
- const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts');
40656
- const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' });
40657
- ```
40658
-
40659
- ---
40660
-
40661
- ### `detectConflicts()`
40662
-
40663
- ```ts
40664
- ward.detectConflicts(): WardConflict[]
40665
- ```
40666
-
40667
- Returns all rule conflicts in the policy. Lazily computed and cached — every call after the first returns the same array reference. O(n²) in the number of rules.
40668
-
40669
- Two conflict kinds, narrowable by `kind`:
40670
-
40671
- - **`'duplicate'`** — two predicate-free rules share the same (role set, resource, action). Fields: `ruleA`/`indexA` (first-declared, wins) and `ruleB`/`indexB` (unreachable).
40672
- - **`'shadowed'`** — a higher-ranked predicate-free rule covers the narrower rule's patterns entirely. Fields: `shadowingRule`/`shadowingIndex` (always wins) and `shadowedRule`/`shadowedIndex` (can never win).
40673
-
40674
- Rules with a `when` predicate are excluded from both checks — their applicability can only be determined at runtime.
40675
-
40676
- **Returns:** `WardConflict[]`
40677
-
40678
- **Example:**
40679
-
40680
- ```ts
40681
- const conflicts = ward.detectConflicts();
40682
-
40683
- conflicts.forEach((c) => {
40684
- if (c.kind === 'duplicate') {
40685
- console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`);
40686
- } else {
40687
- console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`);
40688
- }
41168
+ const extracted = await guardRequestWith({
41169
+ ward,
41170
+ req,
41171
+ extractPrincipal: async (request) => request.user ?? null,
41172
+ resource: 'posts',
41173
+ action: 'read',
40689
41174
  });
40690
41175
  ```
40691
41176
 
40692
- ---
40693
-
40694
- ### `forUser()`
40695
-
40696
- ```ts
40697
- ward.forUser(principal: UserPrincipal): BoundWard
40698
- ```
40699
-
40700
- Creates a principal-bound view of the ward. The principal — including nested `attributes` — is deep-snapshotted at call time; subsequent mutations to the original object have no effect on the bound view.
40701
-
40702
- **Returns:** `BoundWard`
40703
-
40704
- **Methods on `BoundWard`:**
40705
-
40706
- | Method | Signature | Description |
40707
- | ---------------- | ---------------------------------------------- | ------------------------------ |
40708
- | `checkAll` | `(checks) => WardDecisionResult[]` | Batch decisions |
40709
- | `allowedActions` | `(resource, knownActions, data?) => TAction[]` | Action enumeration (no logger) |
40710
- | `explain` | `(resource, action, data?) => WardDecision` | Full decision with reason |
40711
- | `rulesInScope` | `(resource, data?) => ReadonlyArray>` | Rule introspection (no logger) |
40712
- | `trace` | `(resource, action, data?) => WardTrace` | Decision trace (does not fire the logger) |
40713
-
40714
- **Example:**
40715
-
40716
- ```ts
40717
- const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
41177
+ See the [usage guide](./usage.md), [API reference](./api.md), and [examples](./examples/blog-roles.md).
40718
41178
 
40719
- bound.explain('posts', 'read').allowed;
40720
- bound.checkAll([
40721
- { resource: 'posts', action: 'read' },
40722
- { resource: 'posts', action: 'update', data: { authorId: 'u1' } },
40723
- ]);
40724
- bound.allowedActions('posts', ['read', 'update', 'delete']);
40725
- bound.allowedActions('posts', ['read', 'update', 'delete'], { authorId: 'u1' });
40726
- bound.explain('posts', 'delete');
40727
- bound.trace('posts', 'read');
40728
- bound.rulesInScope('posts');
40729
- ```
41179
+ ### API Reference
40730
41180
 
40731
- ## Helper Functions
41181
+ ## Core Factory
40732
41182
 
40733
- ### `allow()` / `deny()`
41183
+ ### `createWard(rules, options?)`
40734
41184
 
40735
41185
  ```ts
40736
- allow(
40737
- role: string | readonly string[],
40738
- resource: string | typeof WILDCARD,
40739
- actions: readonly (TAction | typeof WILDCARD)[],
40740
- options?: { priority?: number; when?: WardPredicate },
40741
- ): WardRule[]
40742
-
40743
- deny(
40744
- role: string | readonly string[],
40745
- resource: string | typeof WILDCARD,
40746
- actions: readonly (TAction | typeof WILDCARD)[],
40747
- options?: { priority?: number; when?: WardPredicate },
40748
- ): WardRule[]
41186
+ createWard(
41187
+ rules: ReadonlyArray>>,
41188
+ options?: WardOptions,
41189
+ ): Ward;
40749
41190
  ```
40750
41191
 
40751
- Ergonomic rule factories one `WardRule` per action, with `effect` fixed to `'allow'` or `'deny'`. Spread the result into the array passed to `createWard`.
41192
+ Creates an immutable ward instance.
40752
41193
 
40753
- **Returns:** `WardRule[]`
41194
+ ## Rule Builders
40754
41195
 
40755
- **Example:**
41196
+ ### `allow(role, resource, actions, options?)`
41197
+ ### `deny(role, resource, actions, options?)`
41198
+ ### `ruleFor(effect, role, resource, actions, options?)`
40756
41199
 
40757
- ```ts
40758
- import { WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
40759
-
40760
- const ward = createWard([
40761
- ...allow(['viewer', 'editor'], 'posts', ['read']),
40762
- ...allow('editor', 'posts', ['update'], { when: owns('authorId'), priority: 5 }),
40763
- ...deny('blocked', WILDCARD, [WILDCARD], { priority: 100 }),
40764
- ]);
40765
- ```
41200
+ All three return `WardRule[]` (one rule per action).
40766
41201
 
40767
- ---
41202
+ ## Ward Methods
40768
41203
 
40769
- ### `ruleFor()`
41204
+ ### `checkAll(principal, checks)`
40770
41205
 
40771
41206
  ```ts
40772
- ruleFor(
40773
- effect: 'allow' | 'deny',
40774
- role: string | readonly string[],
40775
- resource: string | typeof WILDCARD,
40776
- actions: readonly (TAction | typeof WILDCARD)[],
40777
- options?: { priority?: number; when?: WardPredicate },
40778
- ): WardRule[]
41207
+ checkAll(
41208
+ principal: UserPrincipal,
41209
+ checks: ReadonlyArray>,
41210
+ ): WardDecisionResult[];
40779
41211
  ```
40780
41212
 
40781
- Low-level rule factory with `effect` as the first argument. `allow()` and `deny()` are thin wrappers over `ruleFor()` — prefer them for readability; use `ruleFor()` when the effect is only known dynamically.
40782
-
40783
- **Returns:** `WardRule[]`
40784
-
40785
- **Example:**
41213
+ ### `explain(input)`
40786
41214
 
40787
41215
  ```ts
40788
- import { ruleFor } from '@vielzeug/ward';
40789
-
40790
- ruleFor('allow', 'viewer', 'posts', ['read', 'update']);
40791
- ruleFor('deny', ['blocked', 'suspended'], WILDCARD, [WILDCARD], { priority: 100 });
41216
+ explain(input: WardExplainInput): WardDecision;
40792
41217
  ```
40793
41218
 
40794
- ---
40795
-
40796
- ### `owns()` / `predicate`
41219
+ `WardExplainInput`:
40797
41220
 
40798
41221
  ```ts
40799
- owns(attributeKey: keyof TData & string): WardPredicate
40800
-
40801
- const predicate: {
40802
- and(...preds: WardPredicate[]): WardPredicate;
40803
- not(pred: WardPredicate): WardPredicate;
40804
- or(...preds: WardPredicate[]): WardPredicate;
40805
- owns(attributeKey: keyof TData & string): WardPredicate;
40806
- };
41222
+ {
41223
+ principal: UserPrincipal;
41224
+ resource: string;
41225
+ action: TAction;
41226
+ data?: TData;
41227
+ }
40807
41228
  ```
40808
41229
 
40809
- `owns()` returns a predicate that allows the action when `principal.id === data[attributeKey]`. Returns `false` when `data` is absent, not an object, or the attribute is not an own property (guards against prototype-inherited values). `predicate.owns` is the same function, grouped under the `predicate` namespace alongside the combinators:
40810
-
40811
- - `predicate.and(...preds)` — all predicates must return `true`. Zero arguments → `true` (vacuously).
40812
- - `predicate.or(...preds)` — at least one predicate must return `true`. Zero arguments → `false`.
40813
- - `predicate.not(pred)` — inverts a predicate.
40814
-
40815
- `owns()` (and any `when` predicate) must only be used with rules that require authentication (non-`ANONYMOUS` role). Predicates are skipped for unauthenticated requests — pairing `owns` with `ANONYMOUS` produces a rule that can never match.
40816
-
40817
- **Example:**
41230
+ ### `trace(input)`
40818
41231
 
40819
41232
  ```ts
40820
- import { allow, predicate } from '@vielzeug/ward';
40821
-
40822
- allow('editor', 'posts:*', ['update'], { when: predicate.owns('authorId') });
40823
- allow('user', 'posts:*', ['read'], { when: predicate.and(predicate.owns('authorId'), isBusinessHours) });
41233
+ trace(input: WardTraceInput): WardTrace;
40824
41234
  ```
40825
41235
 
40826
- ---
41236
+ Same request shape as `explain()`. Returns winner + candidate list. Does not fire logger.
40827
41237
 
40828
- ### `matchesPattern()`
41238
+ ### `allowedActions(input)`
40829
41239
 
40830
41240
  ```ts
40831
- matchesPattern(pattern: string, value: string): boolean
41241
+ allowedActions(
41242
+ input: WardAllowedActionsInput,
41243
+ ): TKnown[];
40832
41244
  ```
40833
41245
 
40834
- Tests whether a pattern matches a concrete value string. Works for both resource patterns and action patterns.
40835
-
40836
- **Pattern semantics:**
40837
-
40838
- | Pattern | Matches |
40839
- | ----------- | -------------------------------------------------------------------- |
40840
- | `*` | Any value |
40841
- | `posts` | Exactly `posts` |
40842
- | `posts:*` | Any value starting with `posts:` (e.g. `posts:123`, `posts:draft:1`) |
40843
- | `posts:123` | Exactly `posts:123` |
40844
- | `read:*` | Any action starting with `read:` (e.g. `read:own`, `read:all`) |
40845
-
40846
- **Example:**
41246
+ Input shape:
40847
41247
 
40848
41248
  ```ts
40849
- import { matchesPattern } from '@vielzeug/ward';
40850
-
40851
- matchesPattern('posts:*', 'posts:123'); // true
40852
- matchesPattern('posts:*', 'comments:1'); // false
40853
- matchesPattern('read:*', 'read:own'); // true
41249
+ {
41250
+ principal: UserPrincipal;
41251
+ resource: string;
41252
+ knownActions: readonly TKnown[];
41253
+ data?: TData;
41254
+ }
40854
41255
  ```
40855
41256
 
40856
- ---
40857
-
40858
- ### `patternCovers()`
41257
+ ### `rulesInScope(input)`
40859
41258
 
40860
41259
  ```ts
40861
- patternCovers(broad: string, narrow: string): boolean
41260
+ rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
40862
41261
  ```
40863
41262
 
40864
- Returns `true` if every concrete value matching `narrow` also matches `broad`. This is the static coverage relation used by `detectConflicts`. Exported for custom policy analysis tooling.
40865
-
40866
- **Example:**
41263
+ Input shape:
40867
41264
 
40868
41265
  ```ts
40869
- import { patternCovers } from '@vielzeug/ward';
40870
-
40871
- patternCovers('*', 'posts:*'); // true
40872
- patternCovers('posts:*', 'posts:123'); // true
40873
- patternCovers('posts:*', '*'); // false
40874
- patternCovers('posts', 'posts:*'); // false
41266
+ {
41267
+ principal: UserPrincipal;
41268
+ resource: string;
41269
+ data?: TData;
41270
+ }
40875
41271
  ```
40876
41272
 
40877
- ---
40878
-
40879
- ### Middleware Factories
40880
-
40881
- #### `guardRequest()`
41273
+ ### `detectConflicts()`
40882
41274
 
40883
41275
  ```ts
40884
- guardRequest(
40885
- ward: Ward,
40886
- principal: Principal,
40887
- resource: string,
40888
- action: TAction,
40889
- data?: TData,
40890
- ): GuardResult
41276
+ detectConflicts(): WardConflict[];
40891
41277
  ```
40892
41278
 
40893
- Framework-agnostic synchronous guard for a known principal. Returns a `GuardResult`:
41279
+ ### `forUser(principal)`
40894
41280
 
40895
41281
  ```ts
40896
- type GuardResult =
40897
- | { granted: true; principal: Principal }
40898
- | { granted: false; decision: WardDecision; principal: Principal; reason: 'explicit-deny' | 'no-matching-rule' };
41282
+ forUser(principal: UserPrincipal): BoundWard;
40899
41283
  ```
40900
41284
 
40901
- Use `guardRequestWith` when the principal must be resolved asynchronously from a request object.
41285
+ Returns a principal-bound view.
40902
41286
 
40903
- **Example:**
41287
+ ## `BoundWard` Methods
40904
41288
 
40905
41289
  ```ts
40906
- import { guardRequest } from '@vielzeug/ward';
40907
-
40908
- const result = guardRequest(ward, principal, 'posts', 'update');
40909
-
40910
- if (!result.granted) {
40911
- return response.status(403).json({ reason: result.reason });
41290
+ interface BoundWard {
41291
+ checkAll(checks: ReadonlyArray>): WardDecisionResult[];
41292
+ explain(input: BoundWardExplainInput): WardDecision;
41293
+ trace(input: BoundWardTraceInput): WardTrace;
41294
+ allowedActions(input: BoundWardAllowedActionsInput): TKnown[];
41295
+ rulesInScope(input: BoundWardRulesInScopeInput): ReadonlyArray>>;
40912
41296
  }
40913
41297
  ```
40914
41298
 
40915
- ---
40916
-
40917
- #### `guardRequestWith()`
41299
+ Bound input shapes remove `principal`:
40918
41300
 
40919
41301
  ```ts
40920
- guardRequestWith(
40921
- ward: Ward,
40922
- req: TReq,
40923
- extractPrincipal: (req: TReq) => Principal | Promise,
40924
- resource: string,
40925
- action: TAction,
40926
- data?: TData,
40927
- ): Promise>
41302
+ { resource: string; action: TAction; data?: TData } // explain/trace
41303
+ { resource: string; knownActions: readonly TKnown[]; data?: TData } // allowedActions
41304
+ { resource: string; data?: TData } // rulesInScope
40928
41305
  ```
40929
41306
 
40930
- Framework-agnostic async guard that first extracts the principal from a request object. The extractor may be async (e.g. to verify a JWT). Use `guardRequest` when the principal is already resolved.
40931
-
40932
- **Example:**
40933
-
40934
- ```ts
40935
- import { guardRequestWith } from '@vielzeug/ward';
41307
+ ## Predicate Helpers
40936
41308
 
40937
- const result = await guardRequestWith(ward, req, (req) => req.user ?? null, 'posts', 'update');
41309
+ ### `predicate.owns(attributeKey)`
41310
+ ### `predicate.and(...predicates)`
41311
+ ### `predicate.or(...predicates)`
41312
+ ### `predicate.not(predicate)`
41313
+ ### `owns(attributeKey)` (alias)
40938
41314
 
40939
- if (!result.granted) {
40940
- return response.status(403).json({ reason: result.reason });
40941
- }
40942
- ```
41315
+ Predicates run synchronously. Returning a Promise throws `WardPredicateError`.
40943
41316
 
40944
- ## Types
41317
+ ## Pattern Helpers
40945
41318
 
40946
- ### `UserPrincipal`
41319
+ ### `matchesPattern(pattern, value): boolean`
41320
+ ### `patternCovers(broad, narrow): boolean`
40947
41321
 
40948
- ```ts
40949
- type UserPrincipal = {
40950
- id: string;
40951
- roles: readonly string[];
40952
- attributes?: Record;
40953
- };
40954
- ```
41322
+ ## Middleware Guards
40955
41323
 
40956
- ### `Principal`
41324
+ ### `guardRequest(input)`
40957
41325
 
40958
41326
  ```ts
40959
- type Principal = UserPrincipal | null;
41327
+ guardRequest(
41328
+ input: GuardRequestInput,
41329
+ ): GuardResult;
40960
41330
  ```
40961
41331
 
40962
- `null` represents an unauthenticated (anonymous) user.
40963
-
40964
- ### `RuleContext`
41332
+ Input:
40965
41333
 
40966
41334
  ```ts
40967
- type RuleContext = {
41335
+ {
41336
+ ward: Ward;
40968
41337
  principal: UserPrincipal;
41338
+ resource: string;
41339
+ action: TAction;
40969
41340
  data?: TData;
40970
- };
40971
- ```
40972
-
40973
- ### `WardPredicate`
40974
-
40975
- ```ts
40976
- type WardPredicate = (ctx: RuleContext) => boolean;
40977
- ```
40978
-
40979
- ### `WardRule`
40980
-
40981
- The single rule shape — used both when authoring rules passed to `createWard` and when reading rules back from decisions, `trace()`, `rulesInScope()`, and `detectConflicts()`.
40982
-
40983
- ```ts
40984
- type WardRule = {
40985
- action: TAction | typeof WILDCARD;
40986
- effect: 'allow' | 'deny';
40987
- priority?: number; // defaults to 0
40988
- resource: string | typeof WILDCARD;
40989
- role: string | readonly string[];
40990
- when?: WardPredicate;
40991
- };
40992
- ```
40993
-
40994
- Internally, `createWard` normalizes each rule at compile time — `role` becomes a deduplicated `readonly string[]` and `priority` defaults to `0` — and freezes the result. Rules read back from `explain()`, `trace()`, `rulesInScope()`, or `detectConflicts()` are these normalized, frozen objects (`Readonly>`); mutating them throws `TypeError`.
40995
-
40996
- ### `WardDecision`
40997
-
40998
- Three distinct variants — use discriminated narrowing:
40999
-
41000
- ```ts
41001
- type WardDecision =
41002
- | { allowed: true; rule: WardRule }
41003
- | { allowed: false; reason: 'explicit-deny'; rule: WardRule }
41004
- | { allowed: false; reason: 'no-matching-rule' }; // no rule field
41005
- ```
41006
-
41007
- ```ts
41008
- const d = ward.explain(principal, 'posts', 'delete');
41009
-
41010
- if (d.allowed) {
41011
- console.log(d.rule.effect); // 'allow'
41012
- } else if (d.reason === 'explicit-deny') {
41013
- console.log(d.rule.effect); // 'deny'
41014
- } else {
41015
- // d.reason === 'no-matching-rule' — no rule field present
41016
41341
  }
41017
-
41018
- // Generic narrowing:
41019
- if ('rule' in d) console.log(d.rule);
41020
41342
  ```
41021
41343
 
41022
- ### `WardCheck`
41344
+ ### `guardRequestWith(input)`
41023
41345
 
41024
41346
  ```ts
41025
- type WardCheck = {
41026
- resource: string;
41027
- action: TAction;
41028
- data?: TData;
41029
- };
41347
+ guardRequestWith(
41348
+ input: GuardRequestWithInput,
41349
+ ): Promise>;
41030
41350
  ```
41031
41351
 
41032
- ### `WardLoggerContext`
41033
-
41034
- Structurally identical to `WardDecision` plus the request fields — narrow `rule` with the same `if (ctx.allowed)` pattern used for decisions:
41352
+ Input:
41035
41353
 
41036
41354
  ```ts
41037
- type WardLoggerContext = WardDecision & {
41355
+ {
41356
+ ward: Ward;
41357
+ req: TReq;
41358
+ extractPrincipal: PrincipalExtractor;
41359
+ resource: string;
41038
41360
  action: TAction;
41039
41361
  data?: TData;
41040
- principal: Principal;
41041
- resource: string;
41042
- };
41043
- ```
41044
-
41045
- ```ts
41046
- logger: (ctx) => {
41047
- if (ctx.allowed) {
41048
- console.log(ctx.rule.role); // no ?. needed — 'allowed: true' always carries a rule
41049
- } else if (ctx.reason === 'explicit-deny') {
41050
- console.log(ctx.rule.role); // 'explicit-deny' also carries a rule
41051
- }
41052
- },
41053
- ```
41054
-
41055
- ### `WardOptions`
41056
-
41057
- ```ts
41058
- type WardOptions = {
41059
- logger?: (context: WardLoggerContext) => void;
41060
- onConflict?: (conflict: WardConflict) => void;
41061
- strict?: boolean;
41062
- maxConflicts?: number;
41063
- };
41064
- ```
41065
-
41066
- ### `ConflictKind` / `WardConflict`
41067
-
41068
- `WardConflict` is a discriminated union, narrowable by `kind`:
41069
-
41070
- ```ts
41071
- type ConflictKind = 'duplicate' | 'shadowed';
41072
-
41073
- type WardConflict =
41074
- | {
41075
- kind: 'duplicate';
41076
- indexA: number; // first-declared rule (always wins)
41077
- indexB: number; // second-declared rule (unreachable)
41078
- ruleA: Readonly>;
41079
- ruleB: Readonly>;
41080
- }
41081
- | {
41082
- kind: 'shadowed';
41083
- shadowedIndex: number; // the rule that can never win
41084
- shadowedRule: Readonly>;
41085
- shadowingIndex: number; // the rule that always wins instead
41086
- shadowingRule: Readonly>;
41087
- };
41362
+ }
41088
41363
  ```
41089
41364
 
41090
- ### `WardTrace` / `WardTraceCandidate`
41091
-
41092
- ```ts
41093
- type WardTraceCandidate = {
41094
- index: number; // original index in the input array passed to createWard
41095
- priority: number;
41096
- rule: Readonly>;
41097
- score: number;
41098
- won: boolean;
41099
- };
41365
+ ## Devtools
41100
41366
 
41101
- type WardTrace = {
41102
- candidates: WardTraceCandidate[];
41103
- decision: WardDecision;
41104
- };
41105
- ```
41367
+ ### `debugWard(ward, logger?)`
41106
41368
 
41107
- ## `debugWard(rules, options?)`
41369
+ Sub-path import: `@vielzeug/ward/devtools`.
41108
41370
 
41109
41371
  ```ts
41110
41372
  import { debugWard } from '@vielzeug/ward/devtools';
41111
-
41112
- const permit = debugWard(rules);
41113
-
41114
- permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read');
41115
- // [ward:decision] allow (allow) viewer posts read
41116
-
41117
- permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'delete');
41118
- // [ward:decision] no-matching-rule viewer posts delete
41119
- ```
41120
-
41121
- Wraps `createWard()` with a `logger` pre-wired to `console.debug`. Returns the same `Ward` instance — all methods are identical to `createWard()`. Debug output fires on `explain()` and `checkAll()`; `trace()` never fires the logger (by design — see `trace()` above).
41122
-
41123
- Import from the dedicated sub-path so the `console.debug` reference is tree-shaken from production bundles when not imported.
41124
-
41125
- Accepts the same `options` as `createWard()` except `logger`, which is reserved for the debug output. All other options (`maxConflicts`, `onConflict`, `strict`) pass through unchanged.
41126
-
41127
- ### `WardDecisionResult`
41128
-
41129
- ```ts
41130
- type WardDecisionResult = WardDecision & {
41131
- action: TAction;
41132
- resource: string;
41133
- };
41134
- ```
41135
-
41136
- The return type of `checkAll()` — a `WardDecision` with the originating `resource` and `action` attached, so callers do not need to zip the result by index.
41137
-
41138
- ### `WardRequest`
41139
-
41140
- ```ts
41141
- type WardRequest = Record;
41142
41373
  ```
41143
41374
 
41144
- Base constraint for the request object type used in `guardRequestWith`. Any object type satisfies this constraint.
41145
-
41146
- ### `Ward` / `BoundWard`
41147
-
41148
- `Ward` is returned by `createWard()`. `BoundWard` is returned by `ward.forUser()` and omits `forUser` and `detectConflicts`. Full method signatures are documented in the sections above.
41149
-
41150
41375
  ### Usage Guide
41151
41376
 
41152
- ## Basic Usage
41153
-
41154
- Create a ward instance with an array of rules. Rules are compiled once at creation time.
41377
+ ## Create a Ward
41155
41378
 
41156
41379
  ```ts
41157
41380
  import { WILDCARD, createWard } from '@vielzeug/ward';
@@ -41159,592 +41382,124 @@ import { WILDCARD, createWard } from '@vielzeug/ward';
41159
41382
  const ward = createWard([
41160
41383
  { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
41161
41384
  { role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
41162
- // High-priority deny blocks the blocked role from every action on posts
41163
41385
  { role: 'blocked', resource: 'posts', action: WILDCARD, effect: 'deny', priority: 100 },
41164
41386
  ]);
41165
-
41166
- ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true
41167
- ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update').allowed; // false
41168
- ward.explain({ id: 'u2', roles: ['blocked'] }, 'posts', 'read').allowed; // false
41169
- ```
41170
-
41171
- To update the policy, create a new instance — rules are immutable after creation.
41172
-
41173
- ## Rule Factories
41174
-
41175
- Use `allow()` / `deny()` as an alternative to raw rule objects. Each produces one `WardRule` per action — spread the result into the array passed to `createWard`. They read naturally: "allow editor to read/update posts".
41176
-
41177
- ```ts
41178
- import { allow, createWard, owns } from '@vielzeug/ward';
41179
-
41180
- const ward = createWard([
41181
- ...allow(['viewer', 'editor'], 'posts', ['read']),
41182
- ...allow('editor', 'posts', ['update', 'delete'], { when: owns('authorId') }),
41183
- ]);
41184
- ```
41185
-
41186
- Pass `{ priority: n }` and/or `{ when: predicate }` as the fourth argument. `deny()` is the same shape with `effect: 'deny'` fixed:
41187
-
41188
- ```ts
41189
- import { WILDCARD, deny } from '@vielzeug/ward';
41190
-
41191
- deny('blocked', 'posts', [WILDCARD], { priority: 100 });
41192
- ```
41193
-
41194
- `ruleFor(effect, role, resource, actions, options?)` is the low-level factory that `allow()`/`deny()` wrap — use it when the effect is only known dynamically.
41195
-
41196
- ## Hierarchical Resources
41197
-
41198
- Use colon-namespaced patterns to scope rules to resource instances:
41199
-
41200
- ```ts
41201
- const ward = createWard([
41202
- // Applies to any resource under 'posts:' namespace
41203
- { role: 'editor', resource: 'posts:*', action: 'update', effect: 'allow' },
41204
- // Applies only to one specific post
41205
- { role: 'viewer', resource: 'posts:123', action: 'read', effect: 'allow' },
41206
- ]);
41207
-
41208
- ward.explain(editor, 'posts:456', 'update').allowed; // true — matches posts:*
41209
- ward.explain(viewer, 'posts:123', 'read').allowed; // true — exact match
41210
- ward.explain(viewer, 'posts:456', 'read').allowed; // false — no matching rule
41211
- ```
41212
-
41213
- The same namespace-wildcard syntax works for actions (action hierarchy):
41214
-
41215
- ```ts
41216
- // 'read:*' matches 'read:own', 'read:all', 'read:draft:1', etc.
41217
- const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read:*', effect: 'allow' }]);
41218
-
41219
- ward.explain(viewer, 'posts', 'read:own').allowed; // true
41220
- ward.explain(viewer, 'posts', 'read:all').allowed; // true
41221
- ward.explain(viewer, 'posts', 'write').allowed; // false
41222
41387
  ```
41223
41388
 
41224
- `matchesPattern(pattern, value)` is exported for custom integration code. `patternCovers(broad, narrow)` is exported to test whether one pattern statically covers another (used by `detectConflicts`).
41389
+ Rules are immutable after creation. Create a new ward to update policy.
41225
41390
 
41226
- ## Check Permissions
41391
+ ## Explain a Decision
41227
41392
 
41228
41393
  ```ts
41229
- const principal = { id: 'u1', roles: ['editor'] };
41230
-
41231
- ward.explain(principal, 'posts', 'read').allowed;
41232
- ward.explain(principal, 'posts', 'delete').allowed;
41233
- ward.explain(null, 'posts', 'read').allowed;
41234
- ```
41235
-
41236
- `principal` must be either:
41237
-
41238
- - `null` for anonymous users
41239
- - `{ id: string, roles: readonly string[] }` for authenticated users
41240
-
41241
- Malformed principal values throw errors.
41242
-
41243
- ## Bind a User with `forUser`
41244
-
41245
- `BoundWard` does not expose `detectConflicts()`. Run `ward.detectConflicts()` on the parent ward before calling `forUser()` — typically at startup or during policy initialization.
41246
-
41247
- ```ts
41248
- const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
41249
-
41250
- bound.explain('posts', 'read').allowed;
41251
- bound.explain('posts', 'update', { authorId: 'u1' }).allowed;
41252
- ```
41253
-
41254
- `forUser()` returns a reusable bound ward object and snapshots roles/attributes at binding time.
41255
-
41256
- ## Check Multiple Actions
41257
-
41258
- Ward has no dedicated "all"/"any" helper — use `checkAll()` and reduce with `Array.every` / `Array.some`:
41259
-
41260
- ```ts
41261
- const checks = [
41262
- { action: 'read', resource: 'posts' },
41263
- { action: 'update', resource: 'posts', data: { authorId: 'u1' } },
41264
- ] as const;
41265
-
41266
- const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, checks);
41394
+ const decision = ward.explain({
41395
+ principal: { id: 'u1', roles: ['editor'] },
41396
+ resource: 'posts',
41397
+ action: 'update',
41398
+ data: { authorId: 'u1' },
41399
+ });
41267
41400
 
41268
- const allAllowed = decisions.every((d) => d.allowed);
41269
- const anyAllowed = decisions.some((d) => d.allowed);
41401
+ if (decision.allowed) {
41402
+ console.log(decision.rule);
41403
+ } else {
41404
+ console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
41405
+ }
41270
41406
  ```
41271
41407
 
41272
- ## Batch Decisions with `checkAll`
41408
+ ## Batch Decisions
41273
41409
 
41274
41410
  ```ts
41275
- const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
41411
+ const results = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
41276
41412
  { resource: 'posts', action: 'read' },
41277
41413
  { resource: 'posts', action: 'update', data: { authorId: 'u1' } },
41278
41414
  ]);
41279
-
41280
- const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
41281
- const boundDecisions = bound.checkAll([
41282
- { resource: 'posts', action: 'read' },
41283
- { resource: 'posts', action: 'delete' },
41284
- ]);
41285
- ```
41286
-
41287
- `checkAll()` returns a `WardDecisionResult[]` — each entry is a `WardDecision` with the originating `resource` and `action` fields attached, so callers do not need to zip the result by index.
41288
-
41289
- ## List Allowed Actions
41290
-
41291
- `allowedActions(principal, resource, knownActions, data?)` returns the subset of `knownActions` that the principal is allowed to perform on `resource`.
41292
-
41293
- `knownActions` is required because Ward cannot enumerate actions on its own — an action defined with `WILDCARD` has no finite list of concrete values. Passing `knownActions` resolves wildcard-action rules against that set:
41294
-
41295
- ```ts
41296
- // Returns the subset of the provided list that is allowed
41297
- const actions = ward.allowedActions({ id: 'u1', roles: ['admin'] }, 'posts', ['read', 'update', 'delete']);
41298
-
41299
- // With runtime data for predicate-gated rules
41300
- const ownedActions = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], {
41301
- authorId: 'u1',
41302
- });
41303
41415
  ```
41304
41416
 
41305
- `allowedActions` does not invoke the logger — it is a side-effect-free enumeration helper.
41306
-
41307
- ## Inspect Rule Scope with `rulesInScope`
41417
+ ## Bound Ward (`forUser`)
41308
41418
 
41309
41419
  ```ts
41310
- const rules = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts');
41311
- const narrowed = ward.rulesInScope({ id: 'u1', roles: ['editor'] }, 'posts', { authorId: 'u1' });
41312
-
41313
41420
  const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
41314
- const boundRules = bound.rulesInScope('posts');
41315
- ```
41316
-
41317
- `rulesInScope()` is introspection-only. It returns rules in scope for the principal/resource pair and never mutates the ward.
41318
- If you pass `data`, Ward also filters predicate rules by whether they match that runtime payload.
41319
-
41320
- ## Explain Denials and Winners
41321
41421
 
41322
- ```ts
41323
- const decision = ward.explain({ id: 'u1', roles: ['editor'] }, 'posts', 'delete');
41324
-
41325
- if (!decision.allowed) {
41326
- console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
41327
- // decision.rule is only present for 'explicit-deny', not 'no-matching-rule'
41328
- if (decision.reason === 'explicit-deny') {
41329
- console.log(decision.rule.effect); // 'deny'
41330
- }
41331
- }
41422
+ bound.explain({ resource: 'posts', action: 'read' });
41423
+ bound.trace({ resource: 'posts', action: 'update', data: { authorId: 'u1' } });
41424
+ bound.rulesInScope({ resource: 'posts' });
41425
+ bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
41332
41426
  ```
41333
41427
 
41334
- `explain()` returns a **3-variant discriminated union**:
41428
+ `forUser()` snapshots the principal. Re-bind when roles/identity change.
41335
41429
 
41336
- | Variant | `allowed` | `reason` | `rule` |
41337
- | ------------- | --------- | -------------------- | --------------------- |
41338
- | Allow | `true` | — | The winning rule |
41339
- | Explicit deny | `false` | `'explicit-deny'` | The winning deny rule |
41340
- | No match | `false` | `'no-matching-rule'` | Not present |
41430
+ ## Allowed Actions
41341
41431
 
41342
- Use `'rule' in decision` to safely access the rule across all variants.
41343
-
41344
- ## Trace Decisions
41345
-
41346
- `trace()` returns the complete decision trace: every rule that matched the request before the winner was selected, with per-candidate scoring details.
41432
+ `allowedActions()` evaluates a provided action set:
41347
41433
 
41348
41434
  ```ts
41349
- const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read');
41350
-
41351
- candidates.forEach(({ rule, priority, score, won }) => {
41352
- console.log(rule.effect, priority, score, won ? '← winner' : '');
41435
+ const actions = ward.allowedActions({
41436
+ principal: { id: 'u1', roles: ['admin'] },
41437
+ resource: 'posts',
41438
+ knownActions: ['read', 'update', 'delete'] as const,
41353
41439
  });
41354
41440
  ```
41355
41441
 
41356
- `trace()` does **not** fire the logger — it is a side-channel-free inspection tool. Use `explain()` when you need the logger to fire and only need the final decision.
41357
-
41358
- `trace()` is also available on `BoundWard`: `bound.trace(resource, action, data?)`.
41359
-
41360
- ## Detect Policy Conflicts
41442
+ It does not fire the logger.
41361
41443
 
41362
- `detectConflicts()` performs a static O(n²) analysis of your rule set and returns all detected conflicts. The result is lazily computed — every call after the first returns the same array reference.
41444
+ ## Rule Introspection
41363
41445
 
41364
41446
  ```ts
41365
- const conflicts = ward.detectConflicts();
41366
-
41367
- conflicts.forEach((c) => {
41368
- if (c.kind === 'duplicate') {
41369
- console.warn(`Rule[${c.indexB}] is an unreachable duplicate of Rule[${c.indexA}]`);
41370
- } else {
41371
- console.warn(`Rule[${c.shadowedIndex}] is shadowed by Rule[${c.shadowingIndex}]`);
41372
- }
41447
+ const scoped = ward.rulesInScope({
41448
+ principal: { id: 'u1', roles: ['editor'] },
41449
+ resource: 'posts',
41373
41450
  });
41374
41451
  ```
41375
41452
 
41376
- Two conflict kinds, narrowable by `kind`:
41377
-
41378
- - **`'duplicate'`** — two predicate-free rules have the same (role set, resource, action). The second (`ruleB`/`indexB`) can never fire because the first (`ruleA`/`indexA`) always wins.
41379
- - **`'shadowed'`** — a higher-ranked predicate-free rule (`shadowingRule`/`shadowingIndex`) covers the other's (`shadowedRule`/`shadowedIndex`) patterns entirely. The shadowed rule can never win.
41380
-
41381
- Rules with a `when` predicate are excluded from both checks because their applicability is determined at runtime, not statically.
41453
+ Use optional `data` to filter predicate-gated matches.
41382
41454
 
41383
- To surface conflicts eagerly at startup:
41455
+ ## Trace Candidates
41384
41456
 
41385
41457
  ```ts
41386
- // Warn on conflicts
41387
- const ward = createWard(rules, {
41388
- onConflict: (c) =>
41389
- console.warn(c.kind === 'duplicate' ? `[ward] duplicate: Rule[${c.indexB}]` : `[ward] shadowed: Rule[${c.shadowedIndex}]`),
41458
+ const trace = ward.trace({
41459
+ principal: { id: 'u1', roles: ['editor', 'blocked'] },
41460
+ resource: 'posts',
41461
+ action: 'read',
41390
41462
  });
41391
41463
 
41392
- // Throw on first conflict (strict mode)
41393
- const ward = createWard(rules, { strict: true });
41394
-
41395
- // Cap O(n²) cost for large auto-generated policies
41396
- const ward = createWard(rules, { maxConflicts: 20 });
41397
- ```
41398
-
41399
- ## Use Dynamic Conditions with `when`
41400
-
41401
- ```ts
41402
- const ward = createWard([
41403
- {
41404
- role: 'editor',
41405
- resource: 'posts',
41406
- action: 'update',
41407
- effect: 'allow',
41408
- when: ({ principal, data }) => principal.id === data?.authorId,
41409
- },
41410
- ]);
41411
- ```
41412
-
41413
- `when` only runs for authenticated principals. For anonymous (`null`) checks, predicates are skipped and the rule does not match. Do not pair `owns()` or any `when` predicate with an `ANONYMOUS`-role rule — it can never match.
41414
-
41415
- ### Ownership Checks with `owns`
41416
-
41417
- ```ts
41418
- import { createWard, owns } from '@vielzeug/ward';
41419
-
41420
- const ward = createWard([
41421
- {
41422
- role: 'editor',
41423
- resource: 'posts',
41424
- action: 'update',
41425
- effect: 'allow',
41426
- when: owns('authorId'),
41427
- },
41428
- ]);
41429
- ```
41430
-
41431
- `owns()` is a convenience helper for the common `principal.id === data[attributeKey]` pattern.
41432
-
41433
- ### Attribute-Based Conditions (ABAC)
41434
-
41435
- ```ts
41436
- const ward = createWard([
41437
- {
41438
- role: 'editor',
41439
- resource: 'posts',
41440
- action: 'publish',
41441
- effect: 'allow',
41442
- when: ({ principal }) => principal.attributes?.tier === 'pro',
41443
- },
41444
- ]);
41445
- ```
41446
-
41447
- `principal.attributes` can store arbitrary user metadata for runtime policy checks.
41448
-
41449
- ## Multi-Role Rules
41450
-
41451
- The `role` field accepts either a single string or an array of strings. A rule matches if the principal holds **any** of the listed roles (OR semantics).
41452
-
41453
- Multi-role rules reduce repetition when several roles share identical permissions:
41454
-
41455
- ```ts
41456
- import { createWard } from '@vielzeug/ward';
41457
-
41458
- const ward = createWard([
41459
- // One rule instead of three separate allow rules
41460
- { role: ['viewer', 'editor', 'admin'], resource: 'posts', action: 'read', effect: 'allow' },
41461
- { role: ['editor', 'admin'], resource: 'posts', action: 'update', effect: 'allow' },
41462
- { role: 'admin', resource: 'posts', action: 'delete', effect: 'allow' },
41463
- ]);
41464
-
41465
- ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true
41466
- ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'update').allowed; // true
41467
- ward.explain({ id: 'u2', roles: ['editor'] }, 'posts', 'delete').allowed; // false
41468
- ```
41469
-
41470
- `ANONYMOUS` works inside multi-role arrays. The rule matches both unauthenticated visitors and any authenticated role listed alongside it:
41471
-
41472
- ```ts
41473
- import { ANONYMOUS, createWard } from '@vielzeug/ward';
41474
-
41475
- const ward = createWard([{ role: [ANONYMOUS, 'viewer'], resource: 'posts', action: 'read', effect: 'allow' }]);
41476
-
41477
- ward.explain(null, 'posts', 'read').allowed; // true (anonymous)
41478
- ward.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read').allowed; // true (viewer)
41479
- ward.explain({ id: 'u2', roles: ['admin'] }, 'posts', 'read').allowed; // false (not in list)
41480
- ```
41481
-
41482
- ## Anonymous and Wildcards
41483
-
41484
- ```ts
41485
- import { ANONYMOUS, WILDCARD } from '@vielzeug/ward';
41486
-
41487
- const ward = createWard([
41488
- { role: ANONYMOUS, resource: 'posts', action: 'read', effect: 'allow' },
41489
- { role: WILDCARD, resource: 'status', action: 'read', effect: 'allow' },
41490
- ]);
41491
- ```
41492
-
41493
- Use `ANONYMOUS` for anonymous-only rules and `WILDCARD` for any role/resource/action.
41494
-
41495
- ## Logger and Auditing
41496
-
41497
- ```ts
41498
- const ward = createWard([{ role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' }], {
41499
- logger: (ctx) => {
41500
- const subject = ctx.principal === null ? 'anonymous' : ctx.principal.id;
41501
- const outcome = ctx.allowed ? 'allow' : ctx.reason;
41502
- console.log(subject, ctx.resource, ctx.action, outcome);
41503
- },
41464
+ trace.candidates.forEach((c) => {
41465
+ console.log(c.index, c.priority, c.score, c.won);
41504
41466
  });
41505
41467
  ```
41506
41468
 
41507
- The logger runs after `explain()` and `checkAll()` (including through a `BoundWard`). It does **not** run after `trace()`.
41508
- Enumeration and introspection helpers (`allowedActions()`, `rulesInScope()`, `detectConflicts()`) stay side-effect free.
41469
+ `trace()` does not fire the logger.
41509
41470
 
41510
- `WardLoggerContext` is structurally identical to `WardDecision` plus the request fields, so `rule` narrows the same way — present when `allowed` is `true` or `reason` is `'explicit-deny'`:
41471
+ ## Predicate Helpers
41511
41472
 
41512
41473
  ```ts
41513
- logger: (ctx) => {
41514
- if (ctx.allowed || ctx.reason === 'explicit-deny') {
41515
- console.log(ctx.rule.role); // no ?. needed — rule is present
41516
- }
41517
- },
41518
- ```
41519
-
41520
- - `allowed: true` — a matching allow rule won
41521
- - `allowed: false, reason: 'explicit-deny'` — a matching deny rule won
41522
- - `allowed: false, reason: 'no-matching-rule'` — no rule matched at all (default deny)
41523
-
41524
- This lets you distinguish explicit blocks from gaps in your policy in audit logs and metrics.
41525
-
41526
- ## Decision Precedence
41527
-
41528
- Ward uses one deterministic model:
41529
-
41530
- 1. If no rule matches, decision is deny.
41531
- 2. Higher `priority` wins.
41532
- 3. For equal `priority`, higher specificity wins — `exact > namespace-wildcard (ns:*) > global-wildcard (*)`, applied independently to role, resource, and action.
41533
- 4. For equal `priority` and specificity, deny overrides allow.
41534
- 5. On absolute tie (identical priority, specificity, and effect), the rule declared **first in the array** wins.
41535
-
41536
- ## Exact Matching
41537
-
41538
- Ward uses exact string matching for role/resource/action.
41539
-
41540
- ```ts
41541
- const ward = createWard([{ role: 'admin', resource: 'posts', action: 'read', effect: 'allow' }]);
41542
-
41543
- ward.explain({ id: 'u1', roles: ['admin'] }, 'posts', 'read').allowed; // true
41544
- ward.explain({ id: 'u1', roles: ['ADMIN'] }, 'posts', 'read').allowed; // false
41545
- ```
41546
-
41547
- Adopt one identifier convention (for example all lowercase) at your app boundary.
41548
-
41549
- ## Framework Integration
41550
-
41551
- ```tsx [React]
41552
- import { createContext, useContext, type ReactNode } from 'react';
41553
- import { createWard } from '@vielzeug/ward';
41554
-
41555
- type User = { id: string; roles: string[] };
41556
-
41557
- const ward = createWard([
41558
- { role: 'admin', resource: '*', action: '*', effect: 'allow' },
41559
- { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
41560
- ]);
41561
-
41562
- const UserContext = createContext(null);
41563
-
41564
- function useWard(resource: string, action: string) {
41565
- const user = useContext(UserContext);
41566
- if (!user) return false;
41567
- return ward.explain(user, resource, action).allowed;
41568
- }
41569
-
41570
- function EditButton({ postId }: { postId: string }) {
41571
- const canEdit = useWard('posts', 'write');
41572
- if (!canEdit) return null;
41573
- return Edit {postId};
41574
- }
41575
- ```
41576
-
41577
- ```ts [Vue 3]
41578
- import { computed } from 'vue';
41579
- import { createWard } from '@vielzeug/ward';
41580
-
41581
- type User = { id: string; roles: string[] };
41582
-
41583
- const ward = createWard([
41584
- { role: 'admin', resource: '*', action: '*', effect: 'allow' },
41585
- { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
41586
- ]);
41587
-
41588
- function useWard(user: { value: User | null }, resource: string, action: string) {
41589
- return computed(() => (user.value ? ward.explain(user.value, resource, action).allowed : false));
41590
- }
41591
- ```
41592
-
41593
- ```svelte [Svelte]
41594
-
41595
- import { createWard } from '@vielzeug/ward';
41596
-
41597
- type User = { id: string; roles: string[] };
41598
-
41599
- export let user: User;
41600
-
41601
- const ward = createWard([
41602
- { role: 'admin', resource: '*', action: '*', effect: 'allow' },
41603
- { role: 'editor', resource: 'posts', action: 'write', effect: 'allow' },
41604
- ]);
41605
-
41606
- $: canEdit = ward.explain(user, 'posts', 'write').allowed;
41474
+ import { owns, predicate } from '@vielzeug/ward';
41607
41475
 
41608
- {#if canEdit}Edit{/if}
41476
+ const isOwner = owns('authorId');
41477
+ const canEdit = predicate.and(isOwner, ({ principal }) => principal !== null);
41609
41478
  ```
41610
41479
 
41611
- ### Pitfalls
41612
-
41613
- - **React:** If the ward is created inside a component that re-renders often, `createWard()` runs on every render. Memoize with `useMemo(() => createWard(...), [role])`, or define it once at module scope as in the example above.
41614
- - **Vue 3:** Injecting `ward` as a plain value (not a `ComputedRef`) means role changes don't propagate to child components. Always inject as a reactive ref.
41615
- - **Svelte:** `setContext` must be called synchronously during component initialization. Calling it inside a reactive statement (`$:`) works only for setting the initial value — child components reading the context must use `getContext` in their own `` block.
41616
-
41617
- ## Middleware Integration
41480
+ Async predicates are rejected at runtime with `WardPredicateError`.
41618
41481
 
41619
- Ward has no framework-specific middleware — `guardRequest` and `guardRequestWith` are small, framework-agnostic helpers you wire into a 2–3 line adapter for whichever server you use.
41482
+ ## Framework Guards
41620
41483
 
41621
41484
  ```ts
41622
41485
  import { guardRequest, guardRequestWith } from '@vielzeug/ward';
41623
41486
 
41624
- // Principal is already resolved (e.g. from session) — sync, no await needed
41625
- const result = guardRequest(ward, principal, 'posts', 'update');
41626
-
41627
- // Principal must be extracted from a request object (e.g. verify JWT) — async
41628
- const result = await guardRequestWith(ward, req, getPrincipal, 'posts', 'update');
41629
-
41630
- if (!result.granted) {
41631
- return new Response(JSON.stringify({ reason: result.reason }), { status: 403 });
41632
- }
41633
- ```
41634
-
41635
- ### Express / Connect
41636
-
41637
- ```ts
41638
- app.use('/posts', async (req, res, next) => {
41639
- const result = await guardRequestWith(ward, req, (r) => r.user ?? null, 'posts:*', 'update');
41640
- result.granted ? next() : res.status(403).json({ reason: result.reason });
41641
- });
41642
- ```
41643
-
41644
- ### Hono
41645
-
41646
- ```ts
41647
- app.put('/posts/:id', async (c, next) => {
41648
- const result = guardRequest(ward, c.get('user') ?? null, `posts:${c.req.param('id')}`, 'update');
41649
- return result.granted ? next() : c.json({ reason: result.reason }, 403);
41487
+ const direct = guardRequest({
41488
+ ward,
41489
+ principal: { id: 'u1', roles: ['viewer'] },
41490
+ resource: 'posts',
41491
+ action: 'read',
41650
41492
  });
41651
- ```
41652
-
41653
- ## Debug Mode
41654
41493
 
41655
- Import `debugWard` from the dedicated sub-path to create a ward with decision logging pre-enabled. The sub-path is tree-shaken from production bundles when not imported.
41656
-
41657
- ```ts
41658
- import { debugWard } from '@vielzeug/ward/devtools';
41659
-
41660
- const permit = debugWard([
41661
- { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
41662
- { role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
41663
- ]);
41664
-
41665
- permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'read');
41666
- // [ward:decision] allow (allow) viewer posts read
41667
-
41668
- permit.explain({ id: 'u1', roles: ['viewer'] }, 'posts', 'update');
41669
- // [ward:decision] no-matching-rule viewer posts update
41670
-
41671
- permit.explain(null, 'posts', 'read');
41672
- // [ward:decision] no-matching-rule anonymous posts read
41673
- ```
41674
-
41675
- The ward returned is identical to `createWard()` — all methods (`explain`, `checkAll`, `forUser`, etc.) work the same way.
41676
-
41677
- Alternatively, pass a custom `logger` directly to `createWard()` to route decisions to a structured logger:
41678
-
41679
- ```ts
41680
- const permit = createWard(rules, {
41681
- logger: (ctx) => myLogger.debug('access decision', ctx),
41494
+ const extracted = await guardRequestWith({
41495
+ ward,
41496
+ req,
41497
+ extractPrincipal: async (request) => request.user ?? null,
41498
+ resource: 'posts',
41499
+ action: 'read',
41682
41500
  });
41683
41501
  ```
41684
41502
 
41685
- Debug logging fires on `explain()` and `checkAll()` (including through a `BoundWard`). It does **not** fire on `trace()`, or on the side-effect-free helpers `allowedActions()`, `rulesInScope()`, and `detectConflicts()`.
41686
-
41687
- ## Working with Other Vielzeug Libraries
41688
-
41689
- ### With Wayfinder
41690
-
41691
- Use ward guards inside Wayfinder middleware to protect routes.
41692
-
41693
- ```ts
41694
- import { createWard } from '@vielzeug/ward';
41695
- import { createRouter } from '@vielzeug/wayfinder';
41696
-
41697
- type User = { id: string; roles: string[] };
41698
-
41699
- const ward = createWard([{ role: 'admin', resource: 'settings', action: 'read', effect: 'allow' }]);
41700
-
41701
- const router = createRouter({
41702
- routes: {
41703
- settings: {
41704
- path: '/settings',
41705
- handler: ({ data }) => renderSettings(data),
41706
- },
41707
- },
41708
- middleware: [
41709
- (ctx, next) => {
41710
- const user: User = getSessionUser(); // your auth provider
41711
- if (!ward.explain(user, 'settings', 'read').allowed) {
41712
- return ctx.navigate({ path: '/login' });
41713
- }
41714
- return next();
41715
- },
41716
- ],
41717
- });
41718
- ```
41719
-
41720
- ### With Rune
41721
-
41722
- Use ward's `logger` option to audit every access decision.
41723
-
41724
- ```ts
41725
- import { createWard } from '@vielzeug/ward';
41726
- import { createLogger } from '@vielzeug/rune';
41727
-
41728
- const log = createLogger({ namespace: 'ward' });
41729
-
41730
- const ward = createWard(
41731
- [
41732
- /* rules */
41733
- ],
41734
- {
41735
- logger: (decision) => log.info('access decision', decision),
41736
- },
41737
- );
41738
- ```
41739
-
41740
- ## Best Practices
41741
-
41742
- - Keep roles and resources explicit and predictable.
41743
- - Use `priority` sparingly for explicit overrides.
41744
- - Keep `when` predicates pure and side-effect free.
41745
- - Prefer one ward instance per app boundary and keep rules centralized.
41746
- - Use `forUser({ ... })` for repeated checks in UI or request scopes.
41747
-
41748
41503
  ### Examples
41749
41504
 
41750
41505
  ## Examples