scenescout 3.14.1 → 3.15.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.
@@ -40,45 +40,104 @@ export const XPATH_OF_SRC = `(el) => {
40
40
  return "/html/" + parts.join("/");
41
41
  }`;
42
42
  /**
43
- * The collector's accessible name, as page-side source. Exported for the same
44
- * reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
45
- * must name it exactly as the snapshot did, or the two cannot be compared.
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.
46
50
  */
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);
65
- }
51
+ export const NAME_FACTS_SRC = `(el) => {
66
52
  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();
53
+ const role = el.getAttribute("role") || "";
54
+ const inputType = tag === "input" ? el.type : "";
55
+ const byId = (id) => { const n = document.getElementById(id); return n ? n.textContent || "" : ""; };
56
+ const labels = [];
57
+ for (const label of Array.from(el.labels || [])) {
58
+ const own = label.contains(el) ? el.textContent || "" : "";
59
+ labels.push((label.textContent || "").replace(own, ""));
60
+ }
61
+ const live = /^(status|alert|log|timer|marquee)$/.test(role) || tag === "output" ||
62
+ (el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off");
63
+ return {
64
+ tag,
65
+ inputType,
66
+ labelledBy: (el.getAttribute("aria-labelledby") || "").split(/\\s+/).filter(Boolean).map(byId).join(" "),
67
+ ariaLabel: el.getAttribute("aria-label"),
68
+ labels,
69
+ title: el.getAttribute("title") || "",
70
+ placeholder: el.getAttribute("placeholder") || "",
71
+ nameAttr: el.getAttribute("name") || "",
72
+ // Read only for button-like inputs: a text field's value is what the user typed, not its name.
73
+ value: inputType === "submit" || inputType === "reset" || inputType === "button" ? el.value || "" : "",
74
+ alt: el.getAttribute("alt") || "",
75
+ live,
76
+ text: el.innerText || el.textContent || "",
77
+ };
78
+ }`;
79
+ /**
80
+ * The accessible name chosen from NAME_FACTS_SRC's facts, as page-side source,
81
+ * in the order of the accessible-name computation: aria-labelledby,
82
+ * aria-label, then the host language's label (an image's alt; a <label>,
83
+ * for= or wrapping; a button-like input's value), then title, then the
84
+ * placeholder. Returns the name and, for a field, where it came from when that
85
+ * is not a label (`from`, see NameFrom): the name falls back to the
86
+ * placeholder, the name attribute or the type so every field can be told apart
87
+ * and targeted, and the name is half of the element's coverage key, so the
88
+ * fallback stays.
89
+ *
90
+ * - 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).
93
+ * - A live region (status, alert, log, timer, an aria-live region, <output>)
94
+ * is named by the text it announces, which is what matters after an action.
95
+ * - A whitespace-only aria-label ends the name with nothing, as it always has
96
+ * here: the field it hides is reported as unnamed.
97
+ */
98
+ export const PICK_NAME_SRC = `(f) => {
99
+ const clean = (s) => (s || "").replace(/\\s+/g, " ").trim();
100
+ const named = (s) => ({ name: s.slice(0, 80), from: null });
101
+ const field = f.tag === "input" || f.tag === "textarea" || f.tag === "select";
102
+ if (clean(f.labelledBy)) return named(clean(f.labelledBy));
103
+ if (f.ariaLabel) {
104
+ if (clean(f.ariaLabel)) return named(clean(f.ariaLabel));
105
+ return { name: "", from: field ? "fallback" : null };
106
+ }
107
+ if (f.tag === "img") return named(clean(f.alt));
108
+ if (f.live && clean(f.text)) return named(clean(f.text));
109
+ const labels = f.labels.map(clean).filter(Boolean).join(" ");
110
+ if (labels) return named(labels);
111
+ if (field) {
112
+ const t = f.inputType;
113
+ if (t === "submit" || t === "reset" || t === "button") {
114
+ if (clean(f.value)) return named(clean(f.value));
115
+ // The browser's own text: what a user sees on the button.
116
+ if (t !== "button") return named(t === "submit" ? "Submit" : "Reset");
76
117
  }
77
- return (el.getAttribute("placeholder") || el.getAttribute("name") || el.type || "input").trim();
118
+ if (t === "image" && clean(f.alt)) return named(clean(f.alt));
119
+ if (clean(f.title)) return named(clean(f.title));
120
+ if (f.tag !== "select" && clean(f.placeholder)) return { name: clean(f.placeholder).slice(0, 80), from: "placeholder" };
121
+ return { name: (clean(f.nameAttr) || t || f.tag).slice(0, 80), from: "fallback" };
78
122
  }
79
- const text = el.innerText || el.textContent || "";
80
- return text.trim().replace(/\\s+/g, " ").slice(0, 80);
123
+ if (clean(f.text)) return named(clean(f.text));
124
+ return named(clean(f.title));
81
125
  }`;
