@reticlehq/browser 2.4.0 → 2.6.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.
Files changed (75) hide show
  1. package/dist/actions/actions-dom.d.ts +8 -1
  2. package/dist/actions/actions-dom.js +26 -12
  3. package/dist/actions/actions.d.ts +46 -0
  4. package/dist/actions/actions.js +114 -26
  5. package/dist/actions/appeared-text.d.ts +27 -0
  6. package/dist/actions/appeared-text.js +92 -0
  7. package/dist/actions/scroll.js +1 -1
  8. package/dist/commands/commands.js +45 -22
  9. package/dist/connect-options.d.ts +6 -0
  10. package/dist/dom/a11y.js +20 -20
  11. package/dist/dom/auto-anchor.js +1 -1
  12. package/dist/dom/blind-spots.js +1 -1
  13. package/dist/dom/chart.js +5 -5
  14. package/dist/dom/desktop-capture.d.ts +2 -1
  15. package/dist/dom/desktop-capture.js +4 -4
  16. package/dist/dom/occlusion.js +2 -2
  17. package/dist/dom/query.js +68 -25
  18. package/dist/dom/realm.js +13 -13
  19. package/dist/dom/refs.d.ts +14 -0
  20. package/dist/dom/refs.js +20 -1
  21. package/dist/dom/shadow-registry.js +2 -2
  22. package/dist/dom/snapshot.js +32 -11
  23. package/dist/dom/source.js +3 -3
  24. package/dist/dom/theme.js +6 -6
  25. package/dist/dom/virtualized.js +1 -1
  26. package/dist/observers/blind-spots.js +2 -2
  27. package/dist/observers/console.js +3 -3
  28. package/dist/observers/dom.js +5 -5
  29. package/dist/observers/download.d.ts +2 -1
  30. package/dist/observers/download.js +2 -2
  31. package/dist/observers/focus.js +1 -1
  32. package/dist/observers/frames.js +1 -1
  33. package/dist/observers/health.js +70 -2
  34. package/dist/observers/install-all.d.ts +2 -1
  35. package/dist/observers/ipc.d.ts +2 -7
  36. package/dist/observers/ipc.js +7 -7
  37. package/dist/observers/network-body.js +2 -2
  38. package/dist/observers/network-redact.js +5 -5
  39. package/dist/observers/network-stream.js +2 -2
  40. package/dist/observers/network.d.ts +3 -3
  41. package/dist/observers/network.js +9 -9
  42. package/dist/observers/perf.js +1 -1
  43. package/dist/observers/scroll.js +1 -1
  44. package/dist/observers/state.js +1 -1
  45. package/dist/observers/storage.js +7 -7
  46. package/dist/presenter/presenter-brand.d.ts +19 -0
  47. package/dist/presenter/presenter-brand.js +21 -0
  48. package/dist/presenter/presenter-config.d.ts +17 -0
  49. package/dist/presenter/presenter-config.js +21 -0
  50. package/dist/presenter/presenter-controls.d.ts +1 -11
  51. package/dist/presenter/presenter-controls.js +6 -7
  52. package/dist/presenter/presenter-log.js +2 -2
  53. package/dist/presenter/presenter-run-state.js +1 -1
  54. package/dist/presenter/presenter-styles.d.ts +1 -1
  55. package/dist/presenter/presenter-styles.js +20 -9
  56. package/dist/presenter/presenter-tally.js +3 -3
  57. package/dist/presenter/presenter-test-helpers.d.ts +2 -1
  58. package/dist/presenter/presenter-test-helpers.js +2 -2
  59. package/dist/presenter/presenter-verbs.js +16 -11
  60. package/dist/presenter/presenter.js +13 -10
  61. package/dist/recorder/recorder.js +10 -9
  62. package/dist/registry/store-adapters.js +1 -1
  63. package/dist/registry/stores.js +3 -3
  64. package/dist/reticle-presenter-helpers.js +3 -3
  65. package/dist/reticle.js +31 -13
  66. package/dist/review/annotator.js +7 -7
  67. package/dist/security/serialization.js +9 -9
  68. package/dist/session-continuity.d.ts +17 -0
  69. package/dist/session-continuity.js +42 -0
  70. package/dist/timers/clock.js +28 -15
  71. package/dist/timers/native-console.js +1 -1
  72. package/dist/timers/native-timers.js +4 -4
  73. package/dist/transport/transport.d.ts +23 -0
  74. package/dist/transport/transport.js +85 -25
  75. package/package.json +6 -4
@@ -5,7 +5,7 @@ import { executeAction, executeSequence, dispatchWebMcp, } from '../actions/acti
5
5
  import { describe } from '../dom/a11y.js';
