@reticlehq/browser 2.2.0 → 2.3.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 (269) hide show
  1. package/dist/actions/actions-dom.d.ts +0 -1
  2. package/dist/actions/actions-dom.js +0 -1
  3. package/dist/actions/actions.d.ts +3 -1
  4. package/dist/actions/actions.js +55 -36
  5. package/dist/actions/scroll.d.ts +0 -1
  6. package/dist/actions/scroll.js +0 -1
  7. package/dist/actions/value-input.d.ts +33 -0
  8. package/dist/actions/value-input.js +63 -0
  9. package/dist/commands/commands.d.ts +0 -1
  10. package/dist/commands/commands.js +15 -7
  11. package/dist/dom/a11y.d.ts +0 -2
  12. package/dist/dom/a11y.js +54 -24
  13. package/dist/dom/auto-anchor.d.ts +0 -1
  14. package/dist/dom/auto-anchor.js +0 -1
  15. package/dist/dom/blind-spots.d.ts +0 -1
  16. package/dist/dom/blind-spots.js +1 -2
  17. package/dist/dom/chart.d.ts +0 -1
  18. package/dist/dom/chart.js +0 -1
  19. package/dist/dom/desktop-capture.d.ts +36 -0
  20. package/dist/dom/desktop-capture.js +113 -0
  21. package/dist/dom/dom-ignore.d.ts +7 -1
  22. package/dist/dom/dom-ignore.js +1 -2
  23. package/dist/dom/occlusion.d.ts +0 -1
  24. package/dist/dom/occlusion.js +0 -1
  25. package/dist/dom/query.d.ts +0 -1
  26. package/dist/dom/query.js +61 -13
  27. package/dist/dom/realm.d.ts +30 -0
  28. package/dist/dom/realm.js +58 -0
  29. package/dist/dom/refs.d.ts +0 -1
  30. package/dist/dom/refs.js +0 -1
  31. package/dist/dom/shadow-registry.d.ts +40 -0
  32. package/dist/dom/shadow-registry.js +165 -0
  33. package/dist/dom/snapshot.d.ts +2 -2
  34. package/dist/dom/snapshot.js +21 -4
  35. package/dist/dom/source.d.ts +0 -1
  36. package/dist/dom/source.js +0 -1
  37. package/dist/dom/theme.d.ts +0 -1
  38. package/dist/dom/theme.js +0 -1
  39. package/dist/dom/virtualized.d.ts +19 -0
  40. package/dist/dom/virtualized.js +80 -0
  41. package/dist/index.d.ts +0 -1
  42. package/dist/index.js +0 -1
  43. package/dist/observers/animation.d.ts +0 -1
  44. package/dist/observers/animation.js +0 -1
  45. package/dist/observers/blind-spots.d.ts +16 -4
  46. package/dist/observers/blind-spots.js +65 -11
  47. package/dist/observers/console.d.ts +0 -1
  48. package/dist/observers/console.js +0 -1
  49. package/dist/observers/dom.d.ts +0 -2
  50. package/dist/observers/dom.js +67 -9
  51. package/dist/observers/download.d.ts +6 -0
  52. package/dist/observers/download.js +103 -0
  53. package/dist/observers/focus.d.ts +0 -1
  54. package/dist/observers/focus.js +0 -1
  55. package/dist/observers/frames.d.ts +12 -0
  56. package/dist/observers/frames.js +67 -0
  57. package/dist/observers/health.d.ts +0 -1
  58. package/dist/observers/health.js +30 -2
  59. package/dist/observers/install-all.d.ts +5 -0
  60. package/dist/observers/install-all.js +63 -0
  61. package/dist/observers/ipc.d.ts +22 -0
  62. package/dist/observers/ipc.js +121 -0
  63. package/dist/observers/network-body.d.ts +17 -1
  64. package/dist/observers/network-body.js +26 -1
  65. package/dist/observers/network-redact.d.ts +0 -1
  66. package/dist/observers/network-redact.js +6 -3
  67. package/dist/observers/network-stream.d.ts +23 -0
  68. package/dist/observers/network-stream.js +102 -0
  69. package/dist/observers/network.d.ts +11 -1
  70. package/dist/observers/network.js +7 -1
  71. package/dist/observers/perf.d.ts +0 -1
  72. package/dist/observers/perf.js +0 -1
  73. package/dist/observers/route.d.ts +0 -1
  74. package/dist/observers/route.js +0 -1
  75. package/dist/observers/scroll.d.ts +0 -1
  76. package/dist/observers/scroll.js +0 -1
  77. package/dist/observers/sdk-failure.d.ts +28 -0
  78. package/dist/observers/sdk-failure.js +60 -0
  79. package/dist/observers/state.d.ts +0 -1
  80. package/dist/observers/state.js +0 -1
  81. package/dist/observers/storage.d.ts +0 -1
  82. package/dist/observers/storage.js +0 -1
  83. package/dist/observers/types.d.ts +0 -1
  84. package/dist/observers/types.js +0 -1
  85. package/dist/patching/capture-method.d.ts +0 -1
  86. package/dist/patching/capture-method.js +0 -1
  87. package/dist/presenter/overlay.d.ts +0 -1
  88. package/dist/presenter/overlay.js +0 -1
  89. package/dist/presenter/presenter-config.d.ts +0 -1
  90. package/dist/presenter/presenter-config.js +0 -1
  91. package/dist/presenter/presenter-controls.d.ts +0 -1
  92. package/dist/presenter/presenter-controls.js +0 -1
  93. package/dist/presenter/presenter-effects.d.ts +0 -1
  94. package/dist/presenter/presenter-effects.js +0 -1
  95. package/dist/presenter/presenter-glow.d.ts +0 -1
  96. package/dist/presenter/presenter-glow.js +0 -1
  97. package/dist/presenter/presenter-log.d.ts +0 -1
  98. package/dist/presenter/presenter-log.js +0 -1
  99. package/dist/presenter/presenter-run-state.d.ts +0 -1
  100. package/dist/presenter/presenter-run-state.js +0 -1
  101. package/dist/presenter/presenter-styles.d.ts +0 -1
  102. package/dist/presenter/presenter-styles.js +0 -1
  103. package/dist/presenter/presenter-tally.d.ts +0 -1
  104. package/dist/presenter/presenter-tally.js +0 -1
  105. package/dist/presenter/presenter-test-helpers.d.ts +0 -1
  106. package/dist/presenter/presenter-test-helpers.js +0 -1
  107. package/dist/presenter/presenter-verbs.d.ts +0 -1
  108. package/dist/presenter/presenter-verbs.js +0 -1
  109. package/dist/presenter/presenter.d.ts +0 -1
  110. package/dist/presenter/presenter.js +0 -1
  111. package/dist/recorder/recorder-styles.d.ts +0 -1
  112. package/dist/recorder/recorder-styles.js +0 -1
  113. package/dist/recorder/recorder.d.ts +0 -1
  114. package/dist/recorder/recorder.js +0 -1
  115. package/dist/registry/adapters.d.ts +0 -1
  116. package/dist/registry/adapters.js +0 -1
  117. package/dist/registry/capabilities.d.ts +0 -1
  118. package/dist/registry/capabilities.js +0 -1
  119. package/dist/registry/commit-and-signal.d.ts +0 -1
  120. package/dist/registry/commit-and-signal.js +0 -1
  121. package/dist/registry/domains.d.ts +0 -1
  122. package/dist/registry/domains.js +0 -1
  123. package/dist/registry/emitter.d.ts +0 -1
  124. package/dist/registry/emitter.js +0 -1
  125. package/dist/registry/store-adapters.d.ts +14 -6
  126. package/dist/registry/store-adapters.js +0 -1
  127. package/dist/registry/stores.d.ts +0 -1
  128. package/dist/registry/stores.js +10 -2
  129. package/dist/reticle-presenter-helpers.d.ts +0 -1
  130. package/dist/reticle-presenter-helpers.js +0 -1
  131. package/dist/reticle.d.ts +3 -2
  132. package/dist/reticle.js +12 -32
  133. package/dist/review/annotator.d.ts +0 -1
  134. package/dist/review/annotator.js +0 -1
  135. package/dist/review/mark-anchor.d.ts +0 -1
  136. package/dist/review/mark-anchor.js +0 -1
  137. package/dist/security/serialization.d.ts +0 -1
  138. package/dist/security/serialization.js +0 -1
  139. package/dist/timers/clock.d.ts +0 -1
  140. package/dist/timers/clock.js +0 -1
  141. package/dist/timers/native-console.d.ts +1 -0
  142. package/dist/timers/native-console.js +9 -0
  143. package/dist/timers/native-timers.d.ts +0 -1
  144. package/dist/timers/native-timers.js +0 -1
  145. package/dist/transport/transport.d.ts +2 -6
  146. package/dist/transport/transport.js +95 -18
  147. package/package.json +8 -3
  148. package/dist/actions/actions-dom.d.ts.map +0 -1
  149. package/dist/actions/actions-dom.js.map +0 -1
  150. package/dist/actions/actions.d.ts.map +0 -1
  151. package/dist/actions/actions.js.map +0 -1
  152. package/dist/actions/scroll.d.ts.map +0 -1
  153. package/dist/actions/scroll.js.map +0 -1
  154. package/dist/commands/commands.d.ts.map +0 -1
  155. package/dist/commands/commands.js.map +0 -1
  156. package/dist/dom/a11y.d.ts.map +0 -1
  157. package/dist/dom/a11y.js.map +0 -1
  158. package/dist/dom/auto-anchor.d.ts.map +0 -1
  159. package/dist/dom/auto-anchor.js.map +0 -1
  160. package/dist/dom/blind-spots.d.ts.map +0 -1
  161. package/dist/dom/blind-spots.js.map +0 -1
  162. package/dist/dom/chart.d.ts.map +0 -1
  163. package/dist/dom/chart.js.map +0 -1
  164. package/dist/dom/dom-ignore.d.ts.map +0 -1
  165. package/dist/dom/dom-ignore.js.map +0 -1
  166. package/dist/dom/occlusion.d.ts.map +0 -1
  167. package/dist/dom/occlusion.js.map +0 -1
  168. package/dist/dom/query.d.ts.map +0 -1
  169. package/dist/dom/query.js.map +0 -1
  170. package/dist/dom/refs.d.ts.map +0 -1
  171. package/dist/dom/refs.js.map +0 -1
  172. package/dist/dom/snapshot.d.ts.map +0 -1
  173. package/dist/dom/snapshot.js.map +0 -1
  174. package/dist/dom/source.d.ts.map +0 -1
  175. package/dist/dom/source.js.map +0 -1
  176. package/dist/dom/theme.d.ts.map +0 -1
  177. package/dist/dom/theme.js.map +0 -1
  178. package/dist/index.d.ts.map +0 -1
  179. package/dist/index.js.map +0 -1
  180. package/dist/observers/animation.d.ts.map +0 -1
  181. package/dist/observers/animation.js.map +0 -1
  182. package/dist/observers/blind-spots.d.ts.map +0 -1
  183. package/dist/observers/blind-spots.js.map +0 -1
  184. package/dist/observers/console.d.ts.map +0 -1
  185. package/dist/observers/console.js.map +0 -1
  186. package/dist/observers/dom.d.ts.map +0 -1
  187. package/dist/observers/dom.js.map +0 -1
  188. package/dist/observers/focus.d.ts.map +0 -1
  189. package/dist/observers/focus.js.map +0 -1
  190. package/dist/observers/health.d.ts.map +0 -1
  191. package/dist/observers/health.js.map +0 -1
  192. package/dist/observers/network-body.d.ts.map +0 -1
  193. package/dist/observers/network-body.js.map +0 -1
  194. package/dist/observers/network-redact.d.ts.map +0 -1
  195. package/dist/observers/network-redact.js.map +0 -1
  196. package/dist/observers/network.d.ts.map +0 -1
  197. package/dist/observers/network.js.map +0 -1
  198. package/dist/observers/perf.d.ts.map +0 -1
  199. package/dist/observers/perf.js.map +0 -1
  200. package/dist/observers/route.d.ts.map +0 -1
  201. package/dist/observers/route.js.map +0 -1
  202. package/dist/observers/scroll.d.ts.map +0 -1
  203. package/dist/observers/scroll.js.map +0 -1
  204. package/dist/observers/state.d.ts.map +0 -1
  205. package/dist/observers/state.js.map +0 -1
  206. package/dist/observers/storage.d.ts.map +0 -1
  207. package/dist/observers/storage.js.map +0 -1
  208. package/dist/observers/types.d.ts.map +0 -1
  209. package/dist/observers/types.js.map +0 -1
  210. package/dist/patching/capture-method.d.ts.map +0 -1
  211. package/dist/patching/capture-method.js.map +0 -1
  212. package/dist/presenter/overlay.d.ts.map +0 -1
  213. package/dist/presenter/overlay.js.map +0 -1
  214. package/dist/presenter/presenter-config.d.ts.map +0 -1
  215. package/dist/presenter/presenter-config.js.map +0 -1
  216. package/dist/presenter/presenter-controls.d.ts.map +0 -1
  217. package/dist/presenter/presenter-controls.js.map +0 -1
  218. package/dist/presenter/presenter-effects.d.ts.map +0 -1
  219. package/dist/presenter/presenter-effects.js.map +0 -1
  220. package/dist/presenter/presenter-glow.d.ts.map +0 -1
  221. package/dist/presenter/presenter-glow.js.map +0 -1
  222. package/dist/presenter/presenter-log.d.ts.map +0 -1
  223. package/dist/presenter/presenter-log.js.map +0 -1
  224. package/dist/presenter/presenter-run-state.d.ts.map +0 -1
  225. package/dist/presenter/presenter-run-state.js.map +0 -1
  226. package/dist/presenter/presenter-styles.d.ts.map +0 -1
  227. package/dist/presenter/presenter-styles.js.map +0 -1
  228. package/dist/presenter/presenter-tally.d.ts.map +0 -1
  229. package/dist/presenter/presenter-tally.js.map +0 -1
  230. package/dist/presenter/presenter-test-helpers.d.ts.map +0 -1
  231. package/dist/presenter/presenter-test-helpers.js.map +0 -1
  232. package/dist/presenter/presenter-verbs.d.ts.map +0 -1
  233. package/dist/presenter/presenter-verbs.js.map +0 -1
  234. package/dist/presenter/presenter.d.ts.map +0 -1
  235. package/dist/presenter/presenter.js.map +0 -1
  236. package/dist/recorder/recorder-styles.d.ts.map +0 -1
  237. package/dist/recorder/recorder-styles.js.map +0 -1
  238. package/dist/recorder/recorder.d.ts.map +0 -1
  239. package/dist/recorder/recorder.js.map +0 -1
  240. package/dist/registry/adapters.d.ts.map +0 -1
  241. package/dist/registry/adapters.js.map +0 -1
  242. package/dist/registry/capabilities.d.ts.map +0 -1
  243. package/dist/registry/capabilities.js.map +0 -1
  244. package/dist/registry/commit-and-signal.d.ts.map +0 -1
  245. package/dist/registry/commit-and-signal.js.map +0 -1
  246. package/dist/registry/domains.d.ts.map +0 -1
  247. package/dist/registry/domains.js.map +0 -1
  248. package/dist/registry/emitter.d.ts.map +0 -1
  249. package/dist/registry/emitter.js.map +0 -1
  250. package/dist/registry/store-adapters.d.ts.map +0 -1
  251. package/dist/registry/store-adapters.js.map +0 -1
  252. package/dist/registry/stores.d.ts.map +0 -1
  253. package/dist/registry/stores.js.map +0 -1
  254. package/dist/reticle-presenter-helpers.d.ts.map +0 -1
  255. package/dist/reticle-presenter-helpers.js.map +0 -1
  256. package/dist/reticle.d.ts.map +0 -1
  257. package/dist/reticle.js.map +0 -1
  258. package/dist/review/annotator.d.ts.map +0 -1
  259. package/dist/review/annotator.js.map +0 -1
  260. package/dist/review/mark-anchor.d.ts.map +0 -1
  261. package/dist/review/mark-anchor.js.map +0 -1
  262. package/dist/security/serialization.d.ts.map +0 -1
  263. package/dist/security/serialization.js.map +0 -1
  264. package/dist/timers/clock.d.ts.map +0 -1
  265. package/dist/timers/clock.js.map +0 -1
  266. package/dist/timers/native-timers.d.ts.map +0 -1
  267. package/dist/timers/native-timers.js.map +0 -1
  268. package/dist/transport/transport.d.ts.map +0 -1
  269. package/dist/transport/transport.js.map +0 -1
