@reticlehq/browser 3.7.0 → 3.8.1

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 (104) hide show
  1. package/dist/actions/action-report.d.ts +10 -0
  2. package/dist/actions/action-report.js +59 -14
  3. package/dist/actions/actions-dom.d.ts +4 -0
  4. package/dist/actions/actions-dom.js +20 -4
  5. package/dist/actions/actions.d.ts +1 -0
  6. package/dist/actions/actions.js +94 -46
  7. package/dist/actions/appeared-text.js +2 -2
  8. package/dist/actions/click-target.d.ts +1 -0
  9. package/dist/actions/click-target.js +14 -0
  10. package/dist/actions/danger-context.d.ts +2 -1
  11. package/dist/actions/danger-context.js +43 -17
  12. package/dist/actions/scroll.d.ts +2 -0
  13. package/dist/actions/scroll.js +10 -2
  14. package/dist/activate.d.ts +14 -0
  15. package/dist/activate.js +43 -0
  16. package/dist/busy.d.ts +3 -0
  17. package/dist/busy.js +42 -0
  18. package/dist/commands/commands.js +45 -17
  19. package/dist/connect-options.d.ts +12 -1
  20. package/dist/connect-options.js +18 -1
  21. package/dist/dom/a11y.d.ts +0 -2
  22. package/dist/dom/a11y.js +103 -363
  23. package/dist/dom/chart.d.ts +3 -2
  24. package/dist/dom/chart.js +3 -2
  25. package/dist/dom/dom-ignore.d.ts +6 -0
  26. package/dist/dom/dom-ignore.js +60 -5
  27. package/dist/dom/layout-audit.d.ts +15 -0
  28. package/dist/dom/layout-audit.js +208 -0
  29. package/dist/dom/paint-context.d.ts +15 -0
  30. package/dist/dom/paint-context.js +78 -0
  31. package/dist/dom/query-finders.d.ts +6 -0
  32. package/dist/dom/query-finders.js +216 -0
  33. package/dist/dom/query.d.ts +1 -1
  34. package/dist/dom/query.js +154 -227
  35. package/dist/dom/shadow-registry.d.ts +1 -0
  36. package/dist/dom/shadow-registry.js +27 -0
  37. package/dist/dom/snapshot.d.ts +5 -0
  38. package/dist/dom/snapshot.js +87 -35
  39. package/dist/dom/stable-selector.d.ts +1 -0
  40. package/dist/dom/stable-selector.js +19 -0
  41. package/dist/dom/text-match.d.ts +12 -0
  42. package/dist/dom/text-match.js +115 -0
  43. package/dist/dom/theme.d.ts +1 -0
  44. package/dist/dom/theme.js +21 -9
  45. package/dist/dom/virtualized.js +5 -1
  46. package/dist/dom/visibility.d.ts +7 -0
  47. package/dist/dom/visibility.js +322 -0
  48. package/dist/index.d.ts +4 -2
  49. package/dist/index.js +7 -1
  50. package/dist/observers/blind-spots.js +3 -2
  51. package/dist/observers/canvas.d.ts +18 -0
  52. package/dist/observers/canvas.js +76 -0
  53. package/dist/observers/dom.js +68 -19
  54. package/dist/observers/field.d.ts +11 -0
  55. package/dist/observers/field.js +117 -13
  56. package/dist/observers/install-all.js +4 -0
  57. package/dist/observers/media.d.ts +2 -0
  58. package/dist/observers/media.js +32 -0
  59. package/dist/observers/net-detail/network-body.d.ts +2 -0
  60. package/dist/observers/net-detail/network-body.js +36 -9
  61. package/dist/observers/net-detail/network-event-stream.d.ts +5 -0
  62. package/dist/observers/net-detail/network-event-stream.js +55 -0
  63. package/dist/observers/net-detail/network-frames.d.ts +2 -0
  64. package/dist/observers/net-detail/network-frames.js +39 -0
  65. package/dist/observers/net-detail/network-response-body.d.ts +3 -0
  66. package/dist/observers/net-detail/network-response-body.js +97 -0
  67. package/dist/observers/network.js +111 -101
  68. package/dist/observers/route.d.ts +2 -0
  69. package/dist/observers/route.js +33 -4
  70. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
  71. package/dist/presenter/chrome/presenter-report-copy.js +0 -14
  72. package/dist/presenter/hud-telemetry.js +1 -1
  73. package/dist/presenter/presenter-chat-views.d.ts +3 -4
  74. package/dist/presenter/presenter-chat-views.js +47 -20
  75. package/dist/presenter/presenter-config.d.ts +1 -0
  76. package/dist/presenter/presenter-config.js +1 -0
  77. package/dist/presenter/presenter-frame.d.ts +3 -2
  78. package/dist/presenter/presenter-frame.js +24 -15
  79. package/dist/presenter/presenter-harness-row.d.ts +4 -15
  80. package/dist/presenter/presenter-harness-row.js +11 -50
  81. package/dist/presenter/presenter-report-styles.js +0 -17
  82. package/dist/presenter/presenter-report.d.ts +1 -2
  83. package/dist/presenter/presenter-report.js +4 -54
  84. package/dist/presenter/presenter-run-state.js +2 -1
  85. package/dist/presenter/presenter-settings.d.ts +2 -3
  86. package/dist/presenter/presenter-settings.js +14 -17
  87. package/dist/presenter/presenter-shell.d.ts +6 -4
  88. package/dist/presenter/presenter-shell.js +37 -7
  89. package/dist/presenter/presenter.js +32 -11
  90. package/dist/registry/store-adapters.d.ts +5 -2
  91. package/dist/registry/store-adapters.js +40 -10
  92. package/dist/reticle-inject.js +700 -645
  93. package/dist/reticle.d.ts +4 -2
  94. package/dist/reticle.js +36 -6
  95. package/dist/security/page-location.d.ts +2 -0
  96. package/dist/security/page-location.js +7 -0
  97. package/dist/security/serialization.js +1 -1
  98. package/dist/storage-keys.d.ts +1 -0
  99. package/dist/storage-keys.js +1 -0
  100. package/dist/timers/native/native-timers.d.ts +1 -0
  101. package/dist/timers/native/native-timers.js +5 -0
  102. package/dist/transport/transport.d.ts +1 -0
  103. package/dist/transport/transport.js +18 -0
  104. package/package.json +6 -2