6
6
  import { sourceFor, formatSource } from '../dom/source.js';
7
7
  import { themeReport } from '../dom/theme.js';
8
- import { refs } from '../dom/refs.js';
8
+ import { echoRef, refs } from '../dom/refs.js';
9
9
  import { isButton, isInput } from '../dom/realm.js';
10
10
  import { hitTestOccluder } from '../dom/occlusion.js';
11
11
  import { readStorage } from '../observers/storage.js';
@@ -19,16 +19,16 @@ import { scrollContainer } from '../actions/scroll.js';
19
19
  /** Query param appended on a hard reload to bypass the browser cache. */
20
20
  export const RELOAD_CACHE_BUST_PARAM = '_reticle_reload';
21
21
  function str(value) {
22
- return typeof value === 'string' ? value : undefined;
22
+ return 'string' === typeof value ? value : undefined;
23
23
  }
24
24
  function num(value) {
25
- return typeof value === 'number' ? value : undefined;
25
+ return 'number' === typeof value ? value : undefined;
26
26
  }
27
27
  function record(value) {
28
- return typeof value === 'object' && value !== null ? value : {};
28
+ return 'object' === typeof value && value !== null ? value : {};
29
29
  }
30
30
  function sourceLocation(value) {
31
- if (typeof value !== 'object' || value === null)
31
+ if (typeof value !== 'object' || null === value)
32
32
  return undefined;
33
33
  const obj = value;
34
34
  if (typeof obj['file'] !== 'string' || typeof obj['line'] !== 'number')
@@ -49,15 +49,20 @@ function queryFromArgs(args) {
49
49
  component: str(args['component']),
50
50
  scope: str(args['scope']),
51
51
  attrs: Array.isArray(args['attrs'])
52
- ? args['attrs'].filter((a) => typeof a === 'string')
52
+ ? args['attrs'].filter((a) => 'string' === typeof a)
53
53
  : undefined,
54
54
  source: sourceLocation(args['source']),
55
55
  };
56
56
  }
