@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
@@ -5,30 +5,36 @@
5
5
  /**
6
6
  * @check aria-deprecated-role
7
7
  * @atomic true
8
- * @summary An explicit role attribute must not use a deprecated or author-prohibited ARIA role
8
+ * @summary An explicit role attribute should not use a deprecated or author-discouraged ARIA role
9
9
  * @standard WCAG 2.2
10
10
  * @sc 4.1.2
11
11
  * @applicability
12
12
  * Applies to any element whose role attribute's first (used) token is a
13
- * valid, non-abstract ARIA role that authors must never explicitly
13
+ * valid, non-abstract ARIA role that authors should not explicitly
14
14
  * declare — either because WAI-ARIA has deprecated it (e.g. "directory",
15
- * superseded by role="list") or because it's reserved for user-agent-
16
- * internal use only, not a spec deprecation but the same "valid token,
17
- * prohibited for authors" shape (role="generic" — per WAI-ARIA 1.2 and
18
- * MDN's "It should not be used by web authors" guidance).
15
+ * superseded by role="list") or because it is reserved for user-agent-
16
+ * internal use (role="generic", which ARIA 1.2 §5.4 says authors SHOULD
17
+ * NOT use in content).
19
18
  * @expectation
20
- * The role in use is neither deprecated nor author-prohibited. This is a
21
- * distinct, atomic decision from aria-roles-valid (existence/
22
- * abstractness): a role can be perfectly valid and non-abstract while
23
- * still being off-limits for explicit author use.
19
+ * The role in use is neither deprecated nor reserved. Graded by the
20
+ * strength of the rule ARIA states:
21
+ * - CANTTELL at SHOULD NOT, which leaves the usage conforming, so the
22
+ * author decides whether it matters: a deprecated role ("directory") or
23
+ * one reserved for user agents ("generic").
24
+ * - FAIL at MUST NOT. No ARIA 1.2 or 1.3 role carries an author MUST NOT
25
+ * outside the abstract roles, so this outcome is reserved for a later
26
+ * revision promoting a role to that strength.
27
+ * Distinct, atomic decision from aria-roles-valid (existence/
28
+ * abstractness): a role can be valid and non-abstract while still being
29
+ * discouraged in explicit author use.
24
30
  */
25
31
 
26
32
  const id = 'aria-deprecated-role';
27
33
 
