scenescout 3.15.0 → 3.17.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 (59) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/README.md +70 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +191 -14
  5. package/dist/ci-run.js +268 -52
  6. package/dist/cli.js +107 -47
  7. package/dist/commands.js +3 -2
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1147 -286
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +244 -42
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +136 -18
  16. package/dist/engine/claims.js +159 -3
  17. package/dist/engine/collector.js +561 -30
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/design.js +281 -38
  20. package/dist/engine/export.js +877 -0
  21. package/dist/engine/fingerprint.js +92 -4
  22. package/dist/engine/flow.js +18 -6
  23. package/dist/engine/forms.js +181 -18
  24. package/dist/engine/journey.js +29 -1
  25. package/dist/engine/lane.js +13 -3
  26. package/dist/engine/launch.js +45 -6
  27. package/dist/engine/limits.js +7 -0
  28. package/dist/engine/live-page.js +49 -2
  29. package/dist/engine/live.js +4 -1
  30. package/dist/engine/memory.js +501 -47
  31. package/dist/engine/open.js +118 -0
  32. package/dist/engine/oracles.js +41 -1
  33. package/dist/engine/plain.js +268 -0
  34. package/dist/engine/png.js +127 -0
  35. package/dist/engine/policy.js +379 -9
  36. package/dist/engine/probes.js +3 -2
  37. package/dist/engine/profiles.js +45 -9
  38. package/dist/engine/project-folder.js +191 -0
  39. package/dist/engine/refresh.js +68 -3
  40. package/dist/engine/replay.js +63 -10
  41. package/dist/engine/report.js +241 -40
  42. package/dist/engine/request.js +317 -23
  43. package/dist/engine/sarif.js +120 -0
  44. package/dist/engine/settle.js +67 -0
  45. package/dist/engine/signed-in.js +256 -0
  46. package/dist/engine/status-pane-page.js +441 -0
  47. package/dist/engine/status-pane.js +128 -0
  48. package/dist/engine/tickets.js +671 -0
  49. package/dist/engine/unload.js +3 -2
  50. package/dist/export-run.js +633 -0
  51. package/dist/first-run.js +5 -0
  52. package/dist/installer.js +378 -8
  53. package/dist/intake.js +104 -0
  54. package/dist/login-run.js +250 -36
  55. package/dist/mcp-server.js +660 -65
  56. package/dist/playbook.js +5 -0
  57. package/dist/prompts.js +106 -0
  58. package/package.json +8 -5
  59. package/skills/scenescout/SKILL.md +49 -16
