@reticlehq/browser 3.6.0 → 3.8.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 (129) hide show
  1. package/dist/actions/action-report.d.ts +64 -0
  2. package/dist/actions/action-report.js +87 -0
  3. package/dist/actions/actions-dom.d.ts +5 -0
  4. package/dist/actions/actions-dom.js +32 -4
  5. package/dist/actions/actions-press.d.ts +1 -0
  6. package/dist/actions/actions-press.js +25 -1
  7. package/dist/actions/actions.d.ts +2 -37
  8. package/dist/actions/actions.js +56 -80
  9. package/dist/actions/appeared-text.js +5 -2
  10. package/dist/actions/click-target.d.ts +1 -0
  11. package/dist/actions/click-target.js +14 -0
  12. package/dist/actions/danger-context.d.ts +4 -1
  13. package/dist/actions/danger-context.js +45 -4
  14. package/dist/actions/value-input.d.ts +1 -0
  15. package/dist/actions/value-input.js +11 -0
  16. package/dist/activate.d.ts +14 -0
  17. package/dist/activate.js +43 -0
  18. package/dist/busy.d.ts +3 -0
  19. package/dist/busy.js +42 -0
  20. package/dist/commands/commands.js +49 -17
  21. package/dist/connect-options.d.ts +12 -1
  22. package/dist/connect-options.js +18 -1
  23. package/dist/dom/a11y.d.ts +2 -0
  24. package/dist/dom/a11y.js +353 -80
  25. package/dist/dom/addressing/coverage-markers.d.ts +6 -0
  26. package/dist/dom/addressing/coverage-markers.js +13 -0
  27. package/dist/dom/chart.d.ts +3 -2
  28. package/dist/dom/chart.js +3 -2
  29. package/dist/dom/dom-ignore.d.ts +8 -0
  30. package/dist/dom/dom-ignore.js +57 -2
  31. package/dist/dom/layout-audit.d.ts +15 -0
  32. package/dist/dom/layout-audit.js +208 -0
  33. package/dist/dom/paint-context.d.ts +15 -0
  34. package/dist/dom/paint-context.js +78 -0
  35. package/dist/dom/query.d.ts +2 -1
  36. package/dist/dom/query.js +163 -38
  37. package/dist/dom/render-page.d.ts +12 -0
  38. package/dist/dom/render-page.js +135 -0
  39. package/dist/dom/shadow-registry.d.ts +1 -0
  40. package/dist/dom/shadow-registry.js +27 -0
  41. package/dist/dom/snapshot.d.ts +5 -0
  42. package/dist/dom/snapshot.js +135 -36
  43. package/dist/dom/stable-selector.d.ts +1 -0
  44. package/dist/dom/stable-selector.js +19 -0
  45. package/dist/dom/text-match.d.ts +12 -0
  46. package/dist/dom/text-match.js +115 -0
  47. package/dist/dom/theme.d.ts +1 -0
  48. package/dist/dom/theme.js +21 -9
  49. package/dist/dom/virtualized.js +5 -1
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.js +5 -0
  52. package/dist/observers/blind-spots.js +3 -2
  53. package/dist/observers/canvas.d.ts +18 -0
  54. package/dist/observers/canvas.js +76 -0
  55. package/dist/observers/dom.js +96 -32
  56. package/dist/observers/field.d.ts +11 -0
  57. package/dist/observers/field.js +116 -30
  58. package/dist/observers/install-all.js +4 -0
  59. package/dist/observers/media.d.ts +2 -0
  60. package/dist/observers/media.js +32 -0
  61. package/dist/observers/net-detail/network-body.d.ts +2 -0
  62. package/dist/observers/net-detail/network-body.js +8 -1
  63. package/dist/observers/network.js +82 -13
  64. package/dist/observers/perf.js +53 -1
  65. package/dist/observers/route.d.ts +2 -0
  66. package/dist/observers/route.js +39 -9
  67. package/dist/presenter/carousel/carousel.js +2 -7
  68. package/dist/presenter/carousel/panel-slides.d.ts +2 -4
  69. package/dist/presenter/carousel/panel-slides.js +17 -15
  70. package/dist/presenter/chrome/log-memory.d.ts +1 -0
  71. package/dist/presenter/chrome/presenter-log.d.ts +7 -0
  72. package/dist/presenter/chrome/presenter-log.js +48 -8
  73. package/dist/presenter/chrome/presenter-morph.d.ts +1 -0
  74. package/dist/presenter/chrome/presenter-morph.js +34 -0
  75. package/dist/presenter/chrome/presenter-report-copy.d.ts +12 -4
  76. package/dist/presenter/chrome/presenter-report-copy.js +12 -4
  77. package/dist/presenter/hud-telemetry.js +55 -4
  78. package/dist/presenter/presenter-account-styles.js +3 -3
  79. package/dist/presenter/presenter-account.d.ts +27 -5
  80. package/dist/presenter/presenter-account.js +83 -6
  81. package/dist/presenter/presenter-agent-link.d.ts +9 -0
  82. package/dist/presenter/presenter-agent-link.js +62 -0
  83. package/dist/presenter/presenter-chat-views.d.ts +25 -4
  84. package/dist/presenter/presenter-chat-views.js +339 -72
  85. package/dist/presenter/presenter-config.d.ts +3 -0
  86. package/dist/presenter/presenter-config.js +2 -0
  87. package/dist/presenter/presenter-controls.d.ts +0 -1
  88. package/dist/presenter/presenter-controls.js +13 -91
  89. package/dist/presenter/presenter-design-tokens.js +1 -1
  90. package/dist/presenter/presenter-frame.d.ts +3 -2
  91. package/dist/presenter/presenter-frame.js +31 -24
  92. package/dist/presenter/presenter-harness-row.d.ts +56 -0
  93. package/dist/presenter/presenter-harness-row.js +170 -0
  94. package/dist/presenter/presenter-personas.d.ts +35 -0
  95. package/dist/presenter/presenter-personas.js +95 -0
  96. package/dist/presenter/presenter-plan.js +25 -24
  97. package/dist/presenter/presenter-report-styles.js +23 -22
  98. package/dist/presenter/presenter-report.js +29 -13
  99. package/dist/presenter/presenter-run-state.js +2 -1
  100. package/dist/presenter/presenter-settings-styles.js +8 -4
  101. package/dist/presenter/presenter-settings.d.ts +4 -4
  102. package/dist/presenter/presenter-settings.js +66 -28
  103. package/dist/presenter/presenter-shell-styles.js +12 -3
  104. package/dist/presenter/presenter-shell.d.ts +11 -3
  105. package/dist/presenter/presenter-shell.js +60 -22
  106. package/dist/presenter/presenter-styles.js +0 -2
  107. package/dist/presenter/presenter-workspace.js +10 -3
  108. package/dist/presenter/presenter.d.ts +3 -2
  109. package/dist/presenter/presenter.js +107 -25
  110. package/dist/registry/declared-platform.js +2 -0
  111. package/dist/registry/store-adapters.d.ts +5 -2
  112. package/dist/registry/store-adapters.js +40 -10
  113. package/dist/reticle-inject.js +771 -703
  114. package/dist/reticle.d.ts +4 -2
  115. package/dist/reticle.js +72 -8
  116. package/dist/security/page-location.d.ts +2 -0
  117. package/dist/security/page-location.js +7 -0
  118. package/dist/security/serialization.js +1 -1
  119. package/dist/storage-keys.d.ts +4 -2
  120. package/dist/storage-keys.js +8 -3
  121. package/dist/timers/native/native-timers.d.ts +1 -0
  122. package/dist/timers/native/native-timers.js +5 -0
  123. package/dist/transport/transport.d.ts +1 -0
  124. package/dist/transport/transport.js +23 -3
  125. package/dist/transport/unreachable-message.d.ts +1 -1
  126. package/dist/transport/unreachable-message.js +3 -5
  127. package/package.json +6 -2
  128. package/dist/presenter/carousel/offer-card.d.ts +0 -21
  129. package/dist/presenter/carousel/offer-card.js +0 -56
