@reticlehq/browser 3.5.0 → 3.7.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 (116) hide show
  1. package/dist/actions/action-report.d.ts +54 -0
  2. package/dist/actions/action-report.js +60 -0
  3. package/dist/actions/actions-dom.d.ts +1 -0
  4. package/dist/actions/actions-dom.js +56 -43
  5. package/dist/actions/actions-press.d.ts +11 -4
  6. package/dist/actions/actions-press.js +132 -17
  7. package/dist/actions/actions.d.ts +1 -35
  8. package/dist/actions/actions.js +32 -65
  9. package/dist/actions/appeared-text.js +3 -0
  10. package/dist/actions/danger-context.d.ts +2 -0
  11. package/dist/actions/danger-context.js +29 -3
  12. package/dist/actions/value-input.d.ts +1 -0
  13. package/dist/actions/value-input.js +11 -0
  14. package/dist/commands/commands.js +4 -0
  15. package/dist/connect-options.d.ts +1 -0
  16. package/dist/dom/a11y.js +264 -14
  17. package/dist/dom/addressing/coverage-markers.d.ts +6 -0
  18. package/dist/dom/addressing/coverage-markers.js +13 -0
  19. package/dist/dom/addressing/refs.js +2 -1
  20. package/dist/dom/addressing/testid-attr.d.ts +4 -0
  21. package/dist/dom/addressing/testid-attr.js +14 -0
  22. package/dist/dom/dom-ignore.d.ts +2 -0
  23. package/dist/dom/dom-ignore.js +15 -2
  24. package/dist/dom/query.d.ts +2 -1
  25. package/dist/dom/query.js +116 -27
  26. package/dist/dom/render-page.d.ts +12 -0
  27. package/dist/dom/render-page.js +135 -0
  28. package/dist/dom/snapshot.js +56 -7
  29. package/dist/observers/departure.js +51 -9
  30. package/dist/observers/dom.js +35 -18
  31. package/dist/observers/field.js +5 -21
  32. package/dist/observers/health.js +63 -2
  33. package/dist/observers/network-stream.d.ts +1 -1
  34. package/dist/observers/network-stream.js +3 -3
  35. package/dist/observers/network.js +57 -8
  36. package/dist/observers/perf.js +53 -1
  37. package/dist/observers/route.js +6 -5
  38. package/dist/observers/storage.js +6 -1
  39. package/dist/presenter/carousel/carousel.d.ts +5 -2
  40. package/dist/presenter/carousel/carousel.js +30 -8
  41. package/dist/presenter/carousel/panel-slides.d.ts +3 -4
  42. package/dist/presenter/carousel/panel-slides.js +48 -9
  43. package/dist/presenter/carousel/talk-card.d.ts +1 -1
  44. package/dist/presenter/chrome/log-kinds.d.ts +5 -0
  45. package/dist/presenter/chrome/log-kinds.js +1 -0
  46. package/dist/presenter/chrome/log-memory.d.ts +13 -0
  47. package/dist/presenter/chrome/log-memory.js +39 -0
  48. package/dist/presenter/chrome/presenter-hud-chrome.d.ts +1 -0
  49. package/dist/presenter/chrome/presenter-hud-chrome.js +7 -1
  50. package/dist/presenter/chrome/presenter-log.d.ts +12 -4
  51. package/dist/presenter/chrome/presenter-log.js +100 -10
  52. package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
  53. package/dist/presenter/chrome/presenter-morph.js +34 -0
  54. package/dist/presenter/chrome/presenter-report-copy.d.ts +28 -5
  55. package/dist/presenter/chrome/presenter-report-copy.js +40 -5
  56. package/dist/presenter/hud-telemetry.js +83 -3
  57. package/dist/presenter/icons/presenter-heroicons-data.d.ts +1 -0
  58. package/dist/presenter/icons/presenter-heroicons-data.js +4 -3
  59. package/dist/presenter/icons/presenter-icons.d.ts +2 -0
  60. package/dist/presenter/icons/presenter-icons.js +2 -0
  61. package/dist/presenter/presenter-account-styles.js +3 -3
  62. package/dist/presenter/presenter-account.d.ts +31 -8
  63. package/dist/presenter/presenter-account.js +105 -15
  64. package/dist/presenter/presenter-agent-link.d.ts +9 -0
  65. package/dist/presenter/presenter-agent-link.js +62 -0
  66. package/dist/presenter/presenter-chat-views.d.ts +43 -0
  67. package/dist/presenter/presenter-chat-views.js +622 -0
  68. package/dist/presenter/presenter-config.d.ts +5 -2
  69. package/dist/presenter/presenter-config.js +5 -3
  70. package/dist/presenter/presenter-controls.d.ts +3 -2
  71. package/dist/presenter/presenter-controls.js +163 -61
  72. package/dist/presenter/presenter-design-tokens.d.ts +1 -0
  73. package/dist/presenter/presenter-design-tokens.js +34 -0
  74. package/dist/presenter/presenter-dock-layout.js +1 -1
  75. package/dist/presenter/presenter-frame.d.ts +12 -0
  76. package/dist/presenter/presenter-frame.js +187 -0
  77. package/dist/presenter/presenter-harness-row.d.ts +67 -0
  78. package/dist/presenter/presenter-harness-row.js +209 -0
  79. package/dist/presenter/presenter-personas.d.ts +35 -0
  80. package/dist/presenter/presenter-personas.js +95 -0
  81. package/dist/presenter/presenter-plan.d.ts +10 -0
  82. package/dist/presenter/presenter-plan.js +186 -0
  83. package/dist/presenter/presenter-report-styles.js +46 -21
  84. package/dist/presenter/presenter-report.d.ts +2 -1
  85. package/dist/presenter/presenter-report.js +123 -20
  86. package/dist/presenter/presenter-settings-styles.js +18 -4
  87. package/dist/presenter/presenter-settings.d.ts +8 -2
  88. package/dist/presenter/presenter-settings.js +134 -33
  89. package/dist/presenter/presenter-shell-styles.js +48 -42
  90. package/dist/presenter/presenter-shell.d.ts +17 -4
  91. package/dist/presenter/presenter-shell.js +139 -79
  92. package/dist/presenter/presenter-styles.js +9 -3
  93. package/dist/presenter/presenter-workspace.js +10 -3
  94. package/dist/presenter/presenter.d.ts +6 -4
  95. package/dist/presenter/presenter.js +171 -22
  96. package/dist/presenter/tour/tour.d.ts +4 -1
  97. package/dist/presenter/tour/tour.js +6 -4
  98. package/dist/recorder/recorder.js +2 -2
  99. package/dist/registry/auto-testids.js +3 -4
  100. package/dist/registry/declared-platform.js +4 -0
  101. package/dist/reticle-inject.js +938 -494
  102. package/dist/reticle.d.ts +10 -1
  103. package/dist/reticle.js +109 -11
  104. package/dist/review/annotator-styles.d.ts +1 -1
  105. package/dist/review/annotator.d.ts +7 -0
  106. package/dist/review/annotator.js +20 -2
  107. package/dist/review/mark-anchor.js +2 -2
  108. package/dist/session-continuity.js +2 -1
  109. package/dist/storage-keys.d.ts +15 -0
  110. package/dist/storage-keys.js +21 -0
  111. package/dist/transport/transport.js +5 -3
  112. package/dist/transport/unreachable-message.d.ts +1 -1
  113. package/dist/transport/unreachable-message.js +3 -5
  114. package/package.json +3 -2
  115. package/dist/presenter/carousel/offer-card.d.ts +0 -21
  116. package/dist/presenter/carousel/offer-card.js +0 -55
