@reticlehq/browser 3.7.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 (92) hide show
  1. package/dist/actions/action-report.d.ts +10 -0
  2. package/dist/actions/action-report.js +29 -2
  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 +35 -17
  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 +20 -5
  12. package/dist/activate.d.ts +14 -0
  13. package/dist/activate.js +43 -0
  14. package/dist/busy.d.ts +3 -0
  15. package/dist/busy.js +42 -0
  16. package/dist/commands/commands.js +45 -17
  17. package/dist/connect-options.d.ts +12 -1
  18. package/dist/connect-options.js +18 -1
  19. package/dist/dom/a11y.d.ts +2 -0
  20. package/dist/dom/a11y.js +191 -159
  21. package/dist/dom/chart.d.ts +3 -2
  22. package/dist/dom/chart.js +3 -2
  23. package/dist/dom/dom-ignore.d.ts +6 -0
  24. package/dist/dom/dom-ignore.js +43 -1
  25. package/dist/dom/layout-audit.d.ts +15 -0
  26. package/dist/dom/layout-audit.js +208 -0
  27. package/dist/dom/paint-context.d.ts +15 -0
  28. package/dist/dom/paint-context.js +78 -0
  29. package/dist/dom/query.d.ts +2 -1
  30. package/dist/dom/query.js +97 -34
  31. package/dist/dom/shadow-registry.d.ts +1 -0
  32. package/dist/dom/shadow-registry.js +27 -0
  33. package/dist/dom/snapshot.d.ts +5 -0
  34. package/dist/dom/snapshot.js +85 -34
  35. package/dist/dom/stable-selector.d.ts +1 -0
  36. package/dist/dom/stable-selector.js +19 -0
  37. package/dist/dom/text-match.d.ts +12 -0
  38. package/dist/dom/text-match.js +115 -0
  39. package/dist/dom/theme.d.ts +1 -0
  40. package/dist/dom/theme.js +21 -9
  41. package/dist/dom/virtualized.js +5 -1
  42. package/dist/index.d.ts +2 -1
  43. package/dist/index.js +5 -0
  44. package/dist/observers/blind-spots.js +3 -2
  45. package/dist/observers/canvas.d.ts +18 -0
  46. package/dist/observers/canvas.js +76 -0
  47. package/dist/observers/dom.js +66 -18
  48. package/dist/observers/field.d.ts +11 -0
  49. package/dist/observers/field.js +117 -13
  50. package/dist/observers/install-all.js +4 -0
  51. package/dist/observers/media.d.ts +2 -0
  52. package/dist/observers/media.js +32 -0
  53. package/dist/observers/net-detail/network-body.d.ts +2 -0
  54. package/dist/observers/net-detail/network-body.js +8 -1
  55. package/dist/observers/network.js +26 -6
  56. package/dist/observers/route.d.ts +2 -0
  57. package/dist/observers/route.js +33 -4
  58. package/dist/presenter/chrome/presenter-report-copy.d.ts +0 -5
  59. package/dist/presenter/chrome/presenter-report-copy.js +0 -14
  60. package/dist/presenter/hud-telemetry.js +1 -1
  61. package/dist/presenter/presenter-chat-views.d.ts +3 -4
  62. package/dist/presenter/presenter-chat-views.js +47 -20
  63. package/dist/presenter/presenter-config.d.ts +1 -0
  64. package/dist/presenter/presenter-config.js +1 -0
  65. package/dist/presenter/presenter-frame.d.ts +3 -2
  66. package/dist/presenter/presenter-frame.js +24 -15
  67. package/dist/presenter/presenter-harness-row.d.ts +4 -15
  68. package/dist/presenter/presenter-harness-row.js +11 -50
  69. package/dist/presenter/presenter-report-styles.js +0 -17
  70. package/dist/presenter/presenter-report.d.ts +1 -2
  71. package/dist/presenter/presenter-report.js +4 -54
  72. package/dist/presenter/presenter-run-state.js +2 -1
  73. package/dist/presenter/presenter-settings.d.ts +2 -3
  74. package/dist/presenter/presenter-settings.js +14 -17
  75. package/dist/presenter/presenter-shell.d.ts +6 -4
  76. package/dist/presenter/presenter-shell.js +37 -7
  77. package/dist/presenter/presenter.js +32 -11
  78. package/dist/registry/store-adapters.d.ts +5 -2
  79. package/dist/registry/store-adapters.js +40 -10
  80. package/dist/reticle-inject.js +602 -611
  81. package/dist/reticle.d.ts +4 -2
  82. package/dist/reticle.js +36 -6
  83. package/dist/security/page-location.d.ts +2 -0
  84. package/dist/security/page-location.js +7 -0
  85. package/dist/security/serialization.js +1 -1
  86. package/dist/storage-keys.d.ts +1 -0
  87. package/dist/storage-keys.js +1 -0
  88. package/dist/timers/native/native-timers.d.ts +1 -0
  89. package/dist/timers/native/native-timers.js +5 -0
  90. package/dist/transport/transport.d.ts +1 -0
  91. package/dist/transport/transport.js +18 -0
  92. package/package.json +6 -2