package/dist/dom/query.js CHANGED
@@ -1,13 +1,16 @@
1
1
  import { ALTERNATIVE_TESTID_ATTRS, DEFAULT_TESTID_ATTR, DATA_RETICLE_SOURCE_ATTR, ElementState, QueryBy, REDACTED_VALUE, TRANSPORT_LIMITS, } from '@reticlehq/core';
2
2
  import { isActionTarget, isElement, isFrame, isInput, isSelect, isTextArea } from './realm.js';
3
3
  import { capturedRootOf } from './shadow-registry.js';
4
- import { getAccessibleName, getRole, describe, getStates, isInViewport, isVisible, } from './a11y.js';
5
- import { isIgnored, isReticleOverlay } from './dom-ignore.js';
4
+ import { getAccessibleName, getRole, describe, getStates, isHiddenByAriaOnly, isInViewport, isRenderedOutsideAriaHidden, isVisible, } from './a11y.js';
5
+ import { dialogCovering, isIgnored, isReticleOverlay, isWordsIgnored } from './dom-ignore.js';
6
6
  import { isSensitiveKey } from '../security/serialization.js';
7
7
  import { declaredTestids } from '../registry/capabilities.js';
8
8
  import { identifyComponent } from '../registry/stores/adapters.js';
9
9
  import { refs } from './addressing/refs.js';