@@ -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,14 +1,15 @@
1
- import { EventType, TruncationChannel } from '@reticlehq/core';
2
- import { getAccessibleName, getRole, isVisible } from '../dom/a11y.js';
1
+ import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
2
+ import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
3
+ import { getAccessibleName, getRole, isSensitiveField, isVisible } from '../dom/a11y.js';
3
4
  import { refs } from '../dom/addressing/refs.js';
4
5
  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
- import { isReticleOverlay } from '../dom/dom-ignore.js';
12
+ import { isUnobserved } from '../dom/dom-ignore.js';
12
13
  import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
13
14
  import { isElement } from '../dom/realm.js';
14
15
  import { observeSameOriginFrames } from './frames.js';
@@ -50,7 +51,7 @@ function describeMount(node) {
50
51
  if (scanned >= MOUNT_SCAN_LIMIT)
51
52
  return undefined;
52
53
  scanned += 1;
53
- if (isReticleOverlay(child))
54
+ if (isUnobserved(child))
54
55
  continue;
55
56
  const childRole = getRole(child);
56
57
  const childName = getAccessibleName(child);
@@ -70,6 +71,18 @@ function textOf(nodes) {
70
71
  const trimmed = out.trim();
71
72
  return trimmed.length > 0 ? trimmed : undefined;
72
73
  }
74
+ const SAFE_ON_SECRET = new Set([
75
+ 'class',
76
+ 'style',
77
+ 'type',
78
+ 'disabled',
79
+ 'readonly',
80
+ 'required',
81
+ 'hidden',
82
+ ]);
83
+ function secretAttr(el, attr) {
84
+ return !SAFE_ON_SECRET.has(attr) && !attr.startsWith('aria-') && isSensitiveField(el);
85
+ }
73
86
  export function installDom(emit) {
74
87
  const stopRegistry = installShadowRegistry();
75
88
  const observer = new MutationObserver((records) => {
@@ -80,14 +93,15 @@ export function installDom(emit) {
80
93
  for (const record of records) {
81
94
  if ('attributes' === record.type) {
82
95
  const target = record.target;
83
- if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
96
+ if (isElement(target) && record.attributeName !== null && !isUnobserved(target)) {
84
97
  if (changed >= MAX_PER_BATCH) {
85
98
  dropped += 1;
86
99
  continue;
87
100
  }
88
101
  changed += 1;
89
- const value = capValue(target.getAttribute(record.attributeName));
90
- const old = capValue(record.oldValue);
102
+ const hide = secretAttr(target, record.attributeName);
103
+ const value = hide ? REDACTED_VALUE : capValue(target.getAttribute(record.attributeName));
104
+ const old = hide && null !== record.oldValue ? REDACTED_VALUE : capValue(record.oldValue);
91
105
  emit(EventType.DOM_ATTR, {
92
106
  attr: record.attributeName,
93
107
  ...(value === undefined ? {} : { value }),
@@ -98,27 +112,31 @@ export function installDom(emit) {
98
112
  }
99
113
  if ('characterData' === record.type) {
100
114
  const parent = record.target.parentElement;
101
- if (parent !== null && !isReticleOverlay(parent)) {
115
+ if (parent !== null && !isUnobserved(parent)) {
102
116
  if (changed >= MAX_PER_BATCH) {
103
117
  dropped += 1;
104
118
  continue;
105
119
  }
106
120
  changed += 1;
107
- const text = (record.target.textContent ?? '').trim().slice(0, 80);
108
- const old = capValue(record.oldValue?.trim() ?? null);
121
+ const hide = isSensitiveField(parent);
122
+ const text = hide
123
+ ? REDACTED_VALUE
124
+ : (record.target.textContent ?? '').trim().slice(0, 80);
125
+ const old = hide ? undefined : capValue(record.oldValue?.trim() ?? null);
109
126
  emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
110
127
  }
111
128
  continue;
112
129
  }
113
130
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
114
131
  const parent = isElement(record.target) ? record.target : null;
115
- if (parent !== null && !isReticleOverlay(parent)) {
132
+ if (parent !== null && !isUnobserved(parent)) {
116
133
  if (changed >= MAX_PER_BATCH)
117
134
  dropped += 1;
118
135
  else {
119
136
  changed += 1;
120
- const text = (parent.textContent ?? '').trim().slice(0, 80);
121
- const old = capValue(textOf(record.removedNodes) ?? null);
137
+ const hide = isSensitiveField(parent);
138
+ const text = hide ? REDACTED_VALUE : (parent.textContent ?? '').trim().slice(0, 80);
139
+ const old = hide ? undefined : capValue(textOf(record.removedNodes) ?? null);
122
140
  emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
123
141
  }
124
142
  }
@@ -130,7 +148,7 @@ export function installDom(emit) {
130
148
  dropped += 1;
131
149
  continue;
132
150
  }
133
- if (isReticleOverlay(node))
151
+ if (isUnobserved(node))
134
152
  continue;
135
153
  const mounted = describeMount(node);
136
154
  if (mounted === undefined)
@@ -153,8 +171,7 @@ export function installDom(emit) {
153
171
  dropped += 1;
154
172
  continue;
155
173
  }
156
- if (isReticleOverlay(node) ||
157
- (isElement(record.target) && isReticleOverlay(record.target))) {
174
+ if (isUnobserved(node) || (isElement(record.target) && isUnobserved(record.target))) {
158
175
  continue;
159
176
  }
160
177
  const gone = describeMount(node);
@@ -1,15 +1,15 @@
1
- import { EventType, isSensitiveKey } from '@reticlehq/core';
2
- import { getAccessibleName } from '../dom/a11y.js';
1
+ import { readTestId } from '../dom/addressing/testid-attr.js';
2
+ import { EventType } from '@reticlehq/core';
3
+ import { getAccessibleName, isSensitiveField } from '../dom/a11y.js';
3
4
  const MAX_VALUE_LEN = 160;
4
5
  const BURST_MS = 150;
5
- const PAYMENT_AUTOCOMPLETE = /^(cc-|current-password|new-password)/i;
6
6
  function isField(target) {
7
7
  return (target instanceof HTMLInputElement ||
8
8
  target instanceof HTMLTextAreaElement ||
9
9
  target instanceof HTMLSelectElement);
10
10
  }
11
11
  function fieldName(element) {
12
- const testid = element.getAttribute('data-testid');
12
+ const testid = readTestId(element);
13
13
  if (null !== testid && testid.length > 0)
14
14
  return testid;
15
15
  if (element.name.length > 0)
@@ -20,29 +20,13 @@ function fieldName(element) {
20
20
  const id = element.getAttribute('id');
21
21
  return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
22
22
  }
23
- function isSecret(element) {
24
- if (element instanceof HTMLInputElement && 'password' === element.type)
25
- return true;
26
- const hint = element.getAttribute('autocomplete');
27
- if (null !== hint && PAYMENT_AUTOCOMPLETE.test(hint))
28
- return true;
29
- for (const key of [
30
- element.name,
31
- element.getAttribute('data-testid'),
32
- element.getAttribute('id'),
33
- ]) {
34
- if (null !== key && key.length > 0 && isSensitiveKey(key))
35
- return true;
36
- }
37
- return false;
38
- }
39
23
  export function installField(emit) {
40
24
  const ac = new AbortController();
41
25
  const { signal } = ac;
42
26
  const pending = new Map();
43
27
  const report = (element, kind) => {
44
28
  const value = element.value;
45
- const secret = isSecret(element);
29
+ const secret = isSensitiveField(element);
46
30
  emit(EventType.FIELD_CHANGE, {
47
31
  field: fieldName(element),
48
32
  kind,
@@ -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
  });
@@ -63,15 +63,64 @@ function jsonShape(text) {
63
63
  return ShapeToken.OTHER;
64
64
  }
65
65
  }
66
+ const MAX_DECODED_BODY_BYTES = 1 << 20;
67
+ const TAB = 0x09;
68
+ const LINE_FEED = 0x0a;
69
+ const CARRIAGE_RETURN = 0x0d;
70
+ const FIRST_PRINTABLE = 0x20;
71
+ const DELETE = 0x7f;
72
+ function hasControlCharacter(text) {
73
+ for (let i = 0; i < text.length; i += 1) {
74
+ const code = text.charCodeAt(i);
75
+ if (code === DELETE)
76
+ return true;
77
+ if (code < FIRST_PRINTABLE && code !== TAB && code !== LINE_FEED && code !== CARRIAGE_RETURN) {
78
+ return true;
79
+ }
80
+ }
81
+ return false;
82
+ }
83
+ function textOfBytes(body) {
84
+ if (!(body instanceof ArrayBuffer || ArrayBuffer.isView(body)))
85
+ return undefined;
86
+ if (body.byteLength > MAX_DECODED_BODY_BYTES)
87
+ return undefined;
88
+ try {
89
+ const text = new TextDecoder('utf-8', { fatal: true }).decode(body);
90
+ return hasControlCharacter(text) ? undefined : text;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ function formShape(form) {
97
+ const fields = [];
98
+ let length = 0;
99
+ form.forEach((value, name) => {
100
+ if ('string' === typeof value)
101
+ length += value.length;
102
+ fields.push(JSON.stringify([name, 'string' === typeof value ? 0 : [value.type, value.size]]));
103
+ });
104
+ return 0 === fields.length ? undefined : fingerprintBody(fields.sort().join(), length);
105
+ }
66
106
  function projectRequestBody(body, captureBodies) {
67
107
  let text;
68
108
  let contentType = 'application/json';
69
109
  let shape = REQUEST_SHAPE_NONE;
70
- if ('string' === typeof body) {
71
- text = body;
110
+ const bytesText = textOfBytes(body);
111
+ const typeOf = () => body.constructor?.name ?? typeof body;
112
+ if ('string' === typeof body || bytesText !== undefined) {
113
+ text = bytesText ?? body;
72
114
  if (text.length > 0)
73
115
  shape = fingerprintBody(jsonShape(text), text.length);
74
116
  }
117
+ else if (typeof FormData !== 'undefined' && body instanceof FormData) {
118
+ const form = formShape(body);
119
+ return {
120
+ ...(form === undefined ? {} : { [REQUEST_SHAPE_FIELD]: form }),
121
+ ...(captureBodies ? { requestBodyType: typeOf() } : {}),
122
+ };
123
+ }
75
124
  else if (typeof URLSearchParams !== 'undefined' && body instanceof URLSearchParams) {
76
125
  text = body.toString();
77
126
  contentType = 'application/x-www-form-urlencoded';
@@ -81,8 +130,7 @@ function projectRequestBody(body, captureBodies) {
81
130
  }
82
131
  }
83
132
  else if (body !== undefined && body !== null) {
84
- const kind = body.constructor?.name ?? typeof body;
85
- return captureBodies ? { requestBodyType: kind } : {};
133
+ return captureBodies ? { requestBodyType: typeOf() } : {};
86
134
  }
87
135
  const fields = { [REQUEST_SHAPE_FIELD]: shape };
88
136
  if (!captureBodies || text === undefined || 0 === text.length)
@@ -202,6 +250,7 @@ export function installNetwork(emit, opts = {}) {
202
250
  const method = methodOf(input, init);
203
251
  const nextAction = nextActionOf(input, init);
204
252
  const nextActionFields = nextAction === undefined ? {} : { [NEXT_ACTION_FIELD]: nextAction };
253
+ const bodyFields = observeValue(() => projectRequestBody(init?.body, captureBodies)) ?? {};
205
254
  const urlFields = netUrlFields(rawUrl);
206
255
  const url = urlFields.url;
207
256
  const initiatorStack = observeValue(() => initiatorFrame());
@@ -220,7 +269,7 @@ export function installNetwork(emit, opts = {}) {
220
269
  const headersAt = performance.now();
221
270
  const contentType = observeValue(() => res.headers.get('content-type')) ?? null;
222
271
  observeSafely(() => {
223
- watchStreamedBody(emit, res, id, url, contentType, res.headers.get('content-length'));
272
+ watchStreamedBody(emit, res, id, urlFields, contentType, res.headers.get('content-length'));
224
273
  });
225
274
  reportedNetUrls.add(rawUrl);
226
275
  const emitRequest = (responseBodyFields) => {
@@ -236,7 +285,7 @@ export function installNetwork(emit, opts = {}) {
236
285
  ...initiatorFields,
237
286
  ...resourceTiming(rawUrl),
238
287
  ...netResponseMeta(res.statusText, contentType, res.headers.get('content-length')),
239
- ...projectRequestBody(init?.body, captureBodies),
288
+ ...bodyFields,
240
289
  ...nextActionFields,
241
290
  ...responseBodyFields,
242
291
  ...(reinterpret?.(url, (name) => res.headers.get(name)) ?? {}),
@@ -326,7 +375,7 @@ export function installNetwork(emit, opts = {}) {
326
375
  const m = meta.get(this);
327
376
  if (m !== undefined) {
328
377
  m.start = performance.now();
329
- m.reqBody = body ?? null;
378
+ m.reqFields = observeValue(() => projectRequestBody(body ?? null, captureBodies)) ?? {};
330
379
  m.initiatorStack = initiatorFrame();
331
380
  const initiatorFields = m.initiatorStack === undefined ? {} : { initiatorStack: m.initiatorStack };
332
381
  emit(EventType.NET_PENDING, {
@@ -375,7 +424,7 @@ export function installNetwork(emit, opts = {}) {
375
424
  ...(cur.initiatorStack === undefined ? {} : { initiatorStack: cur.initiatorStack }),
376
425
  ...resourceTiming(cur.rawUrl),
377
426
  ...netResponseMeta(this.statusText, xhrContentType, this.getResponseHeader('content-length')),
378
- ...projectRequestBody(cur.reqBody, captureBodies),
427
+ ...cur.reqFields,
379
428
  ...responseBodyFields,
380
429
  });
381
430
  });
@@ -1,4 +1,50 @@
1
1
  import { EventType, PerfMetric } from '@reticlehq/core';
2
+ import { refs } from '../dom/addressing/refs.js';
3
+ import { getTestIdAttr, readTestId } from '../dom/addressing/testid-attr.js';
4
+ import { isReticleOverlay } from '../dom/dom-ignore.js';
5
+ import { isElement } from '../dom/realm.js';
6
+ const areaOf = (r) => r === undefined ? 0 : Math.max(0, r.width) * Math.max(0, r.height);
7
+ function cssIdent(value) {
8
+ const css = globalThis.CSS;
9
+ if ('function' === typeof css?.escape)
10
+ return css.escape(value);
11
+ let out = '';
12
+ for (const [i, ch] of [...value].entries()) {
13
+ const startsWithDigit = /\d/.test(ch) && (0 === i || (1 === i && value.startsWith('-')));
14
+ if (startsWithDigit)
15
+ out += `\\${ch.charCodeAt(0).toString(16)} `;
16
+ else if (/[\w-]/.test(ch))
17
+ out += ch;
18
+ else
19
+ out += `\\${ch}`;
20
+ }
21
+ return out;
22
+ }
23
+ function shortSelector(el) {
24
+ const testid = readTestId(el);
25
+ if (null !== testid)
26
+ return `[${getTestIdAttr()}="${testid.replace(/["\\]/g, '\\$&')}"]`;
27
+ const tag = el.tagName.toLowerCase();
28
+ if (0 < el.id.length)
29
+ return `${tag}#${cssIdent(el.id)}`;
30
+ const cls = el.classList.item(0);
31
+ return null === cls ? tag : `${tag}.${cssIdent(cls)}`;
32
+ }
33
+ function largestShifted(sources) {
34
+ let best;
35
+ for (const source of sources ?? []) {
36
+ const node = source.node ?? null;
37
+ const el = null === node ? null : isElement(node) ? node : node.parentElement;
38
+ if (null === el || isReticleOverlay(el))
39
+ continue;
40
+ const area = Math.max(areaOf(source.previousRect), areaOf(source.currentRect));
41
+ if (best === undefined || area > best.area)
42
+ best = { el, area };
43
+ }
44
+ return best === undefined
45
+ ? undefined
46
+ : { ref: refs.refFor(best.el), selector: shortSelector(best.el) };
47
+ }
2
48
  export function installPerf(emit) {
3
49
  if (typeof PerformanceObserver !== 'function')
4
50
  return () => undefined;
@@ -29,7 +75,13 @@ export function installPerf(emit) {
29
75
  if (true === ls.hadRecentInput)
30
76
  return;
31
77
  cls += ls.value ?? 0;
32
- emit(EventType.PERF, { metric: PerfMetric.CLS, value: cls, at: Math.round(e.startTime) });
78
+ const shifted = largestShifted(ls.sources);
79
+ emit(EventType.PERF, {
80
+ metric: PerfMetric.CLS,
81
+ value: cls,
82
+ at: Math.round(e.startTime),
83
+ ...(shifted === undefined ? {} : { shifted }),
84
+ });
33
85
  });
34
86
  observe('longtask', (e) => {
35
87
  emit(EventType.PERF, {
@@ -1,4 +1,4 @@
1
- import { EventType } from '@reticlehq/core';
1
+ import { EventType, ROUTE_CHANGE_HOW_FIELD, RouteChangeHow } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  import { captureMethod } from '../patching/capture-method.js';
4
4
  function snapshotLocation() {
@@ -16,7 +16,7 @@ export function installRoute(emit) {
16
16
  const callPush = origPush.bind(history);
17
17
  const callReplace = origReplace.bind(history);
18
18
  let lastHref = location.href;
19
- const fire = (from) => {
19
+ const fire = (from, how) => {
20
20
  observeSafely(() => {
21
21
  const to = snapshotLocation();
22
22
  if (to.href === from)
@@ -28,23 +28,24 @@ export function installRoute(emit) {
28
28
  pathname: to.pathname,
29
29
  search: to.search,
30
30
  hash: to.hash,
31
+ [ROUTE_CHANGE_HOW_FIELD]: how,
31
32
  });
32
33
  });
33
34
  };
34
35
  const patchedPush = (data, unused, url) => {
35
36
  const from = location.href;
36
37
  callPush(data, unused, url ?? null);
37
- fire(from);
38
+ fire(from, RouteChangeHow.PUSH);
38
39
  };
39
40
  const patchedReplace = (data, unused, url) => {
40
41
  const from = location.href;
41
42
  callReplace(data, unused, url ?? null);
42
- fire(from);
43
+ fire(from, RouteChangeHow.REPLACE);
43
44
  };
44
45
  history.pushState = patchedPush;
45
46
  history.replaceState = patchedReplace;
46
47
  const onNav = () => {
47
- fire(lastHref);
48
+ fire(lastHref, RouteChangeHow.POP);
48
49
  };
49
50
  window.addEventListener('popstate', onNav, { signal: ac.signal });
50
51
  window.addEventListener('hashchange', onNav, { signal: ac.signal });
@@ -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;