@reticlehq/browser 3.9.0 → 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 (56) 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 +16 -6
  4. package/dist/actions/actions-dom.js +94 -46
  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 +52 -22
  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 +82 -6
  15. package/dist/connect-options.d.ts +11 -1
  16. package/dist/connect-options.js +27 -1
  17. package/dist/dom/a11y.js +4 -3
  18. package/dist/dom/chart-geometry.d.ts +7 -0
  19. package/dist/dom/chart-geometry.js +39 -0
  20. package/dist/dom/occlusion.js +21 -0
  21. package/dist/dom/query-finders.d.ts +1 -1
  22. package/dist/dom/query-finders.js +10 -7
  23. package/dist/dom/query.d.ts +1 -0
  24. package/dist/dom/query.js +78 -51
  25. package/dist/dom/scope-roots.d.ts +8 -0
  26. package/dist/dom/scope-roots.js +44 -0
  27. package/dist/dom/snapshot.d.ts +3 -0
  28. package/dist/dom/snapshot.js +9 -2
  29. package/dist/dom/text-match.js +4 -3
  30. package/dist/dom/theme.js +6 -7
  31. package/dist/dom/visibility.d.ts +2 -0
  32. package/dist/dom/visibility.js +40 -8
  33. package/dist/observers/canvas.d.ts +1 -0
  34. package/dist/observers/canvas.js +10 -1
  35. package/dist/observers/console.js +46 -35
  36. package/dist/observers/dom.js +28 -4
  37. package/dist/observers/net-detail/resource-timing.d.ts +12 -0
  38. package/dist/observers/net-detail/resource-timing.js +69 -0
  39. package/dist/observers/network.js +12 -45
  40. package/dist/observers/perf.js +15 -1
  41. package/dist/presenter/chrome/presenter-verbs.js +1 -0
  42. package/dist/presenter/presenter-config.d.ts +1 -0
  43. package/dist/presenter/presenter-config.js +1 -0
  44. package/dist/presenter/presenter-shell.d.ts +1 -0
  45. package/dist/presenter/presenter-shell.js +6 -3
  46. package/dist/presenter/tour/tour.js +4 -0
  47. package/dist/recorder/recorder.js +82 -39
  48. package/dist/reticle-inject.js +597 -597
  49. package/dist/reticle.d.ts +3 -19
  50. package/dist/reticle.js +74 -108
  51. package/dist/security/serialization.js +35 -0
  52. package/dist/storage-keys.js +2 -1
  53. package/dist/timers/native/native-timers.js +21 -4
  54. package/dist/transport/bridge-url.d.ts +8 -0
  55. package/dist/transport/bridge-url.js +52 -0
  56. package/package.json +10 -10
@@ -0,0 +1,7 @@
1
+ export declare const CHART_SHAPE_CAP = 120;
2
+ export interface ChartReading {
3
+ label: string;
4
+ shapes: string[];
5
+ cut?: number;
6
+ }
7
+ export declare function readCharts(root: Element): ChartReading[];
@@ -0,0 +1,39 @@
1
+ export const CHART_SHAPE_CAP = 120;
2
+ const MIN_CHART_WIDTH = 120;
3
+ const MIN_CHART_HEIGHT = 60;
4
+ const SHAPE_TAGS = ['rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon', 'path'];
5
+ const NOT_DRAWN = 'defs, clipPath, mask, pattern, marker, symbol';
6
+ const pct = (v, of) => {
7
+ const n = Math.round((v / of) * 1000) / 10;
8
+ return Object.is(n, -0) ? '0' : String(n);
9
+ };
10
+ export function readCharts(root) {
11
+ const out = [];
12
+ const svgs = Array.from(root.querySelectorAll('svg')).filter((s) => null === s.parentElement?.closest('svg'));
13
+ for (const svg of svgs) {
14
+ const frame = svg.getBoundingClientRect();
15
+ if (frame.width < MIN_CHART_WIDTH || frame.height < MIN_CHART_HEIGHT) {
16
+ continue;
17
+ }
18
+ const marks = Array.from(svg.querySelectorAll(SHAPE_TAGS.join(','))).filter((el) => null === el.closest(NOT_DRAWN));
19
+ const shapes = [];
20
+ for (const el of marks) {
21
+ const b = el.getBoundingClientRect();
22
+ if (0 === b.width && 0 === b.height) {
23
+ continue;
24
+ }
25
+ const x = pct(b.left - frame.left, frame.width);
26
+ const y = pct(b.top - frame.top, frame.height);
27
+ shapes.push(`${el.tagName.toLowerCase()} ${x} ${y} ${pct(b.width, frame.width)} ${pct(b.height, frame.height)}`);
28
+ }
29
+ if (0 === shapes.length) {
30
+ continue;
31
+ }
32
+ const label = svg.getAttribute('aria-label') ?? `chart ${String(out.length + 1)}`;
33
+ const kept = shapes.slice(0, CHART_SHAPE_CAP);
34
+ out.push(shapes.length > CHART_SHAPE_CAP
35
+ ? { label, shapes: kept, cut: shapes.length - CHART_SHAPE_CAP }
36
+ : { label, shapes: kept });
37
+ }
38
+ return out;
39
+ }
@@ -13,6 +13,10 @@ export function hitTestOccluder(el, rect) {
13
13
  const doc = el.ownerDocument;
14
14
  if (typeof doc.elementFromPoint !== 'function')
15
15
  return null;
16
+ const passThrough = pointerPassesThrough(el, rect);
17
+ if (passThrough !== undefined) {
18
+ return passThrough;
19
+ }
16
20
  const withStack = doc;
17
21
  const isOursButNotTheTarget = (candidate) => isReticleUi(candidate) &&
18
22
  candidate !== el &&
@@ -41,3 +45,20 @@ export function hitTestOccluder(el, rect) {
41
45
  }
42
46
  return worstCount >= BLOCKED_MAJORITY ? worst : null;
43
47
  }