@@ -47,6 +47,11 @@ export const XPATH_OF_SRC = `(el) => {
47
47
  * A label's text is read without the control's own text: a wrapping label
48
48
  * holds the control, and a select's options or a textarea's contents are not
49
49
  * its label.
50
+ *
51
+ * `content` is what the element's image content says, which the text cannot
52
+ * hold: the alt text of the first descendant <img>, else the aria-label or
53
+ * <title> of a descendant <svg> or role="img", in document order, skipping
54
+ * anything inside an aria-hidden part of the element.
50
55
  */
51
56
  export const NAME_FACTS_SRC = `(el) => {
52
57
  const tag = el.tagName.toLowerCase();
@@ -58,6 +63,17 @@ export const NAME_FACTS_SRC = `(el) => {
58
63
  const own = label.contains(el) ? el.textContent || "" : "";
59
64
  labels.push((label.textContent || "").replace(own, ""));
60
65
  }
66
+ let content = "";
67
+ for (const d of el.querySelectorAll("img, svg, [role='img']")) {
68
+ const hidden = d.closest('[aria-hidden="true"]');
69
+ if (hidden && el.contains(hidden)) continue;
70
+ const title = d.tagName.toLowerCase() === "svg" ? d.querySelector(":scope > title") : null;
71
+ const said = (d.tagName === "IMG" ? d.getAttribute("alt") : d.getAttribute("aria-label") || (title ? title.textContent : "")) || "";
72
+ if (said.trim()) {
73
+ content = said;
74
+ break;
75
+ }
76
+ }
61
77
  const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
62
78
  (el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
63
79
  return {
@@ -74,6 +90,7 @@ export const NAME_FACTS_SRC = `(el) => {
74
90
  alt: el.getAttribute("alt") || "",
75
91
  live,
76
92
  text: el.innerText || el.textContent || "",
93
+ content,
77
94
  };
78
95
  }`;
79
96
  /**
@@ -88,8 +105,15 @@ export const NAME_FACTS_SRC = `(el) => {
88
105
  * fallback stays.
89
106
  *
90
107
  * - A select is never named by its options: their text is its value, not its name.
91
- * - Other elements are named by their text, and by title when they have none
92
- * (an icon-only button with a tooltip).
108
+ * - Other elements are named by their text; with none, by their image content
109
+ * (a link holding only a logo with alt text, a button holding only an svg
110
+ * with a <title>); and with neither, by title (an icon-only button with a
111
+ * tooltip). Text and image content are not joined: a control with both is
112
+ * named by its text, as it was before image content counted.
113
+ * - A name taken from image content carries `prior`, the name the rule gave
114
+ * before image content counted (the title, or nothing). The name is half of
115
+ * the coverage key, so the key under the earlier rule is kept beside the new
116
+ * one and coverage recorded under it carries over (memory.ts keyAliases).
93
117
  * - A live region (status, alert, log, timer, an aria-live region, <output>)
94
118
  * is named by the text it announces, which is what matters after an action.
95
119
  * - A whitespace-only aria-label ends the name with nothing, as it always has
@@ -121,6 +145,7 @@ export const PICK_NAME_SRC = `(f) => {
121
145
  return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
122
146
  }
123
147
  if (clean(f.text)) return named(clean(f.text));
148
+ if (clean(f.content)) return { ...named(clean(f.content)), prior: clean(f.title).slice(0, 80) };
124
149
  return named(clean(f.title));
125
150
  }`;
126
151
  /**
@@ -138,6 +163,49 @@ export const NAME_SRC = `(el) => (${PICK_NAME_SRC})((${NAME_FACTS_SRC})(el))`;
138
163
  * must name it exactly as the snapshot did, or the two cannot be compared.
139
164
  */
140
165
  export const ACCESSIBLE_NAME_SRC = `(el) => (${NAME_SRC})(el).name`;
166
+ /**
167
+ * What the label policy reads of an element besides its name (policy.ts
168
+ * destructiveLabelOf), as page-side source shared by the snapshot and the
169
+ * live re-check before an action, so the two cannot judge differently.
170
+ *
171
+ * `ownText`: the label the element is given (aria-label, aria-labelledby),
172
+ * then its text without the text of the controls inside it.
173
+ * `centre`: the labels of the controls inside it whose boxes cover the
174
+ * element's centre point, which is where a click on the element lands. Boxes,
175
+ * not a hit test, so it reads the same whether or not the element is scrolled
176
+ * into view, and every covering control is named, not only the topmost.
177
+ */
178
+ export const POLICY_TEXT_SRC = `(el) => {
179
+ const CONTROL = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], ' +
180
+ '[role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="checkbox"], [role="radio"], [role="switch"], [role="combobox"], [role="listbox"], [onclick]';
181
+ const parts = [];
182
+ const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
183
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
184
+ const owner = node.parentElement && node.parentElement.closest(CONTROL);
185
+ if (owner && owner !== el && el.contains(owner)) continue;
186
+ const t = (node.textContent || "").trim();
187
+ if (t) parts.push(t);
188
+ if (parts.join(" ").length > 400) break;
189
+ }
190
+ // A label the element is given (aria-label, aria-labelledby) is its own: an icon-only control's only name.
191
+ const given = [el.getAttribute("aria-label") || ""].concat((el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean).map((id) => {
192
+ const n = document.getElementById(id);
193
+ return n ? n.textContent || "" : "";
194
+ })).join(" ").trim();
195
+ const ownText = (given + " " + parts.join(" ")).replace(/\\s+/g, " ").trim().slice(0, 400);
196
+ const r = el.getBoundingClientRect();
197
+ const cx = r.left + r.width / 2, cy = r.top + r.height / 2;
198
+ const centre = [];
199
+ for (const c of Array.from(el.querySelectorAll(CONTROL))) {
200
+ const b = c.getBoundingClientRect();
201
+ if (b.width === 0 || b.height === 0 || cx < b.left || cx > b.right || cy < b.top || cy > b.bottom) continue;
202
+ const label = (c.getAttribute("aria-label") || c.innerText || c.textContent || c.getAttribute("value") || "").trim().replace(/\\s+/g, " ").slice(0, 120);
203
+ if (label && centre.length < 10) centre.push(label);
204
+ const tid = c.getAttribute("data-testid");
205
+ if (tid && centre.length < 10) centre.push(tid);
206
+ }
207
+ return { ownText, centre };
208
+ }`;
141
209
  /**
142
210
  * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
143
211
  * than the ARIA set: a hand-rolled role-less modal must still count as "an
@@ -146,6 +214,63 @@ export const ACCESSIBLE_NAME_SRC = `(el) => (${NAME_SRC})(el).name`;
146
214
  */
147
215
  export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
148
216
  // Declared above the collector script because that script interpolates it.
217
+ /**
218
+ * The accessible name of a control that pages a clipping container: next and
219
+ * previous arrows, numbered slide or page buttons, scroll-left and
220
+ * scroll-right. Content clipped inside a container such a control sits beside
221
+ * is revealed by it, so it is not unreachable. The page-side walk carries a
222
+ * literal copy (no value is spliced into page code); oracle-test holds the two
223
+ * equal.
224
+ */
225
+ export const PAGER_NAME = /^(?:[‹›«»<>←→⟨⟩❮❯]|(?:go to |show )?(?:next|previous|prev)(?:\s+\w+)?|(?:go to |show )?(?:slide|page|image|item|step)\s*\d+.*|scroll (?:left|right|up|down)\b.*)$/i;
226
+ /** Whether a control's name says it pages a container (see PAGER_NAME). */
227
+ export function isPagerName(name) {
228
+ return PAGER_NAME.test(name.trim());
229
+ }
230
+ /**
231
+ * An href that points at a place in this same document: "#main". Not a bare
232
+ * "#", and not a hash route ("#/reports", "#!/reports"), which is navigation.
233
+ * The collector carries a literal copy; oracle-test holds the two equal.
234
+ */
235
+ export const IN_PAGE_ANCHOR = /^#(?![/!])./;
236
+ /**
237
+ * Style properties that can bring a box parked off the page back into it. A
238
+ * :focus rule that sets one of these is how a skip link is revealed; a :focus
239
+ * rule that only draws an outline is not. The collector carries a literal
240
+ * copy; oracle-test holds the two equal.
241
+ */
242
+ export const FOCUS_MOVES_PROPS = [
243
+ "position",
244
+ "top",
245
+ "left",
246
+ "right",
247
+ "bottom",
248
+ "inset",
249
+ "transform",
250
+ "translate",
251
+ "clip",
252
+ "clip-path",
253
+ "margin",
254
+ "margin-top",
255
+ "margin-left",
256
+ "width",
257
+ "height",
258
+ ];
259
+ /** The most elements a snapshot lists in full; past it, what is left is counted (cutSummary). */
260
+ export const COLLECTOR_CAP = 150;
261
+ /** Pager and "load more" controls listed past the cap, so a lane can still reach the rest of a long list. */
262
+ export const MAX_KEPT_PAST_CAP = 10;
263
+ /** How many elements past the cap are looked at, to keep them or count them, before the rest goes uncounted. */
264
+ export const MAX_SCANNED_PAST_CAP = 2000;
265
+ /**
266
+ * A control that pages a list or loads more of it, by its name: "Next",
267
+ * "Previous page", "Page 3", "»", "Load more", "Show 20 more", "More results".
268
+ * Narrower than PAGER_NAME, which also takes "Item 4" and "Step 2" for a
269
+ * carousel's slides: past the cap, those are the list itself.
270
+ */
271
+ export const LIST_PAGER_NAME = /^(?:[‹›«»<>←→]|(?:go to )?(?:next|previous|prev|first|last)(?: page)?|(?:go to )?page \d+|(?:load|show|see|view) (?:\d+ )?more\b.*|more results)$/i;
272
+ /** A test id that names a pager or a "load more" control: "pager-next", "pagination", "load-more", "next-page". */
273
+ export const PAGER_TESTID = /(?:^|[-_.:])(?:pager|pagination|paginator|load-?more|show-?more|(?:next|prev|previous)-?page|page-?(?:next|prev|previous))(?:$|[-_.:])/i;
149
274
  /**
150
275
  * Page-side interactable collector. Shipped as a STRING, not a function:
151
276
  * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
@@ -156,6 +281,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
156
281
  const xpathOf = ${XPATH_OF_SRC};
157
282
  const visible = ${VISIBLE_SRC};
158
283
  const nameOf = ${NAME_SRC};
284
+ const policyText = ${POLICY_TEXT_SRC};
159
285
  // What the collector lists before anything else: controls, and anything the
160
286
  // app tagged with a test id so it can be targeted.
161
287
  const controlSelector =
@@ -173,18 +299,29 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
173
299
  // Everything else listed is here for its test id or its text, and is neither
174
300
  // an unnamed control nor a gap in coverage.
175
301
  const CONTROL_ROLES = /^(button|link|tab|menuitem|menuitemcheckbox|menuitemradio|checkbox|radio|switch|combobox|textbox|searchbox|slider|spinbutton|option|treeitem)$/;
176
- const isInteractive = (el) => {
177
- if (el.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"], [contenteditable=""]')) return true;
178
- if (CONTROL_ROLES.test(el.getAttribute("role") || "")) return true;
302
+ // For an element that is neither a native control nor in a control role:
303
+ // what alone makes it actionable (affordanceFlags). Null for a control.
304
+ const affordancesOf = (el) => {
305
+ if (el.matches('a[href], button, input, select, textarea, summary, [contenteditable="true"], [contenteditable=""]')) return null;
306
+ if (CONTROL_ROLES.test(el.getAttribute("role") || "")) return null;
179
307
  const tabindex = el.getAttribute("tabindex");
180
- if (tabindex !== null && tabindex.trim() !== "" && Number(tabindex) >= 0) return true;
181
- if (el.hasAttribute("onclick") || typeof el.onclick === "function") return true;
308
+ const tabStop = tabindex !== null && tabindex.trim() !== "" && Number(tabindex) >= 0;
309
+ const clickHandler = el.hasAttribute("onclick") || typeof el.onclick === "function";
310
+ let pointer = false;
182
311
  if (window.getComputedStyle(el).cursor === "pointer") {
183
312
  const parent = el.parentElement;
184
- return !parent || window.getComputedStyle(parent).cursor !== "pointer";
313
+ pointer = !parent || window.getComputedStyle(parent).cursor !== "pointer";
185
314
  }
186
- return false;
315
+ return { tabStop, clickHandler, pointer };
187
316
  };
317
+ const isInteractive = (aff) => aff === null || aff.tabStop || aff.clickHandler || aff.pointer;
318
+ // Past the element cap, a control that pages the list or loads more of it
319
+ // is still listed (up to MAX_KEPT_PAST_CAP), and the rest is only counted.
320
+ const LIST_PAGER = ${String(LIST_PAGER_NAME)};
321
+ const PAGER_TID = ${String(PAGER_TESTID)};
322
+ let keptPastCap = 0;
323
+ let scannedPastCap = 0;
324
+ const cut = [];
188
325
  // A horizontally scrolling container a control sits outside of: the control
189
326
  // is reachable, by a sideways scroll of that container, but nothing on screen
190
327
  // shows it is there. Carousels (scroll-snap) page sideways by design and are
@@ -300,6 +437,39 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
300
437
  const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
301
438
  return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
302
439
  };
440
+ /** A link to a place in this same document (a skip link's shape): "#main", never a bare "#" or a hash route ("#/reports"). */
441
+ const inPageAnchor = (node) => node.tagName === "A" && /^#(?![/!])./.test(node.getAttribute("href") || "");
442
+ // The page's :focus rules that move a box (FOCUS_MOVES_PROPS), as selectors
443
+ // with the :focus part removed: an element they match is shown when it takes
444
+ // focus. Read once, only when an element sits off the page.
445
+ let focusMoveSelectors = null;
446
+ const FOCUS_MOVES = ["position", "top", "left", "right", "bottom", "inset", "transform", "translate", "clip", "clip-path", "margin", "margin-top", "margin-left", "width", "height"];
447
+ const readFocusRules = (rules, out) => {
448
+ for (const rule of Array.from(rules || [])) {
449
+ if (out.length >= 500) return;
450
+ if (rule.cssRules && !rule.selectorText) { readFocusRules(rule.cssRules, out); continue; }
451
+ const sel = rule.selectorText || "";
452
+ if (!/:focus/.test(sel) || !rule.style) continue;
453
+ const props = Array.from(rule.style);
454
+ if (!props.some((p) => FOCUS_MOVES.indexOf(p) >= 0 || /^(inset|margin)-/.test(p))) continue;
455
+ for (const part of sel.split(",")) {
456
+ if (/:focus/.test(part)) out.push(part.replace(/:focus(-visible|-within)?/g, "").trim() || "*");
457
+ }
458
+ }
459
+ };
460
+ const focusMoves = (el) => {
461
+ if (focusMoveSelectors === null) {
462
+ focusMoveSelectors = [];
463
+ for (const sheet of Array.from(document.styleSheets)) {
464
+ // Reading another origin's stylesheet throws by design; its rules stay unknown.
465
+ try { readFocusRules(sheet.cssRules, focusMoveSelectors); } catch (e) { continue; }
466
+ }
467
+ }
468
+ return focusMoveSelectors.some((sel) => {
469
+ // Removing :focus can leave an invalid selector (":not()"), which matches nothing.
470
+ try { return el.matches(sel); } catch (e) { return false; }
471
+ });
472
+ };
303
473
  const coveredByPinnedChrome = (el, rect, role) => {
304
474
  if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
305
475
  const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
@@ -308,6 +478,11 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
308
478
  if (!ownRoot || insideScrollablePane(el)) return null;
309
479
  const top = document.elementFromPoint(cx, cy);
310
480
  if (!top || top === el || el.contains(top) || top.contains(el)) return null;
481
+ // A skip link is pinned out of sight until it takes focus, then shown over
482
+ // the header. While focused it covers whatever is under it, by design, and
483
+ // it leaves again as soon as focus moves on.
484
+ const active = document.activeElement;
485
+ if (active && inPageAnchor(active) && (top === active || active.contains(top))) return null;
311
486
  const coverRoot = pinnedRootOf(top);
312
487
  if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
313
488
  // A dialog's fixed wrapper often carries no role or class itself; the
@@ -319,6 +494,63 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
319
494
  return describe(coverRoot);
320
495
  };
321
496
 
497
+ const PAGER = /^(?:[‹›«»<>←→⟨⟩❮❯]|(?:go to |show )?(?:next|previous|prev)(?:\\s+\\w+)?|(?:go to |show )?(?:slide|page|image|item|step)\\s*\\d+.*|scroll (?:left|right|up|down)\\b.*)$/i;
498
+ // Per container and axis: a carousel clips every slide but one, and they share the answer.
499
+ const pagedClips = new Map();
500
+ const revealedByControl = (clip, axis) => {
501
+ const key = pagedClips.get(clip) || {};
502
+ if (key[axis] === undefined) { key[axis] = pagesClip(clip, axis); pagedClips.set(clip, key); }
503
+ return key[axis];
504
+ };
505
+ // Content laid out as slides along the clipped axis: two or more children
506
+ // (of the clip, or of its single track) the clip's own size on that axis,
507
+ // at least one starting outside it. A table wider than its card, or a list
508
+ // longer than it, is not; a "Next page" in its footer pages rows, not them.
509
+ const slidesIn = (clip, axis) => {
510
+ const cr = clip.getBoundingClientRect();
511
+ const start = axis === "x" ? cr.left : cr.top;
512
+ const span = axis === "x" ? cr.width : cr.height;
513
+ const track = clip.children.length === 1 ? clip.children[0] : clip;
514
+ let size = 0, outside = 0;
515
+ for (const kid of Array.from(track.children).slice(0, 50)) {
516
+ const r = kid.getBoundingClientRect();
517
+ const s = axis === "x" ? r.width : r.height;
518
+ if (s < span * 0.9 || s > span * 1.1) continue;
519
+ size += 1;
520
+ const at = axis === "x" ? r.left : r.top;
521
+ if (at >= start + span - 1 || at + s <= start + 1) outside += 1;
522
+ }
523
+ return size >= 2 && outside >= 1;
524
+ };
525
+ /**
526
+ * Whether a control in or beside a clipping container pages it: a visible
527
+ * control within two levels above the container that names the container
528
+ * (or something in it) in aria-controls, or, when the container holds
529
+ * slides on the clipped axis, one whose name says next, previous, or a
530
+ * numbered slide or page.
531
+ */
532
+ const pagesClip = (clip, axis) => {
533
+ let region = clip;
534
+ for (let i = 0; i < 2 && region.parentElement && region.parentElement !== document.body; i++) region = region.parentElement;
535
+ const cr = clip.getBoundingClientRect();
536
+ let slides = null;
537
+ const controls = region.querySelectorAll('button, [role="button"], a[href], [role="tab"], input[type="button"]');
538
+ for (const c of Array.from(controls).slice(0, 200)) {
539
+ if (!visible(c)) continue;
540
+ // A control inside the clip must itself be showing: a button on another
541
+ // hidden slide (the clipped element among them) pages nothing.
542
+ if (clip.contains(c)) {
543
+ const r = c.getBoundingClientRect();
544
+ if (r.bottom <= cr.top || r.top >= cr.bottom || r.right <= cr.left || r.left >= cr.right) continue;
545
+ }
546
+ const controlled = (c.getAttribute("aria-controls") || "").split(/\\s+/).filter(Boolean);
547
+ if (controlled.some((id) => { const t = document.getElementById(id); return t && (t === clip || clip.contains(t) || t.contains(clip)); })) return true;
548
+ const name = (c.getAttribute("aria-label") || c.getAttribute("title") || c.textContent || "").trim().replace(/\\s+/g, " ");
549
+ if (PAGER.test(name) && (slides === null ? (slides = slidesIn(clip, axis)) : slides)) return true;
550
+ }
551
+ return false;
552
+ };
553
+
322
554
  for (const el of Array.from(document.querySelectorAll(selector))) {
323
555
  if (seen.has(el) || !visible(el)) continue;
324
556
  seen.add(el);
@@ -342,17 +574,30 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
342
574
  : tag === "output" ? "status"
343
575
  : el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off" ? (el.getAttribute("aria-live") === "assertive" ? "alert" : "status")
344
576
  : "generic");
577
+ if (out.length >= ${COLLECTOR_CAP}) {
578
+ // Past the cap only a bounded number of elements is looked at at all.
579
+ if (++scannedPastCap > ${MAX_SCANNED_PAST_CAP}) { cut.push({ cut: true, uncounted: true }); break; }
580
+ const tid = el.getAttribute("data-testid");
581
+ const said = (el.getAttribute("aria-label") || el.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 60);
582
+ const pages = LIST_PAGER.test(said) || (tid !== null && PAGER_TID.test(tid));
583
+ if (!pages || keptPastCap >= ${MAX_KEPT_PAST_CAP}) { cut.push({ cut: true, role, testid: tid }); continue; }
584
+ keptPastCap += 1;
585
+ }
345
586
  const rect = el.getBoundingClientRect();
587
+ const elStyle = window.getComputedStyle(el);
346
588
  // Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
347
589
  // ancestor is not — the container cannot scroll, so the control exists in
348
590
  // layout but no user can ever see or reach it. Out-of-flow boxes are only
349
591
  // clipped by their CONTAINING-BLOCK chain: position:fixed escapes ordinary
350
592
  // ancestors entirely, and position:absolute skips static ones — a dropdown
351
593
  // panel deliberately escaping its clipping wrapper is NOT unreachable.
352
- const ePos = window.getComputedStyle(el).position;
594
+ // A scrolling ancestor reveals what it holds: past one, what the user can
595
+ // see is that scroller's box, so it is the scroller's box (not the
596
+ // element's) that the clipping ancestors above it are tested against.
353
597
  let clippedByAncestor = false;
354
- if (ePos !== "fixed") {
355
- let escaping = ePos === "absolute";
598
+ if (elStyle.position !== "fixed") {
599
+ let escaping = elStyle.position === "absolute";
600
+ const box = { top: rect.top, bottom: rect.bottom, left: rect.left, right: rect.right };
356
601
  let anc = el.parentElement;
357
602
  while (anc && anc !== document.body && anc.tagName !== "HTML") {
358
603
  const as = window.getComputedStyle(anc);
@@ -362,30 +607,59 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
362
607
  escaping = false;
363
608
  }
364
609
  const oy = as.overflowY, ox = as.overflowX;
365
- const hidesY = oy === "hidden" || oy === "clip";
366
- const hidesX = ox === "hidden" || ox === "clip";
367
- if (hidesY || hidesX) {
368
- const ar = anc.getBoundingClientRect();
369
- if (ar.width > 0 && ar.height > 0) {
370
- const outY = hidesY && (rect.bottom <= ar.top || rect.top >= ar.bottom);
371
- const outX = hidesX && (rect.right <= ar.left || rect.left >= ar.right);
372
- if (outY || outX) { clippedByAncestor = true; break; }
610
+ const ar = anc.getBoundingClientRect();
611
+ if (ar.width > 0 && ar.height > 0) {
612
+ let hidden = null;
613
+ let scrolls = false;
614
+ // A scroller reveals anything it holds on that axis. Not tested
615
+ // against its scroll range: reversed flex columns (chat logs) and
616
+ // right-to-left text scroll to negative offsets.
617
+ if (oy === "auto" || oy === "scroll") { box.top = ar.top; box.bottom = ar.bottom; scrolls = true; }
618
+ else if ((oy === "hidden" || oy === "clip") && (box.bottom <= ar.top || box.top >= ar.bottom)) hidden = "y";
619
+ if (ox === "auto" || ox === "scroll") { box.left = ar.left; box.right = ar.right; scrolls = true; }
620
+ else if ((ox === "hidden" || ox === "clip") && (box.right <= ar.left || box.left >= ar.right)) hidden = "x";
621
+ if (hidden) {
622
+ // A pager beside the container (next/previous, numbered slides, or
623
+ // a control naming it in aria-controls) reveals what it clips.
624
+ if (!revealedByControl(anc, hidden)) clippedByAncestor = true;
625
+ break;
626
+ }
627
+ // Above a scroller, clipping follows the SCROLLER's containing chain.
628
+ if (scrolls) {
629
+ if (as.position === "fixed") break;
630
+ if (as.position === "absolute") escaping = true;
373
631
  }
374
632
  }
375
633
  anc = anc.parentElement;
376
634
  }
377
635
  }
636
+ // Parked above or left of the page: can taking focus bring it back?
637
+ const docX = rect.x + window.scrollX, docY = rect.y + window.scrollY;
638
+ const offPage = docX + rect.width <= 0 || docY + rect.height <= 0;
639
+ const focusable = el.tabIndex >= 0 && el.disabled !== true;
378
640
  const named = nameOf(el);
379
- const interactive = isInteractive(el);
641
+ const affords = affordancesOf(el);
642
+ const interactive = isInteractive(affords);
380
643
  const checkable = tag === "input" && (inputType === "checkbox" || inputType === "radio");
381
644
  out.push({
382
645
  coveredBy: coveredByPinnedChrome(el, rect, role),
646
+ focusable,
647
+ focusMoves: offPage && focusable ? focusMoves(el) : false,
648
+ // pointer-events:none lets every click through: it cannot take one meant for something else.
649
+ passThrough: elStyle.pointerEvents === "none",
650
+ // A text field's side padding, where an adornment (a clear button, an icon) is meant to sit.
651
+ fieldPad: tag === "textarea" || (tag === "input" && /^(textbox|combobox|searchbox|spinbutton)$/.test(role))
652
+ ? { l: parseFloat(elStyle.paddingLeft) || 0, r: parseFloat(elStyle.paddingRight) || 0 }
653
+ : null,
383
654
  tag,
384
655
  role,
385
656
  name: named.name,
657
+ ...policyText(el),
386
658
  nameFrom: named.from,
659
+ ...(named.prior !== undefined ? { priorName: named.prior } : {}),
387
660
  testid: el.getAttribute("data-testid"),
388
661
  interactive,
662
+ affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
389
663
  ariaHidden: el.closest('[aria-hidden="true"]') !== null,
390
664
  liveOnly: !el.matches(controlSelector),
391
665
  state: {
@@ -396,7 +670,7 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
396
670
  current: el.getAttribute("aria-current"),
397
671
  },
398
672
  // Only controls: a tagged cell or row out of view is not something the user has to reach.
399
- scrolledOutIn: ePos === "fixed" || clippedByAncestor || !interactive ? null : scrolledOutIn(el, rect),
673
+ scrolledOutIn: elStyle.position === "fixed" || clippedByAncestor || !interactive ? null : scrolledOutIn(el, rect),
400
674
  xpath: xpathOf(el),
401
675
  disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
402
676
  href: tag === "a" ? el.getAttribute("href") : null,
@@ -411,9 +685,8 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
411
685
  h: Math.round(rect.height),
412
686
  },
413
687
  });
414
- if (out.length >= 150) break;
415
688
  }
416
- return out;
689
+ return cut.length > 0 ? out.concat(cut) : out;
417
690
  })()`;
418
691
  /**
419
692
  * Roles that announce content rather than take input: a live region is empty
@@ -508,6 +781,20 @@ export function placeholderOnly(el) {
508
781
  export function describeControl(el) {
509
782
  return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
510
783
  }
784
+ /**
785
+ * What a forced click's note says about the element on top of its target:
786
+ * `covered by status "Could not save…" [testid=toast-error]`. With
787
+ * `afterBlock`, the write policy refused a request after the tester last
788
+ * looked at the page, so the covering element may be the app's response to
789
+ * the engine's own refusal (an error toast) and in no snapshot.
790
+ */
791
+ export function describeCover(cover, afterBlock) {
792
+ const text = cover.text.replace(/\s+/g, " ").trim();
793
+ const shown = text.length > 60 ? `${text.slice(0, 59)}…` : text;
794
+ const who = cover.role ?? `<${cover.tag}>`;
795
+ return (`covered by ${who}${shown ? ` ${JSON.stringify(shown)}` : ""}${cover.testid ? ` [testid=${cover.testid}]` : ""}` +
796
+ (afterBlock ? " (after a write-policy block since the last snapshot: it may be the app's response to the engine's own refusal)" : ""));
797
+ }
511
798
  /** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
512
799
  export function placeholderEvidence(el) {
513
800
  const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
@@ -527,6 +814,170 @@ export function displayName(el) {
527
814
  return el.name;
528
815
  return LIVE_REGION_ROLES.has(el.role) ? "(empty live region)" : "(unnamed)";
529
816
  }
817
+ /**
818
+ * The snapshot's flags for an element listed without a control's role that a
819
+ * user can still act on: "clickable" for a click handler or its own pointer
820
+ * cursor, "focusable" for a tab stop. A row a click opens then reads apart
821
+ * from a wrapper listed only for its test id, which gets neither.
822
+ */
823
+ export function affordanceFlags(el) {
824
+ const a = el.affords;
825
+ if (!a)
826
+ return [];
827
+ const flags = [];
828
+ if (a.clickHandler || a.pointer)
829
+ flags.push("clickable");
830
+ if (a.tabStop)
831
+ flags.push("focusable");
832
+ return flags;
833
+ }
834
+ /**
835
+ * A test id as a family: a trailing number or id after a separator becomes
836
+ * "*", so "row-12" and "row-13" read as "row-*" while "pager-next" stays itself.
837
+ */
838
+ export function testidFamily(testid) {
839
+ return testid.replace(/([-_.:])(?:\d+|[0-9a-f]{8}(?:-?[0-9a-f]{4}){3}-?[0-9a-f]{12}|[0-9a-f]{12,})$/i, "$1*");
840
+ }
841
+ /**
842
+ * What a truncated snapshot left out, by role and test-id family, largest
843
+ * group first: "38 link [row-*], 1 button [pager-next], 12 generic". At most
844
+ * `groups` groups are named; the rest is counted together. "and more past
845
+ * those, not counted" when the collector stopped looking.
846
+ */
847
+ export function cutSummary(cut, groups = 8) {
848
+ const counts = new Map();
849
+ let uncounted = false;
850
+ for (const c of cut) {
851
+ if (c.uncounted) {
852
+ uncounted = true;
853
+ continue;
854
+ }
855
+ const label = `${c.role ?? "element"}${c.testid ? ` [${testidFamily(c.testid)}]` : ""}`;
856
+ counts.set(label, (counts.get(label) ?? 0) + 1);
857
+ }
858
+ const sorted = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
859
+ const named = sorted.slice(0, groups).map(([label, n]) => `${n} ${label}`);
860
+ const rest = sorted.slice(groups).reduce((sum, [, n]) => sum + n, 0);
861
+ if (rest > 0)
862
+ named.push(`${rest} other`);
863
+ if (uncounted)
864
+ named.push("and more past those, not counted");
865
+ return named.join(", ");
866
+ }
867
+ /** A key without the ordinal the collector adds to the second and later elements sharing it ("tid:row~3" → "tid:row"). */
868
+ export function keyFamily(key) {
869
+ return key.replace(/~\d+$/, "");
870
+ }
871
+ /**
872
+ * Which element of the previous snapshot each current element is, as that
873
+ * snapshot's key, or null for an element new since. Gives the ref an element
874
+ * keeps, and what the diff calls added, removed or changed.
875
+ *
876
+ * An element alone under its key is the one that held the key before, so its
877
+ * new text reads as a relabel. Elements sharing a key (rows tagged with one
878
+ * test id) are told apart by their own text and href, not their position: a
879
+ * filtered list then reads as the rows it lost and gained, where by position
880
+ * the first row "became" another record. A row that kept its link but changed
881
+ * its text is still matched by the link. `byPosition` keeps the position for
882
+ * elements whose text is meant to change, such as live regions.
883
+ */
884
+ export function matchPrevious(prev, current) {
885
+ const prevByFamily = new Map();
886
+ for (const key of prev.keys()) {
887
+ const fam = keyFamily(key);
888
+ const list = prevByFamily.get(fam);
889
+ if (list)
890
+ list.push(key);
891
+ else
892
+ prevByFamily.set(fam, [key]);
893
+ }
894
+ const curByFamily = new Map();
895
+ current.forEach((el, i) => {
896
+ const fam = keyFamily(el.key);
897
+ const list = curByFamily.get(fam);
898
+ if (list)
899
+ list.push(i);
900
+ else
901
+ curByFamily.set(fam, [i]);
902
+ });
903
+ const out = current.map(() => null);
904
+ for (const [fam, idxs] of curByFamily) {
905
+ const prevKeys = prevByFamily.get(fam) ?? [];
906
+ const single = prevKeys.length <= 1 && idxs.length <= 1;
907
+ if (single || idxs.some((i) => current[i].byPosition)) {
908
+ for (const i of idxs)
909
+ out[i] = prev.has(current[i].key) ? current[i].key : null;
910
+ continue;
911
+ }
912
+ const free = new Set(prevKeys);
913
+ const take = (i, same) => {
914
+ for (const k of free) {
915
+ if (same(prev.get(k))) {
916
+ out[i] = k;
917
+ free.delete(k);
918
+ return;
919
+ }
920
+ }
921
+ };
922
+ for (const i of idxs)
923
+ take(i, (p) => p.label === current[i].name && (p.href ?? null) === (current[i].href ?? null));
924
+ for (const i of idxs)
925
+ if (out[i] === null && current[i].href)
926
+ take(i, (p) => !!p.href && p.href === current[i].href);
927
+ }
928
+ return out;
929
+ }
930
+ /**
931
+ * Whether a box parked off the page comes back when it takes focus: a skip
932
+ * link. Either a link into this same document (the skip-link shape), or any
933
+ * focusable element a :focus rule moves. It must be reachable by Tab, or
934
+ * nothing ever brings it back.
935
+ */
936
+ export function revealedOnFocus(el) {
937
+ if (!el.focusable)
938
+ return false;
939
+ return IN_PAGE_ANCHOR.test(el.href ?? "") || el.focusMoves === true;
940
+ }
941
+ /**
942
+ * Whether `other` is an adornment of the text field `field`: a clear button or
943
+ * icon lying wholly inside the padding the field reserves at its left or right
944
+ * edge. Text never runs under that padding, so nothing collides. An element
945
+ * that reaches into the text area is a real collision and is not an adornment.
946
+ */
947
+ export function isFieldAdornment(field, other) {
948
+ const pad = field.fieldPad;
949
+ if (!pad)
950
+ return false;
951
+ const f = field.rect;
952
+ const r = other.rect;
953
+ const t = 4; // borders, and rounding to whole pixels
954
+ if (r.y < f.y - t || r.y + r.h > f.y + f.h + t)
955
+ return false;
956
+ const inLeft = pad.l > 0 && r.x >= f.x - t && r.x + r.w <= f.x + pad.l + t;
957
+ const inRight = pad.r > 0 && r.x >= f.x + f.w - pad.r - t && r.x + r.w <= f.x + f.w + t;
958
+ return inLeft || inRight;
959
+ }
960
+ /** Roles of the controls a click is aimed at; anything else with pointer-events:none is decoration. */
961
+ const CONTROL_ROLES = new Set([
962
+ "button",
963
+ "link",
964
+ "textbox",
965
+ "combobox",
966
+ "checkbox",
967
+ "radio",
968
+ "switch",
969
+ "tab",
970
+ "menuitem",
971
+ "file",
972
+ "searchbox",
973
+ "spinbutton",
974
+ "slider",
975
+ "option",
976
+ ]);
977
+ /** Whether `overlay` is a decorative layer over `other` that lets clicks through to it. */
978
+ export function isPassThroughOverlay(overlay, other) {
979
+ return overlay.passThrough === true && !CONTROL_ROLES.has(overlay.role) && overlay.rect.w * overlay.rect.h >= other.rect.w * other.rect.h;
980
+ }
530
981
  /**
531
982
  * Deterministic geometry oracles — the checks people reach for screenshots to
532
983
  * do, computed from layout boxes instead: interactables rendered fully outside
@@ -539,8 +990,9 @@ export function geometryIssues(elements, viewport) {
539
990
  const { x, y, w, h } = el.rect;
540
991
  // Rects are DOCUMENT coords: below-the-fold content is normal; unreachable
541
992
  // means left/above the document origin, or absurdly far right (no page
542
- // scrolls 3 viewports horizontally on purpose).
543
- if (w > 0 && h > 0 && (x + w <= 0 || y + h <= 0 || x >= viewport.width * 3)) {
993
+ // scrolls 3 viewports horizontally on purpose). A skip link parked there
994
+ // until it takes focus is the accessibility pattern working.
995
+ if (w > 0 && h > 0 && (x + w <= 0 || y + h <= 0 || x >= viewport.width * 3) && !revealedOnFocus(el)) {
544
996
  issues.push(`${el.ref} ${el.role} "${el.name}" is rendered outside the reachable page area (${x},${y} ${w}×${h})`);
545
997
  }
546
998
  // Distinct from below-the-fold: this control's own container hides it and
@@ -608,6 +1060,15 @@ export function geometryIssues(elements, viewport) {
608
1060
  // collision bugs that matter live in the content flow, not the chrome.
609
1061
  if (a.chrome && b.chrome)
610
1062
  continue;
1063
+ // An overlay with pointer-events:none cannot take a click meant for what
1064
+ // it lies over. Only a non-control at least as large as the other counts:
1065
+ // design systems also set it on disabled buttons, and a disabled button
1066
+ // drawn over another is still a collision.
1067
+ if (isPassThroughOverlay(a, b) || isPassThroughOverlay(b, a))
1068
+ continue;
1069
+ // A clear button or icon inside a text field's reserved padding.
1070
+ if (isFieldAdornment(a, b) || isFieldAdornment(b, a))
1071
+ continue;
611
1072
  const area = overlapArea(a.rect, b.rect);
612
1073
  const smaller = Math.min(a.rect.w * a.rect.h, b.rect.w * b.rect.h);
613
1074
  if (smaller > 0 && area / smaller > 0.6) {
@@ -667,7 +1128,7 @@ export const MAIN_REGION_SCRIPT = `(() => {
667
1128
  const landmarks = Array.from(document.querySelectorAll('main, [role="main"]')).filter(shown);
668
1129
  const landmark = landmarks[0] || null;
669
1130
  const region = landmark || document.body;
670
- if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0 };
1131
+ if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0, states: [], rest: 0, text: "" };
671
1132
  const CHROME = 'header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]';
