scenescout 3.14.1 → 3.16.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 +94 -0
  2. package/README.md +90 -18
  3. package/dist/browsers.js +28 -0
  4. package/dist/check-run.js +211 -24
  5. package/dist/ci-run.js +374 -62
  6. package/dist/cli.js +221 -47
  7. package/dist/commands.js +42 -4
  8. package/dist/engine/baseline.js +377 -0
  9. package/dist/engine/brief.js +16 -7
  10. package/dist/engine/browser.js +1660 -386
  11. package/dist/engine/calibration.js +61 -30
  12. package/dist/engine/capture.js +164 -0
  13. package/dist/engine/check.js +258 -68
  14. package/dist/engine/ci-lanes.js +215 -0
  15. package/dist/engine/ci.js +233 -18
  16. package/dist/engine/claims.js +234 -12
  17. package/dist/engine/collector.js +851 -78
  18. package/dist/engine/crawl.js +49 -0
  19. package/dist/engine/dedup.js +329 -9
  20. package/dist/engine/design.js +355 -43
  21. package/dist/engine/export.js +877 -0
  22. package/dist/engine/fingerprint.js +92 -4
  23. package/dist/engine/flow.js +18 -6
  24. package/dist/engine/forms.js +198 -18
  25. package/dist/engine/journey.js +29 -1
  26. package/dist/engine/lane.js +13 -3
  27. package/dist/engine/launch.js +45 -6
  28. package/dist/engine/limits.js +7 -0
  29. package/dist/engine/live-page.js +49 -2
  30. package/dist/engine/live.js +4 -1
  31. package/dist/engine/memory.js +668 -70
  32. package/dist/engine/open.js +118 -0
  33. package/dist/engine/oracles.js +167 -2
  34. package/dist/engine/plain.js +268 -0
  35. package/dist/engine/png.js +127 -0
  36. package/dist/engine/policy.js +379 -9
  37. package/dist/engine/probes.js +3 -2
  38. package/dist/engine/profiles.js +45 -9
  39. package/dist/engine/project-folder.js +191 -0
  40. package/dist/engine/provider.js +4 -3
  41. package/dist/engine/refresh.js +338 -25
  42. package/dist/engine/replay.js +63 -10
  43. package/dist/engine/report.js +248 -40
  44. package/dist/engine/request.js +317 -23
  45. package/dist/engine/sarif.js +120 -0
  46. package/dist/engine/scripted-login.js +342 -62
  47. package/dist/engine/settle.js +67 -0
  48. package/dist/engine/signed-in.js +256 -0
  49. package/dist/engine/tickets.js +671 -0
  50. package/dist/engine/unload.js +3 -2
  51. package/dist/export-run.js +633 -0
  52. package/dist/first-run.js +581 -0
  53. package/dist/installer.js +267 -3
  54. package/dist/intake.js +104 -0
  55. package/dist/login-run.js +386 -71
  56. package/dist/mcp-server.js +637 -68
  57. package/dist/playbook.js +5 -0
  58. package/package.json +6 -3
  59. package/skills/scenescout/SKILL.md +51 -18
@@ -39,45 +39,172 @@ export const XPATH_OF_SRC = `(el) => {
39
39
  }
40
40
  return "/html/" + parts.join("/");
41
41
  }`;
