@surea11y/core 1.4.0 → 1.4.1

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 (49) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/bin/surea11y-core.js +0 -0
  3. package/docs/ARIA_DEPRECATION.md +95 -0
  4. package/docs/RULE_CATALOG.md +6 -6
  5. package/package.json +3 -1
  6. package/src/checks/automatic/aria-allowed-attr.js +648 -83
  7. package/src/checks/automatic/aria-deprecated-role.js +105 -37
  8. package/src/checks/automatic/aria-roles-valid.js +31 -6
  9. package/src/checks/automatic/autocomplete-valid.js +37 -1
  10. package/src/checks/automatic/avoid-inline-spacing.js +179 -19
  11. package/src/checks/automatic/binary-control-name-present.js +11 -3
  12. package/src/checks/automatic/button-name-present.js +50 -17
  13. package/src/checks/automatic/canvas-text-alternative-present.js +15 -8
  14. package/src/checks/automatic/combobox-name-present.js +8 -1
  15. package/src/checks/automatic/dialog-name-present.js +8 -1
  16. package/src/checks/automatic/form-control-programmatic-label-present.js +48 -4
  17. package/src/checks/automatic/form-control-single-label.js +107 -39
  18. package/src/checks/automatic/img-alt-present.js +16 -9
  19. package/src/checks/automatic/input-image-alt-present.js +99 -48
  20. package/src/checks/automatic/label-in-name.js +82 -8
  21. package/src/checks/automatic/language-page-present.js +5 -1
  22. package/src/checks/automatic/link-name-present.js +50 -17
  23. package/src/checks/automatic/listbox-name-present.js +8 -1
  24. package/src/checks/automatic/menuitem-name-present.js +8 -1
  25. package/src/checks/automatic/meta-refresh-no-exceptions.js +29 -0
  26. package/src/checks/automatic/meta-refresh-timing-absent.js +30 -4
  27. package/src/checks/automatic/meta-viewport-zoom-enabled.js +37 -15
  28. package/src/checks/automatic/meter-name-present.js +8 -1
  29. package/src/checks/automatic/nested-interactive-controls-absent.js +161 -31
  30. package/src/checks/automatic/object-text-alternative-present.js +14 -7
  31. package/src/checks/automatic/option-name-present.js +8 -1
  32. package/src/checks/automatic/progressbar-name-present.js +8 -1
  33. package/src/checks/automatic/searchbox-name-present.js +8 -1
  34. package/src/checks/automatic/slider-name-present.js +11 -2
  35. package/src/checks/automatic/spinbutton-name-present.js +8 -1
  36. package/src/checks/automatic/summary-name-present.js +8 -1
  37. package/src/checks/automatic/tab-name-present.js +8 -1
  38. package/src/checks/automatic/table-th-has-data-cells.js +67 -6
  39. package/src/checks/automatic/textbox-name-present.js +8 -1
  40. package/src/checks/automatic/tooltip-name-present.js +8 -1
  41. package/src/checks/automatic/treeitem-name-present.js +8 -1
  42. package/src/checks/automatic/valid-lang.js +16 -3
  43. package/src/checks/{automatic/bypass-blocks-present.js → manual/bypass-blocks-present-manual.js} +97 -35
  44. package/src/checks/manual/input-image-alt-decorative-manual.js +24 -0
  45. package/src/checks/manual/landmark-banner-is-top-level-manual.js +11 -22
  46. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +7 -10
  47. package/src/core.js +4853 -964
  48. package/src/sarif.js +2 -2
  49. package/surea11y.browser.js +2256 -433
@@ -52,6 +52,56 @@ function runInPage(ctx) {
52
52
  return v.replace(/\s+/g, ' ').trim().toLowerCase();
53
53
  }
54
54
 