@@ -1,4 +1,5 @@
1
1
  import { RETICLE_OVERLAY_SELECTOR } from '@reticlehq/core';
2
+ import { capturedRootOf } from './shadow-registry.js';
2
3
  export const RETICLE_OVERLAY = RETICLE_OVERLAY_SELECTOR;
3
4
  const DEV_OVERLAYS = '[data-agentation],#__next-build-watcher,nextjs-portal,[data-nextjs-dialog],[data-nextjs-toast]';
4
5
  let extraIgnore = '';
@@ -29,12 +30,53 @@ export function isUnobserved(el) {
29
30
  export function isReticleUi(node) {
30
31
  return node !== null && node.closest(RETICLE_OVERLAY) !== null;
31
32
  }
32
- const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note]';
33
+ const HUD_NARRATION = '[data-reticle-log],[data-reticle-harness-log],[data-reticle-chat-pill-text],[data-reticle-agent-said],[data-reticle-agent-note],[data-reticle-act-line]';
33
34
  export function isIgnored(el) {
34
35
  const ours = exposed() ? HUD_NARRATION : RETICLE_OVERLAY;
35
36
  const sel = [ours, DEV_OVERLAYS, extraIgnore].filter((part) => part.length > 0).join(',');
36
37
  return el.closest(sel) !== null;
37
38
  }
39
+ export function isWordsIgnored(el) {
40
+ return isIgnored(el) || isReticleOverlay(el);
41
+ }
42
+ export const FrameworkErrorDialog = {
43
+ NEXT: 'Next.js error dialog',
44
+ VITE: 'Vite error overlay',
45
+ };
46
+ const NEXT_PORTAL = 'nextjs-portal';
47
+ const NEXT_DIALOG = '[data-nextjs-dialog]';
48
+ const VITE_OVERLAY = 'vite-error-overlay';
49
+ function openFrameworkErrorDialog(doc) {
50
+ const vite = doc.querySelector(VITE_OVERLAY);
51
+ if (vite !== null)
52
+ return { name: FrameworkErrorDialog.VITE, host: vite };
53
+ for (const portal of Array.from(doc.querySelectorAll(NEXT_PORTAL))) {
54
+ const root = portal.shadowRoot ?? capturedRootOf(portal);
55
+ if (null !== (root ?? portal).querySelector(NEXT_DIALOG)) {
56
+ return { name: FrameworkErrorDialog.NEXT, host: portal };
57
+ }
58
+ }
59
+ const light = doc.querySelector(NEXT_DIALOG);
60
+ return null === light ? undefined : { name: FrameworkErrorDialog.NEXT, host: light };
61
+ }
62
+ export function dialogCovering(matches) {
63
+ const first = matches[0];
64
+ if (first === undefined)
65
+ return undefined;
66
+ const dialog = openFrameworkErrorDialog(first.ownerDocument);
67
+ if (dialog === undefined)
68
+ return undefined;
69
+ return matches.some((el) => !isInsideHost(el, dialog.host)) ? dialog.name : undefined;
70
+ }
71
+ function isInsideHost(el, host) {
72
+ for (let node = el; node !== null;) {
73
+ if (node === host)
74
+ return true;
75
+ const root = node.getRootNode();
76
+ node = node.parentNode ?? (root instanceof ShadowRoot ? root.host : null);
77
+ }
78
+ return false;
79
+ }
38
80
  const APP_MODAL = 'dialog[open], [aria-modal="true"]';
