@reticlehq/browser 3.6.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 (83) 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 +12 -0
  5. package/dist/actions/actions-press.d.ts +1 -0
  6. package/dist/actions/actions-press.js +25 -1
  7. package/dist/actions/actions.d.ts +1 -37
  8. package/dist/actions/actions.js +24 -66
  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 +28 -2
  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/dom/a11y.js +251 -10
  16. package/dist/dom/addressing/coverage-markers.d.ts +6 -0
  17. package/dist/dom/addressing/coverage-markers.js +13 -0
  18. package/dist/dom/dom-ignore.d.ts +2 -0
  19. package/dist/dom/dom-ignore.js +15 -2
  20. package/dist/dom/query.d.ts +1 -1
  21. package/dist/dom/query.js +78 -16
  22. package/dist/dom/render-page.d.ts +12 -0
  23. package/dist/dom/render-page.js +135 -0
  24. package/dist/dom/snapshot.js +55 -7
  25. package/dist/observers/dom.js +32 -16
  26. package/dist/observers/field.js +3 -21
  27. package/dist/observers/network.js +56 -7
  28. package/dist/observers/perf.js +53 -1
  29. package/dist/observers/route.js +6 -5
  30. package/dist/presenter/carousel/carousel.js +2 -7
  31. package/dist/presenter/carousel/panel-slides.d.ts +2 -4
  32. package/dist/presenter/carousel/panel-slides.js +17 -15
  33. package/dist/presenter/chrome/log-memory.d.ts +1 -0
  34. package/dist/presenter/chrome/presenter-log.d.ts +7 -0
  35. package/dist/presenter/chrome/presenter-log.js +48 -8
  36. package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
  37. package/dist/presenter/chrome/presenter-morph.js +34 -0
  38. package/dist/presenter/chrome/presenter-report-copy.d.ts +17 -4
  39. package/dist/presenter/chrome/presenter-report-copy.js +26 -4
  40. package/dist/presenter/hud-telemetry.js +55 -4
  41. package/dist/presenter/presenter-account-styles.js +3 -3
  42. package/dist/presenter/presenter-account.d.ts +27 -5
  43. package/dist/presenter/presenter-account.js +83 -6
  44. package/dist/presenter/presenter-agent-link.d.ts +9 -0
  45. package/dist/presenter/presenter-agent-link.js +62 -0
  46. package/dist/presenter/presenter-chat-views.d.ts +27 -5
  47. package/dist/presenter/presenter-chat-views.js +312 -72
  48. package/dist/presenter/presenter-config.d.ts +2 -0
  49. package/dist/presenter/presenter-config.js +1 -0
  50. package/dist/presenter/presenter-controls.d.ts +0 -1
  51. package/dist/presenter/presenter-controls.js +13 -91
  52. package/dist/presenter/presenter-design-tokens.js +1 -1
  53. package/dist/presenter/presenter-frame.d.ts +1 -1
  54. package/dist/presenter/presenter-frame.js +8 -10
  55. package/dist/presenter/presenter-harness-row.d.ts +67 -0
  56. package/dist/presenter/presenter-harness-row.js +209 -0
  57. package/dist/presenter/presenter-personas.d.ts +35 -0
  58. package/dist/presenter/presenter-personas.js +95 -0
  59. package/dist/presenter/presenter-plan.js +25 -24
  60. package/dist/presenter/presenter-report-styles.js +40 -22
  61. package/dist/presenter/presenter-report.d.ts +2 -1
  62. package/dist/presenter/presenter-report.js +83 -17
  63. package/dist/presenter/presenter-settings-styles.js +8 -4
  64. package/dist/presenter/presenter-settings.d.ts +7 -6
  65. package/dist/presenter/presenter-settings.js +75 -34
  66. package/dist/presenter/presenter-shell-styles.js +12 -3
  67. package/dist/presenter/presenter-shell.d.ts +10 -4
  68. package/dist/presenter/presenter-shell.js +31 -23
  69. package/dist/presenter/presenter-styles.js +0 -2
  70. package/dist/presenter/presenter-workspace.js +10 -3
  71. package/dist/presenter/presenter.d.ts +3 -2
  72. package/dist/presenter/presenter.js +81 -20
  73. package/dist/registry/declared-platform.js +2 -0
  74. package/dist/reticle-inject.js +777 -700
  75. package/dist/reticle.js +36 -2
  76. package/dist/storage-keys.d.ts +3 -2
  77. package/dist/storage-keys.js +7 -3
  78. package/dist/transport/transport.js +5 -3
  79. package/dist/transport/unreachable-message.d.ts +1 -1
  80. package/dist/transport/unreachable-message.js +3 -5
  81. package/package.json +2 -2
  82. package/dist/presenter/carousel/offer-card.d.ts +0 -21
  83. package/dist/presenter/carousel/offer-card.js +0 -56