126
+ /**
127
+ * PICK_NAME_SRC run outside a page, from the same source the page runs, so a
128
+ * table test exercises exactly the rule the collector ships.
129
+ */
130
+ export function pickName(facts) {
131
+ return new Function(`return (${PICK_NAME_SRC});`)()(facts);
132
+ }
133
+ /** The element's name and where a field's came from, as page-side source. */
134
+ export const NAME_SRC = `(el) => (${PICK_NAME_SRC})((${NAME_FACTS_SRC})(el))`;
135
+ /**
136
+ * The collector's accessible name, as page-side source. Exported for the same
137
+ * reason as XPATH_OF_SRC: a page-side probe that names an element (forms.ts)
138
+ * must name it exactly as the snapshot did, or the two cannot be compared.
139
+ */
140
+ export const ACCESSIBLE_NAME_SRC = `(el) => (${NAME_SRC})(el).name`;
82
141
  /**
83
142
  * Anything that plausibly presents as a modal/dialog panel. Deliberately wider
84
143
  * than the ARIA set: a hand-rolled role-less modal must still count as "an
@@ -96,37 +155,57 @@ export const DIALOG_LIKE_SEL = '[role="dialog"], [role="alertdialog"], dialog[op
96
155
  export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
97
156
  const xpathOf = ${XPATH_OF_SRC};
98
157
  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 =
158
+ const nameOf = ${NAME_SRC};
159
+ // What the collector lists before anything else: controls, and anything the
160
+ // app tagged with a test id so it can be targeted.
161
+ const controlSelector =
128
162
  'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], ' +
129
163
  '[role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"], [onclick], [data-testid]';
164
+ // Live regions: what a page announces after an action (an error banner, a
165
+ // "Saved" status). Listed whether or not the app tagged them, because what
166
+ // the user is told must not depend on a test id. Those listed only for this
167
+ // are marked liveOnly: they are read, not acted on.
168
+ const liveSelector = '[role="alert"], [role="alertdialog"], [role="status"], [aria-live]:not([aria-live="off"]), output';
169
+ const selector = controlSelector + ", " + liveSelector;
170
+ // Whether a user can act on the element: a native control, an interactive
171
+ // role, a tab stop, a click handler, or a pointer cursor it sets itself (a
172
+ // cursor inherited from a clickable card is the card's, not its badge's).
173
+ // Everything else listed is here for its test id or its text, and is neither
174
+ // an unnamed control nor a gap in coverage.
175
+ 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;
179
+ 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;
182
+ if (window.getComputedStyle(el).cursor === "pointer") {
183
+ const parent = el.parentElement;
184
+ return !parent || window.getComputedStyle(parent).cursor !== "pointer";
185
+ }
186
+ return false;
187
+ };
188
+ // A horizontally scrolling container a control sits outside of: the control
189
+ // is reachable, by a sideways scroll of that container, but nothing on screen
190
+ // shows it is there. Carousels (scroll-snap) page sideways by design and are
191
+ // left out. Returns how to name the container, or null.
192
+ const scrolledOutIn = (el, rect) => {
193
+ for (let anc = el.parentElement; anc && anc !== document.body && anc !== document.documentElement; anc = anc.parentElement) {
194
+ const as = window.getComputedStyle(anc);
195
+ if ((as.overflowX === "auto" || as.overflowX === "scroll") && anc.scrollWidth > anc.clientWidth + 1) {
196
+ if (as.scrollSnapType && as.scrollSnapType !== "none") return null;
197
+ const left = anc.getBoundingClientRect().left + anc.clientLeft;
198
+ const cx = rect.left + rect.width / 2;
199
+ if (cx >= left && cx <= left + anc.clientWidth) return null;
200
+ const tid = anc.getAttribute("data-testid");
201
+ if (tid) return "[" + tid + "]";
202
+ const label = (anc.getAttribute("aria-label") || "").trim().slice(0, 40);
203
+ return "<" + anc.tagName.toLowerCase() + (anc.id ? "#" + anc.id : "") + ">" + (label ? ' "' + label + '"' : "");
204
+ }
205
+ if (as.position === "fixed") return null;
206
+ }
207
+ return null;
208
+ };
130
209
  const seen = new Set();
131
210
  const out = [];
132
211
  // Positioning/scroll LAYER + CHROME classification, for the overlap oracle.
@@ -258,6 +337,10 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
258
337
  : inputType === "file" ? "file"
259
338
  : "textbox")
260
339
  : tag === "img" ? "image"
340
+ // An aria-live region without a role announces like one: assertive
341
+ // interrupts as an alert does, polite waits as a status does.
342
+ : tag === "output" ? "status"
343
+ : el.hasAttribute("aria-live") && el.getAttribute("aria-live") !== "off" ? (el.getAttribute("aria-live") === "assertive" ? "alert" : "status")
261
344
  : "generic");
262
345
  const rect = el.getBoundingClientRect();
263
346
  // Below-the-fold is reachable (scroll); clipped INSIDE an overflow-hidden
@@ -292,13 +375,28 @@ export const COLLECT_INTERACTABLES_SCRIPT = `(() => {
292
375
  anc = anc.parentElement;
293
376
  }
294
377
  }
378
+ const named = nameOf(el);
379
+ const interactive = isInteractive(el);
380
+ const checkable = tag === "input" && (inputType === "checkbox" || inputType === "radio");
295
381
  out.push({
296
382
  coveredBy: coveredByPinnedChrome(el, rect, role),
297
383
  tag,
298
384
  role,
299
- name: accessibleName(el),
300
- nameFrom: nameFrom(el),
385
+ name: named.name,
386
+ nameFrom: named.from,
301
387
  testid: el.getAttribute("data-testid"),
388
+ interactive,
389
+ ariaHidden: el.closest('[aria-hidden="true"]') !== null,
390
+ liveOnly: !el.matches(controlSelector),
391
+ state: {
392
+ pressed: el.getAttribute("aria-pressed"),
393
+ selected: el.getAttribute("aria-selected"),
394
+ checked: checkable ? String(el.checked) : el.getAttribute("aria-checked"),
395
+ expanded: el.getAttribute("aria-expanded"),
396
+ current: el.getAttribute("aria-current"),
397
+ },
398
+ // 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),
302
400
  xpath: xpathOf(el),
303
401
  disabled: el.disabled === true || el.getAttribute("aria-disabled") === "true",
304
402
  href: tag === "a" ? el.getAttribute("href") : null,
@@ -331,10 +429,70 @@ const LIVE_REGION_ROLES = new Set(["status", "alert", "log", "timer", "marquee"]
331
429
  * targeted, but nothing announces them.
332
430
  */