48
+ function pointerPassesThrough(el, rect) {
49
+ const view = el.ownerDocument.defaultView;
50
+ if (null === view || 'none' !== view.getComputedStyle(el).pointerEvents) {
51
+ return undefined;
52
+ }
53
+ const top = el.ownerDocument.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2);
54
+ if (null === top || el.contains(top) || isReticleUi(top)) {
55
+ return null;
56
+ }
57
+ const labels = 'labels' in el ? el.labels : null;
58
+ for (const label of labels ?? []) {
59
+ if (label === top || label.contains(top)) {
60
+ return null;
61
+ }
62
+ }
63
+ return top;
64
+ }
@@ -1,6 +1,6 @@
1
1
  import { type ElementQuery } from '@reticlehq/core';
2
2
  export declare function elementsUnder(container: HTMLElement): HTMLElement[];
3
- export declare function isNonRendered(el: Element): boolean;
3
+ export declare function ownWords(el: Element): string;
4
4
  export declare function isHiddenNativeOption(el: HTMLElement): boolean;
5
5
  export declare function queryByRoleAndName(container: HTMLElement, role: string, name: string | undefined): HTMLElement[];
6
6
  export declare function findIn(container: HTMLElement, query: ElementQuery): HTMLElement[];
@@ -1,7 +1,7 @@
1
1
  import { DATA_RETICLE_SOURCE_ATTR, QueryBy } from '@reticlehq/core';
2
2
  import { isInput, isSelect, isTextArea } from './realm.js';
3
3
  import { getAccessibleName, getRole } from './a11y.js';
4
- import { isVisible } from './visibility.js';
4
+ import { isNonRendered, isVisible } from './visibility.js';
5
5
  import { identifyComponent } from '../registry/stores/adapters.js';
6
6
  import { readTestId, testIdSelector } from './addressing/testid-attr.js';
7
7
  import { exactVisibleText, fuzzyVisibleText, comparableText, paintedCase, textMatches, } from './text-match.js';
@@ -12,10 +12,6 @@ export function elementsUnder(container) {
12
12
  const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
13
13
  return [...self, ...Array.from(container.querySelectorAll('*'))];
14
14
  }
15
- const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
16
- export function isNonRendered(el) {
17
- return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
18
- }
19
15
  function directText(el) {
20
16
  if (isInput(el)) {
21
17
  const type = el.type.toLowerCase();
@@ -67,15 +63,22 @@ function findByComponentName(container, component) {
67
63
  }
68
64
  return out;
69
65
  }
66
+ export function ownWords(el) {
67
+ return (el.textContent ?? '').replace(/\s+/g, ' ').trim();
68
+ }
70
69
  function findByComponent(container, query) {
70
+ const words = query.text === undefined ? undefined : query.text.replace(/\s+/g, ' ').trim();
71
+ const role = query.role;
72
+ const keep = (els) => els.filter((el) => (words === undefined || ownWords(el) === words) &&
73
+ (role === undefined || matchesRole(getRole(el), role)));
71
74
  if (query.source !== undefined) {
72
- const bySource = findBySource(container, query.source);
75
+ const bySource = keep(findBySource(container, query.source));
73
76
  if (bySource.length > 0) {
74
77
  return bySource;
75
78
  }
76
79
  }
77
80
  if (query.component !== undefined && query.component.length > 0) {
78
- return findByComponentName(container, query.component);
81
+ return keep(findByComponentName(container, query.component));
79
82
  }
80
83
  return [];
81
84
  }
@@ -1,4 +1,5 @@
1
1
  import { ElementState, type ElementQuery, type MatchResult, type NearestTarget, type QueryResult } from '@reticlehq/core';
2
+ export declare function embeddedRootsUnder(root: HTMLElement): HTMLElement[];
2
3
  export declare const ATTR_VALUE_MAX = 512;
3
4
  export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean, fullText?: boolean): MatchResult;