@@ -0,0 +1,135 @@
1
+ import { isSensitiveKey, STEP_SCREENSHOT } from '@reticlehq/core';
2
+ import { RETICLE_OVERLAY } from './dom-ignore.js';
3
+ export const MASK = '••••••••';
4
+ const BUDGET_CHARS = 240_000;
5
+ const CHUNK_CHARS = 60_000;
6
+ const SHRINK = 0.75;
7
+ const MAX_ATTEMPTS = 4;
8
+ const LOAD_TIMEOUT_MS = 5_000;
9
+ const SVG_NS = 'http://www.w3.org/2000/svg';
10
+ const FALLBACK_BACKGROUND = '#ffffff';
11
+ const JPEG_MIME = 'image/jpeg';
12
+ const DATA_URL_PREFIX = /^data:[^,]*,/;
13
+ function isSecretField(el) {
14
+ if (el instanceof HTMLInputElement && 'password' === el.type)
15
+ return true;
16
+ return [el.name, el.id, el.getAttribute('autocomplete') ?? ''].some((key) => key.length > 0 && isSensitiveKey(key));
17
+ }
18
+ function carryState(from, to) {
19
+ if (from instanceof HTMLInputElement || from instanceof HTMLTextAreaElement) {
20
+ const value = isSecretField(from) && from.value.length > 0 ? MASK : from.value;
21
+ if (from instanceof HTMLTextAreaElement)
22
+ to.textContent = value;
23
+ else
24
+ to.setAttribute('value', value);
25
+ }
26
+ if (from instanceof HTMLInputElement) {
27
+ if (from.checked)
28
+ to.setAttribute('checked', '');
29
+ else
30
+ to.removeAttribute('checked');
31
+ }
32
+ if (from instanceof HTMLOptionElement) {
33
+ if (from.selected)
34
+ to.setAttribute('selected', '');
35
+ else
36
+ to.removeAttribute('selected');
37
+ }
38
+ }
39
+ function readableCss(doc) {
40
+ const sheets = [...Array.from(doc.styleSheets), ...(doc.adoptedStyleSheets ?? [])];
41
+ const parts = [];
42
+ for (const sheet of sheets) {
43
+ try {
44
+ for (const rule of Array.from(sheet.cssRules))
45
+ parts.push(rule.cssText);
46
+ }
47
+ catch {
48
+ }
49
+ }
50
+ return parts.join('\n');
51
+ }
52
+ export function cloneForRender(doc) {
53
+ const root = doc.documentElement;
54
+ const clone = root.cloneNode(true);
55
+ const live = root.querySelectorAll('*');
56
+ const copied = clone.querySelectorAll('*');
57
+ live.forEach((el, i) => {
58
+ const twin = copied[i];
59
+ if (twin !== undefined)
60
+ carryState(el, twin);
61
+ });
62
+ const dropped = [RETICLE_OVERLAY, 'script', 'noscript', 'link[rel~="stylesheet"]', 'style'];
63
+ clone.querySelectorAll(dropped.join(',')).forEach((el) => el.remove());
64
+ const style = doc.createElement('style');
65
+ style.textContent = readableCss(doc);
66
+ (clone.querySelector('head') ?? clone).append(style);
67
+ return clone;
68
+ }
69
+ function backgroundOf(doc) {
70
+ const view = doc.defaultView;
71
+ for (const el of [doc.body, doc.documentElement]) {
72
+ if (null === view || null === el)
73
+ continue;
74
+ const colour = view.getComputedStyle(el).backgroundColor;
75
+ if (colour.length > 0 && 'transparent' !== colour && 'rgba(0, 0, 0, 0)' !== colour)
76
+ return colour;
77
+ }
78
+ return FALLBACK_BACKGROUND;
79
+ }
80
+ function loadImage(url) {
81
+ return new Promise((resolve, reject) => {
82
+ const img = new Image();
83
+ const timer = setTimeout(() => reject(new Error('the page did not render in time')), LOAD_TIMEOUT_MS);
84
+ img.onload = () => {
85
+ clearTimeout(timer);
86
+ resolve(img);
87
+ };
88
+ img.onerror = () => {
89
+ clearTimeout(timer);
90
+ reject(new Error('the page could not be drawn as an image'));
91
+ };
92
+ img.src = url;
93
+ });
94
+ }
95
+ function chunked(base64) {
96
+ const out = [];
97
+ for (let i = 0; i < base64.length; i += CHUNK_CHARS)
98
+ out.push(base64.slice(i, i + CHUNK_CHARS));
99
+ return out;
100
+ }
101
+ export async function renderPage() {
102
+ try {
103
+ const width = Math.max(1, window.innerWidth);
104
+ const height = Math.max(1, window.innerHeight);
105
+ const clone = cloneForRender(document);
106
+ clone.style.transform = `translate(${String(-window.scrollX)}px, ${String(-window.scrollY)}px)`;
107
+ const xhtml = new XMLSerializer().serializeToString(clone);
108
+ const svg = `<svg xmlns="${SVG_NS}" width="${String(width)}" height="${String(height)}">` +
109
+ `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject></svg>`;
110
+ const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
111
+ const background = backgroundOf(document);
112
+ let scale = Math.min(1, STEP_SCREENSHOT.MAX_SIDE_PX / Math.max(width, height));
113
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1, scale *= SHRINK) {
114
+ const canvas = document.createElement('canvas');
115
+ canvas.width = Math.max(1, Math.round(width * scale));
116
+ canvas.height = Math.max(1, Math.round(height * scale));
117
+ const ctx = canvas.getContext('2d');
118
+ if (null === ctx)
119
+ return { ok: false, reason: 'this page has no 2D canvas to draw into' };
120
+ ctx.fillStyle = background;
121
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
122
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
123
+ const base64 = canvas
124
+ .toDataURL(JPEG_MIME, STEP_SCREENSHOT.JPEG_QUALITY)
125
+ .replace(DATA_URL_PREFIX, '');
126
+ if (base64.length <= BUDGET_CHARS) {
127
+ return { ok: true, width: canvas.width, height: canvas.height, chunks: chunked(base64) };
128
+ }
129
+ }
130
+ return { ok: false, reason: 'the rendered page was too large to send, even scaled down' };
131
+ }
132
+ catch (error) {
133
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
134
+ }
135
+ }
@@ -1,6 +1,6 @@
1
1
  import { ElementState, SnapshotMode } from '@reticlehq/core';
