@reticlehq/browser 2.6.0 → 2.8.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.
package/README.md CHANGED
@@ -8,10 +8,10 @@ npm i -D @reticlehq/browser
8
8
 
9
9
  ```ts
10
10
  import { reticle } from '@reticlehq/browser';
11
- if (import.meta.env.DEV) reticle.connect({ session: 'my-app' });
11
+ if (import.meta.env.DEV) reticle.connect(); // session defaults to a fresh per-tab id
12
12
 
13
13
  // surface non-DOM events too:
14
14
  reticle.signal('webhook:received', { provider: 'stripe' });
15
15
  ```
16
16
 
17
- Pair with [`@reticlehq/server`](https://www.npmjs.com/package/@reticlehq/server) (the bridge + MCP server) and optionally [`@reticlehq/react`](https://www.npmjs.com/package/@reticlehq/react) for component/source mapping. See the [main README](https://github.com/reticlehq/reticle) for the full picture. Dev-only, localhost-only by default. MIT.
17
+ Pair with [`@reticlehq/server`](https://www.npmjs.com/package/@reticlehq/server) (the bridge + MCP server) and optionally [`@reticlehq/react`](https://www.npmjs.com/package/@reticlehq/react) for component/source mapping. See the [main README](https://github.com/reticlehq/reticle) for the full picture. Dev-only, localhost-only by default. Apache-2.0.
@@ -30,6 +30,10 @@ export declare function firePointerNonBubbling(el: Element, type: string, relate
30
30
  /**
31
31
  * Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
32
32
  * frame between phases so React commits state between steps (fixes stale-closure handlers).
33
+ *
34
+ * Dispatches along a PATH with real client coordinates and a held button. Without those a
35
+ * geometry-based library resolves the drop target as the source, so the action reports success over
36
+ * an app that did not change — a false green produced by the tool rather than caught by it.
33
37
  */
34
38
  export declare function dragElement(source: HTMLElement, target: HTMLElement | null, data: unknown): Promise<boolean>;
35
39
  export {};
@@ -109,28 +109,77 @@ function makeDataTransfer(data) {
109
109
  }
110
110
  return dt;
111
111
  }
112
+ /** `buttons` bitmask for the primary button: 1 while held, 0 once released. */
113
+ const BUTTON_HELD = 1;
114
+ const BUTTON_RELEASED = 0;
115
+ /**
116
+ * How many intermediate moves a drag emits.
117
+ *
118
+ * Enough to cross any realistic `activationConstraint: { distance: N }` while a sensor is watching,
119
+ * and few enough that a drag stays a handful of frames rather than an animation.
120
+ */
121
+ const DRAG_STEPS = 5;
122
+ /** The centre of an element in client coordinates — what a real pointer would be over. */
123
+ function centreOf(el) {
124
+ const box = el.getBoundingClientRect();
125
+ return { x: box.left + box.width / 2, y: box.top + box.height / 2 };
126
+ }
127
+ /** A point `t` of the way from `a` to `b`, rounded — pointer coordinates are integers in practice. */
128
+ function lerp(a, b, t) {
129
+ return { x: Math.round(a.x + (b.x - a.x) * t), y: Math.round(a.y + (b.y - a.y) * t) };
130
+ }
112
131
  /**
113
132
  * Pointer-based drag (dnd-kit / react-beautiful-dnd) + best-effort HTML5 DnD. Async: yields a
114
133
  * frame between phases so React commits state between steps (fixes stale-closure handlers).
134
+ *
135
+ * Dispatches along a PATH with real client coordinates and a held button. Without those a
136
+ * geometry-based library resolves the drop target as the source, so the action reports success over
137
+ * an app that did not change — a false green produced by the tool rather than caught by it.
115
138
  */
116
139
  export async function dragElement(source, target, data) {
117
140
  const dest = target ?? source;
118
- const fire = (el, type) => {
141
+ const from = centreOf(source);
142
+ const to = centreOf(dest);
143
+ /**
144
+ * Dispatch ONE pointer/mouse pair with real coordinates and button state.
145
+ *
146
+ * Everything here was missing before, and each omission breaks a different, standard library
147
+ * pattern: without coordinates a geometry-based collision resolver sees a zero delta and reports
148
+ * the source as its own drop target; without `buttons: 1` the usual "was the mouse released?"
149
+ * guard (`event.buttons === 0`) bails out mid-drag.
150
+ */
151
+ const fire = (el, type, at, buttons) => {
152
+ const init = {
153
+ bubbles: true,
154
+ cancelable: true,
155
+ clientX: at.x,
156
+ clientY: at.y,
157
+ screenX: at.x,
158
+ screenY: at.y,
159
+ buttons,
160
+ button: 0,
161
+ };
119
162
  if ('function' === typeof PointerEvent && type.startsWith('pointer')) {
120
- el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true }));
163
+ el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
121
164
  }
122
165
  else {
123
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true }));
166
+ el.dispatchEvent(new MouseEvent(type, init));
124
167
  }
125
168
  };
126
- fire(source, 'pointerdown');
127
- fire(source, 'mousedown');
169
+ fire(source, 'pointerdown', from, BUTTON_HELD);
170
+ fire(source, 'mousedown', from, BUTTON_HELD);
128
171
  await nativeFrame();
129
- fire(dest, 'pointermove');
130
- fire(dest, 'mousemove');
131
- await nativeFrame();
132
- fire(dest, 'pointerup');
133
- fire(dest, 'mouseup');
172
+ // A path, not a jump. `activationConstraint: { distance: N }` is the standard way to keep a
173
+ // draggable card clickable, and a sensor only starts a drag once it has SEEN the pointer travel
174
+ // that far — which a single move from A to B never shows it.
175
+ for (let step = 1; step <= DRAG_STEPS; step += 1) {
176
+ const at = lerp(from, to, step / DRAG_STEPS);
177
+ fire(dest, 'pointermove', at, BUTTON_HELD);
178
+ fire(dest, 'mousemove', at, BUTTON_HELD);
179
+ await nativeFrame();
180
+ }
181
+ fire(dest, 'pointerup', to, BUTTON_RELEASED);
182
+ fire(dest, 'mouseup', to, BUTTON_RELEASED);
134
183
  let dropPrevented = false;
