@reticlehq/browser 3.4.0 → 3.6.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 (89) hide show
  1. package/dist/actions/actions-dom.js +44 -43
  2. package/dist/actions/actions-press.d.ts +10 -4
  3. package/dist/actions/actions-press.js +108 -17
  4. package/dist/actions/actions.d.ts +2 -1
  5. package/dist/actions/actions.js +25 -26
  6. package/dist/actions/danger-context.js +1 -1
  7. package/dist/commands/commands.js +2 -6
  8. package/dist/connect-options.d.ts +1 -0
  9. package/dist/dom/a11y.js +43 -11
  10. package/dist/dom/addressing/refs.js +2 -1
  11. package/dist/dom/addressing/testid-attr.d.ts +4 -0
  12. package/dist/dom/addressing/testid-attr.js +14 -0
  13. package/dist/dom/occlusion.js +5 -1
  14. package/dist/dom/query.d.ts +1 -0
  15. package/dist/dom/query.js +42 -15
  16. package/dist/dom/snapshot.js +1 -0
  17. package/dist/observers/departure.js +51 -9
  18. package/dist/observers/dom.js +3 -2
  19. package/dist/observers/field.js +5 -3
  20. package/dist/observers/health.js +63 -2
  21. package/dist/observers/network-stream.d.ts +1 -1
  22. package/dist/observers/network-stream.js +3 -3
  23. package/dist/observers/network.js +1 -1
  24. package/dist/observers/storage.js +6 -1
  25. package/dist/presenter/carousel/carousel.d.ts +5 -2
  26. package/dist/presenter/carousel/carousel.js +34 -7
  27. package/dist/presenter/carousel/offer-card.d.ts +2 -2
  28. package/dist/presenter/carousel/offer-card.js +2 -1
  29. package/dist/presenter/carousel/panel-slides.d.ts +4 -3
  30. package/dist/presenter/carousel/panel-slides.js +43 -6
  31. package/dist/presenter/carousel/talk-card.d.ts +1 -1
  32. package/dist/presenter/chrome/log-kinds.d.ts +5 -0
  33. package/dist/presenter/chrome/log-kinds.js +1 -0
  34. package/dist/presenter/chrome/log-memory.d.ts +12 -0
  35. package/dist/presenter/chrome/log-memory.js +39 -0
  36. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +1 -0
  37. package/dist/presenter/chrome/presenter-hud-chrome.js +7 -1
  38. package/dist/presenter/chrome/presenter-log.d.ts +5 -4
  39. package/dist/presenter/chrome/presenter-log.js +55 -5
  40. package/dist/presenter/chrome/presenter-report-copy.d.ts +11 -1
  41. package/dist/presenter/chrome/presenter-report-copy.js +14 -1
  42. package/dist/presenter/hud-telemetry.js +31 -2
  43. package/dist/presenter/icons/presenter-heroicons-data.d.ts +1 -0
  44. package/dist/presenter/icons/presenter-heroicons-data.js +4 -3
  45. package/dist/presenter/icons/presenter-icons.d.ts +2 -0
  46. package/dist/presenter/icons/presenter-icons.js +2 -0
  47. package/dist/presenter/presenter-account.d.ts +5 -4
  48. package/dist/presenter/presenter-account.js +23 -10
  49. package/dist/presenter/presenter-chat-views.d.ts +21 -0
  50. package/dist/presenter/presenter-chat-views.js +382 -0
  51. package/dist/presenter/presenter-config.d.ts +3 -2
  52. package/dist/presenter/presenter-config.js +4 -3
  53. package/dist/presenter/presenter-controls.d.ts +4 -2
  54. package/dist/presenter/presenter-controls.js +231 -51
  55. package/dist/presenter/presenter-design-tokens.d.ts +1 -0
  56. package/dist/presenter/presenter-design-tokens.js +34 -0
  57. package/dist/presenter/presenter-dock-layout.js +1 -1
  58. package/dist/presenter/presenter-frame.d.ts +12 -0
  59. package/dist/presenter/presenter-frame.js +189 -0
  60. package/dist/presenter/presenter-plan.d.ts +10 -0
  61. package/dist/presenter/presenter-plan.js +185 -0
  62. package/dist/presenter/presenter-report-styles.js +7 -0
  63. package/dist/presenter/presenter-report.d.ts +1 -1
  64. package/dist/presenter/presenter-report.js +53 -16
  65. package/dist/presenter/presenter-settings-styles.js +10 -0
  66. package/dist/presenter/presenter-settings.d.ts +5 -0
  67. package/dist/presenter/presenter-settings.js +71 -11
  68. package/dist/presenter/presenter-shell-styles.js +39 -42
  69. package/dist/presenter/presenter-shell.d.ts +8 -1
  70. package/dist/presenter/presenter-shell.js +124 -72
  71. package/dist/presenter/presenter-styles.js +9 -1
  72. package/dist/presenter/presenter.d.ts +4 -3
  73. package/dist/presenter/presenter.js +101 -13
  74. package/dist/presenter/tour/tour.d.ts +4 -1
  75. package/dist/presenter/tour/tour.js +6 -4
  76. package/dist/recorder/recorder.js +2 -2
  77. package/dist/registry/auto-testids.js +3 -4
  78. package/dist/registry/declared-platform.js +2 -0
  79. package/dist/reticle-inject.js +880 -513
  80. package/dist/reticle.d.ts +10 -1
  81. package/dist/reticle.js +73 -9
  82. package/dist/review/annotator-styles.d.ts +1 -1
  83. package/dist/review/annotator.d.ts +7 -0
  84. package/dist/review/annotator.js +20 -2
  85. package/dist/review/mark-anchor.js +2 -2
  86. package/dist/session-continuity.js +2 -1
  87. package/dist/storage-keys.d.ts +14 -0
  88. package/dist/storage-keys.js +17 -0
  89. package/package.json +3 -2
