@reticlehq/browser 2.13.1 → 3.1.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 (244) hide show
  1. package/dist/actions/actions-dom.d.ts +8 -20
  2. package/dist/actions/actions-dom.js +70 -69
  3. package/dist/actions/actions-press.d.ts +14 -0
  4. package/dist/actions/actions-press.js +123 -0
  5. package/dist/actions/actions.d.ts +1 -89
  6. package/dist/actions/actions.js +64 -365
  7. package/dist/actions/appeared-text.d.ts +0 -20
  8. package/dist/actions/appeared-text.js +6 -36
  9. package/dist/actions/danger-context.d.ts +3 -0
  10. package/dist/actions/danger-context.js +39 -0
  11. package/dist/actions/scroll.d.ts +8 -10
  12. package/dist/actions/scroll.js +28 -17
  13. package/dist/actions/synthetic/synthetic-input.d.ts +2 -0
  14. package/dist/actions/synthetic/synthetic-input.js +13 -0
  15. package/dist/actions/value-input.d.ts +0 -30
  16. package/dist/actions/value-input.js +0 -33
  17. package/dist/commands/commands.d.ts +0 -2
  18. package/dist/commands/commands.js +10 -70
  19. package/dist/connect-options.d.ts +2 -96
  20. package/dist/dom/a11y.d.ts +0 -29
  21. package/dist/dom/a11y.js +3 -90
  22. package/dist/dom/addressing/auto-anchor.d.ts +25 -0
  23. package/dist/dom/addressing/auto-anchor.js +41 -0
  24. package/dist/dom/addressing/refs.d.ts +14 -0
  25. package/dist/dom/addressing/refs.js +114 -0
  26. package/dist/dom/addressing/source.d.ts +9 -0
  27. package/dist/dom/addressing/source.js +29 -0
  28. package/dist/dom/chart.d.ts +0 -61
  29. package/dist/dom/chart.js +0 -60
  30. package/dist/dom/desktop-capture.d.ts +0 -30
  31. package/dist/dom/desktop-capture.js +1 -73
  32. package/dist/dom/dom-ignore.d.ts +0 -30
  33. package/dist/dom/dom-ignore.js +1 -48
  34. package/dist/dom/occlusion.d.ts +0 -7
  35. package/dist/dom/occlusion.js +31 -12
  36. package/dist/dom/paint-context.d.ts +6 -0
  37. package/dist/dom/paint-context.js +38 -0
  38. package/dist/dom/query.d.ts +0 -14
  39. package/dist/dom/query.js +50 -216
  40. package/dist/dom/realm.d.ts +0 -20
  41. package/dist/dom/realm.js +0 -28
  42. package/dist/dom/shadow-registry.d.ts +0 -33
  43. package/dist/dom/shadow-registry.js +2 -72
  44. package/dist/dom/snapshot.d.ts +2 -43
  45. package/dist/dom/snapshot.js +27 -152
  46. package/dist/dom/theme.d.ts +0 -37
  47. package/dist/dom/theme.js +2 -52
  48. package/dist/dom/virtualized.d.ts +0 -17
  49. package/dist/dom/virtualized.js +0 -27
  50. package/dist/edit-epoch.d.ts +1 -23
  51. package/dist/edit-epoch.js +1 -53
  52. package/dist/index.d.ts +4 -5
  53. package/dist/index.js +4 -28
  54. package/dist/observers/animation.d.ts +0 -1
  55. package/dist/observers/animation.js +1 -4
  56. package/dist/observers/blind-spots.d.ts +0 -26
  57. package/dist/observers/blind-spots.js +3 -62
  58. package/dist/observers/console.d.ts +1 -2
  59. package/dist/observers/console.js +41 -30
  60. package/dist/observers/context-open.d.ts +1 -11
  61. package/dist/observers/context-open.js +10 -14
  62. package/dist/observers/departure.d.ts +3 -0
  63. package/dist/observers/departure.js +52 -0
  64. package/dist/observers/dialogs.d.ts +2 -0
  65. package/dist/observers/dialogs.js +39 -0
  66. package/dist/observers/dom.js +28 -52
  67. package/dist/observers/download.d.ts +1 -2
  68. package/dist/observers/download.js +39 -56
  69. package/dist/observers/focus.d.ts +0 -6
  70. package/dist/observers/focus.js +0 -7
  71. package/dist/observers/frames.d.ts +0 -9
  72. package/dist/observers/frames.js +1 -28
  73. package/dist/observers/health.d.ts +0 -5
  74. package/dist/observers/health.js +2 -34
  75. package/dist/observers/install-all.d.ts +2 -0
  76. package/dist/observers/install-all.js +26 -27
  77. package/dist/observers/ipc.d.ts +0 -25
  78. package/dist/observers/ipc.js +0 -65
  79. package/dist/observers/navigation.d.ts +2 -0
  80. package/dist/observers/navigation.js +33 -0
  81. package/dist/observers/net-detail/network-body.d.ts +12 -0
  82. package/dist/observers/net-detail/network-body.js +71 -0
  83. package/dist/observers/net-detail/network-redact.d.ts +1 -0
  84. package/dist/observers/net-detail/network-redact.js +1 -0
  85. package/dist/observers/network-stream.d.ts +0 -21
  86. package/dist/observers/network-stream.js +3 -46
  87. package/dist/observers/network.d.ts +3 -27
  88. package/dist/observers/network.js +178 -295
  89. package/dist/observers/perf.d.ts +0 -10
  90. package/dist/observers/perf.js +0 -16
  91. package/dist/observers/route.d.ts +1 -2
  92. package/dist/observers/route.js +13 -19
  93. package/dist/observers/scroll.d.ts +0 -1
  94. package/dist/observers/scroll.js +2 -11
  95. package/dist/observers/sdk-failure.d.ts +1 -9
  96. package/dist/observers/sdk-failure.js +1 -30
  97. package/dist/observers/state.d.ts +1 -11
  98. package/dist/observers/state.js +13 -41
  99. package/dist/observers/storage.d.ts +0 -14
  100. package/dist/observers/storage.js +10 -34
  101. package/dist/observers/types.d.ts +0 -15
  102. package/dist/observers/types.js +0 -14
  103. package/dist/patching/capture-method.d.ts +0 -24
  104. package/dist/patching/capture-method.js +0 -26
  105. package/dist/presenter/chrome/layers.d.ts +2 -0
  106. package/dist/presenter/chrome/layers.js +2 -0
  107. package/dist/presenter/chrome/log-kinds.d.ts +12 -0
  108. package/dist/presenter/chrome/log-kinds.js +7 -0
  109. package/dist/presenter/{overlay.d.ts → chrome/overlay.d.ts} +0 -1
  110. package/dist/presenter/{overlay.js → chrome/overlay.js} +0 -1
  111. package/dist/presenter/chrome/presenter-brand.d.ts +3 -0
  112. package/dist/presenter/{presenter-brand.js → chrome/presenter-brand.js} +0 -21
  113. package/dist/presenter/chrome/presenter-effects.d.ts +4 -0
  114. package/dist/presenter/{presenter-effects.js → chrome/presenter-effects.js} +1 -10
  115. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +7 -0
  116. package/dist/presenter/{presenter-hud-chrome.js → chrome/presenter-hud-chrome.js} +0 -10
  117. package/dist/presenter/chrome/presenter-log.d.ts +17 -0
  118. package/dist/presenter/{presenter-log.js → chrome/presenter-log.js} +5 -54
  119. package/dist/presenter/chrome/presenter-report-copy.d.ts +51 -0
  120. package/dist/presenter/chrome/presenter-report-copy.js +95 -0
  121. package/dist/presenter/chrome/presenter-safe-html.d.ts +2 -0
  122. package/dist/presenter/chrome/presenter-safe-html.js +16 -0
  123. package/dist/presenter/{presenter-tally.d.ts → chrome/presenter-tally.d.ts} +0 -5
  124. package/dist/presenter/{presenter-tally.js → chrome/presenter-tally.js} +1 -5
  125. package/dist/presenter/{presenter-verbs.js → chrome/presenter-verbs.js} +0 -7
  126. package/dist/presenter/{presenter-heroicons-data.d.ts → icons/presenter-heroicons-data.d.ts} +1 -8
  127. package/dist/presenter/{presenter-heroicons-data.js → icons/presenter-heroicons-data.js} +1 -8
  128. package/dist/presenter/{presenter-icons.d.ts → icons/presenter-icons.d.ts} +1 -6
  129. package/dist/presenter/{presenter-icons.js → icons/presenter-icons.js} +1 -6
  130. package/dist/presenter/presenter-account-styles.d.ts +1 -0
  131. package/dist/presenter/presenter-account-styles.js +99 -0
  132. package/dist/presenter/presenter-account.d.ts +40 -0
  133. package/dist/presenter/presenter-account.js +178 -0
  134. package/dist/presenter/presenter-config.d.ts +10 -107
  135. package/dist/presenter/presenter-config.js +5 -92
  136. package/dist/presenter/presenter-controls.d.ts +1 -35
  137. package/dist/presenter/presenter-controls.js +10 -133
  138. package/dist/presenter/presenter-dock-layout.d.ts +0 -3
  139. package/dist/presenter/presenter-dock-layout.js +1 -5
  140. package/dist/presenter/presenter-drag.d.ts +2 -42
  141. package/dist/presenter/presenter-drag.js +3 -76
  142. package/dist/presenter/presenter-glow.d.ts +0 -21
  143. package/dist/presenter/presenter-glow.js +2 -28
  144. package/dist/presenter/presenter-hud-position.d.ts +18 -0
  145. package/dist/presenter/presenter-hud-position.js +50 -0
  146. package/dist/presenter/presenter-report-styles.d.ts +0 -6
  147. package/dist/presenter/presenter-report-styles.js +22 -7
  148. package/dist/presenter/presenter-report.d.ts +5 -7
  149. package/dist/presenter/presenter-report.js +44 -95
  150. package/dist/presenter/presenter-run-state.d.ts +1 -8
  151. package/dist/presenter/presenter-run-state.js +1 -6
  152. package/dist/presenter/presenter-settings-styles.d.ts +0 -1
  153. package/dist/presenter/presenter-settings-styles.js +2 -2
  154. package/dist/presenter/presenter-settings.d.ts +5 -23
  155. package/dist/presenter/presenter-settings.js +21 -33
  156. package/dist/presenter/presenter-shell-styles.js +23 -20
  157. package/dist/presenter/presenter-shell.d.ts +6 -18
  158. package/dist/presenter/presenter-shell.js +26 -77
  159. package/dist/presenter/presenter-styles.d.ts +2 -10
  160. package/dist/presenter/presenter-styles.js +6 -12
  161. package/dist/presenter/presenter-workspace.d.ts +4 -4
  162. package/dist/presenter/presenter-workspace.js +30 -10
  163. package/dist/presenter/presenter.d.ts +3 -63
  164. package/dist/presenter/presenter.js +29 -132
  165. package/dist/presenter/tour/tour-view.d.ts +33 -0
  166. package/dist/presenter/tour/tour-view.js +188 -0
  167. package/dist/presenter/tour/tour.d.ts +25 -0
  168. package/dist/presenter/tour/tour.js +245 -0
  169. package/dist/recorder/recorder-styles.d.ts +0 -6
  170. package/dist/recorder/recorder-styles.js +0 -6
  171. package/dist/recorder/recorder.d.ts +0 -34
  172. package/dist/recorder/recorder.js +6 -49
  173. package/dist/registry/auto-testids.d.ts +0 -19
  174. package/dist/registry/auto-testids.js +1 -21
  175. package/dist/registry/capabilities.d.ts +0 -31
  176. package/dist/registry/capabilities.js +1 -46
  177. package/dist/registry/commit-and-signal.d.ts +0 -6
  178. package/dist/registry/commit-and-signal.js +1 -7
  179. package/dist/registry/declared-channels.d.ts +2 -0
  180. package/dist/registry/declared-channels.js +14 -0
  181. package/dist/registry/declared-platform.d.ts +4 -0
  182. package/dist/registry/declared-platform.js +34 -0
  183. package/dist/registry/domains.d.ts +0 -8
  184. package/dist/registry/domains.js +0 -7
  185. package/dist/registry/emitter.d.ts +0 -14
  186. package/dist/registry/emitter.js +1 -11
  187. package/dist/registry/store-adapters.d.ts +0 -186
  188. package/dist/registry/store-adapters.js +1 -164
  189. package/dist/registry/{adapters.d.ts → stores/adapters.d.ts} +0 -10
  190. package/dist/registry/{adapters.js → stores/adapters.js} +0 -10
  191. package/dist/registry/stores.d.ts +0 -33
  192. package/dist/registry/stores.js +1 -60
  193. package/dist/reticle-presenter-helpers.d.ts +0 -13
  194. package/dist/reticle-presenter-helpers.js +10 -20
  195. package/dist/reticle-singleton.d.ts +2 -0
  196. package/dist/reticle-singleton.js +3 -0
  197. package/dist/reticle.d.ts +1 -52
  198. package/dist/reticle.js +115 -180
  199. package/dist/review/annotator-styles.js +1 -8
  200. package/dist/review/annotator.d.ts +2 -13
  201. package/dist/review/annotator.js +2 -42
  202. package/dist/review/mark-anchor.d.ts +0 -11
  203. package/dist/review/mark-anchor.js +3 -11
  204. package/dist/security/serialization.d.ts +0 -20
  205. package/dist/security/serialization.js +1 -66
  206. package/dist/session-continuity.d.ts +0 -16
  207. package/dist/session-continuity.js +1 -20
  208. package/dist/timers/clock.d.ts +1 -14
  209. package/dist/timers/clock.js +59 -60
  210. package/dist/timers/console/native-console.js +5 -0
  211. package/dist/timers/native/native-timers.d.ts +11 -0
  212. package/dist/timers/native/native-timers.js +48 -0
  213. package/dist/transport/transport.d.ts +0 -51
  214. package/dist/transport/transport.js +5 -131
  215. package/dist/transport/unreachable-message.d.ts +0 -17
  216. package/dist/transport/unreachable-message.js +0 -17
  217. package/dist/util/captured-method.d.ts +0 -35
  218. package/dist/util/captured-method.js +0 -35
  219. package/package.json +5 -4
  220. package/dist/actions/synthetic-input.d.ts +0 -9
  221. package/dist/actions/synthetic-input.js +0 -40
  222. package/dist/dom/auto-anchor.d.ts +0 -65
  223. package/dist/dom/auto-anchor.js +0 -74
  224. package/dist/dom/refs.d.ts +0 -90
  225. package/dist/dom/refs.js +0 -245
  226. package/dist/dom/source.d.ts +0 -53
  227. package/dist/dom/source.js +0 -74
  228. package/dist/observers/network-body.d.ts +0 -35
  229. package/dist/observers/network-body.js +0 -168
  230. package/dist/observers/network-redact.d.ts +0 -20
  231. package/dist/observers/network-redact.js +0 -80
  232. package/dist/presenter/presenter-brand.d.ts +0 -22
  233. package/dist/presenter/presenter-effects.d.ts +0 -13
  234. package/dist/presenter/presenter-hud-chrome.d.ts +0 -17
  235. package/dist/presenter/presenter-log.d.ts +0 -64
  236. package/dist/presenter/presenter-report-copy.d.ts +0 -112
  237. package/dist/presenter/presenter-report-copy.js +0 -156
  238. package/dist/presenter/presenter-test-helpers.d.ts +0 -16
  239. package/dist/presenter/presenter-test-helpers.js +0 -41
  240. package/dist/timers/native-console.js +0 -9
  241. package/dist/timers/native-timers.d.ts +0 -36
  242. package/dist/timers/native-timers.js +0 -78
  243. /package/dist/presenter/{presenter-verbs.d.ts → chrome/presenter-verbs.d.ts} +0 -0
  244. /package/dist/timers/{native-console.d.ts → console/native-console.d.ts} +0 -0
