@reticlehq/browser 3.8.2 → 3.10.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 (60) hide show
  1. package/dist/actions/action-report.d.ts +14 -0
  2. package/dist/actions/action-report.js +95 -22
  3. package/dist/actions/actions-dom.d.ts +17 -6
  4. package/dist/actions/actions-dom.js +98 -47
  5. package/dist/actions/actions-press.d.ts +10 -0
  6. package/dist/actions/actions-press.js +146 -2
  7. package/dist/actions/actions.js +64 -24
  8. package/dist/actions/insert-text.d.ts +3 -0
  9. package/dist/actions/insert-text.js +90 -0
  10. package/dist/actions/scroll.d.ts +1 -1
  11. package/dist/actions/scroll.js +25 -1
  12. package/dist/actions/value-input.d.ts +2 -0
  13. package/dist/actions/value-input.js +30 -9
  14. package/dist/commands/commands.js +85 -9
  15. package/dist/connect-options.d.ts +11 -1
  16. package/dist/connect-options.js +27 -1
  17. package/dist/dom/a11y.d.ts +1 -1
  18. package/dist/dom/a11y.js +11 -10
  19. package/dist/dom/chart-geometry.d.ts +7 -0
  20. package/dist/dom/chart-geometry.js +39 -0
  21. package/dist/dom/occlusion.js +21 -0
  22. package/dist/dom/query-finders.d.ts +1 -1
  23. package/dist/dom/query-finders.js +10 -7
  24. package/dist/dom/query.d.ts +6 -2
  25. package/dist/dom/query.js +165 -54
  26. package/dist/dom/scope-roots.d.ts +8 -0
  27. package/dist/dom/scope-roots.js +44 -0
  28. package/dist/dom/snapshot.d.ts +3 -0
  29. package/dist/dom/snapshot.js +9 -2
  30. package/dist/dom/text-match.js +6 -4
  31. package/dist/dom/theme.js +6 -7
  32. package/dist/dom/visibility.d.ts +3 -0
  33. package/dist/dom/visibility.js +125 -10
  34. package/dist/observers/canvas.d.ts +1 -0
  35. package/dist/observers/canvas.js +10 -1
  36. package/dist/observers/console.js +46 -35
  37. package/dist/observers/dom.js +28 -4
  38. package/dist/observers/net-detail/resource-timing.d.ts +12 -0
  39. package/dist/observers/net-detail/resource-timing.js +69 -0
  40. package/dist/observers/network.js +13 -44
  41. package/dist/observers/perf.js +15 -1
  42. package/dist/presenter/chrome/presenter-verbs.js +1 -0
  43. package/dist/presenter/presenter-config.d.ts +1 -0
  44. package/dist/presenter/presenter-config.js +1 -0
  45. package/dist/presenter/presenter-shell.d.ts +1 -0
  46. package/dist/presenter/presenter-shell.js +6 -3
  47. package/dist/presenter/tour/tour.js +4 -0
  48. package/dist/recorder/recorder.js +82 -39
  49. package/dist/reticle-inject.js +618 -618
  50. package/dist/reticle.d.ts +3 -19
  51. package/dist/reticle.js +76 -110
  52. package/dist/review/annotator.js +4 -0
  53. package/dist/security/serialization.js +35 -0
  54. package/dist/session-continuity.d.ts +1 -0
  55. package/dist/session-continuity.js +18 -0
  56. package/dist/storage-keys.js +2 -1
  57. package/dist/timers/native/native-timers.js +21 -4
  58. package/dist/transport/bridge-url.d.ts +8 -0
  59. package/dist/transport/bridge-url.js +52 -0
  60. package/package.json +10 -10
package/dist/dom/query.js CHANGED
@@ -1,20 +1,23 @@
1
1
  import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
- import { isActionTarget, isElement, isFrame } from './realm.js';
2
+ import { isElement, isFrame } from './realm.js';
3
3
  import { capturedRootOf } from './shadow-registry.js';
4
4
  import { getAccessibleName, getRole, describe, getStates } from './a11y.js';
