@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
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Desktop screenshots, asked of the runtime that owns the window.
3
+ *
4
+ * A browser tab is captured through CDP. A desktop webview has no CDP endpoint, so the pixels have
5
+ * to come from the shell — and the shell is the only party that can produce them CORRECTLY.
6
+ *
7
+ * Capturing a screen *region* instead was tried and rejected: it photographs the glass, so an app
8
+ * window sitting behind the editor yields a picture of the editor, saved as a visual baseline that a
9
+ * later diff would trust. A screenshot tool that can silently return another window's pixels is
10
+ * worse than one that returns nothing — it manufactures exactly the false green Reticle exists to
11
+ * eliminate. Electron's `webContents.capturePage()` reads the window's own backing store instead:
12
+ * correct while occluded, correct while backgrounded, and needing no screen-recording permission.
13
+ *
14
+ * Both desktop runtimes can do this, each through its own shell API — `webContents.capturePage()` on
15
+ * Electron, and on Tauri the platform's own webview snapshot (`WKWebView.takeSnapshot`, WebView2
16
+ * `CapturePreview`, or WebKitGTK's snapshot). None of them reads the screen, so all stay correct with
17
+ * the window occluded, hidden, or running headless with nothing on screen at all.
18
+ *
19
+ * The app opts in with one line in its main process (`@reticlehq/electron/main`) or one Rust command
20
+ * (`reticle-tauri`). Absent that, this returns `{ ok: false }` and the tool reports no-provider
21
+ * rather than guessing.
22
+ */
23
+ export interface CaptureResult {
24
+ ok: boolean;
25
+ /**
26
+ * Filesystem path of the captured PNG, written by the desktop shell.
27
+ *
28
+ * A path, not the image bytes: the SDK's transport sanitizer caps every string at 64KB, so a
29
+ * base64 PNG came back SILENTLY TRUNCATED and was saved as a "successful" screenshot that no
30
+ * decoder could read. The daemon and the app always share a machine here (the bridge is loopback),
31
+ * so handing over a path keeps the image off the event wire entirely.
32
+ */
33
+ path?: string;
34
+ reason?: string;
35
+ }
36
+ export declare function captureDesktopWindow(fullPage?: boolean): Promise<CaptureResult>;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Desktop screenshots, asked of the runtime that owns the window.
3
+ *
4
+ * A browser tab is captured through CDP. A desktop webview has no CDP endpoint, so the pixels have
5
+ * to come from the shell — and the shell is the only party that can produce them CORRECTLY.
6
+ *
7
+ * Capturing a screen *region* instead was tried and rejected: it photographs the glass, so an app
8
+ * window sitting behind the editor yields a picture of the editor, saved as a visual baseline that a
9
+ * later diff would trust. A screenshot tool that can silently return another window's pixels is
10
+ * worse than one that returns nothing — it manufactures exactly the false green Reticle exists to
11
+ * eliminate. Electron's `webContents.capturePage()` reads the window's own backing store instead:
12
+ * correct while occluded, correct while backgrounded, and needing no screen-recording permission.
13
+ *
14
+ * Both desktop runtimes can do this, each through its own shell API — `webContents.capturePage()` on
15
+ * Electron, and on Tauri the platform's own webview snapshot (`WKWebView.takeSnapshot`, WebView2
16
+ * `CapturePreview`, or WebKitGTK's snapshot). None of them reads the screen, so all stay correct with
17
+ * the window occluded, hidden, or running headless with nothing on screen at all.
18
+ *
19
+ * The app opts in with one line in its main process (`@reticlehq/electron/main`) or one Rust command
20
+ * (`reticle-tauri`). Absent that, this returns `{ ok: false }` and the tool reports no-provider
21
+ * rather than guessing.
22
+ */
23
+ import { RETICLE_IPC_GLOBAL, RETICLE_TAURI_CAPTURE_COMMAND, VisualReason } from '@reticlehq/core';
24
+ import { nativeFrame } from '../timers/native-timers.js';
25
+ import { RETICLE_OVERLAY } from './dom-ignore.js';
26
+ /**
27
+ * What a shell returns when it can photograph the viewport but not the whole scroll height.
28
+ *
29
+ * Only WebKitGTK can render a full document offscreen; `capturePage()` and `takeSnapshot` both give
30
+ * what is composited. Downgrading silently would be the worst option available: the caller asked for
31
+ * the content below the fold, would get an image without it, and would bank it as a baseline that a
32
+ * later diff reports as matching. So the shells say so, and the tool reports the reason.
33
+ */
34
+ const FULL_PAGE_UNSUPPORTED_MARKER = VisualReason.FULL_PAGE_UNSUPPORTED;
35
+ const TAURI_INTERNALS_GLOBAL = '__TAURI_INTERNALS__';
36
+ /**
37
+ * Tauri's capture path, used when no preload-installed channel exists.
38
+ *
39
+ * Electron can install `RETICLE_IPC_GLOBAL` from its preload, which runs before app code. Tauri has
40
+ * no preload stage at all, so requiring the same global would mean every Tauri app hand-writing a
41
+ * frontend shim — a setup step that silently yields "no screenshots" when forgotten. Invoking the
42
+ * command directly removes the JavaScript side entirely: the app registers one Rust command and the
43
+ * SDK finds it. Absent that command the invoke rejects, and this reports no-provider like any other.
44
+ */
45
+ function tauriCapture() {
46
+ const internals = window[TAURI_INTERNALS_GLOBAL];
47
+ if (typeof internals?.invoke !== 'function')
48
+ return undefined;
49
+ const invoke = internals.invoke.bind(internals);
50
+ return async (fullPage) => {
51
+ const path = await invoke(RETICLE_TAURI_CAPTURE_COMMAND, { fullPage: fullPage === true });
52
+ return typeof path === 'string' ? path : null;
53
+ };
54
+ }
55
+ /**
56
+ * Take Reticle's own UI out of the shot, then put it back.
57
+ *
58
+ * The shell photographs the composited window, so anything Reticle mounted is IN the picture — and
59
+ * the presenter panel is the worst possible thing to bank into a visual baseline, because it renders
60
+ * live session state: an event tally, a log tail, a run badge. A baseline captured with it is a
61
+ * baseline of Reticle's own UI as much as the app's, and the next `reticle_visual_diff` reports a
62
+ * change that belongs entirely to the observer. That is measuring the instrument.
63
+ *
64
+ * Two frames, not one: the first lets the style change apply, the second lets the compositor present
65
+ * it — capture before that and the shell photographs the pre-hide window anyway. Both are BOUNDED
66
+ * frames, which is load-bearing rather than defensive: a headless Tauri window is hidden, and a
67
+ * hidden webview never fires `requestAnimationFrame` at all, so a raw double-rAF here deadlocked
68
+ * every capture until the command timed out at 8s. Restoration runs in a `finally`, so a capture
69
+ * that throws still leaves the panel on screen for the human watching.
70
+ */
71
+ async function withReticleUiHidden(capture) {
72
+ // The SAME selector the snapshot layer already uses to keep Reticle's own DOM out of what the
73
+ // agent reads — the presenter panel, the cursor, the glow, and the annotator's "flag a bug"
74
+ // button. One definition: a root that leaked into snapshots once would otherwise leak into
75
+ // baselines the second time someone added one.
76
+ const roots = [...document.querySelectorAll(RETICLE_OVERLAY)];
77
+ const previous = roots.map((el) => el.style.visibility);
78
+ // `visibility`, not `display`: it keeps the element's box, so hiding cannot reflow the app
79
+ // underneath and change the very layout the screenshot is meant to record.
80
+ for (const el of roots)
81
+ el.style.visibility = 'hidden';
82
+ try {
83
+ if (roots.length > 0) {
84
+ await nativeFrame();
85
+ await nativeFrame();
86
+ }
87
+ return await capture();
88
+ }
89
+ finally {
90
+ roots.forEach((el, i) => {
91
+ el.style.visibility = previous[i] ?? '';
92
+ });
93
+ }
94
+ }
95
+ export async function captureDesktopWindow(fullPage = false) {
96
+ const channel = window[RETICLE_IPC_GLOBAL];
97
+ const capture = typeof channel?.capture === 'function' ? channel.capture : tauriCapture();
98
+ if (capture === undefined) {
99
+ return { ok: false, reason: 'no desktop capture helper installed' };
100
+ }
101
+ try {
102
+ const path = await withReticleUiHidden(() => capture(fullPage));
103
+ return typeof path === 'string' && path.length > 0
104
+ ? { ok: true, path }
105
+ : { ok: false, reason: 'capture returned no image' };
106
+ }
107
+ catch (error) {
108
+ const reason = error instanceof Error ? error.message : String(error);
109
+ return reason.includes(FULL_PAGE_UNSUPPORTED_MARKER)
110
+ ? { ok: false, reason: FULL_PAGE_UNSUPPORTED_MARKER }
111
+ : { ok: false, reason };
112
+ }
113
+ }
@@ -1,3 +1,10 @@
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.
6
+ */
7
+ export declare const RETICLE_OVERLAY = "[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]";
1
8
  /** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
2
9
  export declare function setIgnoreSelectors(selectors: string[]): void;
3
10
  /** True if the element is part of Reticle's own presenter overlay. */