39
81
  export function appModalOpen(doc) {
40
82
  for (const el of Array.from(doc.querySelectorAll(APP_MODAL))) {
@@ -0,0 +1,15 @@
1
+ export declare const LayoutDefect: {
2
+ readonly CLIPPED: "clipped";
3
+ readonly OVERLAP: "overlap";
4
+ readonly CONTRAST: "contrast";
5
+ };
6
+ export type LayoutDefect = (typeof LayoutDefect)[keyof typeof LayoutDefect];
7
+ export interface LayoutFinding {
8
+ kind: LayoutDefect;
9
+ ref: string;
10
+ what: string;
11
+ detail: string;
12
+ value: number;
13
+ bound: number;
14
+ }
15
+ export declare function auditLayout(root?: Element): LayoutFinding[];
@@ -0,0 +1,208 @@
1
+ import { refs } from './addressing/refs.js';
2
+ import { getAccessibleName } from './a11y.js';
3
+ import { isIgnored } from './dom-ignore.js';
4
+ export const LayoutDefect = {
5
+ CLIPPED: 'clipped',
6
+ OVERLAP: 'overlap',
7
+ CONTRAST: 'contrast',
8
+ };
9
+ const MAX_FINDINGS = 20;
10
+ const MAX_CONTROLS = 300;
11
+ const WHAT_CHARS = 60;
12
+ const SLACK_PX = 1;
13
+ const OVERLAP_SHARE = 0.25;
14
+ const MIN_CONTRAST = 4.5;
15
+ const MIN_CONTRAST_LARGE = 3;
16
+ const LARGE_PX = 24;
17
+ const LARGE_BOLD_PX = 18.66;
18
+ const BOLD_WEIGHT = 700;
19
+ const CONTROLS = 'button, a[href], input:not([type="hidden"]), select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"]';
20
+ const CLIPPED_AWAY = /rect\(\s*0(px)?[\s,]+0(px)?[\s,]+0(px)?[\s,]+0(px)?\s*\)|inset\(\s*50%/;
21
+ const MIN_BOX_PX = 4;
22
+ const shown = (el) => {
23
+ const rect = el.getBoundingClientRect();
24
+ if (rect.width < MIN_BOX_PX || rect.height < MIN_BOX_PX) {
25
+ return null;
26
+ }
27
+ const style = getComputedStyle(el);
28
+ if ('hidden' === style.visibility || 'none' === style.display || '0' === style.opacity) {
29
+ return null;
30
+ }
31
+ if (CLIPPED_AWAY.test(style.clip) || CLIPPED_AWAY.test(style.clipPath)) {
32
+ return null;
33
+ }
34
+ return rect;
35
+ };
36
+ const whatOf = (el) => (getAccessibleName(el) || (el.textContent ?? ''))
37
+ .replace(/\s+/g, ' ')
38
+ .trim()
39
+ .slice(0, WHAT_CHARS);
40
+ const ownText = (el) => [...el.childNodes]
41
+ .filter((n) => Node.TEXT_NODE === n.nodeType)
42
+ .map((n) => n.textContent ?? '')
43
+ .join('')
44
+ .trim();
45
+ const CUTS = new Set(['hidden', 'clip']);
46
+ const clipped = (el) => {
47
+ const style = getComputedStyle(el);
48
+ const wide = el.scrollWidth > el.clientWidth + SLACK_PX && CUTS.has(style.overflowX);
49
+ const tall = el.scrollHeight > el.clientHeight + SLACK_PX && CUTS.has(style.overflowY);
50
+ const declared = 'ellipsis' === style.textOverflow ||
51
+ ('' !== style.getPropertyValue('-webkit-line-clamp') &&
52
+ 'none' !== style.getPropertyValue('-webkit-line-clamp'));
53
+ if (declared || (!wide && !tall)) {
54
+ return null;
55
+ }
56
+ return wide
57
+ ? {
58
+ detail: `${String(el.scrollWidth)}px of text in a ${String(el.clientWidth)}px box, cut with no ellipsis`,
59
+ value: el.scrollWidth,
60
+ bound: el.clientWidth,
61
+ }
62
+ : {
63
+ detail: `${String(el.scrollHeight)}px of text in a ${String(el.clientHeight)}px box, cut with no line clamp`,
64
+ value: el.scrollHeight,
65
+ bound: el.clientHeight,
66
+ };
67
+ };
68
+ const RGB = /rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+%?))?\s*\)/;
69
+ const parse = (value) => {
70
+ const m = RGB.exec(value);
71
+ if (null === m) {
72
+ return null;
73
+ }
74
+ const alphaRaw = m[4];
75
+ let alpha = 1;
76
+ if (alphaRaw !== undefined) {
77
+ alpha = alphaRaw.endsWith('%') ? Number(alphaRaw.slice(0, -1)) / 100 : Number(alphaRaw);
78
+ }
79
+ return [Number(m[1]), Number(m[2]), Number(m[3]), alpha];
80
+ };
81
+ const luminance = ([r, g, b]) => {
82
+ const lin = (c) => {
83
+ const s = c / 255;
84
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
85
+ };
86
+ return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
87
+ };
88
+ const over = (top, under) => {
89
+ const a = top[3];
90
+ return [
91
+ top[0] * a + under[0] * (1 - a),
92
+ top[1] * a + under[1] * (1 - a),
93
+ top[2] * a + under[2] * (1 - a),
94
+ 1,
95
+ ];
96
+ };
97
+ const backdrop = (el) => {
98
+ const layers = [];
99
+ for (let at = el; null !== at; at = at.parentElement) {
100
+ const style = getComputedStyle(at);
101
+ if ('none' !== style.backgroundImage && '' !== style.backgroundImage) {
102
+ return null;
103
+ }
104
+ const colour = parse(style.backgroundColor);
105
+ if (null !== colour && colour[3] > 0) {
106
+ layers.push(colour);
107
+ if (1 === colour[3]) {
108
+ break;
109
+ }
110
+ }
111
+ }
112
+ return layers.reduceRight((under, top) => over(top, under), [255, 255, 255, 1]);
113
+ };
114
+ const contrastOf = (el) => {
115
+ const style = getComputedStyle(el);
116
+ const fg = parse(style.color);
117
+ const bg = backdrop(el);
118
+ if (null === fg || null === bg) {
119
+ return null;
120
+ }
121
+ const text = over(fg, bg);
122
+ const [hi, lo] = [luminance(text), luminance(bg)].sort((a, b) => b - a);
123
+ const size = Number.parseFloat(style.fontSize);
124
+ const weight = Number.parseInt(style.fontWeight, 10);
125
+ const large = size >= LARGE_PX || (size >= LARGE_BOLD_PX && weight >= BOLD_WEIGHT);
126
+ return { ratio: (hi + 0.05) / (lo + 0.05), min: large ? MIN_CONTRAST_LARGE : MIN_CONTRAST };
127
+ };
128
+ const seenAt = (x, y, ...els) => {
129
+ if ('function' !== typeof document.elementFromPoint) {
130
+ return true;
131
+ }
132
+ const top = document.elementFromPoint(x, y);
133
+ return null !== top && els.some((el) => el === top || el.contains(top));
134
+ };
135
+ const INSET_PX = 2;
136
+ const visible = (el, r) => [
137
+ [r.left + r.width / 2, r.top + r.height / 2],
138
+ [r.left + INSET_PX, r.top + INSET_PX],
139
+ [r.right - INSET_PX, r.top + INSET_PX],
140
+ [r.left + INSET_PX, r.bottom - INSET_PX],
141
+ [r.right - INSET_PX, r.bottom - INSET_PX],
142
+ ].some(([x = 0, y = 0]) => seenAt(x, y, el));
143
+ const share = (a, b) => {
144
+ const w = Math.min(a.right, b.right) - Math.max(a.left, b.left);
145
+ const h = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top);
146
+ if (w <= 0 || h <= 0) {
147
+ return 0;
148
+ }
149
+ return (w * h) / Math.min(a.width * a.height, b.width * b.height);
150
+ };
151
+ export function auditLayout(root = document.body) {
152
+ const found = [];
153
+ const add = (kind, el, measured) => {
154
+ if (found.length < MAX_FINDINGS) {
155
+ found.push({ kind, ref: refs.refFor(el), what: whatOf(el), ...measured });
156
+ }
157
+ };
158
+ for (const el of root.querySelectorAll('*')) {
159
+ if (found.length >= MAX_FINDINGS) {
160
+ break;
161
+ }
162
+ if (isIgnored(el) || '' === ownText(el)) {
163
+ continue;
164
+ }
165
+ const rect = shown(el);
166
+ if (null === rect || !seenAt(rect.left + rect.width / 2, rect.top + rect.height / 2, el)) {
167
+ continue;
168
+ }
169
+ const cut = clipped(el);
170
+ if (null !== cut) {
171
+ add(LayoutDefect.CLIPPED, el, cut);
172
+ }
173
+ const contrast = contrastOf(el);
174
+ if (null !== contrast && contrast.ratio < contrast.min) {
175
+ add(LayoutDefect.CONTRAST, el, {
176
+ detail: `contrast ${contrast.ratio.toFixed(2)}:1, under ${String(contrast.min)}:1`,
177
+ value: Math.round(contrast.ratio * 100) / 100,
178
+ bound: contrast.min,
179
+ });
180
+ }
181
+ }
182
+ const controls = [...root.querySelectorAll(CONTROLS)]
183
+ .filter((el) => !isIgnored(el))
184
+ .slice(0, MAX_CONTROLS)
185
+ .flatMap((el) => {
186
+ const rect = shown(el);
187
+ return null === rect ? [] : [{ el, rect }];
188
+ });
189
+ for (const [i, a] of controls.entries()) {
190
+ for (const b of controls.slice(i + 1)) {
191
+ if (found.length >= MAX_FINDINGS) {
192
+ return found;
193
+ }
194
+ if (a.el.contains(b.el) || b.el.contains(a.el)) {
195
+ continue;
196
+ }
197
+ const covered = share(a.rect, b.rect);
198
+ if (covered >= OVERLAP_SHARE && visible(a.el, a.rect) && visible(b.el, b.rect)) {
199
+ add(LayoutDefect.OVERLAP, a.el, {
200
+ detail: `drawn over "${whatOf(b.el)}" (${refs.refFor(b.el)})`,
201
+ value: Math.round(covered * 100) / 100,
202
+ bound: OVERLAP_SHARE,
203
+ });
204
+ }
205
+ }
206
+ }
207
+ return found;
208
+ }
@@ -4,3 +4,18 @@ export interface PaintContextEntry {
4
4
  readonly value: string;
5
5
  }