55
+ // WCAG 2.5.3's label-in-name comparison is over words, not characters: drop
56
+ // parenthesised text, case-fold and NFKD-normalise, then reduce every
57
+ // non-letter/digit to a space. `hyphensJoin` deletes hyphens instead of
58
+ // splitting on them, which distinguishes a real mismatch from one that is
59
+ // only a hyphenation difference.
60
+ function tokenize(s, hyphensJoin) {
61
+ let v = (s == null ? '' : String(s)).replace(/\([^)]*\)/g, ' ').toLowerCase();
62
+ try {
63
+ v = v.normalize('NFKD');
64
+ } catch {
65
+ // Realm without String#normalize: the word comparison below still holds.
66
+ }
67
+ if (hyphensJoin) v = v.replace(/[-‐-―−]/g, '');
68
+ return v
69
+ .replace(/[^\p{L}\p{N}]+/gu, ' ')
70
+ .split(' ')
71
+ .filter(Boolean);
72
+ }
73
+
74
+ // The label's words must appear adjacent and in order inside the name, so a
75
+ // scattered subsequence does not count. Words in `prefixable` may match a
76
+ // longer name word they start.
77
+ function containsWordRun(needle, hay, prefixable) {
78
+ if (!needle.length) return true;
79
+ for (let i = 0; i + needle.length <= hay.length; i++) {
80
+ let ok = true;
81
+ for (let j = 0; j < needle.length; j++) {
82
+ const want = needle[j];
83
+ const got = hay[i + j];
84
+ if (got === want) continue;
85
+ if (prefixable && prefixable.has(want) && got.indexOf(want) === 0) continue;
86
+ ok = false;
87
+ break;
88
+ }
89
+ if (ok) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ // A trailing period marks a word the author may have shortened ("Ave." for
95
+ // "Avenue"). Tokenizing removes the period, so collect these beforehand.
96
+ function abbreviatedWords(s) {
97
+ const out = new Set();
98
+ for (const w of (s == null ? '' : String(s)).split(/\s+/)) {
99
+ const m = /^([\p{L}\p{N}]+)\.$/u.exec(w);
100
+ if (m) out.add(m[1].toLowerCase());
101
+ }
102
+ return out;
103
+ }
104
+
55
105
  function getElementDescriptor(el) {
56
106
  const tag = el && el.tagName ? String(el.tagName).toLowerCase() : 'element';
57
107
  let role;
@@ -297,7 +347,23 @@ function runInPage(ctx) {
297
347
  const accName = acc && acc.value != null ? String(acc.value) : '';
298
348
  const accNorm = norm(accName);
299
349
 
300
- const contains = !!(accNorm && visibleNorm && accNorm.indexOf(visibleNorm) !== -1);
350
+ const labelTokens = tokenize(visibleLabel, false);
351
+ const nameTokens = tokenize(accName, false);
352
+ const contains = containsWordRun(labelTokens, nameTokens, null);
353
+
354
+ // An abbreviation, or a word hyphenated differently in the two places, is
355
+ // not something markup settles: the author may have meant either. Report
356
+ // without asserting a defect instead of failing or staying silent.
357
+ let uncertainty = '';
358
+ if (!contains) {
359
+ if (containsWordRun(tokenize(visibleLabel, true), tokenize(accName, true), null)) {
360
+ uncertainty = 'HYPHENATION_DIFFERS';
361
+ } else {
362
+ const abbreviated = abbreviatedWords(visibleLabel);
363
+ if (abbreviated.size && containsWordRun(labelTokens, nameTokens, abbreviated))
364
+ uncertainty = 'POSSIBLE_ABBREVIATION';
365
+ }
366
+ }
301
367
 
302
368
  if (!contains) {
303
369
  const selectorOut = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
@@ -308,11 +374,16 @@ function runInPage(ctx) {
308
374
  occurrences.push({
309
375
  selector: selectorOut,
310
376
  html,
311
- summary: 'Accessible name does not contain the visible label text.',
312
- hint: 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
377
+ ...(uncertainty ? { outcome: 'cantTell' } : null),
378
+ summary: uncertainty
379
+ ? 'Accessible name may not contain the visible label text.'
380
+ : 'Accessible name does not contain the visible label text.',
381
+ hint: uncertainty
382
+ ? 'Check by hand: the two differ only by an abbreviation or by hyphenation, which markup cannot settle.'
383
+ : 'Ensure the accessible name includes the visible text label (e.g., update aria-label/aria-labelledby to include the visible wording).',
313
384
  i18n: {
314
- summaryKey: 'labelInName_summary_fail',
315
- hintKey: 'labelInName_hint_fail',
385
+ summaryKey: uncertainty ? 'labelInName_summary_cantTell' : 'labelInName_summary_fail',
386
+ hintKey: uncertainty ? 'labelInName_hint_cantTell' : 'labelInName_hint_fail',
316
387
  params: {
317
388
  element: getElementDescriptor(el),
318
389
  visibleLabel: clipForSummary(visibleLabel),
@@ -322,10 +393,11 @@ function runInPage(ctx) {
322
393
  },
323
394
  data: {
324
395
  details: {
325
- reasonCode: 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
396
+ reasonCode: uncertainty || 'VISIBLE_LABEL_NOT_IN_ACCESSIBLE_NAME',
326
397
  visibleLabel,
327
398
  accessibleName: accName,
328
399
  normalized: { visibleLabel: visibleNorm, accessibleName: accNorm },
400
+ tokenized: { visibleLabel: labelTokens, accessibleName: nameTokens },
329
401
  labelSource: labelInfo && labelInfo.source ? labelInfo.source : 'none',
330
402
  nameMechanism: acc && acc.mechanism ? acc.mechanism : 'none'
331
403
  }
@@ -336,13 +408,15 @@ function runInPage(ctx) {
336
408
 
337
409
  if (applicableCount === 0)
338
410
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
339
- if (occurrences.length)
411
+ if (occurrences.length) {
412
+ const anyFail = occurrences.some((o) => o.outcome !== 'cantTell');
340
413
  return {
341
414
  ruleId: rule.ruleId,
342
- outcome: 'fail',
415
+ outcome: anyFail ? 'fail' : 'cantTell',
343
416
  severity: rule.defaultSeverity || 'minor',
344
417
  occurrences
345
418
  };
419
+ }
346
420
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
347
421
  }
348
422
 
@@ -144,7 +144,11 @@ function runInPage(ctx) {
144
144
  }
145
145
 
146
146
  // Minimal BCP47 primary subtag check
147
- if (!/^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(lang)) {
147
+ const isValidTag =
148
+ helpers && typeof helpers.isValidLanguageTag === 'function'
149
+ ? helpers.isValidLanguageTag
150
+ : (v) => /^[a-zA-Z]{2,3}(-[a-zA-Z0-9]{2,8})*$/.test(String(v || ''));
151
+ if (!isValidTag(lang)) {
148
152
  return {
149
153
  ruleId: rule.ruleId,
150
154
  outcome: 'fail',
@@ -59,7 +59,14 @@ function runInPage(ctx) {
59
59
 
60
60
  for (const el of nodes) {
61
61
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
62
- const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
62
+ // Naming rules apply only to elements included in the accessibility tree
63
+ // (ACT c487ae), which excludes focusable aria-hidden content;
64
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
65
+ const eligResult = helpers.isIncludedInAccessibilityTree
66
+ ? helpers.isIncludedInAccessibilityTree(el, ctx)
67
+ : helpers.isAccTreeEligible
68
+ ? helpers.isAccTreeEligible(el, ctx)
69
+ : true;
63
70
  const eligible =
64
71
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
65
72
  if (!eligible) continue;
@@ -69,33 +76,59 @@ function runInPage(ctx) {
69
76
  const nameInfo = helpers.getAccessibleNameInfo ? helpers.getAccessibleNameInfo(el, ctx) : null;
70
77
  const programmaticName = nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : '';
71
78
 
72
- // A native <a href>/<area href> (or [role="link"]) whose role has been
73
- // overridden to one of these roles is no longer semantically a link —
74
- // per the WAI-ARIA Accessible Name and Description Computation spec
75
- // these roles are name-from-author-only, and their rendered content
76
- // represents a VALUE, not a NAME (see button-name-present.js's identical
77
- // fix for a real-world example — <button role="combobox">List</button> —
78
- // of the same class of bug on a different host tag).
79
79
  const role = el.getAttribute ? el.getAttribute('role') : null;
80
80
  const roleNorm = String(role || '')
81
81
  .replace(/\s+/g, ' ')
82
82
  .trim()
83
83
  .toLowerCase();
84
- const VALUE_ROLES = [
85
- 'textbox',
86
- 'progressbar',
87
- 'scrollbar',
88
- 'slider',
89
- 'spinbutton',
90
- 'combobox',
91
- 'listbox'
84
+ // ARIA 1.2 "Name From: author, contents". Every other known role is
85
+ // name-from-author-only. An unknown role falls back to the implicit role.
86
+ // <generated:aria-name-from-content>
87
+ const NAME_FROM_CONTENT_ROLES = [
88
+ 'button',
89
+ 'cell',
90
+ 'checkbox',
91
+ 'columnheader',
92
+ 'doc-backlink',
93
+ 'doc-biblioref',
94
+ 'doc-glossref',
95
+ 'doc-noteref',
96
+ 'graphics-object',
97
+ 'gridcell',
98
+ 'heading',
99
+ 'link',
100
+ 'menuitem',
101
+ 'menuitemcheckbox',
102
+ 'menuitemradio',
103
+ 'option',
104
+ 'radio',
105
+ 'row',
106
+ 'rowgroup',
107
+ 'rowheader',
108
+ 'switch',
109
+ 'tab',
110
+ 'tooltip',
111
+ 'treeitem'
92
112
  ];
93
- const isContentNameCandidate = !VALUE_ROLES.includes(roleNorm);
113
+ // </generated:aria-name-from-content>
114
+ const isKnownRoleToken =
115
+ helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
116
+ ? (() => {
117
+ try {
118
+ return !!helpers.aria.isKnownRole(roleNorm);
119
+ } catch {
120
+ return false;
121
+ }
122
+ })()
123
+ : false;
124
+ const isContentNameCandidate =
125
+ !roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm);
94
126
 
95
127
  const contentName =
96
128
  programmaticName.trim().length === 0 && isContentNameCandidate
97
129
  ? getConservativeSubtreeText(el)
98
130
  : '';
131
+
99
132
  const finalName = (programmaticName.trim().length ? programmaticName : contentName).trim();
100
133
 
101
134
  if (finalName.length === 0) {
@@ -109,9 +109,16 @@ function runInPage(ctx) {
109
109
  return '';
110
110
  }
111
111
 
112
+ // Naming rules apply only to elements included in the accessibility tree
113
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
114
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
112
115
  function isEligibleAcc(helpers, el, ctx) {
113
116
  const fn =
114
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
117
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
118
+ ? helpers.isIncludedInAccessibilityTree
119
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
120
+ ? helpers.isAccTreeEligible
121
+ : null;
115
122
  if (!fn) return true;
116
123
  try {
117
124
  const r = fn(el, ctx);
@@ -84,9 +84,16 @@ function runInPage(ctx) {
84
84
  return '';
85
85
  }
86
86
 
87
+ // Naming rules apply only to elements included in the accessibility tree
88
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
89
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
87
90
  function isEligibleAcc(helpers, el, ctx) {
88
91
  const fn =
89
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
92
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
93
+ ? helpers.isIncludedInAccessibilityTree
94
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
95
+ ? helpers.isAccTreeEligible
96
+ : null;
90
97
  if (!fn) return true;
91
98
  try {
92
99
  const r = fn(el, ctx);
@@ -87,6 +87,34 @@ function runInPage(ctx) {
87
87
  : [];
88
88
 
89
89
  const occurrences = [];
90
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
91
+ // null when the value is not a valid refresh directive, in which case the
92
+ // browser never refreshes and there is nothing to report. Rejects a leading
93
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
94
+ // "," or ";" ("0:1").
95
+ function parseRefreshDelay(input) {
96
+ const s = String(input == null ? '' : input);
97
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
98
+ let i = 0;
99
+ while (i < s.length && isSpace(s[i])) i++;
100
+ let digits = '';
101
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
102
+ if (!digits) return null;
103
+ if (s[i] === '.') {
104
+ i++;
105
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
106
+ }
107
+ if (i >= s.length) return parseInt(digits, 10);
108
+ let sawSpace = false;
109
+ while (i < s.length && isSpace(s[i])) {
110
+ sawSpace = true;
111
+ i++;
112
+ }
113
+ if (i >= s.length) return parseInt(digits, 10);
114
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
115
+ return null;
116
+ }
117
+
90
118
  let applicableCount = 0;
91
119
 
92
120
  for (const el of nodes) {
@@ -94,6 +122,7 @@ function runInPage(ctx) {
94
122
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled
95
123
  const raw = String(el.getAttribute('content') || '').trim();
96
124
  if (!raw) continue;
125
+ if (parseRefreshDelay(raw) === null) continue;
97
126
 
98
127
  applicableCount += 1;
99
128
 
@@ -79,16 +79,42 @@ function runInPage(ctx) {
79
79
  const occurrences = [];
80
80
  let applicableCount = 0;
81
81
 
82
+ // HTML's shared declarative refresh steps. Returns the delay in seconds, or
83
+ // null when the value is not a valid refresh directive, in which case the
84
+ // browser never refreshes and there is nothing to report. Rejects a leading
85
+ // sign ("+72001"), a non-numeric time ("foo"), and a separator other than
86
+ // "," or ";" ("0:1").
87
+ function parseRefreshDelay(input) {
88
+ const s = String(input == null ? '' : input);
89
+ const isSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
90
+ let i = 0;
91
+ while (i < s.length && isSpace(s[i])) i++;
92
+ let digits = '';
93
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') digits += s[i++];
94
+ if (!digits) return null;
95
+ if (s[i] === '.') {
96
+ i++;
97
+ while (i < s.length && s[i] >= '0' && s[i] <= '9') i++;
98
+ }
99
+ if (i >= s.length) return parseInt(digits, 10);
100
+ let sawSpace = false;
101
+ while (i < s.length && isSpace(s[i])) {
102
+ sawSpace = true;
103
+ i++;
104
+ }
105
+ if (i >= s.length) return parseInt(digits, 10);
106
+ if (s[i] === ';' || s[i] === ',' || sawSpace) return parseInt(digits, 10);
107
+ return null;
108
+ }
109
+
82
110
  for (const el of nodes) {
83
111
  if (!el || !el.getAttribute) continue;
84
112
  if (el.closest && el.closest('noscript')) continue; // never applies with scripting enabled — see meta-refresh-no-exceptions.js's header comment
85
113
  const raw = String(el.getAttribute('content') || '').trim();
86
114
  if (!raw) continue;
87
115
 
88
- const match = raw.match(/^([0-9]*\.?[0-9]+)/);
89
- if (!match) continue;
90
- const delay = parseFloat(match[1]);
91
- if (Number.isNaN(delay)) continue;
116
+ const delay = parseRefreshDelay(raw);
117
+ if (delay === null) continue;
92
118
 
93
119
  applicableCount += 1;
94
120
 
@@ -9,17 +9,19 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.4.4
11
11
  * @applicability
12
- * Applies to <meta name="viewport"> elements that carry a non-empty
13
- * content attribute.
12
+ * Applies to <meta name="viewport"> elements whose content attribute sets
13
+ * maximum-scale or user-scalable. Content setting neither cannot restrict
14
+ * zoom.
14
15
  * @expectation
15
- * The content attribute does not set user-scalable to "no"/"0", and does
16
- * not set maximum-scale below 2 (200%). Either disables the user's
17
- * ability to zoom text to at least 200%, which WCAG 1.4.4 (Resize Text)
18
- * requires be possible.
16
+ * user-scalable is absent, yes, device-width, device-height, or a number
17
+ * outside the range -1 to 1; and maximum-scale is absent, device-width,
18
+ * device-height, negative, or 2 or more. Anything else stops the user
19
+ * zooming text to 200%, which WCAG 1.4.4 (Resize Text) requires.
19
20
  * @implementation-notes
20
- * - Malformed/unparseable maximum-scale values are ignored (not flagged) —
21
- * this rule only reports a clearly-detected zoom restriction, matching
22
- * this engine's no-false-positives policy.
21
+ * - An unparseable value counts as a restriction, because CSS Device
22
+ * Adaptation translates it to 0: maximum-scale=yes disables zoom exactly
23
+ * as maximum-scale=0 does. A negative maximum-scale is out of range and
24
+ * dropped by the browser, so it restricts nothing and passes.
23
25
  */
24
26
 
25
27
  const id = 'meta-viewport-zoom-enabled';
@@ -89,20 +91,40 @@ function runInPage(ctx) {
89
91
  const raw = String(el.getAttribute('content') || '').trim();
90
92
  if (!raw) continue;
91
93
 
92
- applicableCount += 1;
93
-
94
94
  const parsed = parseContent(raw);
95
+
96
+ // ACT b4f0c3 applies only when content carries maximum-scale or
97
+ // user-scalable; content that sets neither cannot restrict zoom.
98
+ if (parsed['user-scalable'] === undefined && parsed['maximum-scale'] === undefined) continue;
99
+
100
+ applicableCount += 1;
95
101
  const reasons = [];
96
102
 
103
+ // CSS Device Adaptation translates an unparseable value to 0, so
104
+ // user-scalable=invalid and maximum-scale=yes disable zoom just as
105
+ // user-scalable=no does. A negative maximum-scale is out of range and
106
+ // dropped instead, which is why it does not restrict anything.
97
107
  const userScalable = parsed['user-scalable'];
98
- if (userScalable === 'no' || userScalable === '0') {
99
- reasons.push('user-scalable=' + userScalable);
108
+ if (
109
+ userScalable !== undefined &&
110
+ userScalable !== 'yes' &&
111
+ userScalable !== 'device-width' &&
112
+ userScalable !== 'device-height'
113
+ ) {
114
+ const scale = parseFloat(userScalable);
115
+ if (Number.isNaN(scale) || (scale > -1 && scale < 1)) {
116
+ reasons.push('user-scalable=' + userScalable);
117
+ }
100
118
  }
101
119
 
102
120
  const maxScaleRaw = parsed['maximum-scale'];
103
- if (maxScaleRaw !== undefined) {
121
+ if (
122
+ maxScaleRaw !== undefined &&
123
+ maxScaleRaw !== 'device-width' &&
124
+ maxScaleRaw !== 'device-height'
125
+ ) {
104
126
  const maxScale = parseFloat(maxScaleRaw);
105
- if (!Number.isNaN(maxScale) && maxScale < 2) {
127
+ if (Number.isNaN(maxScale) || (maxScale >= 0 && maxScale < 2)) {
106
128
  reasons.push('maximum-scale=' + maxScaleRaw);
107
129
  }
108
130
  }
@@ -67,9 +67,16 @@ function runInPage(ctx) {
67
67
  return '';
68
68
  }
69
69
 
70
+ // Naming rules apply only to elements included in the accessibility tree
71
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
72
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
70
73
  function isEligibleAcc(helpers, el, ctx) {
71
74
  const fn =
72
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
75
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
76
+ ? helpers.isIncludedInAccessibilityTree
77
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
78
+ ? helpers.isAccTreeEligible
79
+ : null;
73
80
  if (!fn) return true;
74
81
  try {
75
82
  const r = fn(el, ctx);