@surea11y/core 1.6.0 → 1.7.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 +47 -0
  2. package/README.md +24 -38
  3. package/docs/ACT_RULE_MAPPING.md +8 -6
  4. package/docs/API_STABILITY.md +51 -3
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/DESIGN_CHALLENGES.md +66 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +28 -2
  9. package/docs/INTEGRATION.md +4 -2
  10. package/docs/LIMITATIONS.md +3 -1
  11. package/docs/OUTPUT_SCHEMA.md +44 -6
  12. package/docs/POLICY.md +1 -1
  13. package/docs/RULE_AUTHORING.md +11 -12
  14. package/docs/RULE_CATALOG.md +76 -26
  15. package/docs/RULE_HELPERS.md +333 -0
  16. package/docs/RULE_TAXONOMY.md +25 -4
  17. package/docs/SARIF.md +21 -2
  18. package/docs/WCAG_CONFORMANCE.md +9 -1
  19. package/package.json +9 -3
  20. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  21. package/src/checks/automatic/aria-allowed-role.js +32 -23
  22. package/src/checks/automatic/aria-braille-equivalent.js +18 -10
  23. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  24. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  25. package/src/checks/automatic/aria-hidden-body.js +1 -1
  26. package/src/checks/automatic/aria-prohibited-attr.js +5 -0
  27. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  28. package/src/checks/automatic/aria-required-attr.js +59 -12
  29. package/src/checks/automatic/aria-required-children.js +33 -16
  30. package/src/checks/automatic/aria-required-parent.js +32 -6
  31. package/src/checks/automatic/aria-role-name-present.js +1 -1
  32. package/src/checks/automatic/aria-roles-valid.js +52 -21
  33. package/src/checks/automatic/aria-valid-attr-value.js +74 -21
  34. package/src/checks/automatic/aria-valid-attr.js +14 -9
  35. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  36. package/src/checks/automatic/contrast-computable.js +10 -0
  37. package/src/checks/automatic/contrast-enhanced.js +12 -0
  38. package/src/checks/automatic/contrast-minimum.js +12 -0
  39. package/src/checks/automatic/css-orientation-lock.js +42 -5
  40. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  41. package/src/checks/automatic/duplicate-id.js +13 -8
  42. package/src/checks/automatic/form-control-single-label.js +9 -0
  43. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  44. package/src/checks/automatic/iframe-focusable-content.js +5 -0
  45. package/src/checks/automatic/label-in-name.js +38 -56
  46. package/src/checks/automatic/link-in-text-block.js +279 -23
  47. package/src/checks/automatic/target-size-minimum.js +84 -5
  48. package/src/checks/automatic/td-has-header.js +19 -18
  49. package/src/checks/manual/form-control-label-quality-manual.js +134 -24
  50. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  51. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  52. package/src/core.js +3863 -44184
  53. package/src/earl.js +144 -0
  54. package/src/sarif.js +22 -2
  55. package/surea11y.browser.js +10 -41039
  56. package/surea11y.i18n.de.js +2 -21
  57. package/surea11y.i18n.es.js +2 -21
  58. package/surea11y.i18n.fr.js +2 -21
  59. /package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +0 -0
@@ -7,7 +7,7 @@
7
7
  * @atomic true
8
8
  * @summary Roles that require a specific ancestor/owner context role must have one
9
9
  * @standard WCAG 2.2
10
- * @sc 4.1.2
10
+ * @sc 1.3.1
11
11
  * @applicability
12
12
  * Applies to elements with an explicit, valid, non-abstract role that is
13
13
  * also one of the roles with a documented, non-empty "required context
@@ -43,6 +43,16 @@
43
43
  * aria-required-children/aria-prohibited-children, applied for
44
44
  * consistency; an element that isn't currently exposed to the
45
45
  * accessibility tree is skipped (notApplicable), not failed.
46
+ * - Also honors WAI-ARIA's escape hatch for a widget script has not
47
+ * finished assembling: "When a widget is missing required owned elements due to
48
+ * script execution or loading, authors MUST mark a containing element
49
+ * with aria-busy equal to true." aria-required-children reads that from
50
+ * the container it is checking; read from the item's side it is an
51
+ * ancestor, so the walk looks up rather than at the element itself, and
52
+ * only the exact string "true" counts. It also outranks the
53
+ * roleless-generic-parent rule below, since aria-busy is itself a global
54
+ * ARIA attribute and would otherwise block the context search and fail
55
+ * the very markup the spec says to mark.
46
56
  */
