@vielzeug/codex 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1687,6 +1687,7 @@ type InterpretOptions = {
1687
1687
  onDebug?: (event: DebugEvent) => void;
1688
1688
  persistence?: PersistenceAdapter;
1689
1689
  snapshot?: MachineSnapshot;
1690
+ validateHydratedContext?: boolean;
1690
1691
  traceLimit?: number;
1691
1692
  };
1692
1693
  ```
@@ -1699,6 +1700,7 @@ type InterpretOptions = {
1699
1700
  | `onDebug` | `undefined` | Callback for all debug events (guards, transitions, invokes, skips). Auto-enables a 50-entry trace buffer unless `traceLimit` is set. |
1700
1701
  | `persistence` | `undefined` | Save/load adapter for snapshot persistence |
1701
1702
  | `snapshot` | `undefined` | Snapshot to hydrate from on startup (takes priority over persistence) |
1703
+ | `validateHydratedContext`| `false` | When `true`, runs `validateContext` against hydrated context during startup; when `false`, hydrated context is trusted and only validated on transitions. |
1702
1704
  | `traceLimit` | auto (`50`/`0`) | Ring buffer capacity for `getTrace()`. Defaults to `50` when `onDebug` is set; `0` (disabled) otherwise. Set explicitly to override. |
1703
1705
 
1704
1706
  ---
@@ -1826,7 +1828,7 @@ interface MachineInstance {
1826
1828
  | `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
1829
  | `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
1830
  | `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. |
1831
+ | `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
1832
  | `[Symbol.dispose]()` | `void` | Delegates to `dispose()`. Enables `using` declarations. |
1831
1833
 
1832
1834
  ---
@@ -1878,7 +1880,7 @@ type PersistenceAdapter = {
1878
1880
  };
1879
1881
  ```
1880
1882
 
1881
- `save` is called after every committed transition. `load` is called once during startup if no `snapshot` option is provided.
1883
+ `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
1884
 
1883
1885
  ---
1884
1886
 
@@ -2472,7 +2474,9 @@ On startup, `createMachine().start()` checks `options.snapshot` first, then `per
2472
2474
 
2473
2475
  `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
2476
 
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.
2477
+ Hydrated context is trusted by default for backward compatibility. To enforce `validateContext` during hydration, pass `validateHydratedContext: true` to `.start(...)`.
2478
+
2479
+ If context is loaded from untrusted sources (e.g. `localStorage`), enable `validateHydratedContext` or validate inside `persistence.load()` before returning the snapshot.
2476
2480
 
2477
2481
  ## Interceptors
2478
2482
 
@@ -2547,6 +2551,7 @@ unsub(); // stop listening
2547
2551
  ```
2548
2552
 
2549
2553
  The callback fires only when `state` or `context` reference changes — not on every signal read.
2554
+ Each callback receives an isolated snapshot object; mutating the callback payload does not mutate machine state.
2550
2555
 
2551
2556
  ## Debugging and Tracing
2552
2557
 
@@ -7052,7 +7057,7 @@ Creates a query client with caching, deduplication, prefix invalidation, and rea
7052
7057
  | `getState` | `(key) => QueryState \| null` | Full state snapshot |
7053
7058
  | `observeMany` | `(keys: QueryKey[]) => SyncStore[]>` | Observe multiple keys as one combined store; updates on any key change |
7054
7059
  | `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 |
7060
+ | `remove` | `(key: QueryKey) => void` | Evict a single entry; aborts any in-flight fetch; resets observers to `'loading'` if active |
7056
7061
  | `cancel` | `(key) => void` | Cancel an in-flight fetch; entry returns to `'loading'` or retains prior success data |
7057
7062
  | `clear` | `() => void` | Clear all entries; active subscribers see `'loading'` |
7058
7063
  | `refetchStale` | `() => void` | Manually revalidate all stale observed entries |
@@ -7113,7 +7118,7 @@ Creates a standalone, observable mutation handle.
7113
7118
  | `peek` | `() => MutationState` | Read current state snapshot |
7114
7119
  | `subscribe` | `(cb: () => void) => () => void` | Subscribe to state changes; returns unsubscribe fn |
7115
7120
  | `store` | `SyncStore>` (property) | Framework-friendly external store; stable reference |
7116
- | `reset` | `() => void` | Reset back to the idle state |
7121
+ | `reset` | `() => void` | Reset back to the `'loading'` baseline state |
7117
7122
  | `dispose` | `() => void` | Abort active run, clear observers, and mark as disposed |
7118
7123
  | `disposed` | `boolean` (getter) | Whether `dispose()` has been called |
7119
7124
  | `[Symbol.dispose]` | — | Delegates to `dispose()`; enables `using` declarations |
@@ -8285,7 +8290,7 @@ const state = qc.getState(['users', 1]);
8285
8290
  ```ts
8286
8291
  const store = qc.watchKey(['users', 1]);
8287
8292
 
8288
- const initial = store.peek(); // idle state if not yet fetched
8293
+ const initial = store.peek(); // loading baseline if not yet fetched
8289
8294
  const stop = store.subscribe(() => {
8290
8295
  console.log(store.peek());
8291
8296
  });
@@ -8324,7 +8329,7 @@ const store = qc.observe({
8324
8329
  });
8325
8330
 
8326
8331
  // Synchronously read the current state
8327
- console.log(store.peek().status); // 'idle' or 'pending'
8332
+ console.log(store.peek().status); // 'loading' (or 'success' / 'error' if already cached)
8328
8333
  console.log(store.peek().data); // placeholderData while fetching
8329
8334
 
8330
8335
  // Subscribe to future changes
