@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
@@ -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;
@@ -3,8 +3,12 @@ import { capturedRootOf } from './shadow-registry.js';
3
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,8 +27,9 @@ 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
  }
@@ -56,30 +61,36 @@ function readingParts(el) {
56
61
  function isInlineIn(el) {
57
62
  const view = el.ownerDocument.defaultView;
58
63
  const parent = el.parentElement;
59
- if (null === view || null === parent)
64
+ if (null === view || null === parent) {
60
65
  return false;
66
+ }
61
67
  const around = view.getComputedStyle(parent).display;
62
- if (around.includes('flex') || around.includes('grid'))
68
+ if (around.includes('flex') || around.includes('grid')) {
63
69
  return false;
70
+ }
64
71
  return 'inline' === view.getComputedStyle(el).display;
65
72
  }
66
73
  function foldsIntoParent(el) {
67
- if (skipEarly(el) || !isInlineIn(el))
74
+ if (skipEarly(el) || !isInlineIn(el)) {
68
75
  return false;
76
+ }
69
77
  const role = getRole(el);
70
78
  return !INTERACTIVE.has(role) && !announces(el, role) && 0 === getAccessibleName(el).length;
71
79
  }
72
80
  export const MAX_UNREAD_BRANCHES = 50;
73
81
  function hiddenByMarkup(el) {
74
- if ('true' === el.getAttribute('aria-hidden'))
82
+ if ('true' === el.getAttribute('aria-hidden')) {
75
83
  return true;
84
+ }
76
85
  return el instanceof HTMLElement && el.hidden;
77
86
  }
78
87
  function skipEarly(el) {
79
- if (SKIP_TAGS.has(el.tagName.toLowerCase()))
88
+ if (SKIP_TAGS.has(el.tagName.toLowerCase())) {
80
89
  return true;
81
- if (isIgnored(el))
90
+ }
91
+ if (isIgnored(el)) {
82
92
  return true;
93
+ }
83
94
  return hiddenByMarkup(el);
84
95
  }
85
96
  function stateSuffix(el) {
@@ -90,7 +101,7 @@ function stateSuffix(el) {
90
101
  s === ElementState.FOCUSED);
91
102
  return states.length > 0 ? ` [${states.join(',')}]` : '';
92
103
  }
93
- function formatLine(el, depth, role, name, layout, ownText = '') {
104
+ function formatLine(el, depth, role, name, layout, ownText = '', withTestId = false) {
94
105
  const indent = ' '.repeat(depth);
95
106
  const value = getValue(el);
96
107
  const label = name;
@@ -99,14 +110,27 @@ function formatLine(el, depth, role, name, layout, ownText = '') {
99
110
  const valuePart = value !== undefined && value.length > 0 ? ` [value="${value}"]` : '';
100
111
  const layoutPart = layout.length > 0 ? ` [${layout}]` : '';
101
112
  const textPart = ownText.length > 0 ? ` [text="${ownText}"]` : '';
102
- 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}`;
103
126
  }
104
127
  function formatTextLine(depth, text) {
105
128
  return `${' '.repeat(depth)}- text "${text}"`;
106
129
  }
107
130
  function layoutSignature(style) {
108
- if (null === style)
131
+ if (null === style) {
109
132
  return '';
133
+ }
110
134
  const display = style.display;
111
135
  if ('grid' === display || 'inline-grid' === display) {
112
136
  const cols = style.gridTemplateColumns;
@@ -127,13 +151,15 @@ function recordUnread(branches, ctx) {
127
151
  function pierceChildren(parent) {
128
152
  const out = [...parent.children];
129
153
  const shadow = parent.shadowRoot ?? capturedRootOf(parent);
130
- if (shadow !== null)
154
+ if (shadow !== null) {
131
155
  out.push(...shadow.children);
156
+ }
132
157
  if (isFrame(parent)) {
133
158
  try {
134
159
  const body = parent.contentDocument?.body;
135
- if (body !== null && body !== undefined)
160
+ if (body !== null && body !== undefined) {
136
161
  out.push(...body.children);
162
+ }
137
163
  }
138
164
  catch {
139
165
  }
@@ -146,8 +172,9 @@ function visit(child, depth, ctx, inLive) {
146
172
  !isIgnored(child) &&
147
173
  !(child instanceof HTMLElement && child.hidden);
148
174
  if (!stampedOnly && skipEarly(child)) {
149
- if (hiddenByMarkup(child))
175
+ if (hiddenByMarkup(child)) {
150
176
  ctx.hiddenSkipped += 1;
177
+ }
151
178
  return;
152
179
  }
153
180
  const view = child.ownerDocument.defaultView;
@@ -170,13 +197,14 @@ function visit(child, depth, ctx, inLive) {
170
197
  const actionable = interactive;
171
198
  const meaningful = actionable || role !== 'generic' || name.length > 0 || text.length > 0 || layout.length > 0;
172
199
  const include = lean ? actionable : meaningful;
173
- if (lean && !include && meaningful)
200
+ if (lean && !include && meaningful) {
174
201
  ctx.leanSkipped += 1;
202
+ }
175
203
  if (include) {
176
204
  ctx.nodes += 1;
177
205
  ctx.lines.push('generic' === role && text.length > 0 && 0 === layout.length
178
206
  ? formatTextLine(depth, text)
179
- : formatLine(child, depth, role, name, layout, text));
207
+ : formatLine(child, depth, role, name, layout, text, true === ctx.ids));
180
208
  walk(child, depth + 1, ctx, announce);
181
209
  }
182
210
  else {
@@ -199,31 +227,36 @@ function walk(parent, depth, ctx, inLive = false) {
199
227
  return;
200
228
  }
201
229
  const child = children[index];
202
- if (child !== undefined)
230
+ if (child !== undefined) {
203
231
  visit(child, depth, ctx, inLive);
232
+ }
204
233
  }
205
234
  }
206
235
  function collectDialogs(root) {
207
236
  const nodes = root.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
208
237
  const names = [];
209
238
  for (const node of nodes) {
210
- if (isReticleOverlay(node))
239
+ if (isReticleOverlay(node)) {
211
240
  continue;
212
- if (isVisible(node))
241
+ }
242
+ if (isVisible(node)) {
213
243
  names.push(getAccessibleName(node) || '(unnamed dialog)');
244
+ }
214
245
  }
215
246
  return names;
216
247
  }
217
248
  const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"]';
218
249
  function overlayHidingPage(root) {
219
- if (root !== document.body)
250
+ if (root !== document.body) {
220
251
  return undefined;
252
+ }
221
253
  const dialogs = document.body.querySelectorAll('[role="dialog"], dialog[open], [aria-modal="true"]');
222
254
  let modal;
223
255
  let hiddenCandidate;
224
256
  for (const d of dialogs) {
225
- if (isReticleOverlay(d))
257
+ if (isReticleOverlay(d)) {
226
258
  continue;
259
+ }
227
260
  if (isVisible(d)) {
228
261
  modal = d;
229
262
  break;
@@ -231,14 +264,17 @@ function overlayHidingPage(root) {
231
264
  hiddenCandidate ??= d;
232
265
  }
233
266
  modal ??= hiddenCandidate;
234
- if (modal === undefined)
267
+ if (modal === undefined) {
235
268
  return undefined;
269
+ }
236
270
  const modalEl = modal;
237
271
  const outside = Array.from(document.body.children).filter((c) => !c.contains(modalEl));
238
- if (0 === outside.length)
272
+ if (0 === outside.length) {
239
273
  return undefined;
240
- if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden')))
274
+ }
275
+ if (!outside.every((c) => 'true' === c.getAttribute('aria-hidden'))) {
241
276
  return undefined;
277
+ }
242
278
  return ('the rest of the page is aria-hidden behind an open overlay (a focus-trap modal), so this ' +
243
279
  'snapshot shows only the overlay. If it will not close its cleanup may be stuck on a throttled ' +
244
280
  'tab: dismiss the overlay, or snapshot with { scope } inside it.');
@@ -247,7 +283,7 @@ function buildStatus(root) {
247
283
  const visibleDialogs = collectDialogs(root);
248
284
  const overlay = overlayHidingPage(root);
249
285
  return {
250
- route: `${location.pathname}${location.search}${location.hash}`,
286
+ route: pageRoute(),
251
287
  title: document.title,
252
288
  ...(visibleDialogs.length > 0 ? { visibleDialogs } : {}),
253
289
  ...(overlay !== undefined ? { overlayHidingPage: overlay } : {}),
@@ -255,11 +291,20 @@ function buildStatus(root) {
255
291
  }
256
292
  export function buildSnapshot(options = {}) {
257
293
  const mode = options.mode ?? SnapshotMode.FULL;
258
- const scopeEl = options.scope !== undefined
259
- ? (refs.resolve(options.scope) ?? document.querySelector(options.scope))
260
- : document.body;
261
- const scopeMissing = options.scope !== undefined && !(scopeEl instanceof Element);
262
- 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;
263
308
  const status = buildStatus(root);
264
309
  if (scopeMissing) {
265
310
  return { tree: '', status, nodes: 0, truncated: false, scopeMissing: true };
@@ -278,16 +323,21 @@ export function buildSnapshot(options = {}) {
278
323
  maxDepth: options.maxDepth ?? 20,
279
324
  unread: [],
280
325
  unreadOverflow: false,
326
+ ...(true === options.ids ? { ids: true } : {}),
281
327
  };
282
- if (true === options.includeRoot)
328
+ if (true === options.includeRoot) {
283
329
  visit(root, 0, ctx, false);
284
- else
330
+ }
331
+ else {
285
332
  walk(root, 0, ctx);
333
+ }
286
334
  if (0 === ctx.nodes && root === document.body) {
287
335
  ctx.throughOverlay = true;
288
- for (const child of Array.from(root.children))
289
- if (overlayStamped(child) && null !== child.querySelector(MODAL_SELECTOR))
336
+ for (const child of Array.from(root.children)) {
337
+ if (overlayStamped(child) && null !== child.querySelector(MODAL_SELECTOR)) {
290
338
  visit(child, 0, ctx, false);
339
+ }
340
+ }
291
341
  ctx.throughOverlay = false;
292
342
  }
293
343
  return {
@@ -300,5 +350,6 @@ export function buildSnapshot(options = {}) {
300
350
  ...(ctx.hiddenSkipped > 0 ? { hiddenSkipped: ctx.hiddenSkipped } : {}),
301
351
  ...(ctx.unread.length > 0 ? { unread: ctx.unread } : {}),
302
352
  ...(ctx.unreadOverflow ? { unreadOverflow: true } : {}),
353
+ ...(true === options.audit ? { audit: auditLayout(root) } : {}),
303
354
  };
304
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;