47
57
 
48
58
  const id = 'aria-required-parent';
@@ -56,14 +66,14 @@ const meta = {
56
66
  descriptionKey: 'ariaRequiredParent_description'
57
67
  },
58
68
  helpUrl: null,
59
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
60
- wcagSc: ['4.1.2'],
69
+ tags: ['wcag2a', 'wcag131', 'aria', 'structure', 'atomic', 'automatic'],
70
+ wcagSc: ['1.3.1'],
61
71
  normativeMappings: [
62
72
  {
63
73
  standard: 'WCAG',
64
74
  version: '2.2',
65
- requirement: '4.1.2',
66
- title: 'Name, Role, Value',
75
+ requirement: '1.3.1',
76
+ title: 'Info and Relationships',
67
77
  conformanceLevel: 'A'
68
78
  }
69
79
  ],
@@ -71,7 +81,7 @@ const meta = {
71
81
  category: 'robust',
72
82
  type: 'automatic',
73
83
  defaultConfidence: 'medium',
74
- coverage: { facetsBySc: { '4.1.2': ['aria-role-required-context-parent'] } }
84
+ coverage: { facetsBySc: { '1.3.1': ['aria-role-required-context-parent'] } }
75
85
  };
76
86
 
77
87
  function runInPage(ctx) {
@@ -183,6 +193,21 @@ function runInPage(ctx) {
183
193
  return n && n.parentElement ? n.parentElement : null;
184
194
  };
185
195
 
196
+ // The escape hatch marks the container being assembled, not the item inside
197
+ // it, so this walks up instead of reading the element's own attribute.
198
+ function hasBusyAncestor(el) {
199
+ let cur = getComposedParent(el);
200
+ let guard = 0;
201
+ while (cur && guard++ < 200) {
202
+ if (cur.nodeType === 1 && cur.getAttribute) {
203
+ const v = cur.getAttribute('aria-busy');
204
+ if (v != null && String(v).trim().toLowerCase() === 'true') return true;
205
+ }
206
+ cur = getComposedParent(cur);
207
+ }
208
+ return false;
209
+ }
210
+
186
211
  function hasAcceptableAncestorContext(el, acceptableRoles, ownRole) {
187
212
  const allowsGroup = acceptableRoles.has('group');
188
213
  let cur = getComposedParent(el);
@@ -252,6 +277,7 @@ function runInPage(ctx) {
252
277
  if (!requiredContext || !requiredContext.length) continue; // no entry, or explicitly unconstrained
253
278
 
254
279
  if (!isEligibleAcc(el)) continue; // not currently exposed to the accessibility tree
280
+ if (hasBusyAncestor(el)) continue; // author has signaled transient incompleteness per WAI-ARIA
255
281
 
256
282
  applicableCount += 1;
257
283
 
@@ -45,7 +45,7 @@ const meta = {
45
45
  descriptionKey: 'ariaRoleNamePresent_description'
46
46
  },
47
47
  helpUrl: null,
48
- tags: ['wcag2a', 'wcag412', 'navigation', 'atomic', 'automatic', 'name'],
48
+ tags: ['wcag2a', 'wcag412', 'aria', 'navigation', 'atomic', 'automatic', 'name'],
49
49
  wcagSc: ['4.1.2'],
50
50
  normativeMappings: [
51
51
  {
@@ -11,16 +11,15 @@
11
11
  * @applicability
12
12
  * Applies to any element with a non-empty role="" attribute in the composed DOM.
13
13
  * @expectation
14
- * The role attribute's first token (the role actually used by assistive technology;
15
- * later space-separated tokens are author-supplied fallbacks and are not evaluated
16
- * here) must be a real WAI-ARIA role name, and must not be an abstract role
17
- * (abstract roles exist only for the specification's own role taxonomy and must
18
- * never be used directly in markup).
19
- * @implementation-notes
20
- * - Unlike this engine's accessible-name rules, ARIA validity is a static markup
21
- * property independent of current visibility/eligibility, so this rule does not
22
- * gate on isAccTreeEligible: an invalid role is a defect whether or not the
23
- * element happens to be hidden right now.
14
+ * At least one role token names a concrete, non-abstract ARIA role.
15
+ * Graded by what the element falls back to when none does:
16
+ * - FAIL on a roleless host (div, span, custom element), which is left
17
+ * exposed as generic, so the role the author meant reaches no one.
18
+ * - CANTTELL where the element has a native role (a <button>, <nav>,
19
+ * <a href>), which the accessibility tree keeps using. ACT 674b10 lists
20
+ * 4.1.2 as a secondary requirement only, "satisfied through the implicit
21
+ * role," so the bad token is worth reporting but is not itself the
22
+ * criterion failing.
24
23
  */
25
24
 
26
25
  const id = 'aria-roles-valid';
@@ -64,7 +63,8 @@ function runInPage(ctx) {
64
63
  ? helpers.queryAllSmart('[role]')
65
64
  : helpers.queryAll('[role]');
66
65
 
67
- const occurrences = [];
66
+ const failOccurrences = [];
67
+ const cantTellOccurrences = [];
68
68
  let applicableCount = 0;
69
69
 
70
70
  // Programmatically hidden per the ACT glossary: display:none, visibility not
@@ -121,8 +121,42 @@ function runInPage(ctx) {
121
121
  const isKnown = tokens.some((t) => ariaHelpers.isKnownRole(t));
122
122
  const reasonCode = !isKnown ? 'ARIA_ROLE_INVALID' : 'ARIA_ROLE_ABSTRACT';
123
123
 
124
- occurrences.push(
124
+ // An unusable role token leaves the element on its native role, when it
125
+ // has one: ACT 674b10 lists 4.1.2 as only a secondary requirement for
126
+ // exactly that reason. A roleless host (div, span, custom element) has
127
+ // nothing to fall back to and is exposed as generic instead.
128
+ const nativeRole =
129
+ typeof ariaHelpers.getNativeRoleForElement === 'function'
130
+ ? ariaHelpers.getNativeRoleForElement(el) || ''
131
+ : '';
132
+
133
+ if (nativeRole) {
134
+ cantTellOccurrences.push(
135
+ helpers.reportOccurrence(el, {
136
+ occurrenceOutcome: 'cantTell',
137
+ summary: `The role attribute value is not usable, so this element is still exposed as its native role="${nativeRole}".`,
138
+ hint: 'Fix or remove the role token; assistive technology is using the native role in the meantime.',
139
+ i18n: {
140
+ summaryKey: 'ariaRolesValid_summary_cantTell',
141
+ hintKey: 'ariaRolesValid_hint_cantTell',
142
+ params: { role, nativeRole }
143
+ },
144
+ uncertainty: {
145
+ code: 'spec-only',
146
+ needed: 'Whether the native role this element falls back to is the one intended.',
147
+ evidence: { role, nativeRole, fallbackExposed: true }
148
+ },
149
+ data: {
150
+ details: { reasonCode, role, nativeRole }
151
+ }
152
+ })
153
+ );
154
+ continue;
155
+ }
156
+
157
+ failOccurrences.push(
125
158
  helpers.reportOccurrence(el, {
159
+ occurrenceOutcome: 'fail',
126
160
  summary: !isKnown
127
161
  ? 'The role attribute value is not a recognized ARIA role.'
128
162
  : 'The role attribute value is an abstract ARIA role, which must not be used directly.',
@@ -146,15 +180,12 @@ function runInPage(ctx) {
146
180
  if (applicableCount === 0) {
147
181
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
182
  }
149
- if (occurrences.length) {
150
- return {
151
- ruleId: rule.ruleId,
152
- outcome: 'fail',
153
- severity: rule.defaultSeverity || 'serious',
154
- occurrences
155
- };
156
- }
157
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
183
+ const resolved = helpers.resolveTieredOutcome(
184
+ failOccurrences,
185
+ cantTellOccurrences,
186
+ rule.defaultSeverity || 'serious'
187
+ );
188
+ return { ruleId: rule.ruleId, ...resolved };
158
189
  }
159
190
 
160
191
  module.exports = { id, meta, runInPage };
@@ -38,6 +38,19 @@
38
38
  * text names it as a non-required property whose target "may be created
39
39
  * in response to an event that may or may not happen" (a validation
40
40
  * error message rendered only once the error actually occurs).
41
+ * - aria-controls is never a fail on a target that doesn't resolve, and
42
+ * this is the one place the rule reports two tiers. The controlled
43
+ * element is routinely built when the widget opens, so a static scan
44
+ * that cannot find it has not found a defect; it has found markup it
45
+ * cannot decide. A collapsed widget (aria-expanded="false" or
46
+ * aria-selected="false") passes outright, since the absence is exactly
47
+ * what that state means; anything else is a `cantTell` for human
48
+ * review. Every other idref/idref-list attribute keeps its fail: a
49
+ * dangling aria-labelledby or aria-owns names content that was supposed
50
+ * to be there already.
51
+ * - Two tiers in one run means helpers.resolveTieredOutcome decides the
52
+ * aggregate: a real fail elsewhere on the page still reports fail, and
53
+ * the aria-controls occurrences ride along rather than being dropped.
41
54
  */
42
55
 
43
56
  const id = 'aria-valid-attr-value';
@@ -79,13 +92,15 @@ function runInPage(ctx) {
79
92
 
80
93
  const nodes = helpers.queryAllSmart ? helpers.queryAllSmart('*') : helpers.queryAll('*');
81
94
 
82
- const occurrences = [];
95
+ const failOccurrences = [];
96
+ const cantTellOccurrences = [];
83
97
  let applicableCount = 0;
84
98
 
85
99
  for (const el of nodes) {
86
100
  if (!el || !el.attributes || !el.getAttribute) continue;
87
101
 
88
102
  let invalid = null;
103
+ let review = null;
89
104
  const attrs = el.attributes;
90
105
  for (let i = 0; i < attrs.length; i++) {
91
106
  const name = String(attrs[i].name || '').toLowerCase();
@@ -95,21 +110,26 @@ function runInPage(ctx) {
95
110
  applicableCount += 1;
96
111
 
97
112
  const rawValue = el.getAttribute(name);
98
- const result = ariaHelpers.validateAttrValue(name, rawValue);
99
- if (!result.valid) {
113
+ const result = ariaHelpers.validateAttrValue(name, rawValue, el);
114
+ if (result.valid) continue;
115
+
116
+ const item = {
117
+ name,
118
+ value: rawValue == null ? '' : String(rawValue),
119
+ reason: result.reason
120
+ };
121
+
122
+ if (result.review) {
123
+ if (!review) review = [];
124
+ review.push(item);
125
+ } else {
100
126
  if (!invalid) invalid = [];
101
- invalid.push({
102
- name,
103
- value: rawValue == null ? '' : String(rawValue),
104
- reason: result.reason
105
- });
127
+ invalid.push(item);
106
128
  }
107
129
  }
108
130
 
109
- if (!invalid || !invalid.length) continue;
110
-
111
- for (const item of invalid) {
112
- occurrences.push(
131
+ for (const item of invalid || []) {
132
+ failOccurrences.push(
113
133
  helpers.reportOccurrence(el, {
114
134
  summary: 'This element has an ARIA attribute with an invalid value.',
115
135
  hint: 'Use a value that matches the attribute’s expected type (see the WAI-ARIA specification for this attribute).',
@@ -129,20 +149,53 @@ function runInPage(ctx) {
129
149
  })
130
150
  );
131
151
  }
152
+
153
+ for (const item of review || []) {
154
+ cantTellOccurrences.push(
155
+ helpers.reportOccurrence(el, {
156
+ summary:
157
+ 'No element with this id exists right now, so the engine cannot tell whether this reference is wrong.',
158
+ hint: 'Confirm the controlled element is created when the widget opens; if it never exists, remove or correct the reference.',
159
+ i18n: {
160
+ summaryKey: 'ariaValidAttrValue_summary_cantTell_idref',
161
+ hintKey: 'ariaValidAttrValue_hint_cantTell_idref',
162
+ params: { attr: item.name, value: item.value }
163
+ },
164
+ uncertainty: {
165
+ code: 'runtime-dependent',
166
+ needed: 'Whether the widget creates the referenced element when it opens.',
167
+ evidence: {
168
+ attribute: item.name,
169
+ referencedId: item.value,
170
+ resolvedAtScanTime: false
171
+ }
172
+ },
173
+ data: {
174
+ details: {
175
+ reasonCode: 'ARIA_ATTR_VALUE_TARGET_ABSENT',
176
+ attr: item.name,
177
+ value: item.value,
178
+ valueReason: item.reason
179
+ }
180
+ }
181
+ })
182
+ );
183
+ }
132
184
  }
133
185
 
134
186
  if (applicableCount === 0) {
135
187
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
136
188
  }
137
- if (occurrences.length) {
138
- return {
139
- ruleId: rule.ruleId,
140
- outcome: 'fail',
141
- severity: rule.defaultSeverity || 'serious',
142
- occurrences
143
- };
144
- }
145
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
189
+
190
+ // See helpers.resolveTieredOutcome's own header comment
191
+ // (src/core/dom-helpers.js): a fail-tier finding never silently discards
192
+ // cantTell-tier findings from the same run.
193
+ const resolved = helpers.resolveTieredOutcome(
194
+ failOccurrences,
195
+ cantTellOccurrences,
196
+ rule.defaultSeverity || 'serious'
197
+ );
198
+ return { ruleId: rule.ruleId, ...resolved };
146
199
  }
147
200
 
148
201
  module.exports = { id, meta, runInPage };
@@ -16,6 +16,12 @@
16
16
  * specification (catches typos / made-up attribute names, which are
17
17
  * silently ignored by assistive technology and therefore a real,
18
18
  * deterministic defect).
19
+ * Reported at CANTTELL rather than FAIL: an aria-* attribute the spec
20
+ * does not define is inert, so nothing about the element's exposed name,
21
+ * role or value changes because it is there. Where the author meant a real
22
+ * attribute and the element ends up without a name, that absence is the
23
+ * naming rules' decision, not this one's. ACT 5f99a7 maps 1.3.1/4.1.2 as
24
+ * secondary requirements, "less strict" than the rule itself.
19
25
  * @implementation-notes
20
26
  * - Distinct from aria-valid-attr-value (which validates the VALUE
21
27
  * of a recognized attribute), this rule only validates the attribute
@@ -96,6 +102,12 @@ function runInPage(ctx) {
96
102
  hintKey: 'ariaValidAttr_hint_fail',
97
103
  params: { attr: name }
98
104
  },
105
+ uncertainty: {
106
+ code: 'spec-only',
107
+ needed:
108
+ 'Whether the misspelling cost the element a name or state it has no other source for.',
109
+ evidence: { attribute: name, definedInAria: false, exposedEffect: 'none' }
110
+ },
99
111
  data: {
100
112
  details: { reasonCode: 'ARIA_ATTR_INVALID', attr: name }
101
113
  }
@@ -107,15 +119,8 @@ function runInPage(ctx) {
107
119
  if (applicableCount === 0) {
108
120
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
109
121
  }
110
- if (occurrences.length) {
111
- return {
112
- ruleId: rule.ruleId,
113
- outcome: 'fail',
114
- severity: rule.defaultSeverity || 'serious',
115
- occurrences
116
- };
117
- }
118
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
122
+ const resolved = helpers.resolveTieredOutcome([], occurrences, rule.defaultSeverity || 'serious');
123
+ return { ruleId: rule.ruleId, ...resolved };
119
124
  }
120
125
 
121
126
  module.exports = { id, meta, runInPage };
@@ -25,8 +25,12 @@
25
25
  * - Within one declaration block, importance outranks order, so the effective
26
26
  * declaration is the last `!important` one for that property.
27
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.
28
+ * wrap break, which layout would settle and this cannot. Two shapes do
29
+ * establish that no wrap is possible -- text not allowed to wrap, and a
30
+ * fixed-width element inside a horizontally scrolling ancestor -- and those
31
+ * are reported for review rather than failed. Anything else is treated as
32
+ * wrapping, so a forced value on text that never wraps for some other reason
33
+ * is still reported.
30
34
  */
31
35
 
32
36
  const id = 'avoid-inline-spacing';
@@ -83,6 +87,8 @@ function runInPage(ctx) {
83
87
 
84
88
  const occurrences = [];
85
89
  let applicableCount = 0;
90
+ const undecided = [];
91
+ const noWrap = [];
86
92
 
87
93
  // Within one declaration block, importance wins over order, so the last
88
94
  // important declaration is the one that takes effect. Passed Example 5 of ACT
@@ -148,6 +154,36 @@ function runInPage(ctx) {
148
154
  return null;
149
155
  }
150
156
 
157
+ // ACT 78fd32/24afc2/9e45ec apply only to text that contains a soft wrap
158
+ // break. Layout settles that; short of it, two shapes establish that no wrap
159
+ // can happen -- text not allowed to wrap, and a fixed-width element inside a
160
+ // horizontally scrolling ancestor, which narrowing the viewport cannot reach.
161
+ function cannotSoftWrap(el) {
162
+ const cs = computedStyleOf(el);
163
+ const whiteSpace = cs ? String(cs.whiteSpace || '').toLowerCase() : '';
164
+ if (whiteSpace === 'nowrap' || whiteSpace === 'pre') return true;
165
+
166
+ if (!/(^|;)\s*width\s*:/i.test(String(el.getAttribute('style') || ''))) return false;
167
+
168
+ const chain =
169
+ helpers && typeof helpers.ancestorsIncludingSelf === 'function'
170
+ ? helpers.ancestorsIncludingSelf(el)
171
+ : null;
172
+ const ancestors = chain || [];
173
+ if (!chain) {
174
+ for (let a = el.parentElement; a; a = a.parentElement) ancestors.push(a);
175
+ }
176
+
177
+ for (const ancestor of ancestors) {
178
+ if (ancestor === el) continue;
179
+ const acs = computedStyleOf(ancestor);
180
+ if (!acs) continue;
181
+ const overflowX = String(acs.overflowX || acs.overflow || '').toLowerCase();
182
+ if (overflowX === 'scroll' || overflowX === 'auto') return true;
183
+ }
184
+ return false;
185
+ }
186
+
151
187
  // ACT scopes these rules to text visible on screen, and text pushed far off
152
188
  // canvas is the one hidden shape the shared eligibility check keeps eligible.
153
189
  function isOffScreen(el) {
@@ -228,6 +264,7 @@ function runInPage(ctx) {
228
264
  if (!hasVisibleTextChild(el) || !isRendered(el) || isOffScreen(el)) continue;
229
265
 
230
266
  const flagged = [];
267
+ const unresolved = [];
231
268
  let inScope = false;
232
269
  for (const prop of SPACING_PROPS) {
233
270
  const decl = effectiveDeclaration(raw, prop);
@@ -235,14 +272,28 @@ function runInPage(ctx) {
235
272
  if (INHERITED_KEYWORDS.indexOf(decl.value.toLowerCase()) !== -1) continue;
236
273
  inScope = true;
237
274
  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;
275
+ // Not flagged, since `fail` needs a measured value -- but recorded, so
276
+ // the element does not fall through to `pass` unmeasured.
277
+ if (ratio === null) {
278
+ unresolved.push(prop);
279
+ continue;
280
+ }
241
281
  if (ratio < MIN_RATIO[prop]) flagged.push(prop);
242
282
  }
243
283
 
244
284
  if (inScope) applicableCount += 1;
245
- if (!flagged.length) continue;
285
+ if (!flagged.length) {
286
+ if (unresolved.length) undecided.push({ el, props: unresolved.slice() });
287
+ continue;
288
+ }
289
+
290
+ // A forced value on text that cannot wrap is outside these ACT rules'
291
+ // applicability, and whether it wraps is not decidable here, so it is
292
+ // reported for review rather than failed.
293
+ if (cannotSoftWrap(el)) {
294
+ noWrap.push({ el, props: flagged.slice() });
295
+ continue;
296
+ }
246
297
 
247
298
  const tag = el.tagName.toLowerCase();
248
299
 
@@ -273,6 +324,82 @@ function runInPage(ctx) {
273
324
  occurrences
274
325
  };
275
326
  }
327
+ if (noWrap.length) {
328
+ return {
329
+ ruleId: rule.ruleId,
330
+ outcome: 'cantTell',
331
+ severity: rule.defaultSeverity || 'moderate',
332
+ confidence: 'low',
333
+ occurrences: noWrap.map(({ el, props }) =>
334
+ helpers.reportOccurrence(el, {
335
+ summary: `This element's inline style forces ${props.join(', ')} with !important, but its text does not appear able to wrap, so the text-spacing criterion may not apply to it.`,
336
+ hint: 'Confirm whether this text ever wraps. If it cannot, the criterion does not apply; if it can, remove !important or set a value that already meets the metric.',
337
+ i18n: {
338
+ summaryKey: 'avoidInlineSpacing_summary_cantTell_noSoftWrap',
339
+ hintKey: 'avoidInlineSpacing_hint_cantTell_noSoftWrap',
340
+ params: {
341
+ element: (el.tagName || '').toLowerCase(),
342
+ properties: props.join(', ')
343
+ }
344
+ },
345
+ uncertainty: {
346
+ code: 'not-computable',
347
+ needed: 'Whether this text ever contains a soft wrap break, which needs layout.',
348
+ evidence: {
349
+ element: (el.tagName || '').toLowerCase(),
350
+ properties: props,
351
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP'
352
+ }
353
+ },
354
+ data: {
355
+ details: {
356
+ reasonCode: 'INLINE_SPACING_NO_SOFT_WRAP',
357
+ element: (el.tagName || '').toLowerCase(),
358
+ properties: props
359
+ }
360
+ }
361
+ })
362
+ )
363
+ };
364
+ }
365
+ if (undecided.length) {
366
+ return {
367
+ ruleId: rule.ruleId,
368
+ outcome: 'cantTell',
369
+ severity: rule.defaultSeverity || 'moderate',
370
+ confidence: 'low',
371
+ occurrences: undecided.map(({ el, props }) =>
372
+ helpers.reportOccurrence(el, {
373
+ summary: `This element's inline style sets ${props.join(', ')} with !important, but the value could not be resolved, so whether it meets the WCAG text-spacing metric could not be determined.`,
374
+ hint: 'Check this value by hand against the metric (line-height 1.5, letter-spacing 0.12em, word-spacing 0.16em), or express it in a unit the engine can resolve against the element’s computed font size.',
375
+ i18n: {
376
+ summaryKey: 'avoidInlineSpacing_summary_cantTell',
377
+ hintKey: 'avoidInlineSpacing_hint_cantTell',
378
+ params: {
379
+ element: (el.tagName || '').toLowerCase(),
380
+ properties: props.join(', ')
381
+ }
382
+ },
383
+ uncertainty: {
384
+ code: 'not-computable',
385
+ needed: 'A resolved value for the spacing declarations marked !important.',
386
+ evidence: {
387
+ element: (el.tagName || '').toLowerCase(),
388
+ properties: props,
389
+ reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE'
390
+ }
391
+ },
392
+ data: {
393
+ details: {
394
+ reasonCode: 'INLINE_SPACING_NOT_RESOLVABLE',
395
+ element: (el.tagName || '').toLowerCase(),
396
+ properties: props
397
+ }
398
+ }
399
+ })
400
+ )
401
+ };
402
+ }
276
403
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
277
404
  }
278
405
 
@@ -178,6 +178,16 @@ function runInPage(ctx) {
178
178
  : {}
179
179
  )
180
180
  },
181
+ uncertainty: {
182
+ code: 'not-computable',
183
+ needed: 'A contrast ratio for this text, which this page composition blocks.',
184
+ evidence: {
185
+ reasonCode: rc,
186
+ blockerProperty: details.blockerProperty || null,
187
+ blockerValue: details.blockerValue || null,
188
+ backgroundAlpha: details.backgroundAlpha === undefined ? null : details.backgroundAlpha
189
+ }
190
+ },
181
191
  data: { details }
182
192
  };
183
193
 
@@ -216,6 +216,17 @@ function runInPage(ctx) {
216
216
 
217
217
  const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
218
218
 
219
+ // The background is the only input this rule can fail to resolve; every other
220
+ // reason code here describes a ratio it did compute.
221
+ const uncertainty =
222
+ det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
223
+ ? {
224
+ code: 'not-computable',
225
+ needed: 'The effective background colour behind this text.',
226
+ evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
227
+ }
228
+ : null;
229
+
219
230
  const occBase = {
220
231
  selector: '',
221
232
  html: '',
@@ -226,6 +237,7 @@ function runInPage(ctx) {
226
237
  hintKey: '',
227
238
  params: params && typeof params === 'object' ? params : {}
228
239
  },
240
+ ...(uncertainty ? { uncertainty } : {}),
229
241
  data: { details: det }
230
242
  };
231
243
 
@@ -213,6 +213,17 @@ function runInPage(ctx) {
213
213
 
214
214
  const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
215
215
 
216
+ // The background is the only input this rule can fail to resolve; every other
217
+ // reason code here describes a ratio it did compute.
218
+ const uncertainty =
219
+ det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
220
+ ? {
221
+ code: 'not-computable',
222
+ needed: 'The effective background colour behind this text.',
223
+ evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
224
+ }
225
+ : null;
226
+
216
227
  const occBase = {
217
228
  selector: '',
218
229
  html: '',
@@ -223,6 +234,7 @@ function runInPage(ctx) {
223
234
  hintKey: '',
224
235
  params: params && typeof params === 'object' ? params : {}
225
236
  },
237
+ ...(uncertainty ? { uncertainty } : {}),
226
238
  data: { details: det }
227
239
  };
228
240