@reticlehq/browser 2.4.0 → 2.5.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 (71) hide show
  1. package/dist/actions/actions-dom.js +10 -10
  2. package/dist/actions/actions.d.ts +13 -0
  3. package/dist/actions/actions.js +45 -15
  4. package/dist/actions/scroll.js +1 -1
  5. package/dist/commands/commands.js +27 -22
  6. package/dist/connect-options.d.ts +6 -0
  7. package/dist/dom/a11y.js +20 -20
  8. package/dist/dom/auto-anchor.js +1 -1
  9. package/dist/dom/blind-spots.js +1 -1
  10. package/dist/dom/chart.js +5 -5
  11. package/dist/dom/desktop-capture.d.ts +2 -1
  12. package/dist/dom/desktop-capture.js +4 -4
  13. package/dist/dom/occlusion.js +2 -2
  14. package/dist/dom/query.js +28 -18
  15. package/dist/dom/realm.js +13 -13
  16. package/dist/dom/refs.d.ts +14 -0
  17. package/dist/dom/refs.js +20 -1
  18. package/dist/dom/shadow-registry.js +2 -2
  19. package/dist/dom/snapshot.js +7 -7
  20. package/dist/dom/source.js +3 -3
  21. package/dist/dom/theme.js +6 -6
  22. package/dist/dom/virtualized.js +1 -1
  23. package/dist/observers/blind-spots.js +2 -2
  24. package/dist/observers/console.js +3 -3
  25. package/dist/observers/dom.js +5 -5
  26. package/dist/observers/download.d.ts +2 -1
  27. package/dist/observers/download.js +2 -2
  28. package/dist/observers/focus.js +1 -1
  29. package/dist/observers/frames.js +1 -1
  30. package/dist/observers/health.js +70 -2
  31. package/dist/observers/install-all.d.ts +2 -1
  32. package/dist/observers/ipc.d.ts +2 -7
  33. package/dist/observers/ipc.js +7 -7
  34. package/dist/observers/network-body.js +2 -2
  35. package/dist/observers/network-redact.js +5 -5
  36. package/dist/observers/network-stream.js +2 -2
  37. package/dist/observers/network.d.ts +3 -3
  38. package/dist/observers/network.js +9 -9
  39. package/dist/observers/perf.js +1 -1
  40. package/dist/observers/scroll.js +1 -1
  41. package/dist/observers/state.js +1 -1
  42. package/dist/observers/storage.js +7 -7
  43. package/dist/presenter/presenter-brand.d.ts +19 -0
  44. package/dist/presenter/presenter-brand.js +21 -0
  45. package/dist/presenter/presenter-config.d.ts +17 -0
  46. package/dist/presenter/presenter-config.js +21 -0
  47. package/dist/presenter/presenter-controls.d.ts +1 -11
  48. package/dist/presenter/presenter-controls.js +6 -7
  49. package/dist/presenter/presenter-log.js +2 -2
  50. package/dist/presenter/presenter-run-state.js +1 -1
  51. package/dist/presenter/presenter-styles.d.ts +1 -1
  52. package/dist/presenter/presenter-styles.js +20 -9
  53. package/dist/presenter/presenter-tally.js +3 -3
  54. package/dist/presenter/presenter-test-helpers.d.ts +2 -1
  55. package/dist/presenter/presenter-test-helpers.js +2 -2
  56. package/dist/presenter/presenter-verbs.js +16 -11
  57. package/dist/presenter/presenter.js +13 -10
  58. package/dist/recorder/recorder.js +10 -9
  59. package/dist/registry/store-adapters.js +1 -1
  60. package/dist/registry/stores.js +3 -3
  61. package/dist/reticle-presenter-helpers.js +3 -3
  62. package/dist/reticle.js +31 -13
  63. package/dist/review/annotator.js +7 -7
  64. package/dist/security/serialization.js +9 -9
  65. package/dist/session-continuity.d.ts +17 -0
  66. package/dist/session-continuity.js +42 -0
  67. package/dist/timers/clock.js +4 -4
  68. package/dist/timers/native-console.js +1 -1
  69. package/dist/timers/native-timers.js +4 -4
  70. package/dist/transport/transport.js +5 -5
  71. package/package.json +6 -4
@@ -7,8 +7,8 @@ import { CONTROLS_CSS } from './presenter-controls.js';
7
7
  */
8
8
  // No web-font @import: a dev-only SDK must not make the host page issue a cross-origin request to
9
9
  // fonts.googleapis.com (and trip a `style-src 'self'` CSP with a console violation) just for HUD
10
- // polish. The font vars below already prefer Inter/IBM Plex Serif if present and fall back to the
11
- // system stack otherwise — so the overlay stays self-contained and never touches the network.
10
+ // polish. The font var below already prefers Inter if present and falls back to the system stack
11
+ // otherwise — so the overlay stays self-contained and never touches the network.
12
12
  export const PRESENTER_CSS = `
13
13
  [data-reticle-glow]{position:fixed;inset:0;pointer-events:none;z-index:2147483600;opacity:0;
14
14
  transition:opacity .25s ease;box-shadow:inset 0 0 0 3px rgba(99,102,241,.9),inset 0 0 28px 6px rgba(99,102,241,.45);}
@@ -36,7 +36,6 @@ export const PRESENTER_CSS = `
36
36
  --reticle-line:rgba(255,255,255,.09);--reticle-line2:rgba(255,255,255,.05);
37
37
  --reticle-read:#54d2e6;--reticle-ok:#3dd7a6;--reticle-bad:#ff7a7a;