5
- import { isHiddenByAriaOnly, isInViewport, isRenderedOutsideAriaHidden, isVisible, } from './visibility.js';
5
+ import { isHiddenByAriaOnly, isHiddenSubtree, isInViewport, isRenderedOutsideAriaHidden, isVisible, isNonRendered, } from './visibility.js';
6
6
  import { dialogCovering, isIgnored, isReticleOverlay, isWordsIgnored } from './dom-ignore.js';
7
+ import { hitTestOccluder } from './occlusion.js';
7
8
  import { isSensitiveKey } from '../security/serialization.js';
8
9
  import { declaredTestids } from '../registry/capabilities.js';
9
10
  import { refs } from './addressing/refs.js';
11
+ import { firstIn, firstListIn, hintRoots, pageContent, resolveContainers } from './scope-roots.js';
10
12
  import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
11
13
  import { stableSelectorFor } from './stable-selector.js';
12
14
  import { fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, textMatches, unreadableMatch, } from './text-match.js';
13
15
  import { pageRoute } from '../security/page-location.js';
14
- import { elementsUnder, findIn, isNonRendered, queryByRoleAndName } from './query-finders.js';
16
+ import { elementsUnder, findIn, queryByRoleAndName } from './query-finders.js';
17
+ import { INTERACTIVE } from './snapshot.js';
15
18
  const MAX_PRESENT_TESTIDS = 12;