6
6
  export declare function paintContextOf(el: Element): PaintContextEntry[];
7
+ export type VisibleBounds = Pick<DOMRect, 'left' | 'right' | 'top' | 'bottom' | 'width' | 'height'>;
8
+ export interface VisibilityInfo {
9
+ hidden: boolean;
10
+ style: CSSStyleDeclaration | null;
11
+ parent: Element | null;
12
+ clipX: boolean;
13
+ clipY: boolean;
14
+ scrollX: boolean;
15
+ scrollY: boolean;
16
+ hasClipping: boolean;
17
+ clipBox?: VisibleBounds;
18
+ }
19
+ export declare function overflowAxes(style: CSSStyleDeclaration | null): [string, string];
20
+ export declare function clippingBox(el: Element, info: VisibilityInfo): VisibleBounds;
21
+ export declare function scrolledInto(b: VisibleBounds, port: VisibleBounds, alongX: boolean, alongY: boolean): VisibleBounds;
@@ -1,3 +1,4 @@
1
+ import { isHtmlElement } from './realm.js';
1
2
  const PAINT_PROPERTIES = [
2
3
  'filter',
3
4
  'opacity',
@@ -36,3 +37,80 @@ export function paintContextOf(el) {
36
37
  }
37
38
  return found;
38
39
  }