672
1133
  const outside = (n) => !landmark && !!n.closest(CHROME);
673
1134
  const CONTROL = 'a[href], button, input, select, textarea, option, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"]';
@@ -677,24 +1138,94 @@ export const MAIN_REGION_SCRIPT = `(() => {
677
1138
  const level = h ? (/^H[1-6]$/.test(h.tagName) ? Number(h.tagName[1]) : Number(h.getAttribute("aria-level") || 2)) : 0;
678
1139
  const heading = h ? { level, text: (h.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 60) } : null;
679
1140
  const paragraphs = inRegion("p").filter((n) => (n.textContent || "").trim()).length;
1141
+ // Status regions: an alert, a live status, a busy or progress marker. What lies outside
1142
+ // them is the page's own content; a main area with none is showing only its state.
1143
+ const STATE = '[role="alert"], [role="status"], [aria-busy="true"], [role="progressbar"]';
1144
+ const stateOf = (n) => {
1145
+ const s = n.closest(STATE);
1146
+ return s && (s === region || region.contains(s)) ? s : null;
1147
+ };
1148
+ const states = [];
1149
+ for (const s of [region, ...inRegion(STATE)]) {
1150
+ if (!s.matches(STATE)) continue;
1151
+ const role = s.getAttribute("role");
1152
+ const kind = role === "alert" || role === "status" || role === "progressbar" ? role : "busy";
1153
+ if (!states.includes(kind)) states.push(kind);
1154
+ if (s.getAttribute("aria-busy") === "true" && !states.includes("busy")) states.push("busy");
1155
+ }
680
1156
  let chars = 0;
1157
+ let rest = 0;
1158
+ let text = "";
681
1159
  let seen = 0;
682
1160
  const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
683
1161
  for (let t = walker.nextNode(); t && seen < 5000; t = walker.nextNode()) {
684
1162
  seen += 1;
685
1163
  const parent = t.parentElement;
686
1164
  if (!parent || parent.closest('script, style, noscript, template, [aria-hidden="true"]') || parent.closest(CONTROL) || outside(parent) || !shown(parent)) continue;
687
- chars += (t.textContent || "").replace(/\\s+/g, " ").trim().length;
1165
+ const words = (t.textContent || "").replace(/\\s+/g, " ").trim();
1166
+ chars += words.length;
1167
+ if (!stateOf(parent)) rest += words.length;
1168
+ if (words && text.length < 160) text = (text ? text + " " + words : words).slice(0, 160);
688
1169
  }
1170
+ const controls = inRegion(CONTROL);
1171
+ const media = inRegion("img, svg, video, canvas, iframe, object, embed");
1172
+ rest += [...headings, ...controls, ...media].filter((n) => !stateOf(n)).length;
689
1173
  return {
690
1174
  landmark: !!landmark,
691
1175
  heading,
692
1176
  paragraphs,
693
1177
  chars,
694
- controls: inRegion(CONTROL).length,
695
- media: inRegion("img, svg, video, canvas, iframe, object, embed").length,
1178
+ controls: controls.length,
1179
+ media: media.length,
1180
+ states,
1181
+ rest,
1182
+ text,
696
1183
  };
697
1184
  })()`;