@@ -1,12 +1,14 @@
1
- import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, isDangerousActionText, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
- import { asSyntheticInput } from './synthetic-input.js';
3
- import { echoRef, refs } from '../dom/refs.js';
1
+ import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
+ import { asSyntheticInput } from './synthetic/synthetic-input.js';
3
+ import { echoRef, refs } from '../dom/addressing/refs.js';
4
+ import { dangerousActionContext, requiresDangerousConfirmation, submitControlFor, } from './danger-context.js';
4
5
  import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
5
6
  import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
6
- import { elementHasHoverHandlers, identifyComponent } from '../registry/adapters.js';
7
+ import { elementHasHoverHandlers, identifyComponent } from '../registry/stores/adapters.js';
7
8
  import { isForm, isHtmlElement, isInput, isSelect, isTextArea } from '../dom/realm.js';
8
- import { nativeSetTimeout, settle } from '../timers/native-timers.js';
9
+ import { nativeSetTimeout, settle } from '../timers/native/native-timers.js';
9
10
  import { AppearedText } from './appeared-text.js';
11
+ import { focusedOrDocument, isReflessDocumentPress, pressCode, pressKey, pressModifiers, pressKeys, holdKey, pressCombo, } from './actions-press.js';
10
12
  function asString(value, fallback = '') {
11
13
  return 'string' === typeof value ? value : fallback;
12
14
  }
