@reticlehq/browser 2.7.0 → 2.9.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 (92) hide show
  1. package/NOTICE +7 -0
  2. package/README.md +2 -2
  3. package/dist/actions/actions-dom.js +23 -15
  4. package/dist/actions/actions.d.ts +17 -0
  5. package/dist/actions/actions.js +132 -29
  6. package/dist/actions/synthetic-input.d.ts +9 -0
  7. package/dist/actions/synthetic-input.js +40 -0
  8. package/dist/commands/commands.js +18 -1
  9. package/dist/connect-options.d.ts +9 -9
  10. package/dist/dom/a11y.d.ts +9 -0
  11. package/dist/dom/a11y.js +19 -0
  12. package/dist/dom/desktop-capture.js +20 -3
  13. package/dist/dom/dom-ignore.d.ts +19 -6
  14. package/dist/dom/dom-ignore.js +20 -13
  15. package/dist/dom/query.d.ts +2 -2
  16. package/dist/dom/query.js +85 -45
  17. package/dist/dom/refs.d.ts +28 -0
  18. package/dist/dom/refs.js +87 -0
  19. package/dist/dom/snapshot.d.ts +5 -0
  20. package/dist/dom/snapshot.js +34 -0
  21. package/dist/dom/source.d.ts +14 -0
  22. package/dist/dom/source.js +16 -0
  23. package/dist/dom/theme.d.ts +33 -10
  24. package/dist/dom/theme.js +109 -50
  25. package/dist/observers/console.js +5 -2
  26. package/dist/observers/install-all.js +4 -1
  27. package/dist/observers/ipc.d.ts +16 -0
  28. package/dist/observers/ipc.js +19 -1
  29. package/dist/observers/network.d.ts +8 -0
  30. package/dist/observers/network.js +29 -6
  31. package/dist/observers/state.js +21 -2
  32. package/dist/presenter/presenter-brand.d.ts +24 -7
  33. package/dist/presenter/presenter-brand.js +23 -6
  34. package/dist/presenter/presenter-config.d.ts +102 -9
  35. package/dist/presenter/presenter-config.js +94 -8
  36. package/dist/presenter/presenter-controls.d.ts +10 -10
  37. package/dist/presenter/presenter-controls.js +175 -85
  38. package/dist/presenter/presenter-dock-layout.d.ts +6 -0
  39. package/dist/presenter/presenter-dock-layout.js +170 -0
  40. package/dist/presenter/presenter-drag.d.ts +48 -0
  41. package/dist/presenter/presenter-drag.js +198 -0
  42. package/dist/presenter/presenter-effects.d.ts +1 -1
  43. package/dist/presenter/presenter-effects.js +1 -1
  44. package/dist/presenter/presenter-glow.d.ts +5 -5
  45. package/dist/presenter/presenter-glow.js +7 -7
  46. package/dist/presenter/presenter-heroicons-data.d.ts +41 -0
  47. package/dist/presenter/presenter-heroicons-data.js +38 -0
  48. package/dist/presenter/presenter-hud-chrome.d.ts +17 -0
  49. package/dist/presenter/presenter-hud-chrome.js +41 -0
  50. package/dist/presenter/presenter-icons.d.ts +44 -0
  51. package/dist/presenter/presenter-icons.js +100 -0
  52. package/dist/presenter/presenter-log.d.ts +27 -5
  53. package/dist/presenter/presenter-log.js +114 -28
  54. package/dist/presenter/presenter-report-copy.d.ts +83 -0
  55. package/dist/presenter/presenter-report-copy.js +127 -0
  56. package/dist/presenter/presenter-report-styles.d.ts +7 -0
  57. package/dist/presenter/presenter-report-styles.js +102 -0
  58. package/dist/presenter/presenter-report.d.ts +23 -0
  59. package/dist/presenter/presenter-report.js +217 -0
  60. package/dist/presenter/presenter-run-state.d.ts +1 -1
  61. package/dist/presenter/presenter-settings-styles.d.ts +2 -0
  62. package/dist/presenter/presenter-settings-styles.js +153 -0
  63. package/dist/presenter/presenter-settings.d.ts +82 -0
  64. package/dist/presenter/presenter-settings.js +494 -0
  65. package/dist/presenter/presenter-shell-styles.d.ts +1 -0
  66. package/dist/presenter/presenter-shell-styles.js +375 -0
  67. package/dist/presenter/presenter-shell.d.ts +48 -0
  68. package/dist/presenter/presenter-shell.js +408 -0
  69. package/dist/presenter/presenter-styles.d.ts +9 -4
  70. package/dist/presenter/presenter-styles.js +61 -125
  71. package/dist/presenter/presenter-tally.d.ts +8 -11
  72. package/dist/presenter/presenter-tally.js +48 -23
  73. package/dist/presenter/presenter-test-helpers.d.ts +1 -1
  74. package/dist/presenter/presenter-test-helpers.js +1 -1
  75. package/dist/presenter/presenter-verbs.js +2 -2
  76. package/dist/presenter/presenter-workspace.d.ts +15 -0
  77. package/dist/presenter/presenter-workspace.js +180 -0
  78. package/dist/presenter/presenter.d.ts +15 -10
  79. package/dist/presenter/presenter.js +194 -46
  80. package/dist/registry/emitter.d.ts +2 -2
  81. package/dist/reticle.d.ts +2 -2
  82. package/dist/reticle.js +27 -25
  83. package/dist/review/annotator-styles.d.ts +6 -0
  84. package/dist/review/annotator-styles.js +81 -0
  85. package/dist/review/annotator.d.ts +29 -17
  86. package/dist/review/annotator.js +477 -146
  87. package/dist/timers/clock.js +8 -5
  88. package/dist/transport/unreachable-message.d.ts +12 -0
  89. package/dist/transport/unreachable-message.js +20 -0
  90. package/dist/util/captured-method.d.ts +37 -0
  91. package/dist/util/captured-method.js +45 -0
  92. package/package.json +9 -6
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's "flag a bug"
3
- * UI (`data-reticle-mark`) — never observed/snapshotted. The annotator mounts by DEFAULT with the
4
- * presenter, so omitting its selector here leaked a phantom `<button>Flag a bug</button>` into every
5
- * snapshot and streamed its highlight-box mutations/transitions as if the APP had rendered them.
2
+ * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's
3
+ * UI (`data-reticle-mark`) - never observed/snapshotted. The annotator mounts by DEFAULT with the
4
+ * presenter, so omitting its selector here leaked annotation chrome into every snapshot.
6
5
  */