38
38
  --reticle-font:"Inter",system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
39
- --reticle-serif:"IBM Plex Serif",Georgia,"Times New Roman",serif;
40
39
  position:fixed;left:50%;right:auto;bottom:20px;box-sizing:border-box;
41
40
  width:384px;height:468px;max-width:calc(100vw - 32px);max-height:calc(100vh - 32px);
42
41
  display:flex;flex-direction:column;overflow:hidden;text-align:left;z-index:2147483647;pointer-events:none;
@@ -68,11 +67,15 @@ export const PRESENTER_CSS = `
68
67
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud][data-on="1"]{pointer-events:auto;}
69
68
  [data-reticle-hud] .reticle-hud-head{display:flex;align-items:center;gap:8px;flex:none;
70
69
  padding:12px 12px 12px 15px;border-bottom:1px solid var(--reticle-line2);}
71
- [data-reticle-hud] .reticle-dot{width:9px;height:9px;border-radius:50%;flex:none;background:var(--reticle-accent);
72
- animation:reticle-breathe 2.6s ease-in-out infinite;}
73
- @keyframes reticle-breathe{0%,100%{box-shadow:0 0 0 0 var(--reticle-accent),0 0 7px 1px var(--reticle-accent);opacity:.85}
74
- 50%{box-shadow:0 0 0 4px var(--reticle-accent-soft),0 0 15px 3px var(--reticle-accent);opacity:1}}
75
- [data-reticle-hud] .reticle-brand{font-family:var(--reticle-serif);font-weight:500;font-size:15px;letter-spacing:.01em;color:var(--reticle-fg);}
70
+ /* Brand block: both marks always in the DOM, CSS picks one off data-reticle-min (see below) so the
71
+ collapsed/expanded swap needs no JS state. Artwork paints with currentColor, so it inherits
72
+ --reticle-fg and reads correctly on any HUD surface instead of baking in the export's #FAFAFA. */
73
+ [data-reticle-hud] .reticle-brand{display:inline-flex;align-items:center;flex:none;color:var(--reticle-fg);}
74
+ [data-reticle-hud] .reticle-mark{display:none;height:18px;width:auto;flex:none;}
75
+ [data-reticle-hud] .reticle-wordmark{display:block;height:16px;width:auto;flex:none;}
76
+ /* Visually hidden, still announced — the marks are aria-hidden artwork, this names the HUD. */
77
+ [data-reticle-hud] .reticle-brand-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;
78
+ overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0;}
76
79
  [data-reticle-hud] .reticle-head-sp{flex:1;}
77
80
  [data-reticle-hud] .reticle-live{display:none;flex:1;min-width:0;color:var(--reticle-muted);font-size:12.5px;
78
81
  white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
@@ -103,7 +106,6 @@ export const PRESENTER_CSS = `
103
106
  [data-reticle-hud] .reticle-chip[data-mode="idle"]{display:none;}
104
107
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud]{height:50px;border-radius:25px;cursor:pointer;}
105
108
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-hud-head{border-bottom:none;height:50px;padding:0 12px 0 16px;}
106
- [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-brand,
107
109
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-chip,
108
110
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-head-sp,
109
111
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-min-btn],
@@ -113,6 +115,9 @@ export const PRESENTER_CSS = `
113
115
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-log],
114
116
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] [data-reticle-foot],
115
117
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-banner{display:none;}
118
+ /* Collapsed to a pill: mark only, beside the dot. Expanded: the full wordmark. */
119
+ [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-wordmark{display:none;}
120
+ [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-mark{display:block;}
116
121
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-live{display:block;}
117
122
  [data-reticle-overlay][data-reticle-min="1"] [data-reticle-hud] .reticle-maxhint{display:inline-flex;}
118
123
  [data-reticle-mode="reading"] [data-reticle-glow][data-on="1"]{
@@ -122,5 +127,11 @@ export const PRESENTER_CSS = `
122
127
  [data-reticle-overlay][data-reticle-throttled="1"] [data-reticle-glow][data-on="1"]{
123
128
  box-shadow:inset 0 0 0 3px rgba(251,191,36,.9),inset 0 0 28px 6px rgba(251,191,36,.45);}