135
184
  if ('function' === typeof DragEvent) {
136
185
  const dataTransfer = makeDataTransfer(data);
@@ -112,13 +112,112 @@ function dangerousActionContext(el) {
112
112
  function requiresDangerousConfirmation(text) {
113
113
  return isDangerousActionText(text);
114
114
  }
115
+ /**
116
+ * Which key a `press` is asking for.
117
+ *
118
+ * `text` FIRST, because that is what the tool description documents ("{ text } for type/press") and
119
+ * therefore what agents send. The implementation read only `args['key']` and defaulted to `'Enter'`,
120
+ * so the documented call — `press` with `{ text: 'Escape' }` — silently dispatched **Enter** and
121
+ * reported success. Two field reports blamed "synthetic events not reaching the app"; the events
122
+ * arrived perfectly and said the wrong thing.
123
+ *
124
+ * Enter is the worst possible substitute to pick by accident: on a focused field inside a form it
125
+ * SUBMITS it, so a request to dismiss a dialog could file the form behind it. `key` still works —
126
+ * it is what anyone reading the source would have sent — and the default only applies when neither
127
+ * was named.
128
+ */
129
+ function pressKey(args) {
130
+ const text = args['text'];
131
+ if ('string' === typeof text && text.length > 0)
132
+ return text;
133
+ return asString(args['key'], 'Enter');
134
+ }
135
+ /**
136
+ * The PHYSICAL key identity — `event.code` — derived from the logical key.
137
+ *
138
+ * A real browser always sends both, and a meaningful class of library reads only `code`, because it
139
+ * is the layout-independent one: dnd-kit's KeyboardSensor matches its activation and arrow keys on
140
+ * it, and so does react-aria. We sent `key` alone, so those handlers simply never matched — the
141
+ * event fired, the listener ran, the guard failed, and the action reported dispatched over an app
142
+ * that did nothing. Reported from the field as a keyboard drag that could be neither started nor
143
+ * steered.
144
+ *
145
+ * An explicit `code` always wins: a caller driving a non-US layout knows something this derivation
146
+ * cannot. And an unrecognised multi-character key yields `''` rather than a guess — a wrong `code`
147
+ * is worse than none, because a handler will act on it.
148
+ */
149
+ function pressCode(args, key) {
150
+ const explicit = args['code'];
151
+ if ('string' === typeof explicit && explicit.length > 0)
152
+ return explicit;
153
+ if (' ' === key)
154
+ return 'Space';
155
+ if (1 === key.length) {
156
+ if (/[a-z]/i.test(key))
157
+ return `Key${key.toUpperCase()}`;
158
+ if (/[0-9]/.test(key))
159
+ return `Digit${key}`;
160
+ return '';
161
+ }
162
+ // Named keys — 'Enter', 'Escape', 'Tab', 'ArrowDown' — already ARE their own code.
163
+ return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
164
+ }
165
+ /**
166
+ * Named keys whose `code` equals their `key`. An allow-list rather than a shape test: `Zzz` looks
167
+ * exactly like `Tab` to a regex, and inventing `code: "Zzz"` would be a confident fabrication.
168
+ */
169
+ const KNOWN_NAMED_KEYS = new Set([
170
+ 'Enter',
171
+ 'Escape',
172
+ 'Tab',
173
+ 'Backspace',
174
+ 'Delete',
175
+ 'Home',
176
+ 'End',
177
+ 'PageUp',
178
+ 'PageDown',
179
+ 'ArrowUp',
180
+ 'ArrowDown',
181
+ 'ArrowLeft',
182
+ 'ArrowRight',
183
+ 'Insert',
184
+ 'F1',
185
+ 'F2',
186
+ 'F3',
187
+ 'F4',
188
+ 'F5',
189
+ 'F6',
190
+ 'F7',
191
+ 'F8',
192
+ 'F9',
193
+ 'F10',
194
+ 'F11',
195
+ 'F12',
196
+ ]);
197
+ /**
198
+ * The drop target a `drag` names, under either spelling.
199
+ *
200
+ * `toRef` is what the source has always read and it appears NOWHERE in the tool description an
201
+ * agent sees — the `args` sentence names `value`, `text`, `native`, `holdMs` and `confirmDangerous`
202
+ * and omits the one argument without which this action cannot do its job. So agents guess, and
203
+ * `target` is the guess they make. Accepting it costs nothing and removes a whole class of
204
+ * silently-target-less drags; the description now documents `toRef` as well.
205
+ */
206
+ function dragTargetRef(args) {
207
+ const named = asString(args['toRef']);
208
+ return '' !== named ? named : asString(args['target']);
209
+ }
115
210
  function assertActionAllowed(el, action, args) {
116
211
  const canTrigger = action === ActionType.CLICK ||
117
212
  action === ActionType.DBLCLICK ||
118
213
  action === ActionType.DRAG ||
119
214
  action === ActionType.SUBMIT ||
120
- (action === ActionType.PRESS && 'Enter' === asString(args['key'], 'Enter'));
121
- const dragTarget = action === ActionType.DRAG ? refs.resolve(asString(args['toRef'])) : null;
215
+ // Read through the SAME resolver as the dispatch below. Reading a different argument here meant
216
+ // the destructive-action guard classified the call by a key nobody had asked for.
217
+ (action === ActionType.PRESS && 'Enter' === pressKey(args));
218
+ // Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
219
+ // by a target the dispatch will not use.
220
+ const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
122
221
  const context = isHtmlElement(dragTarget)
123
222
  ? `${dangerousActionContext(el)} ${dangerousActionContext(dragTarget)}`
124
223
  : dangerousActionContext(el);
@@ -304,13 +403,39 @@ async function dispatchOther(el, action, args) {
304
403
  }
305
404
  throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
306
405
  case ActionType.CHECK:
307
- case ActionType.UNCHECK:
308
- if (isInput(el)) {
309
- el.checked = action === ActionType.CHECK;
310
- el.dispatchEvent(new Event('change', { bubbles: true }));
406
+ case ActionType.UNCHECK: {
407
+ if (!isInput(el))
408
+ throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
409
+ // Same rule as the readonly/disabled refusal on fill: if a real user could not do it, forcing
410
+ // it is not a test, it is damage — and a green over it is a false one.
411
+ if (!enabledOf(el)) {
412
+ throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
413
+ }
414
+ const wanted = action === ActionType.CHECK;
415
+ // Already there: report success and touch nothing. `check` means "end up checked", not
416
+ // "toggle", so clicking here would flip it OFF and hand the app an event no user produced.
417
+ if (el.checked === wanted)
311
418
  return false;
419
+ // `click()`, never `el.checked = …`. Assigning the property flips the pixel and tells no
420
+ // framework: React binds a checkbox's onChange to the CLICK event, and its value tracker
421
+ // dedups the change it would otherwise synthesise from a direct assignment — so a controlled
422
+ // `checked={state}` box never heard from us while the action reported success. `click()` is
423
+ // the one DOM call that runs the element's ACTIVATION BEHAVIOUR (the native toggle) as well
424
+ // as firing the event; `dispatchEvent` does not.
425
+ let prevented = false;
426
+ // On window, so it runs after every listener on the element itself whenever they were added.
427
+ const probe = (e) => {
428
+ prevented = e.defaultPrevented;
429
+ };
430
+ window.addEventListener('click', probe, { once: true, capture: false });
431
+ try {
432
+ el.click();
433
+ }
434
+ finally {
435
+ window.removeEventListener('click', probe, false);
312
436
  }
313
- throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
437
+ return prevented;
438
+ }
314
439
  case ActionType.SUBMIT: {
315
440
  const form = isForm(el) ? el : el.closest('form');
316
441
  if (null === form)
@@ -319,9 +444,10 @@ async function dispatchOther(el, action, args) {
319
444
  return false; // requestSubmit returns void; the internal submit event is unobservable.
320
445
  }
321
446
  case ActionType.PRESS: {
322
- const key = asString(args['key'], 'Enter');
323
- const down = el.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }));
324
- el.dispatchEvent(new KeyboardEvent('keyup', { key, bubbles: true }));
447
+ const key = pressKey(args);
448
+ const code = pressCode(args, key);
449
+ const down = el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true }));
450
+ el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true }));
325
451
  return !down;
