@jarenjs/app 0.34.0

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.
@@ -0,0 +1,77 @@
1
+ //@ts-check
2
+ /**
3
+ * @file A bounded transaction log over `app.observe` (APP-FORMAT §8.3).
4
+ *
5
+ * The log lives OUTSIDE application state by design: diagnostics are
6
+ * host memory, never data. Records are the observer's bounded JSON
7
+ * metadata; payload capture stays whatever the app was created with
8
+ * (`capturePayloads`), so the log adds no leak surface of its own. A
9
+ * redaction hook lets a host scrub or drop records before they are
10
+ * retained at all.
11
+ */
12
+
13
+ /**
14
+ * @typedef {Object} TransactionLogOptions
15
+ * @property {number} [limit] - Maximum retained records (default 200);
16
+ * older records fall off the front.
17
+ * @property {(record: any) => any} [redact] - Applied to every record
18
+ * before retention; return the (possibly rewritten) record, or
19
+ * `null`/`undefined` to drop it entirely. Secrets and personal data
20
+ * are the host's responsibility — this is the hook to enforce it.
21
+ */
22
+
23
+ /**
24
+ * Create a bounded transaction log. Wire it up with
25
+ * `app.observe(log.observer)`; read it back with `log.entries()`;
26
+ * export it as versioned JSON with `log.export()`.
27
+ *
28
+ * @example
29
+ * const log = createTransactionLog({ limit: 100 });
30
+ * const stop = app.observe(log.observer);
31
+ * // ... later, in a support bundle:
32
+ * const dump = log.export(); // { version: 1, entries: [...] }
33
+ *
34
+ * @param {TransactionLogOptions} [options]
35
+ */
36
+ export function createTransactionLog(options = {}) {
37
+ const limit = options.limit ?? 200;
38
+ if (!Number.isInteger(limit) || limit < 1) {
39
+ throw new TypeError('createTransactionLog: "limit" must be a positive integer');
40
+ }
41
+ const redact = options.redact ?? null;
42
+ /** @type {any[]} */
43
+ let entries = [];
44
+
45
+ return {
46
+ /**
47
+ * The observer to register with `app.observe`.
48
+ * @param {any} record
49
+ */
50
+ observer(record) {
51
+ let entry = record;
52
+ if (redact !== null) {
53
+ entry = redact(record);
54
+ if (entry === null || entry === undefined) return;
55
+ }
56
+ entries.push(entry);
57
+ if (entries.length > limit) entries.splice(0, entries.length - limit);
58
+ },
59
+ /** The retained records, oldest first (a fresh array each call). */
60
+ entries() {
61
+ return entries.slice();
62
+ },
63
+ /** Drop every retained record. */
64
+ clear() {
65
+ entries = [];
66
+ },
67
+ /**
68
+ * A versioned export envelope for support bundles. The version
69
+ * covers the envelope shape; record fields follow the observer
70
+ * contract of the app that produced them.
71
+ * @returns {{ version: 1, entries: any[] }}
72
+ */
73
+ export() {
74
+ return { version: 1, entries: entries.slice() };
75
+ },
76
+ };
77
+ }
@@ -0,0 +1,71 @@
1
+ //@ts-check
2
+ /**
3
+ * @file A tiny persisted document store plus a share-link codec — the
4
+ * save / load / delete / list + share primitives an IDE-style surface (the
5
+ * studio, the play playground) needs, with the storage backend injected by
6
+ * the host so the package stays free of `localStorage`.
7
+ *
8
+ * `createDocStore` is a keyed CRUD over an injected `storage` ({ read, write }):
9
+ * the store is one JSON object `{ [key]: { name → value } }`, read once at
10
+ * creation and written back on every mutation. `encodeShare` / `decodeShare`
11
+ * turn a small snapshot into a Unicode-safe base64url token and back —
12
+ * forgivingly: a corrupt token decodes to `null`, never a throw.
13
+ */
14
+
15
+ /**
16
+ * @param {Object} opts
17
+ * @param {{ read: () => any, write: (store: any) => void }} opts.storage
18
+ * the host persistence adapter (localStorage in the browser, an in-memory
19
+ * object in tests)
20
+ * @param {string} [opts.key='experiments'] - the store's collection key
21
+ * @returns {{
22
+ * save: (name: string, value: any) => void,
23
+ * load: (name: string) => any,
24
+ * remove: (name: string) => void,
25
+ * names: () => string[],
26
+ * all: () => Record<string, any>,
27
+ * }}
28
+ */
29
+ export function createDocStore({ storage, key = 'experiments' }) {
30
+ // read once; keep the SAME object reference for every write-back
31
+ const store = storage.read() ?? {};
32
+ if (store[key] === undefined || store[key] === null) store[key] = {};
33
+ return {
34
+ save(name, value) { store[key][name] = value; storage.write(store); },
35
+ load(name) { return store[key][name]; },
36
+ remove(name) { delete store[key][name]; storage.write(store); },
37
+ names() { return Object.keys(store[key]).sort(); },
38
+ all() { return store[key]; },
39
+ };
40
+ }
41
+
42
+ /**
43
+ * Encode a snapshot as a base64url token: Unicode-safe (TextEncoder),
44
+ * portable between browser and Node.
45
+ * @param {any} snapshot
46
+ * @returns {string} the base64url token
47
+ */
48
+ export function encodeShare(snapshot) {
49
+ const bytes = new TextEncoder().encode(JSON.stringify(snapshot));
50
+ let binary = '';
51
+ for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]);
52
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
53
+ }
54
+
55
+ /**
56
+ * Decode a base64url token back to its snapshot, forgivingly.
57
+ * @param {string} token
58
+ * @returns {any} the snapshot object, or `null` when the token is unusable
59
+ */
60
+ export function decodeShare(token) {
61
+ try {
62
+ const binary = atob(token.replace(/-/g, '+').replace(/_/g, '/'));
63
+ const bytes = new Uint8Array(binary.length);
64
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
65
+ const snapshot = JSON.parse(new TextDecoder().decode(bytes));
66
+ return snapshot !== null && typeof snapshot === 'object' ? snapshot : null;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
package/src/errors.js ADDED
@@ -0,0 +1,245 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Error types for @jarenjs/app, built on `@jarenjs/core`'s coded
4
+ * contract: every failure carries a stable `code` (JA0xxx compile,
5
+ * JA2xxx runtime), a bare `reason`, a composed `message`, and — where
6
+ * one exists — the `docPath` of the offending member of the app
7
+ * document. The feedback shape a repair loop needs.
8
+ */
9
+
10
+ import { CodedError } from '@jarenjs/core/errors';
11
+
12
+ /**
13
+ * The runtime code table (the `CSV_CODES` shape): one entry per code
14
+ * this package can raise, proven in sync with APP-FORMAT.md's normative
15
+ * table by a test.
16
+ */
17
+ export const APP_CODES = Object.freeze({
18
+ JA0001: 'the app document is not an object',
19
+ JA0002: 'view is missing or failed to compile',
20
+ JA0003: 'actions is not an object of named documents',
21
+ JA0004: 'an action document failed to compile',
22
+ JA0005: 'subs is not an array of subscription entries',
23
+ JA0006: 'a subscription entry is malformed or its when failed to compile',
24
+ JA0007: 'the app failed to boot after compilation succeeded',
25
+ JA0008: 'a subscription dynamic member failed to compile or combines invalidly',
26
+ JA2001: 'an unknown action was dispatched',
27
+ JA2002: 'an action, when document or event-field extractor threw',
28
+ JA2003: 'an action produced a transition that is not an object',
29
+ JA2004: 'a transition patch failed to apply',
30
+ JA2005: 'the next state violated the app invariants',
31
+ JA2006: 'a transition named an effect with no registered handler',
32
+ JA2007: 'an effect handler threw',
33
+ JA2008: 'a subscription entry names no registered handler',
34
+ JA2009: 'a binding requested an unknown event field',
35
+ JA2010: 'the dispatch loop exceeded maxTurns transactions in one drain',
36
+ JA2011: 'a state listener or transaction observer threw',
37
+ JA2012: 'a cleanup threw while stopping, reconciling or destroying',
38
+ JA2013: 'a subscription handler threw while starting',
39
+ JA2014: 'a post-render intent named a data-ref with no rendered target',
40
+ JA2015: 'the validateState hook itself threw',
41
+ JA2016: 'a subscription dynamic query (withQuery, key or for) failed at runtime',
42
+ JA2017: 'a subscription fan-out exceeded maxSubInstances',
43
+ });
44
+
45
+ /**
46
+ * A defect in the app document itself, raised while `createApp` compiles
47
+ * it. Codes:
48
+ *
49
+ * - `JA0001` — the app document is not an object
50
+ * - `JA0002` — `view` is missing or not a stylesheet document
51
+ * - `JA0003` — `actions` is not an object of named documents
52
+ * - `JA0004` — an action document failed to compile (see `cause`)
53
+ * - `JA0005` — `subs` is not an array of subscription entries
54
+ * - `JA0006` — a subscription entry is malformed or its `when` failed
55
+ * to compile (see `cause`)
56
+ * - `JA0007` — the app failed to boot: the renderer construction, the
57
+ * initial-state check, the initial subscriptions, the first frame or
58
+ * the queued boot work failed after compilation succeeded; every
59
+ * already-acquired resource was rolled back (see `cause`)
60
+ * - `JA0008` — a subscription's dynamic member (`withQuery`, `key`,
61
+ * `for`) failed to compile (see `cause`), or the members combine
62
+ * invalidly (`with` beside `withQuery` or `for`; `key` without
63
+ * either)
64
+ */
65
+ export class AppCompileError extends CodedError {
66
+ /**
67
+ * @param {string} code
68
+ * @param {string} reason - The bare reason; `message` is composed as
69
+ * `${code}: ${reason} at ${docPath}` per the coded contract.
70
+ * @param {string} [docPath] - JSON Pointer into the app document;
71
+ * `''` is the document root, `undefined` means no location (never
72
+ * normalized to `''` — root and unknown are different facts).
73
+ * @param {Error} [cause]
74
+ */
75
+ constructor(code, reason, docPath, cause) {
76
+ super('AppCompileError', code, reason, docPath,
77
+ cause !== undefined ? { cause } : undefined);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * A failure while the app is running. Codes:
83
+ *
84
+ * - `JA2001` — an unknown action was dispatched
85
+ * - `JA2002` — an action or `when` document threw while evaluating,
86
+ * or a registered event-field extractor threw (see `cause`; the
87
+ * extractor's member binds `null` and the dispatch still runs)
88
+ * - `JA2003` — an action produced a transition that is not an object
89
+ * - `JA2004` — a transition's `patch` failed to apply (see `cause`)
90
+ * - `JA2005` — the next state violated the app's invariants
91
+ * (`validateState` rejected it); the transition was NOT applied
92
+ * - `JA2006` — a transition named an effect with no registered handler
93
+ * - `JA2007` — an effect handler threw (see `cause`)
94
+ * - `JA2008` — a subscription entry names no registered handler
95
+ * - `JA2009` — a binding requested an unknown event field (the member
96
+ * is bound `null`; the dispatch itself is NOT dropped)
97
+ * - `JA2010` — the dispatch loop exceeded `maxTurns` transactions in
98
+ * one drain (an accidental action/effect loop); the queue was
99
+ * abandoned
100
+ * - `JA2011` — a state listener or transaction observer threw
101
+ * (isolated; the queue drains on)
102
+ * - `JA2012` — a cleanup threw while stopping/reconciling/destroying
103
+ * (isolated; sibling cleanups still run)
104
+ * - `JA2013` — a subscription handler threw while starting; the slot
105
+ * stays stopped
106
+ * - `JA2014` — a post-render focus/measure intent named a `data-ref`
107
+ * with no rendered target
108
+ * - `JA2015` — the `validateState` hook itself threw (see `cause`) —
109
+ * distinct from a rejection verdict (`JA2005`); the transaction
110
+ * fails and the queue keeps draining
111
+ * - `JA2016` — a subscription's dynamic query (`withQuery`, `key` or
112
+ * `for`) threw while evaluating — a cyclic resolved value included —
113
+ * and the subscription failed closed (see `cause`; `docPath` names
114
+ * the member)
115
+ * - `JA2017` — a subscription fan-out resolved more instances than
116
+ * `maxSubInstances`; the previous instance set was kept
117
+ */
118
+ export class AppRuntimeError extends CodedError {
119
+ /**
120
+ * @param {string} code
121
+ * @param {string} reason - The bare reason; `message` is composed
122
+ * from `code`, `reason` and the location per the coded contract.
123
+ * @param {{ docPath?: string, cause?: Error }} [options] - `docPath`
124
+ * is a JSON Pointer into the app document where one exists
125
+ * (`undefined` when there is no location — never `''`, which means
126
+ * the document root); `cause` retains what host code threw.
127
+ */
128
+ constructor(code, reason, options) {
129
+ super('AppRuntimeError', code, reason, options?.docPath,
130
+ options !== undefined && options.cause !== undefined
131
+ ? { cause: options.cause }
132
+ : undefined);
133
+ /** Structured detail, e.g. validateState errors for JA2005. */
134
+ this.detail = undefined;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * A non-Error value thrown by host code, wrapped for the framework's
140
+ * error channels. JavaScript permits `throw null`, `throw undefined`,
141
+ * strings, numbers and arbitrary objects; host extension points
142
+ * (effects, subscriptions, validators, extractors, listeners,
143
+ * observers, widgets, sinks) may produce any of them, and the
144
+ * framework's isolation guarantees must hold for all of them.
145
+ *
146
+ * The original value is retained as an OWN `cause` property — set even
147
+ * when the value is `undefined`, so `Object.hasOwn(err, 'cause')`
148
+ * distinguishes "threw undefined" from "no cause" — and the message
149
+ * describes the value without invoking any user coercion (`toString`
150
+ * on a hostile object is never called).
151
+ */
152
+ export class HostValueError extends Error {
153
+ /** @param {unknown} value - The value host code threw. */
154
+ constructor(value) {
155
+ super(`host code threw a non-Error value (${describeThrown(value)})`);
156
+ this.name = 'HostValueError';
157
+ Object.defineProperty(this, 'cause', {
158
+ value, writable: true, enumerable: false, configurable: true,
159
+ });
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Describe a thrown non-Error value without observing it: only
165
+ * conversions no host code can trap are used — never a `toString`,
166
+ * never `Symbol.toPrimitive`, and never proxy-observable reflection
167
+ * (`Array.isArray` runs the proxy-sensitive IsArray operation, so a
168
+ * revoked proxy is simply "an object"). `typeof` and identity
169
+ * comparisons are untrappable, which is the whole vocabulary here.
170
+ * @param {unknown} value
171
+ * @returns {string}
172
+ */
173
+ function describeThrown(value) {
174
+ if (value === null) return 'null';
175
+ if (value === undefined) return 'undefined';
176
+ switch (typeof value) {
177
+ case 'string': {
178
+ const short = value.length > 80 ? value.slice(0, 80) + '…' : value;
179
+ return JSON.stringify(short);
180
+ }
181
+ case 'number':
182
+ case 'boolean':
183
+ case 'bigint':
184
+ return String(value);
185
+ case 'symbol':
186
+ return 'a symbol';
187
+ case 'function':
188
+ return 'a function';
189
+ default:
190
+ return 'an object';
191
+ }
192
+ }
193
+
194
+ /**
195
+ * `value instanceof Error` without trusting the value: `instanceof`
196
+ * walks the prototype chain, which a revoked or hostile proxy turns
197
+ * into a throw. A value whose very classification throws is treated as
198
+ * not-an-Error and wrapped.
199
+ * @param {unknown} value
200
+ * @returns {value is Error}
201
+ */
202
+ export function isErrorSafely(value) {
203
+ try {
204
+ return value instanceof Error;
205
+ }
206
+ catch {
207
+ return false;
208
+ }
209
+ }
210
+
211
+ /**
212
+ * Read an error's `message` without trusting it: JavaScript permits an
213
+ * own `message` accessor (or a proxy `get` trap) that throws, and the
214
+ * framework must never fail while formatting a failure. The original
215
+ * error object is never mutated — it stays the causal identity; this
216
+ * only projects a safe diagnostic string.
217
+ * @param {Error} error
218
+ * @returns {string}
219
+ */
220
+ export function safeErrorMessage(error) {
221
+ try {
222
+ const message = error.message;
223
+ if (typeof message === 'string') return message;
224
+ }
225
+ catch { /* a hostile accessor is a diagnostic, not a crash */ }
226
+ return 'host error (message unavailable)';
227
+ }
228
+
229
+ /**
230
+ * The one host-failure normalization policy (APP-FORMAT §10.1): every
231
+ * value caught at a host boundary passes through here, and the policy
232
+ * is TOTAL — no ECMAScript value, revoked proxies and throwing
233
+ * accessors included, can make it throw. An `Error` instance passes by
234
+ * IDENTITY — wherever a contract promises the original error as
235
+ * `cause`, that identity survives; anything else (a value whose
236
+ * classification itself throws included) is wrapped in a
237
+ * {@link HostValueError} that retains the original value as an own
238
+ * `cause` property. No caught value is ever assumed to have
239
+ * `.message`, and no thrown value is ever used as an absence sentinel.
240
+ * @param {unknown} value - Whatever host code threw.
241
+ * @returns {Error}
242
+ */
243
+ export function toError(value) {
244
+ return isErrorSafely(value) ? /** @type {Error} */ (value) : new HostValueError(value);
245
+ }
package/src/focus.js ADDED
@@ -0,0 +1,188 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The post-render focus/measurement queue (APP-FORMAT §8.4).
4
+ *
5
+ * Focus, text selection and measurement need a real DOM element at a
6
+ * moment when the frame is committed — but DOM nodes must never enter
7
+ * state. The bridge is a JSON intent naming a `data-ref` token that the
8
+ * view places as an ordinary attribute:
9
+ *
10
+ * view: ["input", { "data-ref": "search" }]
11
+ * action: { "effects": [{ "run": "focus", "with": { "ref": "search" } }] }
12
+ *
13
+ * Intents queue during the transaction and flush after the NEXT
14
+ * committed frame — after the DOM patch and after widget mounts, so a
15
+ * target inside freshly rendered markup is already connected. Only a
16
+ * state change schedules a frame: an intent queued by an effect-only
17
+ * action (no state transition) waits until the next state-changing
18
+ * transaction or a manual `render()` commits one — pair the intent
19
+ * with the transition that produces its target. A missing target is a
20
+ * diagnosable `JA2014`, never a silent no-op. Destroying the app (or
21
+ * `dispose()`) cancels pending intents; a headless app never flushes
22
+ * (there is no frame), which makes the queue a documented no-op there.
23
+ */
24
+
25
+ import { AppRuntimeError } from './errors.js';
26
+
27
+ /**
28
+ * @typedef {Object} FocusEffectOptions
29
+ * @property {any} container - The rendered root (the same element
30
+ * handed to `createApp` as `node`); intents resolve inside it.
31
+ * @property {(error: Error) => void} [onError] - Sink for `JA2014`
32
+ * missing-target diagnostics; default: the first one is thrown after
33
+ * the flush completes (siblings still run). The sink itself is
34
+ * isolated: a throwing sink never stops the flush — every sibling
35
+ * intent still resolves, and the first error the sink threw
36
+ * surfaces after the flush completes.
37
+ */
38
+
39
+ /**
40
+ * The effect handler returned by {@link createFocusEffect}, with its
41
+ * host-side controls.
42
+ * @typedef {((props: any, dispatch: (name: string, payload?: any) => void) => void) & {
43
+ * flush: () => void,
44
+ * dispose: () => void,
45
+ * }} FocusEffect
46
+ */
47
+
48
+ /**
49
+ * Create the post-render intent queue as a registered effect. Intent
50
+ * props (all JSON):
51
+ *
52
+ * - `ref` (REQUIRED, string) — the `data-ref` token to resolve;
53
+ * - `op` (OPTIONAL) — `"focus"` (default), `"select"`, or
54
+ * `"measure"`;
55
+ * - `done` (REQUIRED for `measure`) — the action dispatched with
56
+ * `{ id, ref, rect }` where `rect` is the JSON-reduced bounding
57
+ * rect;
58
+ * - `id` (OPTIONAL) — echoed in the `measure` completion payload.
59
+ *
60
+ * Wire `flush` as the app's `afterRender` so intents resolve exactly
61
+ * once per committed frame, ordered after widget mounts:
62
+ *
63
+ * @example
64
+ * const focus = createFocusEffect({ container: node });
65
+ * const app = createApp(doc, {
66
+ * node,
67
+ * effects: { focus },
68
+ * afterRender: focus.flush,
69
+ * });
70
+ * // app.destroy() disposes the queue through the handler's dispose()
71
+ *
72
+ * @param {FocusEffectOptions} options
73
+ * @returns {FocusEffect}
74
+ */
75
+ export function createFocusEffect(options) {
76
+ const container = options?.container;
77
+ if (container === null || container === undefined) {
78
+ throw new TypeError('createFocusEffect: an options.container element is required');
79
+ }
80
+ const onError = options.onError ?? null;
81
+
82
+ /** @type {Array<{ props: any, dispatch: (name: string, payload?: any) => void }>} */
83
+ let queue = [];
84
+ let disposed = false;
85
+
86
+ /** @type {any} */
87
+ const focusEffect = function focusEffect(props, dispatch) {
88
+ if (props === null || typeof props !== 'object' || typeof props.ref !== 'string') {
89
+ throw new TypeError('createFocusEffect: the effect props must carry a string "ref"');
90
+ }
91
+ const op = props.op ?? 'focus';
92
+ if (op !== 'focus' && op !== 'select' && op !== 'measure') {
93
+ throw new TypeError(`createFocusEffect: unknown op '${String(op)}'`);
94
+ }
95
+ if (op === 'measure' && typeof props.done !== 'string') {
96
+ throw new TypeError('createFocusEffect: a "measure" intent must carry a "done" action name');
97
+ }
98
+ if (disposed) return;
99
+ queue.push({ props, dispatch });
100
+ };
101
+
102
+ /**
103
+ * Resolve every queued intent against the committed frame. Missing
104
+ * targets report `JA2014`; every sibling intent still runs.
105
+ */
106
+ focusEffect.flush = function flush() {
107
+ if (disposed || queue.length === 0) return;
108
+ const batch = queue;
109
+ queue = [];
110
+ /** A presence record: a sink may legally throw `null`, which must
111
+ * still surface after the batch completes.
112
+ * @type {{ value: unknown } | null} */
113
+ let firstError = null;
114
+ for (const { props, dispatch } of batch) {
115
+ const target = findByRef(container, props.ref);
116
+ if (target === null) {
117
+ const err = new AppRuntimeError('JA2014',
118
+ `a post-render intent named data-ref '${props.ref}' but no rendered element carries it`);
119
+ if (onError !== null) {
120
+ // the sink is host code: isolate it like the app loop does,
121
+ // so one throwing report never starves the sibling intents
122
+ try {
123
+ onError(err);
124
+ }
125
+ catch (thrown) {
126
+ if (firstError === null) firstError = { value: thrown };
127
+ }
128
+ }
129
+ else if (firstError === null) firstError = { value: err };
130
+ continue;
131
+ }
132
+ const op = props.op ?? 'focus';
133
+ if (op === 'focus') {
134
+ if (typeof target.focus === 'function') target.focus();
135
+ }
136
+ else if (op === 'select') {
137
+ if (typeof target.select === 'function') target.select();
138
+ else if (typeof target.focus === 'function') target.focus();
139
+ }
140
+ else {
141
+ const rect = typeof target.getBoundingClientRect === 'function'
142
+ ? target.getBoundingClientRect()
143
+ : null;
144
+ dispatch(props.done, {
145
+ id: props.id ?? null,
146
+ ref: props.ref,
147
+ rect: rect === null ? null : {
148
+ x: rect.x, y: rect.y,
149
+ width: rect.width, height: rect.height,
150
+ top: rect.top, left: rect.left,
151
+ right: rect.right, bottom: rect.bottom,
152
+ },
153
+ });
154
+ }
155
+ }
156
+ if (firstError !== null) throw firstError.value;
157
+ };
158
+
159
+ /** Terminal: drop pending intents and ignore new ones. Idempotent. */
160
+ focusEffect.dispose = function dispose() {
161
+ disposed = true;
162
+ queue = [];
163
+ };
164
+
165
+ return focusEffect;
166
+ }
167
+
168
+ /**
169
+ * Depth-first search for the element whose `data-ref` attribute equals
170
+ * the token. Attribute-based on purpose: it works on any DOM the
171
+ * renderer can write to (real, stub, test), independent of
172
+ * `querySelector` and CSS escaping rules.
173
+ * @param {any} node
174
+ * @param {string} token
175
+ * @returns {any | null}
176
+ */
177
+ function findByRef(node, token) {
178
+ if (typeof node?.getAttribute === 'function' && node.getAttribute('data-ref') === token) {
179
+ return node;
180
+ }
181
+ const children = node?.childNodes;
182
+ if (children === undefined) return null;
183
+ for (let i = 0; i < children.length; i++) {
184
+ const found = findByRef(children[i], token);
185
+ if (found !== null) return found;
186
+ }
187
+ return null;
188
+ }