40
+ export function overflowAxes(style) {
41
+ const shorthand = style?.overflow ?? '';
42
+ const [x = 'visible', y = x] = ('' === shorthand ? 'visible' : shorthand).split(/\s+/);
43
+ return [
44
+ null === style || '' === style.overflowX ? x : style.overflowX,
45
+ null === style || '' === style.overflowY ? y : style.overflowY,
46
+ ];
47
+ }
48
+ export function clippingBox(el, info) {
49
+ if (info.clipBox !== undefined) {
50
+ return info.clipBox;
51
+ }
52
+ const rect = el.getBoundingClientRect();
53
+ const style = info.style;
54
+ const px = (value) => Number.parseFloat(value ?? '') || 0;
55
+ const border = (value, kind) => kind === undefined || '' === kind || 'none' === kind || 'hidden' === kind ? 0 : px(value);
56
+ const scaleX = isHtmlElement(el) && el.offsetWidth > 0 ? rect.width / el.offsetWidth : 1;
57
+ const scaleY = isHtmlElement(el) && el.offsetHeight > 0 ? rect.height / el.offsetHeight : 1;
58
+ const left = isHtmlElement(el) && el.offsetWidth > 0
59
+ ? rect.left + el.clientLeft * scaleX
60
+ : rect.left + border(style?.borderLeftWidth, style?.borderLeftStyle);
61
+ const top = isHtmlElement(el) && el.offsetHeight > 0
62
+ ? rect.top + el.clientTop * scaleY
63
+ : rect.top + border(style?.borderTopWidth, style?.borderTopStyle);
64
+ const right = isHtmlElement(el) && el.offsetWidth > 0
65
+ ? left + el.clientWidth * scaleX
66
+ : rect.right - border(style?.borderRightWidth, style?.borderRightStyle);
67
+ const bottom = isHtmlElement(el) && el.offsetHeight > 0
68
+ ? top + el.clientHeight * scaleY
69
+ : rect.bottom - border(style?.borderBottomWidth, style?.borderBottomStyle);
70
+ const margin = (style?.overflowClipMargin ?? '').split(/\s+/);
71
+ const outset = px(margin.at(-1));
72
+ const borderBox = margin.includes('border-box');
73
+ const contentBox = margin.includes('content-box');
74
+ const [x, y] = overflowAxes(style);
75
+ const clipLeft = 'clip' === x
76
+ ? (borderBox ? rect.left : left + (contentBox ? px(style?.paddingLeft) * scaleX : 0)) -
77
+ outset * scaleX
78
+ : left;
79
+ const clipRight = 'clip' === x
80
+ ? (borderBox ? rect.right : right - (contentBox ? px(style?.paddingRight) * scaleX : 0)) +
81
+ outset * scaleX
82
+ : right;
83
+ const clipTop = 'clip' === y
84
+ ? (borderBox ? rect.top : top + (contentBox ? px(style?.paddingTop) * scaleY : 0)) -
85
+ outset * scaleY
86
+ : top;
87
+ const clipBottom = 'clip' === y
88
+ ? (borderBox ? rect.bottom : bottom - (contentBox ? px(style?.paddingBottom) * scaleY : 0)) +
89
+ outset * scaleY
90
+ : bottom;
91
+ info.clipBox = {
92
+ left: clipLeft,
93
+ right: clipRight,
94
+ top: clipTop,
95
+ bottom: clipBottom,
96
+ width: clipRight - clipLeft,
97
+ height: clipBottom - clipTop,
98
+ };
99
+ return info.clipBox;
100
+ }
101
+ export function scrolledInto(b, port, alongX, alongY) {
102
+ const axis = (lo, hi, size, from, to) => {
103
+ if (hi > from && lo < to) {
104
+ return [lo, hi];
105
+ }
106
+ const span = Math.min(size, to - from);
107
+ return lo >= to ? [to - span, to] : [from, from + span];
108
+ };
109
+ const [left, right] = alongX
110
+ ? axis(b.left, b.right, b.width, port.left, port.right)
111
+ : [b.left, b.right];
112
+ const [top, bottom] = alongY
113
+ ? axis(b.top, b.bottom, b.height, port.top, port.bottom)
114
+ : [b.top, b.bottom];
115
+ return { left, right, top, bottom, width: right - left, height: bottom - top };
116
+ }
@@ -1,5 +1,6 @@
1
1
  import { ElementState, type ElementQuery, type MatchResult, type QueryResult } from '@reticlehq/core';
