@reticlehq/browser 3.5.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 (87) 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 -0
  5. package/dist/actions/actions.js +25 -16
  6. package/dist/actions/danger-context.js +1 -1
  7. package/dist/connect-options.d.ts +1 -0
  8. package/dist/dom/a11y.js +13 -4
  9. package/dist/dom/addressing/refs.js +2 -1
  10. package/dist/dom/addressing/testid-attr.d.ts +4 -0
  11. package/dist/dom/addressing/testid-attr.js +14 -0
  12. package/dist/dom/query.d.ts +1 -0
  13. package/dist/dom/query.js +38 -11
  14. package/dist/dom/snapshot.js +1 -0
  15. package/dist/observers/departure.js +51 -9
  16. package/dist/observers/dom.js +3 -2
  17. package/dist/observers/field.js +5 -3
  18. package/dist/observers/health.js +63 -2
  19. package/dist/observers/network-stream.d.ts +1 -1
  20. package/dist/observers/network-stream.js +3 -3
  21. package/dist/observers/network.js +1 -1
  22. package/dist/observers/storage.js +6 -1
  23. package/dist/presenter/carousel/carousel.d.ts +5 -2
  24. package/dist/presenter/carousel/carousel.js +34 -7
  25. package/dist/presenter/carousel/offer-card.d.ts +2 -2
  26. package/dist/presenter/carousel/offer-card.js +2 -1
  27. package/dist/presenter/carousel/panel-slides.d.ts +4 -3
  28. package/dist/presenter/carousel/panel-slides.js +43 -6
  29. package/dist/presenter/carousel/talk-card.d.ts +1 -1
  30. package/dist/presenter/chrome/log-kinds.d.ts +5 -0
  31. package/dist/presenter/chrome/log-kinds.js +1 -0
  32. package/dist/presenter/chrome/log-memory.d.ts +12 -0
  33. package/dist/presenter/chrome/log-memory.js +39 -0
  34. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +1 -0
  35. package/dist/presenter/chrome/presenter-hud-chrome.js +7 -1
  36. package/dist/presenter/chrome/presenter-log.d.ts +5 -4
  37. package/dist/presenter/chrome/presenter-log.js +55 -5
  38. package/dist/presenter/chrome/presenter-report-copy.d.ts +11 -1
  39. package/dist/presenter/chrome/presenter-report-copy.js +14 -1
  40. package/dist/presenter/hud-telemetry.js +31 -2
  41. package/dist/presenter/icons/presenter-heroicons-data.d.ts +1 -0
  42. package/dist/presenter/icons/presenter-heroicons-data.js +4 -3
  43. package/dist/presenter/icons/presenter-icons.d.ts +2 -0
  44. package/dist/presenter/icons/presenter-icons.js +2 -0
  45. package/dist/presenter/presenter-account.d.ts +5 -4
  46. package/dist/presenter/presenter-account.js +23 -10
  47. package/dist/presenter/presenter-chat-views.d.ts +21 -0
  48. package/dist/presenter/presenter-chat-views.js +382 -0
  49. package/dist/presenter/presenter-config.d.ts +3 -2
  50. package/dist/presenter/presenter-config.js +4 -3
  51. package/dist/presenter/presenter-controls.d.ts +4 -2
  52. package/dist/presenter/presenter-controls.js +231 -51
  53. package/dist/presenter/presenter-design-tokens.d.ts +1 -0
  54. package/dist/presenter/presenter-design-tokens.js +34 -0
  55. package/dist/presenter/presenter-dock-layout.js +1 -1
  56. package/dist/presenter/presenter-frame.d.ts +12 -0
  57. package/dist/presenter/presenter-frame.js +189 -0
  58. package/dist/presenter/presenter-plan.d.ts +10 -0
  59. package/dist/presenter/presenter-plan.js +185 -0
  60. package/dist/presenter/presenter-report-styles.js +7 -0
  61. package/dist/presenter/presenter-report.d.ts +1 -1
  62. package/dist/presenter/presenter-report.js +53 -16
  63. package/dist/presenter/presenter-settings-styles.js +10 -0
  64. package/dist/presenter/presenter-settings.d.ts +5 -0
  65. package/dist/presenter/presenter-settings.js +71 -11
  66. package/dist/presenter/presenter-shell-styles.js +39 -42
  67. package/dist/presenter/presenter-shell.d.ts +8 -1
  68. package/dist/presenter/presenter-shell.js +124 -72
  69. package/dist/presenter/presenter-styles.js +9 -1
  70. package/dist/presenter/presenter.d.ts +4 -3
  71. package/dist/presenter/presenter.js +101 -13
  72. package/dist/presenter/tour/tour.d.ts +4 -1
  73. package/dist/presenter/tour/tour.js +6 -4
  74. package/dist/recorder/recorder.js +2 -2
  75. package/dist/registry/auto-testids.js +3 -4
  76. package/dist/registry/declared-platform.js +2 -0
  77. package/dist/reticle-inject.js +880 -513
  78. package/dist/reticle.d.ts +10 -1
  79. package/dist/reticle.js +73 -9
  80. package/dist/review/annotator-styles.d.ts +1 -1
  81. package/dist/review/annotator.d.ts +7 -0
  82. package/dist/review/annotator.js +20 -2
  83. package/dist/review/mark-anchor.js +2 -2
  84. package/dist/session-continuity.js +2 -1
  85. package/dist/storage-keys.d.ts +14 -0
  86. package/dist/storage-keys.js +17 -0
  87. package/package.json +3 -2
