@reticlehq/browser 3.2.0 → 3.3.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 (69) hide show
  1. package/dist/actions/actions-dom.d.ts +1 -1
  2. package/dist/actions/actions-dom.js +5 -4
  3. package/dist/actions/actions.d.ts +0 -1
  4. package/dist/actions/actions.js +4 -2
  5. package/dist/actions/value-input.js +14 -5
  6. package/dist/connect-options.d.ts +1 -0
  7. package/dist/dom/addressing/source.d.ts +2 -1
  8. package/dist/dom/dom-ignore.d.ts +1 -0
  9. package/dist/dom/dom-ignore.js +13 -0
  10. package/dist/dom/query.js +6 -4
  11. package/dist/dom/snapshot.d.ts +1 -1
  12. package/dist/observers/console.js +38 -13
  13. package/dist/observers/dom.js +3 -1
  14. package/dist/observers/download.js +3 -2
  15. package/dist/observers/error-repeats.d.ts +18 -0
  16. package/dist/observers/error-repeats.js +41 -0
  17. package/dist/observers/field.d.ts +2 -0
  18. package/dist/observers/field.js +86 -0
  19. package/dist/observers/frames.js +3 -4
  20. package/dist/observers/install-all.js +2 -0
  21. package/dist/observers/net-detail/network-body.d.ts +2 -0
  22. package/dist/observers/net-detail/network-body.js +24 -7
  23. package/dist/observers/route.js +4 -4
  24. package/dist/observers/state.js +18 -8
  25. package/dist/presenter/carousel/carousel.d.ts +13 -0
  26. package/dist/presenter/carousel/carousel.js +126 -0
  27. package/dist/presenter/carousel/offer-card.d.ts +21 -0
  28. package/dist/presenter/carousel/offer-card.js +55 -0
  29. package/dist/presenter/carousel/panel-slides.d.ts +7 -0
  30. package/dist/presenter/carousel/panel-slides.js +10 -0
  31. package/dist/presenter/carousel/talk-card.d.ts +8 -0
  32. package/dist/presenter/carousel/talk-card.js +20 -0
  33. package/dist/presenter/chrome/presenter-log.d.ts +2 -1
  34. package/dist/presenter/chrome/presenter-log.js +14 -3
  35. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -1
  36. package/dist/presenter/chrome/presenter-report-copy.js +1 -1
  37. package/dist/presenter/hud-telemetry.d.ts +3 -0
  38. package/dist/presenter/hud-telemetry.js +87 -0
  39. package/dist/presenter/presenter-account.js +1 -1
  40. package/dist/presenter/presenter-config.d.ts +5 -1
  41. package/dist/presenter/presenter-config.js +4 -1
  42. package/dist/presenter/presenter-controls.d.ts +1 -0
  43. package/dist/presenter/presenter-controls.js +12 -1
  44. package/dist/presenter/presenter-report.js +6 -6
  45. package/dist/presenter/presenter-settings-styles.js +2 -1
  46. package/dist/presenter/presenter-settings.d.ts +2 -4
  47. package/dist/presenter/presenter-settings.js +13 -4
  48. package/dist/presenter/presenter-shell-styles.js +4 -0
  49. package/dist/presenter/presenter-shell.d.ts +4 -1
  50. package/dist/presenter/presenter-shell.js +49 -11
  51. package/dist/presenter/presenter-styles.js +5 -1
  52. package/dist/presenter/presenter-workspace.d.ts +0 -7
  53. package/dist/presenter/presenter-workspace.js +7 -7
  54. package/dist/presenter/presenter.js +43 -10
  55. package/dist/presenter/tour/tour.d.ts +3 -0
  56. package/dist/presenter/tour/tour.js +5 -0
  57. package/dist/recorder/recorder.js +25 -22
  58. package/dist/reticle-inject.d.ts +2 -0
  59. package/dist/reticle-inject.js +1337 -0
  60. package/dist/reticle.js +6 -0
  61. package/dist/review/annotator.d.ts +3 -0
  62. package/dist/review/annotator.js +37 -37
  63. package/dist/review/marks-for-agent.d.ts +9 -0
  64. package/dist/review/marks-for-agent.js +12 -0
  65. package/dist/util/captured-method.d.ts +0 -1
  66. package/dist/util/captured-method.js +1 -1
  67. package/package.json +8 -4
  68. package/dist/presenter/presenter-offer.d.ts +0 -25
  69. package/dist/presenter/presenter-offer.js +0 -87
