@reticlehq/browser 2.8.0 → 2.10.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 (79) hide show
  1. package/NOTICE +7 -0
  2. package/dist/actions/actions-dom.js +23 -15
  3. package/dist/actions/actions.d.ts +17 -0
  4. package/dist/actions/actions.js +132 -29
  5. package/dist/actions/synthetic-input.d.ts +9 -0
  6. package/dist/actions/synthetic-input.js +40 -0
  7. package/dist/commands/commands.js +11 -1
  8. package/dist/connect-options.d.ts +9 -9
  9. package/dist/dom/a11y.d.ts +9 -0
  10. package/dist/dom/a11y.js +19 -0
  11. package/dist/dom/dom-ignore.d.ts +19 -6
  12. package/dist/dom/dom-ignore.js +20 -13
  13. package/dist/dom/query.d.ts +2 -2
  14. package/dist/dom/query.js +85 -45
  15. package/dist/dom/snapshot.d.ts +5 -0
  16. package/dist/dom/snapshot.js +34 -0
  17. package/dist/dom/source.d.ts +14 -0
  18. package/dist/dom/source.js +16 -0
  19. package/dist/observers/install-all.js +4 -1
  20. package/dist/observers/ipc.d.ts +16 -0
  21. package/dist/observers/ipc.js +19 -1
  22. package/dist/observers/network.d.ts +8 -0
  23. package/dist/observers/network.js +24 -3
  24. package/dist/observers/state.js +21 -2
  25. package/dist/presenter/presenter-brand.d.ts +24 -7
  26. package/dist/presenter/presenter-brand.js +23 -6
  27. package/dist/presenter/presenter-config.d.ts +102 -9
  28. package/dist/presenter/presenter-config.js +94 -8
  29. package/dist/presenter/presenter-controls.d.ts +10 -10
  30. package/dist/presenter/presenter-controls.js +175 -85
  31. package/dist/presenter/presenter-dock-layout.d.ts +6 -0
  32. package/dist/presenter/presenter-dock-layout.js +170 -0
  33. package/dist/presenter/presenter-drag.d.ts +48 -0
  34. package/dist/presenter/presenter-drag.js +198 -0
  35. package/dist/presenter/presenter-effects.d.ts +1 -1
  36. package/dist/presenter/presenter-effects.js +1 -1
  37. package/dist/presenter/presenter-glow.d.ts +5 -5
  38. package/dist/presenter/presenter-glow.js +7 -7
  39. package/dist/presenter/presenter-heroicons-data.d.ts +41 -0
  40. package/dist/presenter/presenter-heroicons-data.js +38 -0
  41. package/dist/presenter/presenter-hud-chrome.d.ts +17 -0
  42. package/dist/presenter/presenter-hud-chrome.js +41 -0
  43. package/dist/presenter/presenter-icons.d.ts +44 -0
  44. package/dist/presenter/presenter-icons.js +100 -0
  45. package/dist/presenter/presenter-log.d.ts +27 -5
  46. package/dist/presenter/presenter-log.js +114 -28
  47. package/dist/presenter/presenter-report-copy.d.ts +83 -0
  48. package/dist/presenter/presenter-report-copy.js +127 -0
  49. package/dist/presenter/presenter-report-styles.d.ts +7 -0
  50. package/dist/presenter/presenter-report-styles.js +102 -0
  51. package/dist/presenter/presenter-report.d.ts +23 -0
  52. package/dist/presenter/presenter-report.js +217 -0
  53. package/dist/presenter/presenter-run-state.d.ts +1 -1
  54. package/dist/presenter/presenter-settings-styles.d.ts +2 -0
  55. package/dist/presenter/presenter-settings-styles.js +153 -0
  56. package/dist/presenter/presenter-settings.d.ts +82 -0
  57. package/dist/presenter/presenter-settings.js +494 -0
  58. package/dist/presenter/presenter-shell-styles.d.ts +1 -0
  59. package/dist/presenter/presenter-shell-styles.js +375 -0
  60. package/dist/presenter/presenter-shell.d.ts +48 -0
  61. package/dist/presenter/presenter-shell.js +408 -0
  62. package/dist/presenter/presenter-styles.d.ts +9 -4
  63. package/dist/presenter/presenter-styles.js +61 -125
  64. package/dist/presenter/presenter-tally.d.ts +8 -11
  65. package/dist/presenter/presenter-tally.js +48 -23
  66. package/dist/presenter/presenter-test-helpers.d.ts +1 -1
  67. package/dist/presenter/presenter-test-helpers.js +1 -1
  68. package/dist/presenter/presenter-verbs.js +2 -2
  69. package/dist/presenter/presenter-workspace.d.ts +15 -0
  70. package/dist/presenter/presenter-workspace.js +180 -0
  71. package/dist/presenter/presenter.d.ts +15 -10
  72. package/dist/presenter/presenter.js +194 -46
  73. package/dist/reticle.d.ts +2 -2
  74. package/dist/reticle.js +23 -19
  75. package/dist/review/annotator-styles.d.ts +6 -0
  76. package/dist/review/annotator-styles.js +81 -0
  77. package/dist/review/annotator.d.ts +29 -17
  78. package/dist/review/annotator.js +477 -146
  79. package/package.json +7 -4