package/dist/dom/query.js CHANGED
@@ -1,4 +1,4 @@
1
- import { DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
1
+ import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
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';
@@ -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('*'))];
@@ -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) => {
@@ -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[];
@@ -1,10 +1,47 @@
1
1
  import { offerHtml } from './offer-card.js';
2
- import { talkCardHtml } from './talk-card.js';
3
- export const SLIDE_ID = { HARNESS: 'harness', FOUNDERS: 'founders' };
4
- export function panelSlides(offer, offerDeclined) {
5
- const harness = offerHtml(offer, offerDeclined);
2
+ import { HudNoticeSchema } from '@reticlehq/core/hud';
3
+ import { esc } from '../chrome/presenter-safe-html.js';
4
+ import { DEFAULT_PLATFORM_URL } from '@reticlehq/core';
5
+ const HARNESS_PATH = '/settings?group=billing';
6
+ export const SLIDE_ID = {
7
+ HARNESS: 'harness-journeys',
8
+ REPLAY: 'replay-confidence',
9
+ OFFER: 'harness-offer',
10
+ };
11
+ const valueSlides = (base) => [
12
+ {
13
+ id: SLIDE_ID.HARNESS,
14
+ html: `<div class="reticle-promo-copy"><span class="reticle-promo-kicker">RETICLE HARNESS</span><strong class="reticle-promo-title">Let Reticle drive your app for you</strong><span class="reticle-promo-detail">Describe a user; it drives the journey and saves it to replay.</span><a data-reticle-promo-link class="reticle-promo-link" href="${base}${HARNESS_PATH}" target="_blank" rel="noopener noreferrer">Explore →</a></div>`,
15
+ },
16
+ {
17
+ id: SLIDE_ID.REPLAY,
18
+ html: `<div class="reticle-promo-copy"><span class="reticle-promo-kicker">FROM RUN TO REGRESSION</span><strong class="reticle-promo-title">Keep every journey replayable</strong><span class="reticle-promo-detail">Catch what changes before users do.</span><a data-reticle-promo-link class="reticle-promo-link" href="${base}${HARNESS_PATH}" target="_blank" rel="noopener noreferrer">See how →</a></div>`,
19
+ },
20
+ ];
21
+ const NOTICE_SLIDE_PREFIX = 'notice-';
22
+ function noticeSlide(notice) {
23
+ const kicker = notice.kicker === undefined
24
+ ? ''
25
+ : `<span class="reticle-promo-kicker">${esc(notice.kicker)}</span>`;
26
+ const detail = notice.detail === undefined
27
+ ? ''
28
+ : `<span class="reticle-promo-detail">${esc(notice.detail)}</span>`;
29
+ const link = notice.cta === undefined
30
+ ? ''
31
+ : `<a data-reticle-promo-link class="reticle-promo-link" href="${esc(notice.cta.url)}" target="_blank" rel="noopener noreferrer">${esc(notice.cta.label)} →</a>`;
32
+ return {
33
+ id: `${NOTICE_SLIDE_PREFIX}${notice.id}`,
34
+ html: `<div class="reticle-promo-copy">${kicker}<strong class="reticle-promo-title">${esc(notice.title)}</strong>${detail}${link}</div>`,
35
+ };
36
+ }
37
+ export function panelSlides(offer, offerDeclined, notices = [], base = DEFAULT_PLATFORM_URL) {
38
+ const harnessOffer = offerHtml(offer, offerDeclined);
39
+ const valid = notices.flatMap((n) => {
40
+ const parsed = HudNoticeSchema.safeParse(n);
41
+ return parsed.success ? [parsed.data] : [];
42
+ });
6
43
  return [
7
- ...('' === harness ? [] : [{ id: SLIDE_ID.HARNESS, html: harness }]),
8
- { id: SLIDE_ID.FOUNDERS, html: talkCardHtml() },
44
+ ...('' === harnessOffer ? [] : [{ id: SLIDE_ID.OFFER, html: harnessOffer }]),
45
+ ...(0 < valid.length ? valid.map(noticeSlide) : valueSlides(base)),
9
46
  ];
10
47
  }
@@ -5,4 +5,4 @@ export declare const TALK_TEXT: {
5
5
  readonly BOOK: "Book a call";
6
6
  };
7
7
  export declare function talkCardHtml(): string;
8
- export declare const TALK_CSS = "\n.reticle-talk-title{display:block;font-weight:600;font-size:12px;padding-right:18px;}\n.reticle-talk-body{margin:6px 0 8px;font-size:11px;line-height:1.45;opacity:.8;}\n.reticle-talk-book{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";
8
+ export declare const TALK_CSS: string;
@@ -10,3 +10,8 @@ export declare const LOG_RESULT: {
10
10
  readonly FAIL: "fail";
11
11
  };
12
12
  export type LogResult = (typeof LOG_RESULT)[keyof typeof LOG_RESULT];
13
+ export declare const LOG_ACTOR: {
14
+ readonly AGENT: "agent";
15
+ readonly HARNESS: "harness";
16
+ };
17
+ export type LogActor = (typeof LOG_ACTOR)[keyof typeof LOG_ACTOR];
@@ -5,3 +5,4 @@ export const LOG_KIND = {
5
5
  HUMAN: 'human',
6
6
  };
7
7
  export const LOG_RESULT = { PASS: 'pass', FAIL: 'fail' };
8
+ export const LOG_ACTOR = { AGENT: 'agent', HARNESS: 'harness' };