@@ -14,9 +14,13 @@ export function hitTestOccluder(el, rect) {
14
14
  if (typeof doc.elementFromPoint !== 'function')
15
15
  return null;
16
16
  const withStack = doc;
17
+ const isOursButNotTheTarget = (candidate) => isReticleUi(candidate) &&
18
+ candidate !== el &&
19
+ !el.contains(candidate) &&
20
+ !candidate.contains(el);
17
21
  const topAt = (x, y) => {
18
22
  const found = withStack.elementsFromPoint?.(x, y) ?? [doc.elementFromPoint(x, y)];
19
- return found.find((c) => null !== c && !isReticleUi(c)) ?? null;
23
+ return found.find((c) => null !== c && !isOursButNotTheTarget(c)) ?? null;
20
24
  };
21
25
  const blockers = new Map();
22
26
  for (const [fx, fy] of SAMPLE_POINTS) {
@@ -1,4 +1,5 @@
1
1
  import { ElementState, type ElementQuery, type MatchResult, type QueryResult } from '@reticlehq/core';
2
2
  export declare const ATTR_VALUE_MAX = 512;
3
3
  export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number): MatchResult;
4
+ export declare function elementsMatching(query: ElementQuery): HTMLElement[];
4
5
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
package/dist/dom/query.js CHANGED
@@ -1,5 +1,5 @@
1
- import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
- import { isElement, isFrame, isHtmlElement, isInput, isSelect, isTextArea } from './realm.js';
1
+ import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
+ import { isActionTarget, isElement, isFrame, isInput, isSelect, isTextArea } from './realm.js';
3
3
  import { capturedRootOf } from './shadow-registry.js';
4
4
  import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
5
5
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
@@ -7,11 +7,11 @@ import { isSensitiveKey } from '../security/serialization.js';
7
7
  import { declaredTestids } from '../registry/capabilities.js';
8
8
  import { identifyComponent } from '../registry/stores/adapters.js';
9
9
  import { refs } from './addressing/refs.js';
10
- const TESTID_ATTR = 'data-testid';
10
+ import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
11
11
  const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
12
12
  const MAX_PRESENT_TESTIDS = 12;
13
13
  const MAX_COMPONENT_CANDIDATES = 2000;
14
- const COMPONENT_CANDIDATE_SELECTOR = `[${SOURCE_ATTR}], [${TESTID_ATTR}], button, a, input, select, textarea, [role]`;
14
+ const componentCandidateSelector = () => `[${SOURCE_ATTR}], ${testIdSelector()}, button, a, input, select, textarea, [role]`;
15
15
  function elementsUnder(container) {
16
16
  const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
17
17
  return [...self, ...Array.from(container.querySelectorAll('*'))];
@@ -71,11 +71,11 @@ function resolveContainer(scope) {
71
71
  if (scope === undefined)
72
72
  return { container: document.body, scopeMissing: false };
73
73
  const byRef = refs.resolve(scope);
74
- if (isHtmlElement(byRef))
74
+ if (isActionTarget(byRef))
75
75
  return { container: byRef, scopeMissing: false };
76
76
  try {
77
77
  const found = document.querySelector(scope);
78
- if (isHtmlElement(found))
78
+ if (isActionTarget(found))
79
79
  return { container: found, scopeMissing: false };
80
80
  }
81
81
  catch {
@@ -94,7 +94,7 @@ function findBySource(container, source) {
94
94
  function findByComponentName(container, component) {
95
95
  const out = [];
96
96
  let scanned = 0;
97
- for (const el of Array.from(container.querySelectorAll(COMPONENT_CANDIDATE_SELECTOR))) {
97
+ for (const el of Array.from(container.querySelectorAll(componentCandidateSelector()))) {
98
98
  if (scanned >= MAX_COMPONENT_CANDIDATES)
99
99
  break;
100
100
  scanned += 1;
@@ -115,8 +115,16 @@ function findByComponent(container, query) {
115
115
  }
116
116
  return [];
117
117
  }
118
+ function matchesRole(actual, queried) {
119
+ if (actual === queried)
120
+ return true;
121
+ if ('textbox' === queried && 'searchbox' === actual)
122
+ return true;
123
+ return false;
124
+ }
118
125
  function queryByRoleAndName(container, role, name) {
119
- return elementsUnder(container).filter((el) => getRole(el) === role && (name === undefined || exactVisibleText(getAccessibleName(el), name)));
126
+ return elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
127
+ (name === undefined || exactVisibleText(getAccessibleName(el), name)));
120
128
  }
121
129
  function queryByText(container, value) {
122
130
  return elementsUnder(container).filter((el) => fuzzyVisibleText(directText(el), value));
@@ -131,7 +139,7 @@ function queryByPlaceholder(container, value) {
131
139
  });
132
140
  }
133
141
  function queryByTestId(container, value) {
134
- return elementsUnder(container).filter((el) => el.getAttribute(TESTID_ATTR) === value);
142
+ return elementsUnder(container).filter((el) => readTestId(el) === value);
135
143
  }
136
144
  function queryByAlt(container, value) {
137
145
  return elementsUnder(container).filter((el) => {
@@ -269,7 +277,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
269
277
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
270
278
  const attrs = query.attrs;
271
279
  const described = filtered.slice(0, Math.max(0, Math.min(limit, MAX_DESCRIBED)));
272
- const stampViewport = described.length > 1;
280
+ const stampViewport = described.length > 1 || ElementState.IN_VIEWPORT === state;
273
281
  const descriptors = described.map((el) => {
274
282
  let base = describe(el, visMemo);
275
283
  if (stampViewport &&
@@ -329,7 +337,7 @@ function buildPresentRegions(query) {
329
337
  continue;
330
338
  const name = el.getAttribute('aria-label') ??
331
339
  resolveLabelledBy(el) ??
332
- el.getAttribute('data-testid') ??
340
+ readTestId(el) ??
333
341
  undefined;
334
342
  const children = el.querySelectorAll('[role]');
335
343
  const sample = [];
@@ -338,7 +346,7 @@ function buildPresentRegions(query) {
338
346
  break;
339
347
  const childRole = child.getAttribute('role');
340
348
  const childName = child.getAttribute('aria-label') ??
341
- child.getAttribute('data-testid') ??
349
+ readTestId(child) ??
342
350
  child.textContent?.trim().slice(0, 40) ??
343
351
  '';
344
352
  if (childRole !== null && childName.length > 0) {
@@ -381,7 +389,7 @@ function wantedTextOf(query) {
381
389
  }
382
390
  const MAX_NAME_NEAR_MISSES = 5;
383
391
  function nameNearMisses(container, query) {
384
- const role = QueryBy.ROLE === query.by ? query.value : undefined;
392
+ const role = QueryBy.ROLE === query.by ? query.value : query.role;
385
393
  const wanted = query.name;
386
394
  if (role === undefined || wanted === undefined || 0 === wanted.length)
387
395
  return [];
@@ -405,15 +413,28 @@ function nameNearMisses(container, query) {
405
413
  }
406
414
  return out;
407
415
  }
416
+ function testidFoundUnder(container, query) {
417
+ if (QueryBy.TESTID !== query.by || undefined === query.value)
418
+ return undefined;
419
+ const configured = getTestIdAttr();
420
+ for (const attr of [DEFAULT_TESTID_ATTR, ...ALTERNATIVE_TESTID_ATTRS]) {
421
+ if (attr === configured)
422
+ continue;
423
+ const found = elementsUnder(container).some((el) => el.getAttribute(attr) === query.value && !isIgnored(el));
424
+ if (found)
425
+ return attr;
426
+ }
427
+ return undefined;
428
+ }
408
429
  function buildEmptyHint(query) {
409
430
  const container = resolveContainer(query.scope).container ?? document.body;
410
- const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
431
+ const all = container.querySelectorAll(testIdSelector());
411
432
  const present = [];
412
433
  const seen = new Set();
413
434
  for (const el of Array.from(all)) {
414
435
  if (isIgnored(el))
415
436
  continue;
416
- const id = el.getAttribute(TESTID_ATTR);
437
+ const id = readTestId(el);
417
438
  if (null === id || 0 === id.length || seen.has(id))
418
439
  continue;
419
440
  seen.add(id);
@@ -439,8 +460,14 @@ function buildEmptyHint(query) {
439
460
  const near = nameNearMisses(container, query);
440
461
  if (near.length > 0)
441
462
  hint.nameNearMiss = near;
463
+ const elsewhere = testidFoundUnder(container, query);
464
+ if (elsewhere !== undefined)
465
+ hint.testidFoundUnder = elsewhere;
442
466
  return hint;
443
467
  }
468
+ export function elementsMatching(query) {
469
+ return findCandidates(query).candidates;
470
+ }
444
471
  export function runQuery(query, limit) {
445
472
  const result = matchQuery(query, undefined, limit);
446
473
  const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
@@ -8,6 +8,7 @@ const INTERACTIVE = new Set([
8
8
  'button',
9
9
  'link',
10
10
  'textbox',
11
+ 'searchbox',
11
12
  'checkbox',
12
13
  'radio',
13
14
  'combobox',
@@ -1,12 +1,19 @@
1
1
  import { EventType, NetInitiator } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  const NON_NAVIGATING = /^(javascript|mailto|tel|sms|blob|data):/i;
4
+ function navigationOf() {
5
+ if ('undefined' === typeof window)
6
+ return undefined;
7
+ const nav = window.navigation;
8
+ return nav instanceof EventTarget ? nav : undefined;
9
+ }
4
10
  function anchorFor(target) {
5
11
  if (!(target instanceof Element))
6
12
  return undefined;
7
13
  const anchor = target.closest('a');
8
14
  return anchor instanceof HTMLAnchorElement ? anchor : undefined;
9
15
  }
16
+ const DUPLICATE_WINDOW_MS = 1000;
10
17
  export function isDeparture(href, downloadAttr, here) {
11
18
  if (0 === href.length || NON_NAVIGATING.test(href))
12
19
  return false;
@@ -28,6 +35,24 @@ export function installDeparture(emit) {
28
35
  if ('undefined' === typeof document)
29
36
  return () => undefined;
30
37
  let seq = 0;
38
+ let lastRecorded;
39
+ const record = (url, download) => {
40
+ const now = Date.now();
41
+ if (lastRecorded !== undefined &&
42
+ lastRecorded.url === url &&
43
+ now - lastRecorded.at < DUPLICATE_WINDOW_MS) {
44
+ return;
45
+ }
46
+ lastRecorded = { url, at: now };
47
+ seq += 1;
48
+ emit(EventType.NET_PENDING, {
49
+ id: `nav-${String(seq)}`,
50
+ method: 'GET',
51
+ url,
52
+ initiator: NetInitiator.NAVIGATION,
53
+ ...(download ? { download: true } : {}),
54
+ });
55
+ };
31
56
  const onClick = (event) => {
32
57
  observeSafely(() => {
33
58
  const anchor = anchorFor(event.target);
@@ -43,18 +68,35 @@ export function installDeparture(emit) {
43
68
  observeSafely(() => {
44
69
  if (event.defaultPrevented)
45
70
  return;
46
- seq += 1;
47
- emit(EventType.NET_PENDING, {
48
- id: `nav-${String(seq)}`,
49
- method: 'GET',
50
- url,
51
- initiator: NetInitiator.NAVIGATION,
52
- ...(download ? { download: true } : {}),
53
- });
71
+ record(url, download);
54
72
  });
55
73
  });
56
74
  });
57
75
  };
76
+ const onNavigate = (event) => {
77
+ observeSafely(() => {
78
+ const destination = event.destination;
79
+ if (destination === undefined || destination.sameDocument)
80
+ return;
81
+ const url = destination.url;
82
+ if (!isDeparture(url, false, document.location.href))
83
+ return;
84
+ queueMicrotask(() => {
85
+ observeSafely(() => {
86
+ if (event.defaultPrevented)
87
+ return;
88
+ record(url, false);
89
+ });
90
+ });
91
+ });
92
+ };
93
+ const navigation = navigationOf();
94
+ if (navigation !== undefined)
95
+ navigation.addEventListener('navigate', onNavigate);
58
96
  document.addEventListener('click', onClick, true);
59
- return () => document.removeEventListener('click', onClick, true);
97
+ return () => {
98
+ document.removeEventListener('click', onClick, true);
99
+ if (navigation !== undefined)
100
+ navigation.removeEventListener('navigate', onNavigate);
101
+ };
60
102
  }
@@ -1,3 +1,4 @@
1
+ import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
1
2
  import { EventType, TruncationChannel } from '@reticlehq/core';
2
3
  import { getAccessibleName, getRole, isVisible } from '../dom/a11y.js';
3
4
  import { refs } from '../dom/addressing/refs.js';
@@ -5,8 +6,8 @@ function regionKeyOf(target) {
5
6
  const el = isElement(target) ? target : null;
6
7
  if (null === el)
7
8
  return undefined;
8
- const labelled = el.closest('[data-testid]');
9
- return labelled?.getAttribute('data-testid') ?? refs.refFor(el);
9
+ const labelled = el.closest(testIdSelector());
10
+ return (labelled ? readTestId(labelled) : null) ?? refs.refFor(el);
10
11
  }
11
12
  import { isReticleOverlay } from '../dom/dom-ignore.js';
12
13
  import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
@@ -1,4 +1,5 @@
1
- import { EventType, isSensitiveKey } from '@reticlehq/core';
1
+ import { readTestId } from '../dom/addressing/testid-attr.js';
2
+ import { DEFAULT_TESTID_ATTR, EventType, isSensitiveKey } from '@reticlehq/core';
2
3
  import { getAccessibleName } from '../dom/a11y.js';
3
4
  const MAX_VALUE_LEN = 160;
4
5
  const BURST_MS = 150;
@@ -9,7 +10,7 @@ function isField(target) {
9
10
  target instanceof HTMLSelectElement);
10
11
  }
11
12
  function fieldName(element) {
12
- const testid = element.getAttribute('data-testid');
13
+ const testid = readTestId(element);
13
14
  if (null !== testid && testid.length > 0)
14
15
  return testid;
15
16
  if (element.name.length > 0)
@@ -28,7 +29,8 @@ function isSecret(element) {
28
29
  return true;
29
30
  for (const key of [
30
31
  element.name,
31
- element.getAttribute('data-testid'),
32
+ readTestId(element),
33
+ element.getAttribute(DEFAULT_TESTID_ATTR),
32
34
  element.getAttribute('id'),
33
35
  ]) {
34
36
  if (null !== key && key.length > 0 && isSensitiveKey(key))
@@ -67,25 +67,86 @@ function detectBrand() {
67
67
  return BrowserBrand.SAFARI;
68
68
  return BrowserBrand.OTHER;
69
69
  }
70
- function snapshotHealth() {
70
+ const pageFetch = 'undefined' !== typeof window && 'function' === typeof window.fetch
71
+ ? window.fetch.bind(window)
72
+ : undefined;
73
+ function tauriWindowLabel() {
74
+ const internals = window['__TAURI_INTERNALS__'];
75
+ const label = internals?.metadata?.currentWindow?.label;
76
+ return 'string' === typeof label && label.length > 0 ? label : undefined;
77
+ }
78
+ function navigationStatus() {
79
+ try {
80
+ const [entry] = performance.getEntriesByType('navigation');
81
+ const known = entry?.responseStatus;
82
+ return 'number' === typeof known && known > 0 ? known : undefined;
83
+ }
84
+ catch {
85
+ return undefined;
86
+ }
87
+ }
88
+ async function documentStatus(runtime) {
89
+ const known = navigationStatus();
90
+ if (known !== undefined)
91
+ return known;
92
+ try {
93
+ if ('web' === runtime || pageFetch === undefined || !/^https?:$/.test(location.protocol)) {
94
+ return undefined;
95
+ }
96
+ return (await pageFetch(location.href, { method: 'HEAD', cache: 'no-store' })).status;
97
+ }
98
+ catch {
99
+ return undefined;
100
+ }
101
+ }
102
+ async function electronWindowLabel() {
103
+ const channel = window[RETICLE_IPC_GLOBAL];
104
+ if ('function' !== typeof channel?.windowLabel)
105
+ return undefined;
106
+ try {
107
+ const label = await channel.windowLabel();
108
+ return 'string' === typeof label && label.length > 0 ? label : undefined;
109
+ }
110
+ catch {
111
+ return undefined;
112
+ }
113
+ }
114
+ function snapshotHealth(windowLabel) {
71
115
  return {
72
116
  hidden: 'hidden' === document.visibilityState,
73
117
  focused: document.hasFocus(),
74
118
  runtime: detectRuntime(),
75
119
  engine: detectEngine(),
76
120
  brand: detectBrand(),
121
+ ...(windowLabel === undefined ? {} : { windowLabel }),
77
122
  };
78
123
  }
79
124
  export function installHealth(emit) {
80
125
  const ac = new AbortController();
81
126
  const { signal } = ac;
127
+ let status;
128
+ let label = tauriWindowLabel();
82
129
  const report = (reason) => {
83
- emit(EventType.PAGE_HEALTH, { ...snapshotHealth(), reason });
130
+ emit(EventType.PAGE_HEALTH, {
131
+ ...snapshotHealth(label),
132
+ ...(status === undefined ? {} : { documentStatus: status }),
133
+ reason,
134
+ });
84
135
  };
85
136
  document.addEventListener('visibilitychange', () => report(HealthReason.VISIBILITY), { signal });
86
137
  window.addEventListener('focus', () => report(HealthReason.FOCUS), { signal });
87
138
  window.addEventListener('blur', () => report(HealthReason.BLUR), { signal });
88
139
  report(HealthReason.INITIAL);
140
+ void Promise.all([
141
+ documentStatus(detectRuntime()),
142
+ label === undefined ? electronWindowLabel() : Promise.resolve(label),
143
+ ]).then(([known, named]) => {
144
+ if (signal.aborted || (known === undefined && named === label))
145
+ return;
146
+ status = known;
147
+ label = named;
148
+ report(HealthReason.HEARTBEAT);
149
+ });
89
150
  const stopHeartbeat = nativeSetInterval(() => report(HealthReason.HEARTBEAT), SESSION_HEALTH.HEARTBEAT_MS);
90
151
  return () => {
91
152
  stopHeartbeat();
@@ -1,2 +1,2 @@
1
1
  import type { Emit } from './types.js';
2
- export declare function watchStreamedBody(emit: Emit, res: Response, id: string, url: string, contentType: string | null, contentLength: string | null): void;
2
+ export declare function watchStreamedBody(emit: Emit, res: Response, id: string, urlFields: Readonly<Record<string, unknown>>, contentType: string | null, contentLength: string | null): void;
@@ -2,7 +2,7 @@ import { EventType, StreamDirection, StreamTransport } from '@reticlehq/core';
2
2
  import { isStreamingBody } from './net-detail/network-body.js';
3
3
  import { nativeClearTimeout, nativeSetTimeout } from '../timers/native/native-timers.js';
4
4
  const STREAM_WATCH_MS = 10_000;
5
- export function watchStreamedBody(emit, res, id, url, contentType, contentLength) {
5
+ export function watchStreamedBody(emit, res, id, urlFields, contentType, contentLength) {
6
6
  if (!isStreamingBody(contentType, contentLength))
7
7
  return;
8
8
  if (typeof res.body?.getReader !== 'function')
@@ -20,7 +20,7 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
20
20
  emit(EventType.NET_STREAM, {
21
21
  transport: StreamTransport.FETCH,
22
22
  direction: StreamDirection.OPEN,
23
- url,
23
+ ...urlFields,
24
24
  id,
25
25
  });
26
26
  void (async () => {
@@ -51,7 +51,7 @@ export function watchStreamedBody(emit, res, id, url, contentType, contentLength
51
51
  emit(EventType.NET_STREAM, {
52
52
  transport: StreamTransport.FETCH,
53
53
  direction: StreamDirection.CLOSE,
54
- url,
54
+ ...urlFields,
55
55
  id,
56
56
  ...(gaveUp ? { gaveUp: true } : {}),
57
57
  });
@@ -220,7 +220,7 @@ export function installNetwork(emit, opts = {}) {
220
220
  const headersAt = performance.now();
221
221
  const contentType = observeValue(() => res.headers.get('content-type')) ?? null;
222
222
  observeSafely(() => {
223
- watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
223
+ watchStreamedBody(emit, res, id, urlFields, contentType, res.headers.get('content-length'));
224
224
  });
225
225
  reportedNetUrls.add(rawUrl);
226
226
  const emitRequest = (responseBodyFields) => {
@@ -1,5 +1,6 @@
1
1
  import { EventType, REDACTED_VALUE, StorageArea } from '@reticlehq/core';
2
2
  import { isSensitiveKey, scrubKnownSecrets } from '../security/serialization.js';
3
+ import { isReticleStorageKey } from '../storage-keys.js';
3
4
  import { observeSafely, observeValue } from './types.js';
4
5
  function safeArea(get) {
5
6
  try {
@@ -95,6 +96,8 @@ export function installStorage(emit) {
95
96
  origSet.call(this, key, value);
96
97
  if (old === value)
97
98
  return;
99
+ if (isReticleStorageKey(key))
100
+ return;
98
101
  observeSafely(() => {
99
102
  emit(EventType.STORAGE_CHANGE, {
100
103
  area: areaOf(this),
@@ -108,6 +111,8 @@ export function installStorage(emit) {
108
111
  const patchedRemoveItem = function (key) {
109
112
  const old = observeValue(() => readOld(this, key)) ?? null;
110
113
  origRemove.call(this, key);
114
+ if (isReticleStorageKey(key))
115
+ return;
111
116
  observeSafely(() => {
112
117
  emit(EventType.STORAGE_CHANGE, {
113
118
  area: areaOf(this),
@@ -123,7 +128,7 @@ export function installStorage(emit) {
123
128
  try {
124
129
  for (let i = 0; i < this.length; i += 1) {
125
130
  const key = this.key(i);
126
- if (key !== null)
131
+ if (key !== null && !isReticleStorageKey(key))
127
132
  removed.push({ key, old: readOld(this, key) });
128
133
  }
129
134
  }
@@ -2,7 +2,10 @@ export declare const CAROUSEL_ATTR = "data-reticle-carousel";
2
2
  export declare const CAROUSEL_SLIDE_ATTR = "data-reticle-carousel-slide";
3
3
  export declare const CAROUSEL_DOT_ATTR = "data-reticle-carousel-dot";
4
4
  export declare const CAROUSEL_CLOSE_ATTR = "data-reticle-carousel-close";
5
- export declare const CAROUSEL_DISMISSED_KEY = "reticle.carousel.dismissed";
5
+ export declare const CAROUSEL_PREV_ATTR = "data-reticle-carousel-prev";
6
+ export declare const CAROUSEL_NEXT_ATTR = "data-reticle-carousel-next";
7
+ export declare const CAROUSEL_POSITION_ATTR = "data-reticle-carousel-position";
8
+ export declare const CAROUSEL_DISMISSED_KEY: "reticle.carousel.dismissed";
6
9
  export declare const ROTATE_MS = 6000;
7
10
  export interface Slide {
8
11
  id: string;
@@ -10,4 +13,4 @@ export interface Slide {
10
13
  }
11
14
  export declare function carouselHtml(slides: readonly Slide[], active: number): string;
12
15
  export declare function paintCarousel(log: HTMLElement, slides: readonly Slide[], storage: Pick<Storage, 'getItem' | 'setItem'> | undefined): void;
13
- export declare const CAROUSEL_CSS = "\n.reticle-carousel{position:relative;margin:8px 10px;padding:10px 12px 8px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}\n.reticle-carousel-track{height:100px;overflow:hidden;}\n.reticle-carousel-slide .reticle-offer,.reticle-carousel-slide .reticle-talk{margin:0;padding:0;border:0;background:none;}\n.reticle-carousel-close{position:absolute;top:4px;right:6px;width:20px;height:20px;padding:0;border:0;background:none;color:inherit;opacity:.55;cursor:pointer;font-size:14px;line-height:20px;}\n.reticle-carousel-close:hover{opacity:1;}\n.reticle-carousel-dots{display:flex;justify-content:center;gap:6px;margin-top:6px;}\n.reticle-carousel-dot{width:6px;height:6px;padding:0;border:0;border-radius:999px;background:currentColor;opacity:.3;cursor:pointer;}\n.reticle-carousel-dot[aria-current=\"true\"]{opacity:.9;}\n";
16
+ export declare const CAROUSEL_CSS: string;
@@ -1,8 +1,12 @@
1
+ import { ReticleStorageKey } from '../../storage-keys.js';
1
2
  export const CAROUSEL_ATTR = 'data-reticle-carousel';
2
3
  export const CAROUSEL_SLIDE_ATTR = 'data-reticle-carousel-slide';
3
4
  export const CAROUSEL_DOT_ATTR = 'data-reticle-carousel-dot';
4
5
  export const CAROUSEL_CLOSE_ATTR = 'data-reticle-carousel-close';
5
- export const CAROUSEL_DISMISSED_KEY = 'reticle.carousel.dismissed';
6
+ export const CAROUSEL_PREV_ATTR = 'data-reticle-carousel-prev';
7
+ export const CAROUSEL_NEXT_ATTR = 'data-reticle-carousel-next';
8
+ export const CAROUSEL_POSITION_ATTR = 'data-reticle-carousel-position';
9
+ export const CAROUSEL_DISMISSED_KEY = ReticleStorageKey.CAROUSEL_DISMISSED;
6
10
  const CLOSE_LABEL = 'Close';
7
11
  export const ROTATE_MS = 6000;
8
12
  const rotations = new WeakMap();
@@ -22,7 +26,11 @@ function prefersReducedMotion() {
22
26
  }
23
27
  function show(root, index) {
24
28
  root.setAttribute('data-active', String(index));
25
- root.querySelectorAll(`[${CAROUSEL_SLIDE_ATTR}]`).forEach((slide, i) => {
29
+ const slides = root.querySelectorAll(`[${CAROUSEL_SLIDE_ATTR}]`);
30
+ const position = root.querySelector(`[${CAROUSEL_POSITION_ATTR}]`);
31
+ if (position !== null)
32
+ position.textContent = `${String(index + 1)} / ${String(slides.length)}`;
33
+ slides.forEach((slide, i) => {
26
34
  slide.hidden = i !== index;
27
35
  });
28
36
  root.querySelectorAll(`[${CAROUSEL_DOT_ATTR}]`).forEach((dot, i) => {
@@ -37,15 +45,15 @@ export function carouselHtml(slides, active) {
37
45
  const track = slides
38
46
  .map((slide, i) => `<div ${CAROUSEL_SLIDE_ATTR}="${slide.id}" class="reticle-carousel-slide"${i === current ? '' : ' hidden'}>${slide.html}</div>`)
39
47
  .join('');
40
- const dots = 1 === slides.length
48
+ const controls = 1 === slides.length
41
49
  ? ''
42
- : `<div class="reticle-carousel-dots">${slides
43
- .map((slide, i) => `<button type="button" ${CAROUSEL_DOT_ATTR}="${String(i)}" class="reticle-carousel-dot" aria-label="${slide.id}" aria-current="${String(i === current)}"></button>`)
44
- .join('')}</div>`;
50
+ : `<div class="reticle-carousel-controls"><button type="button" ${CAROUSEL_PREV_ATTR} aria-label="Previous message" class="reticle-carousel-arrow">‹</button><div class="reticle-carousel-dots">${slides
51
+ .map((slide, i) => `<button type="button" ${CAROUSEL_DOT_ATTR}="${String(i)}" class="reticle-carousel-dot" aria-label="Show ${slide.id}" aria-current="${String(i === current)}"></button>`)
52
+ .join('')}</div><span ${CAROUSEL_POSITION_ATTR} aria-live="polite">${String(current + 1)} / ${String(slides.length)}</span><button type="button" ${CAROUSEL_NEXT_ATTR} aria-label="Next message" class="reticle-carousel-arrow">›</button></div>`;
45
53
  return `<div ${CAROUSEL_ATTR} class="reticle-carousel" role="region" aria-label="Reticle" data-active="${String(current)}">
46
54
  <button type="button" ${CAROUSEL_CLOSE_ATTR} class="reticle-carousel-close" aria-label="${CLOSE_LABEL}" title="${CLOSE_LABEL}">×</button>
47
55
  <div class="reticle-carousel-track">${track}</div>
48
- ${dots}
56
+ ${controls}
49
57
  </div>`;
50
58
  }
51
59
  function dismissed(storage) {
@@ -88,6 +96,16 @@ export function paintCarousel(log, slides, storage) {
88
96
  show(root, Number(dot.getAttribute(CAROUSEL_DOT_ATTR)));
89
97
  });
90
98
  }
99
+ root.querySelector(`[${CAROUSEL_PREV_ATTR}]`)?.addEventListener('click', (event) => {
100
+ event.preventDefault();
101
+ event.stopPropagation();
102
+ show(root, (Number(root.getAttribute('data-active')) + slides.length - 1) % slides.length);
103
+ });
104
+ root.querySelector(`[${CAROUSEL_NEXT_ATTR}]`)?.addEventListener('click', (event) => {
105
+ event.preventDefault();
106
+ event.stopPropagation();
107
+ show(root, (Number(root.getAttribute('data-active')) + 1) % slides.length);
108
+ });
91
109
  startRotation(log, root, slides.length);
92
110
  }
93
111
  function startRotation(log, root, count) {
@@ -123,4 +141,13 @@ export const CAROUSEL_CSS = `
123
141
  .reticle-carousel-dots{display:flex;justify-content:center;gap:6px;margin-top:6px;}
124
142
  .reticle-carousel-dot{width:6px;height:6px;padding:0;border:0;border-radius:999px;background:currentColor;opacity:.3;cursor:pointer;}
125
143
  .reticle-carousel-dot[aria-current="true"]{opacity:.9;}
144
+ .reticle-carousel-controls{display:flex;align-items:center;gap:8px;margin-top:6px;}
145
+ .reticle-carousel-arrow{width:24px;height:24px;flex:none;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--reticle-line);border-radius:6px;background:rgba(255,255,255,.04);color:var(--reticle-fg);font:inherit;font-size:18px;line-height:1;cursor:pointer;}
146
+ .reticle-carousel-arrow:hover{border-color:var(--reticle-accent);background:var(--reticle-accent-soft);}
147
+ .reticle-carousel-position{margin-left:auto;color:var(--reticle-muted);font-size:10px;font-variant-numeric:tabular-nums;white-space:nowrap;}
148
+ .reticle-carousel-slide .reticle-offer{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;gap:3px 8px;margin:0;padding:0;border:0;background:none;}
149
+ .reticle-carousel-slide .reticle-offer-title{grid-column:1/-1;overflow:hidden;padding:0;font-size:11px;text-overflow:ellipsis;white-space:nowrap;}
150
+ .reticle-carousel-slide .reticle-offer-body{grid-column:1;grid-row:2;margin:0;overflow:hidden;font-size:10px;line-height:1.2;text-overflow:ellipsis;white-space:nowrap;}
151
+ .reticle-carousel-slide .reticle-offer-claim{grid-column:2;grid-row:2;padding:3px 7px;font-size:10px;white-space:nowrap;}
152
+ .reticle-carousel-slide .reticle-offer-claimed{overflow:hidden;margin:0;text-overflow:ellipsis;white-space:nowrap;}
126
153
  `;
@@ -1,6 +1,6 @@
1
1
  export declare const OFFER_ATTR = "data-reticle-offer";
2
2
  export declare const OFFER_CLAIM_ATTR = "data-reticle-offer-claim";
3
- export declare const OFFER_DISMISSED_KEY = "reticle.harnessOffer.dismissed";
3
+ export declare const OFFER_DISMISSED_KEY: "reticle.harnessOffer.dismissed";
4
4
  export declare const OFFER_TEXT: {
5
5
  readonly HEADLINE: "Get Harness free for 3 months";
6
6
  readonly BODY: "Explores your app and saves every journey as a free replay.";
@@ -18,4 +18,4 @@ export interface OfferState {
18
18
  drivable?: boolean | undefined;
19
19
  }
20
20
  export declare function offerHtml(offer: OfferState | undefined, dismissed: boolean): string;
21
- export declare const OFFER_CSS = "\n.reticle-offer{margin:8px 10px;padding:10px 12px;border:1px solid var(--reticle-line,#2a2f3a);border-radius:10px;background:var(--reticle-surface-inset,rgba(255,255,255,.03));}\n.reticle-offer-title{display:block;font-weight:600;font-size:12px;padding-right:18px;}\n.reticle-offer-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}\n.reticle-offer-claim{display:inline-block;font-size:11px;font-weight:600;padding:4px 10px;border-radius:6px;background:var(--reticle-accent,#4f7cff);color:#fff;text-decoration:none;}\n.reticle-offer-claimed{margin:6px 10px;font-size:10px;opacity:.6;}\n";
21
+ export declare const OFFER_CSS: string;
@@ -1,6 +1,7 @@
1
+ import { ReticleStorageKey } from '../../storage-keys.js';
1
2
  export const OFFER_ATTR = 'data-reticle-offer';
2
3
  export const OFFER_CLAIM_ATTR = 'data-reticle-offer-claim';
3
- export const OFFER_DISMISSED_KEY = 'reticle.harnessOffer.dismissed';
4
+ export const OFFER_DISMISSED_KEY = ReticleStorageKey.HARNESS_OFFER_DISMISSED;
4
5
  export const OFFER_TEXT = {
5
6
  HEADLINE: 'Get Harness free for 3 months',
6
7
  BODY: 'Explores your app and saves every journey as a free replay.',
@@ -1,7 +1,8 @@
1
1
  import { type OfferState } from './offer-card.js';
2
2
  import type { Slide } from './carousel.js';
3
3
  export declare const SLIDE_ID: {
4
- readonly HARNESS: "harness";
5
- readonly FOUNDERS: "founders";
4
+ readonly HARNESS: "harness-journeys";
5
+ readonly REPLAY: "replay-confidence";
6
+ readonly OFFER: "harness-offer";
6
7
  };
7
- export declare function panelSlides(offer: OfferState | undefined, offerDeclined: boolean): Slide[];
8
+ export declare function panelSlides(offer: OfferState | undefined, offerDeclined: boolean, notices?: readonly unknown[], base?: string): Slide[];