10
10
  import { getTestIdAttr, readTestId, testIdSelector } from './addressing/testid-attr.js';
11
+ import { stableSelectorFor } from './stable-selector.js';
12
+ import { exactVisibleText, fuzzyVisibleText, normaliseVisibleText, comparableText, foldTypography, paintedCase, textMatches, unreadableMatch, } from './text-match.js';
13
+ import { pageRoute } from '../security/page-location.js';
11
14
  const SOURCE_ATTR = DATA_RETICLE_SOURCE_ATTR;
12
15
  const MAX_PRESENT_TESTIDS = 12;
13
16
  const MAX_COMPONENT_CANDIDATES = 2000;
@@ -16,17 +19,6 @@ function elementsUnder(container) {
16
19
  const self = Node.ELEMENT_NODE === container.nodeType ? [container] : [];
17
20
  return [...self, ...Array.from(container.querySelectorAll('*'))];
18
21
  }
19
- function normaliseVisibleText(value) {
20
- return value.replace(/\s+/g, ' ').trim().normalize('NFC');
21
- }
22
- function fuzzyVisibleText(actual, expected) {
23
- return normaliseVisibleText(actual)
24
- .toLowerCase()
25
- .includes(normaliseVisibleText(expected).toLowerCase());
26
- }
27
- function exactVisibleText(actual, expected) {
28
- return normaliseVisibleText(actual) === normaliseVisibleText(expected);
29
- }
30
22
  const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