2
2
  import { capturedRootOf } from './shadow-registry.js';
3
- import { isFrame } from './realm.js';
3
+ import { isElement, isFrame } from './realm.js';
4
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
5
5
  import { refs } from './addressing/refs.js';
6
6
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
@@ -30,13 +30,44 @@ function announces(el, role) {
30
30
  }
31
31
  const TEXT_MAX = 80;
32
32
  function directText(el) {
33
- let out = '';
33
+ const parts = readingParts(el);
34
+ const collapsed = (parts.own ? parts.text : '').replace(/\s+/g, ' ').trim();
35
+ return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
36
+ }
37
+ function readingParts(el) {
38
+ let text = '';
39
+ let own = false;
34
40
  for (const node of el.childNodes) {
35
- if (3 === node.nodeType)
36
- out += node.textContent ?? '';
41
+ if (3 === node.nodeType) {
42
+ text += node.textContent ?? '';
43
+ own ||= 0 < (node.textContent ?? '').trim().length;
44
+ }
45
+ else if (isElement(node) && foldsIntoParent(node)) {
46
+ const inner = readingParts(node);
47
+ text += inner.text;
48
+ own ||= inner.own;
49
+ }
50
+ else if (isElement(node) && isInlineIn(node) && INTERACTIVE.has(getRole(node))) {
51
+ text += getAccessibleName(node);
52
+ }
37
53
  }
38
- const collapsed = out.replace(/\s+/g, ' ').trim();
39
- return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
54
+ return { text, own };
55
+ }
56
+ function isInlineIn(el) {
57
+ const view = el.ownerDocument.defaultView;
58
+ const parent = el.parentElement;
59
+ if (null === view || null === parent)
60
+ return false;
61
+ const around = view.getComputedStyle(parent).display;
62
+ if (around.includes('flex') || around.includes('grid'))
63
+ return false;
64
+ return 'inline' === view.getComputedStyle(el).display;
65
+ }
66
+ function foldsIntoParent(el) {
67
+ if (skipEarly(el) || !isInlineIn(el))
68
+ return false;
69
+ const role = getRole(el);
70
+ return !INTERACTIVE.has(role) && !announces(el, role) && 0 === getAccessibleName(el).length;
40
71
  }
