@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 +2 -2
- package/dist/actions/actions-dom.d.ts +4 -0
- package/dist/actions/actions-dom.js +59 -10
- package/dist/actions/actions.js +150 -14
- package/dist/commands/commands.js +12 -0
- package/dist/dom/desktop-capture.js +20 -3
- package/dist/dom/query.js +10 -0
- package/dist/dom/refs.d.ts +28 -0
- package/dist/dom/refs.js +87 -0
- package/dist/dom/theme.d.ts +33 -10
- package/dist/dom/theme.js +109 -50
- package/dist/observers/console.js +5 -2
- package/dist/observers/network.js +9 -3
- package/dist/registry/emitter.d.ts +2 -2
- package/dist/reticle.js +5 -7
- package/dist/timers/clock.js +8 -5
- package/dist/transport/unreachable-message.d.ts +12 -0
- package/dist/transport/unreachable-message.js +20 -0
- package/dist/util/captured-method.d.ts +37 -0
- package/dist/util/captured-method.js +45 -0
- package/package.json +4 -4
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(
|
|
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.
|
|
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
|
|
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, {
|
|
163
|
+
el.dispatchEvent(new PointerEvent(type, { ...init, pointerId: 1, isPrimary: true }));
|
|
121
164
|
}
|
|
122
165
|
else {
|
|
123
|
-
el.dispatchEvent(new MouseEvent(type,
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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);
|
package/dist/actions/actions.js
CHANGED
|
@@ -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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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 =
|
|
323
|
-
const
|
|
324
|
-
el.dispatchEvent(new KeyboardEvent('
|
|
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 =
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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) => {
|
package/dist/dom/refs.d.ts
CHANGED
|
@@ -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);
|
package/dist/dom/theme.d.ts
CHANGED
|
@@ -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
|
|
5
|
-
* color/background
|
|
6
|
-
* signal no outside-the-page tool has
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
5
|
-
* color/background
|
|
6
|
-
* signal no outside-the-page tool has
|
|
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
|
-
*
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
45
|
-
|
|
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
|
-
|
|
49
|
+
names.add(prop);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
}
|
|
52
|
-
return
|
|
53
|
+
return names;
|
|
53
54
|
}
|
|
54
|
-
/**
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
const
|
|
76
|
-
const colorOff = !isTransparent(cs.color) &&
|
|
77
|
-
const bgOff = !isTransparent(cs.backgroundColor) &&
|
|
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
|
-
|
|
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:
|
|
83
|
-
tokenCount
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
324
|
-
const patchedFetch = window
|
|
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>)
|
|
10
|
-
state(name: string, value: unknown)
|
|
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 ⇒
|
|
232
|
-
//
|
|
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(
|
|
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
|
package/dist/timers/clock.js
CHANGED
|
@@ -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
|
|
22
|
-
clearTimeout: window
|
|
23
|
-
setInterval: window
|
|
24
|
-
clearInterval: window
|
|
25
|
-
dateNow: Date
|
|
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.
|
|
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.
|
|
17
|
+
"@reticlehq/core": "2.8.0"
|
|
18
18
|
},
|
|
19
19
|
"devDependencies": {
|
|
20
20
|
"@tanstack/query-core": "^5.101.4",
|
|
21
|
-
"@types/jsdom": "^
|
|
21
|
+
"@types/jsdom": "^30.0.0",
|
|
22
22
|
"jotai": "^2.20.2",
|
|
23
|
-
"jsdom": "^
|
|
23
|
+
"jsdom": "^30.0.1",
|
|
24
24
|
"mobx": "^7.0.0",
|
|
25
25
|
"pinia": "^4.0.2",
|
|
26
26
|
"react": "^19.2.8",
|