package/NOTICE CHANGED
@@ -3,3 +3,10 @@ Copyright 2026 Reticle Labs
3
3
 
4
4
  This product includes software developed at Reticle Labs.
5
5
  Licensed under the Apache License, Version 2.0.
6
+
7
+ ---
8
+
9
+ Heroicons (presenter HUD)
10
+ Copyright (c) Tailwind Labs, Inc.
11
+ Licensed under the MIT License.
12
+ https://github.com/tailwindlabs/heroicons
@@ -1,6 +1,7 @@
1
1
  import { refs } from '../dom/refs.js';
2
2
  import { hitTestOccluder } from '../dom/occlusion.js';
3
3
  import { nativeFrame } from '../timers/native-timers.js';
4
+ import { asSyntheticInput } from './synthetic-input.js';
4
5
  export const NO_GEOMETRY = {
5
6
  occluded: false,
6
7
  occludedBy: null,
@@ -16,7 +17,7 @@ export async function fireClickSequence(el, hold) {
16
17
  const doc = el.ownerDocument;
17
18
  const from = doc.activeElement ?? doc.body;
18
19
  firePointer(el, 'pointerdown', from);
19
- el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
20
+ asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true })));
20
21
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
21
22
  el.focus();
22
23
  // The gap that makes hold-to-confirm driveable. Everything between down and up used to be
@@ -34,8 +35,11 @@ export async function fireClickSequence(el, hold) {
34
35
  heldMs = hold.now() - startedAt;
35
36
  }
36
37
  firePointer(el, 'pointerup', from);
37
- el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true }));
38
- const notPrevented = el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
38
+ asSyntheticInput(() => el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true })));
39
+ // Marked as ours so the annotator's capture-phase listener lets it through. Only the CHECK branch
40
+ // used to do this, so the ordinary click action — which is this function — was swallowed whole in
41
+ // annotate mode while still reporting `dispatched: true`.
42
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })));
39
43
  return { prevented: !notPrevented, heldMs };
40
44
  }
41
45
  /** A box has layout we can reason about (jsdom returns an all-zero box — nothing to hit-test). */
@@ -78,21 +82,25 @@ export function clickGeometry(el) {
78
82
  return { ...hitTest(el, rect), scrolledIntoView };
79
83
  }
80
84
  export function firePointer(el, type, relatedTarget = null) {
81
- if ('function' === typeof PointerEvent) {
82
- el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
83
- }
84
- else {
85
- el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
86
- }
85
+ asSyntheticInput(() => {
86
+ if ('function' === typeof PointerEvent) {
87
+ el.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
88
+ }
89
+ else {
90
+ el.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, relatedTarget }));
91
+ }
92
+ });
87
93
  }
88
94
  /** Enter/leave pointer events are non-bubbling per spec; keep them so to avoid double-firing. */