42
+ /**
43
+ * What an element's accessible name is read from, as page-side source: every
44
+ * candidate source, read from the DOM and nothing decided. PICK_NAME_SRC
45
+ * decides, so the order can be table-tested without a browser.
46
+ *
47
+ * A label's text is read without the control's own text: a wrapping label
48
+ * holds the control, and a select's options or a textarea's contents are not
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.
55
+ */
56
+ export const NAME_FACTS_SRC = `(el) => {
57
+ const tag = el.tagName.toLowerCase();
58
+ const role = el.getAttribute("role") || "";
59
+ const inputType = tag === "input" ? el.type : "";
60
+ const byId = (id) => { const n = document.getElementById(id); return n ? n.textContent || "" : ""; };
61
+ const labels = [];
62
+ for (const label of Array.from(el.labels || [])) {
63
+ const own = label.contains(el) ? el.textContent || "" : "";
64
+ labels.push((label.textContent || "").replace(own, ""));
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
+ }
77
+ const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
78
+ (el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
79
+ return {
80
+ tag,
81
+ inputType,
82
+ labelledBy: (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean).map(byId).join(" "),
83
+ ariaLabel: el.getAttribute("aria-label"),
84
+ labels,
85
+ title: el.getAttribute("title") || "",
86
+ placeholder: el.getAttribute("placeholder") || "",
87
+ nameAttr: el.getAttribute("name") || "",
88
+ // Read only for button-like inputs: a text field's value is what the user typed, not its name.
89
+ value: inputType === "submit" || inputType === "reset" || inputType === "button" ? el.value || "" : "",
90
+ alt: el.getAttribute("alt") || "",
91
+ live,
92
+ text: el.innerText || el.textContent || "",
93
+ content,
94
+ };
95
+ }`;
96
+ /**
97
+ * The accessible name chosen from NAME_FACTS_SRC's facts, as page-side source,
98
+ * in the order of the accessible-name computation: aria-labelledby,
99
+ * aria-label, then the host language's label (an image's alt; a <label>,
100
+ * for= or wrapping; a button-like input's value), then title, then the
101
+ * placeholder. Returns the name and, for a field, where it came from when that
102
+ * is not a label (`from`, see NameFrom): the name falls back to the
103
+ * placeholder, the name attribute or the type so every field can be told apart
104
+ * and targeted, and the name is half of the element's coverage key, so the
105
+ * fallback stays.
106
+ *
107
+ * - A select is never named by its options: their text is its value, not its name.
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).
117
+ * - A live region (status, alert, log, timer, an aria-live region, <output>)
118
+ * is named by the text it announces, which is what matters after an action.
119
+ * - A whitespace-only aria-label ends the name with nothing, as it always has
120
+ * here: the field it hides is reported as unnamed.
121
+ */
122
+ export const PICK_NAME_SRC = `(f) => {
123
+ const clean = (s) => (s || "").replace(/\\s+/g, " ").trim();
124
+ const named = (s) => ({ name: s.slice(0, 80), from: null });
125
+ const field = f.tag === "input" || f.tag === "textarea" || f.tag === "select";
126
+ if (clean(f.labelledBy)) return named(clean(f.labelledBy));
127
+ if (f.ariaLabel) {
128
+ if (clean(f.ariaLabel)) return named(clean(f.ariaLabel));
129
+ return { name: "", from: field ? "fallback" : null };
130
+ }
131
+ if (f.tag === "img") return named(clean(f.alt));
132
+ if (f.live && clean(f.text)) return named(clean(f.text));
133
+ const labels = f.labels.map(clean).filter(Boolean).join(" ");
134
+ if (labels) return named(labels);
135
+ if (field) {
136
+ const t = f.inputType;
137
+ if (t === "submit" || t === "reset" || t === "button") {
138
+ if (clean(f.value)) return named(clean(f.value));
139
+ // The browser's own text: what a user sees on the button.
140
+ if (t !== "button") return named(t === "submit" ? "Submit" : "Reset");
141
+ }
142
+ if (t === "image" && clean(f.alt)) return named(clean(f.alt));
143
+ if (clean(f.title)) return named(clean(f.title));
144
+ if (f.tag !== "select" && clean(f.placeholder)) return { name: clean(f.placeholder).slice(0, 80), from: "placeholder" };
145
+ return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
146
+ }
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) };
149
+ return named(clean(f.title));
150
+ }`;
151
+ /**
152
+ * PICK_NAME_SRC run outside a page, from the same source the page runs, so a
153
+ * table test exercises exactly the rule the collector ships.
154
+ */
155
+ export function pickName(facts) {
156
+ return new Function(`return (${PICK_NAME_SRC});`)()(facts);
157
+ }
158
+ /** The element's name and where a field's came from, as page-side source. */
159
+ export const NAME_SRC = `(el) => (${PICK_NAME_SRC})((${NAME_FACTS_SRC})(el))`;
42
160
  /**
43
161
  * The collector's accessible name, as page-side source. Exported for the same
44
162
  * reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
45
163
  * must name it exactly as the snapshot did, or the two cannot be compared.
46
164
  */