2
+ export declare function isHiddenNativeOption(el: HTMLElement): boolean;
2
3
  export declare const ATTR_VALUE_MAX = 512;
3
- export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean): MatchResult;
4
+ export declare function matchQuery(query: ElementQuery, state?: ElementState, limit?: number, wholePhrase?: boolean, diagnose?: boolean): MatchResult;
4
5
  export declare function elementsMatching(query: ElementQuery): HTMLElement[];
5
6
  export declare function runQuery(query: ElementQuery, limit?: number): QueryResult;
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,22 +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 textMatches(actual, query, expected) {
28
- if (true !== query.matchCase)
29
- return fuzzyVisibleText(actual, expected);
30
- return normaliseVisibleText(actual).includes(normaliseVisibleText(expected));
31
- }
32
- function exactVisibleText(actual, expected) {
33
- return normaliseVisibleText(actual) === normaliseVisibleText(expected);
34
- }
35
22
  const NON_RENDERED_TAGS = ['script', 'style', 'template', 'noscript'];
36
23
  function isNonRendered(el) {
37
24
  return NON_RENDERED_TAGS.includes(el.tagName.toLowerCase());
@@ -50,7 +37,7 @@ function directText(el) {
50
37
  .join('');
51
38
  }
52
39
  function visibleTextOf(el, memo) {
53
- if (isNonRendered(el) || isIgnored(el))
40
+ if (isNonRendered(el) || isWordsIgnored(el))
54
41
  return '';
55
42
  const visible = isVisible(el, memo);
56
43
  const apart = laysOutApart(el);
@@ -139,21 +126,57 @@ function matchesRole(actual, queried) {
139
126
  return true;
140
127
  return false;
141
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
+ }
142
156
  function queryByRoleAndName(container, role, name) {
143
- return elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
157
+ const matched = elementsUnder(container).filter((el) => matchesRole(getRole(el), role) &&
144
158
  (name === undefined || exactVisibleText(getAccessibleName(el), name)));
159
+ return preferVisibleActionableOption(role, matched);
145
160
  }
146
161
  function queryByText(container, value, query) {
147
- return elementsUnder(container).filter((el) => textMatches(directText(el), query, value));
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
+ });
148
171
  }