89
95
  export function firePointerNonBubbling(el, type, relatedTarget = null) {
90
- if ('function' === typeof PointerEvent) {
91
- el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
92
- }
93
- else {
94
- el.dispatchEvent(new MouseEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
95
- }
96
+ asSyntheticInput(() => {
97
+ if ('function' === typeof PointerEvent) {
98
+ el.dispatchEvent(new PointerEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
99
+ }
100
+ else {
101
+ el.dispatchEvent(new MouseEvent(type, { bubbles: false, cancelable: true, relatedTarget }));
102
+ }
103
+ });
96
104
  }
97
105
  function makeDataTransfer(data) {
98
106
  if (typeof DataTransfer !== 'function')
@@ -51,6 +51,23 @@ interface ActionEffect {
51
51
  * the achieved hold can legitimately be much longer than the one requested.
52
52
  */
53
53
  heldMs?: number;
54
+ /**
55
+ * check/uncheck only: the control ALREADY read as the requested state, so NOTHING was dispatched.
56
+ * OMITTED otherwise (the uninformative default).
57
+ *
58
+ * Not clicking is right — clicking an already-checked box would uncheck it. But the branch that
59
+ * skips the click reads the DOM PROPERTY, and that is not evidence about what the APPLICATION
60
+ * holds. They come apart exactly when it matters: a default-checked input the app has not
61
+ * committed, a property written earlier by something else, a control read mid-render. Then `check`
62
+ * no-ops, the framework never hears, and every later read agrees because every later read is also
63
+ * reading the DOM — reported from the field as a form whose flag was unset in the database while
64
+ * Reticle confirmed it checked.
65
+ *
66
+ * So this is a caveat, not a failure: the requested state is what the DOM shows, and Reticle did
67
+ * not put it there. Assert the app's own state (a signal, a request, a derived control) rather than
68
+ * the box.
69
+ */
70
+ alreadyAtValue?: true;
54
71
  }
55
72
  interface ActionResult {
56
73
  ok: true;
@@ -1,4 +1,5 @@
1
- import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, isDangerousActionText, SettleReason, } from '@reticlehq/core';
1
+ import { ActionType, ActionWarning, DANGEROUS_ACTION_CONFIRM_ARG, ElementState, isDangerousActionText, NATIVE_INPUT_ARG, SettleReason, } from '@reticlehq/core';
2
+ import { asSyntheticInput } from './synthetic-input.js';
2
3
  import { echoRef, refs } from '../dom/refs.js';
3
4
  import { assertEditable, assertNotRichText, setNativeValue } from './value-input.js';
4
5
  import { getAccessibleName, getRole, isVisible, getStates } from '../dom/a11y.js';
@@ -95,7 +96,40 @@ const FILL_LIKE = new Set([
95
96
  ]);
96
97
  const isFillLike = (action) => FILL_LIKE.has(action);
97
98
  /** Actions that resolve to a point and so benefit from off-viewport scroll + occlusion hit-test. */
98
- const CLICK_LIKE = new Set([ActionType.CLICK, ActionType.DBLCLICK]);
99
+ const CLICK_LIKE = new Set([
100
+ ActionType.CLICK,
101
+ 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
+ ActionType.CHECK,
107
+ ActionType.UNCHECK,
108
+ ]);
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
+ function alreadyAtCheckedState(el, action) {
117
+ if (action !== ActionType.CHECK && action !== ActionType.UNCHECK)
118
+ return false;
119
+ return isInput(el) && el.checked === (action === ActionType.CHECK);
120
+ }
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
+ */
99
133
  function dangerousActionContext(el) {
100
134
  const form = el.closest('form');
101
135
  return [
@@ -106,7 +140,6 @@ function dangerousActionContext(el) {
106
140
  el.getAttribute('aria-label') ?? '',
107
141
  el.getAttribute('href') ?? '',
108
142
  form?.getAttribute('action') ?? '',
109
- form?.textContent ?? '',
110
143
  ].join(' ');
111
144
  }
112
145
  function requiresDangerousConfirmation(text) {
@@ -132,6 +165,23 @@ function pressKey(args) {
132
165
  return text;
133
166
  return asString(args['key'], 'Enter');
134
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
+ }
135
185
  /**
136
186
  * The PHYSICAL key identity — `event.code` — derived from the logical key.
137
187
  *
@@ -207,9 +257,50 @@ function dragTargetRef(args) {
207
257
  const named = asString(args['toRef']);
208
258
  return '' !== named ? named : asString(args['target']);
209
259
  }
260
+ /** The keys `upload` actually reads, and the generic ones every action accepts. */
261
+ const UPLOAD_ARG_KEYS = new Set(['content', 'name', 'type']);
262
+ const GENERIC_ACTION_ARG_KEYS = new Set([
263
+ DANGEROUS_ACTION_CONFIRM_ARG,
264
+ NATIVE_INPUT_ARG,
265
+ 'holdMs',
266
+ ]);
267
+ /**
268
+ * An `upload` must have been told what to upload.
269
+ *
270
+ * The branch below defaults every field, so a call whose keys were all unrecognised — the field
271
+ * report sent `args.files` — manufactured a 17-byte `file.txt` reading "reticle test file", uploaded
272
+ * THAT, and returned ok:true. The server answered 200, the UI refreshed, and every signal the agent
273
+ * could read said its PDF had been processed. That is a manufactured green on the write path, the
274
+ * same shape `drag` refuses one branch below.
275
+ *
276
+ * Both halves matter. A call with no recognised key at all never described a file; a call that mixes
277
+ * one in with a dropped one (`{ path, name }`) is worse, because the right filename arrives attached
278
+ * to invented bytes. A name-only call keeps working: the placeholder body is documented and the
279
+ * caller that omitted `content` knows it did.
280
+ *
281
+ * The refusal names the keys upload reads so a caller that guessed can correct in one turn.
282
+ *
283
+ * Scoped to upload deliberately. Dropping an unrecognised key in silence is a property of EVERY
284
+ * action — `args` is `z.record(z.unknown())` at the wire boundary — but upload is the one where the
285
+ * dropped key is replaced with fabricated content. Refusing unknown keys everywhere needs a
286
+ * per-action arg schema in core and is a larger change than this release should carry.
287
+ */
288
+ function assertUploadArgs(args) {
289
+ const keys = Object.keys(args);
290
+ const dropped = keys.filter((k) => !UPLOAD_ARG_KEYS.has(k) && !GENERIC_ACTION_ARG_KEYS.has(k));
291
+ if (0 === dropped.length && keys.some((k) => UPLOAD_ARG_KEYS.has(k)))
292
+ return;
293
+ const detail = 0 === dropped.length
294
+ ? 'no file was described'
295
+ : `upload does not read ${dropped.join(', ')}, so it would be dropped`;
296
+ throw new Error(`upload needs the file described as args: { name, content?, type? } — ${detail}. ` +
297
+ 'Reading a file from disk is not supported; pass its bytes as `content`.');
298
+ }
210
299
  function assertActionAllowed(el, action, args) {
211
300
  const canTrigger = action === ActionType.CLICK ||
212
301
  action === ActionType.DBLCLICK ||
302
+ action === ActionType.CHECK ||
303
+ action === ActionType.UNCHECK ||
213
304
  action === ActionType.DRAG ||
214
305
  action === ActionType.SUBMIT ||
215
306
  // Read through the SAME resolver as the dispatch below. Reading a different argument here meant
@@ -285,7 +376,7 @@ async function dispatchFor(el, action, args) {
285
376
  async function dispatchOther(el, action, args) {
286
377
  switch (action) {
287
378
  case ActionType.DBLCLICK:
288
- return !el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }));
379
+ return !asSyntheticInput(() => el.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })));
289
380
  case ActionType.HOVER: {
290
381
  const doc = el.ownerDocument;
291
382
  // Best-effort "previous" node for relatedTarget so React's enter/leave synthesis has a "from".
@@ -411,30 +502,33 @@ async function dispatchOther(el, action, args) {
411
502
  if (!enabledOf(el)) {
412
503
  throw new Error(`cannot ${action} a disabled control — a real user could not, so neither will Reticle`);
413
504
  }
414
- const wanted = action === ActionType.CHECK;
415
- // Already there: report success and touch nothing. `check` means "end up checked", not
416
- // "toggle", so clicking here would flip it OFF and hand the app an event no user produced.
417
- if (el.checked === wanted)
418
- return false;
419
- // `click()`, never `el.checked = …`. Assigning the property flips the pixel and tells no
420
- // framework: React binds a checkbox's onChange to the CLICK event, and its value tracker
421
- // dedups the change it would otherwise synthesise from a direct assignment — so a controlled
422
- // `checked={state}` box never heard from us while the action reported success. `click()` is
423
- // the one DOM call that runs the element's ACTIVATION BEHAVIOUR (the native toggle) as well
424
- // as firing the event; `dispatchEvent` does not.
425
- let prevented = false;
426
- // On window, so it runs after every listener on the element itself whenever they were added.
427
- const probe = (e) => {
428
- prevented = e.defaultPrevented;
429
- };
430
- window.addEventListener('click', probe, { once: true, capture: false });
431
- try {
432
- el.click();
433
- }
434
- finally {
435
- window.removeEventListener('click', probe, false);
505
+ // A radio is deselected by selecting another radio, never on its own. Refusing is the same
506
+ // rule as the disabled control above: a state no user could reach must not be forced.
507
+ if ('radio' === el.type && action === ActionType.UNCHECK) {
508
+ throw new Error('cannot uncheck a radio button — a real user could not; select another radio in the group');
436
509
  }
437
- return prevented;
510
+ // Already there: touch nothing. `check` means "end up checked", not "toggle", so clicking here
511
+ // would flip it OFF, and SETTING it would hand the app an `input`/`change` no user produced —
512
+ // enough to make an app that autosaves, logs or POSTs on change act on a no-op. Reported as
513
+ // `effect.alreadyAtValue` — the same predicate, read again by executeAction — because "nothing
514
+ // was dispatched" is not the same claim as "the app holds this value". See ActionEffect.
515
+ if (alreadyAtCheckedState(el, action))
516
+ return false;
517
+ // A real click, never `el.checked = …`. Assigning the property flips the pixel and tells no
518
+ // framework: React binds a checkbox's onChange to the CLICK event, and its value tracker dedups
519
+ // the change it would otherwise synthesise from a direct assignment — so a controlled
520
+ // `checked={state}` box never heard from us while the action reported success.
521
+ //
522
+ // Dispatched rather than `el.click()` so cancellation is read off the event object itself. The
523
+ // old probe listened on `window`, which never runs when a handler calls `stopPropagation()`, so
524
+ // a cancelled activation was reported as a successful one. Activation behaviour runs either way
525
+ // — a synthetic click toggles the box, which `adversarial.check.test.ts` pins, because the
526
+ // comment this replaces asserted the opposite and was wrong.
527
+ const event = new MouseEvent('click', { bubbles: true, cancelable: true, composed: true });
528
+ // Marked as ours so the annotator's capture-phase listener lets it through: in annotate mode
529
+ // it cancels clicks to place a mark, which would otherwise swallow every action we dispatch.
530
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(event));
531
+ return !notPrevented || event.defaultPrevented;
438
532
  }
439
533
  case ActionType.SUBMIT: {
440
534
  const form = isForm(el) ? el : el.closest('form');
@@ -446,8 +540,11 @@ async function dispatchOther(el, action, args) {
446
540
  case ActionType.PRESS: {
447
541
  const key = pressKey(args);
448
542
  const code = pressCode(args, key);
449
- const down = el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true }));
450
- el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true }));
543
+ const mods = pressModifiers(args);
544
+ // Marked as ours like the click sequence: the annotator leaves annotate mode on Escape, so an
545
+ // agent pressing Escape would otherwise switch off a mode the person turned on.
546
+ const down = asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keydown', { key, code, bubbles: true, cancelable: true, ...mods })));
547
+ asSyntheticInput(() => el.dispatchEvent(new KeyboardEvent('keyup', { key, code, bubbles: true, ...mods })));
451
548
  return !down;
452
549
  }