57
57
  function inspect(ref) {
58
58
  const el = refs.resolve(ref);
59
- if (el === null)
60
- return { error: `ref '${ref}' no longer resolves` };
59
+ // THROW, exactly as executeAction does. Returning an error payload made it a SUCCESSFUL command
60
+ // result, which the server then handed to reticle_inspect's outputSchema (ref/role/name/states/
61
+ // visible all required) — so the MCP layer answered -32602 Output validation error for the most
62
+ // ordinary thing that follows a click. The wording matches actions.ts because the server's
63
+ // recovery table keys off /no longer resolves to an element/ to attach the stale-ref recovery.
64
+ if (null === el)
65
+ throw new Error(`ref '${echoRef(ref)}' no longer resolves to an element`);
61
66
  const rect = el.getBoundingClientRect();
62
67
  const component = identifyComponent(el);
63
68
  const view = el.ownerDocument.defaultView;
@@ -104,7 +109,7 @@ function isOccluded(el, rect) {
104
109
  }
105
110
  /** Narrowing guard: an adapter returned a ComponentStateResult (has a boolean `ok`). */
106
111
  function isComponentStateResult(value) {
107
- return (typeof value === 'object' && value !== null && 'ok' in value && typeof value.ok === 'boolean');
112
+ return ('object' === typeof value && value !== null && 'ok' in value && 'boolean' === typeof value.ok);
108
113
  }
109
114
  const COMPONENT_UNAVAILABLE = {
110
115
  ok: false,
@@ -158,7 +163,7 @@ function readState(ref, store, path, depth) {
158
163
  result.truncation = truncation;
159
164
  if (ref !== undefined && ref.length > 0) {
160
165
  const el = refs.resolve(ref);
161
- if (el === null) {
166
+ if (null === el) {
162
167
  result.component = COMPONENT_UNAVAILABLE;
163
168
  }
164
169
  else {
@@ -168,6 +173,19 @@ function readState(ref, store, path, depth) {
168
173
  }
169
174
  return result;
170
175
  }
176
+ /**
177
+ * Every animation, and WHICH element it drives.
178
+ *
179
+ * Reported from the field: a page with nine concurrent animations returned nine indistinguishable
180
+ * `{playState, currentTime, duration}` rows. `a.effect` was read only for `getTiming()`, never for
181
+ * its `target` — so the tool advertised "targets/timing" and shipped timing alone. A list you cannot
182
+ * index is not a list, and the question this tool exists to answer is "is THAT thing still
183
+ * animating".
184
+ *
185
+ * `describe()` is reused rather than inventing a target shape: it is what `reticle_query` returns,
186
+ * so the descriptor carries a `ref` the agent can pass straight to `reticle_act` — a target you
187
+ * cannot act on would be half an answer.
188
+ */
171
189
  function listAnimations() {
172
190
  const doc = document;
173
191
  if (typeof doc.getAnimations !== 'function')
@@ -175,20 +193,25 @@ function listAnimations() {
175
193
  const animations = doc.getAnimations().map((a) => {
176
194
  const effect = a.effect;
177
195
  const timing = effect?.getTiming();
196
+ // A KeyframeEffect's target is legitimately nullable, and only KeyframeEffect has one at all.
197
+ // `null` is reported explicitly: omitting the key would make "this animation has no element"
198
+ // indistinguishable from the bug above, which is the distinction the fix exists to draw.
199
+ const target = effect?.target ?? null;
178
200
  return {
179
201
  playState: a.playState,
180
202
  currentTime: a.currentTime,
181
203
  duration: timing?.duration,
204
+ target: null === target ? null : describe(target),
182
205
  };
183
206
  });
184
207
  return { animations };
185
208
  }
186
209
  export function resolveNavigationUrl(rawUrl, baseUrl) {
187
- if (rawUrl.length === 0 || rawUrl.length > TRANSPORT_LIMITS.MAX_URL_LENGTH)
210
+ if (0 === rawUrl.length || rawUrl.length > TRANSPORT_LIMITS.MAX_URL_LENGTH)
188
211
  return null;
189
212
  try {
190
213
  const url = new URL(rawUrl, baseUrl);
191
- return url.protocol === 'http:' || url.protocol === 'https:' ? url.toString() : null;
214
+ return 'http:' === url.protocol || 'https:' === url.protocol ? url.toString() : null;
192
215
  }
193
216
  catch {
194
217
  return null;
@@ -203,14 +226,14 @@ export function createCommandRegistry() {
203
226
  }));
204
227
  reg.set(ReticleCommand.QUERY, (args) => {
205
228
  const limit = args['limit'];
206
- return runQuery(queryFromArgs(args), typeof limit === 'number' ? limit : undefined);
229
+ return runQuery(queryFromArgs(args), 'number' === typeof limit ? limit : undefined);
207
230
  });
208
231
  reg.set(ReticleCommand.MATCH, (args) => matchQuery(ElementQuerySchema.parse(record(args['query'])), str(args['state'])));
209
232
  reg.set(ReticleCommand.ACT, (args) => {
210
233
  const action = str(args['action']) ?? '';
211
234
  if (action === ActionType.WEBMCP) {
212
235
  const inner = record(args['args']);
213
- return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params']), inner[DANGEROUS_ACTION_CONFIRM_ARG] === true);
236
+ return dispatchWebMcp(str(inner['tool']) ?? '', record(inner['params']), true === inner[DANGEROUS_ACTION_CONFIRM_ARG]);
214
237
  }
215
238
  return executeAction(str(args['ref']) ?? '', action, record(args['args']));
216
239
  });
@@ -218,14 +241,14 @@ export function createCommandRegistry() {
218
241
  reg.set(ReticleCommand.INSPECT, (args) => inspect(str(args['ref']) ?? ''));
219
242
  reg.set(ReticleCommand.ANIMATIONS, () => listAnimations());
220
243
  reg.set(ReticleCommand.CLOCK, (args) => {
221
- if (args['reset'] === true) {
244
+ if (true === args['reset']) {
222
245
  resetClock();
223
246
  }
224
247
  else {
225
- if (args['freeze'] === true)
248
+ if (true === args['freeze'])
226
249
  freezeClock();
227
250
  const adv = args['advanceMs'];
228
- if (typeof adv === 'number')
251
+ if ('number' === typeof adv)
229
252
  advanceClock(adv);
230
253
  }
231
254
  return { frozen: isClockFrozen() };
@@ -233,24 +256,24 @@ export function createCommandRegistry() {
233
256
  reg.set(ReticleCommand.STATE_READ, (args) => readState(str(args['ref']), str(args['store']), str(args['path']), num(args['depth'])));
234
257
  reg.set(ReticleCommand.STORAGE_READ, (args) => readStorage(str(args['area'])));
235
258
  reg.set(ReticleCommand.CAPABILITIES, () => getCapabilities());
236
- reg.set(ReticleCommand.CAPTURE, (args) => captureDesktopWindow(args['fullPage'] === true));
259
+ reg.set(ReticleCommand.CAPTURE, (args) => captureDesktopWindow(true === args['fullPage']));
237
260
  reg.set(ReticleCommand.SCROLL, (args) => {
238
261
  const dy = args['dy'];
239
262
  const fraction = args['fraction'];
240
- return scrollContainer(str(args['ref']), typeof dy === 'number' ? dy : undefined, typeof fraction === 'number' ? fraction : undefined);
263
+ return scrollContainer(str(args['ref']), 'number' === typeof dy ? dy : undefined, 'number' === typeof fraction ? fraction : undefined);
241
264
  });
242
265
  reg.set(ReticleCommand.NAVIGATE, (args) => {
243
266
  const rawUrl = str(args['url']);
244
- if (rawUrl === undefined || rawUrl.length === 0)
267
+ if (rawUrl === undefined || 0 === rawUrl.length)
245
268
  return { ok: false, reason: 'url required' };
246
269
  const url = resolveNavigationUrl(rawUrl, window.location.href);
247
- if (url === null)
270
+ if (null === url)
248
271
  return { ok: false, reason: 'only http(s) navigation is allowed' };
249
272
  window.location.assign(url);
250
273
  return { ok: true, url };
251
274
  });
252
275
  reg.set(ReticleCommand.REFRESH, (args) => {
253
- if (args['hard'] === true) {
276
+ if (true === args['hard']) {
254
277
  // Hard reload: navigate to self with a cache-busting param then replace history.
255
278
  const url = new URL(window.location.href);
256
279
  url.searchParams.set(RELOAD_CACHE_BUST_PARAM, String(Date.now()));
@@ -45,6 +45,12 @@ export interface ReticleConnectOptions {
45
45
  * and the TypeScript cast that needed shipped into a `.jsx` file and broke the build.
46
46
  */
47
47
  root?: string;
48
+ /**
49
+ * The SDK's own package version, so a version-skewed pair against the daemon can name itself
50
+ * rather than surfacing as a bare `-32000`. Build plugins supply it automatically; pass it here
51
+ * only for a hand-wired connect that has no plugin.
52
+ */
53
+ sdkVersion?: string;
48
54
  /** Presenter mode: glow border, animated cursor, click/hover effects, narration HUD. */
49
55
  present?: boolean;
50
56
  /** Per-action pacing (ms) in presenter mode so a human can follow. Default 450. */
package/dist/dom/a11y.js CHANGED
@@ -45,15 +45,15 @@ function inputRole(input) {
45
45
  const type = input.type.toLowerCase();
46
46
  if (INPUT_TEXT_TYPES.has(type))
47
47
  return 'textbox';
48
- if (type === 'checkbox')
48
+ if ('checkbox' === type)
49
49
  return 'checkbox';
50
- if (type === 'radio')
50
+ if ('radio' === type)
51
51
  return 'radio';
52
- if (type === 'range')
52
+ if ('range' === type)
53
53
  return 'slider';
54
- if (type === 'number')
54
+ if ('number' === type)
55
55
  return 'spinbutton';
56
- if (type === 'submit' || type === 'button' || type === 'reset')
56
+ if ('submit' === type || 'button' === type || 'reset' === type)
57
57
  return 'button';
58
58
  return 'textbox';
59
59
  }
@@ -115,7 +115,7 @@ function collapse(text) {
115
115
  }
116
116
  function labelledByText(el) {
117
117
  const ids = el.getAttribute('aria-labelledby');
118
- if (ids === null)
118
+ if (null === ids)
119
119
  return null;
120
120
  const parts = [];
121
121
  for (const id of ids.split(/\s+/)) {
@@ -144,7 +144,7 @@ function textWithoutHidden(node) {
144
144
  if (node.nodeType !== Node.ELEMENT_NODE)
145
145
  return '';
146
146
  const el = node;
147
- if (el.getAttribute('aria-hidden') === 'true')
147
+ if ('true' === el.getAttribute('aria-hidden'))
148
148
  return '';
149
149
  let out = '';
150
150
  for (const child of el.childNodes)
@@ -191,9 +191,9 @@ export function getAccessibleName(el) {
191
191
  }
192
192
  function ariaBool(el, attr) {
193
193
  const value = el.getAttribute(attr);
194
- if (value === null)
194
+ if (null === value)
195
195
  return undefined;
196
- return value === 'true';
196
+ return 'true' === value;
197
197
  }
198
198
  /**
199
199
  * The set of states relevant to assertions. `visible` is an O(depth) forced-style walk; callers that
@@ -203,12 +203,12 @@ export function getStates(el, visible = isVisible(el)) {
203
203
  const states = [ElementState.PRESENT];
204
204
  states.push(visible ? ElementState.VISIBLE : ElementState.HIDDEN);
205
205
  const disabledProp = (isButton(el) || isInput(el) || isSelect(el) || isTextArea(el)) && el.disabled;
206
- const disabled = disabledProp || ariaBool(el, 'aria-disabled') === true;
206
+ const disabled = disabledProp || true === ariaBool(el, 'aria-disabled');
207
207
  states.push(disabled ? ElementState.DISABLED : ElementState.ENABLED);
208
- const checkedProp = isInput(el) && (el.type === 'checkbox' || el.type === 'radio') && el.checked;
209
- if (checkedProp || ariaBool(el, 'aria-checked') === true)
208
+ const checkedProp = isInput(el) && ('checkbox' === el.type || 'radio' === el.type) && el.checked;
209
+ if (checkedProp || true === ariaBool(el, 'aria-checked'))
210
210
  states.push(ElementState.CHECKED);
211
- if (ariaBool(el, 'aria-expanded') === true)
211
+ if (true === ariaBool(el, 'aria-expanded'))
212
212
  states.push(ElementState.EXPANDED);
213
213
  if (el.ownerDocument.activeElement === el)
214
214
  states.push(ElementState.FOCUSED);
@@ -232,7 +232,7 @@ export function isSensitiveField(el) {
232
232
  el.getAttribute('aria-label') ?? '',
233
233
  ];
234
234
  const sensitiveAutocomplete = /current-password|new-password|cc-number|cc-csc|one-time-code/i.test(autocomplete);
235
- return ((isInput(el) && el.type.toLowerCase() === 'password') ||
235
+ return ((isInput(el) && 'password' === el.type.toLowerCase()) ||
236
236
  sensitiveAutocomplete ||
237
237
  identifiers.some(isSensitiveKey));
238
238
  }
@@ -247,19 +247,19 @@ export function getValue(el) {
247
247
  }
248
248
  /** Whether the element's OWN box hides it — one forced-style resolution, no ancestor walk. */
249
249
  function selfHidden(el) {
250
- if (el.getAttribute('aria-hidden') === 'true')
250
+ if ('true' === el.getAttribute('aria-hidden'))
251
251
  return true;
252
252
  if (isHtmlElement(el) && el.hidden)
253
253
  return true;
254
254
  const view = el.ownerDocument.defaultView;
255
255
  if (view !== null) {
256
256
  const style = view.getComputedStyle(el);
257
- if (style.display === 'none' ||
258
- style.visibility === 'hidden' ||
259
- style.visibility === 'collapse') {
257
+ if ('none' === style.display ||
258
+ 'hidden' === style.visibility ||
259
+ 'collapse' === style.visibility) {
260
260
  return true;
261
261
  }
262
- if (Number.parseFloat(style.opacity || '1') === 0)
262
+ if (0 === Number.parseFloat(style.opacity || '1'))
263
263
  return true;
264
264
  }
265
265
  return false;
@@ -282,7 +282,7 @@ export function isVisible(el, memo) {
282
282
  // Each cached boolean already folds in that node's own aria-hidden/[hidden]/display/visibility/opacity,
283
283
  // so inherited visibility composes by AND up the chain and a sibling short-circuits at the first
284
284
  // cached ancestor.
285
- const result = !selfHidden(el) && (parent === null || isVisible(parent, memo));
285
+ const result = !selfHidden(el) && (null === parent || isVisible(parent, memo));
286
286
  if (memo !== undefined)
287
287
  memo.set(el, result);
288
288
  return result;
@@ -32,7 +32,7 @@ export const AnchorStrategy = {
32
32
  POSITION: 'position',
33
33
  };
34
34
  function nonEmpty(s) {
35
- return typeof s === 'string' && s.length > 0;
35
+ return 'string' === typeof s && s.length > 0;
36
36
  }
37
37
  /** Source as a compact `file:line` suffix; basename only to stay terse and path-independent. */
38
38
  function sourceTag(source) {
@@ -10,7 +10,7 @@ export function classifyCrossOriginFrames(frames) {
10
10
  }
11
11
  /** Probe the live document: count iframes whose document is unreachable (cross-origin). */
12
12
  export function countCrossOriginFrames() {
13
- if (typeof document === 'undefined')
13
+ if ('undefined' === typeof document)
14
14
  return 0;
15
15
  const frames = [];
16
16
  for (const frame of document.querySelectorAll('iframe')) {
package/dist/dom/chart.js CHANGED
@@ -54,12 +54,12 @@ export function hasNonFiniteCoordinate(value) {
54
54
  /** True when the geometry encloses no area — no coordinates, or every coordinate pair identical. */
55
55
  export function isDegenerate(value) {
56
56
  const nums = geometryNumbers(value).filter((n) => Number.isFinite(n));
57
- if (nums.length === 0)
57
+ if (0 === nums.length)
58
58
  return true;
59
59
  // A polyline of one repeated point draws nothing. Compare pairs, not raw numbers, so a legitimately
60
60
  // flat line (varying x, constant y) is NOT flagged — that is a real chart of constant data.
61
- const xs = nums.filter((_, i) => i % 2 === 0);
62
- const ys = nums.filter((_, i) => i % 2 === 1);
61
+ const xs = nums.filter((_, i) => 0 === i % 2);
62
+ const ys = nums.filter((_, i) => 1 === i % 2);
63
63
  const allSame = (arr) => arr.every((v) => v === arr[0]);
64
64
  return xs.length > 1 && allSame(xs) && allSame(ys);
65
65
  }
@@ -74,14 +74,14 @@ export function inspectChart(root) {
74
74
  for (const el of Array.from(root.querySelectorAll(tag))) {
75
75
  for (const attr of GEOMETRY_ATTRS) {
76
76
  const value = el.getAttribute(attr);
77
- if (value === null)
77
+ if (null === value)
78
78
  continue;
79
79
  examined += 1;
80
80
  const sample = value.length > SAMPLE_MAX ? `${value.slice(0, SAMPLE_MAX)}…` : value;
81
81
  if (hasNonFiniteCoordinate(value)) {
82
82
  findings.push({ kind: 'non-finite-coordinates', tag, attr, sample });
83
83
  }
84
- else if (value.trim().length === 0) {
84
+ else if (0 === value.trim().length) {
85
85
  findings.push({ kind: 'empty-geometry', tag, attr, sample });
86
86
  }
87
87
  else if (isDegenerate(value)) {
@@ -20,7 +20,7 @@
20
20
  * (`reticle-tauri`). Absent that, this returns `{ ok: false }` and the tool reports no-provider
21
21
  * rather than guessing.
22
22
  */
23
- export interface CaptureResult {
23
+ interface CaptureResult {
24
24
  ok: boolean;
25
25
  /**
26
26
  * Filesystem path of the captured PNG, written by the desktop shell.
@@ -34,3 +34,4 @@ export interface CaptureResult {
34
34
  reason?: string;
35
35
  }
36
36
  export declare function captureDesktopWindow(fullPage?: boolean): Promise<CaptureResult>;
37
+ export {};
@@ -48,8 +48,8 @@ function tauriCapture() {
48
48
  return undefined;
49
49
  const invoke = internals.invoke.bind(internals);
50
50
  return async (fullPage) => {
51
- const path = await invoke(RETICLE_TAURI_CAPTURE_COMMAND, { fullPage: fullPage === true });
52
- return typeof path === 'string' ? path : null;
51
+ const path = await invoke(RETICLE_TAURI_CAPTURE_COMMAND, { fullPage: true === fullPage });
52
+ return 'string' === typeof path ? path : null;
53
53
  };
54
54
  }
55
55
  /**
@@ -94,13 +94,13 @@ async function withReticleUiHidden(capture) {
94
94
  }
95
95
  export async function captureDesktopWindow(fullPage = false) {
96
96
  const channel = window[RETICLE_IPC_GLOBAL];
97
- const capture = typeof channel?.capture === 'function' ? channel.capture : tauriCapture();
97
+ const capture = 'function' === typeof channel?.capture ? channel.capture : tauriCapture();
98
98
  if (capture === undefined) {
99
99
  return { ok: false, reason: 'no desktop capture helper installed' };
100
100
  }
101
101
  try {
102
102
  const path = await withReticleUiHidden(() => capture(fullPage));
103
- return typeof path === 'string' && path.length > 0
103
+ return 'string' === typeof path && path.length > 0
104
104
  ? { ok: true, path }
105
105
  : { ok: false, reason: 'capture returned no image' };
106
106
  }
@@ -7,13 +7,13 @@ import { isReticleUi } from './dom-ignore.js';
7
7
  * we never false-positive an occlusion. Reticle's own HUD is never treated as an occluder.
8
8
  */
9
9
  export function hitTestOccluder(el, rect) {
10
- if (rect.width === 0 || rect.height === 0)
10
+ if (0 === rect.width || 0 === rect.height)
11
11
  return null;
12
12
  const doc = el.ownerDocument;
13
13
  if (typeof doc.elementFromPoint !== 'function')
14
14
  return null;
15
15
  const top = doc.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
16
- if (top === null || isReticleUi(top))
16
+ if (null === top || isReticleUi(top))
17
17
  return null;
18
18
  const lands = top === el || el.contains(top) || top.contains(el);
19
19
  return lands ? null : top;
package/dist/dom/query.js CHANGED
@@ -2,7 +2,7 @@ import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlacehol
2
2
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
3
  import { isFrame, isHtmlElement } from './realm.js';
4
4
  import { capturedRootOf } from './shadow-registry.js';
5
- import { describe, getStates, isVisible } from './a11y.js';
5
+ import { getAccessibleName, getRole, describe, getStates, isVisible } from './a11y.js';
6
6
  import { isIgnored } from './dom-ignore.js';
7
7
  import { isSensitiveKey } from '../security/serialization.js';
8
8
  import { getCapabilities } from '../registry/capabilities.js';
@@ -83,6 +83,41 @@ function findByComponent(container, query) {
83
83
  }
84
84
  return [];
85
85
  }
86
+ /**
87
+ * Role + name, matched the way Reticle REPORTS them — not only the way Testing Library computes them.
88
+ *
89
+ * Two implementations were disagreeing, on both axes, for the same element:
90
+ *
91
+ * <input type="search" placeholder="Search User">
92
+ * Reticle getRole -> "textbox" TL/dom-accessibility-api -> "searchbox"
93
+ * Reticle getAccessibleName -> "Search User" TL -> "" (no placeholder)
94
+ *
95
+ * `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
96
+ * TL's. So Reticle printed `textbox "Search User"` and then could not find it by that exact
97
+ * role and name — the same query call reporting an identity it cannot match.
98
+ *
99
+ * It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
100
+ * graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
101
+ * first replay in a different session. The reporter diagnosed it as the replay resolver computing
102
+ * names differently; it is narrower and worse, because no caller could ever have closed the round
103
+ * trip.
104
+ *
105
+ * The fallback runs ONLY when the spec-accurate query finds nothing, so every name that resolves
106
+ * today keeps resolving exactly as before and this can never take a match away. Fixed on the
107
+ * matching side rather than by changing what is reported: dropping `placeholder` would leave an
108
+ * input whose only label is its placeholder nameless in every snapshot, and renaming the role would
109
+ * churn every stored anchor — both worse trades than a fallback that runs on the empty path.
110
+ */
111
+ function queryByRoleAndName(container, role, name) {
112
+ if (name === undefined)
113
+ return queryAllByRole(container, role, { hidden: true });
114
+ const spec = queryAllByRole(container, role, { hidden: true, name });
115
+ if (spec.length > 0)
116
+ return spec;
117
+ const wanted = name.trim();
118
+ // Reticle's own role AND name: the pair actually printed to the caller.
119
+ return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
120
+ }
86
121
  /** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
87
122
  function findIn(container, query) {
88
123
  const by = query.by;
@@ -91,7 +126,7 @@ function findIn(container, query) {
91
126
  if (by !== undefined && value !== undefined) {
92
127
  switch (by) {
93
128
  case QueryBy.ROLE:
94
- return queryAllByRole(container, value, query.name !== undefined ? { hidden: true, name: query.name } : { hidden: true });
129
+ return queryByRoleAndName(container, value, query.name);
95
130
  case QueryBy.TEXT:
96
131
  return queryAllByText(container, value, { exact: false });
97
132
  case QueryBy.LABEL:
@@ -106,7 +141,13 @@ function findIn(container, query) {
106
141
  // value is the component name;.source (if present) still takes precedence inside.
107
142
  return findByComponent(container, { ...query, component: query.component ?? value });
108
143
  default:
109
- return [];
144
+ // THROW, never `return []`. An unsupported strategy answering "no matches" is
145
+ // indistinguishable from the element genuinely being absent, so `by:'css'` — the first thing
146
+ // anyone arriving from Playwright reaches for — reported a page with a <body> as empty. The
147
+ // server now rejects unknown strategies at the schema; this is the same guarantee for every
148
+ // other path in (replay, reticle_run, an internal caller), because a false negative invented
149
+ // by the tool is the exact failure this product exists to prevent.
150
+ throw new Error(`unsupported query strategy '${String(by)}' — use one of: ${Object.values(QueryBy).join(', ')}`);
110
151
  }
111
152
  }
112
153
  // Auto-anchor (component / source) — checked before the role/text fields so a query carrying
@@ -114,12 +155,10 @@ function findIn(container, query) {
114
155
  if (query.component !== undefined || query.source !== undefined) {
115
156
  return findByComponent(container, query);
116
157
  }
117
- // Structured form (role+name, or any single field).
158
+ // Structured form (role+name, or any single field). Same round-trip guarantee as the by+value
159
+ // spelling above — the two forms must not disagree about what is findable.
118
160
  if (query.role !== undefined) {
119
- const options = query.name !== undefined
120
- ? { hidden: true, name: query.name }
121
- : { hidden: true };
122
- return queryAllByRole(container, query.role, options);
161
+ return queryByRoleAndName(container, query.role, query.name);
123
162
  }
124
163
  if (query.text !== undefined)
125
164
  return queryAllByText(container, query.text, { exact: false });
@@ -184,7 +223,7 @@ function embeddedRootsUnder(root) {
184
223
  if (!isFrame(el))
185
224
  continue;
186
225
  const body = readableFrameBody(el);
187
- if (body === null)
226
+ if (null === body)
188
227
  continue; // cross-origin — declared, not searched
189
228
  found.push(body);
190
229
  walk(body, depth + 1);
@@ -218,7 +257,7 @@ function findCandidates(query) {
218
257
  const { container, scopeMissing } = resolveContainer(query.scope);
219
258
  // A given-but-missing scope searches NOTHING — never the whole page. The empty result plus the
220
259
  // scopeMissing flag is what keeps "gone scope" distinct from "absent element".
221
- if (container === null)
260
+ if (null === container)
222
261
  return { candidates: [], scopeMissing: true };
223
262
  const seen = new Set();
224
263
  const out = [];
@@ -259,7 +298,7 @@ function projectAttrs(el, keys) {
259
298
  const out = {};
260
299
  for (const key of keys.slice(0, ATTR_KEYS_MAX)) {
261
300
  const raw = el.getAttribute(key);
262
- if (raw === null)
301
+ if (null === raw)
263
302
  continue;
264
303
  out[key] = isSensitiveKey(key) ? REDACTED_VALUE : raw.slice(0, ATTR_VALUE_MAX);
265
304
  }
@@ -286,16 +325,15 @@ const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
286
325
  * "how many?" stay exact while the answer stays affordable.
287
326
  */
288
327
  export function matchQuery(query, state, limit = MAX_DESCRIBED) {
289
- let elements;
290
- let scopeMissing = false;
291
- try {
292
- const found = findCandidates(query);
293
- elements = found.candidates;
294
- scopeMissing = found.scopeMissing;
295
- }
296
- catch {
297
- elements = [];
298
- }
328
+ // NO blanket try/catch here. It used to turn ANY exception during candidate-finding into
329
+ // `elements = []`, which is the same lie as the `default` arm above: a query that could not run
330
+ // reported that the element is not on the page. The one failure it was plausibly guarding —
331
+ // a scope selector that matches nothing — is already handled explicitly and distinctly, as
332
+ // `scopeMissing`, so what remained was a net that could only convert real faults into false
333
+ // negatives.
334
+ const found = findCandidates(query);
335
+ const elements = found.candidates;
336
+ const scopeMissing = found.scopeMissing;
299
337
  // One visibility cache for the whole (synchronous) query pass. isVisible is an O(depth) forced-style
300
338
  // walk; the state filter runs it over EVERY candidate (the count must be exact) — on a match-heavy
301
339
  // page (e.g. a 3k-row grid) that is tens of thousands of getComputedStyle calls on the host's main
@@ -306,7 +344,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
306
344
  const described = filtered.slice(0, Math.max(0, Math.min(limit, MAX_DESCRIBED)));
307
345
  const descriptors = described.map((el) => {
308
346
  const base = describe(el, visMemo);
309
- if (attrs === undefined || attrs.length === 0)
347
+ if (attrs === undefined || 0 === attrs.length)
310
348
  return base;
311
349
  const projected = projectAttrs(el, attrs);
312
350
  return projected === undefined ? base : { ...base, attrs: projected };
@@ -316,13 +354,18 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
316
354
  count: filtered.length,
317
355
  elements: descriptors,
318
356
  ...(scopeMissing ? { scopeMissing: true } : {}),
357
+ // On a MISS, carry the same diagnosis `runQuery` has always returned. MATCH is the command every
358
+ // PREDICATE uses, so without this a failed assertion was a dead end ("no element matched") while
359
+ // the identical failure through reticle_query listed the testids that ARE present. Computed only
360
+ // when there is nothing to report, so the hot path pays nothing.
361
+ ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
319
362
  };
320
363
  }
321
364
  /** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text — a bare ID is
322
365
  * not a human-readable name. Undefined when unset or nothing resolves. */
323
366
  function resolveLabelledBy(el) {
324
367
  const ids = el.getAttribute('aria-labelledby');
325
- if (ids === null)
368
+ if (null === ids)
326
369
  return undefined;
327
370
  const text = ids
328
371
  .split(/\s+/)
@@ -426,8 +469,8 @@ function buildEmptyHint(query) {
426
469
  */
427
470
  export function runQuery(query, limit) {
428
471
  const result = matchQuery(query, undefined, limit);
429
- const scopeFields = result.scopeMissing === true ? { scopeMissing: true } : {};
430
- if (result.elements.length === 0) {
472
+ const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
473
+ if (0 === result.elements.length) {
431
474
  return {
432
475
  elements: result.elements,
433
476
  count: result.count,