@@ -0,0 +1,322 @@
1
+ import { isHtmlElement } from './realm.js';
2
+ import { parentAcrossShadowBoundary } from './shadow-registry.js';
3
+ import { clippingBox, overflowAxes, scrolledInto, } from './paint-context.js';
4
+ export const HTML_DETAILS_TAG = 'details';
5
+ export const HTML_DETAILS_OPEN_ATTRIBUTE = 'open';
6
+ export const HTML_SUMMARY_TAG = 'summary';
7
+ function hiddenInsideClosedDetails(el) {
8
+ const parent = el.parentElement;
9
+ if (null === parent) {
10
+ return false;
11
+ }
12
+ const details = parent.closest(HTML_DETAILS_TAG);
13
+ if (null === details || details.hasAttribute(HTML_DETAILS_OPEN_ATTRIBUTE)) {
14
+ return false;
15
+ }
16
+ const summary = Array.from(details.children).find((child) => HTML_SUMMARY_TAG === child.localName);
17
+ return summary === undefined || !summary.contains(el);
18
+ }
19
+ function stalledFade(el) {
20
+ if (!el.ownerDocument.hidden || !('getAnimations' in el)) {
21
+ return false;
22
+ }
23
+ return el.getAnimations().some((a) => 'running' === a.playState || a.pending);
24
+ }
25
+ const OVERLAY_ARIA_HIDDEN = 'data-aria-hidden';
26
+ function hiddenByAnOverlay(el) {
27
+ return el.hasAttribute(OVERLAY_ARIA_HIDDEN);
28
+ }
29
+ function selfCssHidden(el, style) {
30
+ if (isHtmlElement(el) && el.hidden) {
31
+ return true;
32
+ }
33
+ if (hiddenInsideClosedDetails(el)) {
34
+ return true;
35
+ }
36
+ if (style !== null) {
37
+ if ('none' === style.display ||
38
+ 'hidden' === style.visibility ||
39
+ 'collapse' === style.visibility) {
40
+ return true;
41
+ }
42
+ if (0 === Number.parseFloat(style.opacity || '1') && !stalledFade(el)) {
43
+ return true;
44
+ }
45
+ if (clippedAway(style)) {
46
+ return true;
47
+ }
48
+ }
49
+ return false;
50
+ }
51
+ function clippedAway(style) {
52
+ const rect = /^rect\((.*)\)$/.exec(style.getPropertyValue('clip').trim());
53
+ if (null !== rect && ('absolute' === style.position || 'fixed' === style.position)) {
54
+ const [top, right, bottom, left] = (rect[1] ?? '').split(/[\s,]+/).map(Number.parseFloat);
55
+ if (right !== undefined && left !== undefined && right <= left) {
56
+ return true;
57
+ }
58
+ if (bottom !== undefined && top !== undefined && bottom <= top) {
59
+ return true;
60
+ }
61
+ }
62
+ const inset = /^inset\(([^)]*)\)$/.exec(style.getPropertyValue('clip-path').trim());
63
+ const edges = inset?.[1]
64
+ ?.split(/\s+round\s/)[0]
65
+ ?.trim()
66
+ .split(/\s+/) ?? [];
67
+ const known = (edge) => edge.endsWith('%') || 0 === Number.parseFloat(edge);
68
+ if (0 === edges.length || !edges.every(known)) {
69
+ return false;
70
+ }
71
+ const [top = 0, right = top, bottom = top, left = right] = edges.map(Number.parseFloat);
72
+ return top + bottom >= 100 || left + right >= 100;
73
+ }
74
+ function selfHidden(el, style) {
75
+ if ('true' === el.getAttribute('aria-hidden') && !hiddenByAnOverlay(el)) {
76
+ return true;
77
+ }
78
+ return selfCssHidden(el, style);
79
+ }
80
+ export function isHiddenByAriaOnly(el) {
81
+ let foundAriaHidden = false;
82
+ let current = el;
83
+ while (null !== current) {
84
+ if ('true' === current.getAttribute('aria-hidden')) {
85
+ foundAriaHidden = true;
86
+ }
87
+ const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
88
+ if (selfCssHidden(current, style)) {
89
+ return false;
90
+ }
91
+ current = parentAcrossShadowBoundary(current);
92
+ }
93
+ return foundAriaHidden;
94
+ }
95
+ export function isRenderedOutsideAriaHidden(el) {
96
+ let current = el;
97
+ while (null !== current) {
98
+ if ('true' === current.getAttribute('aria-hidden')) {
99
+ return false;
100
+ }
101
+ const style = current.ownerDocument.defaultView?.getComputedStyle(current) ?? null;
102
+ if (selfCssHidden(current, style)) {
103
+ return false;
104
+ }
105
+ current = parentAcrossShadowBoundary(current);
106
+ }
107
+ return true;
108
+ }
109
+ const CLIPPING_OVERFLOW = new Set(['hidden', 'clip']);
110
+ const SCROLLING_OVERFLOW = new Set(['auto', 'scroll']);
111
+ const NON_CLIPPING_DISPLAY = new Set([
112
+ 'contents',
113
+ 'table-row',
114
+ 'table-row-group',
115
+ 'table-header-group',
116
+ 'table-footer-group',
117
+ 'table-column',
118
+ 'table-column-group',
119
+ ]);
120
+ const TRANSFORM_CONTAINING_BLOCK_PROPERTIES = new Set([
121
+ 'transform',
122
+ 'translate',
123
+ 'rotate',
124
+ 'scale',
125
+ 'perspective',
126
+ ]);
127
+ const CONTAINING_BLOCK_PROPERTIES = new Set([
128
+ ...TRANSFORM_CONTAINING_BLOCK_PROPERTIES,
129
+ 'filter',
130
+ 'backdrop-filter',
131
+ ]);
132
+ const CONTAINING_BLOCK_CONTAIN = new Set(['layout', 'paint', 'strict', 'content']);
133
+ const REPLACED_INLINE_TAGS = new Set([
134
+ 'img',
135
+ 'video',
136
+ 'audio',
137
+ 'canvas',
138
+ 'iframe',
139
+ 'embed',
140
+ 'object',
141
+ 'input',
142
+ 'textarea',
143
+ 'select',
144
+ ]);
145
+ const visibilityInfoMemos = new WeakMap();
146
+ function visibilityInfoMemo(memo) {
147
+ if (memo === undefined) {
148
+ return new Map();
149
+ }
150
+ let info = visibilityInfoMemos.get(memo);
151
+ if (info === undefined) {
152
+ info = new Map();
153
+ visibilityInfoMemos.set(memo, info);
154
+ }
155
+ return info;
156
+ }
157
+ function ownVisibilityInfo(el, memo) {
158
+ let info = memo.get(el);
159
+ if (info === undefined) {
160
+ const style = el.ownerDocument.defaultView?.getComputedStyle(el) ?? null;
161
+ if (selfHidden(el, style)) {
162
+ info = {
163
+ hidden: true,
164
+ style,
165
+ parent: null,
166
+ clipX: false,
167
+ clipY: false,
168
+ scrollX: false,
169
+ scrollY: false,
170
+ hasClipping: false,
171
+ };
172
+ memo.set(el, info);
173
+ return info;
174
+ }
175
+ const parent = parentAcrossShadowBoundary(el);
176
+ const inherited = null === parent ? undefined : ownVisibilityInfo(parent, memo);
177
+ const [overflowX, overflowY] = overflowAxes(style);
178
+ const root = el.ownerDocument.documentElement;
179
+ const viewportOverflow = el === root ||
180
+ (el === el.ownerDocument.body &&
181
+ overflowAxes(ownVisibilityInfo(root, memo).style).every((axis) => 'visible' === axis));
182
+ const hasClipBox = !viewportOverflow &&
183
+ !NON_CLIPPING_DISPLAY.has(style?.display ?? '') &&
184
+ !(isHtmlElement(el) && 'inline' === style?.display);
185
+ const clipX = hasClipBox && CLIPPING_OVERFLOW.has(overflowX);
186
+ const clipY = hasClipBox && CLIPPING_OVERFLOW.has(overflowY);
187
+ info = {
188
+ hidden: true === inherited?.hidden,
189
+ style,
190
+ parent,
191
+ clipX,
192
+ clipY,
193
+ scrollX: hasClipBox && SCROLLING_OVERFLOW.has(overflowX),
194
+ scrollY: hasClipBox && SCROLLING_OVERFLOW.has(overflowY),
195
+ hasClipping: clipX || clipY || true === inherited?.hasClipping,
196
+ };
197
+ memo.set(el, info);
198
+ }
199
+ return info;
200
+ }
201
+ function positionedContainingBlock(el, fixed, memo) {
202
+ for (let parent = ownVisibilityInfo(el, memo).parent; parent !== null; parent = ownVisibilityInfo(parent, memo).parent) {
203
+ const style = ownVisibilityInfo(parent, memo).style;
204
+ if (null === style || 'contents' === style.display) {
205
+ continue;
206
+ }
207
+ if (!fixed && '' !== style.position && 'static' !== style.position) {
208
+ return parent;
209
+ }
210
+ const transformable = !('inline' === style.display &&
211
+ isHtmlElement(parent) &&
212
+ !REPLACED_INLINE_TAGS.has(parent.localName)) &&
213
+ 'table-column' !== style.display &&
214
+ 'table-column-group' !== style.display;
215
+ const applies = (property) => transformable || !TRANSFORM_CONTAINING_BLOCK_PROPERTIES.has(property);
216
+ if ([...CONTAINING_BLOCK_PROPERTIES].some((property) => {
217
+ const value = style.getPropertyValue(property);
218
+ return applies(property) && '' !== value && 'none' !== value;
219
+ }) ||
220
+ style.willChange
221
+ .split(',')
222
+ .some((property) => CONTAINING_BLOCK_PROPERTIES.has(property.trim()) && applies(property.trim())) ||
223
+ (transformable &&
224
+ !NON_CLIPPING_DISPLAY.has(style.display) &&
225
+ (style.contain.split(/\s+/).some((value) => CONTAINING_BLOCK_CONTAIN.has(value)) ||
226
+ 'auto' === style.contentVisibility))) {
227
+ return parent;
228
+ }
229
+ }
230
+ return null;
231
+ }
232
+ function visibleBounds(el, memo, initial, throughScrollers = false) {
233
+ let bounds = initial;
234
+ const target = ownVisibilityInfo(el, memo);
235
+ if (target.hidden) {
236
+ return null;
237
+ }
238
+ if (null === target.parent || !ownVisibilityInfo(target.parent, memo).hasClipping) {
239
+ return bounds;
240
+ }
241
+ let clipFrom;
242
+ for (let current = el; current !== null; current = ownVisibilityInfo(current, memo).parent) {
243
+ const info = ownVisibilityInfo(current, memo);
244
+ if (info.hidden) {
245
+ return null;
246
+ }
247
+ if (current === clipFrom) {
248
+ clipFrom = undefined;
249
+ }
250
+ const style = info.style;
251
+ if (throughScrollers &&
252
+ current !== el &&
253
+ undefined === clipFrom &&
254
+ (info.scrollX || info.scrollY)) {
255
+ bounds ??= el.getBoundingClientRect();
256
+ if (bounds.width > 0 && bounds.height > 0) {
257
+ bounds = scrolledInto(bounds, clippingBox(current, info), info.scrollX, info.scrollY);
258
+ }
259
+ }
260
+ if (current !== el && undefined === clipFrom && (info.clipX || info.clipY)) {
261
+ bounds ??= el.getBoundingClientRect();
262
+ if (bounds.width > 0 && bounds.height > 0) {
263
+ const clip = clippingBox(current, info);
264
+ const left = info.clipX ? Math.max(bounds.left, clip.left) : bounds.left;
265
+ const right = info.clipX ? Math.min(bounds.right, clip.right) : bounds.right;
266
+ const top = info.clipY ? Math.max(bounds.top, clip.top) : bounds.top;
267
+ const bottom = info.clipY ? Math.min(bounds.bottom, clip.bottom) : bounds.bottom;
268
+ if (right <= left || bottom <= top) {
269
+ return null;
270
+ }
271
+ bounds = { left, right, top, bottom, width: right - left, height: bottom - top };
272
+ }
273
+ }
274
+ if (clipFrom === undefined && ('absolute' === style?.position || 'fixed' === style?.position)) {
275
+ clipFrom = positionedContainingBlock(current, 'fixed' === style?.position, memo);
276
+ }
277
+ }
278
+ return bounds;
279
+ }
280
+ export function isInViewport(el, memo) {
281
+ if (!isVisible(el, memo)) {
282
+ return false;
283
+ }
284
+ const view = el.ownerDocument.defaultView;
285
+ if (null === view) {
286
+ return false;
287
+ }
288
+ const r = visibleBounds(el, visibilityInfoMemo(memo), el.getBoundingClientRect());
289
+ if (null === r || undefined === r) {
290
+ return false;
291
+ }
292
+ if (r.width <= 0 || r.height <= 0) {
293
+ return false;
294
+ }
295
+ return r.bottom > 0 && r.right > 0 && r.top < view.innerHeight && r.left < view.innerWidth;
296
+ }
297
+ function collapsed(el, memo) {
298
+ const own = ownVisibilityInfo(el, memo);
299
+ if (!own.clipX && !own.clipY) {
300
+ return false;
301
+ }
302
+ const rects = Array.from(el.getClientRects());
303
+ return (rects.length > 0 &&
304
+ rects.every((r) => (own.clipX && 0 === r.width) ||
305
+ (own.clipY && 0 === r.height) ||
306
+ (own.clipX && own.clipY && r.width <= 1 && r.height <= 1)));
307
+ }
308
+ export function isVisible(el, memo) {
309
+ if (!el.isConnected) {
310
+ return false;
311
+ }
312
+ const cached = memo?.get(el);
313
+ if (cached !== undefined) {
314
+ return cached;
315
+ }
316
+ const info = visibilityInfoMemo(memo);
317
+ const result = visibleBounds(el, info, undefined, true) !== null && !collapsed(el, info);
318
+ if (memo !== undefined) {
319
+ memo.set(el, result);
320
+ }
321
+ return result;
322
+ }
package/dist/index.d.ts CHANGED
@@ -18,10 +18,12 @@ export { registerReticleDomain } from './registry/domains.js';
18
18
  export type { ReticleDomain } from './registry/domains.js';