7
- export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]';
6
+ export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark],[data-reticle-blocker]';
8
7
  /** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
9
8
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
10
9
  let extraIgnore = '';
@@ -16,15 +15,23 @@ export function setIgnoreSelectors(selectors) {
16
15
  export function isReticleOverlay(el) {
17
16
  return el.closest(RETICLE_OVERLAY) !== null;
18
17
  }
19
- /** Walk ancestors: true iff any element in the chain carries a data-reticle* attribute (Reticle's own UI). */
18
+ /**
19
+ * True iff the element is part of Reticle's OWN UI - the presenter overlay, the HUD, the synthetic
20
+ * cursor, the glow, or the annotator's marks - or lives inside one of them.
21
+ *
22
+ * The rule used to be "any ancestor carries a data-reticle* attribute", which is wrong twice over.
23
+ * `data-reticle-mark-active` sits on <html> while annotate mode is live, so the whole document
24
+ * answered yes; and `data-reticle-source` is stamped by the Vite/Babel plugins on every element the
25
+ * APP renders, so in an instrumented app - which is the only kind there is - most of the page
26
+ * answered yes too. Those attributes describe page content; they do not make it ours.
27
+ *
28
+ * Two things read this and both failed silently. `pageElementAt` (annotator) skipped every stamped
29
+ * element and anchored the note to the outermost unstamped ancestor, i.e. the app shell instead of
30
+ * the control under the cursor. `occlusion.ts` reads a yes as "nothing to report", so occlusion
31
+ * detection - a bug class Reticle advertises catching - came back clean wherever the stamp reached.
32
+ */
20
33
  export function isReticleUi(node) {
21
- for (let n = node; n !== null; n = n.parentElement) {
22
- for (const attr of Array.from(n.attributes)) {
23
- if (attr.name.startsWith('data-reticle'))
24
- return true;
25
- }
26
- }
27
- return false;
34
+ return node !== null && node.closest(RETICLE_OVERLAY) !== null;
28
35
  }