149
172
  function queryByLabel(container, value) {
150
- const expected = normaliseVisibleText(value).toLowerCase();
173
+ const expected = comparableText(value).toLowerCase();
151
174
  const exact = [];
152
175
  const fuzzy = [];
153
176
  for (const el of elementsUnder(container)) {
154
177
  if (!semanticNameTarget(el))
155
178
  continue;
156
- const name = normaliseVisibleText(getAccessibleName(el)).toLowerCase();
179
+ const name = comparableText(getAccessibleName(el)).toLowerCase();
157
180
  if (!name.includes(expected))
158
181
  continue;
159
182
  (name === expected ? exact : fuzzy).push(el);
@@ -264,11 +287,12 @@ function findCandidates(query) {
264
287
  }
265
288
  const seen = new Set();
266
289
  const out = [];
290
+ const ignored = wantedTextOf(query) === undefined ? isIgnored : isWordsIgnored;
267
291
  const collect = (els) => {
268
292
  for (const el of els) {
269
293
  if (seen.has(el))
270
294
  continue;
271
- if (isIgnored(el))
295
+ if (ignored(el))
272
296
  continue;
273
297
  seen.add(el);
274
298
  out.push(el);
@@ -297,7 +321,7 @@ function inState(el, state, memo) {
297
321
  return getStates(el, isVisible(el, memo)).includes(state);
298
322
  }
299
323
  const MAX_DESCRIBED = TRANSPORT_LIMITS.MAX_COLLECTION_ITEMS;
300
- export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true) {
324
+ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = true, diagnose = true) {
301
325
  const found = findCandidates(query);
302
326
  const scopeMissing = found.scopeMissing;
303
327
  const wanted = wholePhrase && textOnly(query) ? wantedTextOf(query) : undefined;
@@ -321,12 +345,16 @@ export function matchQuery(query, state, limit = MAX_DESCRIBED, wholePhrase = tr
321
345
  const projected = projectAttrs(el, attrs);
322
346
  return projected === undefined ? base : { ...base, attrs: projected };
323
347
  });
348
+ const covered = dialogCovering(filtered);
349
+ const unreadable = wantedTextOf(query) === undefined ? undefined : unreadableMatch(described);
324
350
  return {
325
351
  matched: filtered.length > 0,
326
352
  count: filtered.length,
327
353
  elements: descriptors,
328
354
  ...(scopeMissing ? { scopeMissing: true } : {}),
329
- ...(0 === filtered.length ? { hint: buildEmptyHint(query) } : {}),
355
+ ...(covered === undefined ? {} : { coveredBy: covered }),
356
+ ...(unreadable === undefined ? {} : { unreadable }),
357
+ ...(0 === filtered.length ? { hint: buildEmptyHint(query, state, diagnose) } : {}),
330
358
  };
331
359
  }
332
360
  function resolveLabelledBy(el) {
@@ -414,7 +442,7 @@ function splitTextOwner(container, wanted, query) {
414
442
  return best;
415
443
  }
416
444
  function caseOnlyMatch(container, wanted) {
417
- const target = normaliseVisibleText(wanted).toLowerCase();
445
+ const target = comparableText(wanted).toLowerCase();
418
446
  if (0 === target.length)
419
447
  return undefined;
420
448
  const memo = new Map();
@@ -422,7 +450,7 @@ function caseOnlyMatch(container, wanted) {
422
450
  if (isIgnored(el))
423
451
  continue;
424
452
  const text = normaliseVisibleText(visibleTextOf(el, memo));
425
- const at = text.toLowerCase().indexOf(target);
453
+ const at = foldTypography(text).toLowerCase().indexOf(target);
426
454
  if (-1 !== at)
427
455
  return text.slice(at, at + target.length);
428
456
  }
@@ -450,7 +478,7 @@ function nameNearMisses(container, query) {
450
478
  const wanted = query.name;
451
479
  if (role === undefined || wanted === undefined || 0 === wanted.length)
452
480
  return [];
453
- const target = normaliseVisibleText(wanted).toLowerCase();
481
+ const target = comparableText(wanted).toLowerCase();
454
482
  if (0 === target.length)
455
483
  return [];
456
484
  const out = [];
@@ -460,7 +488,7 @@ function nameNearMisses(container, query) {
460
488
  const name = normaliseVisibleText(getAccessibleName(el));
461
489
  if (0 === name.length)
462
490
  continue;
463
- const folded = name.toLowerCase();
491
+ const folded = foldTypography(name).toLowerCase();
464
492
  if (!folded.includes(target) && !target.includes(folded))
465
493
  continue;
466
494
  if (!out.includes(name))
@@ -483,7 +511,32 @@ function testidFoundUnder(container, query) {
483
511
  }
484
512
  return undefined;
485
513
  }
486
- 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) {
487
540
  const container = resolveContainer(query.scope).container ?? document.body;
488
541
  const all = container.querySelectorAll(testIdSelector());
489
542
  const present = [];
@@ -500,7 +553,7 @@ function buildEmptyHint(query) {
500
553
  }
501
554
  const registered = declaredTestids();
502
555
  const knownEmptyState = present.some((id) => registered.includes(id));
503
- const route = `${location.pathname}${location.search}`;
556
+ const route = pageRoute(false);
504
557
  const hint = {
505
558
  route,
506
559
  presentTestids: present,
@@ -511,14 +564,24 @@ function buildEmptyHint(query) {
511
564
  const wanted = wantedTextOf(query);
512
565
  if (wanted !== undefined) {
513
566
  const owner = splitTextOwner(container, wanted, query);
514
- if (owner !== undefined)
515
- hint.splitText = describe(owner);
567
+ if (owner !== undefined) {
568
+ const described = describe(owner);
569
+ const selector = stableSelectorFor(owner);
570
+ hint.splitText = selector === undefined ? described : { ...described, selector };
571
+ }
516
572
  else if (true === query.matchCase) {
517
573
  const shown = caseOnlyMatch(container, wanted);
518
574
  if (shown !== undefined)
519
575
  hint.caseMiss = shown;
520
576
  }
521
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;
584
+ }
522
585
  const near = nameNearMisses(container, query);
523
586
  if (near.length > 0)
524
587
  hint.nameNearMiss = near;
@@ -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;