@@ -18,18 +20,6 @@ function requireElement(ref) {
18
20
  throw new Error(`ref '${echoRef(ref)}' is not an HTMLElement`);
19
21
  return el;
20
22
  }
21
- /**
22
- * Capture an element's anchor BEFORE the action runs. Critical: a navigating/destructive action can
23
- * unmount the target (login submit, a close button), after which it has no readable attribute — so
24
- * reading the anchor post-settle would silently degrade the recorded step. We read it up front while
25
- * the element is still mounted.
26
- *
27
- * The component walk runs even when a testid is present. It used to be skipped, on the reasoning that
28
- * a testid is the better anchor and the walk is wasted work — true for anchoring, but it also threw
29
- * away the source pointer, which answers a different question (where is this defined?) and is the
30
- * most useful thing we can give an agent that has to fix the thing we just broke. Acts are
31
- * agent-initiated and rare, so the walk's cost is not on any hot path.
32
- */
33
23
  function anchorOf(el) {
34
24
  const testid = el.getAttribute('data-testid') ?? undefined;
35
25
  const info = identifyComponent(el);
@@ -41,14 +31,6 @@ function anchorOf(el) {
41
31
  out.component = component;
42
32
  if (info?.source !== undefined)
43
33
  out.source = info.source;
44
- // Reported INDEPENDENTLY. They used to be all-or-nothing, so an element with a role and no
45
- // accessible name — an icon button, a clickable div, a control labelled by an SVG — came back with
46
- // no identity at all once identifyComponent (React-specific) also found nothing. Two features read
47
- // this and both silently degrade without it: the flow recorder anchors a step by it, and coverage
48
- // recognises a control it already drove across a re-render by it.
49
- //
50
- // A role alone is not a unique anchor, and the flow compiler still requires role AND name before it
51
- // will anchor a step that way. But it is real information, and reporting it beats reporting nothing.
52
34
  const role = getRole(el);
53
35
  const name = getAccessibleName(el);
54
36
  if (role.length > 0)
@@ -67,10 +49,6 @@ const result = (ref, action, effect, settled, settleReason, anchor, warning) =>
67
49
  settleReason,
68
50
  effect,
69
51
  };
70
- // Anchor and provenance are reported side by side rather than as alternatives: the testid says how
71
- // to find this element next run, the component/source says where it is written. A step is anchored
72
- // by the testid when it has one (see the flow compiler) and that is unchanged — what changed is
73
- // that having a testid no longer erases the answer to "which file do I open?".
74
52
  if (anchor.testid !== undefined)
75
53
  base.testid = anchor.testid;
76
54
  if (anchor.component !== undefined)
@@ -85,9 +63,6 @@ const result = (ref, action, effect, settled, settleReason, anchor, warning) =>
85
63
  base.warning = warning;
86
64
  return base;
87
65
  };