@@ -6,4 +13,3 @@ export declare function isReticleOverlay(el: Element): boolean;
6
13
  export declare function isReticleUi(node: Element | null): boolean;
7
14
  /** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
8
15
  export declare function isIgnored(el: Element): boolean;
9
- //# sourceMappingURL=dom-ignore.d.ts.map
@@ -4,7 +4,7 @@
4
4
  * presenter, so omitting its selector here leaked a phantom `<button>Flag a bug</button>` into every
5
5
  * snapshot and streamed its highlight-box mutations/transitions as if the APP had rendered them.
6
6
  */
7
- const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]';
7
+ export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]';
8
8
  /** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
9
9
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
10
10
  let extraIgnore = '';
@@ -33,4 +33,3 @@ export function isIgnored(el) {
33
33
  : `${RETICLE_OVERLAY},${DEV_OVERLAYS}`;
34
34
  return el.closest(sel) !== null;
35
35
  }
36
- //# sourceMappingURL=dom-ignore.js.map
@@ -6,4 +6,3 @@
6
6
  * we never false-positive an occlusion. Reticle's own HUD is never treated as an occluder.
7
7
  */
8
8
  export declare function hitTestOccluder(el: Element, rect: DOMRect): Element | null;
9
- //# sourceMappingURL=occlusion.d.ts.map
@@ -18,4 +18,3 @@ export function hitTestOccluder(el, rect) {
18
18
  const lands = top === el || el.contains(top) || top.contains(el);
19
19
  return lands ? null : top;
20
20
  }