@@ -8368,7 +8373,7 @@ function useUser(id: number) {
8368
8373
  For entries **without active subscribers**, invalidation evicts the cache entry immediately. For entries **with active subscribers**:
8369
8374
 
8370
8375
  - 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`.
8376
+ - If the entry was only populated via `set()`, it resets to `'loading'`.
8372
8377
 
8373
8378
  Supports **prefix matching**: invalidating `['users']` affects `['users', 1]`, `['users', 2]`, and so on.
8374
8379
 
@@ -8379,7 +8384,7 @@ qc.invalidate(['users']);
8379
8384
 
8380
8385
  ### `cancel(key)`
8381
8386
 
8382
- Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'idle'`.
8387
+ Cancels an in-flight fetch without removing the cache entry. State transitions back to `'success'` if data exists, otherwise `'loading'`.
8383
8388
 
8384
8389
  ```ts
8385
8390
  qc.cancel(['users', 1]);
@@ -8387,7 +8392,7 @@ qc.cancel(['users', 1]);
8387
8392
 
8388
8393
  ### `clear()`
8389
8394
 
8390
- Clears every cache entry. Active subscribers are notified with an `'idle'` state.
8395
+ Clears every cache entry. Active subscribers are notified with a `'loading'` state.
8391
8396
 
8392
8397
  ```ts
8393
8398
  qc.clear();
@@ -8968,7 +8973,7 @@ effect(() => console.log('user:', userStore.value.user?.name));
8968
8973
 
8969
8974
  **Category:** ui-interaction
8970
8975
  **Keywords:** drag-drop, sortable, file-upload, drop-zone, dnd, reorder
8971
- **Key exports:** createDropZone, createSortable, createSortableScope, applyReorder, matchesAccept
8976
+ **Key exports:** createDropZone, createSortable, createSortableScope, createTouchDragShim, applyReorder, matchesAccept
8972
8977
  **Related:** ore, scroll, refine
8973
8978
 
8974
8979
  ### Overview
@@ -9016,6 +9021,7 @@ const zone = createDropZone({
9016
9021
  | Sortable lists | | | |
9017
9022
  | Drag handles | | | |
9018
9023
  | `using` support | | | |
9024
+ | Touch support | Opt-in shim | | |
9019
9025
  | Zero dependencies | | | |
9020
9026
 
9021
9027
  **Use Dnd when** you need reliable file drop zones with MIME filtering or sortable lists in a framework-agnostic environment.
@@ -9088,6 +9094,7 @@ using sortable = createSortable({
9088
9094
  - **`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
9095
  - **Boundary-safe keyboard reordering** — arrow keys at the first/last item no longer suppress `preventDefault`, so the browser can scroll the page normally
9090
9096
  - **Explicit connected scopes** — lists only exchange items when they share a `createSortableScope()` instance
9097
+ - **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
9098
  - **Explicit DOM sync** — call `sortable.sync()` after DOM mutations instead of relying on hidden observers
9092
9099
  - **`[Symbol.dispose]`** — both primitives support the `using` keyword for automatic cleanup
9093
9100
  - **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 +9121,7 @@ using sortable = createSortable({
9114
9121
  | `createDropZone()` | Create a typed drop-zone controller | Sync | Remember to destroy the controller during teardown |
9115
9122
  | `createSortable()` | Add sortable drag-and-drop behavior to lists | Sync | Provide stable item identity for reorder operations |
9116
9123
  | `createSortableScope()` | Create a shared scope for connected lists | Sync | Each set of connected containers needs its own scope instance |
9124
+ | `createTouchDragShim()` | Bridge touch gestures to synthetic DragEvents | Sync | Create once per app — it's a single `document`-level listener set |
9117
9125
  | `applyReorder()` | Apply ordered IDs to data arrays | Sync | Unknown IDs are skipped; non-mentioned items are appended |
9118
9126
  | `DropZoneOptions.accept` | Filter file types before processing | Sync | Mismatch between MIME and extension can reject files unexpectedly |
9119
9127
  | `DropZoneOptions.maxFiles` | Cap accepted files per drop | Sync | Excess accepted files become rejected; `onDropRejected` is called |
@@ -9217,6 +9225,15 @@ declare function createSortableScope(): SortableScope;
9217
9225
 
9218
9226
  Creates an explicit connection scope for multi-container sorting. Containers only exchange items when they share the same scope instance.
9219
9227
 
9228
+ ### `TouchDragOptions`
9229
+
9230
+ ```ts
9231
+ interface TouchDragOptions {
9232
+ disabled?: boolean;
9233
+ draggableSelector?: string;
9234
+ }
9235
+ ```
9236
+
9220
9237
  ## `createDropZone()`
9221
9238
 
9222
9239
  ```ts
@@ -9323,7 +9340,7 @@ declare function createSortable(options: SortableOptions): Sortable;
9323
9340
 
9324
9341
  Makes the direct children of a container element reorderable via drag. Returns a `Sortable` handle.
9325
9342
 
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.
9343
+ `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
9344
 
9328
9345
  - `element`: `HTMLElement`, required. The container whose children become sortable.
9329
9346
  - `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 +9466,7 @@ Dnd reads and writes the following DOM attributes:
9449
9466
  - `data-dragging`: set during drag, removed on `dragend` or `dispose()`. Use it as your styling hook for drag state.
9450
9467
  - `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
9468
  - `aria-hidden="true"`: set on placeholder creation and removed with the placeholder. Applied to the `.dnd-placeholder` element.
9469
+ - `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
9470
 
9453
9471
  ## CSS Classes
9454
9472
 
@@ -9456,6 +9474,34 @@ Dnd reads and writes the following DOM attributes:
9456
9474
  | ----------------- | ---------------------------- | ------------------------------------------------------------- |
9457
9475
  | `dnd-placeholder` | `` inserted by sortable | While an item is being dragged, in the placeholder's position |
9458
9476
 
9477
+ ## `createTouchDragShim()`
9478
+
9479
+ ```ts
9480
+ declare function createTouchDragShim(options?: TouchDragOptions): Disposable;
9481
+ ```
9482
+
9483
+ 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.
9484
+
9485
+ | Option | Type | Default | Description |
9486
+ | -------------------- | --------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
9487
+ | `disabled` | `boolean` | — | When `true`, touch gestures are ignored. Read live off the same options object on each `touchstart`, like `SortableOptions.disabled`. |
9488
+ | `draggableSelector` | `string` | `'[draggable="true"]'` | CSS selector identifying draggable elements under the touch point. The default matches what `createSortable`/`createDropZone` already set. |
9489
+ | `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. |
9490
+
9491
+ **Returns:** `Disposable`
9492
+
9493
+ Notes:
9494
+
9495
+ - Listens at the `document` level — create one instance per app, not one per sortable/drop-zone.
9496
+ - 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.
9497
+ - 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.
9498
+
9499
+ ```ts
9500
+ import { createTouchDragShim } from '@vielzeug/dnd';
9501
+
9502
+ using touchDrag = createTouchDragShim();
9503
+ ```
9504
+
9459
9505
  ## `matchesAccept()`
9460
9506
 
9461
9507
  ```ts
@@ -9868,6 +9914,61 @@ try {
9868
9914
  }
9869
9915
  ```
9870
9916
 
9917
+ ## Touch Support
9918
+
9919
+ 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.
9920
+
9921
+ ```ts
9922
+ import { createTouchDragShim } from '@vielzeug/dnd';
9923
+
9924
+ // Call once at app startup — one instance covers the whole page.
9925
+ using touchDrag = createTouchDragShim();
9926
+ ```
9927
+
9928
+ ### Custom draggable selector
9929
+
9930
+ 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.
9931
+
9932
+ ```ts
9933
+ createTouchDragShim({ draggableSelector: '.my-drag-handle' });
9934
+ ```
9935
+
9936
+ ### Drag preview
9937
+
9938
+ 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.
9939
+
9940
+ ```ts
9941
+ // Opt out to render fully custom feedback instead (e.g. toggling a class from your own
9942
+ // dragstart/dragend listeners):
9943
+ createTouchDragShim({ showDragPreview: false });
9944
+ ```
9945
+
9946
+ Note the preview only clones light-DOM content — an item whose visible content lives inside a shadow root will preview as an empty shell.
9947
+
9948
+ ### Why draggable items get `touch-action: none`
9949
+
9950
+ `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.
9951
+
9952
+ 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`.
9953
+
9954
+ ### Disabled state
9955
+
9956
+ ```ts
9957
+ const options = { disabled: false };
9958
+ const touchDrag = createTouchDragShim(options);
9959
+
9960
+ // options.disabled is read live on each touch event — mutate to toggle:
9961
+ options.disabled = true;
9962
+ ```
9963
+
9964
+ ### Cleanup
9965
+
9966
+ ```ts
9967
+ touchDrag.dispose();
9968
+ // or:
9969
+ using touchDrag = createTouchDragShim();
9970
+ ```
9971
+
9871
9972
  ## Framework Integration
9872
9973
 
9873
9974
  ```tsx [React]
@@ -9957,19 +10058,22 @@ Use Dnd in custom web components by attaching behavior in component lifecycle ho
9957
10058
 
9958
10059
  ```ts
9959
10060
  import { createSortable } from '@vielzeug/dnd';
9960
- import { define, onMounted, html } from '@vielzeug/ore';
10061
+ import { define, getHost, html, onMounted } from '@vielzeug/ore';
9961
10062
 
9962
10063
  define('task-list', {
9963
- setup(_props, { host }) {
10064
+ setup(_props) {
10065
+ const el = getHost();
10066
+
9964
10067
  onMounted(() => {
9965
10068
  const sortable = createSortable({
9966
- element: host.el,
10069
+ element: el,
9967
10070
  getKey: (el) => el.dataset.sortId!,
9968
10071
  onReorder: ({ ids }) => save(ids),
9969
10072
  });
9970
10073
  return () => sortable.dispose();
9971
10074
  });
9972
- return () => html``;
10075
+
10076
+ return html``;
9973
10077
  },
9974
10078
  });
9975
10079
  ```
@@ -9983,12 +10087,14 @@ define('task-list', {
9983
10087
  - Use `createSortableScope()` only when items should genuinely move between containers.
9984
10088
  - Use drag handles (`.handle` selector) when the full item surface area conflicts with other interactions such as text selection.
9985
10089
  - Test keyboard reordering explicitly — Dnd sets `tabindex` on items and supports arrow keys by default.
10090
+ - 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
10091
 
9987
10092
  ### Examples
9988
10093
 
9989
10094
  ## Examples
9990
10095
 
9991
10096
  - [Sortable List](./examples/sortable-list.md)
10097
+ - [Touch-Enabled Sortable List](./examples/touch-enabled-sortable-list.md)
9992
10098
  - [File Upload Drop Zone](./examples/file-upload-drop-zone.md)
9993
10099
  - [Optimistic Reorder with Revert and FLIP Animation](./examples/optimistic-reorder-with-revert.md)
9994
10100
  - [Combined Sortable With Inline Editing](./examples/combined-sortable-with-inline-editing.md)
@@ -12900,7 +13006,7 @@ if (!submission.ok && submission.type === 'validation') {
12900
13006
  - Browser-first utility: `toFormData`
12901
13007
  - Framework-agnostic core — wire into React, Vue, Svelte, or vanilla JS with `subscribe()`/`connect()`, no dedicated adapter package
12902
13008
  - `@vielzeug/forge/validators` adapter: `fieldValidator` and `composeValidators`
12903
- - `@vielzeug/forge/devtools`: opt-in `attachForgeDevtools()` for `console.debug` state-transition logging, tree-shaken from production
13009
+ - `@vielzeug/forge/devtools`: opt-in `debugForm()` for `console.debug` state-transition logging, tree-shaken from production
12904
13010
 
12905
13011
  ## Documentation
12906
13012
 
@@ -12947,7 +13053,7 @@ if (!submission.ok && submission.type === 'validation') {
12947
13053
  | ---------------------------- | ---------------------------------------------------------------------------------- |
12948
13054
  | `@vielzeug/forge` | `createForm`, `toFormData`, `ValidationModes`, `FORM_ERROR`, and all types |
12949
13055
  | `@vielzeug/forge/validators` | `fieldValidator`, `composeValidators` — schema and validator composition helpers |
12950
- | `@vielzeug/forge/devtools` | `attachForgeDevtools` — opt-in `console.debug` logging for form state transitions |
13056
+ | `@vielzeug/forge/devtools` | `debugForm` — opt-in `console.debug` logging for form state transitions |
12951
13057
 
12952
13058
  ## createForm()
12953
13059
 
@@ -13471,7 +13577,7 @@ const form = createForm({
13471
13577
  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
13578
 
13473
13579
  ```ts
13474
- function attachForgeDevtools>(
13580
+ function debugForm>(
13475
13581
  form: Form,
13476
13582
  options?: ForgeDevtoolsOptions,
13477
13583
  ): Unsubscribe;
@@ -13487,10 +13593,10 @@ Logs one line per observable state transition: per-field `value`/`error`/`touche
13487
13593
 
13488
13594
  ```ts
13489
13595
  import { createForm } from '@vielzeug/forge';
13490
- import { attachForgeDevtools } from '@vielzeug/forge/devtools';
13596
+ import { debugForm } from '@vielzeug/forge/devtools';
13491
13597
 
13492
13598
  const form = createForm({ defaultValues: { email: '' } });
13493
- const detach = attachForgeDevtools(form, { label: 'signup' });
13599
+ const detach = debugForm(form, { label: 'signup' });
13494
13600
  // [forge:devtools:signup] field "email" value: "" → "a@b.com"
13495
13601
 
13496
13602
  detach(); // stop logging
@@ -13629,6 +13735,12 @@ console.log(result.valid); // true only if no errors exist after this run
13629
13735
  console.log(result.errors); // full current error map after the run
13630
13736
  ```
13631
13737
 
13738
+ Validation race semantics:
13739
+
13740
+ - Forge aborts superseded runs instead of surfacing them as failures.
13741
+ - The newest validation run owns final field errors.
13742
+ - Aborted runs resolve without throwing in normal API usage.
13743
+
13632
13744
  Schema integration — pass a `safeParse`-compatible schema directly to `validator`:
13633
13745
 
13634
13746
  ```ts
@@ -13778,11 +13890,27 @@ await address.validate(); // validates only address.* fields; returns scoped err
13778
13890
  await address.submit((vals) => vals); // validates and submits only address.* fields
13779
13891
  ```
13780
13892
 
13893
+ ### Root vs Scoped Path Semantics
13894
+
13895
+ | Surface | Root form (`form`) | Scoped form (`form.scope('address')`) |
13896
+ | -------------------------- | ------------------ | ------------------------------------- |
13897
+ | `get` / `set` / `field` | absolute keys | relative keys |
13898
+ | `errors` in `state` | absolute keys | relative keys |
13899
+ | `touchedFields` in `state` | absolute keys | relative keys |
13900
+ | `validatingFields` in `state` | absolute keys | relative keys |
13901
+ | `validate(name)` input | absolute key | relative key |
13902
+ | `validate()` result keys | absolute keys | relative keys |
13903
+
13904
+ ### Recommended Scoped Patterns
13905
+
13906
+ 1. Call `const address = form.scope('address')` once per UI/module boundary and pass that around.
13907
+ 2. Use `address.validate()` / `address.submit()` instead of manually feeding `state.touchedFields` into `validate(fields[])`.
13908
+ 3. Prefer `address.subscribeScoped(...)` for section UIs so sibling/root mutations do not trigger redraws.
13909
+
13781
13910
  **Key characteristics:**
13782
13911
 
13783
13912
  - `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.
13913
+ - `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
13914
 
13787
13915
  ### Scoped Subscriptions
13788
13916
 
@@ -13900,12 +14028,12 @@ After `dispose()`, all mutating APIs throw.
13900
14028
 
13901
14029
  ## Debugging
13902
14030
 
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`:
14031
+ 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
14032
 
13905
14033
  ```ts
13906
- import { attachForgeDevtools } from '@vielzeug/forge/devtools';
14034
+ import { debugForm } from '@vielzeug/forge/devtools';
13907
14035
 
13908
- const detach = attachForgeDevtools(form, { label: 'signup' });
14036
+ const detach = debugForm(form, { label: 'signup' });
13909
14037
  // later, e.g. on unmount:
13910
14038
  detach();
13911
14039
  ```
@@ -17742,7 +17870,7 @@ i18n.getSupportedLocales();
17742
17870
  | `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
17871
  | `i18n.isNamespaceRegistered()` | Check if a namespace factory has been registered | Sync | `true` after `registerNamespace()` or `extend()`; `false` before |
17744
17872
  | `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 |
17873
+ | `i18n.restoreState()` | Hydrate instance from serialized state | Sync | Equivalent to `hydrateI18n(i18n, state)` — preferred for public API access; throws `LinguaRestoreError` if locale missing |
17746
17874
  | `serializeI18n()` | Serialise loaded catalogs for SSR hydration | Sync | Loader-only locales are omitted — check `isLoaded()` before calling |
17747
17875
  | `hydrateI18n()` | Hydrate a client instance from server-serialised state | Sync | Throws `LinguaRestoreError` if `state.locale` has no catalog |
17748
17876
  | Error classes | Named error subclasses (`LinguaDisposedError`, `LinguaMissingLocaleError`, …) | — | All runtime errors are `instanceof LinguaError`; use `instanceof` for specific handling |
@@ -17960,7 +18088,7 @@ const state = i18n.getState();
17960
18088
  restoreState(state: I18nState): void
17961
18089
  ```
17962
18090
 
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.
18091
+ Hydrates this instance from an `I18nState` produced by `getState()` or `serializeI18n()`. Equivalent to `hydrateI18n(i18n, state)` but preferred because it is called directly on the instance.
17964
18092
 
17965
18093
  - Replaces all catalogs with those from `state`.
17966
18094
  - Sets the active locale to `state.locale`.
@@ -20718,11 +20846,13 @@ function useFloat(referenceRef: { value: HTMLElement | null }, floatingRef: { va
20718
20846
  Use Orbit inside a Ore component to position tooltips and popovers reactively.
20719
20847
 
20720
20848
  ```ts
20721
- import { define, html } from '@vielzeug/ore';
20849
+ import { define, getHost, html, onMounted } from '@vielzeug/ore';
20722
20850
  import { flip, float, offset, shift } from '@vielzeug/orbit';
20723
20851
 
20724
20852
  define('x-tooltip', {
20725
- setup(_props, { el, onMounted }) {
20853
+ setup(_props) {
20854
+ const el = getHost();
20855
+
20726
20856
  onMounted(() => {
20727
20857
  const tooltipEl = el.querySelector('[role=tooltip]')!;
20728
20858
 
@@ -20783,7 +20913,7 @@ define('x-tooltip', {
20783
20913
 
20784
20914
  **Category:** ui-primitives
20785
20915
  **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)
20916
+ **Key exports:** define, prop, html, css, ref, createContext, inject, injectStrict, provide, onMounted, onCleanup, useEmit (+10 more)
20787
20917
  **Related:** ripple, refine, orbit
20788
20918
 
20789
20919
  ### Overview
@@ -20854,7 +20984,7 @@ yarn add @vielzeug/ore
20854
20984
 
20855
20985
  ```ts
20856
20986
  import { computed, signal } from '@vielzeug/ripple';
20857
- import { css, define, html, prop } from '@vielzeug/ore';
20987
+ import { bind, css, define, html, onMounted, prop } from '@vielzeug/ore';
20858
20988
 
20859
20989
  define('my-counter', {
20860
20990
  props: {
@@ -20869,7 +20999,7 @@ define('my-counter', {
20869
20999
  }
20870
21000
  `,
20871
21001
  ],
20872
- setup(props, { bind, onMounted }) {
21002
+ setup(props) {
20873
21003
  const count = signal(0);
20874
21004
  const doubled = computed(() => count.value * 2);
20875
21005
 
@@ -20890,12 +21020,13 @@ define('my-counter', {
20890
21020
  - Signal-first runtime with `signal`, `computed`, `watch`, `batch` from `@vielzeug/ripple` — import them directly
20891
21021
  - Functional component authoring via `define(tag, { props, setup, styles, formAssociated })`
20892
21022
  - 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`)
21023
+ - `setup(props)` takes only props and returns an `HTMLResult` directly: `return html\`...\``
21024
+ - Lifecycle hooks — `onMounted`, `onCleanup`, `onEvent`, `onElement`, `watchEffect` — plain functions imported from `@vielzeug/ore`, called directly from `setup()` or any composable it calls
20895
21025
  - 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
21026
+ - Host bindings via `bind({ attr, class, style, on })` — pass `{ target: el }` to bind any off-host element
21027
+ - Reactive ARIA sync via `aria(target, config)` — applies `aria-*` attributes reactively to any element, auto-cleanup on disconnect
21028
+ - Context via `provide(key, value)` / `inject(key)`; typed emit/slots via `useEmit()` / `useSlots()`
21029
+ - Form-associated helpers (`@vielzeug/ore/forms`): `useField()`, `createFormContext()`
20899
21030
  - Observers (`@vielzeug/ore/observers`)
20900
21031
  - Testing utilities (`@vielzeug/ore/testing`) — `mount`, `renderHook`, `fire`, `user`, `waitFor`, `cleanup`
20901
21032
  - Debug utilities (`@vielzeug/ore/devtools`) — `debugFlush()` for diagnosing update timing
@@ -20904,9 +21035,10 @@ define('my-counter', {
20904
21035
 
20905
21036
  | Import | Purpose |
20906
21037
  | --------------------------- | ----------------------------------------------------------------------------- |
20907
- | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context, forms) |
21038
+ | `@vielzeug/ore` | Core component API and utilities (`define`, `prop`, `html`, `css`, context) |
20908
21039
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics (dev only) |
20909
21040
  | `@vielzeug/ore/directives` | `each`, `when`, `model`, `live`, `raw`, `classMap`, `styleMap` |
21041
+ | `@vielzeug/ore/forms` | `useField`, `createFormContext`, `FORM_CONTEXT_KEY` |
20910
21042
  | `@vielzeug/ore/observers` | `resizeObserver`, `intersectionObserver`, `mediaObserver`, `mutationObserver` |
20911
21043
  | `@vielzeug/ore/testing` | `mount`, `fire`, `user`, `waitFor`, `cleanup`, and helpers |
20912
21044
 
@@ -20926,23 +21058,31 @@ define('my-counter', {
20926
21058
 
20927
21059
  ## API Overview
20928
21060
 
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 |
21061
+ 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.
21062
+
21063
+ > `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.
21064
+
21065
+ | Symbol | Purpose | Execution mode | Common gotcha |
21066
+ | ---------------------- | ----------------------------------------------------- | -------------- | -------------------------------------------------------------------------- |
21067
+ | `define()` | Register a custom element with reactive setup | Sync | Tag must contain a hyphen; call before first use |
21068
+ | `html` | Tagged template literal returning HTMLResult | Sync | Expressions must be signals, functions, or primitives |
21069
+ | `prop.*` | Typed prop helpers (string, bool, number, …) | Sync | Prop values are signals read `.value` |
21070
+ | `provide()`/`inject()` | Context API for parent-to-descendant sharing | Setup only | Must be called synchronously during `setup()` |
21071
+ | `ref()` | Reactive reference to a DOM element | Sync | Value is null until after first mount |
21072
+ | `createContext()` | Create a typed injection key | Sync | Context is scoped to the component tree |
21073
+ | `each()` | Keyed list rendering with DOM diffing | Sync | Duplicate keys warn in dev; plain `T[]` treated as one-time static render |
21074
+ | `when()` | Conditional branch rendering | Sync | Getter-fn computed disposed on cleanup; static bool skips subscription |
21075
+ | `model(signal)` | Two-way binding for input/select/textarea | Sync | `` uses `Signal`; `select` uses `change` |
21076
+ | `live(signal)` | One-way binding that skips stale writes during input | Sync | Use for controlled inputs alongside a manual `@input` handler |
21077
+ | `onMounted(fn)` | DOM-ready callback | Setup only | Must be called synchronously during `setup()` |
21078
+ | `onCleanup(fn)` | Register teardown | Setup only | Called on component disconnect |
21079
+ | `onEvent(target, …)` | Scoped event listener with auto-cleanup | Setup only | No-ops on null target; removed on disconnect |
21080
+ | `useField(options)` | Wire signal to form `ElementInternals` | Setup only | Requires `formAssociated: true` on the component definition; `@vielzeug/ore/forms` |
21081
+ | `onFormReset(fn)` | Run work when the ancestor `` resets | Setup only | Fires every reset (not one-shot); only for `formAssociated: true` components |
21082
+ | `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 |
21083
+ | `useEmit()` | Typed `emit()` bound to the current host | Setup only | Call once per component; returns `dispatchEvent`'s boolean (`false` if a listener called `preventDefault()`) |
21084
+ | `useSlots()`| Reactive slot presence/element signals | Setup only | Safe to call more than once — the underlying registry is created once |
21085
+ | `getHost()` | The current component's host element | Setup only | Prefer a higher-level helper (`bind`, `aria`, …) when one exists |
20946
21086
 
20947
21087
  ## Package Entry Points
20948
21088
 
@@ -20951,6 +21091,7 @@ define('my-counter', {
20951
21091
  | `@vielzeug/ore` | Core authoring/runtime API |
20952
21092
  | `@vielzeug/ore/devtools` | `debugFlush` — verbose flush for timing diagnostics |
20953
21093
  | `@vielzeug/ore/directives` | Standalone directive imports (`each`, `when`, `classMap`, …) |
21094
+ | `@vielzeug/ore/forms` | Form-association helpers (`useField`, `createFormContext`) |
20954
21095
  | `@vielzeug/ore/observers` | Resize, intersection, mutation, and media observers |
20955
21096
  | `@vielzeug/ore/testing` | DOM-oriented test helpers |
20956
21097
 
@@ -20962,35 +21103,36 @@ define('my-counter', {
20962
21103
  define(tag: string, definition: ComponentDefinition): void;
20963
21104
  ```
20964
21105
 
20965
- The `setup()` function receives typed prop signals and a context bag:
21106
+ The `setup()` function receives only typed prop signals:
20966
21107
 
20967
21108
  ```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
- };
21109
+ setup(props) {
21110
+ return html`${props.label}`;
21111
+ }
20982
21112
  ```
20983
21113
 
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):
21114
+ 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
21115
 
20988
21116
  ```ts
20989
- setup(props, ctx) {
20990
- return html`${props.label}`;
20991
- }
21117
+ import { define, html, onMounted, useEmit, useSlots } from '@vielzeug/ore';
21118
+
21119
+ define('my-card', {
21120
+ setup(_props) {
21121
+ const emit = useEmit();
21122
+ const slots = useSlots();
21123
+
21124
+ onMounted(() => console.log('mounted'));
21125
+
21126
+ // emit() returns dispatchEvent's boolean — false if a listener called preventDefault()
21127
+ const notCancelled = emit('close');
21128
+
21129
+ return html`${when(slots.has('header'), () => html``)}`;
21130
+ },
21131
+ });
20992
21132
  ```
20993
21133
 
21134
+ `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).
21135
+
20994
21136
  ### ComponentDefinition
20995
21137
 
20996
21138
  ```ts
@@ -20999,26 +21141,12 @@ type ComponentDefinition = {
20999
21141
  loading?: () => HTMLResult; // Template shown while async setup is pending
21000
21142
  onError?: (error: OreLifecycleError, element: HTMLElement) => HTMLResult | void;
21001
21143
  props?: PropsDef;
21002
- setup: (
21003
- props: InferProps>,
21004
- ctx: SetupContextBag,
21005
- ) => HTMLResult | Promise;
21144
+ setup: (props: InferProps>) => HTMLResult | Promise;
21006
21145
  shadow?: Partial | false; // false = light DOM (no shadow root)
21007
21146
  styles?: (string | CSSStyleSheet | CSSResult)[];
21008
21147
  };
21009
21148
  ```
21010
21149
 
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
21150
  #### Async setup
21023
21151
 
21024
21152
  When `setup()` returns a `Promise`, `loading()` is rendered immediately. The real template replaces it once the promise resolves.
@@ -21037,10 +21165,12 @@ define('user-profile', {
21037
21165
 
21038
21166
  ## Runtime Helpers
21039
21167
 
21040
- `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch` are available on the setup context bag. Destructure them from the second argument to `setup()`.
21168
+ `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watchEffect` are plain functions imported from `@vielzeug/ore`. Call them directly during `setup()`.
21041
21169
 
21042
21170
  ```ts
21043
- setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
21171
+ import { html, onCleanup, onEvent, onMounted } from '@vielzeug/ore';
21172
+
21173
+ setup(props) {
21044
21174
  onMounted(() => {
21045
21175
  // DOM is ready; return a function for mount-scoped cleanup
21046
21176
  return () => { /* cleanup on unmount */ };
@@ -21054,20 +21184,18 @@ setup(props, { onMounted, onCleanup, onEvent, onElement, watch }) {
21054
21184
  }
21055
21185
  ```
21056
21186
 
21057
- When writing composable helpers called from inside `setup()`, thread the hooks explicitly via function parameters rather than relying on a shared context:
21187
+ 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
21188
 
21059
21189
  ```ts
21060
- type MyHelperOptions = {
21061
- onCleanup: (fn: () => void) => void;
21062
- };
21190
+ import { onCleanup } from '@vielzeug/ore';
21063
21191
 
21064
- function useMyHelper(options: MyHelperOptions) {
21065
- options.onCleanup(() => { /* teardown */ });
21192
+ function useMyHelper() {
21193
+ onCleanup(() => { /* teardown */ });
21066
21194
  }
21067
21195
 
21068
21196
  // In setup:
21069
- setup(_props, { onCleanup }) {
21070
- useMyHelper({ onCleanup });
21197
+ setup(_props) {
21198
+ useMyHelper();
21071
21199
  return html`...`;
21072
21200
  }
21073
21201
  ```
@@ -21149,7 +21277,7 @@ Event bindings support dot-separated modifiers: `@click.prevent.stop=${handler}`
21149
21277
 
21150
21278
  ## Host Bindings
21151
21279
 
21152
- The setup context provides `bind: HostBindFn`:
21280
+ `bind(config, options?)` is a plain function imported from `@vielzeug/ore`:
21153
21281
 
21154
21282
  ```ts
21155
21283
  bind({
@@ -21175,9 +21303,9 @@ bind(
21175
21303
 
21176
21304
  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
21305
 
21178
- ### ctx.aria()
21306
+ ### aria()
21179
21307
 
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):
21308
+ For reactive ARIA attribute syncing, use `aria(target, config)`. Shorthand keys are normalised to `aria-*` automatically (`expanded` → `aria-expanded`; `role` is passed verbatim):
21181
21309
 
21182
21310
  ```ts
21183
21311
  // Inside setup — cleanup auto-registered
@@ -21204,12 +21332,12 @@ Slot signals update reactively when assigned content changes, including when slo
21204
21332
  ## Context API
21205
21333
 
21206
21334
  - `createContext(description?)` — Create a typed injection key
21207
- - `ctx.provide(key, value)` — Provide a value to descendants; called via the setup context bag
21335
+ - `provide(key, value)` — Provide a value to descendants
21208
21336
  - `inject(key)` — Resolve from nearest ancestor; returns `undefined` if not found
21209
21337
  - `inject(key, fallback)` — Resolve with a fallback value
21210
21338
  - `injectStrict(key)` — Resolve or throw if absent
21211
21339
 
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.
21340
+ `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
21341
 
21214
21342
  ## Utilities
21215
21343
 
@@ -21220,6 +21348,8 @@ Slot signals update reactively when assigned content changes, including when slo
21220
21348
 
21221
21349
  ## Form-Associated API
21222
21350
 
21351
+ Import from `@vielzeug/ore/forms`.
21352
+
21223
21353
  ### `useField(options)`
21224
21354
 
21225
21355
  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 +21364,12 @@ type FormFieldOptions = {
21234
21364
  * @default false
21235
21365
  */
21236
21366
  emptyStringForNull?: boolean;
21367
+ /** Called when the ancestor resets (see onFormReset) — restore local field state here. */
21368
+ onReset?: () => void;
21237
21369
  toFormValue?: (value: T) => File | FormData | string | null;
21370
+ /** Recomputed reactively and passed straight to internals.setValidity(). null = always valid. */
21371
+ validationMessage?: Readable;
21372
+ validity?: Readable;
21238
21373
  value: Signal | Readable;
21239
21374
  };
21240
21375
 
@@ -21247,11 +21382,23 @@ type FormFieldHandle = {
21247
21382
  };
21248
21383
  ```
21249
21384
 
21385
+ Pass `validity`/`validationMessage` to make `required`-style constraints participate in native constraint validation (`checkValidity()`/`reportValidity()`, and ``'s submit blocking):
21386
+
21387
+ ```ts
21388
+ const isBlank = (v: string) => v.trim() === '';
21389
+
21390
+ useField({
21391
+ validationMessage: computed(() => (required.value && isBlank(value.value) ? 'This field is required.' : '')),
21392
+ validity: computed(() => (required.value && isBlank(value.value) ? { valueMissing: true } : null)),
21393
+ value,
21394
+ });
21395
+ ```
21396
+
21250
21397
  ### Form Context
21251
21398
 
21252
21399
  Coordinate form state across child field components:
21253
21400
 
21254
- - `createFormContext(options?)` — Create a `FormController`; call `ctx.provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
21401
+ - `createFormContext(options?)` — Create a `FormController`; call `provide(FORM_CONTEXT_KEY, ctrl)` to make it available to descendants
21255
21402
  - `FORM_CONTEXT_KEY` — the `InjectionKey` used to provide/inject the form context
21256
21403
 
21257
21404
  ```ts
@@ -21284,7 +21431,9 @@ Import from `@vielzeug/ore/testing`.
21284
21431
  | ------------------------ | ------------------------------------------------------------------------------------------ |
21285
21432
  | `mount(setup, options?)` | Mount a component and return a test fixture |
21286
21433
  | `cleanup()` | Remove all mounted elements and reset test state |
21287
- | `install(afterEach)` | Register auto-cleanup; pass `afterEach` from your test framework |
21434
+ | `install(afterEach)` | Register auto-cleanup and the `ElementInternals`/`FormData`/`.reset()` jsdom polyfill (see below); pass `afterEach` from your test framework |
21435
+ | `installFormInternalsPolyfill()` | Called automatically by `install()`. Call directly only if you need the polyfill without auto-cleanup |
21436
+ | `walkFlatTree(root, visit)` | Walks the flat tree (expanding `` via `assignedElements()`) — for finding slotted content across a shadow boundary that `querySelectorAll()` can't cross |
21288
21437
  | `flush(options?)` | Drain reactive updates and animation frames |
21289
21438
  | `FLUSH_DEEP` | Pre-built options for deep async chains (`maxTurns: 12`) |
21290
21439
  | `mock(tag, template?)` | Register a no-op stub custom element |
@@ -21297,6 +21446,8 @@ Import from `@vielzeug/ore/testing`.
21297
21446
 
21298
21447
  > **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
21448
 
21449
+ > **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.
21450
+
21300
21451
  #### `Fixture` interface
21301
21452
 
21302
21453
  ```ts
@@ -21321,11 +21472,11 @@ interface Fixture {
21321
21472
 
21322
21473
  #### `renderHook`
21323
21474
 
21324
- Useful for testing composable lifecycle hooks (`onMounted`, `effect`, `inject`, etc.) without a template:
21475
+ 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
21476
 
21326
21477
  ```ts
21327
21478
  // Without props
21328
- const { result, flush, dispose } = await renderHook((_props, { onMounted }) => {
21479
+ const { result, flush, dispose } = await renderHook(() => {
21329
21480
  const count = signal(0);
21330
21481
  onMounted(() => {
21331
21482
  count.value = 1;
@@ -21374,48 +21525,32 @@ type InferProps = {
21374
21525
  readonly [K in keyof D]-?: Readable>;
21375
21526
  };
21376
21527
 
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
- };
21528
+ // Runtime hooks — all plain functions imported from '@vielzeug/ore', not fields on an object.
21529
+ declare function onMounted(fn: OnMountedCallback): void; // DOM-ready callback; runs after first render
21530
+ declare function onCleanup(fn: CleanupFn): void; // Register teardown; called on disconnect
21531
+ declare function onElement(ref: Readable, cb: (el: T) => CleanupFn | void): () => void;
21532
+ declare function onEvent(
21533
+ target: EventTarget | null | undefined,
21534
+ event: string,
21535
+ listener: EventListener,
21536
+ options?: AddEventListenerOptions,
21537
+ ): void;
21538
+ declare function onFormReset(fn: () => void): void; // Runs on every ancestor reset; formAssociated only
21539
+ declare function watchEffect(fn: EffectCallback): () => void; // Scoped reactive effect; auto-cleaned on disconnect
21540
+ declare function bind(config: HostBindConfig, options?: BindOptions): () => void; // Bindings for host or any target element
21541
+ declare function aria(target: Element, config: AriaConfig): () => void; // Reactive ARIA attr sync; auto-cleanup on disconnect
21542
+ declare function provide(key: InjectionKey, value: T): void; // Register a context value on the host element
21543
+ declare function inject(key: InjectionKey, fallback?: T): T | undefined;
21544
+ declare function getHost(): HTMLElement; // The current component's host element
21545
+ declare function useEmit = Record>(): EmitFn;
21546
+ declare function useSlots(): ComponentSlots;
21409
21547
 
21410
21548
  type ComponentDefinition = {
21411
21549
  formAssociated?: boolean;
21412
21550
  loading?: () => HTMLResult; // Shown while async setup is pending
21413
21551
  onError?: (error: OreLifecycleError, el: HTMLElement) => HTMLResult | void;
21414
21552
  props?: PropsDef;
21415
- setup: (
21416
- props: InferProps>,
21417
- ctx: SetupContextBag,
21418
- ) => HTMLResult | Promise;
21553
+ setup: (props: InferProps>) => HTMLResult | Promise;
21419
21554
  shadow?: Partial | false; // false = light DOM
21420
21555
  styles?: (string | CSSStyleSheet | CSSResult)[];
21421
21556
  };
@@ -21478,15 +21613,19 @@ define('status-chip', {
21478
21613
  });
21479
21614
  ```
21480
21615
 
21481
- The setup context bag provides `el`, `aria`, `bind`, `emit`, `slots`, `onMounted`, `onCleanup`, `onEvent`, `onElement`, and `watch`:
21616
+ 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
21617
 
21483
21618
  ```ts
21619
+ import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore';
21620
+
21484
21621
  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
21622
+ setup(_props) {
21623
+ const el = getHost(); // the host HTMLElement
21624
+ const emit = useEmit(); // typed event emitter
21625
+ const slots = useSlots(); // reactive slot observation
21626
+
21627
+ bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)
21628
+
21490
21629
  return html``;
21491
21630
  },
21492
21631
  });
@@ -21518,16 +21657,17 @@ batch(() => {
21518
21657
 
21519
21658
  ## onMounted and lifecycle
21520
21659
 
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.
21660
+ 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
21661
 
21523
21662
  ```ts
21524
21663
  import { signal } from '@vielzeug/ripple';
21525
- import { define, html, ref } from '@vielzeug/ore';
21664
+ import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore';
21526
21665
 
21527
21666
  define('deferred-init', {
21528
- setup(_props, { slots, onMounted, onElement, onEvent }) {
21667
+ setup(_props) {
21529
21668
  const tabIndex = signal(0);
21530
21669
  const inputRef = ref();
21670
+ const slots = useSlots();
21531
21671
 
21532
21672
  onMounted(() => {
21533
21673
  const items = slots.elements('items').value;
@@ -21667,14 +21807,14 @@ define('live-search', {
21667
21807
 
21668
21808
  ## host bindings
21669
21809
 
21670
- The setup context provides `bind` for wiring the host element.
21810
+ `bind()` wires reactive attrs, classes, styles, and events to the host element.
21671
21811
 
21672
21812
  ```ts
21673
21813
  import { signal } from '@vielzeug/ripple';
21674
- import { define, html } from '@vielzeug/ore';
21814
+ import { bind, define, html } from '@vielzeug/ore';
21675
21815
 
21676
21816
  define('x-toggle', {
21677
- setup(_props, { bind }) {
21817
+ setup(_props) {
21678
21818
  const open = signal(false);
21679
21819
 
21680
21820
  bind({
@@ -21692,14 +21832,14 @@ The `bind` config supports `attr`, `class`, `style`, and `on` sections.
21692
21832
 
21693
21833
  ## ARIA bindings
21694
21834
 
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.
21835
+ 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
21836
 
21697
21837
  ```ts
21698
21838
  import { signal } from '@vielzeug/ripple';
21699
- import { define, html } from '@vielzeug/ore';
21839
+ import { aria, bind, define, html, onMounted } from '@vielzeug/ore';
21700
21840
 
21701
21841
  define('x-disclosure', {
21702
- setup(_props, { aria, bind, onMounted }) {
21842
+ setup(_props) {
21703
21843
  const open = signal(false);
21704
21844
  const panelId = 'disclosure-panel';
21705
21845
 
@@ -21745,10 +21885,10 @@ Pass `{ target: el }` as a second argument to bind attributes, classes, styles,
21745
21885
 
21746
21886
  ```ts
21747
21887
  import { signal } from '@vielzeug/ripple';
21748
- import { define, html, ref } from '@vielzeug/ore';
21888
+ import { bind, define, html, onMounted, ref } from '@vielzeug/ore';
21749
21889
 
21750
21890
  define('button-wrapper', {
21751
- setup(_props, { bind, onMounted }) {
21891
+ setup(_props) {
21752
21892
  const visible = signal(false);
21753
21893
  const btnRef = ref();
21754
21894
 
@@ -21774,10 +21914,13 @@ define('button-wrapper', {
21774
21914
 
21775
21915
  ```ts
21776
21916
  import { when } from '@vielzeug/ore/directives';
21777
- import { define, html } from '@vielzeug/ore';
21917
+ import { define, html, useEmit, useSlots } from '@vielzeug/ore';
21918
+
21919
+ define('card-with-footer', {
21920
+ setup(_props) {
21921
+ const slots = useSlots();
21922
+ const emit = useEmit();
21778
21923
 
21779
- define, Record, 'header' | 'footer'>('card-with-footer', {
21780
- setup(_props, { slots, emit }) {
21781
21924
  return html`
21782
21925
 
21783
21926
 
@@ -21790,18 +21933,18 @@ define, Record, 'header' | 'footer'>('card-with-footer', {
21790
21933
  });
21791
21934
  ```
21792
21935
 
21793
- Pass `SlotNames` as a type parameter to `define()` to get typed `slots.has()` and `slots.elements()` calls.
21936
+ Pass a `SlotNames` type parameter to `useSlots()` to get typed `slots.has()` and `slots.elements()` calls.
21794
21937
 
21795
21938
  ## context provide/inject
21796
21939
 
21797
21940
  ```ts
21798
21941
  import { signal } from '@vielzeug/ripple';
21799
- import { createContext, define, html, injectStrict } from '@vielzeug/ore';
21942
+ import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore';
21800
21943
 
21801
21944
  const COUNT_CTX = createContext>>('count');
21802
21945
 
21803
21946
  define('count-provider', {
21804
- setup(_props, { provide }) {
21947
+ setup(_props) {
21805
21948
  const count = signal(0);
21806
21949
  provide(COUNT_CTX, count);
21807
21950
 
@@ -21822,7 +21965,8 @@ define('count-consumer', {
21822
21965
 
21823
21966
  ```ts
21824
21967
  import { signal } from '@vielzeug/ripple';
21825
- import { define, html, prop, useField } from '@vielzeug/ore';
21968
+ import { define, html, prop } from '@vielzeug/ore';
21969
+ import { useField } from '@vielzeug/ore/forms';
21826
21970
 
21827
21971
  define('rating-input', {
21828
21972
  formAssociated: true,
@@ -21862,15 +22006,15 @@ define('user-profile', {
21862
22006
 
21863
22007
  ## platform observers
21864
22008
 
21865
- Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `ctx.onMounted()`.
22009
+ Observer helpers from `@vielzeug/ore/observers` require real DOM nodes, so call them inside `onMounted()`.
21866
22010
 
21867
22011
  ```ts
21868
22012
  import { effect } from '@vielzeug/ripple';
21869
- import { define, html, ref } from '@vielzeug/ore';
22013
+ import { define, html, onMounted, ref } from '@vielzeug/ore';
21870
22014
  import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/ore/observers';
21871
22015
 
21872
22016
  define('x-observed', {
21873
- setup(_props, { onMounted }) {
22017
+ setup(_props) {
21874
22018
  const boxRef = ref();
21875
22019
 
21876
22020
  onMounted(() => {
@@ -21988,10 +22132,11 @@ Use `@vielzeug/forge` for typed form state alongside Ore's `useField()` for form
21988
22132
  ```ts
21989
22133
  import { createForm } from '@vielzeug/forge';
21990
22134
  import { s } from '@vielzeug/spell';
21991
- import { createFormContext, define, html, FORM_CONTEXT_KEY } from '@vielzeug/ore';
22135
+ import { define, html, provide } from '@vielzeug/ore';
22136
+ import { createFormContext, FORM_CONTEXT_KEY } from '@vielzeug/ore/forms';
21992
22137
 
21993
22138
  define('signup-form', {
21994
- setup(_props, { provide }) {
22139
+ setup(_props) {
21995
22140
  const formCtx = createFormContext({
21996
22141
  onSubmit: async (e) => {
21997
22142
  e?.preventDefault();
@@ -22013,13 +22158,13 @@ define('signup-form', {
22013
22158
  ## Best Practices
22014
22159
 
22015
22160
  - 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.
22161
+ - Use `watchEffect()` for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect.
22162
+ - Use `onElement(ref, cb)` instead of `onMounted` when the work is tied to a single DOM node.
22163
+ - Bind host attributes and classes via `bind()` rather than mutating the element directly.
22019
22164
  - Provide context at the nearest ancestor — avoid global context singletons.
22020
- - Call `ctx.onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
22165
+ - Call `onCleanup()` for every resource allocated in `setup()` (WebSockets, intervals, external subscriptions).
22021
22166
  - 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.
22167
+ - 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
22168
  - Test with `@vielzeug/ore/testing` helpers (`mount`, `flush`, `waitFor`) rather than direct DOM manipulation.
22024
22169
 
22025
22170
  ### Examples
@@ -25927,8 +26072,8 @@ When the buffer is full, the **oldest** frame is evicted to make room for the ne
25927
26072
 
25928
26073
  **Category:** ui-components
25929
26074
  **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
26075
+ **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)
26076
+ **Related:** ore, orbit, forge, keymap
25932
26077
 
25933
26078
  ### Overview
25934
26079
 
@@ -26034,17 +26179,17 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
26034
26179
 
26035
26180
  ### Components
26036
26181
 
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`
26182
+ **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
26183
 
26039
26184
  **Disclosure:** `ore-accordion`, `ore-accordion-item`, `ore-tabs`, `ore-tab-item`, `ore-tab-panel`
26040
26185
 
26041
- **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`
26186
+ **Feedback:** `ore-alert`, `ore-async`, `ore-badge`, `ore-chip`, `ore-password-strength`, `ore-progress`, `ore-skeleton`, `ore-toast`, `ore-typing-indicator`
26042
26187
 
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`
26188
+ **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
26189
 
26045
26190
  **Layout:** `ore-box`, `ore-grid`, `ore-grid-item`, `ore-navbar`, `ore-sidebar`
26046
26191
 
26047
- **Overlay:** `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
26192
+ **Overlay:** `ore-command-palette`, `ore-command-palette-item`, `ore-dialog`, `ore-drawer`, `ore-menu`, `ore-popover`, `ore-tooltip`
26048
26193
 
26049
26194
  ## Features
26050
26195
 
@@ -26072,6 +26217,7 @@ Headless widget controllers (`createTextField`, `createListControl`, `createOver
26072
26217
  - [Ore](/ore/) — Web component runtime that powers Refine
26073
26218
  - [Orbit](/orbit/) — Floating UI positioning used in Refine's overlays
26074
26219
  - [Forge](/forge/) — Form state management for use with Refine inputs
26220
+ - [Keymap](/keymap/) — Keyboard shortcut manager that powers the command palette's global trigger
26075
26221
 
26076
26222
  ### API Reference
26077
26223
 
@@ -26131,11 +26277,13 @@ import '@vielzeug/refine/button-group';
26131
26277
  import '@vielzeug/refine/calendar';
26132
26278
  import '@vielzeug/refine/card';
26133
26279
  import '@vielzeug/refine/carousel';
26280
+ import '@vielzeug/refine/chat-message';
26134
26281
  import '@vielzeug/refine/checkbox';
26135
26282
  import '@vielzeug/refine/checkbox-group';
26136
26283
  import '@vielzeug/refine/chip';
26137
26284
  import '@vielzeug/refine/copy-command';
26138
26285
  import '@vielzeug/refine/combobox';
26286
+ import '@vielzeug/refine/command-palette';
26139
26287
  import '@vielzeug/refine/datagrid';
26140
26288
  import '@vielzeug/refine/date-picker';
26141
26289
  import '@vielzeug/refine/dialog';
@@ -26146,7 +26294,10 @@ import '@vielzeug/refine/grid';
26146
26294
  import '@vielzeug/refine/grid-item';
26147
26295
  import '@vielzeug/refine/icon';
26148
26296
  import '@vielzeug/refine/input';
26297
+ import '@vielzeug/refine/list';
26298
+ import '@vielzeug/refine/list-item';
26149
26299
  import '@vielzeug/refine/menu';
26300
+ import '@vielzeug/refine/message-composer';
26150
26301
  import '@vielzeug/refine/navbar';
26151
26302
  import '@vielzeug/refine/number-input';
26152
26303
  import '@vielzeug/refine/otp-input';
@@ -26172,6 +26323,7 @@ import '@vielzeug/refine/textarea';
26172
26323
  import '@vielzeug/refine/time-picker';
26173
26324
  import '@vielzeug/refine/toast';
26174
26325
  import '@vielzeug/refine/tooltip';
26326
+ import '@vielzeug/refine/typing-indicator';
26175
26327
  ```
26176
26328
 
26177
26329
  ## Shared Exported Symbols
@@ -26231,20 +26383,24 @@ Per-component API — attributes, events, slots, CSS custom properties:
26231
26383
  - [Progress](./components/progress.md)
26232
26384
  - [Skeleton](./components/skeleton.md)
26233
26385
  - [Toast](./components/toast.md)
26386
+ - [Typing Indicator](./components/typing-indicator.md)
26234
26387
 
26235
26388
  ### Content
26236
26389
  - [Avatar](./components/avatar.md)
26237
26390
  - [Breadcrumb](./components/breadcrumb.md)
26238
26391
  - [Card](./components/card.md)
26239
26392
  - [Carousel](./components/carousel.md)
26393
+ - [Chat Message](./components/chat-message.md)
26240
26394
  - [Copy Command](./components/copy-command.md)
26241
26395
  - [Icon](./components/icon.md)
26396
+ - [List (+ List Item)](./components/list.md)
26242
26397
  - [Pagination](./components/pagination.md)
26243
26398
  - [Separator](./components/separator.md)
26244
26399
  - [Table](./components/table.md)
26245
26400
  - [Text](./components/text.md)
26246
26401
 
26247
26402
  ### Overlay
26403
+ - [Command Palette](./components/command-palette.md)
26248
26404
  - [Dialog](./components/dialog.md)
26249
26405
  - [Drawer](./components/drawer.md)
26250
26406
  - [Menu](./components/menu.md)
@@ -26261,6 +26417,7 @@ Per-component API — attributes, events, slots, CSS custom properties:
26261
26417
  - [File Input](./components/file-input.md)
26262
26418
  - [Form](./components/form.md)
26263
26419
  - [Input](./components/input.md)
26420
+ - [Message Composer](./components/message-composer.md)
26264
26421
  - [Number Input](./components/number-input.md)
26265
26422
  - [OTP Input](./components/otp-input.md)
26266
26423
  - [Radio (+ Radio Group)](./components/radio.md)
@@ -26313,7 +26470,7 @@ createTextField(options: TextFieldOptions): TextFieldHandle
26313
26470
 
26314
26471
  Controller for `` and ``. Manages value sync, validation triggers, character counter, and event wiring.
26315
26472
 
26316
- Key members: `value` (writable signal), `wire(el, signal?)`, `clear()`, `counter`.
26473
+ 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
26474
 
26318
26475
  ### `createChoiceField(options)`
26319
26476
 
@@ -26323,7 +26480,9 @@ createChoiceField(options: ChoiceFieldOptions): ChoiceFieldHandle
26323
26480
 
26324
26481
  Controller for single and multi-select inputs. Normalises `string | string[]` values.
26325
26482
 
26326
- Key members: `selectedValues`, `selectedValue`, `selectValue()`, `toggleValue()`, `removeValue()`, `clear()`, `setValues()`, `formValue`.
26483
+ 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).
26484
+
26485
+ `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
26486
 
26328
26487
  ### `createCheckable(options)`
26329
26488
 
@@ -26333,7 +26492,7 @@ createCheckable(options: CheckableOptions): CheckableHandle
26333
26492
 
26334
26493
  Controller for checkboxes and radios. Handles checked/indeterminate state, group delegation, and keyboard activation.
26335
26494
 
26336
- Key members: `checked`, `indeterminate`, `toggle()`, `handleClick()`, `handleKeydown()`.
26495
+ 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
26496
 
26338
26497
  ### `createOverlayControl(options)`
26339
26498
 
@@ -26367,6 +26526,8 @@ createListControl(options: ListNavigationOptions): ListControl
26367
26526
 
26368
26527
  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
26528
 
26529
+ 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.
26530
+
26370
26531
  ### Other Headless Exports
26371
26532
 
26372
26533
  | Export | Description |
@@ -26379,7 +26540,6 @@ Keyboard-navigable list without open state. Supports vertical/horizontal/omni na
26379
26540
  | `createDataGridControl()` | Data grid state (sorting, selection, column management, pagination)|
26380
26541
  | `createTypeahead()` | Standalone typeahead buffer with debounced reset |
26381
26542
  | `createDropdownPositioner()` | Floating dropdown positioner (wraps Orbit) |
26382
- | `createDialogFocusControl()` | Dialog-specific focus entry and restoration |
26383
26543
  | `createInteraction()` | Unified click/keyboard press handler for interactive elements |
26384
26544
  | `dispatchKeyboardAction()` | Low-level keymap dispatcher |
26385
26545
  | `createSelectionControl()` | Single/multi/none row-selection controller (used by the data grid) |
@@ -31276,7 +31436,7 @@ Scout builds a **trigram inverted index** at construction time. Query time is O(
31276
31436
  | ------------------------ | ---------------------------------------------- | ----------------------------------------------------------------------------------------- | ---------------------------------------------- |
31277
31437
  | Bundle size | ~3 KB | | ~23 KB |
31278
31438
  | Zero dependencies | | `@vielzeug/ripple` peer (reactive layer only) | |
31279
- | Algorithm | Levenshtein | Trigram + Dice coefficient | Bitap |
31439
+ | Algorithm | Levenshtein | Trigram + overlap coefficient | Bitap |
31280
31440
  | Query time | O(n·m) | O(candidates) | O(n·m) |
31281
31441
  | Stateful index | | | |
31282
31442
  | Match highlighting | | | |
@@ -31388,7 +31548,7 @@ function createIndex(items: T[], options: ScoutIndexOptions): ScoutIndex
31388
31548
  | --- | --- | --- |
31389
31549
  | `items` | `T[]` | Initial corpus to index. |
31390
31550
  | `options.fields` | `ReadonlyArray>` | Fields to index. Required; at least one entry. |
31391
- | `options.threshold` | `number` | Min Dice score for a result (default `0.2`). |
31551
+ | `options.threshold` | `number` | Min overlap score for a result (default `0.2`). |
31392
31552
  | `options.limit` | `number` | Max results returned by `search()` (default `50`). |
31393
31553
  | `options.minQueryLength` | `number` | Min chars before trigram scoring; shorter queries use O(n) containment scan (default `3`). |
31394
31554
 
@@ -31533,7 +31693,7 @@ function createReactiveSearch(
31533
31693
  | `items` | `T[]` | Initial corpus to index. |
31534
31694
  | `options.fields` | `ReadonlyArray>` | Fields to index. Required. |
31535
31695
  | `options.debounce` | `number` | Debounce ms (default `200`). |
31536
- | `options.threshold` | `number` | Min Dice score (default `0.2`). |
31696
+ | `options.threshold` | `number` | Min overlap score (default `0.2`). |
31537
31697
  | `options.limit` | `number` | Max results (default `50`). |
31538
31698
  | `options.minQueryLength` | `number` | Min chars before trigram scoring (default `3`). |
31539
31699
 
@@ -31903,12 +32063,18 @@ index.search('日本語'); // matches the first document
31903
32063
  Pass `limit`, `threshold`, and `minQueryLength` in options to control result count and quality.
31904
32064
 
31905
32065
  ```ts
31906
- // At most 10 results, minimum Dice score 0.3
32066
+ // At most 10 results, minimum overlap score 0.3
31907
32067
  const results = index.search('widget', { limit: 10, threshold: 0.3 });
31908
32068
  ```
31909
32069
 
31910
32070
  Per-call options override the index-level defaults set in `createIndex`.
31911
32071
 
32072
+ Scores come from the overlap (Szymkiewicz–Simpson) coefficient — the fraction of the *shorter*
32073
+ trigram set (almost always the query) found in the longer one. This is deliberate for the
32074
+ autocomplete/command-palette use case `createIndex` targets: a short query that's a clean prefix
32075
+ of a much longer field value (e.g. `'fin'` against `'Finalize Q3 budget report'`) scores on how
32076
+ much of the query matched, not diluted by how much longer the target field happens to be.
32077
+
31912
32078
  ### Controlling short-query behaviour
31913
32079
 
31914
32080
  Queries shorter than `minQueryLength` (default `3`) fall back to an O(n) substring containment scan. Short-query matches return `score = 1.0`.
@@ -32561,6 +32727,7 @@ interface VirtualizerState {
32561
32727
  | `scrollToOffset` | `(offset: number, options?: { behavior?: ScrollBehavior }) => void` | Scroll to a raw pixel offset |
32562
32728
  | `scrollToTop` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to offset `0` |
32563
32729
  | `scrollToBottom` | `(options?: { behavior?: ScrollBehavior }) => void` | Scroll to the end of the list |
32730
+ | `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
32731
  | `invalidate` | `() => void` | Clear all measurements and rebuild from estimates |
32565
32732
  | `dispose` | `() => void` | Detach listeners; idempotent |
32566
32733
  | `disposed` | `boolean` | `true` after `dispose()` is called |
@@ -32696,9 +32863,34 @@ ctrl.dispose();
32696
32863
  | `overscan` | `number \| { start?: number; end?: number }` | `3` | Extra items outside the viewport; number = symmetric |
32697
32864
  | `sticky` | `(index: number, item: T) => boolean` | — | Mark items as sticky headers |
32698
32865
  | `clear` | `(listEl: HTMLElement) => void` | — | Custom teardown for listEl; defaults to `textContent = ''` |
32866
+ | `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
32867
 
32700
32868
  Without `getItemKey`, each `setItems()` call drops cached measurements.
32701
32869
 
32870
+ ### `StickToBottomOptions`
32871
+
32872
+ | Option | Type | Default | Description |
32873
+ | ----------- | --------- | ------- | --------------------------------------------------------------------------- |
32874
+ | `enabled` | `boolean` | `true` | Enable/disable at runtime — pass the object form to toggle without removing it |
32875
+ | `threshold` | `number` | `48` | Distance in pixels from the end still considered "at the end" |
32876
+
32877
+ `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.
32878
+
32879
+ ```ts
32880
+ const chat = createDomVirtualList({
32881
+ estimateSize: 48,
32882
+ getItemKey: (_, m) => m.id,
32883
+ listElement: listEl,
32884
+ render: renderMessages,
32885
+ scrollElement: scrollEl,
32886
+ stickToBottom: true, // or { threshold: 80 } for a larger "still at bottom" tolerance
32887
+ });
32888
+
32889
+ chat.setItems(messages); // scrolls to bottom on first load
32890
+ // … later, a new message arrives (or the last one grows while streaming) …
32891
+ chat.setItems([...messages, newMessage]); // follows along only if the user was already at the bottom
32892
+ ```
32893
+
32702
32894
  ### `DomVirtualListRenderArgs`
32703
32895
 
32704
32896
  ```ts
@@ -32734,6 +32926,9 @@ Extends `Virtualizer` (minus `prepend` and `update`) with `setItems()`. All virt
32734
32926
  | `invalidate` | Clear measurements and rebuild from estimates |
32735
32927
  | `scrollToIndex` | Scroll to an item |
32736
32928
  | `scrollToOffset` | Scroll to a pixel offset |
32929
+ | `scrollToTop` | Scroll to offset `0` |
32930
+ | `scrollToBottom` | Scroll to the end of the list |
32931
+ | `isAtEnd` | `true` when within `threshold` px of the end |
32737
32932
  | `dispose` | Teardown; idempotent |
32738
32933
  | `disposed` | `true` after `dispose()` is called (live getter) |
32739
32934
  | `[Symbol.dispose]` | Delegates to `dispose()` |
@@ -33613,6 +33808,76 @@ virt.scrollToTop();
33613
33808
  virt.scrollToBottom({ behavior: 'smooth' });
33614
33809
  ```
33615
33810
 
33811
+ ### Chat "stick to bottom on new message"
33812
+
33813
+ `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.
33814
+
33815
+ ```ts
33816
+ import { createDomVirtualList } from '@vielzeug/scroll';
33817
+
33818
+ const chat = createDomVirtualList({
33819
+ estimateSize: 48,
33820
+ getItemKey: (_, m) => m.id,
33821
+ listElement: listEl,
33822
+ render: renderMessages,
33823
+ scrollElement: scrollEl,
33824
+ stickToBottom: true, // or { threshold: 80 } to widen the "still at bottom" tolerance
33825
+ });
33826
+
33827
+ chat.setItems(messages);
33828
+
33829
+ // New message arrives — follows only if the user hasn't scrolled up.
33830
+ socket.on('message', (msg) => {
33831
+ messages = [...messages, msg];
33832
+ chat.setItems(messages);
33833
+ });
33834
+ ```
33835
+
33836
+ 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):
33837
+
33838
+ ```ts
33839
+ const showJumpButton = !virt.isAtEnd();
33840
+ ```
33841
+
33842
+ ## Infinite Scroll — Loading More at the End
33843
+
33844
+ 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.
33845
+
33846
+ ```ts
33847
+ import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
33848
+
33849
+ let rows = await fetchPage(0);
33850
+ let loading = false;
33851
+
33852
+ let virt: Virtualizer;
33853
+ virt = createVirtualizer(scrollEl, {
33854
+ count: rows.length,
33855
+ estimateSize: 36,
33856
+ onChange: ({ items, totalSize }) => {
33857
+ listEl.style.height = `${totalSize}px`;
33858
+ listEl.innerHTML = '';
33859
+
33860
+ for (const item of items) {
33861
+ const el = document.createElement('div');
33862
+ el.style.cssText = `position:absolute;top:${item.start}px;left:0;right:0;height:36px;`;
33863
+ el.textContent = rows[item.index]?.label ?? '';
33864
+ listEl.appendChild(el);
33865
+ }
33866
+
33867
+ if (!loading && virt.isAtEnd(200)) {
33868
+ loading = true;
33869
+ fetchPage(rows.length).then((nextRows) => {
33870
+ rows = [...rows, ...nextRows];
33871
+ virt.update({ count: rows.length });
33872
+ loading = false;
33873
+ });
33874
+ }
33875
+ },
33876
+ });
33877
+ ```
33878
+
33879
+ `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.
33880
+
33616
33881
  ## Shared Measurement Cache
33617
33882
 
33618
33883
  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 +33964,7 @@ Scroll is rendering-layer agnostic. The pattern is always the same: create the v
33699
33964
 
33700
33965
  ```tsx [React]
33701
33966
  import { createVirtualizer, type Virtualizer } from '@vielzeug/scroll';
33702
- import { useEffect, useRef } from 'react';
33967
+ import { useEffect, useLayoutEffect, useRef } from 'react';
33703
33968
 
33704
33969
  interface Row {
33705
33970
  id: number;
@@ -33734,7 +33999,10 @@ function VirtualList({ rows }: { rows: Row[] }) {
33734
33999
  return () => virt.dispose();
33735
34000
  }, []); // attach once
33736
34001
 
33737
- useEffect(() => {
34002
+ // useLayoutEffect, not useEffect: syncs count before paint. With useEffect,
34003
+ // the DOM (and anything reading `rows`) paints once with the new length before
34004
+ // the virtualizer's internal count catches up, which can render stale/out-of-bounds indices.
34005
+ useLayoutEffect(() => {
33738
34006
  virtRef.current?.update({ count: rows.length });
33739
34007
  }, [rows.length]);
33740
34008
 
@@ -33877,6 +34145,7 @@ class VirtualList extends LitElement {
33877
34145
  ### Pitfalls
33878
34146
 
33879
34147
  - **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.
34148
+ - **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
34149
  - **Vue 3:** `ref.value` is `null` inside `setup()` — the DOM doesn't exist yet. Always create the virtualizer inside `onMounted`, not in `setup()`.
33881
34150
  - **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
34151
  - **Web Components:** `firstUpdated` fires once after the first render. Use `updated()` for subsequent prop changes — Lit calls it every time `rows` changes.
@@ -34090,7 +34359,7 @@ console.log(source.current, source.meta.totalItems);
34090
34359
 
34091
34360
  | Symbol | Purpose | Execution mode | Common gotcha |
34092
34361
  | -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------- | --------------------------------------------------------------------------------- |
34093
- | `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` is fuzzy, not substring |
34362
+ | `createLocalSource()` | In-memory reactive collection with filter, sort, and search | Sync | Default `searchFn` JSON-stringifies each item for substring matching |
34094
34363
  | `createRemoteSource()` | Async server-backed collection with page navigation | Async | Fetches on creation; set `autoFetch: false` to delay |
34095
34364
  | `createCursorSource()` | Async collection navigated by cursor tokens | Async | `next()`/`prev()` are no-ops when the cursor is absent |
34096
34365
  | `createInfiniteSource()` | Async append-mode (infinite scroll) collection | Async | `loadMore()` is a no-op once `meta.hasMore` is `false` |
@@ -34107,6 +34376,7 @@ console.log(source.current, source.meta.totalItems);
34107
34376
  | `filterContains()` | Preset predicate: case-insensitive substring match | Sync | Matches against a getter's string value |
34108
34377
  | `filterEquals()` | Preset predicate: strict equality match | Sync | Uses `Object.is` semantics |
34109
34378
  | `filterRange()` | Preset predicate: inclusive min/max range | Sync | Works with numbers and Dates |
34379
+ | `searchBy()` | Preset search builder: field-based matching for `LocalSourceConfig.searchFn` | Sync | Prefer this over default JSON-stringify search on large collections |
34110
34380
  | `sortBy()` | Preset comparator: sort by a getter value | Sync | Supports `'asc'` / `'desc'`; handles strings, numbers, Dates |
34111
34381
  | `encodeQuery()` | Serialize source query to URL params | Sync | Filter and sort are JSON-stringified |
34112
34382
  | `decodeQuery()` | Deserialize URL params (or `URLSearchParams`) to a source query | Sync | Malformed JSON is silently dropped by default |
@@ -34144,7 +34414,7 @@ type LocalSourceConfig = {
34144
34414
  };
34145
34415
  ```
34146
34416
 
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.
34417
+ 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
34418
 
34149
34419
  `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
34420
 
@@ -34197,7 +34467,7 @@ type RemoteConfig = {
34197
34467
  ```
34198
34468
 
34199
34469
  `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.
34470
+ `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
34471
 
34202
34472
  **Returns:** `RemoteSource` — async server-backed source with page navigation and optimistic update support.
34203
34473
 
@@ -34830,13 +35100,23 @@ createLocalSource(data, {
34830
35100
  debounceMs: 300, // debounce delay for source.search() (default: 300)
34831
35101
  filter: (u) => u.active, // initial synchronous filter predicate
34832
35102
  sort: (a, b) => a.name.localeCompare(b.name), // initial sorter
34833
- searchFn: (items, query) => items.filter(/* custom match */), // override default search
35103
+ searchFn: searchBy([(u) => u.name, (u) => u.email]), // override default search
34834
35104
  // Async variants — enable Web Worker offloading via @vielzeug/familiar:
34835
35105
  filterAsync: async (items, signal) => items.filter(/* expensive filter */),
34836
35106
  sortAsync: async (items, signal) => [...items].sort(/* expensive sort */),
34837
35107
  });
34838
35108
  ```
34839
35109
 
35110
+ For large in-memory datasets, prefer the `searchBy(...)` preset over the default `JSON.stringify` search:
35111
+
35112
+ ```ts
35113
+ import { createLocalSource, searchBy } from '@vielzeug/sourcerer';
35114
+
35115
+ const source = createLocalSource(users, {
35116
+ searchFn: searchBy([(u) => u.name, (u) => u.email]),
35117
+ });
35118
+ ```
35119
+
34840
35120
  `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
35121
 
34842
35122
  ### Mutations
@@ -34907,6 +35187,7 @@ createRemoteSource({
34907
35187
  ```
34908
35188
 
34909
35189
  `staleTime` compares the **query key** — navigating to a different page always fetches even when the previous result is still within the stale window.
35190
+ When an `optimisticUpdate()` is active, `refresh()` always fetches even within `staleTime` so the optimistic state can settle deterministically.
34910
35191
 
34911
35192
  ### The `fetch` callback
34912
35193
 
@@ -35328,6 +35609,7 @@ effect(() => {
35328
35609
  - Always call the unsubscribe function returned by `subscribe()` when the component is torn down.
35329
35610
  - For URL sync, use `decodeQuery()` + `applyQuery()` rather than reconstructing source state from params manually.
35330
35611
  - Use `staleTime` with `refreshInterval` for stale-while-revalidate patterns on dashboards.
35612
+ - If you use `optimisticUpdate()`, call `refresh()` after mutation confirmation; it bypasses `staleTime` while optimistic state is active.
35331
35613
  - Only one `optimisticUpdate()` can be active at a time — always handle the thrown error or check before calling.
35332
35614
  - 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
35615
  - 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.
@@ -35366,7 +35648,7 @@ effect(() => {
35366
35648
 
35367
35649
  **Category:** validation
35368
35650
  **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)
35651
+ **Key exports:** s, Schema, PipeSchema, SpellValidationError, ErrorCode, errorsAt, fail, descriptorToJsonSchema, schemaToJsonSchema, createParseContext, setMessages, setLogger (+4 more)
35370
35652
  **Related:** forge, courier, vault
35371
35653
 
35372
35654
  ### Overview
@@ -35501,7 +35783,9 @@ const user = User.parse(payload);
35501
35783
  | `Schema.parseAsync()` / `safeParseAsync()` | Validate including async `validate()` callbacks | Async | Required when any nested rule uses an async `validate()` callback. |
35502
35784
  | `descriptorToJsonSchema()` | Convert a `SchemaDescriptor` to JSON Schema | Sync setup | Uses `toDescriptor()` output, not custom transforms. |
35503
35785
  | `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. |
35786
+ | `setMessages()` / `setLogger()` / `resetMessages()` | Override validation messages and warning logger globally | Sync setup | `setMessages()` replaces the active message set each call. |
35787
+ | `createParseContext()` | Create request-scoped message overrides for a parse call | Sync setup | Overrides apply only to calls that receive the returned context. |
35788
+ | `withMessages()` / `withLogger()` | Run a callback with temporary global message/logger overrides | Sync/async setup | Restores previous global state after the callback settles. |
35505
35789
  | `SpellValidationError` | Inspect validation failures | Sync/async failures | `format()` returns nested objects, `flatten()` returns path arrays. |
35506
35790
  | `prependIssuePath()` | Prefix a path segment to an array of issues | Sync | Use inside custom parsers that delegate to inner schemas. |
35507
35791
 
@@ -35518,7 +35802,7 @@ Use this table to scan every runtime export.
35518
35802
  | Category | Exports |
35519
35803
  | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35520
35804
  | Classes | `Schema`, `PipeSchema`, `SpellError`, `SpellValidationError` |
35521
- | Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages` |
35805
+ | Message and error helpers | `ErrorCode`, `errorsAt`, `fail`, `prependIssuePath`, `setMessages`, `setLogger`, `resetMessages`, `createParseContext`, `withMessages`, `withLogger` |
35522
35806
  | Descriptor helpers | `descriptorToJsonSchema`, `schemaToJsonSchema` |
35523
35807
  | Pure validators | `hasMaxLength`, `hasMinLength`, `isArray`, `isBoolean`, `isDate`, `isInteger`, `isMultipleOf`, `isNegative`, `isNonNegative`, `isNullOrUndefined`, `isNumber`, `isPositive`, `isString`, `isInRange` |
35524
35808
  | 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 +35900,7 @@ Builder reference:
35616
35900
  | `s.or(a, b)` | `UnionSchema` | Alias for `s.union()` with exactly two schemas. |
35617
35901
  | `s.and(a, b)` | `IntersectSchema` | Alias for `s.intersect()` with two schemas. |
35618
35902
  | `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. |
35903
+ | `s.variant(key, map)` | `VariantSchema` | Discriminated object union with async-aware branch parsing in `parseAsync()`. |
35620
35904
  | `s.lazy(getter)` | `LazySchema` | Recursive schema definitions. |
35621
35905
  | `s.instanceof(cls)` | `InstanceOfSchema` | Runtime class instance checks. |
35622
35906
 
@@ -36496,7 +36780,7 @@ const Signup = s.object({ confirm: s.string(), password: s.string() }).validate(
36496
36780
  });
36497
36781
  ```
36498
36782
 
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()`.
36783
+ 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
36784
 
36501
36785
  ```ts
36502
36786
  import { s } from '@vielzeug/spell';
@@ -36599,7 +36883,7 @@ Descriptors are serializable snapshots of the schema structure. Use `toDescripto
36599
36883
 
36600
36884
  ## Messages
36601
36885
 
36602
- Use `setMessages()` to replace the active validation message catalog. Each call replaces the current overrides — it does not accumulate.
36886
+ Use `setMessages()` to replace the active validation message catalog globally. Each call replaces the current overrides — it does not accumulate.
36603
36887
 
36604
36888
  ```ts
36605
36889
  import { resetMessages, setMessages } from '@vielzeug/spell';
@@ -36627,6 +36911,35 @@ setLogger(null); // silence
36627
36911
  setLogger((msg) => myLogger.warn(msg)); // redirect
36628
36912
  ```
36629
36913
 
36914
+ Use `createParseContext()` when you need request-scoped message overrides without mutating global state.
36915
+
36916
+ ```ts
36917
+ import { createParseContext, s } from '@vielzeug/spell';
36918
+
36919
+ const User = s.object({ email: s.string().email() });
36920
+
36921
+ User.safeParse(
36922
+ { email: 'ada@example.com', extra: true },
36923
+ createParseContext({ object: { invalidKeys: () => 'No unknown keys in this endpoint' } }),
36924
+ );
36925
+ ```
36926
+
36927
+ Use `withMessages()` / `withLogger()` to apply temporary global overrides inside a bounded sync or async callback.
36928
+
36929
+ ```ts
36930
+ import { s, withLogger, withMessages } from '@vielzeug/spell';
36931
+
36932
+ const Email = s.string().email();
36933
+
36934
+ await withMessages({ string: { email: () => 'Scoped email' } }, async () => {
36935
+ Email.safeParse('bad'); // issue message uses "Scoped email"
36936
+ });
36937
+
36938
+ withLogger((msg) => myLogger.warn(msg), () => {
36939
+ s.string().regex(/^a$/).regex(/^b$/);
36940
+ });
36941
+ ```
36942
+
36630
36943
  To integrate with `@vielzeug/lingua`, call `setMessages()` from your locale change callback:
36631
36944
 
36632
36945
  ```ts
@@ -40270,888 +40583,267 @@ const db = createIndexedDB({
40270
40583
  ## @vielzeug/ward
40271
40584
 
40272
40585
  **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
40586
 
40277
40587
  ### Overview
40278
40588
 
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
- ```
40589
+ `@vielzeug/ward` is a zero-dependency authorization engine for role/resource/action policies.
40333
40590
 
40334
40591
  ## Quick Start
40335
40592
 
40336
40593
  ```ts
40337
- import { ANONYMOUS, WILDCARD, allow, createWard, deny, predicate } from '@vielzeug/ward';
40594
+ import { ANONYMOUS, WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
40338
40595
 
40339
40596
  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
40597
+ ...allow([ANONYMOUS, 'viewer'], 'posts', ['read']),
40598
+ ...allow('editor', 'posts', ['update'], { when: owns('authorId') }),
40345
40599
  ...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
40600
  ]);
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
-
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
40601
 
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']);
40602
+ const principal = { id: 'u1', roles: ['editor'] };
40570
40603
 
40571
- // With runtime data for predicate-gated rules
40572
- const owned = ward.allowedActions({ id: 'u1', roles: ['editor'] }, 'posts', ['read', 'update', 'delete'], {
40573
- authorId: 'u1',
40604
+ const decision = ward.explain({
40605
+ principal,
40606
+ resource: 'posts',
40607
+ action: 'update',
40608
+ data: { authorId: 'u2' },
40574
40609
  });
40575
40610
  ```
40576
40611
 
40577
- ---
40612
+ `explain()` returns a discriminated decision (`allowed: true | false`) and optional matching `rule`.
40578
40613
 
40579
- ### `explain()`
40614
+ ## Bound View
40580
40615
 
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.
40591
-
40592
- **Returns:** `WardDecision`
40593
-
40594
- **Example:**
40616
+ Use `forUser()` when checking many permissions for the same principal:
40595
40617
 
40596
40618
  ```ts
40597
- const decision = ward.explain({ id: 'u1', roles: ['editor'] }, 'posts', 'delete');
40598
-
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
- }
40605
- ```
40606
-
40607
- ---
40608
-
40609
- ### `trace()`
40619
+ const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
40610
40620
 
40611
- ```ts
40612
- ward.trace(
40613
- principal: Principal,
40614
- resource: string,
40615
- action: TAction,
40616
- data?: TData,
40617
- ): WardTrace
40621
+ bound.explain({ resource: 'posts', action: 'read' });
40622
+ bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
40618
40623
  ```
40619
40624
 
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:**
40625
+ ## Middleware Guards
40627
40626
 
40628
40627
  ```ts
40629
- const { decision, candidates } = ward.trace({ id: 'u1', roles: ['editor'] }, 'posts', 'read');
40628
+ import { guardRequest, guardRequestWith } from '@vielzeug/ward';
40630
40629
 
40631
- candidates.forEach(({ rule, priority, score, won }) => {
40632
- console.log(rule.effect, priority, score, won ? '← winner' : '');
40630
+ const direct = guardRequest({
40631
+ ward,
40632
+ principal,
40633
+ resource: 'posts',
40634
+ action: 'read',
40633
40635
  });
40634
- ```
40635
-
40636
- ---
40637
-
40638
- ### `rulesInScope()`
40639
-
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
40636
 
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
- }
40637
+ const extracted = await guardRequestWith({
40638
+ ward,
40639
+ req,
40640
+ extractPrincipal: async (request) => request.user ?? null,
40641
+ resource: 'posts',
40642
+ action: 'read',
40689
40643
  });
40690
40644
  ```
40691
40645
 
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) |
40646
+ See the [usage guide](./usage.md), [API reference](./api.md), and [examples](./examples/blog-roles.md).
40713
40647
 
40714
- **Example:**
40715
-
40716
- ```ts
40717
- const bound = ward.forUser({ id: 'u1', roles: ['editor'] });
40718
-
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
- ```
40648
+ ### API Reference
40730
40649
 
40731
- ## Helper Functions
40650
+ ## Core Factory
40732
40651
 
40733
- ### `allow()` / `deny()`
40652
+ ### `createWard(rules, options?)`
40734
40653
 
40735
40654
  ```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[]
40655
+ createWard(
40656
+ rules: ReadonlyArray>>,
40657
+ options?: WardOptions,
40658
+ ): Ward;
40749
40659
  ```
40750
40660
 
40751
- Ergonomic rule factories one `WardRule` per action, with `effect` fixed to `'allow'` or `'deny'`. Spread the result into the array passed to `createWard`.
40661
+ Creates an immutable ward instance.
40752
40662
 
40753
- **Returns:** `WardRule[]`
40663
+ ## Rule Builders
40754
40664
 
40755
- **Example:**
40665
+ ### `allow(role, resource, actions, options?)`
40666
+ ### `deny(role, resource, actions, options?)`
40667
+ ### `ruleFor(effect, role, resource, actions, options?)`
40756
40668
 
40757
- ```ts
40758
- import { WILDCARD, allow, createWard, deny, owns } from '@vielzeug/ward';
40669
+ All three return `WardRule[]` (one rule per action).
40759
40670
 
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
- ```
40766
-
40767
- ---
40671
+ ## Ward Methods
40768
40672
 
40769
- ### `ruleFor()`
40673
+ ### `checkAll(principal, checks)`
40770
40674
 
40771
40675
  ```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[]
40676
+ checkAll(
40677
+ principal: UserPrincipal,
40678
+ checks: ReadonlyArray>,
40679
+ ): WardDecisionResult[];
40779
40680
  ```
40780
40681
 
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:**
40682
+ ### `explain(input)`
40786
40683
 
40787
40684
  ```ts
40788
- import { ruleFor } from '@vielzeug/ward';
40789
-
40790
- ruleFor('allow', 'viewer', 'posts', ['read', 'update']);
40791
- ruleFor('deny', ['blocked', 'suspended'], WILDCARD, [WILDCARD], { priority: 100 });
40685
+ explain(input: WardExplainInput): WardDecision;
40792
40686
  ```
40793
40687
 
40794
- ---
40795
-
40796
- ### `owns()` / `predicate`
40688
+ `WardExplainInput`:
40797
40689
 
40798
40690
  ```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
- };
40691
+ {
40692
+ principal: UserPrincipal;
40693
+ resource: string;
40694
+ action: TAction;
40695
+ data?: TData;
40696
+ }
40807
40697
  ```
40808
40698
 
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:**
40699
+ ### `trace(input)`
40818
40700
 
40819
40701
  ```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) });
40702
+ trace(input: WardTraceInput): WardTrace;
40824
40703
  ```
40825
40704
 
40826
- ---
40705
+ Same request shape as `explain()`. Returns winner + candidate list. Does not fire logger.
40827
40706
 
40828
- ### `matchesPattern()`
40707
+ ### `allowedActions(input)`
40829
40708
 
40830
40709
  ```ts
40831
- matchesPattern(pattern: string, value: string): boolean
40710
+ allowedActions(
40711
+ input: WardAllowedActionsInput,
40712
+ ): TKnown[];
40832
40713
  ```
40833
40714
 
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:**
40715
+ Input shape:
40847
40716
 
40848
40717
  ```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
40718
+ {
40719
+ principal: UserPrincipal;
40720
+ resource: string;
40721
+ knownActions: readonly TKnown[];
40722
+ data?: TData;
40723
+ }
40854
40724
  ```
40855
40725
 
40856
- ---
40857
-
40858
- ### `patternCovers()`
40726
+ ### `rulesInScope(input)`
40859
40727
 
40860
40728
  ```ts
40861
- patternCovers(broad: string, narrow: string): boolean
40729
+ rulesInScope(input: WardRulesInScopeInput): ReadonlyArray>>;
40862
40730
  ```
40863
40731
 
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:**
40732
+ Input shape:
40867
40733
 
40868
40734
  ```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
40735
+ {
40736
+ principal: UserPrincipal;
40737
+ resource: string;
40738
+ data?: TData;
40739
+ }
40875
40740
  ```
40876
40741
 
40877
- ---
40878
-
40879
- ### Middleware Factories
40880
-
40881
- #### `guardRequest()`
40742
+ ### `detectConflicts()`
40882
40743
 
40883
40744
  ```ts
40884
- guardRequest(
40885
- ward: Ward,
40886
- principal: Principal,
40887
- resource: string,
40888
- action: TAction,
40889
- data?: TData,
40890
- ): GuardResult
40745
+ detectConflicts(): WardConflict[];
40891
40746
  ```
40892
40747
 
40893
- Framework-agnostic synchronous guard for a known principal. Returns a `GuardResult`:
40748
+ ### `forUser(principal)`
40894
40749
 
40895
40750
  ```ts
40896
- type GuardResult =
40897
- | { granted: true; principal: Principal }
40898
- | { granted: false; decision: WardDecision; principal: Principal; reason: 'explicit-deny' | 'no-matching-rule' };
40751
+ forUser(principal: UserPrincipal): BoundWard;
40899
40752
  ```
40900
40753
 
40901
- Use `guardRequestWith` when the principal must be resolved asynchronously from a request object.
40754
+ Returns a principal-bound view.
40902
40755
 
40903
- **Example:**
40756
+ ## `BoundWard` Methods
40904
40757
 
40905
40758
  ```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 });
40759
+ interface BoundWard {
40760
+ checkAll(checks: ReadonlyArray>): WardDecisionResult[];
40761
+ explain(input: BoundWardExplainInput): WardDecision;
40762
+ trace(input: BoundWardTraceInput): WardTrace;
40763
+ allowedActions(input: BoundWardAllowedActionsInput): TKnown[];
40764
+ rulesInScope(input: BoundWardRulesInScopeInput): ReadonlyArray>>;
40912
40765
  }
40913
40766
  ```
40914
40767
 
40915
- ---
40916
-
40917
- #### `guardRequestWith()`
40768
+ Bound input shapes remove `principal`:
40918
40769
 
40919
40770
  ```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>
40771
+ { resource: string; action: TAction; data?: TData } // explain/trace
40772
+ { resource: string; knownActions: readonly TKnown[]; data?: TData } // allowedActions
40773
+ { resource: string; data?: TData } // rulesInScope
40928
40774
  ```
40929
40775
 
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.
40776
+ ## Predicate Helpers
40931
40777
 
40932
- **Example:**
40778
+ ### `predicate.owns(attributeKey)`
40779
+ ### `predicate.and(...predicates)`
40780
+ ### `predicate.or(...predicates)`
40781
+ ### `predicate.not(predicate)`
40782
+ ### `owns(attributeKey)` (alias)
40933
40783
 
40934
- ```ts
40935
- import { guardRequestWith } from '@vielzeug/ward';
40784
+ Predicates run synchronously. Returning a Promise throws `WardPredicateError`.
40936
40785
 
40937
- const result = await guardRequestWith(ward, req, (req) => req.user ?? null, 'posts', 'update');
40786
+ ## Pattern Helpers
40938
40787
 
40939
- if (!result.granted) {
40940
- return response.status(403).json({ reason: result.reason });
40941
- }
40942
- ```
40788
+ ### `matchesPattern(pattern, value): boolean`
40789
+ ### `patternCovers(broad, narrow): boolean`
40943
40790
 
40944
- ## Types
40791
+ ## Middleware Guards
40945
40792
 
40946
- ### `UserPrincipal`
40793
+ ### `guardRequest(input)`
40947
40794
 
40948
40795
  ```ts
40949
- type UserPrincipal = {
40950
- id: string;
40951
- roles: readonly string[];
40952
- attributes?: Record;
40953
- };
40954
- ```
40955
-
40956
- ### `Principal`
40957
-
40958
- ```ts
40959
- type Principal = UserPrincipal | null;
40796
+ guardRequest(
40797
+ input: GuardRequestInput,
40798
+ ): GuardResult;
40960
40799
  ```
40961
40800
 
40962
- `null` represents an unauthenticated (anonymous) user.
40963
-
40964
- ### `RuleContext`
40801
+ Input:
40965
40802
 
40966
40803
  ```ts
40967
- type RuleContext = {
40804
+ {
40805
+ ward: Ward;
40968
40806
  principal: UserPrincipal;
40807
+ resource: string;
40808
+ action: TAction;
40969
40809
  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
40810
  }
41017
-
41018
- // Generic narrowing:
41019
- if ('rule' in d) console.log(d.rule);
41020
40811
  ```
41021
40812
 
41022
- ### `WardCheck`
40813
+ ### `guardRequestWith(input)`
41023
40814
 
41024
40815
  ```ts
41025
- type WardCheck = {
41026
- resource: string;
41027
- action: TAction;
41028
- data?: TData;
41029
- };
40816
+ guardRequestWith(
40817
+ input: GuardRequestWithInput,
40818
+ ): Promise>;
41030
40819
  ```
41031
40820
 
41032
- ### `WardLoggerContext`
41033
-
41034
- Structurally identical to `WardDecision` plus the request fields — narrow `rule` with the same `if (ctx.allowed)` pattern used for decisions:
40821
+ Input:
41035
40822
 
41036
40823
  ```ts
41037
- type WardLoggerContext = WardDecision & {
40824
+ {
40825
+ ward: Ward;
40826
+ req: TReq;
40827
+ extractPrincipal: PrincipalExtractor;
40828
+ resource: string;
41038
40829
  action: TAction;
41039
40830
  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
- };
40831
+ }
41088
40832
  ```
41089
40833
 
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
- };
40834
+ ## Devtools
41100
40835
 
41101
- type WardTrace = {
41102
- candidates: WardTraceCandidate[];
41103
- decision: WardDecision;
41104
- };
41105
- ```
40836
+ ### `debugWard(ward, logger?)`
41106
40837
 
41107
- ## `debugWard(rules, options?)`
40838
+ Sub-path import: `@vielzeug/ward/devtools`.
41108
40839
 
41109
40840
  ```ts
41110
40841
  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
40842
  ```
41135
40843
 
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
- ```
41143
-
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
40844
  ### Usage Guide
41151
40845
 
41152
- ## Basic Usage
41153
-
41154
- Create a ward instance with an array of rules. Rules are compiled once at creation time.
40846
+ ## Create a Ward
41155
40847
 
41156
40848
  ```ts
41157
40849
  import { WILDCARD, createWard } from '@vielzeug/ward';
@@ -41159,592 +40851,124 @@ import { WILDCARD, createWard } from '@vielzeug/ward';
41159
40851
  const ward = createWard([
41160
40852
  { role: 'viewer', resource: 'posts', action: 'read', effect: 'allow' },
41161
40853
  { role: 'editor', resource: 'posts', action: 'update', effect: 'allow' },
41162
- // High-priority deny blocks the blocked role from every action on posts
41163
40854
  { role: 'blocked', resource: 'posts', action: WILDCARD, effect: 'deny', priority: 100 },
41164
40855
  ]);
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
40856
  ```
41170
40857
 
41171
- To update the policy, create a new instance rules are immutable after creation.
41172
-
41173
- ## Rule Factories
40858
+ Rules are immutable after creation. Create a new ward to update policy.
41174
40859
 
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".
40860
+ ## Explain a Decision
41176
40861
 
41177
40862
  ```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
- ```
41223
-
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`).
41225
-
41226
- ## Check Permissions
41227
-
41228
- ```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);
40863
+ const decision = ward.explain({
40864
+ principal: { id: 'u1', roles: ['editor'] },
40865
+ resource: 'posts',
40866
+ action: 'update',
40867
+ data: { authorId: 'u1' },
40868
+ });
41267
40869
 
41268
- const allAllowed = decisions.every((d) => d.allowed);
41269
- const anyAllowed = decisions.some((d) => d.allowed);
40870
+ if (decision.allowed) {
40871
+ console.log(decision.rule);
40872
+ } else {
40873
+ console.log(decision.reason); // 'no-matching-rule' | 'explicit-deny'
40874
+ }
41270
40875
  ```
41271
40876
 
41272
- ## Batch Decisions with `checkAll`
40877
+ ## Batch Decisions
41273
40878
 
41274
40879
  ```ts
41275
- const decisions = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
40880
+ const results = ward.checkAll({ id: 'u1', roles: ['editor'] }, [
41276
40881
  { resource: 'posts', action: 'read' },
41277
40882
  { resource: 'posts', action: 'update', data: { authorId: 'u1' } },
41278
40883
  ]);
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
40884
  ```
41304
40885
 
41305
- `allowedActions` does not invoke the logger — it is a side-effect-free enumeration helper.
41306
-
41307
- ## Inspect Rule Scope with `rulesInScope`
40886
+ ## Bound Ward (`forUser`)
41308
40887
 
41309
40888
  ```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
40889
  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
40890
 
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
- }
40891
+ bound.explain({ resource: 'posts', action: 'read' });
40892
+ bound.trace({ resource: 'posts', action: 'update', data: { authorId: 'u1' } });
40893
+ bound.rulesInScope({ resource: 'posts' });
40894
+ bound.allowedActions({ resource: 'posts', knownActions: ['read', 'update', 'delete'] as const });
41332
40895
  ```
41333
40896
 
41334
- `explain()` returns a **3-variant discriminated union**:
41335
-
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 |
40897
+ `forUser()` snapshots the principal. Re-bind when roles/identity change.
41341
40898
 
41342
- Use `'rule' in decision` to safely access the rule across all variants.
40899
+ ## Allowed Actions
41343
40900
 
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.
40901
+ `allowedActions()` evaluates a provided action set:
41347
40902
 
41348
40903
  ```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' : '');
40904
+ const actions = ward.allowedActions({
40905
+ principal: { id: 'u1', roles: ['admin'] },
40906
+ resource: 'posts',
40907
+ knownActions: ['read', 'update', 'delete'] as const,
41353
40908
  });
41354
40909
  ```
41355
40910
 
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?)`.
40911
+ It does not fire the logger.
41359
40912
 
41360
- ## Detect Policy Conflicts
41361
-
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.
40913
+ ## Rule Introspection
41363
40914
 
41364
40915
  ```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
- }
40916
+ const scoped = ward.rulesInScope({
40917
+ principal: { id: 'u1', roles: ['editor'] },
40918
+ resource: 'posts',
41373
40919
  });
41374
40920
  ```
41375
40921
 
41376
- Two conflict kinds, narrowable by `kind`:
40922
+ Use optional `data` to filter predicate-gated matches.
41377
40923
 
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.
41382
-
41383
- To surface conflicts eagerly at startup:
40924
+ ## Trace Candidates
41384
40925
 
41385
40926
  ```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}]`),
40927
+ const trace = ward.trace({
40928
+ principal: { id: 'u1', roles: ['editor', 'blocked'] },
40929
+ resource: 'posts',
40930
+ action: 'read',
41390
40931
  });
41391
40932
 
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
- },
40933
+ trace.candidates.forEach((c) => {
40934
+ console.log(c.index, c.priority, c.score, c.won);
41504
40935
  });
41505
40936
  ```
41506
40937
 
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.
40938
+ `trace()` does not fire the logger.
41509
40939
 
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'`:
40940
+ ## Predicate Helpers
41511
40941
 
41512
40942
  ```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
- ]);
40943
+ import { owns, predicate } from '@vielzeug/ward';
41561
40944
 
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;
41607
-
41608
- {#if canEdit}Edit{/if}
40945
+ const isOwner = owns('authorId');
40946
+ const canEdit = predicate.and(isOwner, ({ principal }) => principal !== null);
41609
40947
  ```
41610
40948
 
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.
40949
+ Async predicates are rejected at runtime with `WardPredicateError`.
41616
40950
 
41617
- ## Middleware Integration
41618
-
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.
40951
+ ## Framework Guards
41620
40952
 
41621
40953
  ```ts
41622
40954
  import { guardRequest, guardRequestWith } from '@vielzeug/ward';
41623
40955
 
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 });
40956
+ const direct = guardRequest({
40957
+ ward,
40958
+ principal: { id: 'u1', roles: ['viewer'] },
40959
+ resource: 'posts',
40960
+ action: 'read',
41641
40961
  });
41642
- ```
41643
-
41644
- ### Hono
41645
40962
 
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);
40963
+ const extracted = await guardRequestWith({
40964
+ ward,
40965
+ req,
40966
+ extractPrincipal: async (request) => request.user ?? null,
40967
+ resource: 'posts',
40968
+ action: 'read',
41650
40969
  });
41651
40970
  ```
41652
40971
 
41653
- ## Debug Mode
41654
-
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),
41682
- });
41683
- ```
41684
-
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
40972
  ### Examples
41749
40973
 
41750
40974
  ## Examples