47
- export const ACCESSIBLE_NAME_SRC = `(el) => {
48
- const aria = el.getAttribute("aria-label");
49
- if (aria) return aria.trim();
50
- // aria-labelledby before any fallback: it is the standard way to name an
51
- // icon-only control from adjacent text, and skipping it made exactly those
52
- // buttons report an empty name — which then read as an a11y defect the app
53
- // did not actually have, and made the element harder to target.
54
- const labelledBy = el.getAttribute("aria-labelledby");
55
- if (labelledBy) {
56
- const named = labelledBy
57
- .split(/\\s+/)
58
- .map((id) => {
59
- const n = document.getElementById(id);
60
- return n && n.textContent ? n.textContent.trim() : "";
61
- })
62
- .filter(Boolean)
63
- .join(" ");
64
- if (named) return named.replace(/\\s+/g, " ").slice(0, 80);
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;
65
189
  }
66
- const tag = el.tagName.toLowerCase();
67
- // An image's name is its alt text. Without this an <img> read as
68
- // "(unnamed)" even when it was labelled, and a missing alt looked the same
69
- // as a present one.
70
- if (tag === "img") return (el.getAttribute("alt") || "").trim().slice(0, 80);
71
- if (tag === "input" || tag === "textarea") {
72
- const id = el.getAttribute("id");
73
- if (id) {
74
- const label = document.querySelector('label[for="' + CSS.escape(id) + '"]');
75
- if (label && label.textContent) return label.textContent.trim();
76
- }
77
- return (el.getAttribute("placeholder") || el.getAttribute("name") || el.type || "input").trim();
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);
78
206
  }
79
- const text = el.innerText || el.textContent || "";
80
- return text.trim().replace(/\\s+/g, " ").slice(0, 80);
207
+ return { ownText, centre };
81
208
  }`;
82
209
  /**
83
210
  * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
@@ -87,6 +214,63 @@ export const ACCESSIBLE_NAME_SRC = `(el) => {
87
214
  */
88
215
  export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[open], [aria-modal="true"], [class*="modal" i], [class*="dialog" i]';