21
- //# sourceMappingURL=occlusion.js.map
@@ -16,4 +16,3 @@ export declare function matchQuery(query: ElementQuery, state?: ElementState, li
16
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;
19
- //# sourceMappingURL=query.d.ts.map
package/dist/dom/query.js CHANGED
@@ -1,6 +1,9 @@
1
1
  import { queryAllByRole, queryAllByText, queryAllByLabelText, queryAllByPlaceholderText, queryAllByTestId, queryAllByAltText, } from '@testing-library/dom';
2
2
  import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
3
+ import { isFrame, isHtmlElement } from './realm.js';
4
+ import { capturedRootOf } from './shadow-registry.js';
3
5
  import { describe, getStates, isVisible } from './a11y.js';
6
+ import { isIgnored } from './dom-ignore.js';
4
7
  import { isSensitiveKey } from '../security/serialization.js';
5
8
  import { getCapabilities } from '../registry/capabilities.js';
6
9
  import { identifyComponent } from '../registry/adapters.js';
@@ -22,11 +25,11 @@ function resolveContainer(scope) {
22
25
  if (scope === undefined)
23
26
  return { container: document.body, scopeMissing: false };
24
27
  const byRef = refs.resolve(scope);
25
- if (byRef instanceof HTMLElement)
28
+ if (isHtmlElement(byRef))
26
29
  return { container: byRef, scopeMissing: false };
27
30
  try {
28
31
  const found = document.querySelector(scope);
29
- if (found instanceof HTMLElement)
32
+ if (isHtmlElement(found))
30
33
  return { container: found, scopeMissing: false };
31
34
  }
32
35
  catch {
@@ -138,18 +141,56 @@ function findIn(container, query) {
138
141
  * A closed root is deliberately unreachable — `element.shadowRoot` is null by design, and the SDK
139
142
  * reports that as a blind spot rather than pretending to see through it.
140
143
  */
141
- function openShadowRootsUnder(root) {
144
+ /**
145
+ * A same-origin `<iframe>`'s body, or null when the document is unreachable.
146
+ *
147
+ * Cross-origin access THROWS in some engines and yields null in others, so both are handled — and
148
+ * both mean the same thing here: not ours to read. That case is not a silent skip, it is reported
149
+ * separately as a declared blind spot.
150
+ */
151
+ function readableFrameBody(frame) {
152
+ try {
153
+ return frame.contentDocument?.body ?? null;
154
+ }
155
+ catch {
156
+ return null;
157
+ }
158
+ }
159
+ /** How deep to follow frames-inside-frames before giving up. Real consoles nest one, maybe two. */
160
+ const FRAME_DEPTH_MAX = 3;
161
+ /**
162
+ * Every root beneath the scope that the page can read but `querySelectorAll` will not reach:
163
+ * open shadow roots, and the documents of SAME-ORIGIN iframes.
164
+ *
165
+ * The iframe half was measured missing: a same-origin frame whose text the page could read straight
166
+ * off `contentDocument` returned zero matches from `query`, and coverage said nothing about it — the
167
+ * worst combination, an absent-element answer for content that is right there. Cross-origin frames
168
+ * are a genuinely different case and stay unread; they are declared instead.
169
+ */
170
+ function embeddedRootsUnder(root) {
142
171
  const found = [];
143
- const walk = (node) => {
172
+ const walk = (node, depth) => {
144
173
  for (const el of node.querySelectorAll('*')) {
145
- const shadow = el.shadowRoot;
174
+ const shadow = el.shadowRoot ?? capturedRootOf(el);
146
175
  if (shadow !== null) {
176
+ // A ShadowRoot is a DocumentFragment; the Testing-Library queries accept any HTMLElement-like
177
+ // container, and every call site only reads from it.
147
178
  found.push(shadow);
148
- walk(shadow); // nested web components
179
+ walk(shadow, depth); // nested web components
180
+ continue;
149
181
  }
182
+ if (depth >= FRAME_DEPTH_MAX)
183
+ continue;
184
+ if (!isFrame(el))
185
+ continue;
186
+ const body = readableFrameBody(el);
187
+ if (body === null)
188
+ continue; // cross-origin — declared, not searched
189
+ found.push(body);
190
+ walk(body, depth + 1);
150
191
  }
151
192
  };
152
- walk(root);
193
+ walk(root, 0);
153
194
  return found;
154
195
  }
155
196
  /**
@@ -185,16 +226,22 @@ function findCandidates(query) {
185
226
  for (const el of els) {
186
227
  if (seen.has(el))
187
228
  continue; // reachable from both host and root — count once
229
+ // Reticle's OWN UI is not part of the app under test. `snapshot` has always excluded it; query
230
+ // did not, so the presenter panel and the annotator answered `by: role` like app controls —
231
+ // measured on a real merchant dashboard, 7 of the 40 buttons an agent could see were ours
232
+ // ("Pause", "End", "Minimise the panel", "Export", "Flag a bug for the agent"). Two of those
233
+ // share a NAME with a real control on that page, so an agent resolving "Export" could drive the
234
+ // observer instead of the app and then reason about the result. Filtered here, at the one
235
+ // collection point, so every entry path (by role/text/testid, shadow roots, scoped) is covered.
236
+ if (isIgnored(el))
237
+ continue;
188
238
  seen.add(el);
189
239
  out.push(el);
190
240
  }
191
241
  };
192
242
  collect(findIn(container, query));
193
- for (const shadow of openShadowRootsUnder(container)) {
194
- // A ShadowRoot is a DocumentFragment; the Testing-Library queries accept any HTMLElement-like
195
- // container, and every call site below only reads from it.
196
- collect(findIn(shadow, query));
197
- }
243
+ for (const embedded of embeddedRootsUnder(container))
244
+ collect(findIn(embedded, query));
198
245
  return { candidates: out, scopeMissing };
199
246
  }
200
247
  /** Longest attribute value returned; one enormous href must not blow the response budget. */
@@ -351,6 +398,8 @@ function buildEmptyHint(query) {
351
398
  const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
352
399
  const present = [];
353
400
  for (const el of Array.from(all)) {
401
+ if (isIgnored(el))
402
+ continue; // the "what IS here" hint must not advertise Reticle's own UI either
354
403
  const id = el.getAttribute(TESTID_ATTR);
355
404
  if (id !== null && id.length > 0 && !present.includes(id)) {
356
405
  present.push(id);
@@ -388,4 +437,3 @@ export function runQuery(query, limit) {
388
437
  }
389
438
  return { elements: result.elements, count: result.count, ...scopeFields };
390
439
  }
391
- //# sourceMappingURL=query.js.map
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Cross-realm type tests.
3
+ *
4
+ * `instanceof` compares against ONE realm's constructor. An element inside a same-origin `<iframe>`
5
+ * lives in that document's realm, so `frameButton instanceof HTMLElement` — evaluated with the top
6
+ * window's `HTMLElement` — is FALSE for a perfectly ordinary button.
7
+ *
8
+ * That is not theoretical. Once `query` learned to descend into same-origin frames, it returned the
9
+ * frame's button correctly and `act` then rejected the ref with "is not an HTMLElement": found, and
10
+ * unusable. A find you cannot act on is worse than a miss, because it reads as a product bug in the
11
+ * app rather than in the tool.
12
+ *
13
+ * These resolve the constructor from the NODE's own document instead, so an element is measured
14
+ * against its own realm. Same-realm nodes behave exactly as `instanceof` did.
15
+ */
16
+ export declare const isElement: (n: unknown) => n is Element;
17
+ export declare const isImage: (n: unknown) => n is HTMLImageElement;
18
+ export declare const isHtmlElement: (n: unknown) => n is HTMLElement;
19
+ export declare const isInput: (n: unknown) => n is HTMLInputElement;
20
+ export declare const isTextArea: (n: unknown) => n is HTMLTextAreaElement;
21
+ export declare const isSelect: (n: unknown) => n is HTMLSelectElement;
22
+ export declare const isButton: (n: unknown) => n is HTMLButtonElement;
23
+ export declare const isForm: (n: unknown) => n is HTMLFormElement;
24
+ export declare const isFrame: (n: unknown) => n is HTMLIFrameElement;
25
+ /**
26
+ * The prototype whose native value setter drives `el` — React and friends install their own accessor
27
+ * on the instance, so a value write has to go through the prototype's. It must come from the
28
+ * ELEMENT's realm for the same reason as above.
29
+ */
30
+ export declare function valuePrototypeOf(el: Element): object | undefined;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Cross-realm type tests.
3
+ *
4
+ * `instanceof` compares against ONE realm's constructor. An element inside a same-origin `<iframe>`
5
+ * lives in that document's realm, so `frameButton instanceof HTMLElement` — evaluated with the top
6
+ * window's `HTMLElement` — is FALSE for a perfectly ordinary button.
7
+ *
8
+ * That is not theoretical. Once `query` learned to descend into same-origin frames, it returned the
9
+ * frame's button correctly and `act` then rejected the ref with "is not an HTMLElement": found, and
10
+ * unusable. A find you cannot act on is worse than a miss, because it reads as a product bug in the
11
+ * app rather than in the tool.
12
+ *
13
+ * These resolve the constructor from the NODE's own document instead, so an element is measured
14
+ * against its own realm. Same-realm nodes behave exactly as `instanceof` did.
15
+ */
16
+ /** The constructor named `name` in the realm that owns `node`, or undefined if there isn't one. */
17
+ function ctorIn(node, name) {
18
+ if (node === null || typeof node !== 'object')
19
+ return undefined;
20
+ // A Document owns itself; every other node reports an ownerDocument.
21
+ const doc = node.ownerDocument ?? node;
22
+ const view = doc.defaultView;
23
+ if (view === null || view === undefined)
24
+ return undefined;
25
+ return view[name];
26
+ }
27
+ /**
28
+ * `node instanceof <name>`, evaluated in the node's own realm.
29
+ *
30
+ * Falls back to the ambient constructor when the node has no view — a detached document, or a jsdom
31
+ * fragment — so behaviour outside a browser is unchanged.
32
+ */
33
+ function isIn(node, name, ambient) {
34
+ const ctor = ctorIn(node, name) ?? ambient;
35
+ return typeof ctor === 'function' && node instanceof ctor;
36
+ }
37
+ export const isElement = (n) => isIn(n, 'Element', typeof Element === 'undefined' ? undefined : Element);
38
+ export const isImage = (n) => isIn(n, 'HTMLImageElement', typeof HTMLImageElement === 'undefined' ? undefined : HTMLImageElement);
39
+ export const isHtmlElement = (n) => isIn(n, 'HTMLElement', typeof HTMLElement === 'undefined' ? undefined : HTMLElement);
40
+ export const isInput = (n) => isIn(n, 'HTMLInputElement', typeof HTMLInputElement === 'undefined' ? undefined : HTMLInputElement);
41
+ export const isTextArea = (n) => isIn(n, 'HTMLTextAreaElement', typeof HTMLTextAreaElement === 'undefined' ? undefined : HTMLTextAreaElement);
42
+ export const isSelect = (n) => isIn(n, 'HTMLSelectElement', typeof HTMLSelectElement === 'undefined' ? undefined : HTMLSelectElement);
43
+ export const isButton = (n) => isIn(n, 'HTMLButtonElement', typeof HTMLButtonElement === 'undefined' ? undefined : HTMLButtonElement);
44
+ export const isForm = (n) => isIn(n, 'HTMLFormElement', typeof HTMLFormElement === 'undefined' ? undefined : HTMLFormElement);
45
+ export const isFrame = (n) => isIn(n, 'HTMLIFrameElement', typeof HTMLIFrameElement === 'undefined' ? undefined : HTMLIFrameElement);
46
+ /**
47
+ * The prototype whose native value setter drives `el` — React and friends install their own accessor
48
+ * on the instance, so a value write has to go through the prototype's. It must come from the
49
+ * ELEMENT's realm for the same reason as above.
50
+ */
51
+ export function valuePrototypeOf(el) {
52
+ const name = isTextArea(el) ? 'HTMLTextAreaElement' : 'HTMLInputElement';
53
+ const ctor = ctorIn(el, name);
54
+ if (typeof ctor !== 'function')
55
+ return undefined;
56
+ const proto = ctor.prototype;
57
+ return typeof proto === 'object' && proto !== null ? proto : undefined;
58
+ }
@@ -35,4 +35,3 @@ export declare class RefRegistry {
35
35
  }
36
36
  /** Process-wide registry shared by snapshot, query, and the action executor. */
37
37
  export declare const refs: RefRegistry;
38
- //# sourceMappingURL=refs.d.ts.map
package/dist/dom/refs.js CHANGED
@@ -108,4 +108,3 @@ export class RefRegistry {
108
108
  }
109
109
  /** Process-wide registry shared by snapshot, query, and the action executor. */
110
110
  export const refs = new RefRegistry();
111
- //# sourceMappingURL=refs.js.map
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Every shadow root this SDK has seen created, open or closed.
3
+ *
4
+ * Two problems are solved by the same record.
5
+ *
6
+ * **Observation.** A `MutationObserver` on `documentElement` does NOT cross a shadow boundary, so a
7
+ * click that changes text inside a web component produced an entirely empty observe window. Measured
8
+ * on a console with a shadow-DOM alert badge: the click landed, the badge changed, and `observe`
9
+ * reported zero DOM events — indistinguishable from an app that ignored the click. The DOM observer
10
+ * needs a list of roots to attach to, and no API enumerates them.
11
+ *
12
+ * **Closed roots.** `element.shadowRoot` is null forever for `mode: 'closed'`, so a closed root is
13
+ * unreadable AFTER the fact — but `attachShadow` still RETURNS it to its caller. Patching that call
14
+ * captures the root at the one instant it is exposed. This only works when the SDK is installed
15
+ * before the component upgrades, which is why hosts are tracked individually rather than assumed:
16
+ * whatever was missed is still declared as a blind spot, and only what was genuinely captured stops
17
+ * being one.
18
+ */
19
+ /**
20
+ * Bookkeeping ceiling. Weak references make the ROOTS collectable; this bounds the entries pointing
21
+ * at them, which are not. Sized far above any real page's live component count.
22
+ */
23
+ export declare const MAX_TRACKED_ROOTS = 4000;
24
+ /** Dead-entry sweeps are O(retained), so they run once per this many roots rather than per root. */
25
+ export declare const SWEEP_EVERY = 250;
26
+ /** Shadow roots captured so far. Includes closed roots when the patch was installed in time. */
27
+ export declare function capturedRoots(): readonly ShadowRoot[];
28
+ /** Whether this element's shadow root was captured — i.e. whether its contents are reachable. */
29
+ export declare function isCaptured(host: Element): boolean;
30
+ /** The captured root for a host, or null. Non-null for closed roots the patch caught in time. */
31
+ export declare function capturedRootOf(host: Element): ShadowRoot | null;
32
+ /** Run `listener` for every root captured from now on. Returns an unsubscribe. */
33
+ export declare function onShadowRoot(listener: (root: ShadowRoot) => void): () => void;
34
+ /**
35
+ * Patch `attachShadow` to record each root as it is created, and sweep the roots that already exist.
36
+ *
37
+ * The sweep can only find OPEN roots (there is no other way in), so a closed root attached before
38
+ * install stays unreachable — correctly, and it is declared as such.
39
+ */
40
+ export declare function installShadowRegistry(): () => void;
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Every shadow root this SDK has seen created, open or closed.
3
+ *
4
+ * Two problems are solved by the same record.
5
+ *
6
+ * **Observation.** A `MutationObserver` on `documentElement` does NOT cross a shadow boundary, so a
7
+ * click that changes text inside a web component produced an entirely empty observe window. Measured
8
+ * on a console with a shadow-DOM alert badge: the click landed, the badge changed, and `observe`
9
+ * reported zero DOM events — indistinguishable from an app that ignored the click. The DOM observer
10
+ * needs a list of roots to attach to, and no API enumerates them.
11
+ *
12
+ * **Closed roots.** `element.shadowRoot` is null forever for `mode: 'closed'`, so a closed root is
13
+ * unreadable AFTER the fact — but `attachShadow` still RETURNS it to its caller. Patching that call
14
+ * captures the root at the one instant it is exposed. This only works when the SDK is installed
15
+ * before the component upgrades, which is why hosts are tracked individually rather than assumed:
16
+ * whatever was missed is still declared as a blind spot, and only what was genuinely captured stops
17
+ * being one.
18
+ */
19
+ import { captureMethod } from '../patching/capture-method.js';
20
+ /**
21
+ * How many installs are live.
22
+ *
23
+ * The state below is module-level, and a dev-only SDK gets installed more than once: Vite re-runs the
24
+ * module on hot reload, so `connect()` can run again before the previous instance is torn down.
25
+ * Measured: install, subscribe, install again, tear down the SECOND one — and the FIRST instance's
26
+ * subscribers were gone, so the DOM observer silently stopped being told about new shadow roots for
27
+ * the rest of the session. Shared state must only be cleared by the last install standing.
28
+ */
29
+ let installs = 0;
30
+ /**
31
+ * Retained shadow roots, weakly.
32
+ *
33
+ * This was a strong `Set` and it never shrank. Measured: a virtualized list that renders and removes
34
+ * 5,000 web-component rows left 5,000 roots retained — each pinning its whole shadow subtree, and
35
+ * pinning its host through `root.host`, so nothing the list discarded could ever be collected. A
36
+ * design system's rows ARE web components, so the most ordinary data-heavy page is the worst case,
37
+ * and this SDK is meant to sit in a dev session all day.
38
+ *
39
+ * Same shape `RefRegistry` already uses for element handles: hold weakly, bound the bookkeeping,
40
+ * sweep the dead entries periodically rather than on every write.
41
+ */
42
+ const roots = new Set();
43
+ /**
44
+ * Membership, separately and weakly.
45
+ *
46
+ * The weak entries above are for ENUMERATION and cannot answer "have I seen this root" without
47
+ * dereferencing all of them — which turns every `attachShadow` into a scan of every root so far, and
48
+ * a 5,000-row list into 25 million comparisons. A WeakSet answers it in O(1) and retains nothing.
49
+ */
50
+ const known = new WeakSet();
51
+ const hosts = new WeakSet();
52
+ /**
53
+ * Bookkeeping ceiling. Weak references make the ROOTS collectable; this bounds the entries pointing
54
+ * at them, which are not. Sized far above any real page's live component count.
55
+ */
56
+ export const MAX_TRACKED_ROOTS = 4000;
57
+ /** Dead-entry sweeps are O(retained), so they run once per this many roots rather than per root. */
58
+ export const SWEEP_EVERY = 250;
59
+ let sinceSweep = 0;
60
+ /** Drop entries whose root has been collected, then any excess by age (Set keeps insertion order). */
61
+ function sweep() {
62
+ for (const ref of roots) {
63
+ if (ref.deref() === undefined)
64
+ roots.delete(ref);
65
+ }
66
+ while (roots.size > MAX_TRACKED_ROOTS) {
67
+ const oldest = roots.values().next();
68
+ if (oldest.done === true)
69
+ break;
70
+ roots.delete(oldest.value);
71
+ }
72
+ }
73
+ /** The live roots behind the weak entries. */
74
+ function liveRoots() {
75
+ const out = [];
76
+ for (const ref of roots) {
77
+ const root = ref.deref();
78
+ if (root !== undefined)
79
+ out.push(root);
80
+ }
81
+ return out;
82
+ }
83
+ const listeners = new Set();
84
+ function record(root, host) {
85
+ // Idempotent. A hot reload leaves one patch wrapping another, so a single `attachShadow` travels
86
+ // through both and reports twice; the initial sweep can also meet a root the patch already caught.
87
+ // Subscribers must see each root exactly once — they attach an observer per root, and counting one
88
+ // twice is how a "reported once" guarantee quietly becomes "reported per install".
89
+ if (known.has(root))
90
+ return;
91
+ known.add(root);
92
+ roots.add(new WeakRef(root));
93
+ sinceSweep += 1;
94
+ if (sinceSweep >= SWEEP_EVERY) {
95
+ sinceSweep = 0;
96
+ sweep();
97
+ }
98
+ hosts.add(host);
99
+ for (const listener of listeners)
100
+ listener(root);
101
+ }
102
+ /** Shadow roots captured so far. Includes closed roots when the patch was installed in time. */
103
+ export function capturedRoots() {
104
+ return liveRoots();
105
+ }
106
+ /** Whether this element's shadow root was captured — i.e. whether its contents are reachable. */
107
+ export function isCaptured(host) {
108
+ return hosts.has(host);
109
+ }
110
+ /** The captured root for a host, or null. Non-null for closed roots the patch caught in time. */
111
+ export function capturedRootOf(host) {
112
+ if (!hosts.has(host))
113
+ return null;
114
+ for (const root of liveRoots())
115
+ if (root.host === host)
116
+ return root;
117
+ return null;
118
+ }
119
+ /** Run `listener` for every root captured from now on. Returns an unsubscribe. */
120
+ export function onShadowRoot(listener) {
121
+ listeners.add(listener);
122
+ return () => listeners.delete(listener);
123
+ }
124
+ /**
125
+ * Patch `attachShadow` to record each root as it is created, and sweep the roots that already exist.
126
+ *
127
+ * The sweep can only find OPEN roots (there is no other way in), so a closed root attached before
128
+ * install stays unreachable — correctly, and it is declared as such.
129
+ */
130
+ export function installShadowRegistry() {
131
+ const proto = Element.prototype;
132
+ // Through captureMethod, like every other patch site here: reading a method to hold and restore it
133
+ // is the one thing `unbound-method` exists to flag, and the intention is named there once.
134
+ const original = captureMethod(proto, 'attachShadow');
135
+ if (typeof original !== 'function')
136
+ return () => undefined;
137
+ installs += 1;
138
+ const patched = function patchedAttachShadow(init) {
139
+ const root = original.call(this, init);
140
+ record(root, this);
141
+ return root;
142
+ };
143
+ proto.attachShadow = patched;
144
+ const sweep = (node) => {
145
+ for (const el of node.querySelectorAll('*')) {
146
+ const root = el.shadowRoot;
147
+ if (root === null)
148
+ continue;
149
+ record(root, el); // idempotent — a root the patch already caught is skipped inside
150
+ sweep(root);
151
+ }
152
+ };
153
+ if (document.documentElement !== null)
154
+ sweep(document.documentElement);
155
+ return () => {
156
+ // Restore only if nobody patched over us; clobbering a later patch is worse than leaving ours.
157
+ if (captureMethod(proto, 'attachShadow') === patched)
158
+ proto.attachShadow = original;
159
+ installs -= 1;
160
+ if (installs > 0)
161
+ return; // another install is still using this state
162
+ roots.clear();
163
+ listeners.clear();
164
+ };
165
+ }
@@ -2,7 +2,8 @@ import { SnapshotMode } from '@reticlehq/core';
2
2
  interface SnapshotStatus {
3
3
  route: string;
4
4
  title: string;
5
- visibleDialogs: string[];
5
+ /** Open dialogs, OMITTED when there are none — absence means "no dialog is up". */
6
+ visibleDialogs?: string[];
6
7
  }
7
8
  export interface SnapshotResult {
8
9
  tree: string;
@@ -25,4 +26,3 @@ interface SnapshotOptions {
25
26
  /** Build the semantic accessibility snapshot of the page or a subtree. */
26
27
  export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
27
28
  export {};
28
- //# sourceMappingURL=snapshot.d.ts.map