124
129
  [data-reticle-overlay][data-reticle-throttled="1"] [data-reticle-hud]{--reticle-accent:#fbbf24;--reticle-accent-soft:rgba(251,191,36,.16);}
130
+ /* The two INFINITE animations — the header dot and the border glow — are pure decoration and were
131
+ blinking regardless of the user's motion preference. Everything else the presenter animates is a
132
+ one-shot (ripple, tally pop) or a transition, which reduce-motion tolerates. */
133
+ @media (prefers-reduced-motion:reduce){
134
+ [data-reticle-glow][data-on="1"],
135
+ [data-reticle-glow][data-on="1"][data-busy="1"]{animation:none;}}
125
136
  ${LOG_CSS}
126
137
  ${CONTROLS_CSS}`;
@@ -21,14 +21,14 @@ export function renderTally(el, runLog, prev) {
21
21
  const next = countVerdicts(runLog);
22
22
  if (el === undefined)
23
23
  return next;
24
- if (next.passes === 0 && next.fails === 0) {
24
+ if (0 === next.passes && 0 === next.fails) {
25
25
  el.setAttribute('hidden', '');
26
26
  return next;
27
27
  }
28
28
  const bumpPass = next.passes > prev.passes ? ' data-bump="1"' : '';
29
29
  const bumpFail = next.fails > prev.fails ? ' data-bump="1"' : '';
30
- const dimP = next.passes === 0 ? ' data-z="1"' : '';
31
- const dimF = next.fails === 0 ? ' data-z="1"' : '';
30
+ const dimP = 0 === next.passes ? ' data-z="1"' : '';
31
+ const dimF = 0 === next.fails ? ' data-z="1"' : '';
32
32
  el.removeAttribute('hidden');
33
33
  el.innerHTML =
34
34
  `<span class="reticle-t-pass"${dimP}${bumpPass}>✓ ${String(next.passes)}</span>` +
@@ -7,9 +7,10 @@ export declare const wait: (ms: number) => Promise<void>;
7
7
  export declare const until: (pred: () => boolean, ms?: number) => Promise<boolean>;
8
8
  export declare const dataOn: () => string | null;
9
9
  export declare const dataBusy: () => string | null;
10
- export interface GlowFlips {
10
+ interface GlowFlips {
11
11
  enters: number;
12
12
  exits: number;
13
13
  stop: () => void;
14
14
  }
15
15
  export declare function trackGlowFlips(glow: HTMLElement): GlowFlips;
16
+ export {};
@@ -22,9 +22,9 @@ export function trackGlowFlips(glow) {
22
22
  if (r.attributeName !== 'data-on')
23
23
  continue;
24
24
  const v = glow.getAttribute('data-on');
25
- if (v === '1')
25
+ if ('1' === v)
26
26
  counts.enters++;
27
- if (v === '0')
27
+ if ('0' === v)
28
28
  counts.exits++;
29
29
  }
30
30
  });
@@ -1,26 +1,31 @@
1
1
  // Present-tense verb for an action label in the presenter HUD (cursor status + act-log row).
2
2
  // Presenter-only UI copy — never a wire string. Shared by presenter.ts (cursor) and reticle.ts
3
3
  // (act-log row) so the two stay in lockstep.
4
+ //
5
+ // The CASES are wire values though, and they are core's ActionType. Spelled as free strings here,
6
+ // a rename in core left this switch silently falling through to `default` — which returns the raw
7
+ // action name, so the HUD degrades to "webmcp Save" instead of a verb and nothing fails.
8
+ import { ActionType } from '@reticlehq/core';
4
9
  export function actionVerb(action) {
5
10
  switch (action) {
6
- case 'click':
7
- case 'dblclick':
11
+ case ActionType.CLICK:
12
+ case ActionType.DBLCLICK:
8
13
  return 'Clicking';
9
- case 'fill':
10
- case 'type':
14
+ case ActionType.FILL:
15
+ case ActionType.TYPE:
11
16
  return 'Typing into';
12
- case 'hover':
17
+ case ActionType.HOVER:
13
18
  return 'Hovering';
14
- case 'select':
19
+ case ActionType.SELECT:
15
20
  return 'Selecting';
16
- case 'submit':
21
+ case ActionType.SUBMIT:
17
22
  return 'Submitting';
18
- case 'check':
19
- case 'uncheck':
23
+ case ActionType.CHECK:
24
+ case ActionType.UNCHECK:
20
25
  return 'Toggling';
21
- case 'upload':
26
+ case ActionType.UPLOAD:
22
27
  return 'Uploading to';
23
- case 'drag':
28
+ case ActionType.DRAG:
24
29
  return 'Dragging';
25
30
  default:
26
31
  return action;
@@ -1,10 +1,11 @@
1
- import { ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
1
+ import { ActionType, ReticleCommand, PresenterMode, SessionState, isPresenterTone, isSessionState, } from '@reticlehq/core';
2
2
  import { refs } from '../dom/refs.js';
3
3
  import { actionVerb } from './presenter-verbs.js';
4
4
  import { nativeSetTimeout, nativeClearTimeout, nativeNow } from '../timers/native-timers.js';
5
5
  import { LOG_KIND, CHIP_LABEL, DATA_RETICLE_LOG, HUMAN_ROW_PREFIX, clampLogMax, formatElapsed, humanDuration, appendLogRow, } from './presenter-log.js';
6
6
  import { PRESENTER_CSS } from './presenter-styles.js';
7
- import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, DEFAULT_PACE, GlowPhase, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, MIN_ATTR, THROTTLED_ATTR, } from './presenter-config.js';
7
+ import { BRAND_HTML } from './presenter-brand.js';
8
+ import { BorderMode, DEFAULT_BORDER_MODE, DATA_BUSY, BUSY_OFF, effectivePaceMs, GlowPhase, IDLE_AFTER_MS, HEARTBEAT_MS, IDLE_NOTICE_MS, IDLE_END_MS, IDLE_END_MIN_MS, GLOW_FADE_MS, GLOW_ON, GLOW_OFF, DATA_ON, MIN_ATTR, THROTTLED_ATTR, } from './presenter-config.js';
8
9
  import { buildRunState } from './presenter-run-state.js';
9
10
  import { moveCursor, ringAround, spawnRipple, pace } from './presenter-effects.js';
10
11
  import { GlowController } from './presenter-glow.js';
@@ -57,7 +58,7 @@ export class Presenter {
57
58
  #onControl;
58
59
  #panel;
59
60
  constructor(options = {}) {
60
- this.#paceMs = options.paceMs ?? DEFAULT_PACE;
61
+ this.#paceMs = effectivePaceMs(options.paceMs);
61
62
  this.#now = options.now ?? nativeNow;
62
63
  this.#heartbeatMs = options.heartbeatMs ?? HEARTBEAT_MS;
63
64
  this.#idleNoticeMs = options.idleNoticeMs ?? IDLE_NOTICE_MS;
@@ -111,7 +112,7 @@ export class Presenter {
111
112
  return void this.#panel.setFlows(a['flows']);
112
113
  const state = a['state'];
113
114
  const tone = a['tone'];
114
- const text = typeof a['text'] === 'string' && a['text'].length > 0 ? a['text'] : undefined;
115
+ const text = 'string' === typeof a['text'] && a['text'].length > 0 ? a['text'] : undefined;
115
116
  if (isSessionState(state))
116
117
  this.setState(state, text, isPresenterTone(tone) ? tone : undefined);
117
118
  }
@@ -124,7 +125,7 @@ export class Presenter {
124
125
  this.#pruneLog();
125
126
  }
126
127
  mount() {
127
- if (this.#root !== undefined || typeof document === 'undefined')
128
+ if (this.#root !== undefined || 'undefined' === typeof document)
128
129
  return;
129
130
  const style = document.createElement('style');
130
131
  style.setAttribute('data-reticle-overlay', '');
@@ -137,7 +138,7 @@ export class Presenter {
137
138
  <div data-reticle-cursor></div>
138
139
  <div data-reticle-ring></div>
139
140
  <div data-reticle-hud>
140
- <div class="reticle-hud-head"><span class="reticle-dot"></span><span class="reticle-brand">Reticle</span><span class="reticle-chip" data-reticle-chip></span><span class="reticle-tally" data-reticle-tally hidden></span><span class="reticle-live"></span><span class="reticle-head-sp"></span><button type="button" data-reticle-min-btn title="Minimise" aria-label="Minimise the panel">⌄</button>${CONTROLS_HEAD_HTML}<span class="reticle-maxhint" aria-hidden="true">⌃</span></div>
141
+ <div class="reticle-hud-head">${BRAND_HTML}<span class="reticle-chip" data-reticle-chip></span><span class="reticle-tally" data-reticle-tally hidden></span><span class="reticle-live"></span><span class="reticle-head-sp"></span><button type="button" data-reticle-min-btn title="Minimise" aria-label="Minimise the panel">⌄</button>${CONTROLS_HEAD_HTML}<span class="reticle-maxhint" aria-hidden="true">⌃</span></div>
141
142
  <div class="reticle-act-strip"><span class="reticle-act">idle</span></div>
142
143
  ${CONTROLS_BANNER_HTML}
143
144
  <div ${DATA_RETICLE_LOG}></div>
@@ -162,7 +163,7 @@ export class Presenter {
162
163
  setMin(true);
163
164
  });
164
165
  root.querySelector('.reticle-hud-head')?.addEventListener('click', () => {
165
- if (root.getAttribute(MIN_ATTR) === '1')
166
+ if ('1' === root.getAttribute(MIN_ATTR))
166
167
  setMin(false); // clicking the minimised bar restores
167
168
  });
168
169
  this.#glowCtl.setElements(this.#glow, this.#cursor);
@@ -348,7 +349,7 @@ export class Presenter {
348
349
  if (this.#log === undefined)
349
350
  return undefined;
350
351
  const trimmed = text.trim();
351
- if (trimmed.length === 0)
352
+ if (0 === trimmed.length)
352
353
  return undefined;
353
354
  this.#logBaseMs ??= ms;
354
355
  // Structured run-log entry (mirrors the DOM row) for the exported run state, capped like the DOM.
@@ -381,7 +382,7 @@ export class Presenter {
381
382
  }
382
383
  /** Back-compat: narration appends to the live log (append-only, never overwrites). */
383
384
  narrate(text, level = 'info') {
384
- const line = level === 'info' ? text : `[${level}] ${text}`;
385
+ const line = 'info' === level ? text : `[${level}] ${text}`;
385
386
  return this.log(LOG_KIND.NARRATION, line);
386
387
  }
387
388
  #pruneLog() {
@@ -417,7 +418,9 @@ export class Presenter {
417
418
  moveCursor(this.#cursor, cx, cy);
418
419
  ringAround(this.#ring, rect);
419
420
  await pace(this.#paceMs);
420
- if (action === 'click' || action === 'dblclick' || action === 'submit')
421
+ if (ActionType.CLICK === action ||
422
+ ActionType.DBLCLICK === action ||
423
+ ActionType.SUBMIT === action)
421
424
  spawnRipple(this.#root, cx, cy);
422
425
  }
423
426
  }
@@ -33,6 +33,7 @@ const ANNOTATION_LABEL = {
33
33
  [AnnotationKind.ASSERT_SIGNAL]: 'assert signal',
34
34
  [AnnotationKind.ASSERT_VISIBLE]: 'assert visible',
35
35
  [AnnotationKind.ASSERT_STATE]: 'assert state',
36
+ [AnnotationKind.ASSERT_NET]: 'assert request',
36
37
  [AnnotationKind.MARK_DYNAMIC]: 'mark dynamic',
37
38
  [AnnotationKind.SUCCESS_STATE]: 'success state',
38
39
  [AnnotationKind.INTENT]: 'intent',
@@ -64,13 +65,13 @@ export function anchorFor(el) {
64
65
  function isTextbox(el) {
65
66
  if (el instanceof HTMLTextAreaElement)
66
67
  return true;
67
- return el instanceof HTMLInputElement && inputRole(el) === 'textbox';
68
+ return el instanceof HTMLInputElement && 'textbox' === inputRole(el);
68
69
  }
69
70
  function inputRole(el) {
70
71
  const type = el.type.toLowerCase();
71
- if (type === 'checkbox')
72
+ if ('checkbox' === type)
72
73
  return 'checkbox';
73
- if (type === 'radio')
74
+ if ('radio' === type)
74
75
  return 'radio';
75
76
  if (['text', 'email', 'tel', 'url', 'search', 'password', ''].includes(type))
76
77
  return 'textbox';
@@ -163,7 +164,7 @@ class Recorder {
163
164
  mount() {
164
165
  if (this.#root !== undefined)
165
166
  return;
166
- if (typeof document === 'undefined')
167
+ if ('undefined' === typeof document)
167
168
  return;
168
169
  this.#buildToolbar();
169
170
  this.#installCapture();
@@ -222,7 +223,7 @@ class Recorder {
222
223
  // semantic step (CHECK/UNCHECK/SELECT). Recording a CLICK here too would replay any onChange
223
224
  // side effect twice (analytics, dependent-field resets), so skip the duplicate.
224
225
  if ((target instanceof HTMLInputElement &&
225
- (inputRole(target) === 'checkbox' || inputRole(target) === 'radio')) ||
226
+ ('checkbox' === inputRole(target) || 'radio' === inputRole(target))) ||
226
227
  target instanceof HTMLSelectElement) {
227
228
  return;
228
229
  }
@@ -244,12 +245,12 @@ class Recorder {
244
245
  const target = this.#ignore(ev);
245
246
  if (target === undefined)
246
247
  return;
247
- if (target instanceof HTMLInputElement && inputRole(target) === 'checkbox') {
248
+ if (target instanceof HTMLInputElement && 'checkbox' === inputRole(target)) {
248
249
  this.#flushPendingFill();
249
250
  this.#steps.push(buildStep(target, target.checked ? ActionType.CHECK : ActionType.UNCHECK));
250
251
  return;
251
252
  }
252
- if (target instanceof HTMLInputElement && inputRole(target) === 'radio') {
253
+ if (target instanceof HTMLInputElement && 'radio' === inputRole(target)) {
253
254
  this.#flushPendingFill();
254
255
  this.#steps.push(buildStep(target, ActionType.CHECK));
255
256
  return;
@@ -328,7 +329,7 @@ class Recorder {
328
329
  #start() {
329
330
  // A fresh span — no leakage from a previous Record→Stop cycle.
330
331
  this.#steps = [];
331
- this.#startPath = typeof location === 'undefined' ? undefined : location.pathname;
332
+ this.#startPath = 'undefined' === typeof location ? undefined : location.pathname;
332
333
  this.#annotations = [];
333
334
  this.#pendingFill = undefined;
334
335
  this.#draft = undefined;
@@ -340,7 +341,7 @@ class Recorder {
340
341
  const name = this.#nameField() ?? this.#deps.defaultName ?? DEFAULT_NAME;
341
342
  const flow = compileRecording(name, this.#steps, this.#annotations, this.#deps.now(), this.#startPath);
342
343
  this.#deps.emit(EventType.FLOW_RECORDED, { name, flow });
343
- this.#setStatus(this.#steps.length === 0 ? RECORDER_EMPTY_MSG : STATUS_IDLE);
344
+ this.#setStatus(0 === this.#steps.length ? RECORDER_EMPTY_MSG : STATUS_IDLE);
344
345
  this.#setPhase(RecorderPhase.IDLE);
345
346
  this.#closeMenu();
346
347
  }
@@ -181,7 +181,7 @@ export function recoilStore(atoms, snapshot, subscribe) {
181
181
  };
182
182
  }
183
183
  function stopSubscription(handle) {
184
- if (typeof handle === 'function') {
184
+ if ('function' === typeof handle) {
185
185
  handle();
186
186
  return;
187
187
  }
@@ -20,12 +20,12 @@ function isStoreLike(source) {
20
20
  // Zustand's `create` returns a CALLABLE hook that also carries getState/subscribe, so a store can be
21
21
  // typeof 'function' as well as 'object'. What distinguishes it from a plain getter is those two members
22
22
  // — never the typeof. (Checking only for 'object' left every Zustand app on pull-only reads.)
23
- if (source === null)
23
+ if (null === source)
24
24
  return false;
25
25
  if (typeof source !== 'object' && typeof source !== 'function')
26
26
  return false;
27
27
  const candidate = source;
28
- return typeof candidate.getState === 'function' && typeof candidate.subscribe === 'function';
28
+ return 'function' === typeof candidate.getState && 'function' === typeof candidate.subscribe;
29
29
  }
30
30
  /**
31
31
  * App calls this once per store. PREFER passing the store itself — `registerStore('app', useApp)` —
@@ -43,7 +43,7 @@ export function registerStore(name, source, subscribe) {
43
43
  return;
44
44
  }
45
45
  if (typeof source !== 'function') {
46
- const hasSubscribe = typeof source === 'object' && source !== null && 'subscribe' in source;
46
+ const hasSubscribe = 'object' === typeof source && source !== null && 'subscribe' in source;
47
47
  nativeWarn(`[reticle] store "${name}" is neither a getter function nor a {getState, subscribe} store` +
48
48
  (hasSubscribe
49
49
  ? ` — it has subscribe but no getState. Wrap it with an adapter or add getState.`
@@ -1,9 +1,9 @@
1
- import { ReticleCommand, PresenterMode } from '@reticlehq/core';
1
+ import { ReticleCommand, PresenterMode, QueryBy } from '@reticlehq/core';
2
2
  import { refs } from './dom/refs.js';
3
3
  import { describe } from './dom/a11y.js';
4
4
  /** Coerce an unknown arg to a string, falling back to `fallback` (default empty) when it isn't one. */
5
5
  export function str(value, fallback = '') {
6
- return typeof value === 'string' ? value : fallback;
6
+ return 'string' === typeof value ? value : fallback;
7
7
  }
8
8
  /** A short human label for a ref ("button \"Save\"") for the presenter HUD. */
9
9
  export function refLabel(refId) {
@@ -69,7 +69,7 @@ export function presentStatus(commandName, args = {}) {
69
69
  }
70
70
  /** Compact "what we're looking for" from a query's args (testid/value/name/role/text/label). */
71
71
  function queryTarget(q) {
72
- const testid = str(q['testid']) ?? (str(q['by']) === 'testid' ? str(q['value']) : undefined);
72
+ const testid = str(q['testid']) ?? (QueryBy.TESTID === str(q['by']) ? str(q['value']) : undefined);
73
73
  if (testid !== undefined)
74
74
  return `[testid=${testid}]`;
75
75
  const name = str(q['name']);
package/dist/reticle.js CHANGED
@@ -1,4 +1,5 @@
1
- import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, } from '@reticlehq/core';
1
+ import { EventType, RETICLE_DEFAULT_PORT, RETICLE_PROTOCOL_VERSION, RETICLE_URL_PARAM, bridgeWsUrl, ReticleCommand, MessageKind, SESSION_AUTO, SessionState, TRANSPORT_LIMITS, buildRedactionPolicy, isLoopbackHostname, isLocalPage, setActiveRedactionPolicy, wireRedactionKeys, RETICLE_ROOT_GLOBAL, RETICLE_SDK_VERSION_GLOBAL, CONTRACT_FINGERPRINT, } from '@reticlehq/core';
2
+ import { rememberSessionLabel } from './session-continuity.js';
2
3
  import { createCommandRegistry, RELOAD_CACHE_BUST_PARAM, } from './commands/commands.js';
3
4
  import { Transport } from './transport/transport.js';
4
5
  import { adapterNames } from './registry/adapters.js';
@@ -19,7 +20,7 @@ import { Annotator } from './review/annotator.js';
19
20
  * behind `import.meta.env.DEV` so the SDK is dead-code-eliminated from prod bundles entirely.
20
21
  */
21
22
  export function shouldBlockProduction(nodeEnv, allowInProduction) {
22
- return nodeEnv === 'production' && !allowInProduction;
23
+ return 'production' === nodeEnv && !allowInProduction;
23
24
  }
24
25
  export function connectionPolicy(pageHostname, bridgeUrl, allowNonLocalhost, token,
25
26
  /** The page's protocol. Desktop webviews (file:, app:, tauri:) are local despite a non-loopback host. */
@@ -53,7 +54,7 @@ pageProtocol = 'http:') {
53
54
  reason: 'Reticle is disabled outside localhost unless allowNonLocalhost is explicitly enabled',
54
55
  };
55
56
  }
56
- if (token === undefined || token.length === 0) {
57
+ if (token === undefined || 0 === token.length) {
57
58
  return { allowed: false, reason: 'a pairing token is required outside localhost' };
58
59
  }
59
60
  return { allowed: true };
@@ -145,6 +146,7 @@ export class Reticle {
145
146
  #annotator;
146
147
  #eventCount = 0;
147
148
  #token;
149
+ #sdkVersion;
148
150
  #projectId;
149
151
  /** App-declared extra redaction keys, announced in hello so the driven path honours them too. */
150
152
  #redactKeys = [];
@@ -153,13 +155,13 @@ export class Reticle {
153
155
  connect(options = {}) {
154
156
  if (this.#connected)
155
157
  return;
156
- if (typeof window === 'undefined' || typeof document === 'undefined')
158
+ if ('undefined' === typeof window || 'undefined' === typeof document)
157
159
  return;
158
160
  // Dev-only backstop: refuse to activate in a production build (SSR healthcheck, prod bundle opened
159
161
  // on localhost). `process` may not exist in a raw browser, so read NODE_ENV off globalThis.
160
162
  const proc = globalThis.process;
161
163
  const nodeEnv = proc?.env?.NODE_ENV;
162
- if (shouldBlockProduction(nodeEnv, options.allowInProduction === true)) {
164
+ if (shouldBlockProduction(nodeEnv, true === options.allowInProduction)) {
163
165
  globalThis.console.warn('[Reticle] disabled in production (NODE_ENV=production). Gate the import behind ' +
164
166
  'import.meta.env.DEV, or pass allowInProduction:true to override.');
165
167
  return;
@@ -169,7 +171,7 @@ export class Reticle {
169
171
  // reload permanently pollutes the URL the app and the agent see.
170
172
  stripReloadCacheBustParam();
171
173
  const url = options.url ?? bridgeWsUrl(RETICLE_DEFAULT_PORT);
172
- const policy = connectionPolicy(window.location.hostname, url, options.allowNonLocalhost === true, options.token, window.location.protocol);
174
+ const policy = connectionPolicy(window.location.hostname, url, true === options.allowNonLocalhost, options.token, window.location.protocol);
173
175
  if (!policy.allowed) {
174
176
  globalThis.console.warn(`[Reticle] ${policy.reason ?? 'connection blocked'}`);
175
177
  return;
@@ -179,10 +181,21 @@ export class Reticle {
179
181
  if (options.root !== undefined && options.root.length > 0) {
180
182
  globalThis[RETICLE_ROOT_GLOBAL] = options.root;
181
183
  }
184
+ // The build plugin defines this; a hand-wired connect can pass it explicitly. Either way an
185
+ // absent value means UNKNOWN, so the bridge never reports an unknown pair as in sync.
186
+ const declaredVersion = options.sdkVersion ?? globalThis[RETICLE_SDK_VERSION_GLOBAL];
187
+ this.#sdkVersion =
188
+ 'string' === typeof declaredVersion && declaredVersion.length > 0
189
+ ? declaredVersion
190
+ : undefined;
182
191
  // A pooled/headless launcher can stamp identity via namespaced URL params; explicit (non-auto)
183
192
  // options win, but the `auto` sentinel defers to the URL param so leases correlate.
184
193
  const identity = resolveConnectIdentity(options, window.location.search);
185
- this.#session = resolveSessionLabel(identity.session, () => typeof globalThis.crypto?.randomUUID === 'function'
194
+ // Remembered per TAB, so a reload rejoins the same session instead of appearing as a new one and
195
+ // stranding the agent's handle — see session-continuity. An explicit id (connect option, or the
196
+ // lease's URL param) still wins.
197
+ const explicitSession = resolveSessionLabel(identity.session, () => '');
198
+ this.#session = rememberSessionLabel(explicitSession.length > 0 ? explicitSession : undefined, 'undefined' === typeof globalThis.sessionStorage ? undefined : globalThis.sessionStorage, () => 'function' === typeof globalThis.crypto?.randomUUID
186
199
  ? `s${globalThis.crypto.randomUUID()}`
187
200
  : `s${Date.now().toString(36)}`);
188
201
  this.#token =
@@ -211,7 +224,7 @@ export class Reticle {
211
224
  // lodash debounce/throttle in the app (now - lastCall stays 0), so search boxes, autosave
212
225
  // and resize handlers stop firing until a reload — with nothing on screen explaining why.
213
226
  resetClock();
214
- if (this.#presenter?.sessionActive === true) {
227
+ if (true === this.#presenter?.sessionActive) {
215
228
  this.#presenter.setState(SessionState.ENDED, BRIDGE_LOST_SUMMARY);
216
229
  }
217
230
  },
@@ -229,9 +242,9 @@ export class Reticle {
229
242
  setCapabilitiesListener(() => this.#transport?.reannounce());
230
243
  const emit = this.#emit;
231
244
  this.#teardowns = installAllObservers(emit, {
232
- captureBodies: options.captureNetworkBodies === true,
245
+ captureBodies: true === options.captureNetworkBodies,
233
246
  });
234
- if (options.overlay === true) {
247
+ if (true === options.overlay) {
235
248
  this.#overlay = installOverlay();
236
249
  this.#overlay.update({ connected: true, events: 0 });
237
250
  }
@@ -261,7 +274,7 @@ export class Reticle {
261
274
  // so the presenter is visible as soon as the agent is reachable — not just on first command.
262
275
  this.#presenter.mount();
263
276
  }
264
- if (options.recorder === true) {
277
+ if (true === options.recorder) {
265
278
  this.#recorder = installRecorder({ emit, now: () => Date.now() });
266
279
  this.#recorder.mount();
267
280
  }
@@ -375,7 +388,12 @@ export class Reticle {
375
388
  adapters: adapterNames(),
376
389
  ...(this.#token === undefined ? {} : { token: this.#token }),
377
390
  hasCapabilities: hasCapabilities(),
378
- ...(this.#redactKeys.length === 0 ? {} : { redactKeys: this.#redactKeys }),
391
+ // Absent when no build plugin supplied one — "unknown", never "matching".
392
+ ...(this.#sdkVersion === undefined ? {} : { sdkVersion: this.#sdkVersion }),
393
+ // Always present: derived from THIS build's core, so it needs no build plugin to supply it.
394
+ // It is the half of the skew check that works on a hand-wired connect.
395
+ contract: CONTRACT_FINGERPRINT,
396
+ ...(0 === this.#redactKeys.length ? {} : { redactKeys: this.#redactKeys }),
379
397
  };
380
398
  }
381
399
  async #handleCommand(command) {
@@ -388,7 +406,7 @@ export class Reticle {
388
406
  // SESSION_CONFIG: the agent tunes the session for the app (currently the idle-end window).
389
407
  if (command.name === ReticleCommand.SESSION_CONFIG) {
390
408
  const idleEndMs = command.args['idleEndMs'];
391
- if (typeof idleEndMs === 'number')
409
+ if ('number' === typeof idleEndMs)
392
410
  this.#presenter?.setIdleEndMs(idleEndMs);
393
411
  return { ok: true, result: { applied: this.#presenter !== undefined, idleEndMs } };
394
412
  }
@@ -80,7 +80,7 @@ export class Annotator {
80
80
  return this.#markCount;
81
81
  }
82
82
  mount() {
83
- if (this.#root !== undefined || typeof document === 'undefined')
83
+ if (this.#root !== undefined || 'undefined' === typeof document)
84
84
  return;
85
85
  const style = document.createElement('style');
86
86
  style.setAttribute(MARK_ATTR, 'style');
@@ -193,7 +193,7 @@ export class Annotator {
193
193
  return;
194
194
  }
195
195
  const rect = target.getBoundingClientRect();
196
- if (rect.width === 0 && rect.height === 0) {
196
+ if (0 === rect.width && 0 === rect.height) {
197
197
  this.#hi.setAttribute('data-on', '0');
198
198
  return;
199
199
  }
@@ -239,22 +239,22 @@ export class Annotator {
239
239
  const send = pop.querySelector('button[data-send]');
240
240
  const submit = () => {
241
241
  const note = textarea?.value.trim() ?? '';
242
- if (note.length === 0)
242
+ if (0 === note.length)
243
243
  return; // never send an empty mark
244
244
  this.#sendMark(note, resolved, x, y);
245
245
  this.#closePopover();
246
246
  };
247
247
  textarea?.addEventListener('input', () => {
248
248
  if (send !== null)
249
- send.disabled = textarea.value.trim().length === 0;
249
+ send.disabled = 0 === textarea.value.trim().length;
250
250
  });
251
251
  // Keyboard ergonomics devs expect: ⌘/Ctrl+Enter sends, Esc cancels — without leaving the textarea.
252
252
  textarea?.addEventListener('keydown', (e) => {
253
- if (e.key === 'Escape') {
253
+ if ('Escape' === e.key) {
254
254
  e.preventDefault();
255
255
  this.#closePopover();
256
256
  }
257
- else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
257
+ else if ('Enter' === e.key && (e.metaKey || e.ctrlKey)) {
258
258
  e.preventDefault();
259
259
  submit();
260
260
  }
@@ -269,7 +269,7 @@ export class Annotator {
269
269
  anchor: resolved.anchor,
270
270
  strategy: resolved.strategy,
271
271
  label: resolved.label,
272
- route: typeof location === 'undefined' ? '' : location.pathname + location.search,
272
+ route: 'undefined' === typeof location ? '' : location.pathname + location.search,
273
273
  };
274
274
  if (resolved.source !== undefined)
275
275
  data['source'] = resolved.source;
@@ -31,18 +31,18 @@ function sanitize(value, state, depth, key) {
31
31
  return TRUNCATED_VALUE;
32
32
  }
33
33
  state.nodes += 1;
34
- if (value === null || typeof value === 'boolean')
34
+ if (null === value || 'boolean' === typeof value)
35
35
  return value;
36
- if (typeof value === 'string') {
37
- return boundedString(value, state, key?.toLowerCase() === 'error'
36
+ if ('string' === typeof value) {
37
+ return boundedString(value, state, 'error' === key?.toLowerCase()
38
38
  ? TRANSPORT_LIMITS.MAX_ERROR_LENGTH
39
39
  : TRANSPORT_LIMITS.MAX_STRING_LENGTH);
40
40
  }
41
- if (typeof value === 'number')
41
+ if ('number' === typeof value)
42
42
  return Number.isFinite(value) ? value : null;
43
- if (typeof value === 'bigint')
43
+ if ('bigint' === typeof value)
44
44
  return value.toString();
45
- if (typeof value === 'undefined' || typeof value === 'function' || typeof value === 'symbol') {
45
+ if ('undefined' === typeof value || 'function' === typeof value || 'symbol' === typeof value) {
46
46
  return OMIT_VALUE;
47
47
  }
48
48
  // An invalid Date (`new Date(NaN)` / `new Date(undefined)`, easy to land in app state) throws
@@ -65,7 +65,7 @@ function sanitize(value, state, depth, key) {
65
65
  state.droppedItems += value.size - n;
66
66
  break;
67
67
  }
68
- const sk = boundedString(typeof k === 'string' ? k : String(k), state, MAX_KEY_LENGTH);
68
+ const sk = boundedString('string' === typeof k ? k : String(k), state, MAX_KEY_LENGTH);
69
69
  const sv = sanitize(v, state, depth + 1, sk);
70
70
  if (sv !== OMIT_VALUE)
71
71
  out[sk] = sv;
@@ -176,7 +176,7 @@ function sanitize(value, state, depth, key) {
176
176
  const read = keys.map((key) => {
177
177
  try {
178
178
  const v = value[key];
179
- return { key, value: v, scalar: v === null || typeof v !== 'object', ok: true };
179
+ return { key, value: v, scalar: null === v || typeof v !== 'object', ok: true };
180
180
  }
181
181
  catch {
182
182
  return { key, value: undefined, scalar: true, ok: false };
@@ -224,7 +224,7 @@ export function sanitizeWithReport(value) {
224
224
  };
225
225
  const sanitized = sanitize(value, state, 0);
226
226
  const out = sanitized === OMIT_VALUE ? null : sanitized;
227
- if (state.droppedItems === 0 && state.truncatedValues === 0)
227
+ if (0 === state.droppedItems && 0 === state.truncatedValues)
228
228
  return { value: out };
229
229
  const parts = [];
230
230
  if (state.droppedItems > 0)
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Keep a tab's session id across a reload.
3
+ *
4
+ * The id was minted fresh on every `connect()`. A reload is a new document, so the page came back as
5
+ * a NEW session while the agent still held the old id — and every call after
6
+ * `reticle_navigate { reload: true }` was refused with "no browser session connected". Reported on 6
7
+ * of 6 apps; a reload is the most ordinary thing an agent does.
8
+ *
9
+ * `sessionStorage` is exactly the right scope: it survives reloads and same-tab navigations, and is
10
+ * NOT shared with another tab of the same app — so two tabs stay two sessions, which is what the
11
+ * multi-session tooling assumes.
12
+ *
13
+ * An explicit id still wins. `connect({ session })` and the `__reticle_session` URL param a lease
14
+ * stamps are callers ASSERTING an identity, not asking for one; they are also remembered, so a
15
+ * leased tab that reloads rejoins its own lease.
16
+ */
17
+ export declare function rememberSessionLabel(explicit: string | undefined, store: Storage | undefined, generate: () => string): string;