453
550
  case ActionType.SCROLL_INTO_VIEW:
@@ -457,6 +554,7 @@ async function dispatchOther(el, action, args) {
457
554
  if (!isInput(el) || el.type !== 'file') {
458
555
  throw new Error('upload target must be a <input type="file">');
459
556
  }
557
+ assertUploadArgs(args);
460
558
  const file = new File([asString(args['content'], 'reticle test file')], asString(args['name'], 'file.txt'), {
461
559
  type: asString(args['type'], 'text/plain'),
462
560
  });
@@ -504,6 +602,8 @@ export async function executeAction(ref, action, args = {}) {
504
602
  // Click-like: scroll an off-viewport target in + hit-test the click point BEFORE installing the
505
603
  // mutation observer (scroll/hit-test never mutate the DOM, but keep the probe window clean).
506
604
  const geometry = CLICK_LIKE.has(action) ? clickGeometry(el) : NO_GEOMETRY;
605
+ // Read BEFORE dispatch, from the same predicate the dispatch branch uses.
606
+ const alreadyAtValue = alreadyAtCheckedState(el, action);
507
607
  let mutated = 0;
508
608
  const said = new AppearedText();
509
609
  const obs = new MutationObserver((records) => {
@@ -556,6 +656,9 @@ export async function executeAction(ref, action, args = {}) {
556
656
  // Omitted when there was no hold: an absent key says "this action does not hold", where a 0
557
657
  // would read as "it held for no time", which is a different and misleading claim.
558
658
  ...(heldMs > 0 ? { heldMs } : {}),
659
+ // Omitted unless it applies: an absent key means the action actually drove the control (or is
660
+ // not a check at all), which is the only reading that must not be ambiguous.
661
+ ...(alreadyAtValue ? { alreadyAtValue: true } : {}),
559
662
  };
560
663
  // Honesty caveats: a visually-occluded click is reported even though synthetic dispatch landed;
561
664
  // else synthetic hover may not fire framework enter/leave handlers (no native hit-test).
@@ -0,0 +1,9 @@
1
+ /** True while a Reticle-dispatched input event is being delivered. */
2
+ export declare function isSyntheticInput(): boolean;
3
+ /**
4
+ * Run `dispatch` marked as Reticle's own.
5
+ *
6
+ * `finally` rather than a trailing decrement: a listener that throws must not leave the flag stuck
7
+ * on, which would silently disable annotation for the rest of the page's life.
8
+ */
9
+ export declare function asSyntheticInput<T>(dispatch: () => T): T;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Is the input event currently being dispatched one that RETICLE made?
3
+ *
4
+ * The annotator listens for clicks in the capture phase and cancels them, because a click in
5
+ * annotate mode is a request to place a mark rather than a request to press a button. That is right
6
+ * for a person and wrong for the agent: `reticle_act` dispatches its clicks into the same document,
7
+ * so with the HUD expanded every agent click was swallowed while the action still reported
8
+ * `dispatched: true`. Measured on this repo's own fixture — the same click that produced
9
+ * `POST /api/login -> 200` with annotate off produced no network at all with it on.
10
+ *
11
+ * `isTrusted` looks like the answer and is not usable: it is false for everything `dispatchEvent`
12
+ * creates, which is also how every test in this package drives the annotator, so gating on it would
13
+ * make annotation untestable and would still not distinguish Reticle's clicks from the page's own
14
+ * synthetic ones.
15
+ *
16
+ * So Reticle marks its own. Dispatch is synchronous, so a plain depth counter held across the call
17
+ * is exact: any listener running inside it — capture phase included — is running because of us.
18
+ * Nested actions are counted rather than flagged, so an inner action finishing does not clear the
19
+ * mark for the outer one still in flight.
20
+ */
21
+ let depth = 0;
22
+ /** True while a Reticle-dispatched input event is being delivered. */
23
+ export function isSyntheticInput() {
24
+ return depth > 0;
25
+ }
26
+ /**
27
+ * Run `dispatch` marked as Reticle's own.
28
+ *
29
+ * `finally` rather than a trailing decrement: a listener that throws must not leave the flag stuck
30
+ * on, which would silently disable annotation for the rest of the page's life.
31
+ */
32
+ export function asSyntheticInput(dispatch) {
33
+ depth += 1;
34
+ try {
35
+ return dispatch();
36
+ }
37
+ finally {
38
+ depth -= 1;
39
+ }
40
+ }
@@ -3,7 +3,7 @@ import { buildSnapshot } from '../dom/snapshot.js';
3
3
  import { matchQuery, runQuery } from '../dom/query.js';
4
4
  import { executeAction, executeSequence, dispatchWebMcp, } from '../actions/actions.js';
5
5
  import { describe } from '../dom/a11y.js';
6
- import { sourceFor, formatSource } from '../dom/source.js';
6
+ import { documentHasSourceStamps, sourceFor, formatSource } from '../dom/source.js';
7
7
  import { themeReport } from '../dom/theme.js';
8
8
  import { echoRef, refs } from '../dom/refs.js';
9
9
  import { isButton, isInput } from '../dom/realm.js';
@@ -91,6 +91,15 @@ function inspect(ref) {
91
91
  // stamped host. `act` already did this; inspect did not, so the two tools disagreed about the same
92
92
  // ref and inspect — the tool you reach for to ask where something lives — was the one saying null.
93
93
  const source = formatSource(sourceFor(el, component?.source));
94
+ // Say WHY the source is missing rather than omitting the field and leaving the caller to guess.
95
+ // No stamp anywhere in the document is decisive: the loader is not running, so no element will
96
+ // have one this session, and the fix is a build-config change rather than anything about this
97
+ // element. Only computed when `source` is already absent, so the ordinary path pays nothing.
98
+ const sourceUnavailable = source !== undefined
99
+ ? undefined
100
+ : documentHasSourceStamps(el.ownerDocument)
101
+ ? 'This element has no source stamp. Others on the page do, so the stamping loader is running — the nearest stamped ancestor is out of range, or this element is rendered outside instrumented code.'
102
+ : 'No element in this document carries a source stamp, so the stamping loader is not running: an older adapter, a bundler whose hook never ran, or a build the plugin was dropped from. Add @reticlehq/vite-plugin (or @reticlehq/babel-plugin) to the dev build and restart the dev server to get `file:line` back.';
94
103
  const scroll = {
95
104
  scrollTop: el.scrollTop,
96
105
  scrollHeight: el.scrollHeight,
@@ -100,6 +109,7 @@ function inspect(ref) {
100
109
  return {
101
110
  ...describe(el),
102
111
  ...(source !== undefined ? { source } : {}),
112
+ ...(sourceUnavailable !== undefined ? { sourceUnavailable } : {}),
103
113
  tag: el.tagName.toLowerCase(),
104
114
  href: el.getAttribute('href') ?? undefined,
105
115
  formAction: isButton(el) || isInput(el) ? (el.form?.getAttribute('action') ?? undefined) : undefined,
@@ -2,7 +2,7 @@ import type { RedactionConfig } from '@reticlehq/core';
2
2
  /**
3
3
  * What `reticle.connect()` accepts.
4
4
  *
5
- * Split out of reticle.ts when that file crossed the 600-line cap — the same move `session-info.ts`
5
+ * Split out of reticle.ts when that file crossed the 600-line cap - the same move `session-info.ts`
6
6
  * made for the server's Session. A pure description of the SDK's public entry contract has no reason
7
7
  * to live inside the class that happens to consume it, and separating them keeps the surface every
8
8
  * integrating app actually reads reviewable on its own.
@@ -32,7 +32,7 @@ export interface ReticleConnectOptions {
32
32
  overlay?: boolean;
33
33
  /**
34
34
  * Capture request/response bodies on net.request events (dev-only; text-like content only,
35
- * sensitive keys redacted, per-body capped). Off by default — bodies cost tokens and can carry PII.
35
+ * sensitive keys redacted, per-body capped). Off by default - bodies cost tokens and can carry PII.
36
36
  */
37
37
  captureNetworkBodies?: boolean;
38
38
  /**
@@ -41,7 +41,7 @@ export interface ReticleConnectOptions {
41
41
  * React's `_debugSource.fileName` is absolute; the babel stamp is repo-relative. Build plugins that
42
42
  * can `define` a global set it themselves; frameworks that cannot (Next passes it through env,
43
43
  * Astro through its own config) hand it here instead. Taking it as a connect OPTION is what keeps
44
- * the generated app-side code plain JavaScript — the previous shape made the caller assign a global,
44
+ * the generated app-side code plain JavaScript - the previous shape made the caller assign a global,
45
45
  * and the TypeScript cast that needed shipped into a `.jsx` file and broke the build.
46
46
  */
47
47
  root?: string;
@@ -66,13 +66,13 @@ export interface ReticleConnectOptions {
66
66
  logMax?: number;
67
67
  /**
68
68
  * Mount the floating human-recorder toolbar (Record/Stop/Annotate).
69
- * Default off — purely additive, dev-only.
69
+ * Default off - purely additive, dev-only.
70
70
  */
71
71
  recorder?: boolean;
72
72
  /**
73
- * Mount the "Flag a bug" annotator: the human clicks an element that looks wrong, types what's
74
- * wrong, and Reticle emits a HUMAN_MARK the agent drains via reticle_review. Defaults to ON with the
75
- * presenter (it's the human's side of the look→act→assert loop); pass `annotate: false` to suppress.
73
+ * Mount the page annotator: expanding the HUD enters click-to-annotate mode, and Reticle emits
74
+ * a HUMAN_MARK the agent drains via reticle_review. Defaults to ON with the presenter; pass
75
+ * `annotate: false` to suppress.
76
76
  */
77
77
  annotate?: boolean;
78
78
  /** Live-control: overridable ended-border fade delay (native timer). Default 4000. */
@@ -91,13 +91,13 @@ export interface ReticleConnectOptions {
91
91
  * });
92
92
  * ```
93
93
  *
94
- * Additive only — there is no way to replace the built-in rule, because a config that could would
94
+ * Additive only - there is no way to replace the built-in rule, because a config that could would
95
95
  * eventually ship an app that leaks. `keys` strings match a key name exactly (case-insensitively);
96
96
  * a RegExp is tested against it. `allow` exempts a key from the DEFAULT rule and loses to `keys`.
97
97
  *
98
98
  * Literal `keys` strings also cross the bridge, so the daemon redacts them on the driven path,
99
99
  * where request bodies are captured raw from the network stack and never pass through this SDK.
100
- * RegExp entries and `allow` do NOT cross — see docs/usage.md ("Extending the redaction rules") for why, and what that means.
100
+ * RegExp entries and `allow` do NOT cross - see docs/usage.md ("Extending the redaction rules") for why, and what that means.
101
101
  */
102
102
  redact?: RedactionConfig;
103
103
  }
@@ -23,6 +23,15 @@ export declare function getValue(el: Element): string | undefined;
23
23
  * static for the pass's duration — the cache MUST be a per-call Map, never module-level (that would go
24
24
  * stale the instant the app mutates, the same trap the shadow-root note in query.ts documents).
25
25
  */
26
+ /**
27
+ * True when the element is inside the viewport right now: visible AND its bounding box intersects
28
+ * the window. This is what makes a scroll assertable (#398) — content below the fold of a scrolling
29
+ * container is `visible`/`present` before any scroll, so only a viewport-intersection check can tell
30
+ * "scrolled into view" from "was always in the DOM". Uses getBoundingClientRect (viewport-relative,
31
+ * already accounts for scroll position), not an IntersectionObserver, so it stays synchronous inside
32
+ * the predicate pass.
33
+ */
34
+ export declare function isInViewport(el: Element, memo?: Map<Element, boolean>): boolean;
26
35
  export declare function isVisible(el: Element, memo?: Map<Element, boolean>): boolean;
27
36
  /** Build the compact descriptor surfaced to the agent. `memo` (optional) shares the per-call
28
37
  * visibility cache with the query's state filter so ancestors aren't re-walked per element. */
package/dist/dom/a11y.js CHANGED
@@ -272,6 +272,25 @@ function selfHidden(el) {
272
272
  * static for the pass's duration — the cache MUST be a per-call Map, never module-level (that would go
273
273
  * stale the instant the app mutates, the same trap the shadow-root note in query.ts documents).
274
274
  */
275
+ /**
276
+ * True when the element is inside the viewport right now: visible AND its bounding box intersects
277
+ * the window. This is what makes a scroll assertable (#398) — content below the fold of a scrolling
278
+ * container is `visible`/`present` before any scroll, so only a viewport-intersection check can tell
279
+ * "scrolled into view" from "was always in the DOM". Uses getBoundingClientRect (viewport-relative,
280
+ * already accounts for scroll position), not an IntersectionObserver, so it stays synchronous inside
281
+ * the predicate pass.
282
+ */
283
+ export function isInViewport(el, memo) {
284
+ if (!isVisible(el, memo))
285
+ return false;
286
+ const view = el.ownerDocument.defaultView;
287
+ if (null === view)
288
+ return false;
289
+ const r = el.getBoundingClientRect();
290
+ if (r.width <= 0 || r.height <= 0)
291
+ return false;
292
+ return r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth;
293
+ }
275
294
  export function isVisible(el, memo) {
276
295
  if (!el.isConnected)
277
296
  return false;
@@ -1,15 +1,28 @@
1
1
  /**
2
- * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's "flag a bug"
3
- * UI (`data-reticle-mark`) — never observed/snapshotted. The annotator mounts by DEFAULT with the
4
- * presenter, so omitting its selector here leaked a phantom `<button>Flag a bug</button>` into every
5
- * snapshot and streamed its highlight-box mutations/transitions as if the APP had rendered them.
2
+ * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's
3
+ * UI (`data-reticle-mark`) - never observed/snapshotted. The annotator mounts by DEFAULT with the
4
+ * presenter, so omitting its selector here leaked annotation chrome into every snapshot.
6
5
  */
7
- export declare const RETICLE_OVERLAY = "[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]";
6
+ export declare const RETICLE_OVERLAY: string;
8
7
  /** Let the host app add selectors to exclude from snapshots (e.g. its own dev widgets). */
9
8
  export declare function setIgnoreSelectors(selectors: string[]): void;
10
9
  /** True if the element is part of Reticle's own presenter overlay. */
11
10
  export declare function isReticleOverlay(el: Element): boolean;
12
- /** Walk ancestors: true iff any element in the chain carries a data-reticle* attribute (Reticle's own UI). */
11
+ /**
12
+ * True iff the element is part of Reticle's OWN UI - the presenter overlay, the HUD, the synthetic
13
+ * cursor, the glow, or the annotator's marks - or lives inside one of them.
14
+ *
15
+ * The rule used to be "any ancestor carries a data-reticle* attribute", which is wrong twice over.
16
+ * `data-reticle-mark-active` sits on <html> while annotate mode is live, so the whole document
17
+ * answered yes; and `data-reticle-source` is stamped by the Vite/Babel plugins on every element the
18
+ * APP renders, so in an instrumented app - which is the only kind there is - most of the page
19
+ * answered yes too. Those attributes describe page content; they do not make it ours.
20
+ *
21
+ * Two things read this and both failed silently. `pageElementAt` (annotator) skipped every stamped
22
+ * element and anchored the note to the outermost unstamped ancestor, i.e. the app shell instead of
23
+ * the control under the cursor. `occlusion.ts` reads a yes as "nothing to report", so occlusion
24
+ * detection - a bug class Reticle advertises catching - came back clean wherever the stamp reached.
25
+ */
13
26
  export declare function isReticleUi(node: Element | null): boolean;
14
27
  /** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
15
28
  export declare function isIgnored(el: Element): boolean;
@@ -1,10 +1,9 @@
1
1
  /**
2
- * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's "flag a bug"
3
- * UI (`data-reticle-mark`) — never observed/snapshotted. The annotator mounts by DEFAULT with the
4
- * presenter, so omitting its selector here leaked a phantom `<button>Flag a bug</button>` into every
5
- * snapshot and streamed its highlight-box mutations/transitions as if the APP had rendered them.
2
+ * Selectors for Reticle's own presenter overlay (cursor, HUD, glow) + the annotator's
3
+ * UI (`data-reticle-mark`) - never observed/snapshotted. The annotator mounts by DEFAULT with the
4
+ * presenter, so omitting its selector here leaked annotation chrome into every snapshot.
6
5
  */
7
- export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark]';
6
+ export const RETICLE_OVERLAY = '[data-reticle-overlay],[data-reticle-cursor],[data-reticle-hud],[data-reticle-glow],[data-reticle-mark],[data-reticle-blocker]';
8
7
  /** Known third-party dev overlays to keep out of snapshots (Agentation, Next dev UI). */
9
8
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
10
9
  let extraIgnore = '';
@@ -16,15 +15,23 @@ export function setIgnoreSelectors(selectors) {
16
15
  export function isReticleOverlay(el) {
17
16
  return el.closest(RETICLE_OVERLAY) !== null;
18
17
  }
19
- /** Walk ancestors: true iff any element in the chain carries a data-reticle* attribute (Reticle's own UI). */
18
+ /**
19
+ * True iff the element is part of Reticle's OWN UI - the presenter overlay, the HUD, the synthetic
20
+ * cursor, the glow, or the annotator's marks - or lives inside one of them.
21
+ *
22
+ * The rule used to be "any ancestor carries a data-reticle* attribute", which is wrong twice over.
23
+ * `data-reticle-mark-active` sits on <html> while annotate mode is live, so the whole document
24
+ * answered yes; and `data-reticle-source` is stamped by the Vite/Babel plugins on every element the
25
+ * APP renders, so in an instrumented app - which is the only kind there is - most of the page
26
+ * answered yes too. Those attributes describe page content; they do not make it ours.
27
+ *
28
+ * Two things read this and both failed silently. `pageElementAt` (annotator) skipped every stamped
29
+ * element and anchored the note to the outermost unstamped ancestor, i.e. the app shell instead of
30
+ * the control under the cursor. `occlusion.ts` reads a yes as "nothing to report", so occlusion
31
+ * detection - a bug class Reticle advertises catching - came back clean wherever the stamp reached.
32
+ */
20
33
  export function isReticleUi(node) {
21
- for (let n = node; n !== null; n = n.parentElement) {
22
- for (const attr of Array.from(n.attributes)) {
23
- if (attr.name.startsWith('data-reticle'))
24
- return true;
25
- }
26
- }
27
- return false;
34
+ return node !== null && node.closest(RETICLE_OVERLAY) !== null;
28
35
  }
29
36
  /** True if the element should be excluded from snapshots/queries (Reticle overlay or dev overlay). */
30
37
  export function isIgnored(el) {
@@ -12,7 +12,7 @@ export declare function matchQuery(query: ElementQuery, state?: ElementState, li
12
12
  * Resolve a query to descriptors for the `query` MCP tool.
13
13
  *
14
14
  * `count` is carried through deliberately: the server reports match totals from it rather than from
15
- * the array, because the array is capped in transit. Dropping it here — which this function used to
16
- * do — silently put the server back to counting survivors and calling that the answer.
15
+ * the array, because the array is capped in transit. Dropping it here - which this function used to
16
+ * do - silently put the server back to counting survivors and calling that the answer.
17
17
  */
18
18
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;