1185
+ /**
1186
+ * Whether text says only that something is on its way, e.g. "Loading…",
1187
+ * "Loading orders...", "Please wait", once or repeated. Each sentence must
1188
+ * open with the loading words and stay short.
1189
+ */
1190
+ function isLoadingText(text) {
1191
+ const parts = text
1192
+ .split(/\.{1,3}|…/)
1193
+ .map((p) => p.trim())
1194
+ .filter(Boolean);
1195
+ return parts.length > 0 && parts.every((p) => /^(?:loading|please wait)\b[^!?]{0,30}$/i.test(p));
1196
+ }
1197
+ /**
1198
+ * What a main area holding nothing but its state is showing, or null when it
1199
+ * holds content of its own. A client-rendered app answers 200 for a path it
1200
+ * does not have and draws its not-found or error view; a route still waiting
1201
+ * on data after the page settled shows only a placeholder. Both read as a
1202
+ * healthy page to a status code and an element count.
1203
+ *
1204
+ * - `error`: everything in the area is inside a `role="alert"` region that says something.
1205
+ * - `loading`: everything is inside an `aria-busy="true"` region or a
1206
+ * progress bar, or inside a live status whose only words are a loading
1207
+ * message or that holds only a spinner, or (with no such marker at all)
1208
+ * the area's only words are a loading message.
1209
+ *
1210
+ * A live status saying anything else ("No orders yet") is an empty state,
1211
+ * which is content, and so is an alert beside a heading or a control.
1212
+ */
1213
+ export function mainState(m) {
1214
+ const states = m.states ?? [];
1215
+ const text = (m.text ?? "").trim();
1216
+ if (states.length === 0) {
1217
+ return !m.heading && m.controls === 0 && m.media === 0 && isLoadingText(text) ? "loading" : null;
1218
+ }
1219
+ if ((m.rest ?? 1) > 0)
1220
+ return null;
1221
+ // An alert with nothing in it is a live region waiting for a message, not a message.
1222
+ if (states.includes("alert") && text !== "")
1223
+ return "error";
1224
+ if (states.includes("busy") || states.includes("progressbar"))
1225
+ return "loading";
1226
+ // A spinner inside a status, or a status saying it is loading; an empty one alone is just an empty main area.
1227
+ return states.includes("status") && (isLoadingText(text) || (text === "" && m.media > 0)) ? "loading" : null;
1228
+ }
698
1229
  /** Nothing at all in the region: no heading, no text, no control, no image or embed. */
699
1230
  function mainIsEmpty(m) {
700
1231
  return !m.heading && m.chars === 0 && m.controls === 0 && m.media === 0;