19
19
  export { buildSnapshot } from './dom/snapshot.js';
20
20
  export { inspectChart, canvasChartData } from './dom/chart.js';
21
- export type { ChartReport, ChartFinding, CanvasChartData } from './dom/chart.js';
21
+ export type { ChartReport, ChartFinding, CanvasChartData, ChartGlobals } from './dom/chart.js';
22
22
  export { matchQuery, runQuery } from './dom/query.js';
23
23
  export { executeAction, executeSequence } from './actions/actions.js';
24
- export { describe, getRole, getAccessibleName, getStates, isVisible } from './dom/a11y.js';
24
+ export { describe, getRole, getAccessibleName, getStates } from './dom/a11y.js';
25
+ export { isVisible } from './dom/visibility.js';
25
26
  export { refs, RefRegistry } from './dom/addressing/refs.js';
26
27
  export { Annotator, installAnnotator, type AnnotatorDeps } from './review/annotator.js';
27
28
  export { resolveMarkAnchor, type MarkAnchor } from './review/mark-anchor.js';
29
+ export declare const trackShortTimers: (maxMs?: number) => (() => void);
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { reticle } from './reticle-singleton.js';
2
+ import { reticle } from './reticle-singleton.js';
3
+ import { trackShortTimers as trackTimers } from './busy.js';
2
4
  export { Reticle } from './reticle.js';