89
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;
90
274
  /**
91
275
  * Page-side interactable collector. Shipped as a STRING, not a function:
92
276
  * loader transforms (tsx/vitest esbuild hooks inject a `__name` helper) break
@@ -96,37 +280,69 @@ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[op
96
280
  export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
97
281
  const xpathOf = ${XPATH_OF_SRC};
98
282
  const visible = ${VISIBLE_SRC};
99
- const accessibleName = ${ACCESSIBLE_NAME_SRC};
100
- // Whether a field's accessibleName came from something that is not a label. The
101
- // name falls back to the placeholder, then the name attribute, then the type,
102
- // so every field can be told apart and targeted, and it is half of the
103
- // element's key, which project memory keeps across runs: it stays as it is.
104
- // This says what the fallback hides. A real label is aria-label,
105
- // aria-labelledby, a <label> (for= or wrapping, read through el.labels),
106
- // title, or for a button-like input its value or the browser's default text.
107
- // "placeholder" when the placeholder is all there is, "fallback" when not even
108
- // that, null when the field is labelled or is not a field.
109
- const nameFrom = (el) => {
110
- const tag = el.tagName.toLowerCase();
111
- if (tag !== "input" && tag !== "textarea") return null;
112
- if ((el.getAttribute("aria-label") || "").trim()) return null;
113
- const labelledBy = (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean);
114
- if (labelledBy.some((id) => { const n = document.getElementById(id); return n && (n.textContent || "").trim(); })) return null;
115
- for (const label of Array.from(el.labels || [])) {
116
- // A wrapping label's text includes a textarea's own content; that is not a label.
117
- const own = label.contains(el) ? el.textContent || "" : "";
118
- if ((label.textContent || "").replace(own, "").trim()) return null;
119
- }
120
- if ((el.getAttribute("title") || "").trim()) return null;
121
- const type = tag === "input" ? el.type : "";
122
- if (type === "submit" || type === "reset") return null;
123
- if (type === "button" && (el.value || "").trim()) return null;
124
- if (type === "image" && (el.getAttribute("alt") || "").trim()) return null;
125
- return (el.getAttribute("placeholder") || "").trim() ? "placeholder" : "fallback";
126
- };
127
- const selector =
283
+ const nameOf = ${NAME_SRC};
284
+ const policyText = ${POLICY_TEXT_SRC};
285
+ // What the collector lists before anything else: controls, and anything the
286
+ // app tagged with a test id so it can be targeted.
287
+ const controlSelector =
128
288
  'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
129
289
  '[role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"], [onclick], [data-testid]';
290
+ // Live regions: what a page announces after an action (an error banner, a
291
+ // "Saved" status). Listed whether or not the app tagged them, because what
292
+ // the user is told must not depend on a test id. Those listed only for this
293
+ // are marked liveOnly: they are read, not acted on.
294
+ const liveSelector = '[role="alert"], [role="alertdialog"], [role="status"], [aria-live]:not([aria-live="off"]), output';
295
+ const selector = controlSelector + ", " + liveSelector;
296
+ // Whether a user can act on the element: a native control, an interactive
297
+ // role, a tab stop, a click handler, or a pointer cursor it sets itself (a
298
+ // cursor inherited from a clickable card is the card's, not its badge's).
299
+ // Everything else listed is here for its test id or its text, and is neither
300
+ // an unnamed control nor a gap in coverage.
301
+ const CONTROL_ROLES = /^(button|link|tab|menuitem|menuitemcheckbox|menuitemradio|checkbox|radio|switch|combobox|textbox|searchbox|slider|spinbutton|option|treeitem)$/;
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;
307
+ const tabindex = el.getAttribute("tabindex");
308
+ const tabStop = tabindex !== null && tabindex.trim() !== "" && Number(tabindex) >= 0;
309
+ const clickHandler = el.hasAttribute("onclick") || typeof el.onclick === "function";
310
+ let pointer = false;
311
+ if (window.getComputedStyle(el).cursor === "pointer") {
312
+ const parent = el.parentElement;
313
+ pointer = !parent || window.getComputedStyle(parent).cursor !== "pointer";
314
+ }
315
+ return { tabStop, clickHandler, pointer };
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 = [];
325
+ // A horizontally scrolling container a control sits outside of: the control
326
+ // is reachable, by a sideways scroll of that container, but nothing on screen
327
+ // shows it is there. Carousels (scroll-snap) page sideways by design and are
328
+ // left out. Returns how to name the container, or null.
329
+ const scrolledOutIn = (el, rect) => {
330
+ for (let anc = el.parentElement; anc && anc !== document.body && anc !== document.documentElement; anc = anc.parentElement) {
331
+ const as = window.getComputedStyle(anc);
332
+ if ((as.overflowX === "auto" || as.overflowX === "scroll") && anc.scrollWidth > anc.clientWidth + 1) {
333
+ if (as.scrollSnapType && as.scrollSnapType !== "none") return null;
334
+ const left = anc.getBoundingClientRect().left + anc.clientLeft;
335
+ const cx = rect.left + rect.width / 2;
336
+ if (cx >= left && cx <= left + anc.clientWidth) return null;
337
+ const tid = anc.getAttribute("data-testid");
338
+ if (tid) return "[" + tid + "]";
339
+ const label = (anc.getAttribute("aria-label") || "").trim().slice(0, 40);
340
+ return "<" + anc.tagName.toLowerCase() + (anc.id ? "#" + anc.id : "") + ">" + (label ? ' "' + label + '"' : "");
341
+ }
342
+ if (as.position === "fixed") return null;
343
+ }
344
+ return null;
345
+ };
130
346
  const seen = new Set();
131
347
  const out = [];
132
348
  // Positioning/scroll LAYER + CHROME classification, for the overlap oracle.
@@ -221,6 +437,39 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
221
437
  const text = (node.innerText || node.textContent || "").trim().replace(/\\s+/g, " ").slice(0, 40);
222
438
  return "<" + node.tagName.toLowerCase() + ">" + (text ? ' "' + text + '"' : "");
223
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
+ };
224
473
  const coveredByPinnedChrome = (el, rect, role) => {
225
474
  if (INTERACTIVE_ROLES.indexOf(role) === -1) return null;
226
475
  const cx = rect.left + rect.width / 2, cy = rect.top + rect.height / 2;
@@ -229,6 +478,11 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
229
478
  if (!ownRoot || insideScrollablePane(el)) return null;
230
479
  const top = document.elementFromPoint(cx, cy);
231
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;
232
486
  const coverRoot = pinnedRootOf(top);
233
487
  if (!coverRoot || coverRoot === ownRoot || coverRoot.contains(ownRoot) || ownRoot.contains(coverRoot)) return null;
234
488
  // A dialog's fixed wrapper often carries no role or class itself; the
@@ -240,6 +494,63 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
240
494
  return describe(coverRoot);
241
495
  };
242
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
+
243
554
  for (const el of Array.from(document.querySelectorAll(selector))) {
244
555
  if (seen.has(el) || !visible(el)) continue;
245
556
  seen.add(el);
@@ -258,18 +569,35 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
258
569
  : inputType === "file" ? "file"
259
570
  : "textbox")
260
571
  : tag === "img" ? "image"
572
+ // An aria-live region without a role announces like one: assertive
573
+ // interrupts as an alert does, polite waits as a status does.
574
+ : tag === "output" ? "status"
575
+ : el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off" ? (el.getAttribute("aria-live") === "assertive" ? "alert" : "status")
261
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
+ }
262
586
  const rect = el.getBoundingClientRect();
587
+ const elStyle = window.getComputedStyle(el);
263
588
  // Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
264
589
  // ancestor is not — the container cannot scroll, so the control exists in
265
590
  // layout but no user can ever see or reach it. Out-of-flow boxes are only
266
591
  // clipped by their CONTAINING-BLOCK chain: position:fixed escapes ordinary
267
592
  // ancestors entirely, and position:absolute skips static ones — a dropdown
268
593
  // panel deliberately escaping its clipping wrapper is NOT unreachable.
269
- 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.
270
597
  let clippedByAncestor = false;
271
- if (ePos !== "fixed") {
272
- 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 };
273
601
  let anc = el.parentElement;
274
602
  while (anc && anc !== document.body && anc.tagName !== "HTML") {
275
603
  const as = window.getComputedStyle(anc);
@@ -279,26 +607,70 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
279
607
  escaping = false;
280
608
  }
281
609
  const oy = as.overflowY, ox = as.overflowX;
282
- const hidesY = oy === "hidden" || oy === "clip";
283
- const hidesX = ox === "hidden" || ox === "clip";
284
- if (hidesY || hidesX) {
285
- const ar = anc.getBoundingClientRect();
286
- if (ar.width > 0 && ar.height > 0) {
287
- const outY = hidesY && (rect.bottom <= ar.top || rect.top >= ar.bottom);
288
- const outX = hidesX && (rect.right <= ar.left || rect.left >= ar.right);
289
- 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;
290
631
  }
291
632
  }
292
633
  anc = anc.parentElement;
293
634
  }
294
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;
640
+ const named = nameOf(el);
641
+ const affords = affordancesOf(el);
642
+ const interactive = isInteractive(affords);
643
+ const checkable = tag === "input" && (inputType === "checkbox" || inputType === "radio");
295
644
  out.push({
296
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,
297
654
  tag,
298
655
  role,
299
- name: accessibleName(el),
300
- nameFrom: nameFrom(el),
656
+ name: named.name,
657
+ ...policyText(el),
658
+ nameFrom: named.from,
659
+ ...(named.prior !== undefined ? { priorName: named.prior } : {}),
301
660
  testid: el.getAttribute("data-testid"),
661
+ interactive,
662
+ affords: affords && (affords.tabStop || affords.clickHandler || affords.pointer) ? affords : null,
663
+ ariaHidden: el.closest('[aria-hidden="true"]') !== null,
664
+ liveOnly: !el.matches(controlSelector),
665
+ state: {
666
+ pressed: el.getAttribute("aria-pressed"),
667
+ selected: el.getAttribute("aria-selected"),
668
+ checked: checkable ? String(el.checked) : el.getAttribute("aria-checked"),
669
+ expanded: el.getAttribute("aria-expanded"),
670
+ current: el.getAttribute("aria-current"),
671
+ },
672
+ // Only controls: a tagged cell or row out of view is not something the user has to reach.
673
+ scrolledOutIn: elStyle.position === "fixed" || clippedByAncestor || !interactive ? null : scrolledOutIn(el, rect),
302
674
  xpath: xpathOf(el),
303
675
  disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
304
676
  href: tag === "a" ? el.getAttribute("href") : null,
@@ -313,9 +685,8 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
313
685
  h: Math.round(rect.height),
314
686
  },
315
687
  });
316
- if (out.length >= 150) break;
317
688
  }
318
- return out;
689
+ return cut.length > 0 ? out.concat(cut) : out;
319
690
  })()`;
320
691
  /**
321
692
  * Roles that announce content rather than take input: a live region is empty
@@ -331,10 +702,70 @@ const LIVE_REGION_ROLES = new Set(["status", "alert", "log", "timer", "marquee"]
331
702
  * targeted, but nothing announces them.
332
703
  */