333
431
  export function missingName(el) {
432
+ // Listed for its test id or its text, not a control: a decorative badge, a
433
+ // page wrapper. Absent on elements collected before the collector said.
434
+ if (el.interactive === false)
435
+ return false;
436
+ // Hidden from assistive technology on purpose: nothing announces it, so it needs no name.
437
+ if (el.ariaHidden)
438
+ return false;
334
439
  if (el.nameFrom === "fallback")
335
440
  return true;
336
441
  return !el.name && !LIVE_REGION_ROLES.has(el.role);
337
442
  }
443
+ /**
444
+ * The snapshot's state markers: which filter is active, which tab is chosen,
445
+ * which box is ticked, which section is open. Only states that are on are
446
+ * shown, so a page of plain buttons stays as short as it was.
447
+ */
448
+ export function stateFlags(state) {
449
+ if (!state)
450
+ return [];
451
+ const flags = [];
452
+ if (state.pressed === "true")
453
+ flags.push("pressed");
454
+ else if (state.pressed === "mixed")
455
+ flags.push("partly pressed");
456
+ if (state.selected === "true")
457
+ flags.push("selected");
458
+ if (state.checked === "true")
459
+ flags.push("checked");
460
+ else if (state.checked === "mixed")
461
+ flags.push("partly checked");
462
+ if (state.expanded === "true")
463
+ flags.push("expanded");
464
+ if (state.current && state.current !== "false")
465
+ flags.push("current");
466
+ return flags;
467
+ }
468
+ /**
469
+ * How the snapshot diff says an element's state moved: what it gained, then
470
+ * what it lost. Null when the state is the same.
471
+ */
472
+ export function stateChange(was, now) {
473
+ const gained = now.filter((f) => !was.includes(f));
474
+ const lost = was.filter((f) => !now.includes(f));
475
+ if (gained.length === 0 && lost.length === 0)
476
+ return null;
477
+ return [gained.length ? `now [${gained.join(", ")}]` : null, lost.length ? `no longer [${lost.join(", ")}]` : null].filter(Boolean).join(", ");
478
+ }
479
+ /**
480
+ * The elements a state's identity and coverage are kept over: all but the
481
+ * live regions listed only for what they say. Their text changes with every
482
+ * message, so in a fingerprint they would make each message a new state, and
483
+ * nothing in them can be exercised.
484
+ */
485
+ export function trackedElements(elements) {
486
+ return elements.filter((el) => !el.liveOnly);
487
+ }
488
+ /**
489
+ * Keys of tracked elements a user cannot act on: listed for their test id (a
490
+ * wrapper, a heading, a badge). Coverage leaves them out of what there is to
491
+ * exercise, or the denominator counts page structure as untested controls.
492
+ */
493
+ export function inertKeys(elements) {
494
+ return elements.filter((el) => !el.liveOnly && el.interactive === false).map((el) => el.key);
495
+ }
338
496
  /**
339
497
  * Whether a field's only label is its placeholder. Kept apart from missingName
340
498
  * because browsers do announce a placeholder when nothing else names the
@@ -414,6 +572,20 @@ export function geometryIssues(elements, viewport) {
414
572
  }
415
573
  if (coveredTotal > 3)
416
574
  issues.push(`…and ${coveredTotal - 3} more pinned controls covered by other pinned chrome`);
575
+ // Controls a horizontally scrolling container holds outside its visible
576
+ // width (the last column of a wide table): reachable by a sideways scroll,
577
+ // but nothing on screen says they are there. One line per container, since
578
+ // a table repeats its row actions; a layout choice, so worded as one.
579
+ const sideways = new Map();
580
+ for (const el of elements) {
581
+ if (el.scrolledOutIn && el.rect.w > 0 && el.rect.h > 0)
582
+ sideways.set(el.scrolledOutIn, (sideways.get(el.scrolledOutIn) ?? 0) + 1);
583
+ }
584
+ for (const [container, n] of [...sideways].slice(0, 2)) {
585
+ 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`);
586
+ }
587
+ if (sideways.size > 2)
588
+ issues.push(`…and ${sideways.size - 2} more horizontally scrolling containers holding controls out of view`);
417
589
  const overlapArea = (a, b) => {
418
590
  const w = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x);
419
591
  const h = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y);
@@ -478,6 +650,76 @@ export const BROKEN_IMAGES_SCRIPT = `(() => {
478
650
  }
479
651
  return { images, total };
480
652
  })()`;
653
+ /**
654
+ * What the page's main region holds besides controls, read in the page: its
655
+ * first heading, its paragraphs and how much static text it has. A main area
656
+ * with a heading and an empty-state sentence lists no controls, and without
657
+ * this it reads exactly like a main area that rendered nothing.
658
+ *
659
+ * The region is the `<main>` or `[role=main]` landmark; without one, the body
660
+ * less its banner, navigation, complementary and footer regions. Static text
661
+ * is visible text outside controls (links, buttons, fields, options), so a
662
+ * page of nothing but a nav does not count as having content. The text walk
663
+ * stops after 5000 text nodes.
664
+ */
665
+ export const MAIN_REGION_SCRIPT = `(() => {
666
+ const shown = (n) => !!n && (n.offsetWidth > 0 || n.offsetHeight > 0 || n.getClientRects().length > 0) && window.getComputedStyle(n).visibility !== "hidden";
667
+ const landmarks = Array.from(document.querySelectorAll('main, [role="main"]')).filter(shown);
668
+ const landmark = landmarks[0] || null;
669
+ const region = landmark || document.body;
670
+ if (!region) return { landmark: false, heading: null, paragraphs: 0, chars: 0, controls: 0, media: 0 };
671
+ const CHROME = 'header, nav, footer, aside, [role="banner"], [role="navigation"], [role="contentinfo"], [role="complementary"]';
672
+ const outside = (n) => !landmark && !!n.closest(CHROME);
673
+ const CONTROL = 'a[href], button, input, select, textarea, option, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [role="checkbox"], [role="switch"], [role="combobox"]';
674
+ const inRegion = (sel) => Array.from(region.querySelectorAll(sel)).filter((n) => shown(n) && !outside(n));
675
+ const headings = inRegion('h1, h2, h3, h4, h5, h6, [role="heading"]').filter((n) => (n.textContent || "").trim());
676
+ const h = headings.find((n) => n.tagName === "H1") || headings[0] || null;
677
+ const level = h ? (/^H[1-6]$/.test(h.tagName) ? Number(h.tagName[1]) : Number(h.getAttribute("aria-level") || 2)) : 0;
678
+ const heading = h ? { level, text: (h.textContent || "").replace(/\\s+/g, " ").trim().slice(0, 60) } : null;
679
+ const paragraphs = inRegion("p").filter((n) => (n.textContent || "").trim()).length;
680
+ let chars = 0;
681
+ let seen = 0;
682
+ const walker = document.createTreeWalker(region, NodeFilter.SHOW_TEXT);
683
+ for (let t = walker.nextNode(); t && seen < 5000; t = walker.nextNode()) {
684
+ seen += 1;
685
+ const parent = t.parentElement;
686
+ 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;
688
+ }
689
+ return {
690
+ landmark: !!landmark,
691
+ heading,
692
+ paragraphs,
693
+ chars,
694
+ controls: inRegion(CONTROL).length,
695
+ media: inRegion("img, svg, video, canvas, iframe, object, embed").length,
696
+ };
697
+ })()`;
698
+ /** Nothing at all in the region: no heading, no text, no control, no image or embed. */
699
+ function mainIsEmpty(m) {
700
+ return !m.heading && m.chars === 0 && m.controls === 0 && m.media === 0;
701
+ }
702
+ /**
703
+ * The snapshot's one line on the main region, e.g.
704
+ * `main: h1 "Orders" · 2 paragraphs · 180 chars of static text`, or
705
+ * `main: EMPTY`. Named `content` when the page has no main landmark.
706
+ */
707
+ export function mainRegionLine(m) {
708
+ const where = m.landmark ? "main" : "content (no main landmark)";
709
+ if (mainIsEmpty(m))
710
+ return `${where}: EMPTY`;
711
+ const parts = [
712
+ m.heading ? `h${m.heading.level} "${m.heading.text}"` : null,
713
+ m.paragraphs > 0 ? `${m.paragraphs} paragraph${m.paragraphs === 1 ? "" : "s"}` : null,
714
+ m.chars > 0 ? `${m.chars} chars of static text` : "no static text",
715
+ ].filter(Boolean);
716
+ return `${where}: ${parts.join(" · ")}`;
717
+ }
718
+ /** The same in a crawl line's few words: `main 180 chars`, or `main EMPTY`. */
719
+ export function mainRegionTag(m) {
720
+ const where = m.landmark ? "main" : "content";
721
+ return mainIsEmpty(m) ? `${where} EMPTY` : `${where} ${m.chars} chars`;
722
+ }
481
723
  /**
482
724
  * An element's coverage key inside a frame: the frame's origin (another site)
483
725
  * or path (the app's own) before the element's own key, so a "Submit" in an