31
23
  function isNonRendered(el) {
32
24
  return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
@@ -45,17 +37,29 @@ function directText(el) {
45
37
  .join('');
46
38
  }
47
39
  function visibleTextOf(el, memo) {
48
- if (isNonRendered(el) || isIgnored(el) || !isVisible(el, memo))
40
+ if (isNonRendered(el) || isWordsIgnored(el))
49
41
  return '';
42
+ const visible = isVisible(el, memo);
43
+ const apart = laysOutApart(el);
50
44
  let text = '';
51
45
  for (const node of Array.from(el.childNodes)) {
52
- if (Node.TEXT_NODE === node.nodeType)
46
+ if (Node.TEXT_NODE === node.nodeType && visible)
53
47
  text += node.textContent ?? '';
54
- else if (isElement(node))
55
- text += visibleTextOf(node, memo);
48
+ else if (isElement(node)) {
49
+ const own = visibleTextOf(node, memo);
50
+ text += apart || ownBox(node) ? ` ${own} ` : own;
51
+ }
56
52
  }
57
53
  return text;
58
54
  }
55
+ function laysOutApart(el) {
56
+ const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? '';
57
+ return display.includes('flex') || display.includes('grid');
58
+ }
59
+ function ownBox(el) {
60
+ const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? 'inline';
61
+ return !display.startsWith('inline') && 'contents' !== display;
62
+ }
59
63
  function semanticNameTarget(el) {
60
64
  if (isInput(el) || isTextArea(el) || isSelect(el))
61
65
  return true;
@@ -122,15 +126,62 @@ function matchesRole(actual, queried) {
122
126
  return true;
123
127
  return false;
124
128
  }
129
+ const OPTION_ROLE = 'option';
130
+ const HTML_OPTION_TAG = 'option';
131
+ const HTML_SELECT_TAG = 'select';
132
+ const VISUALLY_HIDDEN_MAX_PX = 1;
133
+ export function isHiddenNativeOption(el) {
134
+ if (HTML_OPTION_TAG !== el.localName)
135
+ return false;
136
+ const select = el.closest(HTML_SELECT_TAG);
137
+ if (!isSelect(select))
138
+ return false;
139
+ if (!isVisible(select))
140
+ return true;
141
+ return isVisuallyHiddenBox(select);
142
+ }
143
+ function isVisuallyHiddenBox(select) {
144
+ const box = select.getBoundingClientRect();
145
+ return (box.width > 0 &&
146
+ box.height > 0 &&
147
+ box.width <= VISUALLY_HIDDEN_MAX_PX &&
148
+ box.height <= VISUALLY_HIDDEN_MAX_PX);
149
+ }
150
+ function preferVisibleActionableOption(role, matches) {
151
+ if (OPTION_ROLE !== role)
152
+ return matches;
153
+ const kept = matches.filter((el) => !isHiddenNativeOption(el));
154
+ return kept.length === matches.length ? matches : kept;
155
+ }
125
156
  function queryByRoleAndName(container, role, name) {
126
- return elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
157
+ const matched = elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
127
158
  (name === undefined || exactVisibleText(getAccessibleName(el), name)));
159
+ return preferVisibleActionableOption(role, matched);
128
160
  }
129
- function queryByText(container, value) {
130
- return elementsUnder(container).filter((el) => fuzzyVisibleText(directText(el), value));
161
+ function queryByText(container, value, query) {
162
+ return elementsUnder(container).filter((el) => {
163
+ const own = directText(el);
164
+ if (textMatches(own, query, value))
165
+ return true;
166
+ if (true !== query.matchCase || !fuzzyVisibleText(own, value))
167
+ return false;
168
+ const painted = paintedCase(el, own);
169
+ return painted !== undefined && textMatches(painted, query, value);
170
+ });
131
171
  }
132
172
  function queryByLabel(container, value) {
133
- return elementsUnder(container).filter((el) => semanticNameTarget(el) && fuzzyVisibleText(getAccessibleName(el), value));
173
+ const expected = comparableText(value).toLowerCase();
174
+ const exact = [];
175
+ const fuzzy = [];
176
+ for (const el of elementsUnder(container)) {
177
+ if (!semanticNameTarget(el))
178
+ continue;
179
+ const name = comparableText(getAccessibleName(el)).toLowerCase();
180
+ if (!name.includes(expected))
181
+ continue;
182
+ (name === expected ? exact : fuzzy).push(el);
183
+ }
184
+ return exact.length > 0 ? exact : fuzzy;
134
185
  }
135
186
  function queryByPlaceholder(container, value) {
136
187
  return elementsUnder(container).filter((el) => {
@@ -155,7 +206,7 @@ function findIn(container, query) {
155
206
  case QueryBy.ROLE:
156
207
  return queryByRoleAndName(container, value, query.name);
157
208
  case QueryBy.TEXT:
158
- return queryByText(container, value);
209
+ return queryByText(container, value, query);
159
210
  case QueryBy.LABEL:
160
211
  return queryByLabel(container, value);
161
212
  case QueryBy.PLACEHOLDER:
@@ -177,7 +228,7 @@ function findIn(container, query) {
177
228
  return queryByRoleAndName(container, query.role, query.name);
178
229
  }
179
230
  if (query.text !== undefined)
180
- return queryByText(container, query.text);
231
+ return queryByText(container, query.text, query);
181
232
  if (query.label !== undefined)
182
233
  return queryByLabel(container, query.label);
183
234
  if (query.placeholder !== undefined)
@@ -229,18 +280,19 @@ function findCandidates(query) {
229
280
  if (query.scope === undefined)
230
281
  return { candidates: [], scopeMissing };
231
282
  if (query.text !== undefined &&
232
- !fuzzyVisibleText(visibleTextOf(container, new Map()), query.text)) {
283
+ !textMatches(visibleTextOf(container, new Map()), query, query.text)) {
233
284
  return { candidates: [], scopeMissing };
234
285
  }
235
286
  return { candidates: isIgnored(container) ? [] : [container], scopeMissing };
236
287
  }
237
288
  const seen = new Set();
238
289
  const out = [];
290
+ const ignored = wantedTextOf(query) === undefined ? isIgnored : isWordsIgnored;
239
291
  const collect = (els) => {
240
292
  for (const el of els) {
241
293
  if (seen.has(el))
242
294
  continue;
243
- if (isIgnored(el))
295
+ if (ignored(el))
244
296
  continue;
245
297
  seen.add(el);
246
298
  out.push(el);
@@ -269,10 +321,13 @@ function inState(el, state, memo) {
269
321
  return getStates(el, isVisible(el, memo)).includes(state);
270
322
  }
271
323
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
272
- export function matchQuery(query, state, limit = MAX_DESCRIBED) {
324
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
273
325
  const found = findCandidates(query);
274
- const elements = found.candidates;
275
326
  const scopeMissing = found.scopeMissing;
327
+ const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
328
+ const scope = 0 === found.candidates.length ? resolveContainer(query.scope).container : null;
329
+ const owner = null === scope || wanted === undefined ? undefined : splitTextOwner(scope, wanted, query);
330
+ const elements = owner === undefined ? found.candidates : [owner];
276
331
  const visMemo = new Map();
277
332
  const filtered = state === undefined ? elements : elements.filter((el) => inState(el, state, visMemo));
278
333
  const attrs = query.attrs;
@@ -290,12 +345,16 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED) {
290
345
  const projected = projectAttrs(el, attrs);
291
346
  return projected === undefined ? base : { ...base, attrs: projected };
292
347
  });
348
+ const covered = dialogCovering(filtered);
349
+ const unreadable = wantedTextOf(query) === undefined ? undefined : unreadableMatch(described);
293
350
  return {
294
351
  matched: filtered.length > 0,
295
352
  count: filtered.length,
296
353
  elements: descriptors,
297
354
  ...(scopeMissing ? { scopeMissing: true } : {}),
298
- ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
355
+ ...(covered === undefined ? {} : { coveredBy: covered }),
356
+ ...(unreadable === undefined ? {} : { unreadable }),
357
+ ...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
299
358
  };
300
359
  }
301
360
  function resolveLabelledBy(el) {
@@ -363,14 +422,14 @@ function buildPresentRegions(query) {
363
422
  }
364
423
  return regions;
365
424
  }
366
- function splitTextOwner(container, wanted) {
425
+ function splitTextOwner(container, wanted, query) {
367
426
  let best;
368
427
  let bestDepth = -1;
369
428
  const memo = new Map();
370
429
  for (const el of elementsUnder(container)) {
371
430
  if (isIgnored(el))
372
431
  continue;
373
- if (!fuzzyVisibleText(visibleTextOf(el, memo), wanted))
432
+ if (!textMatches(visibleTextOf(el, memo), query, wanted))
374
433
  continue;
375
434
  let depth = 0;
376
435
  for (let parent = el.parentElement; parent !== null; parent = parent.parentElement)
@@ -382,6 +441,32 @@ function splitTextOwner(container, wanted) {
382
441
  }
383
442
  return best;
384
443
  }
444
+ function caseOnlyMatch(container, wanted) {
445
+ const target = comparableText(wanted).toLowerCase();
446
+ if (0 === target.length)
447
+ return undefined;
448
+ const memo = new Map();
449
+ for (const el of elementsUnder(container)) {
450
+ if (isIgnored(el))
451
+ continue;
452
+ const text = normaliseVisibleText(visibleTextOf(el, memo));
453
+ const at = foldTypography(text).toLowerCase().indexOf(target);
454
+ if (-1 !== at)
455
+ return text.slice(at, at + target.length);
456
+ }
457
+ return undefined;
458
+ }
459
+ const TEXT_ONLY_FIELDS = new Set([
460
+ 'text',
461
+ 'matchCase',
462
+ 'scope',
463
+ 'by',
464
+ 'value',
465
+ ]);
466
+ function textOnly(query) {
467
+ return ((query.by === undefined || QueryBy.TEXT === query.by) &&
468
+ Object.keys(query).every((key) => TEXT_ONLY_FIELDS.has(key)));
469
+ }
385
470
  function wantedTextOf(query) {
386
471
  if (query.text !== undefined)
387
472
  return query.text;
@@ -393,7 +478,7 @@ function nameNearMisses(container, query) {
393
478
  const wanted = query.name;
394
479
  if (role === undefined || wanted === undefined || 0 === wanted.length)
395
480
  return [];
396
- const target = normaliseVisibleText(wanted).toLowerCase();
481
+ const target = comparableText(wanted).toLowerCase();
397
482
  if (0 === target.length)
398
483
  return [];
399
484
  const out = [];
@@ -403,7 +488,7 @@ function nameNearMisses(container, query) {
403
488
  const name = normaliseVisibleText(getAccessibleName(el));
404
489
  if (0 === name.length)
405
490
  continue;
406
- const folded = name.toLowerCase();
491
+ const folded = foldTypography(name).toLowerCase();
407
492
  if (!folded.includes(target) && !target.includes(folded))
408
493
  continue;
409
494
  if (!out.includes(name))
@@ -426,7 +511,32 @@ function testidFoundUnder(container, query) {
426
511
  }
427
512
  return undefined;
428
513
  }
429
- function buildEmptyHint(query) {
514
+ function textInAriaHidden(container, wanted) {
515
+ let inAriaHidden = false;
516
+ for (const el of elementsUnder(container)) {
517
+ if (isIgnored(el))
518
+ continue;
519
+ if (!isHiddenByAriaOnly(el))
520
+ continue;
521
+ if (fuzzyVisibleText(el.textContent ?? '', wanted)) {
522
+ inAriaHidden = true;
523
+ break;
524
+ }
525
+ }
526
+ if (!inAriaHidden)
527
+ return false;
528
+ const memo = new Map();
529
+ for (const el of elementsUnder(container)) {
530
+ if (isIgnored(el))
531
+ continue;
532
+ if (!isRenderedOutsideAriaHidden(el))
533
+ continue;
534
+ if (fuzzyVisibleText(visibleTextOf(el, memo), wanted))
535
+ return false;
536
+ }
537
+ return true;
538
+ }
539
+ function buildEmptyHint(query, state, diagnose = true) {
430
540
  const container = resolveContainer(query.scope).container ?? document.body;
431
541
  const all = container.querySelectorAll(testIdSelector());
432
542
  const present = [];
@@ -443,7 +553,7 @@ function buildEmptyHint(query) {
443
553
  }
444
554
  const registered = declaredTestids();
445
555
  const knownEmptyState = present.some((id) => registered.includes(id));
446
- const route = `${location.pathname}${location.search}`;
556
+ const route = pageRoute(false);
447
557
  const hint = {
448
558
  route,
449
559
  presentTestids: present,
@@ -453,9 +563,24 @@ function buildEmptyHint(query) {
453
563
  };
454
564
  const wanted = wantedTextOf(query);
455
565
  if (wanted !== undefined) {
456
- const owner = splitTextOwner(container, wanted);
457
- if (owner !== undefined)
458
- hint.splitText = describe(owner);
566
+ const owner = splitTextOwner(container, wanted, query);
567
+ if (owner !== undefined) {
568
+ const described = describe(owner);
569
+ const selector = stableSelectorFor(owner);
570
+ hint.splitText = selector === undefined ? described : { ...described, selector };
571
+ }
572
+ else if (true === query.matchCase) {
573
+ const shown = caseOnlyMatch(container, wanted);
574
+ if (shown !== undefined)
575
+ hint.caseMiss = shown;
576
+ }
577
+ }
578
+ if (diagnose &&
579
+ wanted !== undefined &&
580
+ hint.splitText === undefined &&
581
+ (state === undefined || ElementState.VISIBLE === state || ElementState.IN_VIEWPORT === state)) {
582
+ if (textInAriaHidden(container, wanted))
583
+ hint.ariaHiddenMatch = true;
459
584
  }
460
585
  const near = nameNearMisses(container, query);
461
586
  if (near.length > 0)
@@ -469,7 +594,7 @@ export function elementsMatching(query) {
469
594
  return findCandidates(query).candidates;
470
595
  }
471
596
  export function runQuery(query, limit) {
472
- const result = matchQuery(query, undefined, limit);
597
+ const result = matchQuery(query, undefined, limit, false);
473
598
  const scopeFields = true === result.scopeMissing ? { scopeMissing: true } : {};
474
599
  if (0 === result.elements.length) {
475
600
  return {
@@ -0,0 +1,12 @@
1
+ export declare const MASK = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
2
+ export type RenderResult = {
3
+ ok: true;
4
+ width: number;
5
+ height: number;
6
+ chunks: string[];
7
+ } | {
8
+ ok: false;
9
+ reason: string;
10
+ };
11
+ export declare function cloneForRender(doc: Document): HTMLElement;
12
+ export declare function renderPage(): Promise<RenderResult>;
@@ -0,0 +1,135 @@
1
+ import { isSensitiveKey, STEP_SCREENSHOT } from '@reticlehq/core';
2
+ import { RETICLE_OVERLAY } from './dom-ignore.js';
3
+ export const MASK = '••••••••';
4
+ const BUDGET_CHARS = 240_000;
5
+ const CHUNK_CHARS = 60_000;
6
+ const SHRINK = 0.75;
7
+ const MAX_ATTEMPTS = 4;
8
+ const LOAD_TIMEOUT_MS = 5_000;
9
+ const SVG_NS = 'http://www.w3.org/2000/svg';
10
+ const FALLBACK_BACKGROUND = '#ffffff';
11
+ const JPEG_MIME = 'image/jpeg';
12
+ const DATA_URL_PREFIX = /^data:[^,]*,/;
13
+ function isSecretField(el) {
14
+ if (el instanceof HTMLInputElement && 'password' === el.type)
15
+ return true;
16
+ return [el.name, el.id, el.getAttribute('autocomplete') ?? ''].some((key) => key.length > 0 && isSensitiveKey(key));
17
+ }
18
+ function carryState(from, to) {
19
+ if (from instanceof HTMLInputElement || from instanceof HTMLTextAreaElement) {
20
+ const value = isSecretField(from) && from.value.length > 0 ? MASK : from.value;
21
+ if (from instanceof HTMLTextAreaElement)
22
+ to.textContent = value;
23
+ else
24
+ to.setAttribute('value', value);
25
+ }
26
+ if (from instanceof HTMLInputElement) {
27
+ if (from.checked)
28
+ to.setAttribute('checked', '');
29
+ else
30
+ to.removeAttribute('checked');
31
+ }
32
+ if (from instanceof HTMLOptionElement) {
33
+ if (from.selected)
34
+ to.setAttribute('selected', '');
35
+ else
36
+ to.removeAttribute('selected');
37
+ }
38
+ }
39
+ function readableCss(doc) {
40
+ const sheets = [...Array.from(doc.styleSheets), ...(doc.adoptedStyleSheets ?? [])];
41
+ const parts = [];
42
+ for (const sheet of sheets) {
43
+ try {
44
+ for (const rule of Array.from(sheet.cssRules))
45
+ parts.push(rule.cssText);
46
+ }
47
+ catch {
48
+ }
49
+ }
50
+ return parts.join('\n');
51
+ }
52
+ export function cloneForRender(doc) {
53
+ const root = doc.documentElement;
54
+ const clone = root.cloneNode(true);
55
+ const live = root.querySelectorAll('*');
56
+ const copied = clone.querySelectorAll('*');
57
+ live.forEach((el, i) => {
58
+ const twin = copied[i];
59
+ if (twin !== undefined)
60
+ carryState(el, twin);
61
+ });
62
+ const dropped = [RETICLE_OVERLAY, 'script', 'noscript', 'link[rel~="stylesheet"]', 'style'];
63
+ clone.querySelectorAll(dropped.join(',')).forEach((el) => el.remove());
64
+ const style = doc.createElement('style');
65
+ style.textContent = readableCss(doc);
66
+ (clone.querySelector('head') ?? clone).append(style);
67
+ return clone;
68
+ }
69
+ function backgroundOf(doc) {
70
+ const view = doc.defaultView;
71
+ for (const el of [doc.body, doc.documentElement]) {
72
+ if (null === view || null === el)
73
+ continue;
74
+ const colour = view.getComputedStyle(el).backgroundColor;
75
+ if (colour.length > 0 && 'transparent' !== colour && 'rgba(0, 0, 0, 0)' !== colour)
76
+ return colour;
77
+ }
78
+ return FALLBACK_BACKGROUND;
79
+ }
80
+ function loadImage(url) {
81
+ return new Promise((resolve, reject) => {
82
+ const img = new Image();
83
+ const timer = setTimeout(() => reject(new Error('the page did not render in time')), LOAD_TIMEOUT_MS);
84
+ img.onload = () => {
85
+ clearTimeout(timer);
86
+ resolve(img);
87
+ };
88
+ img.onerror = () => {
89
+ clearTimeout(timer);
90
+ reject(new Error('the page could not be drawn as an image'));
91
+ };
92
+ img.src = url;
93
+ });
94
+ }
95
+ function chunked(base64) {
96
+ const out = [];
97
+ for (let i = 0; i < base64.length; i += CHUNK_CHARS)
98
+ out.push(base64.slice(i, i + CHUNK_CHARS));
99
+ return out;
100
+ }
101
+ export async function renderPage() {
102
+ try {
103
+ const width = Math.max(1, window.innerWidth);
104
+ const height = Math.max(1, window.innerHeight);
105
+ const clone = cloneForRender(document);
106
+ clone.style.transform = `translate(${String(-window.scrollX)}px, ${String(-window.scrollY)}px)`;
107
+ const xhtml = new XMLSerializer().serializeToString(clone);
108
+ const svg = `<svg xmlns="${SVG_NS}" width="${String(width)}" height="${String(height)}">` +
109
+ `<foreignObject x="0" y="0" width="100%" height="100%">${xhtml}</foreignObject></svg>`;
110
+ const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
111
+ const background = backgroundOf(document);
112
+ let scale = Math.min(1, STEP_SCREENSHOT.MAX_SIDE_PX / Math.max(width, height));
113
+ for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1, scale *= SHRINK) {
114
+ const canvas = document.createElement('canvas');
115
+ canvas.width = Math.max(1, Math.round(width * scale));
116
+ canvas.height = Math.max(1, Math.round(height * scale));
117
+ const ctx = canvas.getContext('2d');
118
+ if (null === ctx)
119
+ return { ok: false, reason: 'this page has no 2D canvas to draw into' };
120
+ ctx.fillStyle = background;
121
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
122
+ ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
123
+ const base64 = canvas
124
+ .toDataURL(JPEG_MIME, STEP_SCREENSHOT.JPEG_QUALITY)
125
+ .replace(DATA_URL_PREFIX, '');
126
+ if (base64.length <= BUDGET_CHARS) {
127
+ return { ok: true, width: canvas.width, height: canvas.height, chunks: chunked(base64) };
128
+ }
129
+ }
130
+ return { ok: false, reason: 'the rendered page was too large to send, even scaled down' };
131
+ }
132
+ catch (error) {
133
+ return { ok: false, reason: error instanceof Error ? error.message : String(error) };
134
+ }
135
+ }
@@ -5,3 +5,4 @@ export declare function isCaptured(host: Element): boolean;
5
5
  export declare function capturedRootOf(host: Element): ShadowRoot | null;
6
6
  export declare function onShadowRoot(listener: (root: ShadowRoot) => void): () => void;
7
7
  export declare function installShadowRegistry(): () => void;
8
+ export declare function parentAcrossShadowBoundary(el: Element): Element | null;
@@ -93,3 +93,30 @@ export function installShadowRegistry() {
93
93
  listeners.clear();
94
94
  };
95
95
  }
96
+ function slotInCapturedClosedRoot(el) {
97
+ const host = el.parentElement;
98
+ if (null === host || null !== host.shadowRoot) {
99
+ return null;
100
+ }
101
+ const root = capturedRootOf(host);
102
+ if (null === root) {
103
+ return null;
104
+ }
105
+ for (const slot of Array.from(root.querySelectorAll('slot'))) {
106
+ if (slot.assignedElements().includes(el)) {
107
+ return slot;
108
+ }
109
+ }
110
+ return null;
111
+ }
112
+ export function parentAcrossShadowBoundary(el) {
113
+ const slot = el.assignedSlot ?? slotInCapturedClosedRoot(el);
114
+ if (null !== slot) {
115
+ return slot;
116
+ }
117
+ if (null !== el.parentElement) {
118
+ return el.parentElement;
119
+ }
120
+ const host = el.getRootNode().host;
121
+ return host ?? null;
122
+ }
@@ -1,4 +1,6 @@
1
1
  import { SnapshotMode } from '@reticlehq/core';
2
+ import { type LayoutFinding } from './layout-audit.js';
3
+ export declare const INTERACTIVE: Set<string>;
2
4
  interface SnapshotStatus {
3
5
  route: string;
4
6
  title: string;
@@ -11,6 +13,7 @@ interface SnapshotResult {
11
13
  nodes: number;
12
14
  truncated: boolean;
13
15
  scopeMissing?: boolean;
16
+ audit?: LayoutFinding[];
14
17
  leanSkipped?: number;
15
18
  hiddenSkipped?: number;
16
19
  domElements?: number;
@@ -23,6 +26,8 @@ interface SnapshotOptions {
23
26
  maxNodes?: number | undefined;
24
27
  maxDepth?: number | undefined;
25
28
  includeRoot?: boolean | undefined;
29
+ ids?: boolean | undefined;
30
+ audit?: boolean | undefined;
26
31
  }
27
32
  export declare const MAX_UNREAD_BRANCHES = 50;
28
33
  export declare function buildSnapshot(options?: SnapshotOptions): SnapshotResult;