333
704
  export function missingName(el) {
705
+ // Listed for its test id or its text, not a control: a decorative badge, a
706
+ // page wrapper. Absent on elements collected before the collector said.
707
+ if (el.interactive === false)
708
+ return false;
709
+ // Hidden from assistive technology on purpose: nothing announces it, so it needs no name.
710
+ if (el.ariaHidden)
711
+ return false;
334
712
  if (el.nameFrom === "fallback")
335
713
  return true;
336
714
  return !el.name && !LIVE_REGION_ROLES.has(el.role);
337
715
  }
716
+ /**
717
+ * The snapshot's state markers: which filter is active, which tab is chosen,
718
+ * which box is ticked, which section is open. Only states that are on are
719
+ * shown, so a page of plain buttons stays as short as it was.
720
+ */
721
+ export function stateFlags(state) {
722
+ if (!state)
723
+ return [];
724
+ const flags = [];
725
+ if (state.pressed === "true")
726
+ flags.push("pressed");
727
+ else if (state.pressed === "mixed")
728
+ flags.push("partly pressed");
729
+ if (state.selected === "true")
730
+ flags.push("selected");
731
+ if (state.checked === "true")
732
+ flags.push("checked");
733
+ else if (state.checked === "mixed")
734
+ flags.push("partly checked");
735
+ if (state.expanded === "true")
736
+ flags.push("expanded");
737
+ if (state.current && state.current !== "false")
738
+ flags.push("current");
739
+ return flags;
740
+ }
741
+ /**
742
+ * How the snapshot diff says an element's state moved: what it gained, then
743
+ * what it lost. Null when the state is the same.
744
+ */
745
+ export function stateChange(was, now) {
746
+ const gained = now.filter((f) => !was.includes(f));
747
+ const lost = was.filter((f) => !now.includes(f));
748
+ if (gained.length === 0 && lost.length === 0)
749
+ return null;
750
+ return [gained.length ? `now [${gained.join(", ")}]` : null, lost.length ? `no longer [${lost.join(", ")}]` : null].filter(Boolean).join(", ");
751
+ }
752
+ /**
753
+ * The elements a state's identity and coverage are kept over: all but the
754
+ * live regions listed only for what they say. Their text changes with every
755
+ * message, so in a fingerprint they would make each message a new state, and
756
+ * nothing in them can be exercised.
757
+ */
758
+ export function trackedElements(elements) {
759
+ return elements.filter((el) => !el.liveOnly);
760
+ }
761
+ /**
762
+ * Keys of tracked elements a user cannot act on: listed for their test id (a
763
+ * wrapper, a heading, a badge). Coverage leaves them out of what there is to
764
+ * exercise, or the denominator counts page structure as untested controls.
765
+ */
766
+ export function inertKeys(elements) {
767
+ return elements.filter((el) => !el.liveOnly && el.interactive === false).map((el) => el.key);
768
+ }
338
769
  /**
339
770
  * Whether a field's only label is its placeholder. Kept apart from missingName
340
771
  * because browsers do announce a placeholder when nothing else names the
@@ -350,6 +781,20 @@ export function placeholderOnly(el) {
350
781
  export function describeControl(el) {
351
782
  return `${el.role}${el.testid ? ` [testid=${el.testid}]` : ` at ${el.xpath}`}`;
352
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
+ }
353
798
  /** A placeholder-only field's evidence: the control, then its placeholder, capped so a paragraph of hint text stays one line. */