@@ -9,7 +9,7 @@ export declare function fireClickSequence(el: HTMLElement, hold?: {
9
9
  ms: number;
10
10
  sleep: (ms: number) => Promise<void>;
11
11
  now: () => number;
12
- }): Promise<{
12
+ }, detail?: number): Promise<{
13
13
  prevented: boolean;
14
14
  heldMs: number;
15
15
  }>;
@@ -16,11 +16,12 @@ function pointerEventFor(el, type, init) {
16
16
  ? new PointerEvent(type, full)
17
17
  : new MouseEvent(type, full);
18
18
  }
19
- export async function fireClickSequence(el, hold) {
19
+ export async function fireClickSequence(el, hold, detail) {
20
20
  const doc = el.ownerDocument;
21
21
  const from = doc.activeElement ?? doc.body;
22
+ const init = { bubbles: true, cancelable: true, detail: detail ?? 0 };
22
23
  firePointer(el, 'pointerdown', from);
23
- asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', { bubbles: true, cancelable: true })));
24
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mousedown', init)));
24
25
  if (el.tabIndex >= 0 && 'function' === typeof el.focus)
25
26
  el.focus();
26
27
  let heldMs = 0;
@@ -30,8 +31,8 @@ export async function fireClickSequence(el, hold) {
30
31
  heldMs = hold.now() - startedAt;
31
32
  }
32
33
  firePointer(el, 'pointerup', from);
33
- asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', { bubbles: true, cancelable: true })));
34
- const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', { bubbles: true, cancelable: true })));
34
+ asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'mouseup', init)));
35
+ const notPrevented = asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'click', init)));
35
36
  return { prevented: !notPrevented, heldMs };
36
37
  }
37
38
  function isMeasurable(rect) {
@@ -34,7 +34,6 @@ interface ActionResult {
34
34
  };
35
35
  warning?: string;
36
36
  }
37
- export declare const MAX_HOLD_MS = 30000;
38
37
  export declare const ZOOM_NEEDS_REAL_BROWSER_MSG: string;
39
38
  export declare function executeAction(ref: string, action: string, args?: Record<string, unknown>): Promise<ActionResult>;
40
39
  export declare function dispatchWebMcp(tool: string, params: Record<string, unknown>, confirmDangerous?: boolean): Promise<unknown>;
@@ -141,7 +141,7 @@ function activeRef(el) {
141
141
  return null;
142
142
  return refs.refFor(active);
143
143
  }
