@reticlehq/browser 2.8.0 → 2.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (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
@@ -1,121 +1,112 @@
1
+ import { isSyntheticInput } from '../actions/synthetic-input.js';
1
2
  import { EventType } from '@reticlehq/core';
3
+ import { isReticleUi, isReticleOverlay } from '../dom/dom-ignore.js';
2
4
  import { resolveMarkAnchor } from './mark-anchor.js';
3
5
  import { nativeSetTimeout, nativeClearTimeout } from '../timers/native-timers.js';
4
- /** The cursor must rest this long before the outline boxes the element under it — calm, not jumpy. */
6
+ import { ANNOTATOR_CSS, ANNOTATOR_ROOT_HTML, MARK_CANCEL, MARK_PENDING_GLYPH, MARK_PLACEHOLDER, MARK_SUBMIT, } from './annotator-styles.js';
7
+ /** The cursor must rest this long before the outline boxes the element under it - calm, not jumpy. */
5
8
  const HIGHLIGHT_REST_MS = 130;
6
- /** Single base attribute on every UI node (varied by VALUE) so `closest('[data-reticle-mark]')`
7
- * catches the FAB, popover, and pins in one check, and the SDK's observers ignore them all. */
9
+ const SHAKE_MS = 250;
10
+ const POP_W = 280;
11
+ const POP_H = 170;
12
+ const EDGE = 8;
8
13
  const MARK_ATTR = 'data-reticle-mark';
9
14
  const ACTIVE_ATTR = 'data-reticle-mark-active';
10
- const Z = 2147483640;
11
15
  const sel = (role) => `[${MARK_ATTR}="${role}"]`;
12
- const CSS = `
13
- ${sel('fab')}{position:fixed;left:18px;bottom:18px;z-index:${String(Z + 2)};
14
- font:500 13px/1 "Inter",system-ui,sans-serif;display:inline-flex;align-items:center;gap:7px;
15
- padding:9px 13px;border-radius:11px;cursor:pointer;color:#e9ebf2;
16
- background:linear-gradient(180deg,rgba(19,22,32,.92),rgba(13,15,22,.92));
17
- border:1px solid rgba(255,255,255,.12);box-shadow:0 10px 30px -10px rgba(0,0,0,.6);
18
- -webkit-backdrop-filter:blur(20px);backdrop-filter:blur(20px);transition:transform .12s,border-color .15s;}
19
- ${sel('fab')}:hover{transform:translateY(-1px);border-color:rgba(124,131,255,.55);}
20
- ${sel('fab')}[data-on="1"]{color:#fff;border-color:#7c83ff;background:linear-gradient(180deg,#6366f1,#4f46e5);}
21
- ${sel('dot')}{width:8px;height:8px;border-radius:50%;background:#ff7a7a;flex:none;}
22
- ${sel('fab')}[data-on="1"] ${sel('dot')}{background:#fff;}
23
- html[${ACTIVE_ATTR}] *{cursor:crosshair !important;}
24
- /* The Flag button + its popover are interactive — keep the pointer cursor over them, not crosshair. */
25
- html[${ACTIVE_ATTR}] ${sel('fab')},html[${ACTIVE_ATTR}] ${sel('fab')} *,html[${ACTIVE_ATTR}] ${sel('pop')},html[${ACTIVE_ATTR}] ${sel('pop')} *{cursor:pointer !important;}
26
- /* The outline glides to the rested element with an ease (soothing), and fades rather than snapping. */
27
- ${sel('hi')}{position:fixed;z-index:${String(Z + 1)};pointer-events:none;opacity:0;box-sizing:border-box;
28
- border:2px solid #7c83ff;border-radius:6px;background:rgba(124,131,255,.12);box-shadow:0 0 0 2px rgba(124,131,255,.22);
29
- transition:left .22s cubic-bezier(.22,1,.36,1),top .22s cubic-bezier(.22,1,.36,1),width .22s cubic-bezier(.22,1,.36,1),height .22s cubic-bezier(.22,1,.36,1),opacity .18s ease;}
30
- ${sel('hi')}[data-on="1"]{opacity:1;}
31
- ${sel('hilabel')}{position:absolute;top:-21px;left:-2px;background:#6366f1;color:#fff;
32
- font:600 10.5px/1 "Inter",system-ui,sans-serif;padding:3px 6px;border-radius:5px;white-space:nowrap;
33
- max-width:300px;overflow:hidden;text-overflow:ellipsis;}
34
- ${sel('pin')}{position:fixed;z-index:${String(Z + 1)};width:22px;height:22px;margin:-11px 0 0 -11px;
35
- border-radius:50% 50% 50% 2px;background:#ff5d5d;border:2px solid #fff;box-shadow:0 4px 12px -2px rgba(0,0,0,.5);
36
- color:#fff;font:700 11px/18px "Inter",system-ui,sans-serif;text-align:center;pointer-events:none;}
37
- ${sel('pop')}{position:fixed;z-index:${String(Z + 3)};width:280px;box-sizing:border-box;
38
- background:linear-gradient(180deg,rgba(19,22,32,.96),rgba(13,15,22,.96));border:1px solid rgba(255,255,255,.14);
39
- border-radius:14px;padding:12px;box-shadow:0 24px 60px -16px rgba(0,0,0,.7);
40
- -webkit-backdrop-filter:blur(24px);backdrop-filter:blur(24px);font:13px/1.5 "Inter",system-ui,sans-serif;color:#e9ebf2;}
41
- ${sel('pop')} .reticle-mark-where{color:#9aa0b2;font-size:11.5px;margin-bottom:7px;
42
- white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
43
- ${sel('pop')} textarea{width:100%;box-sizing:border-box;min-height:62px;resize:vertical;
44
- background:rgba(0,0,0,.3);border:1px solid rgba(255,255,255,.12);border-radius:9px;color:#e9ebf2;
45
- font:13px/1.45 "Inter",system-ui,sans-serif;padding:8px;outline:none;}
46
- ${sel('pop')} textarea:focus{border-color:#7c83ff;}
47
- ${sel('pop')} .reticle-mark-row{display:flex;gap:8px;align-items:center;margin-top:9px;}
48
- ${sel('pop')} .reticle-mark-hint{margin-right:auto;color:#6a7186;font-size:10.5px;letter-spacing:.02em;}
49
- ${sel('pop')} button{font:600 12px/1 "Inter",system-ui,sans-serif;padding:8px 12px;border-radius:9px;cursor:pointer;border:1px solid rgba(255,255,255,.12);background:rgba(255,255,255,.05);color:#cdd2e2;}
50
- ${sel('pop')} button[data-send]{background:#6366f1;border-color:#7c83ff;color:#fff;}
51
- ${sel('pop')} button[data-send]:disabled{opacity:.5;cursor:default;}`;
52
16
  export class Annotator {
53
17
  #emit;
54
18
  #now;
55
19
  #onMark;
20
+ #shouldBlock;
21
+ #onCountChange;
56
22
  #root;
57
- #fab;
58
- #pop;
59
- /** Hover outline box that shows WHICH element a click would flag (agentation-style). */
60
23
  #hi;
61
24
  #hiLabel;
62
- /** Debounce timer — boxes the element only once the cursor rests, so a fast sweep doesn't flicker. */
25
+ #selBox;
26
+ #pop;
27
+ #pending;
63
28
  #hiTimer;
29
+ #shakeTimer;
64
30
  #active = false;
65
- #markCount = 0;
31
+ #marks = [];
32
+ #editing;
33
+ #pendingKey;
34
+ #pendingTarget;
35
+ #markersBtn;
36
+ #clearBtn;
37
+ #countEl;
38
+ #accent;
66
39
  #onClick;
67
40
  #onKeydown;
68
41
  #onMove;
42
+ #onScroll;
43
+ #onResize;
44
+ #mo;
69
45
  constructor(deps) {
70
46
  this.#emit = deps.emit;
71
47
  this.#now = deps.now;
72
48
  this.#onMark = deps.onMark;
49
+ this.#shouldBlock = deps.shouldBlock ?? (() => true);
50
+ this.#onCountChange = deps.onCountChange;
73
51
  }
74
- /** Whether annotate mode is currently capturing clicks. */
75
52
  get active() {
76
53
  return this.#active;
77
54
  }
78
- /** Number of marks sent this session (drives the pin numbering). */
79
55
  get markCount() {
80
- return this.#markCount;
56
+ return this.#marks.length;
81
57
  }
82
58
  mount() {
83
59
  if (this.#root !== undefined || 'undefined' === typeof document)
84
60
  return;
85
61
  const style = document.createElement('style');
86
62
  style.setAttribute(MARK_ATTR, 'style');
87
- style.textContent = CSS;
63
+ style.textContent = ANNOTATOR_CSS;
88
64
  document.head.appendChild(style);
89
65
  const root = document.createElement('div');
90
66
  root.setAttribute(MARK_ATTR, 'root');
91
- root.innerHTML = `<button type="button" ${MARK_ATTR}="fab" aria-label="Flag a bug for the agent">
92
- <span ${MARK_ATTR}="dot"></span><span>Flag a bug</span></button>
93
- <div ${MARK_ATTR}="hi"><span ${MARK_ATTR}="hilabel"></span></div>`;
67
+ root.innerHTML = ANNOTATOR_ROOT_HTML;
94
68
  document.body.appendChild(root);
95
69
  this.#root = root;
96
- this.#fab = root.querySelector(sel('fab')) ?? undefined;
97
70
  this.#hi = root.querySelector(sel('hi')) ?? undefined;
98
71
  this.#hiLabel = root.querySelector(sel('hilabel')) ?? undefined;
99
- this.#fab?.addEventListener('click', (e) => {
100
- e.stopPropagation();
101
- this.toggle();
102
- });
103
- // Capture-phase click: intercept the FIRST click on a real page element while active.
72
+ this.#selBox = root.querySelector(sel('sel')) ?? undefined;
104
73
  this.#onClick = (ev) => this.#handleClick(ev);
105
74
  document.addEventListener('click', this.#onClick, { capture: true });
106
- // Hover outline: while active, box the element a click would flag — so you see what you're pointing at.
107
75
  this.#onMove = (ev) => this.#scheduleMove(ev);
108
76
  document.addEventListener('mousemove', this.#onMove, { passive: true, capture: true });
109
- // Escape is the universal "back out": close an open popover, else leave annotate mode.
110
- this.#onKeydown = (ev) => {
111
- if (ev.key !== 'Escape' || !this.#active)
112
- return;
113
- if (this.#pop !== undefined)
114
- this.#closePopover();
115
- else
116
- this.toggle(false);
117
- };
77
+ this.#onKeydown = (ev) => this.#handleKey(ev);
118
78
  document.addEventListener('keydown', this.#onKeydown);
79
+ this.#onScroll = () => this.#reposition();
80
+ this.#onResize = () => this.#reposition();
81
+ window.addEventListener('scroll', this.#onScroll, true);
82
+ window.addEventListener('resize', this.#onResize);
83
+ this.#mo = new MutationObserver(() => this.syncAnchors());
84
+ this.#mo.observe(document.documentElement, { childList: true, subtree: true });
85
+ if (undefined !== this.#accent)
86
+ this.setAccent(this.#accent);
87
+ }
88
+ /** Drive marker/highlight chrome from the HUD accent swatch. */
89
+ setAccent(hex) {
90
+ this.#accent = hex;
91
+ this.#root?.style.setProperty('--reticle-mark-accent', hex);
92
+ }
93
+ /** @deprecated HUD expand/collapse owns annotate mode; chrome buttons are hide/clear. */
94
+ attachFlagButton(btn) {
95
+ this.attachChrome({ markersBtn: btn });
96
+ }
97
+ attachChrome(chrome) {
98
+ this.#markersBtn = chrome.markersBtn;
99
+ this.#clearBtn = chrome.clearBtn;
100
+ this.#countEl = chrome.countEl;
101
+ this.#markersBtn?.addEventListener('click', (e) => {
102
+ e.stopPropagation();
103
+ this.#toggleMarkers();
104
+ });
105
+ this.#clearBtn?.addEventListener('click', (e) => {
106
+ e.stopPropagation();
107
+ this.clearAll();
108
+ });
109
+ this.#syncChrome();
119
110
  }
120
111
  destroy() {
121
112
  if (this.#onClick !== undefined) {
@@ -130,22 +121,30 @@ export class Annotator {
130
121
  document.removeEventListener('mousemove', this.#onMove, { capture: true });
131
122
  this.#onMove = undefined;
132
123
  }
133
- if (this.#hiTimer !== undefined) {
134
- nativeClearTimeout(this.#hiTimer);
135
- this.#hiTimer = undefined;
124
+ if (this.#onScroll !== undefined) {
125
+ window.removeEventListener('scroll', this.#onScroll, true);
126
+ this.#onScroll = undefined;
136
127
  }
128
+ if (this.#onResize !== undefined) {
129
+ window.removeEventListener('resize', this.#onResize);
130
+ this.#onResize = undefined;
131
+ }
132
+ this.#mo?.disconnect();
133
+ this.#mo = undefined;
134
+ if (this.#hiTimer !== undefined)
135
+ nativeClearTimeout(this.#hiTimer);
136
+ if (this.#shakeTimer !== undefined)
137
+ nativeClearTimeout(this.#shakeTimer);
137
138
  this.#closePopover();
138
139
  document.documentElement.removeAttribute(ACTIVE_ATTR);
139
140
  this.#root?.remove();
140
141
  document.querySelectorAll(`style[${MARK_ATTR}="style"]`).forEach((s) => s.remove());
141
142
  this.#root = undefined;
142
- this.#fab = undefined;
143
143
  this.#active = false;
144
+ this.#marks = [];
144
145
  }
145
- /** Turn annotate mode on/off. With no argument, flips the current state. */
146
146
  toggle(on) {
147
147
  this.#active = on ?? !this.#active;
148
- this.#fab?.setAttribute('data-on', this.#active ? '1' : '0');
149
148
  if (this.#active)
150
149
  document.documentElement.setAttribute(ACTIVE_ATTR, '1');
151
150
  else {
@@ -153,24 +152,116 @@ export class Annotator {
153
152
  this.#hideHighlight();
154
153
  this.#closePopover();
155
154
  }
155
+ this.#syncChrome();
156
+ }
157
+ clearAll() {
158
+ this.#closePopover();
159
+ for (const mark of this.#marks)
160
+ mark.pin.remove();
161
+ this.#marks = [];
162
+ this.#syncChrome();
163
+ }
164
+ syncAnchors() {
165
+ const route = currentRoute();
166
+ for (const mark of this.#marks) {
167
+ const live = mark.target !== undefined && document.contains(mark.target);
168
+ if (!live) {
169
+ mark.target = undefined;
170
+ mark.pin.setAttribute('data-stale', '1');
171
+ }
172
+ else {
173
+ mark.pin.removeAttribute('data-stale');
174
+ }
175
+ mark.pin.hidden = mark.route !== route;
176
+ }
177
+ this.#paintSelection();
178
+ }
179
+ #toggleMarkers() {
180
+ const root = this.#root;
181
+ if (root === undefined)
182
+ return;
183
+ const hide = '1' === root.getAttribute('data-hide') ? '0' : '1';
184
+ if ('1' === hide)
185
+ root.setAttribute('data-hide', '1');
186
+ else
187
+ root.removeAttribute('data-hide');
188
+ this.#markersBtn?.setAttribute('data-active', '0' === hide ? '0' : '1');
189
+ this.#markersBtn?.setAttribute('aria-pressed', '1' === hide ? 'true' : 'false');
190
+ }
191
+ #syncChrome() {
192
+ const n = this.#marks.length;
193
+ this.#onCountChange?.(n);
194
+ if (this.#countEl !== undefined) {
195
+ this.#countEl.textContent = String(n);
196
+ this.#countEl.hidden = 0 === n;
197
+ }
198
+ this.#clearBtn?.toggleAttribute('disabled', 0 === n);
199
+ this.#markersBtn?.toggleAttribute('disabled', 0 === n);
200
+ }
201
+ #handleKey(ev) {
202
+ if (ev.key !== 'Escape' || !this.#active)
203
+ return;
204
+ if (this.#pop !== undefined) {
205
+ ev.preventDefault();
206
+ this.#closePopover();
207
+ return;
208
+ }
209
+ this.toggle(false);
156
210
  }
157
211
  #handleClick(ev) {
158
212
  if (!this.#active)
159
213
  return;
160
- const target = ev.target;
161
- if (!(target instanceof Element))
214
+ // A click Reticle dispatched is the agent driving the app, not a person placing a mark. Without
215
+ // this, expanding the HUD silently disabled every `reticle_act` click while the action still
216
+ // reported success — a false green in our own UI. See synthetic-input.ts.
217
+ if (isSyntheticInput())
218
+ return;
219
+ const raw = ev.target;
220
+ if (!(raw instanceof Element))
221
+ return;
222
+ // The page blocker is a full-viewport shield Reticle puts up WHILE annotating, so with it on
223
+ // every click lands on Reticle's own UI and was discarded as such — annotate mode looked
224
+ // completely dead, because the one thing standing between the user and the page is the thing
225
+ // annotate mode turns on. Resolve what is underneath instead of giving up on the click.
226
+ const target = raw.hasAttribute(BLOCKER_ATTR_NAME)
227
+ ? pageElementAt(ev.clientX, ev.clientY)
228
+ : raw;
229
+ if (target === undefined)
230
+ return;
231
+ if (isReticleOverlay(target))
232
+ return;
233
+ if (true === this.#markersBtn?.contains(target))
234
+ return;
235
+ if (true === this.#clearBtn?.contains(target))
236
+ return;
237
+ if (target.closest(sel('pin')) !== null)
238
+ return;
239
+ if (target.closest(sel('pop')) !== null)
240
+ return;
241
+ if (this.#pop !== undefined) {
242
+ ev.preventDefault();
243
+ ev.stopPropagation();
244
+ this.#shakePopover();
162
245
  return;
163
- if (target.closest(`[${MARK_ATTR}]`) !== null)
164
- return; // never mark our own UI
165
- ev.preventDefault();
166
- ev.stopPropagation();
167
- this.#openPopover(target, ev.clientX, ev.clientY);
246
+ }
247
+ const el = target instanceof HTMLElement ? target : target.parentElement;
248
+ if (null === el)
249
+ return;
250
+ if (this.#shouldBlock()) {
251
+ ev.preventDefault();
252
+ ev.stopPropagation();
253
+ }
254
+ else {
255
+ ev.preventDefault();
256
+ }
257
+ const key = markKey(el);
258
+ const existing = this.#marks.find((m) => m.anchor === key && m.route === currentRoute());
259
+ if (existing !== undefined) {
260
+ this.#openEdit(existing);
261
+ return;
262
+ }
263
+ this.#openPopover(el, ev.clientX, ev.clientY);
168
264
  }
169
- /**
170
- * Debounce the outline: while the cursor is moving the human is still travelling, so we wait for a
171
- * brief rest before boxing the element under it (the box then eases into place via CSS). A pending
172
- * timer is replaced on every move, so only the resting position ever paints.
173
- */
174
265
  #scheduleMove(ev) {
175
266
  if (this.#hiTimer !== undefined)
176
267
  nativeClearTimeout(this.#hiTimer);
@@ -179,15 +270,21 @@ export class Annotator {
179
270
  this.#handleMove(ev);
180
271
  }, HIGHLIGHT_REST_MS);
181
272
  }
182
- /** Box the element under the cursor (when active, no popover open) so you see what a click flags. */
183
273
  #handleMove(ev) {
184
274
  if (this.#hi === undefined)
185
275
  return;
186
- const target = ev.target;
276
+ const raw = ev.target;
277
+ // Same shield as in #handleClick: while annotating, a full-viewport blocker sits over the page,
278
+ // so EVERY mousemove reports it as the target. Resolving only there left the outline suppressed
279
+ // on every element - a crosshair with no indication of what it was on. See #handleClick.
280
+ const target = raw instanceof Element && raw.hasAttribute(BLOCKER_ATTR_NAME)
281
+ ? pageElementAt(ev.clientX, ev.clientY)
282
+ : raw;
187
283
  const skip = !this.#active ||
188
284
  this.#pop !== undefined ||
189
285
  !(target instanceof Element) ||
190
- target.closest(`[${MARK_ATTR}]`) !== null;
286
+ isReticleOverlay(target) ||
287
+ target.closest(sel('pin')) !== null;
191
288
  if (skip) {
192
289
  this.#hi.setAttribute('data-on', '0');
193
290
  return;
@@ -205,7 +302,6 @@ export class Annotator {
205
302
  if (this.#hiLabel !== undefined)
206
303
  this.#hiLabel.textContent = describeEl(target);
207
304
  }
208
- /** Hide the hover outline (annotate mode off, or a popover took over). Cancels any pending box. */
209
305
  #hideHighlight() {
210
306
  if (this.#hiTimer !== undefined) {
211
307
  nativeClearTimeout(this.#hiTimer);
@@ -213,55 +309,152 @@ export class Annotator {
213
309
  }
214
310
  this.#hi?.setAttribute('data-on', '0');
215
311
  }
216
- #openPopover(el, x, y) {
312
+ #openEdit(mark) {
217
313
  this.#closePopover();
218
- this.#hideHighlight(); // the popover is now the focus; drop the hover box
314
+ this.#editing = mark;
315
+ this.#pendingKey = mark.anchor;
316
+ this.#pendingTarget = mark.target;
317
+ this.#hideHighlight();
318
+ // Where the element is NOW, not where it was when the mark was made. The popover is
319
+ // position:fixed, so it is placed in viewport coordinates, and the stored clientX/clientY are
320
+ // viewport coordinates from an earlier scroll position. Reopening a mark after scrolling put the
321
+ // note somewhere unrelated to the thing it annotates — further away the further you had scrolled.
322
+ const at = this.#currentPoint(mark);
323
+ this.#mountPopover(mark.label, at.x, at.y, mark.note);
324
+ this.#dropPending(at.x, at.y);
325
+ this.#paintSelection();
326
+ }
327
+ /**
328
+ * The viewport point a mark's popover should open at.
329
+ *
330
+ * Prefers the element's live box, because that is what the note is ABOUT. Falls back to the stored
331
+ * coordinates only when the element is gone, where there is nothing better and the mark is already
332
+ * shown as stale.
333
+ */
334
+ #currentPoint(mark) {
335
+ const el = mark.target;
336
+ if (el !== undefined && el.isConnected) {
337
+ const r = el.getBoundingClientRect();
338
+ if (r.width > 0 || r.height > 0)
339
+ return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
340
+ }
341
+ return { x: mark.clientX, y: mark.isFixed ? mark.clientY : mark.yDoc - window.scrollY };
342
+ }
343
+ #openPopover(el, x, y) {
219
344
  const resolved = resolveMarkAnchor(el);
220
- const pop = document.createElement('div');
221
- pop.setAttribute(MARK_ATTR, 'pop');
345
+ const key = resolved.anchor;
346
+ if (this.#pendingKey === key && this.#pop !== undefined) {
347
+ this.#shakePopover();
348
+ return;
349
+ }
350
+ this.#closePopover();
351
+ this.#hideHighlight();
352
+ this.#editing = undefined;
353
+ this.#pendingKey = key;
354
+ this.#pendingTarget = el;
222
355
  const where = resolved.source !== undefined
223
356
  ? `${resolved.label} · ${resolved.source.file}:${String(resolved.source.line)}`
224
357
  : resolved.label;
358
+ this.#mountPopover(where, x, y, '');
359
+ this.#dropPending(x, y);
360
+ this.#paintSelection();
361
+ this.#bindSubmit(resolved, x, y);
362
+ }
363
+ #mountPopover(where, x, y, initial) {
364
+ const pop = document.createElement('div');
365
+ pop.setAttribute(MARK_ATTR, 'pop');
366
+ pop.setAttribute('role', 'dialog');
367
+ pop.setAttribute('aria-label', MARK_PLACEHOLDER);
225
368
  pop.innerHTML = `<div class="reticle-mark-where"></div>
226
- <textarea placeholder="What's wrong here? The agent will read this and fix it."></textarea>
227
- <div class="reticle-mark-row"><span class="reticle-mark-hint">⌘↵ send · esc cancel</span><button type="button" data-cancel>Cancel</button>
228
- <button type="button" data-send disabled>Send to agent</button></div>`;
369
+ <textarea rows="2" placeholder="${MARK_PLACEHOLDER}"></textarea>
370
+ <div class="reticle-mark-row">
371
+ <button type="button" data-cancel>${MARK_CANCEL}</button>
372
+ <button type="button" data-send disabled>${MARK_SUBMIT}</button>
373
+ </div>`;
229
374
  const whereEl = pop.querySelector('.reticle-mark-where');
230
375
  if (whereEl !== null)
231
376
  whereEl.textContent = where;
232
- const left = Math.min(x, window.innerWidth - 296);
233
- const top = Math.min(y + 12, window.innerHeight - 170);
234
- pop.style.left = `${String(Math.max(8, left))}px`;
235
- pop.style.top = `${String(Math.max(8, top))}px`;
236
- document.body.appendChild(pop);
377
+ const pos = clampPop(x, y);
378
+ pop.style.left = `${String(pos.left)}px`;
379
+ pop.style.top = `${String(pos.top)}px`;
380
+ this.#root?.appendChild(pop);
237
381
  this.#pop = pop;
382
+ requestAnimationFrame(() => pop.setAttribute('data-in', '1'));
238
383
  const textarea = pop.querySelector('textarea');
239
384
  const send = pop.querySelector('button[data-send]');
240
- const submit = () => {
241
- const note = textarea?.value.trim() ?? '';
242
- if (0 === note.length)
243
- return; // never send an empty mark
244
- this.#sendMark(note, resolved, x, y);
245
- this.#closePopover();
246
- };
247
- textarea?.addEventListener('input', () => {
385
+ if (textarea !== null) {
386
+ textarea.value = initial;
248
387
  if (send !== null)
249
388
  send.disabled = 0 === textarea.value.trim().length;
250
- });
251
- // Keyboard ergonomics devs expect: ⌘/Ctrl+Enter sends, Esc cancels — without leaving the textarea.
252
- textarea?.addEventListener('keydown', (e) => {
253
- if ('Escape' === e.key) {
254
- e.preventDefault();
255
- this.#closePopover();
256
- }
257
- else if ('Enter' === e.key && (e.metaKey || e.ctrlKey)) {
258
- e.preventDefault();
259
- submit();
260
- }
261
- });
262
- textarea?.focus();
389
+ textarea.addEventListener('input', () => {
390
+ if (send !== null)
391
+ send.disabled = 0 === textarea.value.trim().length;
392
+ });
393
+ textarea.addEventListener('keydown', (e) => {
394
+ e.stopPropagation();
395
+ if (e.isComposing)
396
+ return;
397
+ if ('Escape' === e.key) {
398
+ e.preventDefault();
399
+ this.#closePopover();
400
+ }
401
+ else if ('Enter' === e.key && !e.shiftKey) {
402
+ e.preventDefault();
403
+ this.#submitOpen();
404
+ }
405
+ });
406
+ nativeSetTimeout(() => textarea.focus(), 50);
407
+ }
263
408
  pop.querySelector('button[data-cancel]')?.addEventListener('click', () => this.#closePopover());
264
- send?.addEventListener('click', submit);
409
+ send?.addEventListener('click', () => this.#submitOpen());
410
+ this.#pendingResolved = undefined;
411
+ this.#pendingXY = { x, y };
412
+ }
413
+ #pendingResolved;
414
+ #pendingXY = { x: 0, y: 0 };
415
+ #bindSubmit(resolved, x, y) {
416
+ this.#pendingResolved = resolved;
417
+ this.#pendingXY = { x, y };
418
+ }
419
+ #submitOpen() {
420
+ const note = this.#pop?.querySelector('textarea')?.value.trim() ?? '';
421
+ if (0 === note.length)
422
+ return;
423
+ const editing = this.#editing;
424
+ if (editing !== undefined) {
425
+ editing.note = note;
426
+ const tipn = editing.pin.querySelector(sel('tipn'));
427
+ if (tipn !== null)
428
+ tipn.textContent = note;
429
+ this.#emit(EventType.HUMAN_MARK, {
430
+ note,
431
+ anchor: editing.anchor,
432
+ label: editing.label,
433
+ route: editing.route,
434
+ });
435
+ this.#report(note, editing.anchor, editing.label, this.#marks.indexOf(editing) + 1, editing.source);
436
+ this.#closePopover();
437
+ return;
438
+ }
439
+ const resolved = this.#pendingResolved;
440
+ if (resolved === undefined)
441
+ return;
442
+ this.#sendMark(note, resolved, this.#pendingXY.x, this.#pendingXY.y);
443
+ this.#closePopover();
444
+ }
445
+ #shakePopover() {
446
+ const pop = this.#pop;
447
+ if (pop === undefined)
448
+ return;
449
+ pop.classList.remove('reticle-mark-shake');
450
+ void pop.offsetWidth;
451
+ pop.classList.add('reticle-mark-shake');
452
+ if (this.#shakeTimer !== undefined)
453
+ nativeClearTimeout(this.#shakeTimer);
454
+ this.#shakeTimer = nativeSetTimeout(() => {
455
+ pop.classList.remove('reticle-mark-shake');
456
+ this.#shakeTimer = undefined;
457
+ }, SHAKE_MS);
265
458
  }
266
459
  #sendMark(note, resolved, x, y) {
267
460
  const data = {
@@ -269,40 +462,179 @@ export class Annotator {
269
462
  anchor: resolved.anchor,
270
463
  strategy: resolved.strategy,
271
464
  label: resolved.label,
272
- route: 'undefined' === typeof location ? '' : location.pathname + location.search,
465
+ route: currentRoute(),
273
466
  };
274
467
  if (resolved.source !== undefined)
275
468
  data['source'] = resolved.source;
276
469
  this.#emit(EventType.HUMAN_MARK, data);
277
- this.#onMark?.(note, resolved.label); // echo into the live panel so the human sees the flag land
278
- this.#markCount += 1;
279
- this.#dropPin(x, y, this.#markCount);
470
+ this.#now();
471
+ this.#dropPin(resolved, note, x, y);
472
+ this.#report(note, resolved.anchor, resolved.label, this.#marks.length, sourceLabel(resolved));
280
473
  }
281
- #dropPin(x, y, n) {
474
+ /** Hand the HUD everything it needs to name this mark in one row. */
475
+ #report(note, anchor, label, index, source) {
476
+ const mark = { note, anchor, label, index };
477
+ if (source !== undefined)
478
+ mark.source = source;
479
+ this.#onMark?.(mark);
480
+ }
481
+ #dropPending(x, y) {
482
+ this.#pending?.remove();
483
+ const pending = document.createElement('div');
484
+ pending.setAttribute(MARK_ATTR, 'pending');
485
+ pending.textContent = MARK_PENDING_GLYPH;
486
+ pending.style.left = `${String(x)}px`;
487
+ pending.style.top = `${String(y)}px`;
488
+ this.#root?.appendChild(pending);
489
+ this.#pending = pending;
490
+ }
491
+ #dropPin(resolved, note, x, y) {
282
492
  if (this.#root === undefined)
283
493
  return;
284
- const pin = document.createElement('div');
494
+ const pin = document.createElement('button');
495
+ pin.type = 'button';
285
496
  pin.setAttribute(MARK_ATTR, 'pin');
286
- pin.style.left = `${String(x)}px`;
287
- pin.style.top = `${String(y)}px`;
288
- pin.textContent = String(n);
497
+ pin.setAttribute('aria-label', resolved.label);
498
+ const n = this.#marks.length + 1;
499
+ pin.innerHTML = `<span>${String(n)}</span><span ${MARK_ATTR}="tip"><span ${MARK_ATTR}="tipq"></span><span ${MARK_ATTR}="tipn"></span></span>`;
500
+ const q = pin.querySelector(sel('tipq'));
501
+ const tn = pin.querySelector(sel('tipn'));
502
+ if (q !== null)
503
+ q.textContent = resolved.label;
504
+ if (tn !== null)
505
+ tn.textContent = note;
506
+ const isFixed = isElementFixed(this.#pendingTarget);
507
+ const yDoc = isFixed ? y : y + window.scrollY;
508
+ pin.style.left = `${String((x / window.innerWidth) * 100)}%`;
509
+ pin.style.top = `${String(isFixed ? y : yDoc - window.scrollY)}px`;
289
510
  this.#root.appendChild(pin);
290
- // The pin fades after a moment so it confirms the mark landed without cluttering the page.
291
- const ref = pin;
292
- this.#now(); // touch the injected clock (kept for parity with the SDK's timing seams)
293
- // Native (pre-bound) timer so a frozen app clock / fake-timer library can't stall the SDK's own
294
- // UI — every other SDK timer uses the native primitives for exactly this reason.
295
- nativeSetTimeout(() => ref.remove(), 2600);
511
+ const mark = {
512
+ id: `${resolved.anchor}:${String(this.#now())}:${String(n)}`,
513
+ note,
514
+ label: resolved.label,
515
+ anchor: resolved.anchor,
516
+ route: currentRoute(),
517
+ xPct: (x / window.innerWidth) * 100,
518
+ yDoc,
519
+ clientX: x,
520
+ clientY: y,
521
+ isFixed,
522
+ pin,
523
+ target: this.#pendingTarget,
524
+ source: sourceLabel(resolved),
525
+ };
526
+ pin.addEventListener('click', (e) => {
527
+ e.preventDefault();
528
+ e.stopPropagation();
529
+ if (!this.#active)
530
+ return;
531
+ this.#openEdit(mark);
532
+ });
533
+ this.#marks.push(mark);
534
+ this.#syncChrome();
535
+ }
536
+ #paintSelection() {
537
+ const box = this.#selBox;
538
+ if (box === undefined)
539
+ return;
540
+ const el = this.#pendingTarget;
541
+ if (el === undefined || !document.contains(el) || this.#pop === undefined) {
542
+ box.hidden = true;
543
+ return;
544
+ }
545
+ const rect = el.getBoundingClientRect();
546
+ box.hidden = false;
547
+ box.style.left = `${String(rect.left)}px`;
548
+ box.style.top = `${String(rect.top)}px`;
549
+ box.style.width = `${String(rect.width)}px`;
550
+ box.style.height = `${String(rect.height)}px`;
551
+ }
552
+ #reposition() {
553
+ for (const mark of this.#marks) {
554
+ if (mark.isFixed) {
555
+ mark.pin.style.top = `${String(mark.clientY)}px`;
556
+ continue;
557
+ }
558
+ mark.pin.style.top = `${String(mark.yDoc - window.scrollY)}px`;
559
+ }
560
+ this.#repositionPopover();
561
+ this.#paintSelection();
562
+ }
563
+ /**
564
+ * Keep an OPEN popover attached to its element while the page scrolls.
565
+ *
566
+ * The pins were repositioned here and the popover was not, so scrolling with a note open slid the
567
+ * note off its target and left it floating over unrelated content.
568
+ */
569
+ #repositionPopover() {
570
+ const pop = this.#pop;
571
+ const mark = this.#editing;
572
+ if (pop === undefined || mark === undefined)
573
+ return;
574
+ const at = this.#currentPoint(mark);
575
+ const pos = clampPop(at.x, at.y);
576
+ pop.style.left = `${String(pos.left)}px`;
577
+ pop.style.top = `${String(pos.top)}px`;
296
578
  }
297
579
  #closePopover() {
298
580
  this.#pop?.remove();
299
581
  this.#pop = undefined;
582
+ this.#pending?.remove();
583
+ this.#pending = undefined;
584
+ this.#pendingKey = undefined;
585
+ this.#pendingTarget = undefined;
586
+ this.#pendingResolved = undefined;
587
+ this.#editing = undefined;
588
+ if (this.#selBox !== undefined)
589
+ this.#selBox.hidden = true;
300
590
  }
301
591
  }
592
+ /** "file:line" for a resolved anchor, when the app carries source stamps. */
593
+ function sourceLabel(resolved) {
594
+ return resolved.source === undefined
595
+ ? undefined
596
+ : `${resolved.source.file}:${String(resolved.source.line)}`;
597
+ }
598
+ function currentRoute() {
599
+ return 'undefined' === typeof location ? '' : location.pathname + location.search;
600
+ }
601
+ function markKey(el) {
602
+ return resolveMarkAnchor(el).anchor;
603
+ }
604
+ function isElementFixed(el) {
605
+ if (el === undefined || 'undefined' === typeof getComputedStyle)
606
+ return false;
607
+ const pos = getComputedStyle(el).position;
608
+ return 'fixed' === pos || 'sticky' === pos;
609
+ }
610
+ /** The blocker Reticle raises over the page while annotating; see the click handler. */
611
+ const BLOCKER_ATTR_NAME = 'data-reticle-blocker';
302
612
  /**
303
- * A short label for the hovered element — testid > aria-label > tag + text. Cheap on purpose (runs
304
- * on every mousemove, so it does NOT call resolveMarkAnchor; the full anchor is resolved on click).
613
+ * The topmost element of the HOST PAGE at a point, seeing past Reticle's own overlays.
614
+ *
615
+ * `elementsFromPoint` returns the whole stack topmost-first, so the page element is simply the first
616
+ * entry that is not ours. Returns undefined where the API is unavailable (jsdom) or nothing but
617
+ * Reticle UI is there, and the caller drops the click rather than guessing at a target.
305
618
  */
619
+ function pageElementAt(x, y) {
620
+ const from = document.elementsFromPoint.bind(document);
621
+ if (from === undefined)
622
+ return undefined;
623
+ for (const el of from(x, y)) {
624
+ if (!isReticleUi(el))
625
+ return el;
626
+ }
627
+ return undefined;
628
+ }
629
+ function clampPop(x, y) {
630
+ const vw = window.innerWidth;
631
+ const vh = window.innerHeight;
632
+ const left = Math.min(Math.max(x, EDGE + POP_W / 2), vw - EDGE - POP_W / 2);
633
+ let top = y + 12;
634
+ if (top + POP_H > vh - EDGE)
635
+ top = Math.max(EDGE, y - POP_H - 12);
636
+ return { left, top };
637
+ }
306
638
  function describeEl(el) {
307
639
  const testid = el.getAttribute('data-testid');
308
640
  if (testid !== null && testid.length > 0)
@@ -314,7 +646,6 @@ function describeEl(el) {
314
646
  const text = (el.textContent ?? '').trim().replace(/\s+/g, ' ').slice(0, 40);
315
647
  return text.length > 0 ? `${tag} "${text}"` : tag;
316
648
  }
317
- /** Construct + mount the annotator (mirrors installRecorder's ergonomics). */
318
649
  export function installAnnotator(deps) {
319
650
  const annotator = new Annotator(deps);
320
651
  annotator.mount();