16
19
  function visibleTextOf(el, memo) {
17
- if (isNonRendered(el) || isWordsIgnored(el)) {
20
+ if (isHiddenSubtree(el, memo) || isNonRendered(el) || isWordsIgnored(el)) {
18
21
  return '';
19
22
  }
20
23
  const visible = isVisible(el, memo);
@@ -25,6 +28,9 @@ function visibleTextOf(el, memo) {
25
28
  text += node.textContent ?? '';
26
29
  }
27
30
  else if (isElement(node)) {
31
+ if (isHiddenSubtree(node, memo)) {
32
+ continue;
33
+ }
28
34
  const own = visibleTextOf(node, memo);
29
35
  text += apart || ownBox(node) ? ` ${own} ` : own;
30
36
  }
@@ -39,24 +45,6 @@ function ownBox(el) {
39
45
  const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
40
46
  return !display.startsWith('inline') && 'contents' !== display;
41
47
  }
42
- function resolveContainer(scope) {
43
- if (scope === undefined) {
44
- return { container: document.body, scopeMissing: false };
45
- }
46
- const byRef = refs.resolve(scope);
47
- if (isActionTarget(byRef)) {
48
- return { container: byRef, scopeMissing: false };
49
- }
50
- try {
51
- const found = document.querySelector(scope);
52
- if (isActionTarget(found)) {
53
- return { container: found, scopeMissing: false };
54
- }
55
- }
56
- catch {
57
- }
58
- return { container: null, scopeMissing: true };
59
- }
60
48
  function readableFrameBody(frame) {
61
49
  try {
62
50
  return frame.contentDocument?.body ?? null;
@@ -66,7 +54,7 @@ function readableFrameBody(frame) {
66
54
  }
67
55
  }
68
56
  const FRAME_DEPTH_MAX = 3;
69
- function embeddedRootsUnder(root) {
57
+ export function embeddedRootsUnder(root) {
70
58
  const found = [];
71
59
  const walk = (node, depth) => {
72
60
  for (const el of node.querySelectorAll('*')) {
@@ -94,19 +82,20 @@ function embeddedRootsUnder(root) {
94
82
  return found;
95
83
  }
96
84
  function findCandidates(query) {
97
- const { container, scopeMissing } = resolveContainer(query.scope);
98
- if (null === container) {
99
- return { candidates: [], scopeMissing: true };
85
+ const { containers, scopeMissing } = resolveContainers(query.scope);
86
+ const scopeMatches = containers.length;
87
+ if (0 === scopeMatches) {
88
+ return { candidates: [], scopeMissing: true, scopeMatches };
100
89
  }
101
90
  if (true === query.self) {
102
91
  if (query.scope === undefined) {
103
- return { candidates: [], scopeMissing };
104
- }
105
- if (query.text !== undefined &&
106
- !textMatches(visibleTextOf(container, new Map()), query, query.text)) {
107
- return { candidates: [], scopeMissing };
92
+ return { candidates: [], scopeMissing, scopeMatches };
108
93
  }
109
- return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
94
+ const visibleMemo = new Map();
95
+ const selves = containers.filter((container) => !isIgnored(container) &&
96
+ (query.text === undefined ||
97
+ textMatches(visibleTextOf(container, visibleMemo), query, query.text)));
98
+ return { candidates: selves, scopeMissing, scopeMatches };
110
99
  }
111
100
  const seen = new Set();
112
101
  const out = [];
@@ -116,6 +105,9 @@ function findCandidates(query) {
116
105
  if (seen.has(el)) {
117
106
  continue;
118
107
  }
108
+ if (!pageContent(el, query.scope)) {
109
+ continue;
110
+ }
119
111
  if (ignored(el)) {
120
112
  continue;
121
113
  }
@@ -123,11 +115,13 @@ function findCandidates(query) {
123
115
  out.push(el);
124
116
  }
125
117
  };
126
- collect(findIn(container, query));
127
- for (const embedded of embeddedRootsUnder(container)) {
128
- collect(findIn(embedded, query));
118
+ for (const container of containers) {
119
+ collect(findIn(container, query));
120
+ for (const embedded of embeddedRootsUnder(container)) {
121
+ collect(findIn(embedded, query));
122
+ }
129
123
  }
130
- return { candidates: out, scopeMissing };
124
+ return { candidates: out, scopeMissing, scopeMatches };
131
125
  }
132
126
  export const ATTR_VALUE_MAX = 512;
133
127
  const ATTR_KEYS_MAX = 12;
@@ -142,27 +136,51 @@ function projectAttrs(el, keys) {
142
136
  }
143
137
  return Object.keys(out).length > 0 ? out : undefined;
144
138
  }
139
+ function receivesPointer(el, memo) {
140
+ if (!isVisible(el, memo)) {
141
+ return false;
142
+ }
143
+ const rect = el.getBoundingClientRect();
144
+ if (0 === rect.width || 0 === rect.height) {
145
+ return true;
146
+ }
147
+ return isInViewport(el, memo) && null === hitTestOccluder(el, rect);
148
+ }
145
149
  function inState(el, state, memo) {
146
150
  if (ElementState.IN_VIEWPORT === state) {
147
151
  return isInViewport(el, memo);
148
152
  }
153
+ if (ElementState.RECEIVES_POINTER === state) {
154
+ return receivesPointer(el, memo);
155
+ }
156
+ if (ElementState.VISIBLE === state) {
157
+ return isVisible(el, memo);
158
+ }
159
+ if (ElementState.HIDDEN === state) {
160
+ return !isVisible(el, memo);
161
+ }
149
162
  return getStates(el, isVisible(el, memo)).includes(state);
150
163
  }
151
164
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
152
- export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
165
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true, fullText = false) {
153
166
  const found = findCandidates(query);
154
167
  const scopeMissing = found.scopeMissing;
155
168
  const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
156
- const scope = 0 === found.candidates.length ? resolveContainer(query.scope).container : null;
157
- const owner = null === scope || wanted === undefined ? undefined : splitTextOwner(scope, wanted, query);
169
+ const scopes = 0 === found.candidates.length ? resolveContainers(query.scope).containers : [];
170
+ const owner = wanted === undefined
171
+ ? undefined
172
+ : firstIn(scopes, (scope) => splitTextOwner(scope, wanted, query));
158
173
  const elements = owner === undefined ? found.candidates : [owner];
159
174
  const visMemo = new Map();
160
175
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
161
176
  const attrs = query.attrs;
162
- const described = filtered.slice(0, Math.max(0, Math.min(limit, MAX_DESCRIBED)));
177
+ const cap = fullText
178
+ ? Math.min(MAX_DESCRIBED, TRANSPORT_LIMITS.MAX_FULL_TEXT_ELEMENTS)
179
+ : MAX_DESCRIBED;
180
+ const described = filtered.slice(0, Math.max(0, Math.min(limit, cap)));
163
181
  const stampViewport = described.length > 1 || ElementState.IN_VIEWPORT === state;
164
182
  const descriptors = described.map((el) => {
165
- let base = describe(el, visMemo);
183
+ let base = describe(el, visMemo, fullText);
166
184
  if (stampViewport &&
167
185
  isInViewport(el, visMemo) &&
168
186
  !base.states.includes(ElementState.IN_VIEWPORT)) {
@@ -180,7 +198,9 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
180
198
  matched: filtered.length > 0,
181
199
  count: filtered.length,
182
200
  elements: descriptors,
201
+ ...(fullText ? { fullText: true } : {}),
183
202
  ...(scopeMissing ? { scopeMissing: true } : {}),
203
+ ...(found.scopeMatches > 1 ? { scopeMatches: found.scopeMatches } : {}),
184
204
  ...(covered === undefined ? {} : { coveredBy: covered }),
185
205
  ...(unreadable === undefined ? {} : { unreadable }),
186
206
  ...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
@@ -199,7 +219,7 @@ function resolveLabelledBy(el) {
199
219
  return text.length > 0 ? text : undefined;
200
220
  }
201
221
  function buildPresentRegions(query) {
202
- const container = resolveContainer(query.scope).container ?? document.body;
222
+ const roots = hintRoots(query.scope);
203
223
  const regions = [];
204
224
  const CONTAINER_ROLES = [
205
225
  'list',
@@ -220,7 +240,7 @@ function buildPresentRegions(query) {
220
240
  'tablist',
221
241
  ];
222
242
  for (const role of CONTAINER_ROLES) {
223
- const containers = queryByRoleAndName(container, role, undefined);
243
+ const containers = roots.flatMap((root) => queryByRoleAndName(root, role, undefined));
224
244
  for (const el of containers) {
225
245
  if (isReticleOverlay(el)) {
226
246
  continue;
@@ -264,6 +284,9 @@ function splitTextOwner(container, wanted, query) {
264
284
  if (isIgnored(el)) {
265
285
  continue;
266
286
  }
287
+ if (!pageContent(el, query.scope)) {
288
+ continue;
289
+ }
267
290
  if (!textMatches(visibleTextOf(el, memo), query, wanted)) {
268
291
  continue;
269
292
  }
@@ -278,7 +301,7 @@ function splitTextOwner(container, wanted, query) {
278
301
  }
279
302
  return best;
280
303
  }
281
- function caseOnlyMatch(container, wanted) {
304
+ function caseOnlyMatch(container, wanted, scope) {
282
305
  const target = comparableText(wanted).toLowerCase();
283
306
  if (0 === target.length) {
284
307
  return undefined;
@@ -288,6 +311,9 @@ function caseOnlyMatch(container, wanted) {
288
311
  if (isIgnored(el)) {
289
312
  continue;
290
313
  }
314
+ if (!pageContent(el, scope)) {
315
+ continue;
316
+ }
291
317
  const text = normaliseVisibleText(visibleTextOf(el, memo));
292
318
  const at = foldTypography(text).toLowerCase().indexOf(target);
293
319
  if (-1 !== at) {
@@ -346,6 +372,81 @@ function nameNearMisses(container, query) {
346
372
  }
347
373
  return out;
348
374
  }
375
+ const MAX_NEAREST_TARGETS = 3;
376
+ export const MAX_NEAREST_NAME = 60;
377
+ function bigrams(value) {
378
+ const padded = ` ${value} `;
379
+ const out = new Set();
380
+ for (let i = 0; i < padded.length - 1; i += 1) {
381
+ out.add(padded.slice(i, i + 2));
382
+ }
383
+ return out;
384
+ }
385
+ export function nameSimilarity(a, b) {
386
+ const left = bigrams(foldTypography(a).toLowerCase());
387
+ const right = bigrams(foldTypography(b).toLowerCase());
388
+ let shared = 0;
389
+ for (const gram of left) {
390
+ if (right.has(gram)) {
391
+ shared += 1;
392
+ }
393
+ }
394
+ return (2 * shared) / (left.size + right.size);
395
+ }
396
+ function askedRole(query) {
397
+ return QueryBy.ROLE === query.by ? query.value : query.role;
398
+ }
399
+ function askedName(query) {
400
+ const value = QueryBy.ROLE === query.by ? undefined : query.value;
401
+ const asked = query.name ??
402
+ value ??
403
+ query.text ??
404
+ query.label ??
405
+ query.placeholder ??
406
+ query.testid ??
407
+ query.alt;
408
+ return asked === undefined ? '' : normaliseVisibleText(asked);
409
+ }
410
+ export function nearestTargets(container, query) {
411
+ const role = askedRole(query);
412
+ const name = askedName(query);
413
+ if (role === undefined && 0 === name.length) {
414
+ return [];
415
+ }
416
+ const memo = new Map();
417
+ const scored = [];
418
+ for (const el of elementsUnder(container)) {
419
+ const elRole = getRole(el);
420
+ const roleMatch = role !== undefined && elRole === role;
421
+ if (!roleMatch && !INTERACTIVE.has(elRole)) {
422
+ continue;
423
+ }
424
+ if (isIgnored(el) || !isVisible(el, memo)) {
425
+ continue;
426
+ }
427
+ const elName = normaliseVisibleText(getAccessibleName(el));
428
+ if (0 === elName.length) {
429
+ continue;
430
+ }
431
+ const score = 0 === name.length ? 0 : nameSimilarity(name, elName);
432
+ if (!roleMatch && 0 === score) {
433
+ continue;
434
+ }
435
+ scored.push({ el, role: elRole, name: elName, roleMatch, score });
436
+ }
437
+ scored.sort((a, b) => Number(b.roleMatch) - Number(a.roleMatch) || b.score - a.score);
438
+ return scored.slice(0, MAX_NEAREST_TARGETS).map((c) => {
439
+ const target = {
440
+ ref: refs.refFor(c.el),
441
+ role: c.role,
442
+ name: c.name.slice(0, MAX_NEAREST_NAME),
443
+ };
444
+ if (c.name.length > MAX_NEAREST_NAME) {
445
+ target.nameTruncated = true;
446
+ }
447
+ return target;
448
+ });
449
+ }
349
450
  function testidFoundUnder(container, query) {
350
451
  if (QueryBy.TESTID !== query.by || undefined === query.value) {
351
452
  return undefined;
@@ -394,14 +495,17 @@ function textInAriaHidden(container, wanted) {
394
495
  return true;
395
496
  }
396
497
  function buildEmptyHint(query, state, diagnose = true) {
397
- const container = resolveContainer(query.scope).container ?? document.body;
398
- const all = container.querySelectorAll(testIdSelector());
498
+ const roots = hintRoots(query.scope);
499
+ const all = roots.flatMap((root) => Array.from(root.querySelectorAll(testIdSelector())));
399
500
  const present = [];
400
501
  const seen = new Set();
401
- for (const el of Array.from(all)) {
502
+ for (const el of all) {
402
503
  if (isIgnored(el)) {
403
504
  continue;
404
505
  }
506
+ if (!pageContent(el, query.scope)) {
507
+ continue;
508
+ }
405
509
  const id = readTestId(el);
406
510
  if (null === id || 0 === id.length || seen.has(id)) {
407
511
  continue;
@@ -423,14 +527,14 @@ function buildEmptyHint(query, state, diagnose = true) {
423
527
  };
424
528
  const wanted = wantedTextOf(query);
425
529
  if (wanted !== undefined) {
426
- const owner = splitTextOwner(container, wanted, query);
530
+ const owner = firstIn(roots, (root) => splitTextOwner(root, wanted, query));
427
531
  if (owner !== undefined) {
428
532
  const described = describe(owner);
429
533
  const selector = stableSelectorFor(owner);
430
534
  hint.splitText = selector === undefined ? described : { ...described, selector };
431
535
  }
432
536
  else if (true === query.matchCase) {
433
- const shown = caseOnlyMatch(container, wanted);
537
+ const shown = firstIn(roots, (root) => caseOnlyMatch(root, wanted, query.scope));
434
538
  if (shown !== undefined) {
435
539
  hint.caseMiss = shown;
436
540
  }
@@ -440,18 +544,22 @@ function buildEmptyHint(query, state, diagnose = true) {
440
544
  wanted !== undefined &&
441
545
  hint.splitText === undefined &&
442
546
  (state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
443
- if (textInAriaHidden(container, wanted)) {
547
+ if (roots.some((root) => textInAriaHidden(root, wanted))) {
444
548
  hint.ariaHiddenMatch = true;
445
549
  }
446
550
  }
447
- const near = nameNearMisses(container, query);
551
+ const near = firstListIn(roots, (root) => nameNearMisses(root, query));
448
552
  if (near.length > 0) {
449
553
  hint.nameNearMiss = near;
450
554
  }
451
- const elsewhere = testidFoundUnder(container, query);
555
+ const elsewhere = firstIn(roots, (root) => testidFoundUnder(root, query));
452
556
  if (elsewhere !== undefined) {
453
557
  hint.testidFoundUnder = elsewhere;
454
558
  }
559
+ const nearest = diagnose ? firstListIn(roots, (root) => nearestTargets(root, query)) : [];
560
+ if (nearest.length > 0) {
561
+ hint.nearest = nearest;
562
+ }
455
563
  return hint;
456
564
  }
457
565
  export function elementsMatching(query) {
@@ -459,12 +567,15 @@ export function elementsMatching(query) {
459
567
  }
460
568
  export function runQuery(query, limit) {
461
569
  const result = matchQuery(query, undefined, limit, false);
462
- const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
570
+ const scopeFields = {
571
+ ...(true === result.scopeMissing ? { scopeMissing: true } : {}),
572
+ ...(result.scopeMatches === undefined ? {} : { scopeMatches: result.scopeMatches }),
573
+ };
463
574
  if (0 === result.elements.length) {
464
575
  return {
465
576
  elements: result.elements,
466
577
  count: result.count,
467
- hint: buildEmptyHint(query),
578
+ hint: result.hint ?? buildEmptyHint(query),
468
579
  ...scopeFields,
469
580
  };
470
581
  }
@@ -0,0 +1,8 @@
1
+ export declare function resolveContainers(scope: string | undefined): {
2
+ containers: HTMLElement[];
3
+ scopeMissing: boolean;
4
+ };
5
+ export declare function hintRoots(scope: string | undefined): HTMLElement[];
6
+ export declare function firstIn<T>(roots: HTMLElement[], find: (root: HTMLElement) => T | undefined): T | undefined;
7
+ export declare function firstListIn<T>(roots: HTMLElement[], find: (root: HTMLElement) => T[]): T[];
8
+ export declare function pageContent(el: Element, scope: string | undefined): boolean;
@@ -0,0 +1,44 @@
1
+ import { isActionTarget } from './realm.js';
2
+ import { refs } from './addressing/refs.js';
3
+ export function resolveContainers(scope) {
4
+ let containers = [];
5
+ if (scope === undefined) {
6
+ containers = [document.documentElement];
7
+ }
8
+ else {
9
+ const byRef = refs.resolve(scope);
10
+ if (isActionTarget(byRef)) {
11
+ containers = [byRef];
12
+ }
13
+ else {
14
+ try {
15
+ containers = Array.from(document.querySelectorAll(scope)).filter(isActionTarget);
16
+ }
17
+ catch {
18
+ }
19
+ }
20
+ }
21
+ return { containers, scopeMissing: 0 === containers.length };
22
+ }
23
+ export function hintRoots(scope) {
24
+ const { containers } = resolveContainers(scope);
25
+ return containers.length > 0 ? containers : [document.body];
26
+ }
27
+ export function firstIn(roots, find) {
28
+ for (const root of roots) {
29
+ const found = find(root);
30
+ if (found !== undefined) {
31
+ return found;
32
+ }
33
+ }
34
+ return undefined;
35
+ }
36
+ export function firstListIn(roots, find) {
37
+ return (firstIn(roots, (root) => {
38
+ const found = find(root);
39
+ return found.length > 0 ? found : undefined;
40
+ }) ?? []);
41
+ }
42
+ export function pageContent(el, scope) {
43
+ return scope !== undefined || !el.closest('head');
44
+ }
@@ -1,5 +1,6 @@
1
1
  import { SnapshotMode } from '@reticlehq/core';
2
2
  import { type LayoutFinding } from './layout-audit.js';
3
+ import { type ChartReading } from './chart-geometry.js';
3
4
  export declare const INTERACTIVE: Set<string>;
4
5
  interface SnapshotStatus {
5
6
  route: string;
@@ -14,6 +15,7 @@ interface SnapshotResult {
14
15
  truncated: boolean;
15
16
  scopeMissing?: boolean;
16
17
  audit?: LayoutFinding[];
18
+ charts?: ChartReading[];
17
19
  leanSkipped?: number;
18
20
  hiddenSkipped?: number;
19
21
  domElements?: number;
@@ -28,6 +30,7 @@ interface SnapshotOptions {
28
30
  includeRoot?: boolean | undefined;
29
31
  ids?: boolean | undefined;
30
32
  audit?: boolean | undefined;
33
+ charts?: boolean | undefined;
31
34
  }
32
35
  export declare const MAX_UNREAD_BRANCHES = 50;
33
36
  export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;
@@ -7,6 +7,7 @@ import { refs } from './addressing/refs.js';
7
7
  import { readTestId } from './addressing/testid-attr.js';
8
8
  import { identifyComponent } from '../registry/stores/adapters.js';
9
9
  import { auditLayout } from './layout-audit.js';
10
+ import { readCharts } from './chart-geometry.js';
10
11
  import { pageRoute } from '../security/page-location.js';
11
12
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
12
13
  export const INTERACTIVE = new Set([
@@ -40,6 +41,10 @@ function directText(el) {
40
41
  const collapsed = (parts.own ? parts.text : '').replace(/\s+/g, ' ').trim();
41
42
  return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
42
43
  }
44
+ const foldText = (s) => s.toLowerCase().replace(/\s+/g, ' ').trim();
45
+ function textBesideName(own, name) {
46
+ return 0 < name.length && foldText(own) === foldText(name) ? '' : own;
47
+ }
43
48
  function readingParts(el) {
44
49
  let text = '';
45
50
  let own = false;
@@ -193,7 +198,8 @@ function visit(child, depth, ctx, inLive) {
193
198
  const interactive = INTERACTIVE.has(role);
194
199
  const announce = inLive || announces(child, role);
195
200
  const lean = ctx.mode === SnapshotMode.INTERACTIVE && !announce;
196
- const text = !lean && 0 === name.length ? directText(child) : '';
201
+ const ownText = lean || (interactive && 0 < name.length) ? '' : directText(child);
202
+ const text = textBesideName(ownText, name);
197
203
  const layout = lean ? '' : layoutSignature(style);
198
204
  const actionable = interactive;
199
205
  const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
@@ -203,7 +209,7 @@ function visit(child, depth, ctx, inLive) {
203
209
  }
204
210
  if (include) {
205
211
  ctx.nodes += 1;
206
- ctx.lines.push('generic' === role && text.length > 0 && 0 === layout.length
212
+ ctx.lines.push('generic' === role && 0 === name.length && text.length > 0 && 0 === layout.length
207
213
  ? formatTextLine(depth, text)
208
214
  : formatLine(child, depth, role, name, layout, text, true === ctx.ids));
209
215
  walk(child, depth + 1, ctx, announce);
@@ -352,5 +358,6 @@ export function buildSnapshot(options = {}) {
352
358
  ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
353
359
  ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
354
360
  ...(true === options.audit ? { audit: auditLayout(root) } : {}),
361
+ ...(true === options.charts ? { charts: readCharts(root) } : {}),
355
362
  };
356
363
  }
@@ -1,15 +1,17 @@
1
1
  import { parentAcrossShadowBoundary } from './shadow-registry.js';
2
+ import { stalledFade } from './visibility.js';
2
3
  export function normaliseVisibleText(value) {
3
4
  return value.replace(/\s+/g, ' ').trim().normalize('NFC');
4
5
  }
5
- const SINGLE_QUOTES = /[\u2018\u2019]/g;
6
- const DOUBLE_QUOTES = /[\u201c\u201d]/g;
6
+ const SINGLE_QUOTES = /[\u2018-\u201b]/g;
7
+ const DOUBLE_QUOTES = /[\u201c-\u201f]/g;
7
8
  const DASHES = /[\u2013\u2014]/g;
8
9
  export function foldTypography(value) {
9
10
  return value.replace(SINGLE_QUOTES, "'").replace(DOUBLE_QUOTES, '"').replace(DASHES, '-');
10
11
  }
12
+ const INVISIBLE_FORMAT = /[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
11
13
  export function comparableText(value) {
12
- return foldTypography(normaliseVisibleText(value));
14
+ return foldTypography(normaliseVisibleText(value.replace(INVISIBLE_FORMAT, '')));
13
15
  }
14
16
  export function fuzzyVisibleText(actual, expected) {
15
17
  return comparableText(actual).toLowerCase().includes(comparableText(expected).toLowerCase());
@@ -81,7 +83,7 @@ export function unreadableText(el) {
81
83
  let opaque = false;
82
84
  for (let node = el; null !== node; node = parentAcrossShadowBoundary(node)) {
83
85
  const style = view.getComputedStyle(node);
84
- const alpha = Number.parseFloat(style.opacity);
86
+ const alpha = stalledFade(node) ? 1 : Number.parseFloat(style.opacity);
85
87
  opacity *= Number.isNaN(alpha) ? 1 : alpha;
86
88
  if (opaque) {
87
89
  continue;
package/dist/dom/theme.js CHANGED
@@ -34,19 +34,18 @@ export function tokenNames() {
34
34
  }
35
35
  return names;
36
36
  }
37
- function palette() {
37
+ function palette(cs) {
38
38
  const byColor = new Map();
39
- const root = document.body ?? document.documentElement;
40
- if (null === root)
39
+ const host = document.body ?? document.documentElement;
40
+ if (null === host)
41
41
  return byColor;
42
- const rootStyle = getComputedStyle(root);
43
42
  const probe = document.createElement('span');
44
43
  probe.style.position = 'absolute';
45
44
  probe.style.pointerEvents = 'none';
46
- document.body.appendChild(probe);
45
+ host.appendChild(probe);
47
46
  try {
48
47
  for (const name of tokenNames()) {
49
- const rgb = toRgb(probe, rootStyle.getPropertyValue(name).trim());
48
+ const rgb = toRgb(probe, cs.getPropertyValue(name).trim());
50
49
  if (null === rgb)
51
50
  continue;
52
51
  const named = byColor.get(rgb);
@@ -90,7 +89,7 @@ function sole(names) {
90
89
  return 1 === names.length ? (names[0] ?? null) : null;
91
90
  }
92
91
  export function themeReport(cs) {
93
- const byColor = palette();
92
+ const byColor = palette(cs);
94
93
  const colorTokens = byColor.get(cs.color) ?? [];
95
94
  const backgroundTokens = byColor.get(cs.backgroundColor) ?? [];
96
95
  const colorOff = !isTransparent(cs.color) && 0 === colorTokens.length;
@@ -1,7 +1,10 @@
1
1
  export declare const HTML_DETAILS_TAG = "details";
2
2
  export declare const HTML_DETAILS_OPEN_ATTRIBUTE = "open";
3
3
  export declare const HTML_SUMMARY_TAG = "summary";
4
+ export declare function stalledFade(el: Element): boolean;
5
+ export declare function isNonRendered(el: Element): boolean;
4
6
  export declare function isHiddenByAriaOnly(el: Element): boolean;
5
7
  export declare function isRenderedOutsideAriaHidden(el: Element): boolean;
6
8
  export declare function isInViewport(el: Element, memo?: Map<Element, boolean>): boolean;
9
+ export declare function isHiddenSubtree(el: Element, memo?: Map<Element, boolean>): boolean;
7
10
  export declare function isVisible(el: Element, memo?: Map<Element, boolean>): boolean;