144
- export const MAX_HOLD_MS = 30_000;
144
+ const MAX_HOLD_MS = 30_000;
145
145
  function clampHold(raw) {
146
146
  if ('number' !== typeof raw || !Number.isFinite(raw) || raw <= 0)
147
147
  return 0;
@@ -167,7 +167,9 @@ function asFiniteNumber(raw) {
167
167
  async function dispatchOther(el, action, args) {
168
168
  switch (action) {
169
169
  case ActionType.DBLCLICK:
170
- return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true })));
170
+ await fireClickSequence(el, undefined, 1);
171
+ await fireClickSequence(el, undefined, 2);
172
+ return !asSyntheticInput(() => el.dispatchEvent(mouseEventFor(el, 'dblclick', { bubbles: true, cancelable: true, detail: 2 })));
171
173
  case ActionType.HOVER: {
172
174
  const doc = el.ownerDocument;
173
175
  const from = doc.activeElement ?? doc.body;
@@ -13,12 +13,21 @@ export function setNativeValue(el, value) {
13
13
  el.dispatchEvent(new Event('change', { bubbles: true }));
14
14
  return !notPrevented;
15
15
  }
16
- export function assertNotRichText(el, action) {
16
+ function isRichText(el) {
17
17
  const flag = el.getAttribute('contenteditable');
18
- if (el.isContentEditable || (flag !== null && flag !== 'false')) {
19
- throw new Error(`cannot ${action} a contenteditable element — rich-text editors keep their own document model, ` +
20
- 'so writing to the DOM here would look right and submit the old content. This surface is not supported yet.');
21
- }
18
+ if (el.isContentEditable || (null !== flag && 'false' !== flag))
19
+ return true;
20
+ if ('textbox' === el.getAttribute('role'))
21
+ return true;
22
+ if (!('editContext' in el))
23
+ return false;
24
+ const surface = el;
25
+ return null !== surface.editContext && undefined !== surface.editContext;
26
+ }
27
+ export function assertNotRichText(el, action) {
28
+ if (!isRichText(el))
29
+ return;
30
+ throw new Error(`cannot ${action} a contenteditable or EditContext editor; use press or an input`);
22
31
  }
23
32
  export function assertEditable(el, action) {
24
33
  if (el.disabled) {
@@ -8,6 +8,7 @@ export interface ReticleConnectOptions {
8
8
  allowInProduction?: boolean;
9
9
  overlay?: boolean;
10
10
  captureNetworkBodies?: boolean;
11
+ networkBodyMaxChars?: number;
11
12
  sourceMapping?: boolean;
12
13
  captureErrorBodies?: boolean;
13
14
  exposePresenter?: boolean;
@@ -1,4 +1,4 @@
1
- export interface SourceLocation {
1
+ interface SourceLocation {
2
2
  file: string;
3
3
  line: number;
4
4
  }
@@ -7,3 +7,4 @@ export declare function sourceFromDom(el: Element): SourceLocation | undefined;
7
7
  export declare function sourceFor(el: Element, adapterSource: SourceLocation | undefined): SourceLocation | undefined;
8
8
  export declare function documentHasSourceStamps(doc: Document): boolean;
9
9
  export declare function formatSource(source: SourceLocation | undefined): string | undefined;
10
+ export {};
@@ -5,3 +5,4 @@ export declare function setIgnoreSelectors(selectors: string[]): void;
5
5
  export declare function isReticleOverlay(el: Element): boolean;
6
6
  export declare function isReticleUi(node: Element | null): boolean;
7
7
  export declare function isIgnored(el: Element): boolean;
8
+ export declare function appModalOpen(doc: Document): boolean;
@@ -22,3 +22,16 @@ export function isIgnored(el) {
22
22
  const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
23
23
  return el.closest(sel) !== null;
24
24
  }
25
+ const APP_MODAL = 'dialog[open], [aria-modal="true"]';
26
+ export function appModalOpen(doc) {
27
+ for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
28
+ if (el.closest(RETICLE_OVERLAY) !== null)
29
+ continue;
30
+ if (el instanceof HTMLElement && (el.hidden || null !== el.closest('[hidden]')))
31
+ continue;
32
+ if (el instanceof HTMLElement && 'none' === getComputedStyle(el).display)
33
+ continue;
34
+ return true;
35
+ }
36
+ return false;
37
+ }
package/dist/dom/query.js CHANGED
@@ -409,15 +409,16 @@ function buildEmptyHint(query) {
409
409
  const container = resolveContainer(query.scope).container ?? document.body;
410
410
  const all = container.querySelectorAll(`[${TESTID_ATTR}]`);
411
411
  const present = [];
412
+ const seen = new Set();
412
413
  for (const el of Array.from(all)) {
413
414
  if (isIgnored(el))
414
415
  continue;
415
416
  const id = el.getAttribute(TESTID_ATTR);
416
- if (id !== null && id.length > 0 && !present.includes(id)) {
417
+ if (null === id || 0 === id.length || seen.has(id))
418
+ continue;
419
+ seen.add(id);
420
+ if (present.length < MAX_PRESENT_TESTIDS)
417
421
  present.push(id);
418
- if (present.length >= MAX_PRESENT_TESTIDS)
419
- break;
420
- }
421
422
  }
422
423
  const registered = declaredTestids();
423
424
  const knownEmptyState = present.some((id) => registered.includes(id));
@@ -425,6 +426,7 @@ function buildEmptyHint(query) {
425
426
  const hint = {
426
427
  route,
427
428
  presentTestids: present,
429
+ ...(seen.size > present.length ? { presentTestidsTotal: seen.size } : {}),
428
430
  presentRegions: buildPresentRegions(query),
429
431
  knownEmptyState,
430
432
  };
@@ -5,7 +5,7 @@ interface SnapshotStatus {
5
5
  visibleDialogs?: string[];
6
6
  overlayHidingPage?: string;
7
7
  }
8
- export interface SnapshotResult {
8
+ interface SnapshotResult {
9
9
  tree: string;
10
10
  status: SnapshotStatus;
11
11
  nodes: number;
@@ -2,6 +2,9 @@ import { EventType, TRANSPORT_LIMITS } from '@reticlehq/core';
2
2
  import { observeSafely } from './types.js';
3
3
  import { safeStringify } from '../security/serialization.js';
4
4
  import { requireCapturedMethod } from '../util/captured-method.js';
5
+ import { createRepeatLimiter } from './error-repeats.js';
6
+ const CSP_VIOLATION_EVENT = 'securitypolicyviolation';
7
+ const REPORT_ONLY = 'report';
5
8
  const METHOD_EVENT = {
6
9
  log: EventType.CONSOLE_LOG,
7
10
  warn: EventType.CONSOLE_WARN,
@@ -54,6 +57,16 @@ export function installConsole(emit) {
54
57
  patched.set(method, wrapper);
55
58
  console[method] = wrapper;
56
59
  }
60
+ const repeats = createRepeatLimiter();
61
+ const emitError = (payload) => {
62
+ const decision = repeats.admit(payload);
63
+ if (!decision.emit)
64
+ return;
65
+ emit(EventType.ERROR_UNCAUGHT, {
66
+ ...payload,
67
+ ...(undefined === decision.repeats ? {} : { repeats: decision.repeats }),
68
+ });
69
+ };
57
70
  const onError = (event) => {
58
71
  if (!(event instanceof ErrorEvent)) {
59
72
  const target = event.target;
@@ -62,43 +75,55 @@ export function installConsole(emit) {
62
75
  const tag = target.tagName.toLowerCase();
63
76
  const url = target.getAttribute('src') ?? target.getAttribute('href') ?? '';
64
77
  observeSafely(() => {
65
- emit(EventType.ERROR_UNCAUGHT, {
66
- message: `<${tag}> failed to load${0 === url.length ? '' : `: ${url}`}`,
67
- kind: 'resource',
68
- ...(0 === url.length ? {} : { source: url }),
69
- });
78
+ const message = `<${tag}> failed to load${0 === url.length ? '' : `: ${url}`}`;
79
+ emitError({ message, kind: 'resource', ...(0 === url.length ? {} : { source: url }) });
70
80
  });
71
81
  return;
72
82
  }
73
83
  observeSafely(() => {
74
84
  const stack = capStack(event.error instanceof Error ? event.error.stack : undefined);
75
- emit(EventType.ERROR_UNCAUGHT, {
85
+ const payload = {
76
86
  message: event.message,
77
87
  source: event.filename,
78
88
  line: event.lineno,
79
89
  ...(stack === undefined ? {} : { stack }),
80
- });
90
+ };
91
+ emitError(payload);
81
92
  });
82
93
  };
83
94
  const onRejection = (event) => {
84
95
  observeSafely(() => {
85
96
  const reason = event.reason;
86
97
  const stack = capStack(reason instanceof Error ? reason.stack : undefined);
87
- emit(EventType.ERROR_UNCAUGHT, {
98
+ const payload = {
88
99
  message: reason instanceof Error ? reason.message : String(reason),
89
100
  kind: 'unhandledrejection',
90
101
  ...(stack === undefined ? {} : { stack }),
91
- });
102
+ };
103
+ emitError(payload);
104
+ });
105
+ };
106
+ const onViolation = (event) => {
107
+ const what = 0 === event.blockedURI.length ? 'inline content' : event.blockedURI;
108
+ const reportOnly = REPORT_ONLY === event.disposition;
109
+ emit(reportOnly ? EventType.CONSOLE_WARN : EventType.CONSOLE_ERROR, {
110
+ message: reportOnly
111
+ ? `[Report Only] Content Security Policy directive: ${event.violatedDirective} would block ${what}`
112
+ : `Content Security Policy directive: ${event.violatedDirective} blocked ${what}`,
113
+ kind: CSP_VIOLATION_EVENT,
114
+ ...(0 === event.blockedURI.length ? {} : { source: event.blockedURI }),
92
115
  });
93
116
  };
94
- window.addEventListener('error', onError, true);
95
- window.addEventListener('unhandledrejection', onRejection);
117
+ const listeners = new AbortController();
118
+ const { signal } = listeners;
119
+ window.addEventListener('error', onError, { capture: true, signal });
120
+ window.addEventListener('unhandledrejection', onRejection, { signal });
121
+ window.addEventListener(CSP_VIOLATION_EVENT, onViolation, { capture: true, signal });
96
122
  return () => {
97
123
  for (const [method, original] of originals) {
98
124
  if (console[method] === patched.get(method))
99
125
  console[method] = original;
100
126
  }
101
- window.removeEventListener('error', onError, true);
102
- window.removeEventListener('unhandledrejection', onRejection);
127
+ listeners.abort();
103
128
  };
104
129
  }
@@ -153,8 +153,10 @@ export function installDom(emit) {
153
153
  dropped += 1;
154
154
  continue;
155
155
  }
156
- if (isReticleOverlay(node))
156
+ if (isReticleOverlay(node) ||
157
+ (isElement(record.target) && isReticleOverlay(record.target))) {
157
158
  continue;
159
+ }
158
160
  const gone = describeMount(node);
159
161
  if (gone === undefined)
160
162
  continue;
@@ -7,6 +7,7 @@ const PREVIEW_CHARS = 2000;
7
7
  export function installDownload(emit, opts = {}) {
8
8
  if (typeof URL.createObjectURL !== 'function')
9
9
  return () => undefined;
10
+ const ac = new AbortController();
10
11
  const objectUrl = URL.createObjectURL.bind(URL);
11
12
  const known = new Map();
12
13
  const originalCreate = captureMethod(URL, 'createObjectURL');
@@ -58,7 +59,7 @@ export function installDownload(emit, opts = {}) {
58
59
  report(anchor.getAttribute('href') ?? '', anchor.getAttribute('download') ?? undefined);
59
60
  });
60
61
  };
61
- document.addEventListener('click', onClick, true);
62
+ document.addEventListener('click', onClick, { capture: true, signal: ac.signal });
62
63
  const originalClick = captureMethod(HTMLAnchorElement.prototype, 'click');
63
64
  const patchedClick = function reticleObservedAnchorClick() {
64
65
  const pending = observeValue(() => this.hasAttribute('download')
@@ -75,7 +76,7 @@ export function installDownload(emit, opts = {}) {
75
76
  };
76
77
  HTMLAnchorElement.prototype.click = patchedClick;
77
78
  return () => {
78
- document.removeEventListener('click', onClick, true);
79
+ ac.abort();
79
80
  if (URL.createObjectURL === patchedCreate)
80
81
  URL.createObjectURL = originalCreate;
81
82
  if (HTMLAnchorElement.prototype.click === patchedClick) {
@@ -0,0 +1,18 @@
1
+ export declare const REPEAT_BURST = 3;
2
+ export declare const MAX_TRACKED_FINGERPRINTS = 256;
3
+ export interface RepeatDecision {
4
+ readonly emit: boolean;
5
+ readonly repeats?: number;
6
+ }
7
+ export interface ErrorIdentity {
8
+ readonly message: string;
9
+ readonly source?: string;
10
+ readonly line?: number;
11
+ readonly kind?: string;
12
+ readonly stack?: string;
13
+ }
14
+ export declare function fingerprintError(identity: ErrorIdentity): string;
15
+ export interface RepeatLimiter {
16
+ admit(identity: ErrorIdentity): RepeatDecision;
17
+ }
18
+ export declare function createRepeatLimiter(burst?: number, maxTracked?: number): RepeatLimiter;
@@ -0,0 +1,41 @@
1
+ import { fnv1a } from '@reticlehq/core';
2
+ export const REPEAT_BURST = 3;
3
+ export const MAX_TRACKED_FINGERPRINTS = 256;
4
+ const PASS = { emit: true };
5
+ const DROP = { emit: false };
6
+ function isCheckpoint(count) {
7
+ if (count < 10)
8
+ return false;
9
+ let n = count;
10
+ while (0 === n % 10)
11
+ n /= 10;
12
+ return 1 === n;
13
+ }
14
+ export function fingerprintError(identity) {
15
+ return fnv1a([
16
+ identity.message,
17
+ identity.source ?? '',
18
+ undefined === identity.line ? '' : String(identity.line),
19
+ identity.kind ?? '',
20
+ identity.stack ?? '',
21
+ ].join('\x00'));
22
+ }
23
+ export function createRepeatLimiter(burst = REPEAT_BURST, maxTracked = MAX_TRACKED_FINGERPRINTS) {
24
+ const counts = new Map();
25
+ return {
26
+ admit(identity) {
27
+ const key = fingerprintError(identity);
28
+ const seen = (counts.get(key) ?? 0) + 1;
29
+ counts.delete(key);
30
+ if (counts.size >= maxTracked) {
31
+ const oldest = counts.keys().next();
32
+ if (!(oldest.done ?? false))
33
+ counts.delete(oldest.value);
34
+ }
35
+ counts.set(key, seen);
36
+ if (seen <= burst)
37
+ return PASS;
38
+ return isCheckpoint(seen) ? { emit: true, repeats: seen } : DROP;
39
+ },
40
+ };
41
+ }
@@ -0,0 +1,2 @@
1
+ import type { Emit, Teardown } from './types.js';
2
+ export declare function installField(emit: Emit): Teardown;
@@ -0,0 +1,86 @@
1
+ import { EventType, isSensitiveKey } from '@reticlehq/core';
2
+ import { getAccessibleName } from '../dom/a11y.js';
3
+ const MAX_VALUE_LEN = 160;
4
+ const BURST_MS = 150;
5
+ const PAYMENT_AUTOCOMPLETE = /^(cc-|current-password|new-password)/i;
6
+ function isField(target) {
7
+ return (target instanceof HTMLInputElement ||
8
+ target instanceof HTMLTextAreaElement ||
9
+ target instanceof HTMLSelectElement);
10
+ }
11
+ function fieldName(element) {
12
+ const testid = element.getAttribute('data-testid');
13
+ if (null !== testid && testid.length > 0)
14
+ return testid;
15
+ if (element.name.length > 0)
16
+ return element.name;
17
+ const accessible = getAccessibleName(element);
18
+ if (accessible.length > 0)
19
+ return accessible;
20
+ const id = element.getAttribute('id');
21
+ return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
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
+ export function installField(emit) {
40
+ const ac = new AbortController();
41
+ const { signal } = ac;
42
+ const pending = new Map();
43
+ const report = (element, kind) => {
44
+ const value = element.value;
45
+ const secret = isSecret(element);
46
+ emit(EventType.FIELD_CHANGE, {
47
+ field: fieldName(element),
48
+ kind,
49
+ length: value.length,
50
+ ...(secret
51
+ ? { redacted: true }
52
+ : { value: value.length > MAX_VALUE_LEN ? `${value.slice(0, MAX_VALUE_LEN)}…` : value }),
53
+ });
54
+ };
55
+ const clear = (element) => {
56
+ const timer = pending.get(element);
57
+ if (timer !== undefined)
58
+ clearTimeout(timer);
59
+ pending.delete(element);
60
+ };
61
+ const onInput = (event) => {
62
+ const element = event.target;
63
+ if (!isField(element))
64
+ return;
65
+ clear(element);
66
+ pending.set(element, setTimeout(() => {
67
+ pending.delete(element);
68
+ report(element, 'input');
69
+ }, BURST_MS));
70
+ };
71
+ const onChange = (event) => {
72
+ const element = event.target;
73
+ if (!isField(element))
74
+ return;
75
+ clear(element);
76
+ report(element, 'change');
77
+ };
78
+ document.addEventListener('input', onInput, { signal, capture: true });
79
+ document.addEventListener('change', onChange, { signal, capture: true });
80
+ return () => {
81
+ for (const timer of pending.values())
82
+ clearTimeout(timer);
83
+ pending.clear();
84
+ ac.abort();
85
+ };
86
+ }
@@ -24,6 +24,7 @@ export function sameOriginFrameBodies(root) {
24
24
  return found;
25
25
  }
26
26
  export function observeSameOriginFrames(attach) {
27
+ const ac = new AbortController();
27
28
  const attachAll = () => {
28
29
  for (const body of sameOriginFrameBodies(document))
29
30
  attach(body);
@@ -33,8 +34,6 @@ export function observeSameOriginFrames(attach) {
33
34
  if (event.target instanceof HTMLIFrameElement)
34
35
  attachAll();
35
36
  };
36
- document.addEventListener('load', onLoad, true);
37
- return () => {
38
- document.removeEventListener('load', onLoad, true);
39
- };
37
+ document.addEventListener('load', onLoad, { capture: true, signal: ac.signal });
38
+ return () => ac.abort();
40
39
  }
@@ -2,6 +2,7 @@ import { installDom } from './dom.js';
2
2
  import { installStorage } from './storage.js';
3
3
  import { installStoreState } from './state.js';
4
4
  import { installFocus } from './focus.js';
5
+ import { installField } from './field.js';
5
6
  import { installBlindSpots } from './blind-spots.js';
6
7
  import { installDownload } from './download.js';
7
8
  import { installContextOpen } from './context-open.js';
@@ -59,6 +60,7 @@ export function installAllObservers(emit, options) {
59
60
  guard(emit, SdkSite.STORE_ADAPTER, () => installStorage(emit)),
60
61
  guard(emit, SdkSite.STORE_ADAPTER, () => installStoreState(emit)),
61
62
  guard(emit, SdkSite.DOM_OBSERVER, () => installFocus(emit)),
63
+ guard(emit, SdkSite.DOM_OBSERVER, () => installField(emit)),
62
64
  guard(emit, SdkSite.NETWORK_OBSERVER, () => installDownload(emit, { capturePreview: options.captureBodies })),
63
65
  guard(emit, SdkSite.DOM_OBSERVER, () => installBlindSpots(emit)),
64
66
  guard(emit, SdkSite.HEALTH_OBSERVER, () => installHealth(emit)),
@@ -1,6 +1,8 @@
1
1
  export declare function isStreamingBody(contentType: string | null, contentLength: string | null): boolean;
2
2
  export declare function withBodyDeadline(read: Promise<string>): Promise<string | undefined>;
3
3
  export declare function isCapturableType(contentType: string | null): boolean;
4
+ export declare function setNetworkBodyMaxChars(chars: number): void;
5
+ export declare function networkBodyMaxChars(): number;
4
6
  export declare function shouldCaptureErrorBody(status: number, enabled: boolean): boolean;
5
7
  export declare function projectErrorBody(rawText: string, contentType: string | null): {
6
8
  body: string;
@@ -32,8 +32,23 @@ export function isCapturableType(contentType) {
32
32
  return false;
33
33
  return CAPTURABLE_CONTENT.test(contentType);
34
34
  }
35
- const MAX_BODY_CHARS = 8192;
36
- const MAX_BODY_SCAN_CHARS = MAX_BODY_CHARS * 2;
35
+ const DEFAULT_BODY_CHARS = 8192;
36
+ const MAX_CONFIGURABLE_BODY_CHARS = 262144;
37
+ const MIN_CONFIGURABLE_BODY_CHARS = 256;
38
+ let bodyMaxChars = DEFAULT_BODY_CHARS;
39
+ export function setNetworkBodyMaxChars(chars) {
40
+ if (!Number.isFinite(chars))
41
+ return;
42
+ bodyMaxChars = Math.min(MAX_CONFIGURABLE_BODY_CHARS, Math.max(MIN_CONFIGURABLE_BODY_CHARS, Math.floor(chars)));
43
+ }
44
+ export function networkBodyMaxChars() {
45
+ return bodyMaxChars;
46
+ }
47
+ const REDACT_SCAN_CEILING = DEFAULT_BODY_CHARS * 2;
48
+ function scanWidth(redacting) {
49
+ const generous = bodyMaxChars * 2;
50
+ return redacting ? Math.min(generous, REDACT_SCAN_CEILING) : generous;
51
+ }
37
52
  const AUTH_SCHEME_TOKEN = /\b(Bearer|Basic)\s+(?=[A-Za-z0-9._~+/=-]{16,})(?=[A-Za-z0-9._~+/=-]*[\d._~+/=-])[A-Za-z0-9._~+/=-]+/gi;
38
53
  function redactText(text) {
39
54
  return (text
@@ -51,10 +66,12 @@ export function projectErrorBody(rawText, contentType) {
51
66
  return { body: projected.body.slice(0, ERROR_BODY_CHARS), truncated: true };
52
67
  }
53
68
  export function projectBody(rawText, contentType) {
54
- const oversized = rawText.length > MAX_BODY_SCAN_CHARS;
55
- const text = oversized ? rawText.slice(0, MAX_BODY_SCAN_CHARS) : rawText;
69
+ const isJson = contentType !== null && /json|graphql/i.test(contentType);
70
+ const scanChars = scanWidth(!isJson);
71
+ const oversized = rawText.length > scanChars;
72
+ const text = oversized ? rawText.slice(0, scanChars) : rawText;
56
73
  let out;
57
- if (contentType !== null && /json|graphql/i.test(contentType)) {
74
+ if (isJson) {
58
75
  try {
59
76
  out = safeStringify(JSON.parse(text));
60
77
  }
@@ -66,6 +83,6 @@ export function projectBody(rawText, contentType) {
66
83
  out = redactText(text);
67
84
  }
68
85
  out = scrubKnownSecrets(out);
69
- const truncated = oversized || out.length > MAX_BODY_CHARS;
70
- return { body: out.length > MAX_BODY_CHARS ? out.slice(0, MAX_BODY_CHARS) : out, truncated };
86
+ const truncated = oversized || out.length > bodyMaxChars;
87
+ return { body: out.length > bodyMaxChars ? out.slice(0, bodyMaxChars) : out, truncated };
71
88
  }
@@ -10,6 +10,7 @@ function snapshotLocation() {
10
10
  };
11
11
  }
12
12
  export function installRoute(emit) {
13
+ const ac = new AbortController();
13
14
  const origPush = captureMethod(history, 'pushState');
14
15
  const origReplace = captureMethod(history, 'replaceState');
15
16
  const callPush = origPush.bind(history);
@@ -45,14 +46,13 @@ export function installRoute(emit) {
45
46
  const onNav = () => {
46
47
  fire(lastHref);
47
48
  };
48
- window.addEventListener('popstate', onNav);
49
- window.addEventListener('hashchange', onNav);
49
+ window.addEventListener('popstate', onNav, { signal: ac.signal });
50
+ window.addEventListener('hashchange', onNav, { signal: ac.signal });
50
51
  return () => {
51
52
  if (history.pushState === patchedPush)
52
53
  history.pushState = origPush;
53
54
  if (history.replaceState === patchedReplace)
54
55
  history.replaceState = origReplace;
55
- window.removeEventListener('popstate', onNav);
56
- window.removeEventListener('hashchange', onNav);
56
+ ac.abort();
57
57
  };
58
58
  }
@@ -19,6 +19,16 @@ export function diffState(prev, next) {
19
19
  function project(path, value) {
20
20
  return isSensitiveKey(path) ? REDACTED_VALUE : sanitizeForTransport(value);
21
21
  }
22
+ function samePresentedValue(oldValue, newValue) {
23
+ if (Object.is(oldValue, newValue))
24
+ return true;
25
+ try {
26
+ return JSON.stringify(oldValue) === JSON.stringify(newValue);
27
+ }
28
+ catch {
29
+ return false;
30
+ }
31
+ }
22
32
  function safeRead(getter) {
23
33
  try {
24
34
  return getter();
@@ -39,15 +49,15 @@ export function installStoreState(emit) {
39
49
  active.set(name, subscribe(() => {
40
50
  observeSafely(() => {
41
51
  const next = safeRead(getter);
42
- for (const change of diffState(last, next)) {
43
- emit(EventType.STATE_CHANGE, {
44
- name,
45
- path: change.path,
46
- value: project(change.path, change.new),
47
- old: project(change.path, change.old),
48
- });
49
- }
52
+ const changes = diffState(last, next);
50
53
  last = next;
54
+ for (const change of changes) {
55
+ const value = project(change.path, change.new);
56
+ const old = project(change.path, change.old);
57
+ if (!isSensitiveKey(change.path) && samePresentedValue(old, value))
58
+ continue;
59
+ observeSafely(() => emit(EventType.STATE_CHANGE, { name, path: change.path, value, old }));
60
+ }
51
61
  });
52
62
  }));
53
63
  };
@@ -0,0 +1,13 @@
1
+ export declare const CAROUSEL_ATTR = "data-reticle-carousel";
2
+ export declare const CAROUSEL_SLIDE_ATTR = "data-reticle-carousel-slide";
3
+ export declare const CAROUSEL_DOT_ATTR = "data-reticle-carousel-dot";
4
+ export declare const CAROUSEL_CLOSE_ATTR = "data-reticle-carousel-close";
5
+ export declare const CAROUSEL_DISMISSED_KEY = "reticle.carousel.dismissed";
6
+ export declare const ROTATE_MS = 6000;
7
+ export interface Slide {
8
+ id: string;
9
+ html: string;
10
+ }
11
+ export declare function carouselHtml(slides: readonly Slide[], active: number): string;
12
+ 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";