326
452
  }
327
453
  case ActionType.SCROLL_INTO_VIEW:
@@ -342,11 +468,21 @@ async function dispatchOther(el, action, args) {
342
468
  return !inputOk;
343
469
  }
344
470
  case ActionType.DRAG: {
345
- const toRef = asString(args['toRef']);
471
+ const toRef = dragTargetRef(args);
346
472
  // asString falls back to '' (never undefined), so the old `!== undefined` was always true and
347
- // handed '' to refs.resolve. A drag with no target ref is a free drag — resolve nothing.
348
- const resolved = toRef !== '' ? refs.resolve(toRef) : null;
349
- return await dragElement(el, isHtmlElement(resolved) ? resolved : null, args['data']);
473
+ // handed '' to refs.resolve. A drag with NO target named is a free drag — resolve nothing.
474
+ if ('' === toRef)
475
+ return await dragElement(el, null, args['data']);
476
+ // A target WAS named and did not resolve. That is a malformed call, not a free drag: dragging
477
+ // nowhere and reporting `ok: true` is a false green, and it is the one this action produced
478
+ // in the field — the drop target argument is undocumented, the agent guessed a name, the guess
479
+ // read as "no target", and the drag landed nowhere while every effect field looked healthy.
480
+ const resolved = refs.resolve(toRef);
481
+ if (!isHtmlElement(resolved)) {
482
+ throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
483
+ 'reticle_snapshot or reticle_query as args.toRef (alias: args.target)');
484
+ }
485
+ return await dragElement(el, resolved, args['data']);
350
486
  }
351
487
  default:
352
488
  throw new Error(`unknown action '${action}'`);