@@ -1,4 +1,6 @@
1
1
  import { ElementState, SnapshotMode } from '@reticlehq/core';
2
+ import { capturedRootOf } from './shadow-registry.js';
3
+ import { isFrame } from './realm.js';
2
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
3
5
  import { refs } from './refs.js';
4
6
  import { isIgnored } from './dom-ignore.js';
@@ -93,10 +95,16 @@ function layoutSignature(style) {
93
95
  */
94
96
  function pierceChildren(parent) {
95
97
  const out = [...parent.children];
96
- const shadow = parent.shadowRoot;
98
+ // A CLOSED root reports `shadowRoot === null` forever, but the registry captured it at the moment
99
+ // `attachShadow` returned it. `query` has been able to reach that content since; the snapshot could
100
+ // not, so the two tools disagreed about what is on the page — and the snapshot is the one an agent
101
+ // reads to decide what to query for, which makes its omission the one that actually costs.
102
+ const shadow = parent.shadowRoot ?? capturedRootOf(parent);
97
103
  if (shadow !== null)
98
104
  out.push(...shadow.children);
99
- if (parent instanceof HTMLIFrameElement) {
105
+ // Realm-aware: an iframe nested INSIDE another frame's body belongs to that frame's realm, so the
106
+ // top window's constructor never matches it and nested embeds were silently never pierced.
107
+ if (isFrame(parent)) {
100
108
  try {
101
109
  const body = parent.contentDocument?.body;
102
110
  if (body !== null && body !== undefined)
@@ -156,11 +164,21 @@ function collectDialogs(root) {
156
164
  }
157
165
  return names;
158
166
  }
167
+ /**
168
+ * Status for a snapshot, with uninformative defaults OMITTED.
169
+ *
170
+ * `reticle_act` already follows this rule — "fields at their uninformative default are omitted so a
171
+ * clean action collapses to its consequence" — and snapshot did not, so every response carried
172
+ * `visibleDialogs: []` whether or not a dialog existed. An empty array is the overwhelmingly common
173
+ * case, so the field was almost pure repetition; a reader learns nothing from its presence and
174
+ * everything from it.
175
+ */
159
176
  function buildStatus(root) {
177
+ const visibleDialogs = collectDialogs(root);
160
178
  return {
161
179
  route: `${location.pathname}${location.search}${location.hash}`,
162
180
  title: document.title,
163
- visibleDialogs: collectDialogs(root),
181
+ ...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
164
182
  };
165
183
  }
166
184
  /** Build the semantic accessibility snapshot of the page or a subtree. */
@@ -195,4 +213,3 @@ export function buildSnapshot(options = {}) {
195
213
  truncated: ctx.truncated,
196
214
  };
197
215
  }
198
- //# sourceMappingURL=snapshot.js.map
@@ -37,4 +37,3 @@ export declare function sourceFor(el: Element, adapterSource: SourceLocation | u
37
37
  * opening an editor or printing it.
38
38
  */
39
39
  export declare function formatSource(source: SourceLocation | undefined): string | undefined;
40
- //# sourceMappingURL=source.d.ts.map
@@ -56,4 +56,3 @@ export function sourceFor(el, adapterSource) {
56
56
  export function formatSource(source) {
57
57
  return source === undefined ? undefined : `${source.file}:${String(source.line)}`;
58
58
  }
59
- //# sourceMappingURL=source.js.map
@@ -23,4 +23,3 @@ export declare function themeReport(cs: CSSStyleDeclaration): ThemeReport;
23
23
  /** Drop the cached palette (call if the app swaps themes at runtime). */
24
24
  export declare function resetThemeCache(): void;
25
25
  export {};
26
- //# sourceMappingURL=theme.d.ts.map
package/dist/dom/theme.js CHANGED
@@ -87,4 +87,3 @@ export function themeReport(cs) {
87
87
  export function resetThemeCache() {
88
88
  cached = null;
89
89
  }
90
- //# sourceMappingURL=theme.js.map
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Rows a scroll container has reserved space for but has NOT rendered.
3
+ *
4
+ * `BlindSpotKind.VIRTUALIZED_UNMOUNTED` has had a label on the server since it was defined and
5
+ * nothing ever emitted it — a declared blind spot that could not fire, shaped exactly like the most
6
+ * common data-heavy UI there is. Measured on a 10,000-row console: 29 rows in the DOM, and a verdict
7
+ * that reported full coverage. An assertion like "no shipment is held" was a claim about 29 rows,
8
+ * delivered as a claim about all of them.
9
+ *
10
+ * The detector measures EMPTY RESERVED SPACE rather than guessing from child counts. A virtualizer
11
+ * sizes its scroll area for the whole list and renders only a window, so the area above the first
12
+ * child and below the last is space the container has promised and not filled. An ordinary list —
13
+ * even one with large gaps or margins between rows — spans its own scroll height, so it measures
14
+ * zero and never reports.
15
+ */
16
+ /** How many rows a container is holding space for but has not rendered. 0 when it is not virtualized. */
17
+ export declare function unmountedRowsIn(container: HTMLElement): number;
18
+ /** Total unmounted rows across every scroll container on the page. */
19
+ export declare function countUnmountedRows(): number;
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Rows a scroll container has reserved space for but has NOT rendered.
3
+ *
4
+ * `BlindSpotKind.VIRTUALIZED_UNMOUNTED` has had a label on the server since it was defined and
5
+ * nothing ever emitted it — a declared blind spot that could not fire, shaped exactly like the most
6
+ * common data-heavy UI there is. Measured on a 10,000-row console: 29 rows in the DOM, and a verdict
7
+ * that reported full coverage. An assertion like "no shipment is held" was a claim about 29 rows,
8
+ * delivered as a claim about all of them.
9
+ *
10
+ * The detector measures EMPTY RESERVED SPACE rather than guessing from child counts. A virtualizer
11
+ * sizes its scroll area for the whole list and renders only a window, so the area above the first
12
+ * child and below the last is space the container has promised and not filled. An ordinary list —
13
+ * even one with large gaps or margins between rows — spans its own scroll height, so it measures
14
+ * zero and never reports.
15
+ */
16
+ /** Below this, the empty space is padding or a sticky header rather than a virtualized remainder. */
17
+ const UNMOUNTED_RATIO = 0.25;
18
+ /** A container has to be meaningfully scrollable before "what is not rendered" is even a question. */
19
+ const MIN_SCROLL_RATIO = 1.2;
20
+ /**
21
+ * Virtualizers usually put one full-height SPACER inside the scroller and position rows absolutely
22
+ * inside it, so the scroller's own children are just that spacer. Descend through it to reach the
23
+ * rows — without this the spacer measures as filling the container and nothing is ever detected.
24
+ */
25
+ function rowHost(container) {
26
+ let host = container;
27
+ for (let depth = 0; depth < 3; depth += 1) {
28
+ const children = Array.from(host.children).filter((c) => c instanceof HTMLElement);
29
+ const only = children.length === 1 ? children[0] : undefined;
30
+ if (only === undefined)
31
+ return host;
32
+ // A spacer is a single child that fills (or over-fills) the scroll area.
33
+ if (only.offsetHeight < host.scrollHeight * 0.9)
34
+ return host;
35
+ host = only;
36
+ }
37
+ return host;
38
+ }
39
+ /** How many rows a container is holding space for but has not rendered. 0 when it is not virtualized. */
40
+ export function unmountedRowsIn(container) {
41
+ const scrollHeight = container.scrollHeight;
42
+ const clientHeight = container.clientHeight;
43
+ if (clientHeight <= 0 || scrollHeight < clientHeight * MIN_SCROLL_RATIO)
44
+ return 0;
45
+ const host = rowHost(container);
46
+ const rows = Array.from(host.children).filter((c) => c instanceof HTMLElement);
47
+ if (rows.length < 2)
48
+ return 0;
49
+ let top = Infinity;
50
+ let bottom = 0;
51
+ let totalHeight = 0;
52
+ for (const row of rows) {
53
+ const rowTop = row.offsetTop;
54
+ const rowBottom = rowTop + row.offsetHeight;
55
+ if (rowTop < top)
56
+ top = rowTop;
57
+ if (rowBottom > bottom)
58
+ bottom = rowBottom;
59
+ totalHeight += row.offsetHeight;
60
+ }
61
+ // Space the container reserved and left empty: above the first row plus below the last.
62
+ const empty = Math.max(0, top) + Math.max(0, scrollHeight - bottom);
63
+ if (empty < scrollHeight * UNMOUNTED_RATIO)
64
+ return 0;
65
+ const averageRow = totalHeight / rows.length;
66
+ if (averageRow <= 0)
67
+ return 0;
68
+ return Math.round(empty / averageRow);
69
+ }
70
+ /** Total unmounted rows across every scroll container on the page. */
71
+ export function countUnmountedRows() {
72
+ let total = 0;
73
+ for (const element of document.querySelectorAll('*')) {
74
+ // Cheap gate first: only elements that actually scroll are worth measuring.
75
+ if (element.scrollHeight <= element.clientHeight)
76
+ continue;
77
+ total += unmountedRowsIn(element);
78
+ }
79
+ return total;
80
+ }
package/dist/index.d.ts CHANGED
@@ -26,4 +26,3 @@ export { describe, getRole, getAccessibleName, getStates, isVisible } from './do
26
26
  export { refs, RefRegistry } from './dom/refs.js';
27
27
  export { Annotator, installAnnotator, type AnnotatorDeps } from './review/annotator.js';
28
28
  export { resolveMarkAnchor, type MarkAnchor } from './review/mark-anchor.js';
29
- //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -37,4 +37,3 @@ export { describe, getRole, getAccessibleName, getStates, isVisible } from './do
37
37
  export { refs, RefRegistry } from './dom/refs.js';
38
38
  export { Annotator, installAnnotator } from './review/annotator.js';
39
39
  export { resolveMarkAnchor } from './review/mark-anchor.js';
40
- //# sourceMappingURL=index.js.map
@@ -1,4 +1,3 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
2
  /** Observe CSS animations + transitions and emit anim.start / anim.end. */
3
3
  export declare function installAnimation(emit: Emit): Teardown;
4
- //# sourceMappingURL=animation.d.ts.map
@@ -32,4 +32,3 @@ export function installAnimation(emit) {
32
32
  document.removeEventListener('transitionend', onTransitionEnd, true);
33
33
  };
34
34
  }
35
- //# sourceMappingURL=animation.js.map
@@ -3,10 +3,23 @@ import type { Emit, Teardown } from './types.js';
3
3
  * Blind-spot sensor. The SDK instruments the DOM, but a cross-origin iframe is a wall it cannot
4
4
  * see through — its contents, network, and console are invisible. Left unstated, a green verdict would
5
5
  * imply the whole page was verified. This detects cross-origin frames and emits a BLIND_SPOT event so the
6
- * server can mark results `coverage: partial` instead of lying by omission. Closed shadow roots and
7
- * virtualized-unmounted rows are the other kinds (detected elsewhere / by their tools); this sensor owns
8
- * the iframe case, the one that is cheaply and reliably detectable from the page.
6
+ * server can mark results `coverage: partial` instead of lying by omission. This sensor owns the three
7
+ * kinds cheaply and reliably detectable from the page: cross-origin frames, closed shadow roots, and
8
+ * virtualized-unmounted rows.
9
9
  */
10
+ /**
11
+ * Elements rendering content nobody outside them can read.
12
+ *
13
+ * `attachShadow({mode:'closed'})` makes `el.shadowRoot` null FOREVER, for every caller — Reticle,
14
+ * Playwright's accessibility snapshot, a screenshot-reading model, all of them. There is no API that
15
+ * recovers it, so the only honest behaviour is to say the region exists and is unreadable.
16
+ *
17
+ * It is not directly detectable either, so this infers it: a custom element (a dash in the tag name is
18
+ * the spec's own rule) that has NO light-DOM children and no text, yet occupies layout space, is
19
+ * painting from somewhere. Nothing else produces that combination — an empty custom element that
20
+ * renders nothing measures zero and is skipped.
21
+ */
22
+ export declare function countClosedShadowRoots(): number;
10
23
  /**
11
24
  * Whether a frame is cross-origin (unobservable). A same-origin frame exposes its `contentDocument`; a
12
25
  * cross-origin one returns null (or THROWS a SecurityError on access). A src-less / about:blank frame is
@@ -16,4 +29,3 @@ export declare function isCrossOriginFrame(frame: HTMLIFrameElement): boolean;
16
29
  /** How many of the given frames are cross-origin (unobservable). */
17
30
  export declare function countCrossOriginFrames(frames: readonly HTMLIFrameElement[]): number;
18
31
  export declare function installBlindSpots(emit: Emit): Teardown;
19
- //# sourceMappingURL=blind-spots.d.ts.map
@@ -1,13 +1,49 @@
1
1
  import { EventType, BlindSpotKind } from '@reticlehq/core';
2
+ import { countUnmountedRows } from '../dom/virtualized.js';
3
+ import { isCaptured } from '../dom/shadow-registry.js';
4
+ import { sameOriginFrameBodies } from './frames.js';
2
5
  import { nativeClearTimeout, nativeSetTimeout } from '../timers/native-timers.js';
3
6
  /**
4
7
  * Blind-spot sensor. The SDK instruments the DOM, but a cross-origin iframe is a wall it cannot
5
8
  * see through — its contents, network, and console are invisible. Left unstated, a green verdict would
6
9
  * imply the whole page was verified. This detects cross-origin frames and emits a BLIND_SPOT event so the
7
- * server can mark results `coverage: partial` instead of lying by omission. Closed shadow roots and
8
- * virtualized-unmounted rows are the other kinds (detected elsewhere / by their tools); this sensor owns
9
- * the iframe case, the one that is cheaply and reliably detectable from the page.
10
+ * server can mark results `coverage: partial` instead of lying by omission. This sensor owns the three
11
+ * kinds cheaply and reliably detectable from the page: cross-origin frames, closed shadow roots, and
12
+ * virtualized-unmounted rows.
10
13
  */
14
+ /**
15
+ * Elements rendering content nobody outside them can read.
16
+ *
17
+ * `attachShadow({mode:'closed'})` makes `el.shadowRoot` null FOREVER, for every caller — Reticle,
18
+ * Playwright's accessibility snapshot, a screenshot-reading model, all of them. There is no API that
19
+ * recovers it, so the only honest behaviour is to say the region exists and is unreadable.
20
+ *
21
+ * It is not directly detectable either, so this infers it: a custom element (a dash in the tag name is
22
+ * the spec's own rule) that has NO light-DOM children and no text, yet occupies layout space, is
23
+ * painting from somewhere. Nothing else produces that combination — an empty custom element that
24
+ * renders nothing measures zero and is skipped.
25
+ */
26
+ export function countClosedShadowRoots() {
27
+ let count = 0;
28
+ for (const el of document.querySelectorAll('*')) {
29
+ if (!el.tagName.includes('-'))
30
+ continue;
31
+ if (el.shadowRoot !== null)
32
+ continue; // open — readable, not a blind spot
33
+ // A closed root attached AFTER the SDK installed was captured from attachShadow's return value,
34
+ // so it is readable too. Only what was genuinely missed is declared.
35
+ if (isCaptured(el))
36
+ continue;
37
+ if (el.children.length > 0)
38
+ continue;
39
+ if ((el.textContent ?? '').trim().length > 0)
40
+ continue;
41
+ if (el.offsetHeight <= 0 && el.offsetWidth <= 0)
42
+ continue;
43
+ count += 1;
44
+ }
45
+ return count;
46
+ }
11
47
  /**
12
48
  * Whether a frame is cross-origin (unobservable). A same-origin frame exposes its `contentDocument`; a
13
49
  * cross-origin one returns null (or THROWS a SecurityError on access). A src-less / about:blank frame is
@@ -35,6 +71,15 @@ export function countCrossOriginFrames(frames) {
35
71
  function currentCount() {
36
72
  return countCrossOriginFrames(Array.from(document.querySelectorAll('iframe')));
37
73
  }
74
+ /**
75
+ * Same-origin frames — observed for DOM, NOT for network.
76
+ *
77
+ * Their `fetch` lives in the frame's own realm, which the top realm's patch never sees. Left
78
+ * undeclared, `settled` can read true while a frame request is still in flight.
79
+ */
80
+ function uninstrumentedFrameCount() {
81
+ return sameOriginFrameBodies(document).length;
82
+ }
38
83
  /**
39
84
  * Watch the page for cross-origin iframes and emit BLIND_SPOT whenever the count CHANGES (install +
40
85
  * on any added/removed frame). Only the delta is emitted — never a per-mutation flood — so a static
@@ -48,14 +93,24 @@ function currentCount() {
48
93
  */
49
94
  const RECHECK_DEBOUNCE_MS = 250;
50
95
  export function installBlindSpots(emit) {
51
- let last = -1;
96
+ // Every kind is counted the same way and emitted on CHANGE only, so a static embed or a steady list
97
+ // reports once instead of flooding. Virtualized rows matter most: without them an assertion over a
98
+ // 10,000-row grid is a claim about the ~29 on screen, delivered as full coverage.
99
+ const sensors = [
100
+ { kind: BlindSpotKind.CROSS_ORIGIN_IFRAME, count: currentCount },
101
+ { kind: BlindSpotKind.UNINSTRUMENTED_FRAME, count: uninstrumentedFrameCount },
102
+ { kind: BlindSpotKind.CLOSED_SHADOW_ROOT, count: countClosedShadowRoots },
103
+ { kind: BlindSpotKind.VIRTUALIZED_UNMOUNTED, count: countUnmountedRows },
104
+ ];
105
+ const last = new Map();
52
106
  const report = () => {
53
- const count = currentCount();
54
- if (count === last)
55
- return;
56
- last = count;
57
- if (count > 0) {
58
- emit(EventType.BLIND_SPOT, { kind: BlindSpotKind.CROSS_ORIGIN_IFRAME, count });
107
+ for (const sensor of sensors) {
108
+ const count = sensor.count();
109
+ if (count === last.get(sensor.kind))
110
+ continue;
111
+ last.set(sensor.kind, count);
112
+ if (count > 0)
113
+ emit(EventType.BLIND_SPOT, { kind: sensor.kind, count });
59
114
  }
60
115
  };
61
116
  report();
@@ -86,4 +141,3 @@ export function installBlindSpots(emit) {
86
141
  observer.disconnect();
87
142
  };
88
143
  }
89
- //# sourceMappingURL=blind-spots.js.map
@@ -1,4 +1,3 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
2
  /** Patch console.{log,warn,error} and window error events. Reversible. */
3
3
  export declare function installConsole(emit: Emit): Teardown;
4
- //# sourceMappingURL=console.d.ts.map
@@ -88,4 +88,3 @@ export function installConsole(emit) {
88
88
  window.removeEventListener('unhandledrejection', onRejection);
89
89
  };
90
90
  }
91
- //# sourceMappingURL=console.js.map
@@ -1,4 +1,2 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
- /** Observe DOM mutations and emit semantic (not raw) events. */
3
2
  export declare function installDom(emit: Emit): Teardown;
4
- //# sourceMappingURL=dom.d.ts.map
@@ -8,13 +8,16 @@ import { refs } from '../dom/refs.js';
8
8
  * container persists across ticks, so its testid (or ref) is the identity that does accumulate.
9
9
  */
10
10
  function regionKeyOf(target) {
11
- const el = target instanceof Element ? target : null;
11
+ const el = isElement(target) ? target : null;
12
12
  if (el === null)
13
13
  return undefined;
14
14
  const labelled = el.closest('[data-testid]');
15
15
  return labelled?.getAttribute('data-testid') ?? refs.refFor(el);
16
16
  }
17
17
  import { isReticleOverlay } from '../dom/dom-ignore.js';
18
+ import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
19
+ import { isElement } from '../dom/realm.js';
20
+ import { observeSameOriginFrames } from './frames.js';
18
21
  const WATCHED_ATTRS = [
19
22
  'class',
20
23
  'hidden',
@@ -46,7 +49,30 @@ function isMeaningful(role, name) {
46
49
  return role !== 'generic' || name.length > 0;
47
50
  }
48
51
  /** Observe DOM mutations and emit semantic (not raw) events. */
52
+ /**
53
+ * The trimmed text carried by a node list, or undefined when it carries none.
54
+ *
55
+ * Whitespace-only nodes are ignored deliberately: a re-render that reshuffles indentation would
56
+ * otherwise emit a text change on every layout node, which is noise the timeline cannot afford.
57
+ */
58
+ const TEXT_NODE = 3;
59
+ function textOf(nodes) {
60
+ let out = '';
61
+ for (const node of nodes) {
62
+ // The numeric nodeType, not `Node.TEXT_NODE`: the global `Node` constructor is not guaranteed to
63
+ // exist wherever this runs, and referencing it threw a ReferenceError rather than degrading.
64
+ if (node.nodeType !== TEXT_NODE)
65
+ continue;
66
+ out += node.nodeValue ?? '';
67
+ }
68
+ const trimmed = out.trim();
69
+ return trimmed.length > 0 ? trimmed : undefined;
70
+ }
49
71
  export function installDom(emit) {
72
+ // Installed here rather than in the bootstrap list because this observer is the registry's reason
73
+ // for existing, and the ordering matters: a root attached between the two would be missed by both.
74
+ // It is also the only chance to capture a CLOSED root, which attachShadow returns exactly once.
75
+ const stopRegistry = installShadowRegistry();
50
76
  const observer = new MutationObserver((records) => {
51
77
  let added = 0;
52
78
  let removed = 0;
@@ -58,9 +84,7 @@ export function installDom(emit) {
58
84
  for (const record of records) {
59
85
  if (record.type === 'attributes') {
60
86
  const target = record.target;
61
- if (target instanceof Element &&
62
- record.attributeName !== null &&
63
- !isReticleOverlay(target)) {
87
+ if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
64
88
  if (changed >= MAX_PER_BATCH) {
65
89
  dropped += 1;
66
90
  continue;
@@ -93,8 +117,31 @@ export function installDom(emit) {
93
117
  }
94
118
  continue;
95
119
  }
120
+ // A TEXT node arriving or leaving is a text change, and it is the ordinary one. `characterData`
121
+ // fires only when an EXISTING text node's `data` is edited in place; `el.textContent = x`,
122
+ // `innerText`, and React replacing a child all REPLACE the node instead — a childList mutation
123
+ // carrying a Text node, which the Element-only loops below skip. Measured on a live page:
124
+ // `firstChild.data = x` emitted `dom.text`, while `textContent`, `innerText` and
125
+ // `appendChild(createTextNode())` emitted NOTHING. So the most common visible change an app
126
+ // makes — updating text — produced no event at all, `reticle_observe` reported "nothing
127
+ // happened" over a screen that had visibly changed, and `settled` went quiet immediately.
128
+ if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
129
+ const parent = isElement(record.target) ? record.target : null;
130
+ if (parent !== null && !isReticleOverlay(parent)) {
131
+ if (changed >= MAX_PER_BATCH)
132
+ dropped += 1;
133
+ else {
134
+ changed += 1;
135
+ // The element's text AFTER the mutation, so a cleared node reports '' rather than the
136
+ // text that just left — the reader wants what is on screen now.
137
+ const text = (parent.textContent ?? '').trim().slice(0, 80);
138
+ const old = capValue(textOf(record.removedNodes) ?? null);
139
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
140
+ }
141
+ }
142
+ }
96
143
  for (const node of record.addedNodes) {
97
- if (!(node instanceof Element))
144
+ if (!isElement(node))
98
145
  continue;
99
146
  if (added >= MAX_PER_BATCH) {
100
147
  dropped += 1;
@@ -117,7 +164,7 @@ export function installDom(emit) {
117
164
  }
118
165
  }
119
166
  for (const node of record.removedNodes) {
120
- if (!(node instanceof Element))
167
+ if (!isElement(node))
121
168
  continue;
122
169
  if (removed >= MAX_PER_BATCH) {
123
170
  dropped += 1;
@@ -139,7 +186,7 @@ export function installDom(emit) {
139
186
  if (dropped > 0)
140
187
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
141
188
  });
142
- observer.observe(document.documentElement, {
189
+ const options = {
143
190
  subtree: true,
144
191
  childList: true,
145
192
  attributes: true,
@@ -147,9 +194,20 @@ export function installDom(emit) {
147
194
  attributeOldValue: true,
148
195
  characterData: true,
149
196
  characterDataOldValue: true,
150
- });
197
+ };
198
+ observer.observe(document.documentElement, options);
199
+ // A MutationObserver does NOT cross a shadow boundary, so every root needs its own observation.
200
+ // Without this, a click that changed text inside a web component produced an empty observe window —
201
+ // the same shape as an app that ignored the click entirely.
202
+ for (const root of capturedRoots())
203
+ observer.observe(root, options);
204
+ const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
205
+ // Same-origin frame documents are separate node trees for the same reason, with the same symptom.
206
+ const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
151
207
  return () => {
208
+ stopFrames();
209
+ unsubscribe();
210
+ stopRegistry();
152
211
  observer.disconnect();
153
212
  };
154
213
  }
155
- //# sourceMappingURL=dom.js.map
@@ -0,0 +1,6 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export interface DownloadOptions {
3
+ /** Include a content preview. Gated for the same reason request bodies are: exports hold PII. */
4
+ capturePreview?: boolean;
5
+ }
6
+ export declare function installDownload(emit: Emit, opts?: DownloadOptions): Teardown;
@@ -0,0 +1,103 @@
1
+ import { EventType } from '@reticlehq/core';
2
+ import { captureMethod } from '../patching/capture-method.js';
3
+ import { projectBody } from './network-body.js';
4
+ /**
5
+ * Files the app PRODUCES — the one artifact class an outside-the-browser tool cannot inspect.
6
+ *
7
+ * A generated export never crosses the network: the app builds a Blob, hands it to
8
+ * `URL.createObjectURL`, and clicks an anchor. There is no request to intercept, and Playwright MCP
9
+ * has no tool that returns a downloaded file at all. Reticle runs inside the page, where the Blob is
10
+ * constructed, so the export is observable before it reaches the disk.
11
+ *
12
+ * Measured on a real merchant dashboard: one Export click produced a CSV wrong three ways at once —
13
+ * 25 data rows in a file named `transactions-128-rows.csv`, an unquoted `3 Aug, 10:00 pm` that
14
+ * shifts every column after it, and an `amount_inr` header over rows denominated in USD — while the
15
+ * click itself reported a clean success and every other channel agreed. The benchmark's ground truth
16
+ * records those three as missed by BOTH toolchains, for the same reason: nobody opened the file.
17
+ */
18
+ /** Text-like types worth counting lines in and previewing. Anything else reports type + size only. */
19
+ const TEXTUAL = /text\/|json|csv|xml|ndjson|javascript/i;
20
+ /** Lines are reported for text files; the preview is capped and redacted by projectBody. */
21
+ const PREVIEW_CHARS = 2000;
22
+ export function installDownload(emit, opts = {}) {
23
+ // jsdom does not implement `createObjectURL`, and plenty of users run this SDK under jsdom in their
24
+ // own test suite. Reaching for `.bind` on an absent function threw during `connect()` — an
25
+ // observability layer taking the host app down with it, which is the one failure mode it may never
26
+ // have. No object URLs means no generated files to observe, so this simply stands down.
27
+ if (typeof URL.createObjectURL !== 'function')
28
+ return () => undefined;
29
+ const objectUrl = URL.createObjectURL.bind(URL);
30
+ const known = new Map();
31
+ /**
32
+ * `createObjectURL` is where the file becomes real. The filename is NOT here — it lives on the
33
+ * anchor that gets clicked — so the blob is remembered against its URL and reported when either
34
+ * the anchor fires or, failing that, on a microtask so a download that is never clicked (a
35
+ * generated preview, an <img> src) is still visible.
36
+ */
37
+ const originalCreate = captureMethod(URL, 'createObjectURL');
38
+ URL.createObjectURL = function reticleObservedObjectUrl(obj) {
39
+ const url = objectUrl(obj);
40
+ if (typeof Blob !== 'undefined' && obj instanceof Blob) {
41
+ // Started here, awaited at report time — never blocking the app's own download path.
42
+ const text = TEXTUAL.test(obj.type)
43
+ ? obj.text().catch(() => undefined)
44
+ : Promise.resolve(undefined);
45
+ known.set(url, { type: obj.type, size: obj.size, text });
46
+ }
47
+ return url;
48
+ };
49
+ const report = (url, filename) => {
50
+ const record = known.get(url);
51
+ if (record === undefined)
52
+ return;
53
+ known.delete(url);
54
+ void record.text.then((text) => {
55
+ emitDownload(record, filename, text);
56
+ });
57
+ };
58
+ const emitDownload = (record, filename, text) => {
59
+ const data = { mimeType: record.type, bytes: record.size };
60
+ if (filename !== undefined && filename.length > 0)
61
+ data['filename'] = filename;
62
+ if (text !== undefined) {
63
+ // The LINE COUNT is reported even without preview capture: it is a number, not content, and it
64
+ // is the whole tell for "25 rows in a file the toast calls 128".
65
+ data['lines'] = text.split('\n').filter((l) => l.length > 0).length;
66
+ if (opts.capturePreview === true) {
67
+ const { body, truncated } = projectBody(text.slice(0, PREVIEW_CHARS), record.type);
68
+ data['preview'] = body;
69
+ if (truncated)
70
+ data['previewTruncated'] = true;
71
+ }
72
+ }
73
+ emit(EventType.DOWNLOAD, data);
74
+ };
75
+ /**
76
+ * An anchor click carries the NAME. Captured at the document level so it sees the click whether the
77
+ * app dispatches it programmatically or a human does, and whether or not the anchor is in the DOM.
78
+ */
79
+ const onClick = (event) => {
80
+ const target = event.target;
81
+ const anchor = target instanceof Element ? target.closest('a[download]') : null;
82
+ if (anchor === null)
83
+ return;
84
+ report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
85
+ };
86
+ document.addEventListener('click', onClick, true);
87
+ // A programmatic `a.click()` on an anchor never inserted into the document dispatches no bubbling
88
+ // event this listener can see, so the anchor path is patched too.
89
+ const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
90
+ HTMLAnchorElement.prototype.click = function reticleObservedAnchorClick() {
91
+ if (this.hasAttribute('download')) {
92
+ report(this.getAttribute('href') ?? '', this.getAttribute('download') ?? undefined);
93
+ }
94
+ originalClick.call(this);
95
+ };
96
+ return () => {
97
+ // A REAL restore, so teardown leaves the page exactly as it was found.
98
+ document.removeEventListener('click', onClick, true);
99
+ URL.createObjectURL = originalCreate;
100
+ HTMLAnchorElement.prototype.click = originalClick;
101
+ known.clear();
102
+ };
103
+ }
@@ -7,4 +7,3 @@ export declare function focusLabel(el: EventTarget | null): string | undefined;
7
7
  * FOCUS_CHANGE {to, from, toBody}. Reversible.
8
8
  */
9
9
  export declare function installFocus(emit: Emit): Teardown;
10
- //# sourceMappingURL=focus.d.ts.map
@@ -46,4 +46,3 @@ export function installFocus(emit) {
46
46
  document.removeEventListener('focusout', onFocusOut);
47
47
  };
48
48
  }
49
- //# sourceMappingURL=focus.js.map
@@ -0,0 +1,12 @@
1
+ import type { Teardown } from './types.js';
2
+ /** Every same-origin frame body at or beneath `root`, in document order. */
3
+ export declare function sameOriginFrameBodies(root: ParentNode): HTMLElement[];
4
+ /**
5
+ * Call `attach` for every same-origin frame body now and after each frame load.
6
+ *
7
+ * A frame that navigates gets a BRAND NEW document, so the old observation is dead and re-attaching on
8
+ * `load` is not optional — without it, observation silently stops the first time an embedded panel
9
+ * routes. Attaching twice to the same node is harmless (MutationObserver de-duplicates), so no
10
+ * bookkeeping is kept.
11
+ */
12
+ export declare function observeSameOriginFrames(attach: (body: HTMLElement) => void): Teardown;