41
72
  export const MAX_UNREAD_BRANCHES = 50;
42
73
  function hiddenByMarkup(el) {
@@ -83,6 +114,7 @@ function layoutSignature(style) {
83
114
  }
84
115
  return '';
85
116
  }
117
+ const overlayStamped = (el) => el.hasAttribute('data-aria-hidden') && 'true' === el.getAttribute('aria-hidden');
86
118
  function recordUnread(branches, ctx) {
87
119
  for (const branch of branches) {
88
120
  if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
@@ -109,7 +141,11 @@ function pierceChildren(parent) {
109
141
  return out;
110
142
  }
111
143
  function visit(child, depth, ctx, inLive) {
112
- if (skipEarly(child)) {
144
+ const stampedOnly = true === ctx.throughOverlay &&
145
+ overlayStamped(child) &&
146
+ !isIgnored(child) &&
147
+ !(child instanceof HTMLElement && child.hidden);
148
+ if (!stampedOnly && skipEarly(child)) {
113
149
  if (hiddenByMarkup(child))
114
150
  ctx.hiddenSkipped += 1;
115
151
  return;
@@ -120,6 +156,10 @@ function visit(child, depth, ctx, inLive) {
120
156
  ctx.hiddenSkipped += 1;
121
157
  return;
122
158
  }
159
+ if (style !== null && 'inline' === style.display && !inLive && foldsIntoParent(child)) {
160
+ walk(child, depth, ctx, inLive);
161
+ return;
162
+ }
123
163
  const role = getRole(child);
124
164
  const name = getAccessibleName(child);
125
165
  const interactive = INTERACTIVE.has(role);
@@ -174,6 +214,7 @@ function collectDialogs(root) {
174
214
  }
175
215
  return names;
176
216
  }
217
+ const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]';
177
218
  function overlayHidingPage(root) {
178
219
  if (root !== document.body)
179
220
  return undefined;
@@ -242,6 +283,13 @@ export function buildSnapshot(options = {}) {
242
283
  visit(root, 0, ctx, false);
243
284
  else
244
285
  walk(root, 0, ctx);
286
+ if (0 === ctx.nodes && root === document.body) {
287
+ ctx.throughOverlay = true;
288
+ for (const child of Array.from(root.children))
289
+ if (overlayStamped(child) && null !== child.querySelector(MODAL_SELECTOR))
290
+ visit(child, 0, ctx, false);
291
+ ctx.throughOverlay = false;
292
+ }
245
293
  return {
246
294
  tree: ctx.lines.join('\n'),
247
295
  status,
@@ -1,6 +1,6 @@
1
1
  import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
2
- import { EventType, TruncationChannel } from '@reticlehq/core';
3
- import { getAccessibleName, getRole, isVisible } from '../dom/a11y.js';
2
+ import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
3
+ import { getAccessibleName, getRole, isSensitiveField, isVisible } from '../dom/a11y.js';
4
4
  import { refs } from '../dom/addressing/refs.js';
5
5
  function regionKeyOf(target) {
6
6
  const el = isElement(target) ? target : null;
@@ -9,7 +9,7 @@ function regionKeyOf(target) {
9
9
  const labelled = el.closest(testIdSelector());
10
10
  return (labelled ? readTestId(labelled) : null) ?? refs.refFor(el);
11
11
  }
12
- import { isReticleOverlay } from '../dom/dom-ignore.js';
12
+ import { isUnobserved } from '../dom/dom-ignore.js';
13
13
  import { capturedRoots, installShadowRegistry, onShadowRoot } from '../dom/shadow-registry.js';
14
14
  import { isElement } from '../dom/realm.js';
15
15
  import { observeSameOriginFrames } from './frames.js';
@@ -51,7 +51,7 @@ function describeMount(node) {
51
51
  if (scanned >= MOUNT_SCAN_LIMIT)
52
52
  return undefined;
53
53
  scanned += 1;
54
- if (isReticleOverlay(child))
54
+ if (isUnobserved(child))
55
55
  continue;
56
56
  const childRole = getRole(child);
57
57
  const childName = getAccessibleName(child);
@@ -71,6 +71,18 @@ function textOf(nodes) {
71
71
  const trimmed = out.trim();
72
72
  return trimmed.length > 0 ? trimmed : undefined;
73
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
+ }
74
86
  export function installDom(emit) {
75
87
  const stopRegistry = installShadowRegistry();
76
88
  const observer = new MutationObserver((records) => {
@@ -81,14 +93,15 @@ export function installDom(emit) {
81
93
  for (const record of records) {
82
94
  if ('attributes' === record.type) {
83
95
  const target = record.target;
84
- if (isElement(target) && record.attributeName !== null && !isReticleOverlay(target)) {
96
+ if (isElement(target) && record.attributeName !== null && !isUnobserved(target)) {
85
97
  if (changed >= MAX_PER_BATCH) {
86
98
  dropped += 1;
87
99
  continue;
88
100
  }
89
101
  changed += 1;
90
- const value = capValue(target.getAttribute(record.attributeName));
91
- 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);
92
105
  emit(EventType.DOM_ATTR, {
93
106
  attr: record.attributeName,
94
107
  ...(value === undefined ? {} : { value }),
@@ -99,27 +112,31 @@ export function installDom(emit) {
99
112
  }
100
113
  if ('characterData' === record.type) {
101
114
  const parent = record.target.parentElement;
102
- if (parent !== null && !isReticleOverlay(parent)) {
115
+ if (parent !== null && !isUnobserved(parent)) {
103
116
  if (changed >= MAX_PER_BATCH) {
104
117
  dropped += 1;
105
118
  continue;
106
119
  }
107
120
  changed += 1;
108
- const text = (record.target.textContent ?? '').trim().slice(0, 80);
109
- 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);
110
126
  emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
111
127
  }
112
128
  continue;
113
129
  }
114
130
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
115
131
  const parent = isElement(record.target) ? record.target : null;
116
- if (parent !== null && !isReticleOverlay(parent)) {
132
+ if (parent !== null && !isUnobserved(parent)) {
117
133
  if (changed >= MAX_PER_BATCH)
118
134
  dropped += 1;
119
135
  else {
120
136
  changed += 1;
121
- const text = (parent.textContent ?? '').trim().slice(0, 80);
122
- 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);
123
140
  emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
124
141
  }
125
142
  }
@@ -131,7 +148,7 @@ export function installDom(emit) {
131
148
  dropped += 1;
132
149
  continue;
133
150
  }
134
- if (isReticleOverlay(node))
151
+ if (isUnobserved(node))
135
152
  continue;
136
153
  const mounted = describeMount(node);
137
154
  if (mounted === undefined)
@@ -154,8 +171,7 @@ export function installDom(emit) {
154
171
  dropped += 1;
155
172
  continue;
156
173
  }
157
- if (isReticleOverlay(node) ||
158
- (isElement(record.target) && isReticleOverlay(record.target))) {
174
+ if (isUnobserved(node) || (isElement(record.target) && isUnobserved(record.target))) {
159
175
  continue;
160
176
  }
161
177
  const gone = describeMount(node);
@@ -1,9 +1,8 @@
1
1
  import { readTestId } from '../dom/addressing/testid-attr.js';
2
- import { DEFAULT_TESTID_ATTR, EventType, isSensitiveKey } from '@reticlehq/core';
3
- import { getAccessibleName } from '../dom/a11y.js';
2
+ import { EventType } from '@reticlehq/core';
3
+ import { getAccessibleName, isSensitiveField } from '../dom/a11y.js';
4
4
  const MAX_VALUE_LEN = 160;
5
5
  const BURST_MS = 150;
6
- const PAYMENT_AUTOCOMPLETE = /^(cc-|current-password|new-password)/i;
7
6
  function isField(target) {
8
7
  return (target instanceof HTMLInputElement ||
9
8
  target instanceof HTMLTextAreaElement ||
@@ -21,30 +20,13 @@ function fieldName(element) {
21
20
  const id = element.getAttribute('id');
22
21
  return null !== id && id.length > 0 ? id : element.tagName.toLowerCase();
23
22
  }
24
- function isSecret(element) {
25
- if (element instanceof HTMLInputElement && 'password' === element.type)
26
- return true;
27
- const hint = element.getAttribute('autocomplete');
28
- if (null !== hint && PAYMENT_AUTOCOMPLETE.test(hint))
29
- return true;
30
- for (const key of [
31
- element.name,
32
- readTestId(element),
33
- element.getAttribute(DEFAULT_TESTID_ATTR),
34
- element.getAttribute('id'),
35
- ]) {
36
- if (null !== key && key.length > 0 && isSensitiveKey(key))
37
- return true;
38
- }
39
- return false;
40
- }
41
23
  export function installField(emit) {
42
24
  const ac = new AbortController();
43
25
  const { signal } = ac;
44
26
  const pending = new Map();
45
27
  const report = (element, kind) => {
46
28
  const value = element.value;
47
- const secret = isSecret(element);
29
+ const secret = isSensitiveField(element);
48
30
  emit(EventType.FIELD_CHANGE, {
49
31
  field: fieldName(element),
50
32
  kind,
@@ -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());
@@ -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 });
@@ -135,7 +135,7 @@ function startRotation(log, root, count) {
135
135
  export const CAROUSEL_CSS = `
136
136
  .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));}
137
137
  .reticle-carousel-track{height:100px;overflow:hidden;}
138
- .reticle-carousel-slide .reticle-offer,.reticle-carousel-slide .reticle-talk{margin:0;padding:0;border:0;background:none;}
138
+ .reticle-carousel-slide .reticle-talk{margin:0;padding:0;border:0;background:none;}
139
139
  .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;}
140
140
  .reticle-carousel-close:hover{opacity:1;}
141
141
  .reticle-carousel-dots{display:flex;justify-content:center;gap:6px;margin-top:6px;}
@@ -144,10 +144,5 @@ export const CAROUSEL_CSS = `
144
144
  .reticle-carousel-controls{display:flex;align-items:center;gap:8px;margin-top:6px;}
145
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
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;}
147
+ .reticle-carousel-position{margin-left:auto;color:var(--reticle-muted);font-size:11px;font-variant-numeric:tabular-nums;white-space:nowrap;}
153
148
  `;
@@ -1,8 +1,6 @@
1
- import { type OfferState } from './offer-card.js';
2
1
  import type { Slide } from './carousel.js';
3
2
  export declare const SLIDE_ID: {
4
- readonly HARNESS: "harness-journeys";
5
3
  readonly REPLAY: "replay-confidence";
6
- readonly OFFER: "harness-offer";
4
+ readonly NOTES: "notes-for-agent";
7
5
  };
8
- export declare function panelSlides(offer: OfferState | undefined, offerDeclined: boolean, notices?: readonly unknown[], base?: string): Slide[];
6
+ export declare function panelSlides(notices?: readonly unknown[], base?: string): Slide[];