@@ -48,6 +48,11 @@ function queryFromArgs(args) {
48
48
  alt: str(args['alt']),
49
49
  component: str(args['component']),
50
50
  scope: str(args['scope']),
51
+ // The THIRD allowlist a query input has to appear in — tool schema, server forward, and here.
52
+ // `self` was in the first two and missing from this one, so a live call returned zero matches on
53
+ // an element that was plainly on the page, with no error to explain it. Same shape as the
54
+ // `attrs` drop below it.
55
+ self: true === args['self'] ? true : undefined,
51
56
  attrs: Array.isArray(args['attrs'])
52
57
  ? args['attrs'].filter((a) => 'string' === typeof a)
53
58
  : undefined,
@@ -86,6 +91,12 @@ function inspect(ref) {
86
91
  // stamped host. `act` already did this; inspect did not, so the two tools disagreed about the same
87
92
  // ref and inspect — the tool you reach for to ask where something lives — was the one saying null.
88
93
  const source = formatSource(sourceFor(el, component?.source));
94
+ const scroll = {
95
+ scrollTop: el.scrollTop,
96
+ scrollHeight: el.scrollHeight,
97
+ clientHeight: el.clientHeight,
98
+ overflowY: cs?.overflowY ?? 'visible',
99
+ };
89
100
  return {
90
101
  ...describe(el),
91
102
  ...(source !== undefined ? { source } : {}),
@@ -98,6 +109,7 @@ function inspect(ref) {
98
109
  // not this control (a z-index/overlay bug the DOM tree cannot show).
99
110
  occluded: isOccluded(el, rect),
100
111
  styles,
112
+ scroll,
101
113
  // Theme compliance vs the app's design tokens (off-theme colors a DOM tool can't judge).
102
114
  theme: cs !== null ? themeReport(cs) : null,
103
115
  component,
@@ -32,6 +32,18 @@ import { RETICLE_OVERLAY } from './dom-ignore.js';
32
32
  * later diff reports as matching. So the shells say so, and the tool reports the reason.
33
33
  */
34
34
  const FULL_PAGE_UNSUPPORTED_MARKER = VisualReason.FULL_PAGE_UNSUPPORTED;
35
+ /**
36
+ * Reasons a shell can name, in the order they are matched.
37
+ *
38
+ * The shell throws one of these rather than answering a bare null, because "nothing came back" was
39
+ * covering three different problems at once: a window with no composited frame yet, a webContents
40
+ * that had gone away, and any error at all. They need different responses from the reader, and the
41
+ * tool reported them identically.
42
+ */
43
+ const RECOGNISED_MARKERS = [
44
+ FULL_PAGE_UNSUPPORTED_MARKER,
45
+ VisualReason.NOT_COMPOSITED,
46
+ ];
35
47
  const TAURI_INTERNALS_GLOBAL = '__TAURI_INTERNALS__';
36
48
  /**
37
49
  * Tauri's capture path, used when no preload-installed channel exists.
@@ -106,8 +118,13 @@ export async function captureDesktopWindow(fullPage = false) {
106
118
  }
107
119
  catch (error) {
108
120
  const reason = error instanceof Error ? error.message : String(error);
109
- return reason.includes(FULL_PAGE_UNSUPPORTED_MARKER)
110
- ? { ok: false, reason: FULL_PAGE_UNSUPPORTED_MARKER }
111
- : { ok: false, reason };
121
+ // Recognised markers are reported as the reason ITSELF, so the tool can branch on a closed
122
+ // vocabulary rather than on prose. Everything else keeps its own message: dressing an unknown
123
+ // failure up as a known one would send the reader to the wrong place entirely.
124
+ for (const marker of RECOGNISED_MARKERS) {
125
+ if (reason.includes(marker))
126
+ return { ok: false, reason: marker };
127
+ }
128
+ return { ok: false, reason };
112
129
  }
113
130
  }
package/dist/dom/query.js CHANGED
@@ -259,6 +259,16 @@ function findCandidates(query) {
259
259
  // scopeMissing flag is what keeps "gone scope" distinct from "absent element".
260
260
  if (null === container)
261
261
  return { candidates: [], scopeMissing: true };
262
+ // `self: true` asks for the container ITSELF, which every other path excludes by construction.
263
+ // A layout element with no role, name, testid or own text is unreachable by any semantic locator,
264
+ // and it is routinely the element carrying the handler — "click the empty space in this row" is a
265
+ // real user action that was simply not expressible. Requires a scope: without one there is no root
266
+ // to return, and answering `document.body` would be a wrong answer wearing the shape of one.
267
+ if (true === query.self) {
268
+ if (query.scope === undefined)
269
+ return { candidates: [], scopeMissing };
270
+ return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
271
+ }
262
272
  const seen = new Set();
263
273
  const out = [];
264
274
  const collect = (els) => {
@@ -31,8 +31,36 @@ export declare const MAX_TRACKED_REFS = 10000;
31
31
  * the message.
32
32
  */
33
33
  export declare function echoRef(ref: string): string;
34
+ /**
35
+ * How many numbers a document claims at once.
36
+ *
37
+ * The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
38
+ * (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
39
+ * mints is free.
40
+ *
41
+ * Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
42
+ * how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
43
+ * paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
44
+ * message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
45
+ *
46
+ * Measured on one unchanged three-element page across a session: 61 tokens on the first document,
47
+ * 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
48
+ * same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
49
+ *
50
+ * So the block is small. A document claims another one when it outmints this, which costs one more
51
+ * storage write and is exactly proportional: a page with thousands of elements has a long snapshot
52
+ * anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
53
+ * three-digit refs across dozens of navigations.
54
+ */
55
+ export declare const REF_BLOCK = 100;
34
56
  export declare class RefRegistry {
35
57
  #private;
58
+ /**
59
+ * Storage is injected rather than read from the global for the same reason the clock is: it is an
60
+ * ambient dependency that decides behaviour, and the cross-document property is untestable if the
61
+ * only way to get a second document is to actually navigate.
62
+ */
63
+ constructor(storage?: () => Storage | undefined);
36
64
  /** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
37
65
  get size(): number;
38
66
  /** Get the existing ref for an element, or mint a new one. */
package/dist/dom/refs.js CHANGED
@@ -44,11 +44,96 @@ export function echoRef(ref) {
44
44
  * over-cap mint, and the expensive dead-entry sweep runs once per this many mints (amortized O(1)).
45
45
  */
46
46
  const SWEEP_EVERY_MINTS = 1000;
47
+ /**
48
+ * Where a document records the ref numbers it has claimed, so the next one starts past them.
49
+ *
50
+ * Refs are per-DOCUMENT: a reload or a cross-page link tears this module down and the next document
51
+ * used to start minting from `e1` again. So `e7` from page A was a valid, resolvable, DIFFERENT
52
+ * element on page B — nothing refused, the wrong element was acted on, and the result came back `ok`.
53
+ * The SPA case was always safe (WeakRef liveness + isConnected); this was the other half, and the
54
+ * more dangerous one, because it produced a false green instead of an error.
55
+ *
56
+ * Deliberately NOT a change to the ref grammar. Stamping refs `e7@3` would have worked too, but every
57
+ * agent passes refs back verbatim and every saved flow on disk already holds bare ones, so it buys a
58
+ * wire-format migration for a property the sequence itself can carry. A number that never restarts
59
+ * means a stale ref simply misses the map, and the refusal that already exists — already worded for
60
+ * exactly this case — fires unchanged.
61
+ *
62
+ * Namespaced like the session key it sits beside; `sessionStorage` is the right scope for the same
63
+ * reason session continuity uses it (survives reloads and same-tab navigations, not shared with
64
+ * another tab, which is a different session with its own registry anyway).
65
+ */
66
+ const REF_BASE_KEY = '__reticle_ref_base';
67
+ /**
68
+ * How many numbers a document claims at once.
69
+ *
70
+ * The reservation is a synchronous storage write, so it cannot happen per mint — mints are frequent
71
+ * (every meaningful DOM addition, every transitionend, every scroll reveal). One write per this many
72
+ * mints is free.
73
+ *
74
+ * Sized to keep refs SHORT, and the first attempt at that reasoned about the wrong axis. It bounded
75
+ * how many navigations it took to reach seven digits and concluded "a hundred" — but the cost is not
76
+ * paid at seven digits, it is paid at every digit, on every ref, in every snapshot and every error
77
+ * message, on every turn. At 10,000 the SECOND document already mints five-digit refs.
78
+ *
79
+ * Measured on one unchanged three-element page across a session: 61 tokens on the first document,
80
+ * 64 after a reload, 65 later — +6.5% for the same page, entirely in ref width. The benchmark saw the
81
+ * same thing as a ~4% rise in average observation cost, which is what a benchmark is for.
82
+ *
83
+ * So the block is small. A document claims another one when it outmints this, which costs one more
84
+ * storage write and is exactly proportional: a page with thousands of elements has a long snapshot
85
+ * anyway, and a small page — the common case, and the one an agent drives repeatedly — keeps
86
+ * three-digit refs across dozens of navigations.
87
+ */
88
+ export const REF_BLOCK = 100;
89
+ /** The reserved-block high-water mark, or 0 when storage is unavailable or its value is junk. */
90
+ function readBase(store) {
91
+ try {
92
+ const raw = store?.getItem(REF_BASE_KEY);
93
+ const value = null === raw || undefined === raw ? Number.NaN : Number(raw);
94
+ return Number.isSafeInteger(value) && value > 0 ? value : 0;
95
+ }
96
+ catch {
97
+ return 0; // a sandboxed iframe throws on access; a session must still start
98
+ }
99
+ }
47
100
  export class RefRegistry {
48
101
  #toRef = new WeakMap();
49
102
  #fromRef = new Map();
103
+ #storage;
50
104
  #seq = 0;
105
+ /** The end of the block currently reserved. Minting past it claims the next one. */
106
+ #reserved = 0;
51
107
  #mintsSinceSweep = 0;
108
+ /**
109
+ * Storage is injected rather than read from the global for the same reason the clock is: it is an
110
+ * ambient dependency that decides behaviour, and the cross-document property is untestable if the
111
+ * only way to get a second document is to actually navigate.
112
+ */
113
+ constructor(storage = () => globalThis.sessionStorage) {
114
+ this.#storage = storage;
115
+ }
116
+ /** Read the tab's high-water mark and claim the next block, BEFORE any of it is handed out. */
117
+ #reserve() {
118
+ let store;
119
+ try {
120
+ store = this.#storage();
121
+ }
122
+ catch {
123
+ store = undefined; // hostile storage: no cross-document guarantee, but the session still starts
124
+ }
125
+ const base = Math.max(readBase(store), this.#seq);
126
+ this.#reserved = base + REF_BLOCK;
127
+ try {
128
+ store?.setItem(REF_BASE_KEY, String(this.#reserved));
129
+ }
130
+ catch {
131
+ /* same: never block a mint on storage */
132
+ }
133
+ // Claimed on the FIRST mint, not at unload — a page that crashes, is killed, or navigates away
134
+ // without firing unload has still permanently spent its numbers.
135
+ this.#seq = base;
136
+ }
52
137
  /** How many reverse entries are currently retained. Exposed so the bound can be asserted. */
53
138
  get size() {
54
139
  return this.#fromRef.size;
@@ -85,6 +170,8 @@ export class RefRegistry {
85
170
  this.#fromRef.set(existing, weak ?? new WeakRef(el));
86
171
  return existing;
87
172
  }
173
+ if (this.#seq >= this.#reserved)
174
+ this.#reserve();
88
175
  this.#seq += 1;
89
176
  const ref = asRef(`e${String(this.#seq)}`);
90
177
  this.#toRef.set(el, ref);
@@ -1,25 +1,48 @@
1
1
  /**
2
2
  * Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
3
3
  * tool sees a color rendered; it does not know the app's INTENDED palette. Reticle reads the design
4
- * tokens the app declares as `:root` CSS custom properties and reports, per element, whether its
5
- * color/background maps to a token or is an off-palette hardcoded value. That is a theme-compliance
6
- * signal no outside-the-page tool has without re-deriving the palette itself.
4
+ * tokens the app declares as CSS custom properties, resolves them AS THE PAGE CURRENTLY RENDERS
5
+ * THEM, and reports, per element, which tokens its color/background map to or whether the value is
6
+ * an off-palette hardcoded one. That is a theme-compliance signal no outside-the-page tool has
7
+ * without re-deriving the palette itself.
7
8
  *
8
9
  * Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
9
- * the matched token name is returned when there is one — so the agent can judge, not just trust a flag.
10
+ * every matching token name is returned — so the agent can judge, not just trust a flag.
11
+ *
12
+ * Two defects made this field untrustworthy in BOTH directions before it was rewritten:
13
+ * - the reverse map was color → ONE name, first writer wins, so a button styled with a brand token
14
+ * was told its background was a focus-border token because the two resolve to the same hex. The
15
+ * name is the part an agent reasons about, and it was arbitrary among the candidates.
16
+ * - the palette was built from the RAW `:root` declaration text and cached for the page lifetime,
17
+ * on the reasoning that a theme rarely changes at runtime. A human toggling the theme between
18
+ * two inspect calls left every lookup keyed on the previous theme's colors, so a `<summary>` that
19
+ * had just been changed to a text token reported a surface token. The reporter read that as their
20
+ * edit not applying and went hunting for a theme-scope bug that did not exist.
21
+ * Hence: no cache, values resolved through `getComputedStyle` so the active theme wins, and the
22
+ * scope that was active at capture time reported alongside, so two results minutes apart are
23
+ * comparable at all.
10
24
  */
11
25
  interface ThemeReport {
12
- /** Matched design-token name for the text color, or null when the color is off-palette. */
26
+ /**
27
+ * The ONE design token matching the text color, or null. Null both when nothing matches and when
28
+ * several tokens share the color: the previous behaviour picked an arbitrary winner and the caller
29
+ * could not tell, which is the whole defect. Read `colorTokens` for the full answer; this field
30
+ * stays for callers that only ever wanted the unambiguous case.
31
+ */
13
32
  colorToken: string | null;
14
- /** Matched design-token name for the background color, or null. */
33
+ /** Every design token whose resolved value is the text color. Empty when the color is off-palette. */
34
+ colorTokens: string[];
35
+ /** The one design token matching the background color, or null (same abstain-on-tie rule). */
15
36
  backgroundToken: string | null;
16
- /** True when a set, opaque color uses no design token — a theme violation worth surfacing. */
37
+ /** Every design token whose resolved value is the background color. */
38
+ backgroundTokens: string[];
39
+ /** True when a set, opaque color matches NO design token — a theme violation worth surfacing. */
17
40
  offTheme: boolean;
18
- /** How many color tokens the app declared (0 ⇒ no palette found, so offTheme is never asserted). */
41
+ /** How many color tokens resolved under the active theme (0 ⇒ offTheme is never asserted). */
19
42
  tokenCount: number;
43
+ /** Theme scope active at capture time, e.g. `.dark`, so two results taken apart are comparable. */
44
+ themeScope: string | null;
20
45
  }
21
46
  /** Report an element's theme compliance from its computed style. */
22
47
  export declare function themeReport(cs: CSSStyleDeclaration): ThemeReport;
23
- /** Drop the cached palette (call if the app swaps themes at runtime). */
24
- export declare function resetThemeCache(): void;
25
48
  export {};
package/dist/dom/theme.js CHANGED
@@ -1,33 +1,32 @@
1
1
  /**
2
2
  * Design-token (theme) awareness — the in-source answer to "is this color on-theme?". A DOM/a11y
3
3
  * tool sees a color rendered; it does not know the app's INTENDED palette. Reticle reads the design
4
- * tokens the app declares as `:root` CSS custom properties and reports, per element, whether its
5
- * color/background maps to a token or is an off-palette hardcoded value. That is a theme-compliance
6
- * signal no outside-the-page tool has without re-deriving the palette itself.
4
+ * tokens the app declares as CSS custom properties, resolves them AS THE PAGE CURRENTLY RENDERS
5
+ * THEM, and reports, per element, which tokens its color/background map to or whether the value is
6
+ * an off-palette hardcoded one. That is a theme-compliance signal no outside-the-page tool has
7
+ * without re-deriving the palette itself.
7
8
  *
8
9
  * Conservative by design: only a SET, opaque color with no matching token is flagged off-theme, and
9
- * the matched token name is returned when there is one — so the agent can judge, not just trust a flag.
10
+ * every matching token name is returned — so the agent can judge, not just trust a flag.
11
+ *
12
+ * Two defects made this field untrustworthy in BOTH directions before it was rewritten:
13
+ * - the reverse map was color → ONE name, first writer wins, so a button styled with a brand token
14
+ * was told its background was a focus-border token because the two resolve to the same hex. The
15
+ * name is the part an agent reasons about, and it was arbitrary among the candidates.
16
+ * - the palette was built from the RAW `:root` declaration text and cached for the page lifetime,
17
+ * on the reasoning that a theme rarely changes at runtime. A human toggling the theme between
18
+ * two inspect calls left every lookup keyed on the previous theme's colors, so a `<summary>` that
19
+ * had just been changed to a text token reported a surface token. The reporter read that as their
20
+ * edit not applying and went hunting for a theme-scope bug that did not exist.
21
+ * Hence: no cache, values resolved through `getComputedStyle` so the active theme wins, and the
22
+ * scope that was active at capture time reported alongside, so two results minutes apart are
23
+ * comparable at all.
10
24
  */
11
- let cached = null;
12
- /** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
13
- function toRgb(value) {
14
- if (0 === value.length)
15
- return null;
16
- const probe = document.createElement('span');
17
- probe.style.color = '';
18
- probe.style.color = value; // invalid colors leave it empty (the setter rejects them)
19
- if ('' === probe.style.color)
20
- return null;
21
- probe.style.position = 'absolute';
22
- probe.style.pointerEvents = 'none';
23
- document.body.appendChild(probe);
24
- const rgb = getComputedStyle(probe).color;
25
- probe.remove();
26
- return rgb;
27
- }
28
- /** Collect `--token: value` declarations from every:root/html rule across the app's stylesheets. */
29
- function collectTokens() {
30
- const out = {};
25
+ /** Attribute an app flips to switch themes, reported as part of the scope so results are comparable. */
26
+ const THEME_ATTRIBUTE = 'data-theme';
27
+ /** Collect every custom-property NAME the app declares, in any rule. */
28
+ function tokenNames() {
29
+ const names = new Set();
31
30
  for (const sheet of Array.from(document.styleSheets)) {
32
31
  let rules = null;
33
32
  try {
@@ -41,49 +40,109 @@ function collectTokens() {
41
40
  for (const rule of Array.from(rules)) {
42
41
  if (!(rule instanceof CSSStyleRule))
43
42
  continue;
44
- if (!/(^|,)\s*(:root|html)\b/.test(rule.selectorText))
45
- continue;
43
+ // Deliberately NOT filtered to `:root`/`html`: a theme block is usually `.dark` or
44
+ // `[data-theme="dark"]`, and an app that declares its palette only there had no palette at all
45
+ // under the old filter. Names are cheap; a name that is not in scope resolves to nothing below
46
+ // and is dropped, so widening collection cannot invent a token.
46
47
  for (const prop of Array.from(rule.style)) {
47
48
  if (prop.startsWith('--'))
48
- out[prop] = rule.style.getPropertyValue(prop).trim();
49
+ names.add(prop);
49
50
  }
50
51
  }
51
52
  }
52
- return out;
53
+ return names;
53
54
  }
54
- /** The app's palette (color tokens → rgb), built once and cached (theme rarely changes at runtime). */
55
+ /**
56
+ * The palette as the page renders it right now: canonical `rgb(...)` → every token with that color.
57
+ *
58
+ * Resolved against `document.body` rather than the raw declaration text, because custom properties
59
+ * inherit: this picks up a theme scoped to `<html>` or to `<body>`, and picks up the value the
60
+ * ACTIVE theme block won with rather than whichever block the stylesheet happened to list first.
61
+ */
55
62
  function palette() {
56
- if (cached !== null)
57
- return cached;
58
63
  const byColor = new Map();
59
- for (const [name, value] of Object.entries(collectTokens())) {
60
- const rgb = toRgb(value);
61
- if (rgb !== null && !byColor.has(rgb))
62
- byColor.set(rgb, name);
64
+ const root = document.body ?? document.documentElement;
65
+ if (null === root)
66
+ return byColor;
67
+ const rootStyle = getComputedStyle(root);
68
+ // One shared probe for the whole build. There is no cache any more, so this runs per inspect, and
69
+ // a span per token would be a few hundred appends on a real design system.
70
+ const probe = document.createElement('span');
71
+ probe.style.position = 'absolute';
72
+ probe.style.pointerEvents = 'none';
73
+ document.body.appendChild(probe);
74
+ try {
75
+ for (const name of tokenNames()) {
76
+ const rgb = toRgb(probe, rootStyle.getPropertyValue(name).trim());
77
+ if (null === rgb)
78
+ continue;
79
+ const named = byColor.get(rgb);
80
+ if (named === undefined)
81
+ byColor.set(rgb, [name]);
82
+ else
83
+ named.push(name);
84
+ }
85
+ }
86
+ finally {
87
+ probe.remove();
63
88
  }
64
- cached = { byColor };
65
- return cached;
89
+ // Sorted so the reported order is a property of the app's tokens, not of stylesheet iteration
90
+ // order — the same collision must read the same way on two consecutive inspects.
91
+ for (const named of byColor.values())
92
+ named.sort();
93
+ return byColor;
94
+ }
95
+ /** Resolve any CSS color string to canonical computed `rgb...)`/`rgba...)`, or null if not a color. */
96
+ function toRgb(probe, value) {
97
+ if (0 === value.length)
98
+ return null;
99
+ probe.style.color = '';
100
+ probe.style.color = value; // invalid colors leave it empty (the setter rejects them)
101
+ if ('' === probe.style.color)
102
+ return null;
103
+ return getComputedStyle(probe).color;
66
104
  }
67
105
  /** True for a color that carries no visual weight (fully transparent) — never flagged off-theme. */
68
106
  function isTransparent(rgb) {
69
107
  return 'rgba(0, 0, 0, 0)' === rgb || 'transparent' === rgb;
70
108
  }
109
+ /**
110
+ * The theme scope in force at capture time, as a selector-shaped string (`.dark[data-theme="dark"]`),
111
+ * or null when the root carries no theme marker at all.
112
+ */
113
+ function themeScope() {
114
+ const el = document.documentElement;
115
+ if (null === el)
116
+ return null;
117
+ const classes = Array.from(el.classList)
118
+ .map((c) => `.${c}`)
119
+ .join('');
120
+ const attr = el.getAttribute(THEME_ATTRIBUTE);
121
+ const scope = `${classes}${null === attr ? '' : `[${THEME_ATTRIBUTE}="${attr}"]`}`;
122
+ return 0 === scope.length ? null : scope;
123
+ }
124
+ /** The unambiguous match, or null when the palette cannot tell the candidates apart. */
125
+ function sole(names) {
126
+ return 1 === names.length ? (names[0] ?? null) : null;
127
+ }
71
128
  /** Report an element's theme compliance from its computed style. */
72
129
  export function themeReport(cs) {
73
- const p = palette();
74
- const colorToken = p.byColor.get(cs.color) ?? null;
75
- const backgroundToken = p.byColor.get(cs.backgroundColor) ?? null;
76
- const colorOff = !isTransparent(cs.color) && null === colorToken;
77
- const bgOff = !isTransparent(cs.backgroundColor) && null === backgroundToken;
130
+ const byColor = palette();
131
+ const colorTokens = byColor.get(cs.color) ?? [];
132
+ const backgroundTokens = byColor.get(cs.backgroundColor) ?? [];
133
+ const colorOff = !isTransparent(cs.color) && 0 === colorTokens.length;
134
+ const bgOff = !isTransparent(cs.backgroundColor) && 0 === backgroundTokens.length;
135
+ let tokenCount = 0;
136
+ for (const named of byColor.values())
137
+ tokenCount += named.length;
78
138
  return {
79
- colorToken,
80
- backgroundToken,
139
+ colorToken: sole(colorTokens),
140
+ colorTokens,
141
+ backgroundToken: sole(backgroundTokens),
142
+ backgroundTokens,
81
143
  // Only meaningful when a palette exists; an app with no tokens can't violate one.
82
- offTheme: p.byColor.size > 0 && (colorOff || bgOff),
83
- tokenCount: p.byColor.size,
144
+ offTheme: tokenCount > 0 && (colorOff || bgOff),
145
+ tokenCount,
146
+ themeScope: themeScope(),
84
147
  };
85
148
  }
86
- /** Drop the cached palette (call if the app swaps themes at runtime). */
87
- export function resetThemeCache() {
88
- cached = null;
89
- }
@@ -1,5 +1,6 @@
1
1
  import { EventType, TRANSPORT_LIMITS } from '@reticlehq/core';
2
2
  import { safeStringify } from '../security/serialization.js';
3
+ import { requireCapturedMethod } from '../util/captured-method.js';
3
4
  const METHOD_EVENT = {
4
5
  log: EventType.CONSOLE_LOG,
5
6
  warn: EventType.CONSOLE_WARN,
@@ -41,8 +42,10 @@ export function installConsole(emit) {
41
42
  const originals = new Map();
42
43
  const patched = new Map();
43
44
  for (const method of methods) {
44
- // Store the true original for teardown identity; call through a bound copy.
45
- const original = console[method];
45
+ // Store the true original for teardown identity; call through a bound copy. Read as a stored
46
+ // VALUE rather than a method reference — see capturedMethod for why that distinction is the
47
+ // honest way to say "I am detaching this deliberately and putting it back".
48
+ const original = requireCapturedMethod(console, method);
46
49
  originals.set(method, original);
47
50
  const callOriginal = original.bind(console);
48
51
  const wrapper = (...args) => {
@@ -3,6 +3,7 @@ import { captureMethod } from '../patching/capture-method.js';
3
3
  import { isCapturableType, projectBody, withBodyDeadline } from './network-body.js';
4
4
  import { redactUrl } from './network-redact.js';
5
5
  import { watchStreamedBody } from './network-stream.js';
6
+ import { requireCapturedMethod } from '../util/captured-method.js';
6
7
  // Redaction moved to its own cohesive module (network.ts is at its line cap); re-exported so callers
7
8
  // and the existing test suite keep importing it from here.
8
9
  export { redactUrl };
@@ -195,7 +196,7 @@ export function installNetwork(emit, opts = {}) {
195
196
  const reinterpret = opts.reinterpret;
196
197
  // Keep the true original for teardown identity, plus a window-bound copy to invoke
197
198
  // (fetch throws "Illegal invocation" if called with the wrong `this`).
198
- const origFetch = window.fetch;
199
+ const origFetch = requireCapturedMethod(window, 'fetch');
199
200
  const callFetch = origFetch.bind(window);
200
201
  // Declare it if we are not the first to wrap fetch.
201
202
  //
@@ -320,8 +321,9 @@ export function installNetwork(emit, opts = {}) {
320
321
  }
321
322
  };
322
323
  // Remember it, so a later install recognises our own wrapper instead of reporting the app.
323
- OURS.add(window.fetch);
324
- const patchedFetch = window.fetch;
324
+ // Our own wrapper, read the same way — it is a value we stored a line ago, not a method call.
325
+ const patchedFetch = requireCapturedMethod(window, 'fetch');
326
+ OURS.add(patchedFetch);
325
327
  const meta = new WeakMap();
326
328
  const proto = XMLHttpRequest.prototype;
327
329
  const origOpen = captureMethod(proto, 'open');
@@ -449,6 +451,10 @@ export function installNetwork(emit, opts = {}) {
449
451
  });
450
452
  });
451
453
  }
454
+ // `override` is required, not decorative: this shadows WebSocket.prototype.send, and without
455
+ // the keyword a rename or signature drift in the base class would silently turn this from an
456
+ // override into a NEW method — the observer would stop intercepting and report no WebSocket
457
+ // traffic at all, which reads as "the app sends none". Caught by noImplicitOverride.
452
458
  send(data) {
453
459
  if (this.#isBridge) {
454
460
  super.send(data);
@@ -6,8 +6,8 @@
6
6
  */
7
7
  /** Structural emitter the host app's components depend on. */
8
8
  export interface ReticleEmitter {
9
- signal(name: string, data?: Record<string, unknown>): void;
10
- state(name: string, value: unknown): void;
9
+ signal: (name: string, data?: Record<string, unknown>) => void;
10
+ state: (name: string, value: unknown) => void;
11
11
  }
12
12
  /** The subset of the reticle singleton an emitter proxies to (lets tests inject a fake). */
13
13
  export interface EmitterTarget {
package/dist/reticle.js CHANGED
@@ -2,6 +2,7 @@ import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_
2
2
  import { rememberSessionLabel } from './session-continuity.js';
3
3
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
4
4
  import { Transport } from './transport/transport.js';
5
+ import { unreachableMessage } from './transport/unreachable-message.js';
5
6
  import { adapterNames } from './registry/adapters.js';
6
7
  import { registerCapabilities, setCapabilitiesListener, hasCapabilities, } from './registry/capabilities.js';
7
8
  import { installAllObservers } from './observers/install-all.js';
@@ -160,7 +161,7 @@ export class Reticle {
160
161
  // Dev-only backstop: refuse to activate in a production build (SSR healthcheck, prod bundle opened
161
162
  // on localhost). `process` may not exist in a raw browser, so read NODE_ENV off globalThis.
162
163
  const proc = globalThis.process;
163
- const nodeEnv = proc?.env?.NODE_ENV;
164
+ const nodeEnv = proc?.env?.['NODE_ENV'];
164
165
  if (shouldBlockProduction(nodeEnv, true === options.allowInProduction)) {
165
166
  globalThis.console.warn('[Reticle] disabled in production (NODE_ENV=production). Gate the import behind ' +
166
167
  'import.meta.env.DEV, or pass allowInProduction:true to override.');
@@ -228,13 +229,10 @@ export class Reticle {
228
229
  this.#presenter.setState(SessionState.ENDED, BRIDGE_LOST_SUMMARY);
229
230
  }
230
231
  },
231
- // First-connect never succeeded ⇒ the bridge is unreachable at this URL. Tell the developer
232
- // exactly what went wrong and how to fix it, instead of retrying silently forever.
232
+ // First-connect never succeeded ⇒ this page's socket is not opening at this URL. Say what the
233
+ // page observed rather than guessing at the daemon, which it cannot see from in here.
233
234
  onUnreachable: ({ url: tried, attempts }) => {
234
- nativeWarn(`[Reticle] could not reach the bridge at ${tried} after ${String(attempts)} attempts. ` +
235
- `Is the Reticle daemon running on that port? If your app runs in a container/devcontainer/WSL, ` +
236
- `the daemon is on a different host — set the WS URL explicitly (Vite: VITE_RETICLE_WS_URL, ` +
237
- `or reticle.connect({ url })). Still retrying…`);
235
+ nativeWarn(unreachableMessage(tried, attempts));
238
236
  },
239
237
  });
240
238
  // Capabilities registered AFTER connect (which is when they are registered, by design) must
@@ -2,6 +2,7 @@
2
2
  // can deterministically advance time (toasts, debounces, auto-dismiss, commit-on-blur).
3
3
  // We do NOT patch requestAnimationFrame/microtasks/MessageChannel — React's scheduler relies
4
4
  // on those, and freezing them would stall the page. Opt-in + reversible.
5
+ import { requireCapturedMethod } from '../util/captured-method.js';
5
6
  let installed = false;
6
7
  let virtualNow = 0;
7
8
  let realBase = 0;
@@ -17,12 +18,14 @@ export function freezeClock() {
17
18
  installed = true;
18
19
  virtualNow = 0;
19
20
  realBase = Date.now();
21
+ // Captured as stored VALUES, so teardown restores the same function objects the page had. See
22
+ // capturedMethod: these are deliberate unbound captures and the descriptor read says so.
20
23
  originals = {
21
- setTimeout: window.setTimeout,
22
- clearTimeout: window.clearTimeout,
23
- setInterval: window.setInterval,
24
- clearInterval: window.clearInterval,
25
- dateNow: Date.now,
24
+ setTimeout: requireCapturedMethod(window, 'setTimeout'),
25
+ clearTimeout: requireCapturedMethod(window, 'clearTimeout'),
26
+ setInterval: requireCapturedMethod(window, 'setInterval'),
27
+ clearInterval: requireCapturedMethod(window, 'clearInterval'),
28
+ dateNow: requireCapturedMethod(Date, 'now'),
26
29
  };
27
30
  const schedule = (cb, delay, interval) => {
28
31
  const id = seq;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * What the page says when its websocket never opened.
3
+ *
4
+ * Pulled out of the connect path so it is a pure string function with a test on its CLAIMS rather
5
+ * than a template inlined in a warn call. The rule it exists to hold: report what this page observed
6
+ * and what it could not determine, never a cause. See unreachable-message.test.ts.
7
+ */
8
+ /**
9
+ * The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
10
+ * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
+ */
12
+ export declare function unreachableMessage(url: string, attempts: number): string;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * What the page says when its websocket never opened.
3
+ *
4
+ * Pulled out of the connect path so it is a pure string function with a test on its CLAIMS rather
5
+ * than a template inlined in a warn call. The rule it exists to hold: report what this page observed
6
+ * and what it could not determine, never a cause. See unreachable-message.test.ts.
7
+ */
8
+ /**
9
+ * The page tried, failed, and cannot see why. Names the address and the attempt count, refuses to
10
+ * diagnose the daemon, and lists the checks in the order that actually resolves the ambiguity.
11
+ */
12
+ export function unreachableMessage(url, attempts) {
13
+ return (`[Reticle] this page could not open a websocket to ${url}. ${String(attempts)} attempts, all ` +
14
+ `failed. That is everything the page can see: from inside the browser it cannot tell a daemon ` +
15
+ `that is not there from one it is not allowed to reach, so this is not evidence about the ` +
16
+ `daemon. What answers it: run \`npx @reticlehq/server status\` to see whether one is listening ` +
17
+ `and on which port. If your app runs in a container, devcontainer or WSL, the daemon is on a ` +
18
+ `different host, so set the URL explicitly (Vite: VITE_RETICLE_WS_URL, or ` +
19
+ `reticle.connect({ url })). An https page cannot open a ws:// socket at all. Still retrying…`);
20
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Read a host object's method as a STORED VALUE, for code that has to put it back.
3
+ *
4
+ * Every observer here patches a global and must be able to undo it: `console.error`, `window.fetch`,
5
+ * `window.setTimeout`, `Date.now`. Undoing means holding the true original — the same function
6
+ * object, so teardown restores exactly what was there and a later install can recognise it by
7
+ * identity — and calling through a separately bound copy, because a host method invoked with the
8
+ * wrong receiver throws (`fetch` gives "Illegal invocation").
9
+ *
10
+ * Written as `const original = window.fetch`, that is a bare unbound method reference, and
11
+ * `@typescript-eslint/unbound-method` is right to flag the SHAPE even though this use is deliberate:
12
+ * the rule cannot see that the next line binds it, nor that the raw value only ever goes back into
13
+ * the slot it came from.
14
+ *
15
+ * The two obvious ways out are both wrong here. An `eslint-disable` is against this repo's standing
16
+ * rule and hides the shape from every future reader. Annotating `this: void` asserts the function
17
+ * does not use its receiver — which for a console method or `fetch` is simply false, and is the very
18
+ * reason the binding exists.
19
+ *
20
+ * So the intent is expressed instead of suppressed. A property descriptor's `value` is a stored
21
+ * value, not a method being detached from its object, which is exactly the distinction the rule is
22
+ * drawing and exactly what this code wants. It is also strictly more accurate for the restore case:
23
+ * it reads what the slot HOLDS rather than what calling the property would do, so an accessor
24
+ * defined on the object cannot be silently flattened into its result.
25
+ *
26
+ * Returns undefined when the property is absent or inherited rather than own — a caller that needs a
27
+ * fallback to the prototype chain should say so at its own call site rather than have it hidden here.
28
+ */
29
+ export declare function capturedMethod<T>(target: object, key: PropertyKey): T | undefined;
30
+ /**
31
+ * The same, for a property this code knows is present — the globals every observer patches.
32
+ *
33
+ * Throws rather than returning a broken teardown. A missing `window.fetch` is not a condition any
34
+ * caller here can recover from, and silently installing a patch that cannot be undone is worse than
35
+ * refusing: the page keeps the wrapper for the rest of its life with nothing able to remove it.
36
+ */
37
+ export declare function requireCapturedMethod<T>(target: object, key: PropertyKey): T;
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Read a host object's method as a STORED VALUE, for code that has to put it back.
3
+ *
4
+ * Every observer here patches a global and must be able to undo it: `console.error`, `window.fetch`,
5
+ * `window.setTimeout`, `Date.now`. Undoing means holding the true original — the same function
6
+ * object, so teardown restores exactly what was there and a later install can recognise it by
7
+ * identity — and calling through a separately bound copy, because a host method invoked with the
8
+ * wrong receiver throws (`fetch` gives "Illegal invocation").
9
+ *
10
+ * Written as `const original = window.fetch`, that is a bare unbound method reference, and
11
+ * `@typescript-eslint/unbound-method` is right to flag the SHAPE even though this use is deliberate:
12
+ * the rule cannot see that the next line binds it, nor that the raw value only ever goes back into
13
+ * the slot it came from.
14
+ *
15
+ * The two obvious ways out are both wrong here. An `eslint-disable` is against this repo's standing
16
+ * rule and hides the shape from every future reader. Annotating `this: void` asserts the function
17
+ * does not use its receiver — which for a console method or `fetch` is simply false, and is the very
18
+ * reason the binding exists.
19
+ *
20
+ * So the intent is expressed instead of suppressed. A property descriptor's `value` is a stored
21
+ * value, not a method being detached from its object, which is exactly the distinction the rule is
22
+ * drawing and exactly what this code wants. It is also strictly more accurate for the restore case:
23
+ * it reads what the slot HOLDS rather than what calling the property would do, so an accessor
24
+ * defined on the object cannot be silently flattened into its result.
25
+ *
26
+ * Returns undefined when the property is absent or inherited rather than own — a caller that needs a
27
+ * fallback to the prototype chain should say so at its own call site rather than have it hidden here.
28
+ */
29
+ export function capturedMethod(target, key) {
30
+ return Object.getOwnPropertyDescriptor(target, key)?.value;
31
+ }
32
+ /**
33
+ * The same, for a property this code knows is present — the globals every observer patches.
34
+ *
35
+ * Throws rather than returning a broken teardown. A missing `window.fetch` is not a condition any
36
+ * caller here can recover from, and silently installing a patch that cannot be undone is worse than
37
+ * refusing: the page keeps the wrapper for the rest of its life with nothing able to remove it.
38
+ */
39
+ export function requireCapturedMethod(target, key) {
40
+ const value = capturedMethod(target, key);
41
+ if (value === undefined) {
42
+ throw new Error(`[Reticle] cannot patch ${String(key)}: it is not an own property of the target`);
43
+ }
44
+ return value;
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reticlehq/browser",
3
- "version": "2.6.0",
3
+ "version": "2.8.0",
4
4
  "description": "Reticle browser SDK — installs observers, builds semantic snapshots, executes actions, talks to the bridge.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -14,13 +14,13 @@
14
14
  },
15
15
  "dependencies": {
16
16
  "@testing-library/dom": "^10.4.1",
17
- "@reticlehq/core": "2.6.0"
17
+ "@reticlehq/core": "2.8.0"
18
18
  },
19
19
  "devDependencies": {
20
20
  "@tanstack/query-core": "^5.101.4",
21
- "@types/jsdom": "^28.0.3",
21
+ "@types/jsdom": "^30.0.0",
22
22
  "jotai": "^2.20.2",
23
- "jsdom": "^29.1.1",
23
+ "jsdom": "^30.0.1",
24
24
  "mobx": "^7.0.0",
25
25
  "pinia": "^4.0.2",
26
26
  "react": "^19.2.8",