4
5
  export declare const MAX_NEAREST_NAME = 60;
package/dist/dom/query.js CHANGED
@@ -1,21 +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';
15
17
  import { INTERACTIVE } from './snapshot.js';
16
18
  const MAX_PRESENT_TESTIDS = 12;
17
19
  function visibleTextOf(el, memo) {
18
- if (isNonRendered(el) || isWordsIgnored(el)) {
20
+ if (isHiddenSubtree(el, memo) || isNonRendered(el) || isWordsIgnored(el)) {
19
21
  return '';
20
22
  }
21
23
  const visible = isVisible(el, memo);
@@ -26,6 +28,9 @@ function visibleTextOf(el, memo) {
26
28
  text += node.textContent ?? '';
27
29
  }
28
30
  else if (isElement(node)) {
31
+ if (isHiddenSubtree(node, memo)) {
32
+ continue;
33
+ }
29
34
  const own = visibleTextOf(node, memo);
30
35
  text += apart || ownBox(node) ? ` ${own} ` : own;
31
36
  }
@@ -40,24 +45,6 @@ function ownBox(el) {
40
45
  const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
41
46
  return !display.startsWith('inline') && 'contents' !== display;
42
47
  }
43
- function resolveContainer(scope) {
44
- if (scope === undefined) {
45
- return { container: document.body, scopeMissing: false };
46
- }
47
- const byRef = refs.resolve(scope);
48
- if (isActionTarget(byRef)) {
49
- return { container: byRef, scopeMissing: false };
50
- }
51
- try {
52
- const found = document.querySelector(scope);
53
- if (isActionTarget(found)) {
54
- return { container: found, scopeMissing: false };
55
- }
56
- }
57
- catch {
58
- }
59
- return { container: null, scopeMissing: true };
60
- }
61
48
  function readableFrameBody(frame) {
62
49
  try {
63
50
  return frame.contentDocument?.body ?? null;
@@ -67,7 +54,7 @@ function readableFrameBody(frame) {
67
54
  }
68
55
  }
69
56
  const FRAME_DEPTH_MAX = 3;
70
- function embeddedRootsUnder(root) {
57
+ export function embeddedRootsUnder(root) {
71
58
  const found = [];
72
59
  const walk = (node, depth) => {
73
60
  for (const el of node.querySelectorAll('*')) {
@@ -95,19 +82,20 @@ function embeddedRootsUnder(root) {
95
82
  return found;
96
83
  }
97
84
  function findCandidates(query) {
98
- const { container, scopeMissing } = resolveContainer(query.scope);
99
- if (null === container) {
100
- 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 };
101
89
  }
102
90
  if (true === query.self) {
103
91
  if (query.scope === undefined) {
104
- return { candidates: [], scopeMissing };
105
- }
106
- if (query.text !== undefined &&
107
- !textMatches(visibleTextOf(container, new Map()), query, query.text)) {
108
- return { candidates: [], scopeMissing };
92
+ return { candidates: [], scopeMissing, scopeMatches };
109
93
  }
110
- 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 };
111
99
  }
112
100
  const seen = new Set();
113
101
  const out = [];
@@ -117,6 +105,9 @@ function findCandidates(query) {
117
105
  if (seen.has(el)) {
118
106
  continue;
119
107
  }
108
+ if (!pageContent(el, query.scope)) {
109
+ continue;
110
+ }
120
111
  if (ignored(el)) {
121
112
  continue;
122
113
  }
@@ -124,11 +115,13 @@ function findCandidates(query) {
124
115
  out.push(el);
125
116
  }
126
117
  };
127
- collect(findIn(container, query));
128
- for (const embedded of embeddedRootsUnder(container)) {
129
- 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
+ }
130
123
  }
131
- return { candidates: out, scopeMissing };
124
+ return { candidates: out, scopeMissing, scopeMatches };
132
125
  }
133
126
  export const ATTR_VALUE_MAX = 512;
134
127
  const ATTR_KEYS_MAX = 12;
@@ -143,10 +136,29 @@ function projectAttrs(el, keys) {
143
136
  }
144
137
  return Object.keys(out).length > 0 ? out : undefined;
145
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
+ }
146
149
  function inState(el, state, memo) {
147
150
  if (ElementState.IN_VIEWPORT === state) {
148
151
  return isInViewport(el, memo);
149
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
+ }
150
162
  return getStates(el, isVisible(el, memo)).includes(state);
151
163
  }
152
164
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
@@ -154,8 +166,10 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
154
166
  const found = findCandidates(query);
155
167
  const scopeMissing = found.scopeMissing;
156
168
  const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
157
- const scope = 0 === found.candidates.length ? resolveContainer(query.scope).container : null;
158
- 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));
159
173
  const elements = owner === undefined ? found.candidates : [owner];
160
174
  const visMemo = new Map();
161
175
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
@@ -186,6 +200,7 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
186
200
  elements: descriptors,
187
201
  ...(fullText ? { fullText: true } : {}),
188
202
  ...(scopeMissing ? { scopeMissing: true } : {}),
203
+ ...(found.scopeMatches > 1 ? { scopeMatches: found.scopeMatches } : {}),
189
204
  ...(covered === undefined ? {} : { coveredBy: covered }),
190
205
  ...(unreadable === undefined ? {} : { unreadable }),
191
206
  ...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
@@ -204,7 +219,7 @@ function resolveLabelledBy(el) {
204
219
  return text.length > 0 ? text : undefined;
205
220
  }
206
221
  function buildPresentRegions(query) {
207
- const container = resolveContainer(query.scope).container ?? document.body;
222
+ const roots = hintRoots(query.scope);
208
223
  const regions = [];
209
224
  const CONTAINER_ROLES = [
210
225
  'list',
@@ -225,7 +240,7 @@ function buildPresentRegions(query) {
225
240
  'tablist',
226
241
  ];
227
242
  for (const role of CONTAINER_ROLES) {
228
- const containers = queryByRoleAndName(container, role, undefined);
243
+ const containers = roots.flatMap((root) => queryByRoleAndName(root, role, undefined));
229
244
  for (const el of containers) {
230
245
  if (isReticleOverlay(el)) {
231
246
  continue;
@@ -269,6 +284,9 @@ function splitTextOwner(container, wanted, query) {
269
284
  if (isIgnored(el)) {
270
285
  continue;
271
286
  }
287
+ if (!pageContent(el, query.scope)) {
288
+ continue;
289
+ }
272
290
  if (!textMatches(visibleTextOf(el, memo), query, wanted)) {
273
291
  continue;
274
292
  }
@@ -283,7 +301,7 @@ function splitTextOwner(container, wanted, query) {
283
301
  }
284
302
  return best;
285
303
  }
286
- function caseOnlyMatch(container, wanted) {
304
+ function caseOnlyMatch(container, wanted, scope) {
287
305
  const target = comparableText(wanted).toLowerCase();
288
306
  if (0 === target.length) {
289
307
  return undefined;
@@ -293,6 +311,9 @@ function caseOnlyMatch(container, wanted) {
293
311
  if (isIgnored(el)) {
294
312
  continue;
295
313
  }
314
+ if (!pageContent(el, scope)) {
315
+ continue;
316
+ }
296
317
  const text = normaliseVisibleText(visibleTextOf(el, memo));
297
318
  const at = foldTypography(text).toLowerCase().indexOf(target);
298
319
  if (-1 !== at) {
@@ -474,14 +495,17 @@ function textInAriaHidden(container, wanted) {
474
495
  return true;
475
496
  }
476
497
  function buildEmptyHint(query, state, diagnose = true) {
477
- const container = resolveContainer(query.scope).container ?? document.body;
478
- const all = container.querySelectorAll(testIdSelector());
498
+ const roots = hintRoots(query.scope);
499
+ const all = roots.flatMap((root) => Array.from(root.querySelectorAll(testIdSelector())));
479
500
  const present = [];
480
501
  const seen = new Set();
481
- for (const el of Array.from(all)) {
502
+ for (const el of all) {
482
503
  if (isIgnored(el)) {
483
504
  continue;
484
505
  }
506
+ if (!pageContent(el, query.scope)) {
507
+ continue;
508
+ }
485
509
  const id = readTestId(el);
486
510
  if (null === id || 0 === id.length || seen.has(id)) {
487
511
  continue;
@@ -503,14 +527,14 @@ function buildEmptyHint(query, state, diagnose = true) {
503
527
  };
504
528
  const wanted = wantedTextOf(query);
505
529
  if (wanted !== undefined) {
506
- const owner = splitTextOwner(container, wanted, query);
530
+ const owner = firstIn(roots, (root) => splitTextOwner(root, wanted, query));
507
531
  if (owner !== undefined) {
508
532
  const described = describe(owner);
509
533
  const selector = stableSelectorFor(owner);
510
534
  hint.splitText = selector === undefined ? described : { ...described, selector };
511
535
  }
512
536
  else if (true === query.matchCase) {
513
- const shown = caseOnlyMatch(container, wanted);
537
+ const shown = firstIn(roots, (root) => caseOnlyMatch(root, wanted, query.scope));
514
538
  if (shown !== undefined) {
515
539
  hint.caseMiss = shown;
516
540
  }
@@ -520,19 +544,19 @@ function buildEmptyHint(query, state, diagnose = true) {
520
544
  wanted !== undefined &&
521
545
  hint.splitText === undefined &&
522
546
  (state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
523
- if (textInAriaHidden(container, wanted)) {
547
+ if (roots.some((root) => textInAriaHidden(root, wanted))) {
524
548
  hint.ariaHiddenMatch = true;
525
549
  }
526
550
  }
527
- const near = nameNearMisses(container, query);
551
+ const near = firstListIn(roots, (root) => nameNearMisses(root, query));
528
552
  if (near.length > 0) {
529
553
  hint.nameNearMiss = near;
530
554
  }
531
- const elsewhere = testidFoundUnder(container, query);
555
+ const elsewhere = firstIn(roots, (root) => testidFoundUnder(root, query));
532
556
  if (elsewhere !== undefined) {
533
557
  hint.testidFoundUnder = elsewhere;
534
558
  }
535
- const nearest = diagnose ? nearestTargets(container, query) : [];
559
+ const nearest = diagnose ? firstListIn(roots, (root) => nearestTargets(root, query)) : [];
536
560
  if (nearest.length > 0) {
537
561
  hint.nearest = nearest;
538
562
  }
@@ -543,7 +567,10 @@ export function elementsMatching(query) {
543
567
  }
544
568
  export function runQuery(query, limit) {
545
569
  const result = matchQuery(query, undefined, limit, false);
546
- const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
570
+ const scopeFields = {
571
+ ...(true === result.scopeMissing ? { scopeMissing: true } : {}),
572
+ ...(result.scopeMatches === undefined ? {} : { scopeMatches: result.scopeMatches }),
573
+ };
547
574
  if (0 === result.elements.length) {
548
575
  return {
549
576
  elements: result.elements,
@@ -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
  }
@@ -3,14 +3,15 @@ import { stalledFade } from './visibility.js';
3
3
  export function normaliseVisibleText(value) {
4
4
  return value.replace(/\s+/g, ' ').trim().normalize('NFC');
5
5
  }
6
- const SINGLE_QUOTES = /[\u2018\u2019]/g;
7
- const DOUBLE_QUOTES = /[\u201c\u201d]/g;
6
+ const SINGLE_QUOTES = /[\u2018-\u201b]/g;
7
+ const DOUBLE_QUOTES = /[\u201c-\u201f]/g;
8
8
  const DASHES = /[\u2013\u2014]/g;
9
9
  export function foldTypography(value) {
10
10
  return value.replace(SINGLE_QUOTES, "'").replace(DOUBLE_QUOTES, '"').replace(DASHES, '-');
11
11
  }
12
+ const INVISIBLE_FORMAT = /[\u00ad\u200b-\u200f\u202a-\u202e\u2060-\u2064\u2066-\u2069\ufeff]/g;
12
13
  export function comparableText(value) {
13
- return foldTypography(normaliseVisibleText(value));
14
+ return foldTypography(normaliseVisibleText(value.replace(INVISIBLE_FORMAT, '')));
14
15
  }
15
16
  export function fuzzyVisibleText(actual, expected) {
16
17
  return comparableText(actual).toLowerCase().includes(comparableText(expected).toLowerCase());
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;
@@ -2,7 +2,9 @@ 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
4
  export declare function stalledFade(el: Element): boolean;
5
+ export declare function isNonRendered(el: Element): boolean;
5
6
  export declare function isHiddenByAriaOnly(el: Element): boolean;
6
7
  export declare function isRenderedOutsideAriaHidden(el: Element): boolean;
7
8
  export declare function isInViewport(el: Element, memo?: Map<Element, boolean>): boolean;
9
+ export declare function isHiddenSubtree(el: Element, memo?: Map<Element, boolean>): boolean;
8
10
  export declare function isVisible(el: Element, memo?: Map<Element, boolean>): boolean;