@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
@@ -1,10 +1,14 @@
1
1
  import { ElementState, SnapshotMode } from '@reticlehq/core';
2
2
  import { capturedRootOf } from './shadow-registry.js';
3
- import { isFrame } from './realm.js';
3
+ import { isElement, isFrame } from './realm.js';
4
4
  import { getAccessibleName, getRole, getStates, getValue, isVisible } from './a11y.js';
5
5
  import { refs } from './addressing/refs.js';
6
+ import { readTestId } from './addressing/testid-attr.js';
7
+ import { identifyComponent } from '../registry/stores/adapters.js';
8
+ import { auditLayout } from './layout-audit.js';
9
+ import { pageRoute } from '../security/page-location.js';
6
10
  import { isIgnored, isReticleOverlay } from './dom-ignore.js';
7
- const INTERACTIVE = new Set([
11
+ export const INTERACTIVE = new Set([
8
12
  'button',
9
13
  'link',
10
14
  'textbox',
@@ -23,32 +27,70 @@ const INTERACTIVE = new Set([
23
27
  const SKIP_TAGS = new Set(['script', 'style', 'noscript', 'template', 'head', 'meta', 'link']);
24
28
  const ANNOUNCE_ROLES = new Set(['alert', 'status']);
25
29
  function announces(el, role) {
26
- if (ANNOUNCE_ROLES.has(role))
30
+ if (ANNOUNCE_ROLES.has(role)) {
27
31
  return true;
32
+ }
28
33
  const live = el.getAttribute('aria-live');
29
34
  return live !== null && 'off' !== live;
30
35
  }
31
36
  const TEXT_MAX = 80;
32
37
  function directText(el) {
33
- let out = '';
38
+ const parts = readingParts(el);
39
+ const collapsed = (parts.own ? parts.text : '').replace(/\s+/g, ' ').trim();
40
+ return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
41
+ }
42
+ function readingParts(el) {
43
+ let text = '';
44
+ let own = false;
34
45
  for (const node of el.childNodes) {
35
- if (3 === node.nodeType)
36
- out += node.textContent ?? '';
46
+ if (3 === node.nodeType) {
47
+ text += node.textContent ?? '';
48
+ own ||= 0 < (node.textContent ?? '').trim().length;
49
+ }
50
+ else if (isElement(node) && foldsIntoParent(node)) {
51
+ const inner = readingParts(node);
52
+ text += inner.text;
53
+ own ||= inner.own;
54
+ }
55
+ else if (isElement(node) && isInlineIn(node) && INTERACTIVE.has(getRole(node))) {
56
+ text += getAccessibleName(node);
57
+ }
37
58
  }
38
- const collapsed = out.replace(/\s+/g, ' ').trim();
39
- return collapsed.length > TEXT_MAX ? `${collapsed.slice(0, TEXT_MAX)}…` : collapsed;
59
+ return { text, own };
60
+ }
61
+ function isInlineIn(el) {
62
+ const view = el.ownerDocument.defaultView;
63
+ const parent = el.parentElement;
64
+ if (null === view || null === parent) {
65
+ return false;
66
+ }
67
+ const around = view.getComputedStyle(parent).display;
68
+ if (around.includes('flex') || around.includes('grid')) {
69
+ return false;
70
+ }
71
+ return 'inline' === view.getComputedStyle(el).display;
72
+ }
73
+ function foldsIntoParent(el) {
74
+ if (skipEarly(el) || !isInlineIn(el)) {
75
+ return false;
76
+ }
77
+ const role = getRole(el);
78
+ return !INTERACTIVE.has(role) && !announces(el, role) && 0 === getAccessibleName(el).length;
40
79
  }
41
80
  export const MAX_UNREAD_BRANCHES = 50;
42
81
  function hiddenByMarkup(el) {
43
- if ('true' === el.getAttribute('aria-hidden'))
82
+ if ('true' === el.getAttribute('aria-hidden')) {
44
83
  return true;
84
+ }
45
85
  return el instanceof HTMLElement && el.hidden;
46
86
  }
47
87
  function skipEarly(el) {
48
- if (SKIP_TAGS.has(el.tagName.toLowerCase()))
88
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) {
49
89
  return true;
50
- if (isIgnored(el))
90
+ }
91
+ if (isIgnored(el)) {
51
92
  return true;
93
+ }
52
94
  return hiddenByMarkup(el);
53
95
  }
54
96
  function stateSuffix(el) {
@@ -59,7 +101,7 @@ function stateSuffix(el) {
59
101
  s === ElementState.FOCUSED);
60
102
  return states.length > 0 ? ` [${states.join(',')}]` : '';
61
103
  }
62
- function formatLine(el, depth, role, name, layout, ownText = '') {
104
+ function formatLine(el, depth, role, name, layout, ownText = '', withTestId = false) {
63
105
  const indent = ' '.repeat(depth);
64
106
  const value = getValue(el);
65
107
  const label = name;
@@ -68,14 +110,27 @@ function formatLine(el, depth, role, name, layout, ownText = '') {
68
110
  const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
69
111
  const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
70
112
  const textPart = ownText.length > 0 ? ` [text="${ownText}"]` : '';
71
- return `${indent}- ${role}${namePart}${refPart}${valuePart}${textPart}${layoutPart}${stateSuffix(el)}`;
113
+ const testid = withTestId ? readTestId(el) : null;
114
+ const info = withTestId ? identifyComponent(el) : null;
115
+ const component = null === testid ? info?.componentStack[0] : undefined;
116
+ const where = info?.source;
117
+ let who = '';
118
+ if (null !== testid) {
119
+ who = ` [testid=${testid}]`;
120
+ }
121
+ else if (component !== undefined) {
122
+ who = ` [component=${component}]`;
123
+ }
124
+ const idPart = `${who}${where === undefined ? '' : ` [src=${where.file}:${String(where.line)}]`}`;
125
+ return `${indent}- ${role}${namePart}${refPart}${valuePart}${textPart}${layoutPart}${stateSuffix(el)}${idPart}`;
72
126
  }
73
127
  function formatTextLine(depth, text) {
74
128
  return `${' '.repeat(depth)}- text "${text}"`;
75
129
  }
76
130
  function layoutSignature(style) {
77
- if (null === style)
131
+ if (null === style) {
78
132
  return '';
133
+ }
79
134
  const display = style.display;
80
135
  if ('grid' === display || 'inline-grid' === display) {
81
136
  const cols = style.gridTemplateColumns;
@@ -83,6 +138,7 @@ function layoutSignature(style) {
83
138
  }
84
139
  return '';
85
140
  }
141
+ const overlayStamped = (el) => el.hasAttribute('data-aria-hidden') && 'true' === el.getAttribute('aria-hidden');
86
142
  function recordUnread(branches, ctx) {
87
143
  for (const branch of branches) {
88
144
  if (ctx.unread.length >= MAX_UNREAD_BRANCHES) {
@@ -95,13 +151,15 @@ function recordUnread(branches, ctx) {
95
151
  function pierceChildren(parent) {
96
152
  const out = [...parent.children];
97
153
  const shadow = parent.shadowRoot ?? capturedRootOf(parent);
98
- if (shadow !== null)
154
+ if (shadow !== null) {
99
155
  out.push(...shadow.children);
156
+ }
100
157
  if (isFrame(parent)) {
101
158
  try {
102
159
  const body = parent.contentDocument?.body;
103
- if (body !== null && body !== undefined)
160
+ if (body !== null && body !== undefined) {
104
161
  out.push(...body.children);
162
+ }
105
163
  }
106
164
  catch {
107
165
  }
@@ -109,9 +167,14 @@ function pierceChildren(parent) {
109
167
  return out;
110
168
  }
111
169
  function visit(child, depth, ctx, inLive) {
112
- if (skipEarly(child)) {
113
- if (hiddenByMarkup(child))
170
+ const stampedOnly = true === ctx.throughOverlay &&
171
+ overlayStamped(child) &&
172
+ !isIgnored(child) &&
173
+ !(child instanceof HTMLElement && child.hidden);
174
+ if (!stampedOnly && skipEarly(child)) {
175
+ if (hiddenByMarkup(child)) {
114
176
  ctx.hiddenSkipped += 1;
177
+ }
115
178
  return;
116
179
  }
117
180
  const view = child.ownerDocument.defaultView;
@@ -120,6 +183,10 @@ function visit(child, depth, ctx, inLive) {
120
183
  ctx.hiddenSkipped += 1;
121
184
  return;
122
185
  }
186
+ if (style !== null && 'inline' === style.display && !inLive && foldsIntoParent(child)) {
187
+ walk(child, depth, ctx, inLive);
188
+ return;
189
+ }
123
190
  const role = getRole(child);
124
191
  const name = getAccessibleName(child);
125
192
  const interactive = INTERACTIVE.has(role);
@@ -130,13 +197,14 @@ function visit(child, depth, ctx, inLive) {
130
197
  const actionable = interactive;
131
198
  const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
132
199
  const include = lean ? actionable : meaningful;
133
- if (lean && !include && meaningful)
200
+ if (lean && !include && meaningful) {
134
201
  ctx.leanSkipped += 1;
202
+ }
135
203
  if (include) {
136
204
  ctx.nodes += 1;
137
205
  ctx.lines.push('generic' === role && text.length > 0 && 0 === layout.length
138
206
  ? formatTextLine(depth, text)
139
- : formatLine(child, depth, role, name, layout, text));
207
+ : formatLine(child, depth, role, name, layout, text, true === ctx.ids));
140
208
  walk(child, depth + 1, ctx, announce);
141
209
  }
142
210
  else {
@@ -159,30 +227,36 @@ function walk(parent, depth, ctx, inLive = false) {
159
227
  return;
160
228
  }
161
229
  const child = children[index];
162
- if (child !== undefined)
230
+ if (child !== undefined) {
163
231
  visit(child, depth, ctx, inLive);
232
+ }
164
233
  }
165
234
  }
166
235
  function collectDialogs(root) {
167
236
  const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
168
237
  const names = [];
169
238
  for (const node of nodes) {
170
- if (isReticleOverlay(node))
239
+ if (isReticleOverlay(node)) {
171
240
  continue;
172
- if (isVisible(node))
241
+ }
242
+ if (isVisible(node)) {
173
243
  names.push(getAccessibleName(node) || '(unnamed dialog)');
244
+ }
174
245
  }
175
246
  return names;
176
247
  }
248
+ const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]';
177
249
  function overlayHidingPage(root) {
178
- if (root !== document.body)
250
+ if (root !== document.body) {
179
251
  return undefined;
252
+ }
180
253
  const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
181
254
  let modal;
182
255
  let hiddenCandidate;
183
256
  for (const d of dialogs) {
184
- if (isReticleOverlay(d))
257
+ if (isReticleOverlay(d)) {
185
258
  continue;
259
+ }
186
260
  if (isVisible(d)) {
187
261
  modal = d;
188
262
  break;
@@ -190,14 +264,17 @@ function overlayHidingPage(root) {
190
264
  hiddenCandidate ??= d;
191
265
  }
192
266
  modal ??= hiddenCandidate;
193
- if (modal === undefined)
267
+ if (modal === undefined) {
194
268
  return undefined;
269
+ }
195
270
  const modalEl = modal;
196
271
  const outside = Array.from(document.body.children).filter((c) => !c.contains(modalEl));
197
- if (0 === outside.length)
272
+ if (0 === outside.length) {
198
273
  return undefined;
199
- if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden')))
274
+ }
275
+ if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden'))) {
200
276
  return undefined;
277
+ }
201
278
  return ('the rest of the page is aria-hidden behind an open overlay (a focus-trap modal), so this ' +
202
279
  'snapshot shows only the overlay. If it will not close its cleanup may be stuck on a throttled ' +
203
280
  'tab: dismiss the overlay, or snapshot with { scope } inside it.');
@@ -206,7 +283,7 @@ function buildStatus(root) {
206
283
  const visibleDialogs = collectDialogs(root);
207
284
  const overlay = overlayHidingPage(root);
208
285
  return {
209
- route: `${location.pathname}${location.search}${location.hash}`,
286
+ route: pageRoute(),
210
287
  title: document.title,
211
288
  ...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
212
289
  ...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
@@ -214,11 +291,20 @@ function buildStatus(root) {
214
291
  }
215
292
  export function buildSnapshot(options = {}) {
216
293
  const mode = options.mode ?? SnapshotMode.FULL;
217
- const scopeEl = options.scope !== undefined
218
- ? (refs.resolve(options.scope) ?? document.querySelector(options.scope))
219
- : document.body;
220
- const scopeMissing = options.scope !== undefined && !(scopeEl instanceof Element);
221
- const root = scopeEl instanceof Element ? scopeEl : document.body;
294
+ const scope = options.scope?.trim();
295
+ let scopeEl = document.body;
296
+ if (undefined !== scope && '' !== scope) {
297
+ scopeEl = refs.resolve(scope);
298
+ if (null === scopeEl) {
299
+ try {
300
+ scopeEl = document.querySelector(scope);
301
+ }
302
+ catch {
303
+ }
304
+ }
305
+ }
306
+ const scopeMissing = null === scopeEl;
307
+ const root = scopeEl ?? document.body;
222
308
  const status = buildStatus(root);
223
309
  if (scopeMissing) {
224
310
  return { tree: '', status, nodes: 0, truncated: false, scopeMissing: true };
@@ -237,11 +323,23 @@ export function buildSnapshot(options = {}) {
237
323
  maxDepth: options.maxDepth ?? 20,
238
324
  unread: [],
239
325
  unreadOverflow: false,
326
+ ...(true === options.ids ? { ids: true } : {}),
240
327
  };
241
- if (true === options.includeRoot)
328
+ if (true === options.includeRoot) {
242
329
  visit(root, 0, ctx, false);
243
- else
330
+ }
331
+ else {
244
332
  walk(root, 0, ctx);
333
+ }
334
+ if (0 === ctx.nodes && root === document.body) {
335
+ ctx.throughOverlay = true;
336
+ for (const child of Array.from(root.children)) {
337
+ if (overlayStamped(child) && null !== child.querySelector(MODAL_SELECTOR)) {
338
+ visit(child, 0, ctx, false);
339
+ }
340
+ }
341
+ ctx.throughOverlay = false;
342
+ }
245
343
  return {
246
344
  tree: ctx.lines.join('\n'),
247
345
  status,
@@ -252,5 +350,6 @@ export function buildSnapshot(options = {}) {
252
350
  ...(ctx.hiddenSkipped > 0 ? { hiddenSkipped: ctx.hiddenSkipped } : {}),
253
351
  ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
254
352
  ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
353
+ ...(true === options.audit ? { audit: auditLayout(root) } : {}),
255
354
  };
256
355
  }
@@ -0,0 +1 @@
1
+ export declare function stableSelectorFor(el: HTMLElement): string | undefined;
@@ -0,0 +1,19 @@
1
+ import { getTestIdAttr, readTestId } from './addressing/testid-attr.js';
2
+ export function stableSelectorFor(el) {
3
+ const candidate = candidateSelectorFor(el);
4
+ if (undefined === candidate)
5
+ return undefined;
6
+ const matches = el.ownerDocument.querySelectorAll(candidate);
7
+ return 1 === matches.length && matches[0] === el ? candidate : undefined;
8
+ }
9
+ function candidateSelectorFor(el) {
10
+ const testid = readTestId(el);
11
+ if (null !== testid && 0 < testid.length) {
12
+ const escaped = testid.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
13
+ return `[${getTestIdAttr()}="${escaped}"]`;
14
+ }
15
+ const id = el.getAttribute('id');
16
+ if (null !== id && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(id))
17
+ return `#${id}`;
18
+ return undefined;
19
+ }
@@ -0,0 +1,12 @@
1
+ import type { ElementQuery } from '@reticlehq/core';
2
+ export declare function normaliseVisibleText(value: string): string;
3
+ export declare function foldTypography(value: string): string;
4
+ export declare function comparableText(value: string): string;
5
+ export declare function fuzzyVisibleText(actual: string, expected: string): boolean;
6
+ export declare function textMatches(actual: string, query: ElementQuery, expected: string): boolean;
7
+ export declare function exactVisibleText(actual: string, expected: string): boolean;
8
+ export declare function paintedCase(el: Element, text: string): string | undefined;
9
+ export declare const UNREADABLE_CONTRAST = 1.5;
10
+ export declare const UNREADABLE_OPACITY = 0.05;
11
+ export declare function unreadableText(el: Element): string | undefined;
12
+ export declare function unreadableMatch(matches: readonly Element[]): string | undefined;
@@ -0,0 +1,115 @@
1
+ import { parentAcrossShadowBoundary } from './shadow-registry.js';
2
+ export function normaliseVisibleText(value) {
3
+ return value.replace(/\s+/g, ' ').trim().normalize('NFC');
4
+ }
5
+ const SINGLE_QUOTES = /[\u2018\u2019]/g;
6
+ const DOUBLE_QUOTES = /[\u201c\u201d]/g;
7
+ const DASHES = /[\u2013\u2014]/g;
8
+ export function foldTypography(value) {
9
+ return value.replace(SINGLE_QUOTES, "'").replace(DOUBLE_QUOTES, '"').replace(DASHES, '-');
10
+ }
11
+ export function comparableText(value) {
12
+ return foldTypography(normaliseVisibleText(value));
13
+ }
14
+ export function fuzzyVisibleText(actual, expected) {
15
+ return comparableText(actual).toLowerCase().includes(comparableText(expected).toLowerCase());
16
+ }
17
+ export function textMatches(actual, query, expected) {
18
+ if (true !== query.matchCase) {
19
+ return fuzzyVisibleText(actual, expected);
20
+ }
21
+ return comparableText(actual).includes(comparableText(expected));
22
+ }
23
+ export function exactVisibleText(actual, expected) {
24
+ return comparableText(actual) === comparableText(expected);
25
+ }
26
+ const CASE_TRANSFORMS = {
27
+ uppercase: (text) => text.toUpperCase(),
28
+ lowercase: (text) => text.toLowerCase(),
29
+ capitalize: (text) => text.replace(/(^|\s)(\p{L})/gu, (_, gap, c) => gap + c.toUpperCase()),
30
+ };
31
+ export function paintedCase(el, text) {
32
+ const transform = el.ownerDocument.defaultView?.getComputedStyle(el).textTransform ?? '';
33
+ return CASE_TRANSFORMS[transform]?.(text);
34
+ }
35
+ export const UNREADABLE_CONTRAST = 1.5;
36
+ export const UNREADABLE_OPACITY = 0.05;
37
+ const CANVAS = [255, 255, 255, 1];
38
+ function parseRgb(value) {
39
+ const m = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)(?:[\s,/]+([\d.]+))?\s*\)$/.exec(value);
40
+ if (null === m) {
41
+ return undefined;
42
+ }
43
+ return [Number(m[1]), Number(m[2]), Number(m[3]), m[4] === undefined ? 1 : Number(m[4])];
44
+ }
45
+ function over(top, under) {
46
+ const a = top[3];
47
+ const mix = (t, u) => t * a + u * (1 - a);
48
+ return [mix(top[0], under[0]), mix(top[1], under[1]), mix(top[2], under[2]), 1];
49
+ }
50
+ function linear(v) {
51
+ const s = v / 255;
52
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
53
+ }
54
+ function luminance(c) {
55
+ return 0.2126 * linear(c[0]) + 0.7152 * linear(c[1]) + 0.0722 * linear(c[2]);
56
+ }
57
+ function contrast(a, b) {
58
+ const la = luminance(a);
59
+ const lb = luminance(b);
60
+ return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05);
61
+ }
62
+ function show(c) {
63
+ return `rgb(${[c[0], c[1], c[2]].map((v) => String(Math.round(v))).join(', ')})`;
64
+ }
65
+ function outlined(style) {
66
+ const stroke = Number.parseFloat(style.getPropertyValue('-webkit-text-stroke-width'));
67
+ return /\dpx/.test(style.textShadow) || stroke > 0;
68
+ }
69
+ export function unreadableText(el) {
70
+ const view = el.ownerDocument.defaultView;
71
+ if (null === view) {
72
+ return undefined;
73
+ }
74
+ const own = view.getComputedStyle(el);
75
+ const ink = parseRgb(own.color);
76
+ if (ink === undefined || outlined(own)) {
77
+ return undefined;
78
+ }
79
+ let opacity = 1;
80
+ const layers = [];
81
+ let opaque = false;
82
+ for (let node = el; null !== node; node = parentAcrossShadowBoundary(node)) {
83
+ const style = view.getComputedStyle(node);
84
+ const alpha = Number.parseFloat(style.opacity);
85
+ opacity *= Number.isNaN(alpha) ? 1 : alpha;
86
+ if (opaque) {
87
+ continue;
88
+ }
89
+ if ('' !== style.backgroundImage && 'none' !== style.backgroundImage) {
90
+ return undefined;
91
+ }
92
+ const fill = parseRgb(style.backgroundColor);
93
+ if (fill === undefined) {
94
+ return undefined;
95
+ }
96
+ if (fill[3] > 0) {
97
+ layers.push(fill);
98
+ }
99
+ opaque = fill[3] >= 1;
100
+ }
101
+ if (opacity < UNREADABLE_OPACITY) {
102
+ return `the composed opacity is ${opacity.toFixed(2)}`;
103
+ }
104
+ const back = layers.reduceRight((under, top) => over(top, under), CANVAS);
105
+ const painted = over(ink, back);
106
+ const ratio = contrast(painted, back);
107
+ if (ratio >= UNREADABLE_CONTRAST) {
108
+ return undefined;
109
+ }
110
+ return `text ${show(painted)} on ${show(back)}: contrast ${ratio.toFixed(2)}:1`;
111
+ }
112
+ export function unreadableMatch(matches) {
113
+ const reasons = matches.map(unreadableText);
114
+ return reasons.length > 0 && reasons.every((r) => r !== undefined) ? reasons[0] : undefined;
115
+ }
@@ -1,3 +1,4 @@
1
+ export declare function tokenNames(): Set<string>;
1
2
  interface ThemeReport {
2
3
  colorToken: string | null;
3
4
  colorTokens: string[];
package/dist/dom/theme.js CHANGED
@@ -1,5 +1,24 @@
1
1
  const THEME_ATTRIBUTE = 'data-theme';
2
- function tokenNames() {
2
+ const MAX_TOKEN_RULE_DEPTH = 8;
3
+ function isGroupingRule(rule) {
4
+ return 'cssRules' in rule && rule.cssRules instanceof CSSRuleList;
5
+ }
6
+ function collectTokenNames(rules, names, depth) {
7
+ if (depth <= 0)
8
+ return;
9
+ for (const rule of Array.from(rules)) {
10
+ if (rule instanceof CSSStyleRule) {
11
+ for (const prop of Array.from(rule.style)) {
12
+ if (prop.startsWith('--'))
13
+ names.add(prop);
14
+ }
15
+ }
16
+ else if (isGroupingRule(rule)) {
17
+ collectTokenNames(rule.cssRules, names, depth - 1);
18
+ }
19
+ }
20
+ }
21
+ export function tokenNames() {
3
22
  const names = new Set();
4
23
  for (const sheet of Array.from(document.styleSheets)) {
5
24
  let rules = null;
@@ -11,14 +30,7 @@ function tokenNames() {
11
30
  }
12
31
  if (null === rules)
13
32
  continue;
14
- for (const rule of Array.from(rules)) {
15
- if (!(rule instanceof CSSStyleRule))
16
- continue;
17
- for (const prop of Array.from(rule.style)) {
18
- if (prop.startsWith('--'))
19
- names.add(prop);
20
- }
21
- }
33
+ collectTokenNames(rules, names, MAX_TOKEN_RULE_DEPTH);
22
34
  }
23
35
  return names;
24
36
  }
@@ -25,8 +25,12 @@ export function unmountedRowsIn(container) {
25
25
  let top = Infinity;
26
26
  let bottom = 0;
27
27
  let totalHeight = 0;
28
+ const bounds = container.getBoundingClientRect();
29
+ const scaleY = container.offsetHeight > 0 && bounds.height > 0 ? bounds.height / container.offsetHeight : 1;
28
30
  for (const row of rows) {
29
- const rowTop = row.offsetTop;
31
+ const rowTop = (row.getBoundingClientRect().top - bounds.top) / scaleY -
32
+ container.clientTop +
33
+ container.scrollTop;
30
34
  const rowBottom = rowTop + row.offsetHeight;
31
35
  if (rowTop < top)
32
36
  top = rowTop;
package/dist/index.d.ts CHANGED
@@ -18,10 +18,11 @@ export { registerReticleDomain } from './registry/domains.js';
18
18
  export type { ReticleDomain } from './registry/domains.js';
19
19
  export { buildSnapshot } from './dom/snapshot.js';
20
20
  export { inspectChart, canvasChartData } from './dom/chart.js';
21
- export type { ChartReport, ChartFinding, CanvasChartData } from './dom/chart.js';
21
+ export type { ChartReport, ChartFinding, CanvasChartData, ChartGlobals } from './dom/chart.js';
22
22
  export { matchQuery, runQuery } from './dom/query.js';
23
23
  export { executeAction, executeSequence } from './actions/actions.js';
24
24
  export { describe, getRole, getAccessibleName, getStates, isVisible } from './dom/a11y.js';
25
25
  export { refs, RefRegistry } from './dom/addressing/refs.js';
26
26
  export { Annotator, installAnnotator, type AnnotatorDeps } from './review/annotator.js';
27
27
  export { resolveMarkAnchor, type MarkAnchor } from './review/mark-anchor.js';
28
+ export declare const trackShortTimers: (maxMs?: number) => (() => void);
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { reticle } from './reticle-singleton.js';
2
+ import { reticle } from './reticle-singleton.js';
3
+ import { trackShortTimers as trackTimers } from './busy.js';
2
4
  export { Reticle } from './reticle.js';
3
5
  export { SESSION_AUTO } from '@reticlehq/core';
4
6
  export { setIgnoreSelectors } from './dom/dom-ignore.js';
@@ -17,3 +19,6 @@ export { describe, getRole, getAccessibleName, getStates, isVisible } from './do
17
19
  export { refs, RefRegistry } from './dom/addressing/refs.js';
18
20
  export { Annotator, installAnnotator } from './review/annotator.js';
19
21
  export { resolveMarkAnchor } from './review/mark-anchor.js';
22
+ export const trackShortTimers = (maxMs) => trackTimers((name, data) => {
23
+ reticle.signal(name, data);
24
+ }, maxMs);
@@ -58,10 +58,11 @@ export function installBlindSpots(emit) {
58
58
  const report = () => {
59
59
  for (const sensor of sensors) {
60
60
  const count = sensor.count();
61
- if (count === last.get(sensor.kind))
61
+ const previous = last.get(sensor.kind);
62
+ if (count === previous)
62
63
  continue;
63
64
  last.set(sensor.kind, count);
64
- if (count > 0)
65
+ if (count > 0 || previous !== undefined)
65
66
  emit(EventType.BLIND_SPOT, { kind: sensor.kind, count });
66
67
  }
67
68
  };
@@ -0,0 +1,18 @@
1
+ import { CanvasTrigger } from '@reticlehq/core';
2
+ export { CanvasTrigger };
3
+ import { type Emit, type Teardown } from './types.js';
4
+ export interface CanvasSample {
5
+ data: Uint8ClampedArray;
6
+ width: number;
7
+ height: number;
8
+ }
9
+ export declare function digestPixels(data: Uint8ClampedArray): {
10
+ digest: string;
11
+ mean: [number, number, number];
12
+ };
13
+ interface CanvasOptions {
14
+ sample?: (canvas: HTMLCanvasElement) => CanvasSample | undefined;
15
+ every?: (run: () => void, ms: number) => () => void;
16
+ }
17
+ export declare function canvasAfterAct(): void;
18
+ export declare function installCanvas(emit: Emit, opts?: CanvasOptions): Teardown;