29
36
  /** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
30
37
  export function isIgnored(el) {
@@ -12,7 +12,7 @@ export declare function matchQuery(query: ElementQuery, state?: ElementState, li
12
12
  * Resolve a query to descriptors for the `query` MCP tool.
13
13
  *
14
14
  * `count` is carried through deliberately: the server reports match totals from it rather than from
15
- * the array, because the array is capped in transit. Dropping it here — which this function used to
16
- * do — silently put the server back to counting survivors and calling that the answer.
15
+ * the array, because the array is capped in transit. Dropping it here - which this function used to
16
+ * do - silently put the server back to counting survivors and calling that the answer.
17
17
  */
18
18
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
package/dist/dom/query.js CHANGED
@@ -1,8 +1,8 @@
1
- import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, } from '@testing-library/dom';
1
+ import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, getDefaultNormalizer, } from '@testing-library/dom';
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 { getAccessibleName, getRole, describe, getStates, isVisible } from './a11y.js';
5
+ import { getAccessibleName, getRole, describe, getStates, isInViewport, 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';
@@ -17,7 +17,7 @@ const MAX_COMPONENT_CANDIDATES = 2000;
17
17
  const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
18
18
  /**
19
19
  * Resolve a scope to its container. A container of `null` means a scope was GIVEN but resolved to
20
- * nothing (unmounted, or a selector that matches no element) — the caller must NOT fall back to the
20
+ * nothing (unmounted, or a selector that matches no element) - the caller must NOT fall back to the
21
21
  * whole page, or a scoped query silently widens into a phantom match. `scope === undefined` (no scope)
22
22
  * legitimately searches the body.
23
23
  */
@@ -33,12 +33,12 @@ function resolveContainer(scope) {
33
33
  return { container: found, scopeMissing: false };
34
34
  }
35
35
  catch {
36
- // invalid selector — treat as a missing scope, never a whole-page search
36
+ // invalid selector - treat as a missing scope, never a whole-page search
37
37
  }
38
38
  return { container: null, scopeMissing: true };
39
39
  }
40
40
  /**
41
- * Resolve an element by its SOURCE location — the precise, granular auto-anchor. The babel plugin
41
+ * Resolve an element by its SOURCE location - the precise, granular auto-anchor. The babel plugin
42
42
  * stamps `data-reticle-source="file:line:column"` on host elements, so a line-level starts-with match
43
43
  * pins the exact JSX element with a single fast attribute selector (no fiber walk). Column is
44
44
  * ignored so a small column drift doesn't unbind the anchor.
@@ -55,7 +55,7 @@ function findBySource(container, source) {
55
55
  /**
56
56
  * Resolve by component display name when no source stamp is available: scan a bounded set of
57
57
  * likely-actionable elements and keep those whose NEAREST enclosing component (via the registered
58
- * framework adapter) matches. Coarser than source (one component renders many hosts) — used as a
58
+ * framework adapter) matches. Coarser than source (one component renders many hosts) - used as a
59
59
  * fallback / for frameworks without a source plugin.
60
60
  */