88
- // SELECT is fill-like for the purpose of `valueChanged`: selecting a value with NO matching <option>
89
- // leaves el.value unchanged (or ''), and valueChanged:false is the only signal an agent gets that the
90
- // select silently did nothing. Without it a no-op SELECT reported plain success — a false green.
91
66
  const FILL_LIKE = new Set([
92
67
  ActionType.FILL,
93
68
  ActionType.TYPE,
@@ -95,211 +70,27 @@ const FILL_LIKE = new Set([
95
70
  ActionType.SELECT,
96
71
  ]);
97
72
  const isFillLike = (action) => FILL_LIKE.has(action);
98
- /** Actions that resolve to a point and so benefit from off-viewport scroll + occlusion hit-test. */
99
73
  const CLICK_LIKE = new Set([
100
74
  ActionType.CLICK,
101
75
  ActionType.DBLCLICK,
102
- // check/uncheck activate through a real click, so they need everything a click needs: the point
103
- // geometry, the off-viewport scroll, the occlusion hit-test, and the component/source attribution.
104
- // Their absence here is why a `check` came back with no `component` while a `click` on the same
105
- // element carried one — the tell the field report noticed and could not explain.
106
76
  ActionType.CHECK,
107
77
  ActionType.UNCHECK,
108
78
  ]);
109
- /**
110
- * A check/uncheck whose requested state the element already reads as, so nothing will be dispatched.
111
- *
112
- * ONE predicate, read twice — the dispatch branch decides not to click, the effect reports that it
113
- * did not. Two copies of this rule could disagree, and a disagreement here is precisely the false
114
- * green: an act that reports it drove a control it never touched.
115
- */
116
79
  function alreadyAtCheckedState(el, action) {
117
80
  if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
118
81
  return false;
119
82
  return isInput(el) && el.checked === (action === ActionType.CHECK);
120
83
  }
121
- /**
122
- * What the destructive-action guard classifies: the ELEMENT, and nothing rendered around it.
123
- *
124
- * This used to end with `form?.textContent`, so the whole enclosing form's rendered text decided the
125
- * verdict for every control inside it — a settings form with per-row "Remove" buttons made its own
126
- * Save button read as destructive, intermittently, depending on whether the rows had rendered yet.
127
- * The field report's workaround was `confirmDangerous: true` on every click, which deletes the guard
128
- * outright: a guard that fires on everything is a guard that gets switched off.
129
- *
130
- * The form's `action` stays — that is a URL this element submits to, i.e. a property of what this
131
- * click DOES, not of what happens to be on screen beside it.
132
- */
133
- function dangerousActionContext(el) {
134
- const form = el.closest('form');
135
- return [
136
- getAccessibleName(el),
137
- el.textContent ?? '',
138
- el.getAttribute('value') ?? '',
139
- el.getAttribute('title') ?? '',
140
- el.getAttribute('aria-label') ?? '',
141
- el.getAttribute('href') ?? '',
142
- form?.getAttribute('action') ?? '',
143
- ].join(' ');
144
- }
145
- function requiresDangerousConfirmation(text, role) {
146
- return isDangerousActionText(text, role);
147
- }
148
- /**
149
- * Which key a `press` is asking for.
150
- *
151
- * `text` FIRST, because that is what the tool description documents ("{ text } for type/press") and
152
- * therefore what agents send. The implementation read only `args['key']` and defaulted to `'Enter'`,
153
- * so the documented call — `press` with `{ text: 'Escape' }` — silently dispatched **Enter** and
154
- * reported success. Two field reports blamed "synthetic events not reaching the app"; the events
155
- * arrived perfectly and said the wrong thing.
156
- *
157
- * Enter is the worst possible substitute to pick by accident: on a focused field inside a form it
158
- * SUBMITS it, so a request to dismiss a dialog could file the form behind it. `key` still works —
159
- * it is what anyone reading the source would have sent — and the default only applies when neither
160
- * was named.
161
- */
162
- function pressKey(args) {
163
- const text = args['text'];
164
- if ('string' === typeof text && text.length > 0)
165
- return text;
166
- return asString(args['key'], 'Enter');
167
- }
168
- /**
169
- * Modifier flags for a `press`, from `args.modifiers`: an array of Meta / Control / Shift / Alt
170
- * (case-insensitive, with the usual aliases). Without them a Cmd+K / Ctrl+Shift shortcut receives a
171
- * keydown with every modifier false, so the app's own `event.metaKey` check never matches and
172
- * nothing observable happens -- a false negative Reticle reports as no error. (#393)
173
- */
174
- function pressModifiers(args) {
175
- const raw = args['modifiers'];
176
- const names = Array.isArray(raw) ? raw.map((m) => asString(m).toLowerCase()) : [];
177
- const has = (...aliases) => aliases.some((a) => names.includes(a));
178
- return {
179
- metaKey: has('meta', 'cmd', 'command', 'super', 'win'),
180
- ctrlKey: has('control', 'ctrl'),
181
- shiftKey: has('shift'),
182
- altKey: has('alt', 'option', 'opt'),
183
- };
184
- }
185
- /**
186
- * The PHYSICAL key identity — `event.code` — derived from the logical key.
187
- *
188
- * A real browser always sends both, and a meaningful class of library reads only `code`, because it
189
- * is the layout-independent one: dnd-kit's KeyboardSensor matches its activation and arrow keys on
190
- * it, and so does react-aria. We sent `key` alone, so those handlers simply never matched — the
191
- * event fired, the listener ran, the guard failed, and the action reported dispatched over an app
192
- * that did nothing. Reported from the field as a keyboard drag that could be neither started nor
193
- * steered.
194
- *
195
- * An explicit `code` always wins: a caller driving a non-US layout knows something this derivation
196
- * cannot. And an unrecognised multi-character key yields `''` rather than a guess — a wrong `code`
197
- * is worse than none, because a handler will act on it.
198
- */
199
- function pressCode(args, key) {
200
- const explicit = args['code'];
201
- if ('string' === typeof explicit && explicit.length > 0)
202
- return explicit;
203
- if (' ' === key)
204
- return 'Space';
205
- if (1 === key.length) {
206
- if (/[a-z]/i.test(key))
207
- return `Key${key.toUpperCase()}`;
208
- if (/[0-9]/.test(key))
209
- return `Digit${key}`;
210
- return '';
211
- }
212
- // Named keys — 'Enter', 'Escape', 'Tab', 'ArrowDown' — already ARE their own code.
213
- return /^[A-Z][A-Za-z0-9]*$/.test(key) && KNOWN_NAMED_KEYS.has(key) ? key : '';
214
- }
215
- /**
216
- * Named keys whose `code` equals their `key`. An allow-list rather than a shape test: `Zzz` looks
217
- * exactly like `Tab` to a regex, and inventing `code: "Zzz"` would be a confident fabrication.
218
- */
219
- const KNOWN_NAMED_KEYS = new Set([
220
- 'Enter',
221
- 'Escape',
222
- 'Tab',
223
- 'Backspace',
224
- 'Delete',
225
- 'Home',
226
- 'End',
227
- 'PageUp',
228
- 'PageDown',
229
- 'ArrowUp',
230
- 'ArrowDown',
231
- 'ArrowLeft',
232
- 'ArrowRight',
233
- 'Insert',
234
- 'F1',
235
- 'F2',
236
- 'F3',
237
- 'F4',
238
- 'F5',
239
- 'F6',
240
- 'F7',
241
- 'F8',
242
- 'F9',
243
- 'F10',
244
- 'F11',
245
- 'F12',
246
- ]);
247
- /**
248
- * The drop target a `drag` names, under either spelling.
249
- *
250
- * `toRef` is what the source has always read and it appears NOWHERE in the tool description an
251
- * agent sees — the `args` sentence names `value`, `text`, `native`, `holdMs` and `confirmDangerous`
252
- * and omits the one argument without which this action cannot do its job. So agents guess, and
253
- * `target` is the guess they make. Accepting it costs nothing and removes a whole class of
254
- * silently-target-less drags; the description now documents `toRef` as well.
255
- */
256
84
  function dragTargetRef(args) {
257
85
  const named = asString(args['toRef']);
258
86
  return '' !== named ? named : asString(args['target']);
259
87
  }
260
- /**
261
- * The keys `upload` actually reads, and the generic ones every action accepts.
262
- *
263
- * `path` and `__base64` are NOT listed here. `path` is intercepted by the daemon before the
264
- * command reaches the browser and rewritten to `{ content, name, type, __base64: true }`. If a
265
- * `path` key arrives at the browser without a daemon in the loop, `assertUploadArgs` correctly
266
- * refuses it — the guard exists precisely to prevent a filename-only call from uploading fabricated
267
- * bytes under the right name. The browser cannot read disk files; the daemon is the only valid
268
- * path for that crossing.
269
- */
270
88
  const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type', '__base64']);
271
89
  const GENERIC_ACTION_ARG_KEYS = new Set([
272
90
  DANGEROUS_ACTION_CONFIRM_ARG,
273
91
  NATIVE_INPUT_ARG,
274
92
  'holdMs',
275
93
  ]);
276
- /**
277
- * An `upload` must have been told what to upload.
278
- *
279
- * The branch below defaults every field, so a call whose keys were all unrecognised — the field
280
- * report sent `args.files` — manufactured a 17-byte `file.txt` reading "reticle test file", uploaded
281
- * THAT, and returned ok:true. The server answered 200, the UI refreshed, and every signal the agent
282
- * could read said its PDF had been processed. That is a manufactured green on the write path, the
283
- * same shape `drag` refuses one branch below.
284
- *
285
- * Both halves matter. A call with no recognised key at all never described a file; a call that mixes
286
- * one in with a dropped one (`{ files, name }`) is worse, because the right filename arrives attached
287
- * to invented bytes. A name-only call keeps working: the placeholder body is documented and the
288
- * caller that omitted `content` knows it did.
289
- *
290
- * `path` is a recognised key: when a daemon is in the loop it reads the file and rewrites the call
291
- * to `{ content, name, type }` before it arrives here. When there is no daemon, `path` is accepted
292
- * and the name is used correctly — the placeholder body is the documented default, not fabrication,
293
- * because the caller chose to name a path that cannot be read in-browser. This is worse than the
294
- * daemon path, but better than a refusal that tells the agent "upload does not support paths".
295
- *
296
- * The refusal names the keys upload reads so a caller that guessed can correct in one turn.
297
- *
298
- * Scoped to upload deliberately. Dropping an unrecognised key in silence is a property of EVERY
299
- * action — `args` is `z.record(z.unknown())` at the wire boundary — but upload is the one where the
300
- * dropped key is replaced with fabricated content. Refusing unknown keys everywhere needs a
301
- * per-action arg schema in core and is a larger change than this release should carry.
302
- */
303
94
  function assertUploadArgs(args) {
304
95
  const keys = Object.keys(args);
305
96
  const dropped = keys.filter((k) => !UPLOAD_ARG_KEYS.has(k) && !GENERIC_ACTION_ARG_KEYS.has(k));
@@ -319,13 +110,12 @@ function assertActionAllowed(el, action, args) {
319
110
  action === ActionType.UNCHECK ||
320
111
  action === ActionType.DRAG ||
321
112
  action === ActionType.SUBMIT ||
322
- // Read through the SAME resolver as the dispatch below. Reading a different argument here meant
323
- // the destructive-action guard classified the call by a key nobody had asked for.
324
113
  (action === ActionType.PRESS && 'Enter' === pressKey(args));
325
- // Same resolver as the dispatch below, so the destructive-action guard cannot classify a drag
326
- // by a target the dispatch will not use.
327
114
  const dragTarget = action === ActionType.DRAG ? refs.resolve(dragTargetRef(args)) : null;
328
- const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el));
115
+ const submitter = action === ActionType.PRESS && 'Enter' === pressKey(args) ? submitControlFor(el) : null;
116
+ const sourceDangerous = requiresDangerousConfirmation(dangerousActionContext(el), getRole(el)) ||
117
+ (submitter !== null &&
118
+ requiresDangerousConfirmation(dangerousActionContext(submitter), getRole(submitter)));
329
119
  const targetDangerous = isHtmlElement(dragTarget) &&
330
120
  requiresDangerousConfirmation(dangerousActionContext(dragTarget), getRole(dragTarget));
331
121
  if (canTrigger &&
@@ -334,79 +124,59 @@ function assertActionAllowed(el, action, args) {
334
124
  throw new Error(`potentially destructive action blocked; retry with args.${DANGEROUS_ACTION_CONFIRM_ARG}=true`);
335
125
  }
336
126
  }
337
- /** Best-effort click-point geometry. All-false default when there is nothing measurable to test. */
338
- import { NO_GEOMETRY, fireClickSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
339
- /** Derive `enabled` from the shared a11y state logic (disabled prop + aria-disabled). */
127
+ import { NO_GEOMETRY, fireClickSequence, fireTapSequence, clickGeometry, firePointer, firePointerNonBubbling, dragElement, } from './actions-dom.js';
128
+ import { scrollFrom } from './scroll.js';
340
129
  function enabledOf(el) {
341
130
  return !getStates(el).includes(ElementState.DISABLED);
342
131
  }
343
- /** Current value for inputs/textareas/selects, else undefined. */
344
132
  function valueOf(el) {
345
133
  if (isInput(el) || isTextArea(el) || isSelect(el)) {
346
134
  return el.value;
347
135
  }
348
136
  return undefined;
349
137
  }
350
- /** Ref of the currently-focused element, treating body/null as "no focus". */
351
138
  function activeRef(el) {
352
139
  const active = el.ownerDocument.activeElement;
353
140
  if (null === active || active === el.ownerDocument.body)
354
141
  return null;
355
142
  return refs.refFor(active);
356
143
  }
357
- /**
358
- * Dispatch the action's events. Returns `defaultPrevented` of the primary cancelable event
359
- * (false for actions whose primary event is non-cancelable or unobservable). Drag/hover-hold
360
- * await internally; the probe wrapper builds the result once they resolve.
361
- */
362
- /**
363
- * The longest a single action may hold the pointer down, in milliseconds.
364
- *
365
- * An unbounded `holdMs` is a tool call that never returns, which is the failure the transport layer
366
- * is shaped around. 30s is far above any real hold-to-confirm (the reported case was 1.2s) and far
367
- * below a hang.
368
- */
369
144
  export const MAX_HOLD_MS = 30_000;
370
- /** A hold the caller asked for, bounded and sanitised. Non-numbers and negatives mean "no hold". */
371
145
  function clampHold(raw) {
372
146
  if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
373
147
  return 0;
374
148
  return Math.min(raw, MAX_HOLD_MS);
375
149
  }
376
- /**
377
- * Click is the only action that can hold the pointer down, so it is the only one that reports a
378
- * duration. Everything else routes through the switch below unchanged and holds for zero.
379
- */
380
150
  async function dispatchFor(el, action, args) {
381
151
  if (ActionType.CLICK === action) {
382
- // Full pointer/mouse sequence (not a bare click) so pointer- and focus-gated handlers fire the
383
- // way they do for a real user.
384
- //
385
- // `args.holdMs` keeps the pointer down in between, which is the only way to drive a
386
- // hold-to-confirm control. Capped: an unbounded hold is a tool call that never returns.
387
152
  const hold = clampHold(args['holdMs']);
388
153
  return await fireClickSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
389
154
  }
155
+ if (ActionType.TAP === action) {
156
+ const hold = clampHold(args['holdMs']);
157
+ return await fireTapSequence(el, 0 === hold ? undefined : { ms: hold, sleep, now: () => Date.now() });
158
+ }
390
159
  return { prevented: await dispatchOther(el, action, args), heldMs: 0 };
391
160
  }
161
+ export const ZOOM_NEEDS_REAL_BROWSER_MSG = 'cannot zoom from inside the page — CSS zoom changes how it LOOKS without changing the layout ' +
162
+ 'viewport, visualViewport or media queries, so a layout that breaks at 200% would be reported as ' +
163
+ 'checked and passing. Drive a real browser (reticle drive <url>, or reticle_lease) and zoom there.';
164
+ function asFiniteNumber(raw) {
165
+ return 'number' === typeof raw && Number.isFinite(raw) ? raw : undefined;
166
+ }
392
167
  async function dispatchOther(el, action, args) {
393
168
  switch (action) {
394
169
  case ActionType.DBLCLICK:
395
170
  return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
396
171
  case ActionType.HOVER: {
397
172
  const doc = el.ownerDocument;
398
- // Best-effort "previous" node for relatedTarget so React's enter/leave synthesis has a "from".
399
173
  const from = doc.activeElement ?? doc.body;
400
- // Bubbling over/move with relatedTarget so React's delegated root can synthesize enter/leave.
401
174
  firePointer(el, 'pointerover', from);
402
175
  el.dispatchEvent(new MouseEvent('mouseover', { bubbles: true, relatedTarget: from }));
403
- // Non-bubbling enter pair (per spec) for direct enter/onMouseEnter listeners.
404
176
  firePointerNonBubbling(el, 'pointerenter', from);
405
177
  el.dispatchEvent(new MouseEvent('mouseenter', { relatedTarget: from }));
406
178
  firePointer(el, 'pointermove', from);
407
179
  const moved = el.dispatchEvent(new MouseEvent('mousemove', { bubbles: true, cancelable: true }));
408
- // hover-dwell: keep "hovering" for holdMs so timer-gated reveals can mount. Same cap as
409
- // click — this arg was previously unbounded here, which is the same never-returns hazard.
410
180
  const holdMs = clampHold(args['holdMs']);
411
181
  if (holdMs > 0)
412
182
  await sleep(holdMs);
@@ -415,23 +185,8 @@ async function dispatchOther(el, action, args) {
415
185
  case ActionType.FOCUS:
416
186
  el.focus();
417
187
  el.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
418
- return false; // FocusEvents are not cancelable.
188
+ return false;
419
189
  case ActionType.BLUR: {
420
- // React 19 listens for `focusout` at the ROOT, so a commit-on-blur handler needs a BUBBLING
421
- // event to reach it — and `el.blur()` is a no-op on an element that was never focused, which
422
- // is why the synthetic pair exists at all.
423
- //
424
- // But on a FOCUSED element `el.blur()` already dispatches `blur` and a bubbling `focusout`
425
- // natively. Dispatching them again meant React ran `onBlur` TWICE, and Reticle reported the
426
- // resulting double write as a `duplicate-request` contradiction — a defect we invented and
427
- // handed to a human as real. Reported from the field on React 19 + Vite with a single
428
- // `onBlur={() => mutate(...)}`, one render site and no StrictMode.
429
- //
430
- // So synthesize ONLY when nothing native will fire. Read before the call, because `blur()` is
431
- // what changes it — and read off the element's own ROOT: `document.activeElement` reports the
432
- // shadow HOST for an element inside a shadow root, so an element that genuinely was focused
433
- // would read as unfocused and take the double-dispatch path this exists to prevent. Both
434
- // Document and ShadowRoot expose `activeElement`, so one expression covers both.
435
190
  const root = el.getRootNode();
436
191
  const wasFocused = root.activeElement === el;
437
192
  el.blur();
@@ -443,27 +198,18 @@ async function dispatchOther(el, action, args) {
443
198
  }
444
199
  case ActionType.FILL:
445
200
  if (isInput(el) || isTextArea(el)) {
446
- // A MISSING value is a malformed call, not a request to empty the field. `asString` defaults
447
- // to '', which made such a fill indistinguishable from `clear`: it wiped what was there,
448
- // dispatched a real input event so React committed the empty string to state, and reported
449
- // ok:true with nothing flagged. The value is nested (`args:{value}`) at the tool boundary, so
450
- // passing it at the top level lands here — measured on bench-app's login form, where
451
- // "admin@reticle.dev" was destroyed by a fill that reported success. An explicit '' still
452
- // clears, because that is someone asking for it.
453
201
  if (typeof args['value'] !== 'string') {
454
202
  throw new Error("fill requires a string `value` — pass it nested, as args: { value: '…' }. " +
455
203
  'To empty a field on purpose use the `clear` action, or fill with an explicit "".');
456
204
  }
457
205
  assertEditable(el, 'fill');
458
- el.focus(); // focus first so a later blur commits (onBlur editors)
206
+ el.focus();
459
207
  return setNativeValue(el, args['value']);
460
208
  }
461
209
  assertNotRichText(el, 'fill');
462
210
  throw new Error(`cannot fill a <${el.tagName.toLowerCase()}>`);
463
211
  case ActionType.TYPE:
464
212
  if (isInput(el) || isTextArea(el)) {
465
- // Same family as the valueless FILL: a missing `text` appended '' and reported ok:true, so an
466
- // agent believed it had typed into a field nothing was written to.
467
213
  if (typeof args['text'] !== 'string') {
468
214
  throw new Error("type requires a string `text` — pass it nested, as args: { text: '…' }.");
469
215
  }
@@ -477,72 +223,36 @@ async function dispatchOther(el, action, args) {
477
223
  if (isInput(el) || isTextArea(el)) {
478
224
  return setNativeValue(el, '');
479
225
  }
480
- // Consistent with TYPE/SELECT/CHECK: clearing a non-field is not a silent success. Throwing
481
- // surfaces the mismatch instead of reporting ok:true for an action that did nothing.
482
226
  throw new Error(`cannot clear a <${el.tagName.toLowerCase()}>`);
483
227
  case ActionType.SELECT:
484
228
  if (isSelect(el)) {
485
- // A MISSING value assigned '' to the <select>. No option carries that, so the browser sets
486
- // selectedIndex to -1 — deselecting everything — from a call that was simply malformed.
487
229
  if (typeof args['value'] !== 'string') {
488
230
  throw new Error("select requires a string `value` — pass it nested, as args: { value: '…' }.");
489
231
  }
490
232
  const wanted = args['value'];
491
233
  const options = Array.from(el.options);
492
- // REFUSE an option that does not exist, before touching the element.
493
- //
494
- // This used to assign anyway and report the resulting `valueChanged` delta as proof the
495
- // option never took. That only works if nobody is listening. An unmatched value drives
496
- // selectedIndex to -1, so `el.value` becomes '' — and we then dispatched `change`. Reported
497
- // from a real session: the app read that empty value in its change handler and persisted it,
498
- // corrupting a stored language setting. The "detectable no-op" both mutated the app into a
499
- // state no user could reach AND did it through the app's own handler.
500
- //
501
- // Same rule as the readonly/disabled refusal: if a real user could not do it, forcing it is
502
- // not a test, it is damage.
503
234
  if (!options.some((option) => option.value === wanted)) {
504
235
  const available = options.map((option) => `${option.value} (${option.label})`).join(', ');
505
236
  throw new Error(`no <option> with value '${echoRef(wanted)}' — available: ${0 === options.length ? '(none)' : available}`);
506
237
  }
507
238
  el.value = wanted;
508
239
  el.dispatchEvent(new Event('change', { bubbles: true }));
509
- return false; // change is not cancelable.
240
+ return false;
510
241
  }
511
242
  throw new Error(`cannot select on a <${el.tagName.toLowerCase()}>`);
512
243
  case ActionType.CHECK:
513
244
  case ActionType.UNCHECK: {
514
245
  if (!isInput(el))
515
246
  throw new Error(`cannot (un)check a <${el.tagName.toLowerCase()}>`);
516
- // Same rule as the readonly/disabled refusal on fill: if a real user could not do it, forcing
517
- // it is not a test, it is damage — and a green over it is a false one.
518
247
  if (!enabledOf(el)) {
519
248
  throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
520
249
  }
521
- // A radio is deselected by selecting another radio, never on its own. Refusing is the same
522
- // rule as the disabled control above: a state no user could reach must not be forced.
523
250
  if ('radio' === el.type && action === ActionType.UNCHECK) {
524
251
  throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
525
252
  }
526
- // Already there: touch nothing. `check` means "end up checked", not "toggle", so clicking here
527
- // would flip it OFF, and SETTING it would hand the app an `input`/`change` no user produced —
528
- // enough to make an app that autosaves, logs or POSTs on change act on a no-op. Reported as
529
- // `effect.alreadyAtValue` — the same predicate, read again by executeAction — because "nothing
530
- // was dispatched" is not the same claim as "the app holds this value". See ActionEffect.
531
253
  if (alreadyAtCheckedState(el, action))
532
254
  return false;
533
- // A real click, never `el.checked = …`. Assigning the property flips the pixel and tells no
534
- // framework: React binds a checkbox's onChange to the CLICK event, and its value tracker dedups
535
- // the change it would otherwise synthesise from a direct assignment — so a controlled
536
- // `checked={state}` box never heard from us while the action reported success.
537
- //
538
- // Dispatched rather than `el.click()` so cancellation is read off the event object itself. The
539
- // old probe listened on `window`, which never runs when a handler calls `stopPropagation()`, so
540
- // a cancelled activation was reported as a successful one. Activation behaviour runs either way
541
- // — a synthetic click toggles the box, which `adversarial.check.test.ts` pins, because the
542
- // comment this replaces asserted the opposite and was wrong.
543
255
  const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true });
544
- // Marked as ours so the annotator's capture-phase listener lets it through: in annotate mode
545
- // it cancels clicks to place a mark, which would otherwise swallow every action we dispatch.
546
256
  const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
547
257
  return !notPrevented || event.defaultPrevented;
548
258
  }
@@ -551,30 +261,39 @@ async function dispatchOther(el, action, args) {
551
261
  if (null === form)
552
262
  throw new Error('no form to submit');
553
263
  form.requestSubmit();
554
- return false; // requestSubmit returns void; the internal submit event is unobservable.
264
+ return false;
555
265
  }
556
266
  case ActionType.PRESS: {
267
+ const mods = pressModifiers(args);
268
+ const combo = pressKeys(args);
269
+ if (combo.length > 0)
270
+ return await pressCombo(el, combo, mods, clampHold(args['holdMs']));
557
271
  const key = pressKey(args);
558
272
  const code = pressCode(args, key);
559
- const mods = pressModifiers(args);
560
- // Marked as ours like the click sequence: the annotator leaves annotate mode on Escape, so an
561
- // agent pressing Escape would otherwise switch off a mode the person turned on.
562
273
  const down = asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
274
+ const hold = clampHold(args['holdMs']);
275
+ if (hold > 0)
276
+ await holdKey(el, key, code, mods, hold);
563
277
  asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
564
278
  return !down;
565
279
  }
566
280
  case ActionType.SCROLL_INTO_VIEW:
567
281
  el.scrollIntoView();
568
282
  return false;
283
+ case ActionType.ZOOM:
284
+ throw new Error(ZOOM_NEEDS_REAL_BROWSER_MSG);
285
+ case ActionType.SCROLL: {
286
+ const dy = asFiniteNumber(args['dy']);
287
+ const dx = asFiniteNumber(args['dx']);
288
+ const fraction = asFiniteNumber(args['fraction']);
289
+ scrollFrom(el, dy, fraction, dx);
290
+ return false;
291
+ }
569
292
  case ActionType.UPLOAD: {
570
293
  if (!isInput(el) || el.type !== 'file') {
571
294
  throw new Error('upload target must be a <input type="file">');
572
295
  }
573
296
  assertUploadArgs(args);
574
- // When the daemon read a file from disk it base64-encoded the bytes so they survive JSON
575
- // serialisation across the bridge. Decode back to binary before handing to the File API.
576
- // Without this the browser would treat the base64 string as UTF-16 text, and the app
577
- // would receive garbled bytes — correct name and type, wrong payload, still a false green.
578
297
  const rawContent = asString(args['content'], 'reticle test file');
579
298
  let fileBody = rawContent;
580
299
  if (true === args['__base64']) {
@@ -582,9 +301,6 @@ async function dispatchOther(el, action, args) {
582
301
  const arr = new Uint8Array(bin.length);
583
302
  for (let i = 0; i < bin.length; i++)
584
303
  arr[i] = bin.charCodeAt(i);
585
- // Use slice(0) to get a plain ArrayBuffer from the Uint8Array's backing store.
586
- // arr.buffer is SharedArrayBuffer-typed in strict tsconfigs; slice(0) narrows it to
587
- // ArrayBuffer and avoids the type assertion the linter would flag.
588
304
  fileBody = arr.buffer.slice(0);
589
305
  }
590
306
  const file = new File([fileBody], asString(args['name'], 'file.txt'), {
@@ -599,14 +315,8 @@ async function dispatchOther(el, action, args) {
599
315
  }
600
316
  case ActionType.DRAG: {
601
317
  const toRef = dragTargetRef(args);
602
- // asString falls back to '' (never undefined), so the old `!== undefined` was always true and
603
- // handed '' to refs.resolve. A drag with NO target named is a free drag — resolve nothing.
604
318
  if ('' === toRef)
605
319
  return await dragElement(el, null, args['data']);
606
- // A target WAS named and did not resolve. That is a malformed call, not a free drag: dragging
607
- // nowhere and reporting `ok: true` is a false green, and it is the one this action produced
608
- // in the field — the drop target argument is undocumented, the agent guessed a name, the guess
609
- // read as "no target", and the drag landed nowhere while every effect field looked healthy.
610
320
  const resolved = refs.resolve(toRef);
611
321
  if (!isHtmlElement(resolved)) {
612
322
  throw new Error(`drag target '${echoRef(toRef)}' did not resolve to an element — pass a ref from ` +
@@ -618,23 +328,23 @@ async function dispatchOther(el, action, args) {
618
328
  throw new Error(`unknown action '${action}'`);
619
329
  }
620
330
  }
621
- /**
622
- * Execute a single action against a ref and probe for best-effort evidence of effect.
623
- * Always async: the MutationObserver read needs a microtask + rAF after dispatch.
624
- */
331
+ function asSettleBudget(raw) {
332
+ if ('number' !== typeof raw || !Number.isFinite(raw))
333
+ return undefined;
334
+ return Math.max(0, Math.min(raw, FRAME_BUDGET_MS_CEILING));
335
+ }
336
+ const FRAME_BUDGET_MS_CEILING = 200;
625
337
  export async function executeAction(ref, action, args = {}) {
626
- const el = requireElement(ref);
627
- assertActionAllowed(el, action, args);
628
- // Capture the anchor while the element is still mounted — the action may unmount it (navigation).
629
- const anchor = anchorOf(el);
338
+ const named = 0 < ref.length;
339
+ const el = isReflessDocumentPress(ref, action, args) ? focusedOrDocument() : requireElement(ref);
340
+ if (named)
341
+ assertActionAllowed(el, action, args);
342
+ const anchor = named ? anchorOf(el) : {};
630
343
  const visible = isVisible(el);
631
344
  const enabled = enabledOf(el);
632
345
  const prevFocus = activeRef(el);
633
346
  const valueBefore = valueOf(el);
634
- // Click-like: scroll an off-viewport target in + hit-test the click point BEFORE installing the
635
- // mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
636
347
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
637
- // Read BEFORE dispatch, from the same predicate the dispatch branch uses.
638
348
  const alreadyAtValue = alreadyAtCheckedState(el, action);
639
349
  let mutated = 0;
640
350
  const said = new AppearedText();
@@ -649,7 +359,6 @@ export async function executeAction(ref, action, args = {}) {
649
359
  characterData: true,
650
360
  });
651
361
  let defaultPrevented = false;
652
- /** How long the pointer was actually held down. Zero for every action that cannot hold. */
653
362
  let heldMs = 0;
654
363
  let settled = false;
655
364
  let settleReason = null;
@@ -659,11 +368,8 @@ export async function executeAction(ref, action, args = {}) {
659
368
  heldMs = outcome.heldMs;
660
369
  }
661
370
  finally {
662
- // bounded settle — microtask + one BOUNDED frame so React's commit (and the resulting DOM
663
- // mutations → dom.added/dom.text/dom.attr events) flush before we return, landing inside
664
- // observe({ since }). Bounded so a throttled/background tab never hangs; a settle timeout NEVER
665
- // rejects (only a real dispatch failure thrown above does). settle can never throw.
666
- const outcome = await settle();
371
+ const budget = asSettleBudget(args['settleMs']);
372
+ const outcome = await settle(budget);
667
373
  settled = outcome.settled;
668
374
  settleReason = outcome.settled ? null : SettleReason.TIMEOUT;
669
375
  obs.disconnect();
@@ -672,37 +378,30 @@ export async function executeAction(ref, action, args = {}) {
672
378
  const nextFocus = activeRef(el);
673
379
  const effect = {
674
380
  dispatched: true,
675
- targetMatched: el.isConnected,
381
+ targetMatched: named && el.isConnected,
676
382
  visible,
677
383
  enabled,
678
384
  defaultPrevented,
679
385
  focusMoved: prevFocus !== nextFocus ? `${prevFocus ?? 'null'}->${nextFocus ?? 'null'}` : null,
680
386
  valueChanged: isFillLike(action) ? valueBefore !== valueAfter : false,
681
387
  domMutatedWithin: mutated,
682
- // Omitted rather than "" when nothing was said — an empty string reads as "it said nothing
683
- // meaningful", where absence says "it added no text at all". Those are different findings.
684
388
  ...said.effect(valueAfter),
685
389
  occluded: geometry.occluded,
686
390
  occludedBy: geometry.occludedBy,
687
391
  scrolledIntoView: geometry.scrolledIntoView,
688
- // Omitted when there was no hold: an absent key says "this action does not hold", where a 0
689
- // would read as "it held for no time", which is a different and misleading claim.
690
392
  ...(heldMs > 0 ? { heldMs } : {}),
691
- // Omitted unless it applies: an absent key means the action actually drove the control (or is
692
- // not a check at all), which is the only reading that must not be ambiguous.
693
393
  ...(alreadyAtValue ? { alreadyAtValue: true } : {}),
694
394
  };
695
- // Honesty caveats: a visually-occluded click is reported even though synthetic dispatch landed;
696
- // else synthetic hover may not fire framework enter/leave handlers (no native hit-test).
697
- const warning = geometry.occluded
698
- ? ActionWarning.CLICK_OCCLUDED
699
- : action === ActionType.HOVER && elementHasHoverHandlers(el)
700
- ? ActionWarning.HOVER_NATIVE_ENTER_LEAVE
701
- : undefined;
395
+ const warning = !named
396
+ ? ActionWarning.GLOBAL_PRESS
397
+ : geometry.occluded
398
+ ? ActionWarning.CLICK_OCCLUDED
399
+ : action === ActionType.HOVER && elementHasHoverHandlers(el)
400
+ ? ActionWarning.HOVER_NATIVE_ENTER_LEAVE
401
+ : undefined;
702
402
  return result(ref, action, effect, settled, settleReason, anchor, warning);
703
403
  }
704
404
  const sleep = (ms) => new Promise((r) => nativeSetTimeout(r, ms));
705
- /** Best-effort WebMCP passthrough: call a navigator.modelContext tool if the site exposes one. */
706
405
  export async function dispatchWebMcp(tool, params, confirmDangerous = false) {
707
406
  if (requiresDangerousConfirmation(tool) && !confirmDangerous) {
708
407
  throw new Error(`potentially destructive WebMCP tool blocked; retry with ${DANGEROUS_ACTION_CONFIRM_ARG}=true`);