354
799
  export function placeholderEvidence(el) {
355
800
  const text = el.name.length > 80 ? `${el.name.slice(0, 79)}…` : el.name;
@@ -369,6 +814,170 @@ export function displayName(el) {
369
814
  return el.name;
370
815
  return LIVE_REGION_ROLES.has(el.role) ? "(empty live region)" : "(unnamed)";
371
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
+ }
372
981
  /**
373
982
  * Deterministic geometry oracles — the checks people reach for screenshots to
374
983
  * do, computed from layout boxes instead: interactables rendered fully outside
@@ -381,8 +990,9 @@ export function geometryIssues(elements, viewport) {
381
990
  const { x, y, w, h } = el.rect;
382
991
  // Rects are DOCUMENT coords: below-the-fold content is normal; unreachable
383
992
  // means left/above the document origin, or absurdly far right (no page
384
- // scrolls 3 viewports horizontally on purpose).
385
- 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)) {
386
996
  issues.push(`${el.ref} ${el.role} "${el.name}" is rendered outside the reachable page area (${x},${y} ${w}×${h})`);
387
997
  }
388
998
  // Distinct from below-the-fold: this control's own container hides it and
@@ -414,6 +1024,20 @@ export function geometryIssues(elements, viewport) {
414
1024
  }
415
1025
  if (coveredTotal > 3)
416
1026
  issues.push(`…and ${coveredTotal - 3} more pinned controls covered by other pinned chrome`);
1027
+ // Controls a horizontally scrolling container holds outside its visible
1028
+ // width (the last column of a wide table): reachable by a sideways scroll,
1029
+ // but nothing on screen says they are there. One line per container, since
1030
+ // a table repeats its row actions; a layout choice, so worded as one.
1031
+ const sideways = new Map();
1032
+ for (const el of elements) {
1033
+ if (el.scrolledOutIn && el.rect.w > 0 && el.rect.h > 0)
1034
+ sideways.set(el.scrolledOutIn, (sideways.get(el.scrolledOutIn) ?? 0) + 1);
1035
+ }
1036
+ for (const [container, n] of [...sideways].slice(0, 2)) {
1037
+ issues.push(`${n} control${n === 1 ? " is" : "s are"} scrolled out of view inside a horizontally scrolling container ${container} — only a sideways scroll of it shows ${n === 1 ? "it" : "them"}; worth a look at this width, not necessarily a defect`);
1038
+ }
1039
+ if (sideways.size > 2)
1040
+ issues.push(`…and ${sideways.size - 2} more horizontally scrolling containers holding controls out of view`);
417
1041
  const overlapArea = (a, b) => {
418
1042
  const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
419
1043
  const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
@@ -436,6 +1060,15 @@ export function geometryIssues(elements, viewport) {
436
1060
  // collision bugs that matter live in the content flow, not the chrome.
437
1061
  if (a.chrome && b.chrome)
438
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;
439
1072
  const area = overlapArea(a.rect, b.rect);
440
1073
  const smaller = Math.min(a.rect.w * a.rect.h, b.rect.w * b.rect.h);
441
1074
  if (smaller > 0 && area / smaller > 0.6) {
@@ -478,6 +1111,146 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
478
1111
  }
479
1112
  return { images, total };
480
1113
  })()`;
1114
+ /**
1115
+ * What the page's main region holds besides controls, read in the page: its
1116
+ * first heading, its paragraphs and how much static text it has. A main area
1117
+ * with a heading and an empty-state sentence lists no controls, and without
1118
+ * this it reads exactly like a main area that rendered nothing.
1119
+ *
1120
+ * The region is the `<main>` or `[role=main]` landmark; without one, the body
1121
+ * less its banner, navigation, complementary and footer regions. Static text
1122
+ * is visible text outside controls (links, buttons, fields, options), so a
1123
+ * page of nothing but a nav does not count as having content. The text walk
1124
+ * stops after 5000 text nodes.
1125
+ */
1126
+ export const MAIN_REGION_SCRIPT = `(() => {
1127
+ const shown = (n) => !!n && (n.offsetWidth > 0 || n.offsetHeight > 0 || n.getClientRects().length > 0) && window.getComputedStyle(n).visibility !== "hidden";
1128
+ const landmarks = Array.from(document.querySelectorAll('main, [role="main"]')).filter(shown);
1129
+ const landmark = landmarks[0] || null;
1130
+ const region = landmark || document.body;
1131
+ if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0, states: [], rest: 0, text: "" };
1132
+ const CHROME = 'header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]';
1133
+ const outside = (n) => !landmark && !!n.closest(CHROME);
1134
+ const CONTROL = 'a[href], button, input, select, textarea, option, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"]';
1135
+ const inRegion = (sel) => Array.from(region.querySelectorAll(sel)).filter((n) => shown(n) && !outside(n));
1136
+ const headings = inRegion('h1, h2, h3, h4, h5, h6, [role="heading"]').filter((n) => (n.textContent || "").trim());
1137
+ const h = headings.find((n) => n.tagName === "H1") || headings[0] || null;
1138
+ const level = h ? (/^H[1-6]$/.test(h.tagName) ? Number(h.tagName[1]) : Number(h.getAttribute("aria-level") || 2)) : 0;
1139
+ const heading = h ? { level, text: (h.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 60) } : null;
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
+ }
1156
+ let chars = 0;
1157
+ let rest = 0;
1158
+ let text = "";
1159
+ let seen = 0;
1160
+ const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
1161
+ for (let t = walker.nextNode(); t && seen < 5000; t = walker.nextNode()) {
1162
+ seen += 1;
1163
+ const parent = t.parentElement;
1164
+ if (!parent || parent.closest('script, style, noscript, template, [aria-hidden="true"]') || parent.closest(CONTROL) || outside(parent) || !shown(parent)) continue;
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);
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;
1173
+ return {
1174
+ landmark: !!landmark,
1175
+ heading,
1176
+ paragraphs,
1177
+ chars,
1178
+ controls: controls.length,
1179
+ media: media.length,
1180
+ states,
1181
+ rest,
1182
+ text,
1183
+ };
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
+ }
1229
+ /** Nothing at all in the region: no heading, no text, no control, no image or embed. */
1230
+ function mainIsEmpty(m) {
1231
+ return !m.heading && m.chars === 0 && m.controls === 0 && m.media === 0;
1232
+ }
1233
+ /**
1234
+ * The snapshot's one line on the main region, e.g.
1235
+ * `main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or
1236
+ * `main: EMPTY`. Named `content` when the page has no main landmark.
1237
+ */
1238
+ export function mainRegionLine(m) {
1239
+ const where = m.landmark ? "main" : "content (no main landmark)";
1240
+ if (mainIsEmpty(m))
1241
+ return `${where}: EMPTY`;
1242
+ const parts = [
1243
+ m.heading ? `h${m.heading.level} "${m.heading.text}"` : null,
1244
+ m.paragraphs > 0 ? `${m.paragraphs} paragraph${m.paragraphs === 1 ? "" : "s"}` : null,
1245
+ m.chars > 0 ? `${m.chars} chars of static text` : "no static text",
1246
+ ].filter(Boolean);
1247
+ return `${where}: ${parts.join(" · ")}`;
1248
+ }
1249
+ /** The same in a crawl line's few words: `main 180 chars`, or `main EMPTY`. */
1250
+ export function mainRegionTag(m) {
1251
+ const where = m.landmark ? "main" : "content";
1252
+ return mainIsEmpty(m) ? `${where} EMPTY` : `${where} ${m.chars} chars`;
1253
+ }
481
1254
  /**
482
1255
  * An element's coverage key inside a frame: the frame's origin (another site)
483
1256
  * or path (the app's own) before the element's own key, so a "Submit" in an