28
34
  const meta = {
29
- title: 'role attribute must not use a deprecated or author-prohibited ARIA role',
35
+ title: 'role attribute should not use a deprecated or author-discouraged ARIA role',
30
36
  description:
31
- 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use only (e.g. role="generic").',
37
+ 'Checks that an explicit role="" attribute does not use a role deprecated by the WAI-ARIA specification, or one reserved for user-agent-internal use (e.g. role="generic").',
32
38
  i18n: {
33
39
  titleKey: 'ariaDeprecatedRole_title',
34
40
  descriptionKey: 'ariaDeprecatedRole_description'
@@ -64,12 +70,31 @@ function runInPage(ctx) {
64
70
  ? helpers.queryAllSmart('[role]')
65
71
  : helpers.queryAll('[role]');
66
72
 
67
- const occurrences = [];
73
+ const failOccurrences = [];
74
+ const cantTellOccurrences = [];
68
75
  let applicableCount = 0;
69
76
 
77
+ // A role on an element hidden from assistive technology has no effect, so
78
+ // ACT 674b10 does not apply to it.
79
+ function isHidden(el) {
80
+ try {
81
+ if (typeof helpers.isDomVisibleEligible === 'function') {
82
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
83
+ }
84
+ for (let n = el; n && n.getAttribute; n = n.parentElement) {
85
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
86
+ }
87
+ } catch {
88
+ return false;
89
+ }
90
+ return false;
91
+ }
92
+
70
93
  for (const el of nodes) {
71
94
  if (!el || !el.getAttribute) continue;
72
95
 
96
+ if (isHidden(el)) continue;
97
+
73
98
  const role = ariaHelpers.getExplicitRole(el);
74
99
  if (!role) continue;
75
100
 
@@ -79,7 +104,14 @@ function runInPage(ctx) {
79
104
 
80
105
  applicableCount += 1;
81
106
 
82
- if (!ariaHelpers.isDeprecatedRole(role)) continue;
107
+ const deprecated = ariaHelpers.isDeprecatedRole(role);
108
+ const discouraged =
109
+ typeof ariaHelpers.isAuthorDiscouragedRole === 'function' &&
110
+ ariaHelpers.isAuthorDiscouragedRole(role);
111
+ const prohibited =
112
+ typeof ariaHelpers.isAuthorProhibitedRole === 'function' &&
113
+ ariaHelpers.isAuthorProhibitedRole(role);
114
+ if (!deprecated && !discouraged && !prohibited) continue;
83
115
 
84
116
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
85
117
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
@@ -87,34 +119,70 @@ function runInPage(ctx) {
87
119
  ? ariaHelpers.getDeprecatedRoleGuidance(role)
88
120
  : 'Replace the deprecated role with its recommended replacement.';
89
121
 
90
- occurrences.push({
91
- selector: stableSelector,
92
- html,
93
- summary: `This element uses role="${role}", which authors must not explicitly declare.`,
94
- hint: guidance,
95
- i18n: {
96
- summaryKey: 'ariaDeprecatedRole_summary_fail',
97
- hintKey: 'ariaDeprecatedRole_hint_fail',
98
- params: { role, guidance }
99
- },
100
- data: {
101
- details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
102
- }
103
- });
122
+ if (prohibited) {
123
+ // Author MUST NOT: the usage is non-conforming, not merely discouraged.
124
+ failOccurrences.push({
125
+ selector: stableSelector,
126
+ html,
127
+ summary: `This element uses role="${role}", which authors must not explicitly declare.`,
128
+ hint: guidance,
129
+ occurrenceOutcome: 'fail',
130
+ i18n: {
131
+ summaryKey: 'ariaDeprecatedRole_summary_fail',
132
+ hintKey: 'ariaDeprecatedRole_hint_fail',
133
+ params: { role, guidance }
134
+ },
135
+ data: {
136
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_PROHIBITED', role, guidance }
137
+ }
138
+ });
139
+ } else if (discouraged) {
140
+ // Reserved for user-agent-internal use, at SHOULD NOT strength.
141
+ cantTellOccurrences.push({
142
+ selector: stableSelector,
143
+ html,
144
+ summary: `This element uses role="${role}", which is reserved for user agents (still valid, but discouraged).`,
145
+ hint: guidance,
146
+ occurrenceOutcome: 'cantTell',
147
+ i18n: {
148
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell_discouraged',
149
+ hintKey: 'ariaDeprecatedRole_hint_cantTell',
150
+ params: { role, guidance }
151
+ },
152
+ data: {
153
+ details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance }
154
+ }
155
+ });
156
+ } else {
157
+ // Deprecated but still valid: surfaced for the author to decide.
158
+ cantTellOccurrences.push({
159
+ selector: stableSelector,
160
+ html,
161
+ summary: `This element uses role="${role}", which is deprecated in WAI-ARIA.`,
162
+ hint: guidance,
163
+ occurrenceOutcome: 'cantTell',
164
+ i18n: {
165
+ summaryKey: 'ariaDeprecatedRole_summary_cantTell',
166
+ hintKey: 'ariaDeprecatedRole_hint_cantTell',
167
+ params: { role, guidance }
168
+ },
169
+ data: {
170
+ details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance }
171
+ }
172
+ });
173
+ }
104
174
  }
105
175
 
106
176
  if (applicableCount === 0) {
107
177
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
108
178
  }
109
- if (occurrences.length) {
110
- return {
111
- ruleId: rule.ruleId,
112
- outcome: 'fail',
113
- severity: rule.defaultSeverity || 'moderate',
114
- occurrences
115
- };
116
- }
117
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
179
+
180
+ const resolved = helpers.resolveTieredOutcome(
181
+ failOccurrences,
182
+ cantTellOccurrences,
183
+ rule.defaultSeverity || 'moderate'
184
+ );
185
+ return { ruleId: rule.ruleId, ...resolved };
118
186
  }
119
187
 
120
188
  module.exports = { id, meta, runInPage };
@@ -67,19 +67,44 @@ function runInPage(ctx) {
67
67
  const occurrences = [];
68
68
  let applicableCount = 0;
69
69
 
70
+ // Programmatically hidden per the ACT glossary: display:none, visibility not
71
+ // visible, or aria-hidden on the element or an ancestor.
72
+ function isHidden(el) {
73
+ try {
74
+ if (typeof helpers.isDomVisibleEligible === 'function') {
75
+ if (!helpers.isDomVisibleEligible(el, ctx)) return true;
76
+ }
77
+ for (let n = el; n && n.getAttribute; n = n.parentElement) {
78
+ if (String(n.getAttribute('aria-hidden') || '').toLowerCase() === 'true') return true;
79
+ }
80
+ } catch {
81
+ return false;
82
+ }
83
+ return false;
84
+ }
85
+
70
86
  for (const el of nodes) {
71
87
  if (!el || !el.getAttribute) continue;
72
88
 
73
- const role = ariaHelpers.getExplicitRole(el);
74
- if (!role) continue; // role="" or whitespace-only: not this rule's concern
89
+ // ACT 674b10 is not applicable to a programmatically hidden element.
90
+ if (isHidden(el)) continue;
75
91
 
76
- applicableCount += 1;
92
+ // role takes a fallback list and the first token the browser recognises
93
+ // wins, so role="searchfield searchbox" resolves to searchbox. The rule
94
+ // fails only when no token names a concrete role.
95
+ const tokens =
96
+ typeof ariaHelpers.getAllRoleTokens === 'function'
97
+ ? ariaHelpers.getAllRoleTokens(el)
98
+ : [ariaHelpers.getExplicitRole(el)].filter(Boolean);
99
+ if (!tokens.length) continue; // role="" or whitespace-only: not this rule's concern
77
100
 
78
- const isAbstract = ariaHelpers.isAbstractRole(role);
79
- const isKnown = ariaHelpers.isKnownRole(role);
101
+ applicableCount += 1;
80
102
 
81
- if (isKnown && !isAbstract) continue;
103
+ const usable = tokens.find((t) => ariaHelpers.isKnownRole(t) && !ariaHelpers.isAbstractRole(t));
104
+ if (usable) continue;
82
105
 
106
+ const role = tokens[0];
107
+ const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
83
108
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
84
109
  const stableSelector = helpers.buildSelector ? helpers.buildSelector(el) : 'html';
85
110
  const html = helpers.getOuterHtmlSnippet ? helpers.getOuterHtmlSnippet(el) : el.outerHTML || '';
@@ -130,7 +130,15 @@ function runInPage(ctx) {
130
130
  if (tokens[i] && tokens[i].startsWith('section-') && tokens[i].length > 'section-'.length)
131
131
  i += 1;
132
132
  if (tokens[i] === 'shipping' || tokens[i] === 'billing') i += 1;
133
- if (CONTACT_MODALITY.has(tokens[i])) i += 1;
133
+ // A contact modality token is only allowed when the field that follows is
134
+ // a contact field, so "work photo" is invalid while "work email" is not.
135
+ if (CONTACT_MODALITY.has(tokens[i])) {
136
+ const next = tokens[i + 1];
137
+ const isContactField =
138
+ next === 'email' || next === 'impp' || next === 'tel' || (next || '').startsWith('tel-');
139
+ if (!isContactField) return false;
140
+ i += 1;
141
+ }
134
142
 
135
143
  let end = tokens.length;
136
144
  if (tokens[end - 1] === 'webauthn') end -= 1;
@@ -147,11 +155,39 @@ function runInPage(ctx) {
147
155
  const occurrences = [];
148
156
  let applicableCount = 0;
149
157
 
158
+ // ACT 73f2c2 exempts controls where the attribute cannot describe an input
159
+ // purpose: the on/off toggle, disabled controls, input types with a fixed
160
+ // value, and controls that take no input.
161
+ const FIXED_VALUE_TYPES = new Set([
162
+ 'button',
163
+ 'checkbox',
164
+ 'file',
165
+ 'image',
166
+ 'radio',
167
+ 'reset',
168
+ 'submit'
169
+ ]);
170
+
171
+ function isExempt(el) {
172
+ const tag = String(el.tagName || '').toLowerCase();
173
+ if (tag === 'input') {
174
+ const type = String(el.getAttribute('type') || 'text').toLowerCase();
175
+ if (FIXED_VALUE_TYPES.has(type)) return true;
176
+ }
177
+ if (el.hasAttribute && el.hasAttribute('disabled')) return true;
178
+ if (String(el.getAttribute('aria-disabled') || '').toLowerCase() === 'true') return true;
179
+ return false;
180
+ }
181
+
150
182
  for (const el of nodes) {
151
183
  if (!el || !el.getAttribute) continue;
152
184
  const raw = String(el.getAttribute('autocomplete') || '').trim();
153
185
  if (!raw) continue;
154
186
 
187
+ const tokens = raw.toLowerCase().split(/\s+/).filter(Boolean);
188
+ if (tokens.length === 1 && (tokens[0] === 'on' || tokens[0] === 'off')) continue;
189
+ if (isExempt(el)) continue;
190
+
155
191
  applicableCount += 1;
156
192
 
157
193
  if (isValidAutocomplete(raw)) continue;
@@ -5,31 +5,36 @@
5
5
  /**
6
6
  * @check avoid-inline-spacing
7
7
  * @atomic true
8
- * @summary Inline style must not force text-spacing properties with !important
8
+ * @summary Inline style must not force text spacing below the WCAG metric
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.4.12
11
11
  * @applicability
12
- * Applies to elements with an inline style attribute that sets
13
- * line-height, letter-spacing, or word-spacing.
12
+ * Applies to a rendered element with visible text of its own whose style
13
+ * attribute declares line-height, letter-spacing or word-spacing as
14
+ * `!important` with a real value. A CSS-wide keyword (inherit, initial,
15
+ * unset, revert) specifies no spacing of its own and is out of scope.
14
16
  * @expectation
15
- * None of those three properties are declared with `!important` in the
16
- * inline style. WCAG 1.4.12 (Text Spacing) requires that users be able
17
- * to override these properties via a user stylesheet without losing
18
- * content or functionality; `!important` on an inline declaration
19
- * defeats that override (inline `!important` wins over nearly every
20
- * other origin, including user stylesheets applied the normal way).
17
+ * Each such declaration already meets WCAG 1.4.12's own metric for that
18
+ * property, as a multiple of the font size: line-height at least 1.5,
19
+ * letter-spacing at least 0.12, word-spacing at least 0.16. A forced value
20
+ * that already satisfies the criterion leaves the user nothing to override.
21
21
  * @implementation-notes
22
- * - Static text-based parsing of the style attribute string (regex for
23
- * the three property names followed by `!important`), not computed
24
- * style.
22
+ * - Computed style resolves the cascade and the units; the declared value is
23
+ * only a fallback for environments that do not lay the document out. Spacing
24
+ * that resolves to neither is left unreported rather than failed.
25
+ * - Within one declaration block, importance outranks order, so the effective
26
+ * declaration is the last `!important` one for that property.
27
+ * - ACT 78fd32/24afc2/9e45ec additionally require the text to contain a soft
28
+ * wrap break, which cannot be determined without layout. Not implemented, so
29
+ * a forced value on text that never wraps is still reported.
25
30
  */
26
31
 
27
32
  const id = 'avoid-inline-spacing';
28
33
 
29
34
  const meta = {
30
- title: 'Inline style must not force text spacing with !important',
35
+ title: 'Inline style must not force text spacing below the WCAG metric',
31
36
  description:
32
- 'Checks that inline style does not set line-height, letter-spacing, or word-spacing with !important, which blocks user text-spacing overrides.',
37
+ 'Checks that where inline style forces line-height, letter-spacing or word-spacing with !important, the value already meets WCAG 1.4.12, so the user has nothing left to override.',
33
38
  i18n: {
34
39
  titleKey: 'avoidInlineSpacing_title',
35
40
  descriptionKey: 'avoidInlineSpacing_description'
@@ -57,6 +62,20 @@ function runInPage(ctx) {
57
62
  const { helpers, rule } = ctx;
58
63
 
59
64
  const SPACING_PROPS = ['line-height', 'letter-spacing', 'word-spacing'];
65
+ // WCAG 1.4.12's own metrics, as multiples of the font size.
66
+ const MIN_RATIO = { 'line-height': 1.5, 'letter-spacing': 0.12, 'word-spacing': 0.16 };
67
+ // Only these two take the value from the parent, leaving this declaration
68
+ // specifying no spacing of its own. `initial` and `revert` resolve to a
69
+ // concrete value (`normal` for all three properties), so they stay in scope.
70
+ const INHERITED_KEYWORDS = ['inherit', 'unset'];
71
+ // No user agent's `normal` line height reaches 1.5, so a forced `normal`
72
+ // always falls short of the metric.
73
+ const NORMAL_LINE_HEIGHT_RATIO = 1.2;
74
+ const CAMEL = {
75
+ 'line-height': 'lineHeight',
76
+ 'letter-spacing': 'letterSpacing',
77
+ 'word-spacing': 'wordSpacing'
78
+ };
60
79
 
61
80
  const nodes = helpers.queryAllSmart
62
81
  ? helpers.queryAllSmart('[style]')
@@ -65,6 +84,136 @@ function runInPage(ctx) {
65
84
  const occurrences = [];
66
85
  let applicableCount = 0;
67
86
 
87
+ // Within one declaration block, importance wins over order, so the last
88
+ // important declaration is the one that takes effect. Passed Example 5 of ACT
89
+ // 78fd32 turns on this and Passed Example 6 on the non-important half.
90
+ function effectiveDeclaration(raw, prop) {
91
+ const re = new RegExp('(?:^|;)\\s*' + prop + '\\s*:\\s*([^;]*)', 'gi');
92
+ let chosen = null;
93
+ let m;
94
+ while ((m = re.exec(raw))) {
95
+ const value = String(m[1] || '').trim();
96
+ const important = /!\s*important\s*$/i.test(value);
97
+ const clean = value.replace(/!\s*important\s*$/i, '').trim();
98
+ if (!clean) continue;
99
+ if (!chosen || important || !chosen.important) {
100
+ if (chosen && chosen.important && !important) continue;
101
+ chosen = { value: clean, important };
102
+ }
103
+ }
104
+ return chosen;
105
+ }
106
+
107
+ function hasVisibleTextChild(el) {
108
+ let kids;
109
+ try {
110
+ kids = el.childNodes ? Array.from(el.childNodes) : [];
111
+ } catch {
112
+ return false;
113
+ }
114
+ return kids.some((n) => n && n.nodeType === 3 && String(n.nodeValue || '').trim() !== '');
115
+ }
116
+
117
+ function isRendered(el) {
118
+ if (helpers.isDomVisibleEligible) {
119
+ try {
120
+ return !!helpers.isDomVisibleEligible(el, ctx, { targetSet: 'dom' }).eligible;
121
+ } catch {
122
+ return true;
123
+ }
124
+ }
125
+ return true;
126
+ }
127
+
128
+ const px = (v) => {
129
+ const n = parseFloat(v);
130
+ return Number.isFinite(n) && /px\s*$/.test(String(v)) ? n : null;
131
+ };
132
+
133
+ function computedStyleOf(el) {
134
+ if (helpers && typeof helpers.computedStyle === 'function') {
135
+ try {
136
+ const cs = helpers.computedStyle(el);
137
+ if (cs) return cs;
138
+ } catch {
139
+ // fall through to the realm's own view
140
+ }
141
+ }
142
+ try {
143
+ const view = el.ownerDocument && el.ownerDocument.defaultView;
144
+ if (view && typeof view.getComputedStyle === 'function') return view.getComputedStyle(el);
145
+ } catch {
146
+ // no computed style available
147
+ }
148
+ return null;
149
+ }
150
+
151
+ // ACT scopes these rules to text visible on screen, and text pushed far off
152
+ // canvas is the one hidden shape the shared eligibility check keeps eligible.
153
+ function isOffScreen(el) {
154
+ if (!helpers.getVisibilityHintsInfo) return false;
155
+ try {
156
+ const info = helpers.getVisibilityHintsInfo(el, ctx, {});
157
+ return !!(info && Array.isArray(info.hints) && info.hints.indexOf('offscreen') !== -1);
158
+ } catch {
159
+ return false;
160
+ }
161
+ }
162
+
163
+ // A realm that does not lay the document out reports font-size as the CSS
164
+ // absolute-size keyword rather than a length. Resolving those keeps px-valued
165
+ // spacing checkable there, and cannot manufacture a failure: a font size
166
+ // larger than assumed only makes a px ratio smaller.
167
+ const ABSOLUTE_FONT_SIZES = {
168
+ 'xx-small': 9,
169
+ 'x-small': 10,
170
+ small: 13,
171
+ medium: 16,
172
+ large: 18,
173
+ 'x-large': 24,
174
+ 'xx-large': 32
175
+ };
176
+
177
+ function fontSizeOf(cs) {
178
+ if (!cs) return null;
179
+ const direct = px(cs.fontSize);
180
+ if (direct !== null) return direct;
181
+ const keyword = String(cs.fontSize || '')
182
+ .trim()
183
+ .toLowerCase();
184
+ return ABSOLUTE_FONT_SIZES[keyword] || null;
185
+ }
186
+
187
+ /**
188
+ * The spacing as a multiple of the font size, or null when it cannot be
189
+ * resolved. Computed style is preferred because it already applies the
190
+ * cascade and unit resolution; the declared value is only a fallback for
191
+ * environments that do not lay the document out.
192
+ */
193
+ function spacingRatio(el, prop, declared) {
194
+ const cs = computedStyleOf(el);
195
+ const fontSize = fontSizeOf(cs);
196
+ if (cs && fontSize) {
197
+ const used = px(cs[CAMEL[prop]]);
198
+ if (used !== null) return used / fontSize;
199
+ }
200
+
201
+ const v = String(declared || '')
202
+ .trim()
203
+ .toLowerCase();
204
+ // `normal`, and the keywords that resolve to it, add nothing for the two
205
+ // spacing properties and stay under the metric for line height.
206
+ if (v === 'normal' || v === 'initial' || v === 'revert' || v === 'revert-layer') {
207
+ return prop === 'line-height' ? NORMAL_LINE_HEIGHT_RATIO : 0;
208
+ }
209
+ if (/^[0-9.]+$/.test(v)) return parseFloat(v);
210
+ if (/em$/.test(v)) return parseFloat(v);
211
+ if (/%$/.test(v)) return parseFloat(v) / 100;
212
+ const asPx = px(v);
213
+ if (asPx !== null && fontSize) return asPx / fontSize;
214
+ return null;
215
+ }
216
+
68
217
  for (const el of nodes) {
69
218
  if (!el || !el.getAttribute) continue;
70
219
  const raw = String(el.getAttribute('style') || '');
@@ -74,14 +223,25 @@ function runInPage(ctx) {
74
223
  const hasAnySpacingProp = SPACING_PROPS.some((p) => lower.includes(p));
75
224
  if (!hasAnySpacingProp) continue;
76
225
 
77
- applicableCount += 1;
226
+ // The rule is about text the user needs to re-space, so an element with no
227
+ // text of its own, or none that renders, is not a target.
228
+ if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
78
229
 
79
230
  const flagged = [];
231
+ let inScope = false;
80
232
  for (const prop of SPACING_PROPS) {
81
- const re = new RegExp(prop.replace('-', '\\-') + '\\s*:[^;]*!important', 'i');
82
- if (re.test(raw)) flagged.push(prop);
233
+ const decl = effectiveDeclaration(raw, prop);
234
+ if (!decl || !decl.important) continue;
235
+ if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
236
+ inScope = true;
237
+ const ratio = spacingRatio(el, prop, decl.value);
238
+ // Unresolvable spacing is left alone: this engine reserves fail for
239
+ // high-confidence violations.
240
+ if (ratio === null) continue;
241
+ if (ratio < MIN_RATIO[prop]) flagged.push(prop);
83
242
  }
84
243
 
244
+ if (inScope) applicableCount += 1;
85
245
  if (!flagged.length) continue;
86
246
 
87
247
  const tag = el.tagName.toLowerCase();
@@ -91,8 +251,8 @@ function runInPage(ctx) {
91
251
  occurrences.push({
92
252
  selector: stableSelector,
93
253
  html,
94
- summary: `This element's inline style forces ${flagged.join(', ')} with !important, blocking user text-spacing overrides.`,
95
- hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles so users can override text spacing.',
254
+ summary: `This element's inline style forces ${flagged.join(', ')} with !important below the WCAG text-spacing metric, so the user cannot raise it.`,
255
+ hint: 'Remove !important from line-height/letter-spacing/word-spacing in inline styles, or set a value that already meets the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em).',
96
256
  i18n: {
97
257
  summaryKey: 'avoidInlineSpacing_summary_fail',
98
258
  hintKey: 'avoidInlineSpacing_hint_fail',
@@ -137,9 +137,16 @@ function runInPage(ctx) {
137
137
  return '';
138
138
  }
139
139
 
140
+ // Naming rules apply only to elements included in the accessibility tree
141
+ // (ACT c487ae and siblings), which excludes focusable aria-hidden content.
142
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
140
143
  function isEligibleAcc(helpers, el, ctx) {
141
144
  const fn =
142
- helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
145
+ helpers && typeof helpers.isIncludedInAccessibilityTree === 'function'
146
+ ? helpers.isIncludedInAccessibilityTree
147
+ : helpers && typeof helpers.isAccTreeEligible === 'function'
148
+ ? helpers.isAccTreeEligible
149
+ : null;
143
150
  if (!fn) return true;
144
151
  try {
145
152
  const r = fn(el, ctx);
@@ -153,8 +160,9 @@ function runInPage(ctx) {
153
160
  const occurrences = [];
154
161
  let applicableCount = 0;
155
162
 
156
- const selector =
157
- 'input[type="checkbox"], input[type="radio"], [role="checkbox"], [role="radio"], [role="switch"]';
163
+ // Native checkbox/radio without an explicit role belongs to
164
+ // form-control-programmatic-label-present.
165
+ const selector = '[role="checkbox"], [role="radio"], [role="switch"]';
158
166
  const nodes = helpers.queryAllSmart
159
167
  ? helpers.queryAllSmart(selector)
160
168
  : helpers.queryAll(selector);
@@ -78,7 +78,14 @@ function runInPage(ctx) {
78
78
 
79
79
  for (const el of nodes) {
80
80
  // isAccTreeEligible returns { eligible, reasons }, not a boolean.
81
- const eligResult = helpers.isAccTreeEligible ? helpers.isAccTreeEligible(el, ctx) : true;
81
+ // Naming rules apply only to elements included in the accessibility tree
82
+ // (ACT c487ae), which excludes focusable aria-hidden content;
83
+ // aria-hidden-focus (ACT 6cfa84) covers that markup instead.
84
+ const eligResult = helpers.isIncludedInAccessibilityTree
85
+ ? helpers.isIncludedInAccessibilityTree(el, ctx)
86
+ : helpers.isAccTreeEligible
87
+ ? helpers.isAccTreeEligible(el, ctx)
88
+ : true;
82
89
  const eligible =
83
90
  typeof eligResult === 'boolean' ? eligResult : !!(eligResult && eligResult.eligible);
84
91
  if (!eligible) continue;
@@ -104,24 +111,50 @@ function runInPage(ctx) {
104
111
  inputValueName = getInputButtonValueName(el);
105
112
  }
106
113
 
107
- // A native <button> (or [role="button"]) whose role has been overridden to
108
- // one of these roles is no longer semantically a button — per the WAI-ARIA
109
- // Accessible Name and Description Computation spec these roles are
110
- // name-from-author-only, and their rendered content represents a VALUE,
111
- // not a NAME. E.g. <button role="combobox">List</button> where "List" is
112
- // the combobox's currently selected value, not a label — crediting it as
113
- // the accessible name would mask a missing name.
114
- const VALUE_ROLES = [
115
- 'textbox',
116
- 'progressbar',
117
- 'scrollbar',
118
- 'slider',
119
- 'spinbutton',
120
- 'combobox',
121
- 'listbox'
114
+ // ARIA 1.2 "Name From: author, contents". Every other known role is
115
+ // name-from-author-only: <button role="combobox">List</button> exposes a
116
+ // value, not a label. An unknown role falls back to the implicit role.
117
+ // <generated:aria-name-from-content>
118
+ const NAME_FROM_CONTENT_ROLES = [
119
+ 'button',
120
+ 'cell',
121
+ 'checkbox',
122
+ 'columnheader',
123
+ 'doc-backlink',
124
+ 'doc-biblioref',
125
+ 'doc-glossref',
126
+ 'doc-noteref',
127
+ 'graphics-object',
128
+ 'gridcell',
129
+ 'heading',
130
+ 'link',
131
+ 'menuitem',
132
+ 'menuitemcheckbox',
133
+ 'menuitemradio',
134
+ 'option',
135
+ 'radio',
136
+ 'row',
137
+ 'rowgroup',
138
+ 'rowheader',
139
+ 'switch',
140
+ 'tab',
141
+ 'tooltip',
142
+ 'treeitem'
122
143
  ];
144
+ // </generated:aria-name-from-content>
145
+ const isKnownRoleToken =
146
+ helpers && helpers.aria && typeof helpers.aria.isKnownRole === 'function'
147
+ ? (() => {
148
+ try {
149
+ return !!helpers.aria.isKnownRole(roleNorm);
150
+ } catch {
151
+ return false;
152
+ }
153
+ })()
154
+ : false;
123
155
  const isContentNameCandidate =
124
- (tag === 'button' || role === 'button') && !VALUE_ROLES.includes(roleNorm);
156
+ (tag === 'button' || role === 'button') &&
157
+ (!roleNorm || !isKnownRoleToken || NAME_FROM_CONTENT_ROLES.includes(roleNorm));
125
158
  const contentName =
126
159
  !trustedProgrammaticName && !inputValueName && isContentNameCandidate
127
160
  ? getConservativeSubtreeText(el)