3
5
  export { SESSION_AUTO } from '@reticlehq/core';
4
6
  export { setIgnoreSelectors } from './dom/dom-ignore.js';
@@ -13,7 +15,11 @@ export { buildSnapshot } from './dom/snapshot.js';
13
15
  export { inspectChart, canvasChartData } from './dom/chart.js';
14
16
  export { matchQuery, runQuery } from './dom/query.js';
15
17
  export { executeAction, executeSequence } from './actions/actions.js';
16
- export { describe, getRole, getAccessibleName, getStates, isVisible } from './dom/a11y.js';
18
+ export { describe, getRole, getAccessibleName, getStates } from './dom/a11y.js';
19
+ export { isVisible } from './dom/visibility.js';
17
20
  export { refs, RefRegistry } from './dom/addressing/refs.js';
18
21
  export { Annotator, installAnnotator } from './review/annotator.js';
19
22
  export { resolveMarkAnchor } from './review/mark-anchor.js';
23
+ export const trackShortTimers = (maxMs) => trackTimers((name, data) => {
24
+ reticle.signal(name, data);
25
+ }, maxMs);
@@ -58,10 +58,11 @@ export function installBlindSpots(emit) {
58
58
  const report = () => {
59
59
  for (const sensor of sensors) {
60
60
  const count = sensor.count();
61
- if (count === last.get(sensor.kind))
61
+ const previous = last.get(sensor.kind);
62
+ if (count === previous)
62
63
  continue;
63
64
  last.set(sensor.kind, count);
64
- if (count > 0)
65
+ if (count > 0 || previous !== undefined)
65
66
  emit(EventType.BLIND_SPOT, { kind: sensor.kind, count });
66
67
  }
67
68
  };
@@ -0,0 +1,18 @@
1
+ import { CanvasTrigger } from '@reticlehq/core';
2
+ export { CanvasTrigger };
3
+ import { type Emit, type Teardown } from './types.js';
4
+ export interface CanvasSample {
5
+ data: Uint8ClampedArray;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export declare function digestPixels(data: Uint8ClampedArray): {
10
+ digest: string;
11
+ mean: [number, number, number];
12
+ };
13
+ interface CanvasOptions {
14
+ sample?: (canvas: HTMLCanvasElement) => CanvasSample | undefined;
15
+ every?: (run: () => void, ms: number) => () => void;
16
+ }
17
+ export declare function canvasAfterAct(): void;
18
+ export declare function installCanvas(emit: Emit, opts?: CanvasOptions): Teardown;
@@ -0,0 +1,76 @@
1
+ import { CanvasTrigger, EventType } from '@reticlehq/core';
2
+ export { CanvasTrigger };
3
+ import { refs } from '../dom/addressing/refs.js';
4
+ import { nativeSetInterval } from '../timers/native/native-timers.js';
5
+ import { observeSafely, observeValue } from './types.js';
6
+ const SIDE = 16;
7
+ const POLL_MS = 500;
8
+ const MAX_CANVASES = 4;
9
+ const FNV_OFFSET = 0x811c9dc5;
10
+ const FNV_PRIME = 0x01000193;
11
+ const QUANTUM = 8;
12
+ export function digestPixels(data) {
13
+ let hash = FNV_OFFSET;
14
+ const sum = [0, 0, 0];
15
+ const pixels = Math.max(1, data.length / 4);
16
+ for (let i = 0; i < data.length; i += 4) {
17
+ for (let c = 0; c < 3; c += 1) {
18
+ const value = data[i + c] ?? 0;
19
+ sum[c] = (sum[c] ?? 0) + value;
20
+ hash = Math.imul(hash ^ Math.floor(value / QUANTUM), FNV_PRIME) >>> 0;
21
+ }
22
+ }
23
+ const mean = sum.map((s) => Math.round(s / pixels));
24
+ return { digest: hash.toString(16).padStart(8, '0'), mean };
25
+ }
26
+ function sampleCanvas(canvas) {
27
+ if (0 === canvas.width || 0 === canvas.height) {
28
+ return undefined;
29
+ }
30
+ const scratch = document.createElement('canvas');
31
+ scratch.width = SIDE;
32
+ scratch.height = SIDE;
33
+ const ctx = scratch.getContext('2d');
34
+ if (null === ctx) {
35
+ return undefined;
36
+ }
37
+ const data = observeValue(() => {
38
+ ctx.drawImage(canvas, 0, 0, SIDE, SIDE);
39
+ return ctx.getImageData(0, 0, SIDE, SIDE).data;
40
+ });
41
+ return data === undefined ? undefined : { data, width: canvas.width, height: canvas.height };
42
+ }
43
+ let sampleAll;
44
+ export function canvasAfterAct() {
45
+ sampleAll?.(CanvasTrigger.ACT);
46
+ }
47
+ export function installCanvas(emit, opts = {}) {
48
+ const sample = opts.sample ?? sampleCanvas;
49
+ const every = opts.every ?? nativeSetInterval;
50
+ const last = new WeakMap();
51
+ const look = (trigger) => {
52
+ observeSafely(() => {
53
+ const canvases = Array.from(document.querySelectorAll('canvas')).slice(0, MAX_CANVASES);
54
+ for (const canvas of canvases) {
55
+ const read = sample(canvas);
56
+ if (read === undefined) {
57
+ continue;
58
+ }
59
+ const { digest, mean } = digestPixels(read.data);
60
+ if (CanvasTrigger.POLL === trigger && last.get(canvas) === digest) {
61
+ continue;
62
+ }
63
+ last.set(canvas, digest);
64
+ emit(EventType.CANVAS_DIGEST, { digest, width: read.width, height: read.height, mean, trigger }, refs.refFor(canvas));
65
+ }
66
+ });
67
+ };
68
+ sampleAll = look;
69
+ const stop = every(() => look(CanvasTrigger.POLL), POLL_MS);
70
+ return () => {
71
+ stop();
72
+ if (sampleAll === look) {
73
+ sampleAll = undefined;
74
+ }
75
+ };
76
+ }
@@ -1,11 +1,13 @@
1
1
  import { readTestId, testIdSelector } from '../dom/addressing/testid-attr.js';
2
2
  import { EventType, REDACTED_VALUE, TruncationChannel } from '@reticlehq/core';
3
- import { getAccessibleName, getRole, isSensitiveField, isVisible } from '../dom/a11y.js';
3
+ import { getAccessibleName, getRole, isSensitiveField } from '../dom/a11y.js';
4
+ import { isVisible } from '../dom/visibility.js';
4
5
  import { refs } from '../dom/addressing/refs.js';
5
6
  function regionKeyOf(target) {
6
7
  const el = isElement(target) ? target : null;
7
- if (null === el)
8
+ if (null === el) {
8
9
  return undefined;
10
+ }
9
11
  const labelled = el.closest(testIdSelector());
10
12
  return (labelled ? readTestId(labelled) : null) ?? refs.refFor(el);
11
13
  }
@@ -30,8 +32,9 @@ const WATCHED_ATTRS = [
30
32
  ];
31
33
  const MAX_ATTR_VALUE_LEN = 120;
32
34
  function capValue(value) {
33
- if (null === value)
35
+ if (null === value) {
34
36
  return undefined;
37
+ }
35
38
  return value.length > MAX_ATTR_VALUE_LEN ? `${value.slice(0, MAX_ATTR_VALUE_LEN)}…` : value;
36
39
  }
37
40
  const DIALOG_ROLES = new Set(['dialog', 'alertdialog']);
@@ -44,19 +47,23 @@ const MOUNT_SCAN_LIMIT = 40;
44
47
  function describeMount(node) {
45
48
  const role = getRole(node);
46
49
  const name = getAccessibleName(node);
47
- if (isMeaningful(role, name))
50
+ if (isMeaningful(role, name)) {
48
51
  return { role, name, el: node };
52
+ }
49
53
  let scanned = 0;
50
54
  for (const child of node.querySelectorAll('*')) {
51
- if (scanned >= MOUNT_SCAN_LIMIT)
55
+ if (scanned >= MOUNT_SCAN_LIMIT) {
52
56
  return undefined;
57
+ }
53
58
  scanned += 1;
54
- if (isUnobserved(child))
59
+ if (isUnobserved(child)) {
55
60
  continue;
61
+ }
56
62
  const childRole = getRole(child);
57
63
  const childName = getAccessibleName(child);
58
- if (isMeaningful(childRole, childName))
64
+ if (isMeaningful(childRole, childName)) {
59
65
  return { role: childRole, name: childName, el: child };
66
+ }
60
67
  }
61
68
  return undefined;
62
69
  }
@@ -64,8 +71,9 @@ const TEXT_NODE = 3;
64
71
  function textOf(nodes) {
65
72
  let out = '';
66
73
  for (const node of nodes) {
67
- if (node.nodeType !== TEXT_NODE)
74
+ if (node.nodeType !== TEXT_NODE) {
68
75
  continue;
76
+ }
69
77
  out += node.nodeValue ?? '';
70
78
  }
71
79
  const trimmed = out.trim();
@@ -83,12 +91,30 @@ const SAFE_ON_SECRET = new Set([
83
91
  function secretAttr(el, attr) {
84
92
  return !SAFE_ON_SECRET.has(attr) && !attr.startsWith('aria-') && isSensitiveField(el);
85
93
  }
94
+ const TESTID_DEPTH = 3;
95
+ const MAX_INITIAL_TEXTS = 50;
96
+ function testidNear(el) {
97
+ let at = el;
98
+ for (let depth = 0; at !== null && depth <= TESTID_DEPTH; depth += 1) {
99
+ const id = readTestId(at);
100
+ if (id !== null && id.length > 0) {
101
+ return id;
102
+ }
103
+ at = at.parentElement;
104
+ }
105
+ return undefined;
106
+ }
107
+ const named = (el) => {
108
+ const testid = testidNear(el);
109
+ return testid === undefined ? {} : { testid };
110
+ };
86
111
  export function installDom(emit) {
87
112
  const stopRegistry = installShadowRegistry();
88
113
  const observer = new MutationObserver((records) => {
89
114
  let added = 0;
90
115
  let removed = 0;
91
116
  let changed = 0;
117
+ let initials = 0;
92
118
  let dropped = 0;
93
119
  for (const record of records) {
94
120
  if ('attributes' === record.type) {
@@ -123,36 +149,54 @@ export function installDom(emit) {
123
149
  ? REDACTED_VALUE
124
150
  : (record.target.textContent ?? '').trim().slice(0, 80);
125
151
  const old = hide ? undefined : capValue(record.oldValue?.trim() ?? null);
126
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
152
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
127
153
  }
128
154
  continue;
129
155
  }
130
156
  if (textOf(record.addedNodes) !== undefined || textOf(record.removedNodes) !== undefined) {
131
157
  const parent = isElement(record.target) ? record.target : null;
132
158
  if (parent !== null && !isUnobserved(parent)) {
133
- if (changed >= MAX_PER_BATCH)
159
+ if (changed >= MAX_PER_BATCH) {
134
160
  dropped += 1;
161
+ }
135
162
  else {
136
163
  changed += 1;
137
164
  const hide = isSensitiveField(parent);
138
165
  const text = hide ? REDACTED_VALUE : (parent.textContent ?? '').trim().slice(0, 80);
139
166
  const old = hide ? undefined : capValue(textOf(record.removedNodes) ?? null);
140
- emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }) }, refs.refFor(parent));
167
+ emit(EventType.DOM_TEXT, { text, ...(old === undefined ? {} : { old }), ...named(parent) }, refs.refFor(parent));
141
168
  }
142
169
  }
143
170
  }
144
171
  for (const node of record.addedNodes) {
145
- if (!isElement(node))
172
+ if (!isElement(node)) {
146
173
  continue;
174
+ }
147
175
  if (added >= MAX_PER_BATCH) {
148
176
  dropped += 1;
149
177
  continue;
150
178
  }
151
- if (isUnobserved(node))
179
+ if (isUnobserved(node)) {
152
180
  continue;
181
+ }
182
+ const testids = [node, ...Array.from(node.querySelectorAll(testIdSelector()))].filter((el) => readTestId(el) !== null);
183
+ for (const el of testids) {
184
+ const text = (el.textContent ?? '').trim();
185
+ if (0 === text.length) {
186
+ continue;
187
+ }
188
+ if (initials >= MAX_INITIAL_TEXTS) {
189
+ dropped += 1;
190
+ continue;
191
+ }
192
+ initials += 1;
193
+ const shown = isSensitiveField(el) ? REDACTED_VALUE : text.slice(0, 80);
194
+ emit(EventType.DOM_TEXT, { text: shown, testid: readTestId(el) ?? '', initial: true }, refs.refFor(el));
195
+ }
153
196
  const mounted = describeMount(node);
154
- if (mounted === undefined)
197
+ if (mounted === undefined) {
155
198
  continue;
199
+ }
156
200
  const { role, name } = mounted;
157
201
  added += 1;
158
202
  const ref = refs.refFor(mounted.el);
@@ -160,13 +204,15 @@ export function installDom(emit) {
160
204
  if (DIALOG_ROLES.has(role) ||
161
205
  LIVE_ROLES.has(role) ||
162
206
  'true' === node.getAttribute('aria-modal')) {
163
- if (isVisible(node))
207
+ if (isVisible(node)) {
164
208
  emit(EventType.VISIBLE_SHOWN, { role, name }, ref);
209
+ }
165
210
  }
166
211
  }
167
212
  for (const node of record.removedNodes) {
168
- if (!isElement(node))
213
+ if (!isElement(node)) {
169
214
  continue;
215
+ }
170
216
  if (removed >= MAX_PER_BATCH) {
171
217
  dropped += 1;
172
218
  continue;
@@ -175,15 +221,17 @@ export function installDom(emit) {
175
221
  continue;
176
222
  }
177
223
  const gone = describeMount(node);
178
- if (gone === undefined)
224
+ if (gone === undefined) {
179
225
  continue;
226
+ }
180
227
  const { role, name } = gone;
181
228
  removed += 1;
182
229
  emit(EventType.DOM_REMOVED, { role, name, region: regionKeyOf(record.target) });
183
230
  }
184
231
  }
185
- if (dropped > 0)
232
+ if (dropped > 0) {
186
233
  emit(EventType.TRUNCATED, { channel: TruncationChannel.DOM, dropped });
234
+ }
187
235
  });
188
236
  const options = {
189
237
  subtree: true,
@@ -195,8 +243,9 @@ export function installDom(emit) {
195
243
  characterDataOldValue: true,
196
244
  };
197
245
  observer.observe(document.documentElement, options);
198
- for (const root of capturedRoots())
246
+ for (const root of capturedRoots()) {
199
247
  observer.observe(root, options);
248
+ }
200
249
  const unsubscribe = onShadowRoot((root) => observer.observe(root, options));
201
250
  const stopFrames = observeSameOriginFrames((body) => observer.observe(body, options));
202
251
  return () => {
@@ -1,2 +1,13 @@
1
1
  import type { Emit, Teardown } from './types.js';
2
+ export interface FieldReading {
3
+ value: string;
4
+ checked?: boolean;
5
+ }
6
+ export interface ActedField {
7
+ before: FieldReading;
8
+ stop: () => void;
9
+ written: () => boolean;
10
+ }
11
+ export declare function watchActedField(el: Element): ActedField | undefined;
12
+ export declare function publishSettledField(el: Element, acted: ActedField | undefined): void;
2
13
  export declare function installField(emit: Emit): Teardown;