61
61
  function findByComponentName(container, component) {
@@ -84,7 +84,7 @@ function findByComponent(container, query) {
84
84
  return [];
85
85
  }
86
86
  /**
87
- * Role + name, matched the way Reticle REPORTS them — not only the way Testing Library computes them.
87
+ * Role + name, matched the way Reticle REPORTS them - not only the way Testing Library computes them.
88
88
  *
89
89
  * Two implementations were disagreeing, on both axes, for the same element:
90
90
  *
@@ -94,7 +94,7 @@ function findByComponent(container, query) {
94
94
  *
95
95
  * `reticle_query`, `reticle_snapshot` and every act result report Reticle's values. The matcher used
96
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.
97
+ * role and name - the same query call reporting an identity it cannot match.
98
98
  *
99
99
  * It surfaced through the flow recorder, which anchors a step to the reported role+name: `flow_save`
100
100
  * graded the flow `asserted` with `degraded: 0`, a clean bill of health, and the step drifted on the
@@ -106,7 +106,7 @@ function findByComponent(container, query) {
106
106
  * today keeps resolving exactly as before and this can never take a match away. Fixed on the
107
107
  * matching side rather than by changing what is reported: dropping `placeholder` would leave an
108
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.
109
+ * churn every stored anchor - both worse trades than a fallback that runs on the empty path.
110
110
  */
111
111
  function queryByRoleAndName(container, role, name) {
112
112
  if (name === undefined)
@@ -118,6 +118,35 @@ function queryByRoleAndName(container, role, name) {
118
118
  // Reticle's own role AND name: the pair actually printed to the caller.
119
119
  return [...container.querySelectorAll('*')].filter((el) => getRole(el) === role && getAccessibleName(el).trim() === wanted);
120
120
  }
121
+ /**
122
+ * The same text, written two legal ways, did not match itself.
123
+ *
124
+ * Unicode lets `café` be one code point (`é`) or two (`e` + a combining acute). They render
125
+ * identically, they are canonically equivalent, and JavaScript string comparison says they differ —
126
+ * so a query typed one way against a DOM holding the other found nothing. That is a false RED: the
127
+ * agent asserts text that is visibly on screen, is told it is absent, and reports a working app as
128
+ * broken.
129
+ *
130
+ * It cannot happen in English, which is why it survived here. It is ordinary in French, Vietnamese
131
+ * and Korean, and the decomposed form is what macOS filesystems, several IMEs and some databases
132
+ * hand back.
133
+ *
134
+ * Normalising both sides can only ADD matches: normalisation is idempotent and
135
+ * canonical-equivalence-preserving, so two strings equal before are equal after and nothing that
136
+ * matched can stop matching. That property is why this is safe to apply to the matching core.
137
+ *
138
+ * Applied only to queries over USER-VISIBLE text. A testid is an attribute the developer typed on
139
+ * both sides and a component name is an identifier; normalising those would change what an exact
140
+ * match means, for no case anyone has.
141
+ */
142
+ const toNfc = (value) => value.normalize('NFC');
143
+ /**
144
+ * Testing Library's own normaliser — whitespace trimming and collapsing — with NFC on top.
145
+ *
146
+ * Composed rather than replaced: writing a normaliser from scratch here would silently drop their
147
+ * whitespace handling, which every existing match depends on.
148
+ */
149
+ const TEXT_NORMALIZER = { normalizer: (text) => toNfc(getDefaultNormalizer()(text)) };
121
150
  /** Run the appropriate Testing-Library query against ONE root (light DOM or a shadow root). */
122
151
  function findIn(container, query) {
123
152
  const by = query.by;
@@ -128,62 +157,68 @@ function findIn(container, query) {
128
157
  case QueryBy.ROLE:
129
158
  return queryByRoleAndName(container, value, query.name);
130
159
  case QueryBy.TEXT:
131
- return queryAllByText(container, value, { exact: false });
160
+ return queryAllByText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
132
161
  case QueryBy.LABEL:
133
- return queryAllByLabelText(container, value, { exact: false });
162
+ return queryAllByLabelText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
134
163
  case QueryBy.PLACEHOLDER:
135
- return queryAllByPlaceholderText(container, value, { exact: false });
164
+ return queryAllByPlaceholderText(container, toNfc(value), {
165
+ exact: false,
166
+ ...TEXT_NORMALIZER,
167
+ });
136
168
  case QueryBy.TESTID:
137
169
  return queryAllByTestId(container, value, { exact: true });
138
170
  case QueryBy.ALT:
139
- return queryAllByAltText(container, value, { exact: false });
171
+ return queryAllByAltText(container, toNfc(value), { exact: false, ...TEXT_NORMALIZER });
140
172
  case QueryBy.COMPONENT:
141
173
  // value is the component name;.source (if present) still takes precedence inside.
142
174
  return findByComponent(container, { ...query, component: query.component ?? value });
143
175
  default:
144
176
  // 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
177
+ // indistinguishable from the element genuinely being absent, so `by:'css'` - the first thing
178
+ // anyone arriving from Playwright reaches for - reported a page with a <body> as empty. The
147
179
  // server now rejects unknown strategies at the schema; this is the same guarantee for every
148
180
  // other path in (replay, reticle_run, an internal caller), because a false negative invented
149
181
  // 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(', ')}`);
182
+ throw new Error(`unsupported query strategy '${String(by)}' - use one of: ${Object.values(QueryBy).join(', ')}`);
151
183
  }
152
184
  }
153
- // Auto-anchor (component / source) — checked before the role/text fields so a query carrying
185
+ // Auto-anchor (component / source) - checked before the role/text fields so a query carrying
154
186
  // both a component anchor and an incidental role resolves by the more durable anchor.
155
187
  if (query.component !== undefined || query.source !== undefined) {
156
188
  return findByComponent(container, query);
157
189
  }
158
190
  // 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.
191
+ // spelling above - the two forms must not disagree about what is findable.
160
192
  if (query.role !== undefined) {
161
193
  return queryByRoleAndName(container, query.role, query.name);
162
194
  }
163
195
  if (query.text !== undefined)
164
- return queryAllByText(container, query.text, { exact: false });
196
+ return queryAllByText(container, toNfc(query.text), { exact: false, ...TEXT_NORMALIZER });
165
197
  if (query.label !== undefined) {
166
- return queryAllByLabelText(container, query.label, { exact: false });
198
+ return queryAllByLabelText(container, toNfc(query.label), { exact: false, ...TEXT_NORMALIZER });
167
199
  }
168
200
  if (query.placeholder !== undefined) {
169
- return queryAllByPlaceholderText(container, query.placeholder, { exact: false });
201
+ return queryAllByPlaceholderText(container, toNfc(query.placeholder), {
202
+ exact: false,
203
+ ...TEXT_NORMALIZER,
204
+ });
170
205
  }
171
206
  if (query.testid !== undefined)
172
207
  return queryAllByTestId(container, query.testid, { exact: true });
173
208
  if (query.alt !== undefined)
174
- return queryAllByAltText(container, query.alt, { exact: false });
209
+ return queryAllByAltText(container, toNfc(query.alt), { exact: false, ...TEXT_NORMALIZER });
175
210
  return [];
176
211
  }
177
212
  /**
178
213
  * Every open shadow root at or beneath `root`, in document order.
179
214
  *
180
- * A closed root is deliberately unreachable — `element.shadowRoot` is null by design, and the SDK
215
+ * A closed root is deliberately unreachable - `element.shadowRoot` is null by design, and the SDK
181
216
  * reports that as a blind spot rather than pretending to see through it.
182
217
  */
183
218
  /**
184
219
  * A same-origin `<iframe>`'s body, or null when the document is unreachable.
185
220
  *
186
- * Cross-origin access THROWS in some engines and yields null in others, so both are handled — and
221
+ * Cross-origin access THROWS in some engines and yields null in others, so both are handled - and
187
222
  * both mean the same thing here: not ours to read. That case is not a silent skip, it is reported
188
223
  * separately as a declared blind spot.
189
224
  */
@@ -202,7 +237,7 @@ const FRAME_DEPTH_MAX = 3;
202
237
  * open shadow roots, and the documents of SAME-ORIGIN iframes.
203
238
  *
204
239
  * The iframe half was measured missing: a same-origin frame whose text the page could read straight
205
- * off `contentDocument` returned zero matches from `query`, and coverage said nothing about it — the
240
+ * off `contentDocument` returned zero matches from `query`, and coverage said nothing about it - the
206
241
  * worst combination, an absent-element answer for content that is right there. Cross-origin frames
207
242
  * are a genuinely different case and stay unread; they are declared instead.
208
243
  */
@@ -224,7 +259,7 @@ function embeddedRootsUnder(root) {
224
259
  continue;
225
260
  const body = readableFrameBody(el);
226
261
  if (null === body)
227
- continue; // cross-origin — declared, not searched
262
+ continue; // cross-origin - declared, not searched
228
263
  found.push(body);
229
264
  walk(body, depth + 1);
230
265
  }
@@ -234,13 +269,13 @@ function embeddedRootsUnder(root) {
234
269
  }
235
270
  /**
236
271
  * NOTE ON COST. The walk above is a `querySelectorAll('*')` over the container, and `reticle_query` is
237
- * a hot-path tool, so every query on every app pays it — including the overwhelming majority that hold
272
+ * a hot-path tool, so every query on every app pays it - including the overwhelming majority that hold
238
273
  * no web components.
239
274
  *
240
275
  * A cache invalidated by a MutationObserver was written and then REMOVED, because it is not sound:
241
276
  * `attachShadow` on an element that is already in the document mutates only the shadow tree, which an
242
277
  * observer watching documentElement's subtree never sees. The cached "no shadow roots here" would
243
- * persist and the content would be silently missed — a false negative indistinguishable from a genuinely
278
+ * persist and the content would be silently missed - a false negative indistinguishable from a genuinely
244
279
  * absent element, which is the exact bug the piercing was added to fix.
245
280
  *
246
281
  * Correctness wins until there is a measurement saying the walk actually matters. The allocation is
@@ -250,18 +285,18 @@ function embeddedRootsUnder(root) {
250
285
  * Run the query against the light DOM AND every open shadow root beneath the scope.
251
286
  *
252
287
  * Testing Library only walks the light DOM, so a control inside a web component returned zero matches
253
- * on a completely healthy page — a miss indistinguishable from a genuinely absent element. The
288
+ * on a completely healthy page - a miss indistinguishable from a genuinely absent element. The
254
289
  * snapshot has always pierced open roots; this makes `query` agree with it.
255
290
  */
256
291
  function findCandidates(query) {
257
292
  const { container, scopeMissing } = resolveContainer(query.scope);
258
- // A given-but-missing scope searches NOTHING — never the whole page. The empty result plus the
293
+ // A given-but-missing scope searches NOTHING - never the whole page. The empty result plus the
259
294
  // scopeMissing flag is what keeps "gone scope" distinct from "absent element".
260
295
  if (null === container)
261
296
  return { candidates: [], scopeMissing: true };
262
297
  // `self: true` asks for the container ITSELF, which every other path excludes by construction.
263
298
  // 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
299
+ // and it is routinely the element carrying the handler - "click the empty space in this row" is a
265
300
  // real user action that was simply not expressible. Requires a scope: without one there is no root
266
301
  // to return, and answering `document.body` would be a wrong answer wearing the shape of one.
267
302
  if (true === query.self) {
@@ -274,11 +309,11 @@ function findCandidates(query) {
274
309
  const collect = (els) => {
275
310
  for (const el of els) {
276
311
  if (seen.has(el))
277
- continue; // reachable from both host and root — count once
312
+ continue; // reachable from both host and root - count once
278
313
  // Reticle's OWN UI is not part of the app under test. `snapshot` has always excluded it; query
279
- // did not, so the presenter panel and the annotator answered `by: role` like app controls —
314
+ // did not, so the presenter panel and the annotator answered `by: role` like app controls -
280
315
  // measured on a real merchant dashboard, 7 of the 40 buttons an agent could see were ours
281
- // ("Pause", "End", "Minimise the panel", "Export", "Flag a bug for the agent"). Two of those
316
+ // ("Pause", "End", "Chat", "Export"). Two of those
282
317
  // share a NAME with a real control on that page, so an agent resolving "Export" could drive the
283
318
  // observer instead of the app and then reason about the result. Filtered here, at the one
284
319
  // collection point, so every entry path (by role/text/testid, shadow roots, scoped) is covered.
@@ -295,14 +330,14 @@ function findCandidates(query) {
295
330
  }
296
331
  /** Longest attribute value returned; one enormous href must not blow the response budget. */
297
332
  export const ATTR_VALUE_MAX = 512;
298
- /** Most attributes projected per element — a guard against a caller asking for everything. */
333
+ /** Most attributes projected per element - a guard against a caller asking for everything. */
299
334
  const ATTR_KEYS_MAX = 12;
300
335
  /**
301
336
  * Read the requested attributes off an element.
302
337
  *
303
338
  * Absent attributes are OMITTED rather than returned empty, so "not present" and "present but blank"
304
339
  * stay distinguishable. Credential-bearing names are redacted with the same rule the network and
305
- * storage paths use — a projection API must not become an exfiltration path.
340
+ * storage paths use - a projection API must not become an exfiltration path.
306
341
  */
307
342
  function projectAttrs(el, keys) {
308
343
  const out = {};
@@ -315,6 +350,11 @@ function projectAttrs(el, keys) {
315
350
  return Object.keys(out).length > 0 ? out : undefined;
316
351
  }
317
352
  function inState(el, state, memo) {
353
+ // inViewport is computed on demand rather than listed in getStates, so it stays assertable without
354
+ // adding a state to every element in every snapshot (it would bloat the wire and churn every
355
+ // describe). The predicate path is the only consumer that needs it. (#398)
356
+ if (ElementState.IN_VIEWPORT === state)
357
+ return isInViewport(el, memo);
318
358
  return getStates(el, isVisible(el, memo)).includes(state);
319
359
  }
320
360
  /**
@@ -322,7 +362,7 @@ function inState(el, state, memo) {
322
362
  *
323
363
  * describe() is the expensive part of a query: it resolves the accessible name and forces a style
324
364
  * computation for every ancestor to decide visibility. Describing every match on a page with
325
- * thousands of them freezes the main thread for seconds — and then the wire discards all but the
365
+ * thousands of them freezes the main thread for seconds - and then the wire discards all but the
326
366
  * first `MAX_COLLECTION_ITEMS` anyway, so the work past that point was never observable by anyone.
327
367
  * Matching one to the other means the cost of a broad query is bounded by the transport rather than
328
368
  * by the size of the page.
@@ -337,15 +377,15 @@ const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
337
377
  export function matchQuery(query, state, limit = MAX_DESCRIBED) {
338
378
  // NO blanket try/catch here. It used to turn ANY exception during candidate-finding into
339
379
  // `elements = []`, which is the same lie as the `default` arm above: a query that could not run
340
- // reported that the element is not on the page. The one failure it was plausibly guarding —
341
- // a scope selector that matches nothing — is already handled explicitly and distinctly, as
380
+ // reported that the element is not on the page. The one failure it was plausibly guarding -
381
+ // a scope selector that matches nothing - is already handled explicitly and distinctly, as
342
382
  // `scopeMissing`, so what remained was a net that could only convert real faults into false
343
383
  // negatives.
344
384
  const found = findCandidates(query);
345
385
  const elements = found.candidates;
346
386
  const scopeMissing = found.scopeMissing;
347
387
  // One visibility cache for the whole (synchronous) query pass. isVisible is an O(depth) forced-style
348
- // walk; the state filter runs it over EVERY candidate (the count must be exact) — on a match-heavy
388
+ // walk; the state filter runs it over EVERY candidate (the count must be exact) - on a match-heavy
349
389
  // page (e.g. a 3k-row grid) that is tens of thousands of getComputedStyle calls on the host's main
350
390
  // thread. The memo makes each element's ancestors resolve once, then short-circuit for every sibling.
351
391
  const visMemo = new Map();
@@ -371,7 +411,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
371
411
  ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
372
412
  };
373
413
  }
374
- /** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text — a bare ID is
414
+ /** Resolve `aria-labelledby` (one or more element IDs) to the referenced elements' text - a bare ID is
375
415
  * not a human-readable name. Undefined when unset or nothing resolves. */
376
416
  function resolveLabelledBy(el) {
377
417
  const ids = el.getAttribute('aria-labelledby');
@@ -384,7 +424,7 @@ function resolveLabelledBy(el) {
384
424
  .join(' ');
385
425
  return text.length > 0 ? text : undefined;
386
426
  }
387
- /** Structural clusters of the page — the successor to the raw testid list in zero-match hints. */
427
+ /** Structural clusters of the page - the successor to the raw testid list in zero-match hints. */
388
428
  function buildPresentRegions(query) {
389
429
  // For the diagnostic hint we WANT the page's orientation even when the scope is gone, so fall back
390
430
  // to the body here (the match path above already reported scopeMissing, so this can't mask it).
@@ -418,7 +458,7 @@ function buildPresentRegions(query) {
418
458
  }
419
459
  for (const el of containers) {
420
460
  const name = el.getAttribute('aria-label') ??
421
- resolveLabelledBy(el) ?? // aria-labelledby is an element ID — resolve it to the referenced TEXT
461
+ resolveLabelledBy(el) ?? // aria-labelledby is an element ID - resolve it to the referenced TEXT
422
462
  el.getAttribute('data-testid') ??
423
463
  undefined;
424
464
  const children = el.querySelectorAll('[role]');
@@ -474,8 +514,8 @@ function buildEmptyHint(query) {
474
514
  * Resolve a query to descriptors for the `query` MCP tool.
475
515
  *
476
516
  * `count` is carried through deliberately: the server reports match totals from it rather than from
477
- * the array, because the array is capped in transit. Dropping it here — which this function used to
478
- * do — silently put the server back to counting survivors and calling that the answer.
517
+ * the array, because the array is capped in transit. Dropping it here - which this function used to
518
+ * do - silently put the server back to counting survivors and calling that the answer.
479
519
  */
480
520
  export function runQuery(query, limit) {
481
521
  const result = matchQuery(query, undefined, limit);
@@ -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);
@@ -4,6 +4,11 @@ interface SnapshotStatus {
4
4
  title: string;
5
5
  /** Open dialogs, OMITTED when there are none — absence means "no dialog is up". */
6
6
  visibleDialogs?: string[];
7
+ /**
8
+ * Present ONLY when a whole-page snapshot came back near-empty because an open overlay
9
+ * aria-hidden the rest of the page (#397). Absence means the page is not hidden behind an overlay.
10
+ */
11
+ overlayHidingPage?: string;
7
12
  }
8
13
  export interface SnapshotResult {
9
14
  tree: string;
@@ -194,12 +194,46 @@ function collectDialogs(root) {
194
194
  * case, so the field was almost pure repetition; a reader learns nothing from its presence and
195
195
  * everything from it.
196
196
  */
197
+ /**
198
+ * A focus-trap modal (Radix and friends) marks every sibling of the portal root aria-hidden. The
199
+ * snapshot walk correctly skips aria-hidden subtrees, so a whole-page snapshot can return only the
200
+ * overlay's own nodes and nothing else -- indistinguishable from "this page is empty". If the
201
+ * overlay's cleanup is stuck (common on a throttled tab), every later action then dispatches with no
202
+ * effect because the overlay genuinely captures pointer events. Say so instead of returning a
203
+ * near-empty tree with no explanation. (#397)
204
+ */
205
+ function overlayHidingPage(root) {
206
+ // Only meaningful for a whole-page snapshot; a scoped snapshot is the subtree the caller chose.
207
+ if (root !== document.body)
208
+ return undefined;
209
+ const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
210
+ let modal;
211
+ for (const d of dialogs) {
212
+ if (isVisible(d)) {
213
+ modal = d;
214
+ break;
215
+ }
216
+ }
217
+ if (modal === undefined)
218
+ return undefined;
219
+ const modalEl = modal;
220
+ const outside = Array.from(document.body.children).filter((c) => !c.contains(modalEl));
221
+ if (0 === outside.length)
222
+ return undefined;
223
+ if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden')))
224
+ return undefined;
225
+ return ('the rest of the page is aria-hidden behind an open overlay (a focus-trap modal), so this ' +
226
+ 'snapshot shows only the overlay. If it will not close its cleanup may be stuck on a throttled ' +
227
+ 'tab: dismiss the overlay, or snapshot with { scope } inside it.');
228
+ }
197
229
  function buildStatus(root) {
198
230
  const visibleDialogs = collectDialogs(root);
231
+ const overlay = overlayHidingPage(root);
199
232
  return {
200
233
  route: `${location.pathname}${location.search}${location.hash}`,
201
234
  title: document.title,
202
235
  ...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
236
+ ...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
203
237
  };
204
238
  }
205
239
  /** Build the semantic accessibility snapshot of the page or a subtree. */
@@ -29,6 +29,20 @@ export declare function parseSourceAttr(value: string | null): SourceLocation |
29
29
  export declare function sourceFromDom(el: Element): SourceLocation | undefined;
30
30
  /** Best source for the element: the framework adapter's, else the nearest babel-stamped attribute. */
31
31
  export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
32
+ /**
33
+ * Does ANY element in this document carry a source stamp?
34
+ *
35
+ * The difference between "this element has no stamp" and "the stamping loader is not running at
36
+ * all" — two answers that look identical from a single missing `source` field, and that call for
37
+ * completely different next actions. The first is ordinary: not every element sits under a stamped
38
+ * host. The second means "fix at file:line", the single most-cited reason to reach for Reticle over
39
+ * a generic DOM tool, is silently unavailable for the whole session — and it has been discovered by
40
+ * reading the adapter's source rather than from anything Reticle said.
41
+ *
42
+ * One `querySelector`, which stops at the first hit, so this is cheap enough to answer on a
43
+ * single-element path like inspect. It is only consulted when a source is already missing.
44
+ */
45
+ export declare function documentHasSourceStamps(doc: Document): boolean;
32
46
  /**
33
47
  * `file:line` — the form an agent can act on directly.
34
48
  *