@surea11y/core 1.6.0 → 1.8.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 (120) hide show
  1. package/CHANGELOG.md +140 -0
  2. package/README.md +179 -90
  3. package/docs/ACT_RULE_MAPPING.md +10 -8
  4. package/docs/API_STABILITY.md +67 -6
  5. package/docs/BINDING_AUTHORS_GUIDE.md +104 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +162 -2
  8. package/docs/EARL.md +100 -0
  9. package/docs/ENGINE_OPTIONS.md +109 -5
  10. package/docs/I18N.md +62 -20
  11. package/docs/INTEGRATION.md +4 -2
  12. package/docs/JUNIT.md +73 -0
  13. package/docs/LIMITATIONS.md +4 -1
  14. package/docs/OUTPUT_SCHEMA.md +62 -11
  15. package/docs/POLICY.md +1 -1
  16. package/docs/REPORT.md +7 -2
  17. package/docs/RULE_AUTHORING.md +83 -17
  18. package/docs/RULE_CATALOG.md +212 -139
  19. package/docs/RULE_EXAMPLES.md +2189 -0
  20. package/docs/RULE_HELPERS.md +390 -0
  21. package/docs/RULE_TAXONOMY.md +27 -6
  22. package/docs/SARIF.md +23 -3
  23. package/docs/WCAG_CONFORMANCE.md +64 -3
  24. package/package.json +41 -12
  25. package/profiles/index.js +14 -0
  26. package/src/checks/automatic/area-alt-present.js +87 -31
  27. package/src/checks/automatic/aria-allowed-attr.js +6 -0
  28. package/src/checks/automatic/aria-allowed-role.js +32 -23
  29. package/src/checks/automatic/aria-braille-equivalent.js +43 -17
  30. package/src/checks/automatic/aria-conditional-attr.js +17 -10
  31. package/src/checks/automatic/aria-deprecated-role.js +12 -0
  32. package/src/checks/automatic/aria-hidden-body.js +1 -1
  33. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  34. package/src/checks/automatic/aria-prohibited-attr.js +22 -4
  35. package/src/checks/automatic/aria-prohibited-children.js +6 -6
  36. package/src/checks/automatic/aria-required-attr.js +88 -12
  37. package/src/checks/automatic/aria-required-children.js +33 -16
  38. package/src/checks/automatic/aria-required-parent.js +32 -6
  39. package/src/checks/automatic/aria-role-name-present.js +20 -3
  40. package/src/checks/automatic/aria-roles-valid.js +52 -21
  41. package/src/checks/automatic/aria-valid-attr-value.js +89 -24
  42. package/src/checks/automatic/aria-valid-attr.js +14 -9
  43. package/src/checks/automatic/autocomplete-valid.js +152 -26
  44. package/src/checks/automatic/avoid-inline-spacing.js +207 -15
  45. package/src/checks/automatic/button-name-present.js +2 -1
  46. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  47. package/src/checks/automatic/combobox-name-present.js +34 -51
  48. package/src/checks/automatic/contrast-computable.js +45 -4
  49. package/src/checks/automatic/contrast-enhanced.js +16 -4
  50. package/src/checks/automatic/contrast-minimum.js +57 -11
  51. package/src/checks/automatic/css-orientation-lock.js +171 -12
  52. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  53. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  54. package/src/checks/automatic/dialog-name-present.js +28 -9
  55. package/src/checks/automatic/duplicate-id-aria.js +5 -0
  56. package/src/checks/automatic/duplicate-id.js +19 -10
  57. package/src/checks/automatic/form-control-single-label.js +9 -0
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +12 -4
  60. package/src/checks/automatic/iframe-title-unique.js +36 -81
  61. package/src/checks/automatic/input-image-alt-present.js +32 -20
  62. package/src/checks/automatic/label-in-name.js +78 -69
  63. package/src/checks/automatic/language-page-present.js +12 -6
  64. package/src/checks/automatic/link-in-text-block.js +512 -44
  65. package/src/checks/automatic/link-name-present.js +13 -5
  66. package/src/checks/automatic/list-children-valid.js +18 -1
  67. package/src/checks/automatic/listbox-name-present.js +19 -49
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  69. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  70. package/src/checks/automatic/page-title-present.js +16 -4
  71. package/src/checks/automatic/progressbar-name-present.js +11 -1
  72. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +9 -5
  73. package/src/checks/automatic/searchbox-name-present.js +32 -49
  74. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  75. package/src/checks/automatic/slider-name-present.js +38 -52
  76. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  77. package/src/checks/automatic/target-size-minimum.js +84 -16
  78. package/src/checks/automatic/td-has-header.js +60 -23
  79. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  80. package/src/checks/automatic/textbox-name-present.js +32 -49
  81. package/src/checks/automatic/valid-lang.js +15 -10
  82. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  83. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  84. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  85. package/src/checks/manual/css-hidden-focus.js +215 -7
  86. package/src/checks/manual/form-control-label-quality-manual.js +243 -29
  87. package/src/checks/manual/heading-order-manual.js +9 -1
  88. package/src/checks/manual/heading-quality-manual.js +143 -9
  89. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  90. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  91. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  92. package/src/checks/manual/link-name-quality-manual.js +130 -4
  93. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  94. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  95. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  96. package/src/checks/manual/p-as-heading-manual.js +89 -44
  97. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  98. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  99. package/src/checks/manual/skip-link-manual.js +42 -14
  100. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  101. package/src/checks/manual/video-caption-manual.js +47 -24
  102. package/src/checks/manual-review.js +0 -4
  103. package/src/core.js +18061 -46194
  104. package/src/coverage/en301549-map.js +187 -0
  105. package/src/coverage/standards.js +279 -0
  106. package/src/coverage/wcag-facets.js +1119 -0
  107. package/src/coverage/wcag-version-map.js +101 -0
  108. package/src/earl.js +144 -0
  109. package/src/en301549.js +33 -0
  110. package/src/junit.js +321 -0
  111. package/src/profile-kit.js +163 -0
  112. package/src/report.js +343 -74
  113. package/src/sarif.js +56 -5
  114. package/src/wcag.js +105 -0
  115. package/surea11y.browser.js +11 -41039
  116. package/surea11y.i18n.de.js +2 -21
  117. package/surea11y.i18n.es.js +2 -21
  118. package/surea11y.i18n.fr.js +2 -21
  119. package/surea11y.i18n.ja.js +3 -0
  120. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@surea11y/core",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "Deterministic WCAG 2.2 accessibility engine that tells you what it can't tell you. Zero dependencies.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -14,6 +14,7 @@
14
14
  "aria",
15
15
  "contrast",
16
16
  "sarif",
17
+ "junit",
17
18
  "zero-dependencies",
18
19
  "jsdom",
19
20
  "playwright",
@@ -30,7 +31,12 @@
30
31
  "./baseline": "./src/baseline.js",
31
32
  "./report": "./src/report.js",
32
33
  "./sarif": "./src/sarif.js",
34
+ "./junit": "./src/junit.js",
35
+ "./earl": "./src/earl.js",
36
+ "./en301549": "./src/en301549.js",
37
+ "./wcag": "./src/wcag.js",
33
38
  "./browser": "./surea11y.browser.js",
39
+ "./i18n/*": "./surea11y.i18n.*.js",
34
40
  "./package.json": "./package.json"
35
41
  },
36
42
  "author": "Jorge Rumoroso",
@@ -39,7 +45,7 @@
39
45
  "type": "git",
40
46
  "url": "git+https://github.com/SureA11y/core.git"
41
47
  },
42
- "homepage": "https://github.com/SureA11y/core#readme",
48
+ "homepage": "https://surea11y.dev/",
43
49
  "bugs": {
44
50
  "url": "https://github.com/SureA11y/core/issues"
45
51
  },
@@ -55,6 +61,18 @@
55
61
  "src/baseline.js",
56
62
  "src/report.js",
57
63
  "src/sarif.js",
64
+ "src/junit.js",
65
+ "src/earl.js",
66
+ "src/en301549.js",
67
+ "src/coverage/en301549-map.js",
68
+ "src/coverage/standards.js",
69
+ "src/wcag.js",
70
+ "src/profile-kit.js",
71
+ "src/coverage/wcag-facets.js",
72
+ "src/coverage/wcag-version-map.js",
73
+ "profiles/index.js",
74
+ "profiles/*/*.js",
75
+ "profiles/*/rules/**/*.js",
58
76
  "src/checks/**/*.js",
59
77
  "surea11y.browser.js",
60
78
  "surea11y.i18n.*.js",
@@ -70,27 +88,37 @@
70
88
  "scripts": {
71
89
  "lint": "eslint .",
72
90
  "lint:fix": "eslint . --fix",
73
- "format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"eslint.config.js\"",
74
- "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"eslint.config.js\"",
91
+ "format": "prettier --write \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"profiles/**/*.js\" \"eslint.config.js\"",
92
+ "format:check": "prettier --check \"src/**/*.js\" \"scripts/**/*.js\" \"tests/**/*.js\" \"profiles/**/*.js\" \"eslint.config.js\"",
75
93
  "build": "node scripts/build-core.js && node scripts/build-browser.js",
76
94
  "pretest": "playwright install chromium",
77
- "test": "npm run format:check && npm run build && node scripts/run-tests.js",
78
- "test:coverage": "npm run build && node scripts/run-tests.js --experimental-test-coverage --test-coverage-include=src/**/*.js",
95
+ "test": "npm run lint && npm run format:check && npm run build && npm run validate:rules && node scripts/run-tests.js",
96
+ "test:coverage": "npm run build && node scripts/run-tests.js --experimental-test-coverage --test-coverage-include=src/**/*.js --test-coverage-include=profiles/**/*.js",
79
97
  "test:contrast-helpers": "node tests/contrast-helpers.test.js",
80
98
  "helpers-perf-bench": "node --expose-gc scripts/dom-helpers-perf-bench.js",
81
99
  "engine-perf-bench": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --iters=25 --top=15",
82
100
  "engine-perf-bench-transparent": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --top=15 --iters=25 --generated=contrastTransparent",
83
101
  "engine-perf-bench-opaque": "node --expose-gc scripts/engine-perf-bench.js --profileRules=true --top=15 --iters=25 --generated=contrastOpaque",
84
102
  "test:typography-helpers": "node tests/contrast-typography.test.js",
85
- "coverage": "node scripts/generate-wcag-coverage.js --rulesDir src/checks",
86
- "coverage:strict": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --strictFacets",
87
- "coverage:check": "node scripts/generate-wcag-coverage.js --rulesDir src/checks --check",
103
+ "coverage": "node scripts/generate-wcag-coverage.js",
104
+ "coverage:strict": "node scripts/generate-wcag-coverage.js --strictFacets",
105
+ "coverage:check": "node scripts/generate-wcag-coverage.js --check",
106
+ "finding-ids": "npm run build && node scripts/generate-finding-ids.js",
107
+ "finding-ids:release": "node scripts/freeze-finding-ids.js",
88
108
  "fixtures:index": "node scripts/generate-fixture-index.js",
109
+ "fixtures:check": "node scripts/generate-fixture-index.js --check",
110
+ "fixtures:markers": "npm run build && node scripts/generate-fixture-markers.js",
111
+ "fixtures:markers:check": "npm run build && node scripts/generate-fixture-markers.js --check",
89
112
  "docs:rule-catalog": "npm run build && node scripts/generate-rule-catalog.js",
90
- "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic",
91
- "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual",
92
- "validate:rules": "npm run validate:automatic-rules && npm run validate:manual-rules",
113
+ "docs:rule-catalog:check": "npm run build && node scripts/generate-rule-catalog.js --check",
114
+ "rule-examples:coverage": "npm run build && node scripts/generate-rule-examples-coverage.js",
115
+ "rule-examples:coverage:check": "npm run build && node scripts/generate-rule-examples-coverage.js --check",
116
+ "docs:rule-review": "npm run build && node scripts/generate-rule-review.js",
117
+ "validate:automatic-rules": "npm run build && node scripts/validate-all-rules.js src/checks/automatic profiles/*/rules/automatic",
118
+ "validate:manual-rules": "npm run build && node scripts/validate-all-rules.js src/checks/manual profiles/*/rules/manual",
119
+ "validate:rules": "npm run build && node scripts/validate-all-rules.js",
93
120
  "i18n:new": "node scripts/i18n-scaffold.js",
121
+ "profile:new": "node scripts/profile-new.js",
94
122
  "i18n:sync": "node scripts/i18n-sync.js",
95
123
  "i18n:check": "node scripts/i18n-sync.js --check",
96
124
  "i18n:report": "node scripts/i18n-report.js"
@@ -98,6 +126,7 @@
98
126
  "devDependencies": {
99
127
  "@eslint/js": "^10.0.1",
100
128
  "aria-query": "^5.3.2",
129
+ "esbuild": "^0.28.2",
101
130
  "eslint": "^10.8.0",
102
131
  "eslint-config-prettier": "^10.1.8",
103
132
  "globals": "^17.8.0",
@@ -0,0 +1,14 @@
1
+ /* SPDX-License-Identifier: MPL-2.0 */
2
+
3
+ 'use strict';
4
+
5
+ /**
6
+ * The profiles built into the engine, in registry order. Each one is a
7
+ * standard whose verdicts are not WCAG's renumbered (see profiles/README.md),
8
+ * and exports `standard`, its registry entry, which src/coverage/standards.js
9
+ * appends after the standards core keeps for itself, `rulesDir`, the folder
10
+ * of its own rules, which the build compiles with core's, and `i18nDir`, the
11
+ * folder of its dictionaries, which the build adds to core's.
12
+ */
13
+
14
+ module.exports = [];
@@ -11,19 +11,30 @@
11
11
  * @applicability
12
12
  * Applies to <area> elements that:
13
13
  * 1) are in a <map> that is referenced by an <img usemap>, AND
14
- * 2) the referencing <img> is eligible in the accessibility tree (best-effort), AND
15
- * 3) the <area> itself is eligible in the accessibility tree (with engine exceptions).
14
+ * 2) carry a non-empty href (an <area> with no href is not a hyperlink
15
+ * at all per the HTML spec, and has nothing for this rule to name), AND
16
+ * 3) the referencing <img> is actually rendered (hidden/display:none/
17
+ * visibility exclude it; aria-hidden does not, since <area> is not a
18
+ * DOM descendant of <img>), AND
19
+ * 4) the <area> itself is eligible in the accessibility tree. hidden,
20
+ * display:none and inert on the <area> or its <map> do not exclude it:
21
+ * neither element generates a box, so a real browser's image-map
22
+ * hit-testing ignores all three there (verified against Chromium and
23
+ * Firefox); only those mechanisms on a genuine ancestor of the whole
24
+ * <img>+<map> pairing do.
16
25
  * @expectation
17
- * Each applicable <area> element has an alt attribute.
18
- * The alt attribute may be empty (alt="").
26
+ * Each applicable <area> element has a non-empty accessible name, from alt,
27
+ * aria-label/aria-labelledby, or title. An <area> in a used map is always
28
+ * a link, so alt="" is not decorative here as it is on <img>: an empty alt
29
+ * fails the same as a missing one unless another mechanism names it.
19
30
  */
20
31
 
21
32
  const id = 'area-alt-present';
22
33
 
23
34
  const meta = {
24
- title: '<area> must have an alt attribute',
35
+ title: '<area> must have an accessible name',
25
36
  description:
26
- 'Checks that <area> elements provide an alt attribute to support a text alternative mechanism.',
37
+ 'Checks that <area> elements have a non-empty accessible name via alt, aria-label/aria-labelledby, or title.',
27
38
  i18n: {
28
39
  titleKey: 'area_altPresent_title',
29
40
  descriptionKey: 'area_altPresent_description'
@@ -76,6 +87,11 @@ function runInPage(ctx) {
76
87
  const isAccTreeEligible =
77
88
  helpers && typeof helpers.isAccTreeEligible === 'function' ? helpers.isAccTreeEligible : null;
78
89
 
90
+ const isDomVisibleEligible =
91
+ helpers && typeof helpers.isDomVisibleEligible === 'function'
92
+ ? helpers.isDomVisibleEligible
93
+ : null;
94
+
79
95
  const getAriaNameInfo =
80
96
  helpers && typeof helpers.getAriaNameInfo === 'function' ? helpers.getAriaNameInfo : null;
81
97
 
@@ -156,17 +172,32 @@ function runInPage(ctx) {
156
172
  const img = getReferencingImgForArea(el);
157
173
  if (!img) continue;
158
174
 
159
- // 1) The referencing <img> must itself be eligible in the acc tree.
160
- // This is the "visibility of map/area doesn't matter; the image does" policy.
161
- if (isAccTreeEligible) {
162
- const imgElig = (() => {
175
+ // 0b) Without href an <area> is not a hyperlink at all per the HTML
176
+ // spec -- just a shape with no associated action -- so it has nothing
177
+ // for this rule to name.
178
+ const hrefRaw = el.getAttribute('href');
179
+ if (!hrefRaw || !hrefRaw.trim()) continue;
180
+
181
+ // 1) The referencing <img> must actually be rendered: a used map's
182
+ // hotspots depend on the img's box, not its accessibility-tree
183
+ // exposure. <area> is not a DOM descendant of <img> -- only linked by
184
+ // the usemap IDREF -- so aria-hidden on the img has nothing to
185
+ // propagate along (verified against Chromium and Firefox: the area
186
+ // stays reachable regardless). hidden/display:none/visibility on the
187
+ // img itself still excludes it, since that removes the box the
188
+ // hotspot geometry depends on.
189
+ if (isDomVisibleEligible) {
190
+ const imgVis = (() => {
163
191
  try {
164
- return isAccTreeEligible(img, ctx);
192
+ return isDomVisibleEligible(img, ctx, {
193
+ visibilityMode: 'styleOnly',
194
+ disableGeometry: true
195
+ });
165
196
  } catch {
166
197
  return { eligible: true, reasons: [] };
167
198
  }
168
199
  })();
169
- if (imgElig && imgElig.eligible === false) continue;
200
+ if (imgVis && imgVis.eligible === false) continue;
170
201
  }
171
202
 
172
203
  // 2) The <area> itself must be eligible (aria-hidden/inert exceptions handled by helper).
@@ -184,8 +215,18 @@ function runInPage(ctx) {
184
215
  // From here: applicable
185
216
  applicableCount += 1;
186
217
 
187
- const hasAlt = el.getAttribute('alt') !== null;
188
- if (hasAlt) continue;
218
+ let altRaw;
219
+ try {
220
+ altRaw = el.getAttribute('alt');
221
+ } catch {
222
+ altRaw = null;
223
+ }
224
+ const hasAltAttr = altRaw !== null;
225
+ const altGivesName = hasAltAttr && String(altRaw).trim() !== '';
226
+ if (altGivesName) continue;
227
+
228
+ // alt="" is not decorative on an <area>: a used map's area is always a
229
+ // link, so it needs a name from elsewhere or it fails.
189
230
 
190
231
  // aria-label / aria-labelledby is also a valid, standards-recognized
191
232
  // text-alternative mechanism for <area> (HTML-AAM accessible name
@@ -201,8 +242,8 @@ function runInPage(ctx) {
201
242
  }
202
243
 
203
244
  // A non-empty title attribute is HTML-AAM's own next fallback naming
204
- // source once alt is entirely absent. Same gap img-alt-present handles
205
- // for <img title="..."> with no alt.
245
+ // source once alt gives no name (missing or empty). Same gap
246
+ // img-alt-present handles for <img title="..."> with no alt.
206
247
  const titleRaw = (() => {
207
248
  try {
208
249
  return el.getAttribute('title');
@@ -214,21 +255,36 @@ function runInPage(ctx) {
214
255
 
215
256
  const eligInfo = getEligibilityInfo ? getEligibilityInfo(el, ctx, { targetSet: 'acc' }) : null;
216
257
 
217
- const baseOccurrence = {
218
- // Leave selector/html empty so the engine can fill them from __node.
219
- selector: '',
220
- html: '',
221
- summary: 'Missing alt attribute on &lt;area&gt;.',
222
- hint: 'Add an alt attribute (use alt="" only for decorative areas).',
223
- i18n: {
224
- summaryKey: 'area_altPresent_summary_fail',
225
- hintKey: 'area_altPresent_hint_fail',
226
- params: { element: 'area' }
227
- },
228
- data: {
229
- visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
230
- }
231
- };
258
+ const baseOccurrence = hasAltAttr
259
+ ? {
260
+ // Leave selector/html empty so the engine can fill them from __node.
261
+ selector: '',
262
+ html: '',
263
+ summary: 'Empty alt attribute leaves &lt;area&gt; link with no accessible name.',
264
+ hint: 'Describe the link destination in alt, or add aria-label/aria-labelledby (an <area> cannot be decorative once its map is used).',
265
+ i18n: {
266
+ summaryKey: 'area_altPresent_summary_fail_empty',
267
+ hintKey: 'area_altPresent_hint_fail_empty',
268
+ params: { element: 'area' }
269
+ },
270
+ data: {
271
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
272
+ }
273
+ }
274
+ : {
275
+ selector: '',
276
+ html: '',
277
+ summary: 'Missing alt attribute on &lt;area&gt;.',
278
+ hint: 'Add an alt attribute describing the link destination (an <area> cannot be decorative).',
279
+ i18n: {
280
+ summaryKey: 'area_altPresent_summary_fail',
281
+ hintKey: 'area_altPresent_hint_fail',
282
+ params: { element: 'area' }
283
+ },
284
+ data: {
285
+ visibilityFilter: eligInfo || { targetSet: 'acc', accEligible: null, reasons: [] }
286
+ }
287
+ };
232
288
 
233
289
  if (helpers && typeof helpers.reportOccurrence === 'function') {
234
290
  occurrences.push(helpers.reportOccurrence(el, baseOccurrence));
@@ -934,6 +934,12 @@ function runInPage(ctx) {
934
934
  hintKey: 'ariaAllowedAttr_hint_cantTell',
935
935
  params: { attr: name, role }
936
936
  },
937
+ uncertainty: {
938
+ code: 'spec-only',
939
+ needed:
940
+ 'Whether the deprecated attribute is still honoured by the assistive technology in use.',
941
+ evidence: { attribute: name, role, status: 'deprecated-for-role' }
942
+ },
937
943
  data: {
938
944
  details: { reasonCode: 'ARIA_ATTR_DEPRECATED', attr: name, role }
939
945
  }
@@ -6,8 +6,7 @@
6
6
  * @check aria-allowed-role
7
7
  * @atomic true
8
8
  * @summary Explicit role must be permitted by the ARIA-in-HTML spec for its host element
9
- * @standard WCAG 2.2
10
- * @sc 4.1.2
9
+ * @standard Best Practices (no formal WCAG Success Criterion)
11
10
  * @applicability
12
11
  * Applies to elements with an explicit, valid, non-abstract role, where
13
12
  * the host element/attribute combination has an asserted permitted-roles
@@ -16,7 +15,18 @@
16
15
  * @expectation
17
16
  * The explicit role is one of the roles the ARIA-in-HTML specification
18
17
  * permits for that host element.
18
+ * Reported at CANTTELL rather than FAIL: ARIA-in-HTML's permitted-roles
19
+ * table is an author conformance requirement with no ACT rule and no WCAG
20
+ * mapping in any source. The role the author asked for is still the role
21
+ * assistive technology exposes, so whether the combination harms anyone
22
+ * depends on the widget, not on the table.
19
23
  * @implementation-notes
24
+ * - Not WCAG-normative. ARIA-in-HTML's permitted-roles table is an author
25
+ * conformance requirement of that specification; no ACT rule covers it and
26
+ * no source maps it to a Success Criterion, so the rule reports the
27
+ * violation without claiming a criterion is failed. Deterministic all the
28
+ * same, so it stays `type: 'automatic'` and keeps deciding rather than
29
+ * deferring to a reviewer -- see docs/RULE_TAXONOMY.md 1.1.
20
30
  * - Scoped to elements present in ALLOWED_ROLES_BY_ELEMENT;
21
31
  * elements without an asserted constraint are treated as "no constraint"
22
32
  * (not flagged) rather than guessed at, see that table's header comment.
@@ -36,22 +46,14 @@ const meta = {
36
46
  descriptionKey: 'ariaAllowedRole_description'
37
47
  },
38
48
  helpUrl: null,
39
- tags: ['wcag2a', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
40
- wcagSc: ['4.1.2'],
41
- normativeMappings: [
42
- {
43
- standard: 'WCAG',
44
- version: '2.2',
45
- requirement: '4.1.2',
46
- title: 'Name, Role, Value',
47
- conformanceLevel: 'A'
48
- }
49
- ],
49
+ tags: ['best-practice', 'aria', 'structure', 'atomic', 'automatic'],
50
+ wcagSc: [],
51
+ normativeMappings: [],
50
52
  defaultSeverity: 'moderate',
51
53
  category: 'robust',
52
54
  type: 'automatic',
53
55
  defaultConfidence: 'high',
54
- coverage: { facetsBySc: { '4.1.2': ['aria-role-allowed-for-element'] } }
56
+ coverage: {}
55
57
  };
56
58
 
57
59
  function runInPage(ctx) {
@@ -93,6 +95,16 @@ function runInPage(ctx) {
93
95
  hintKey: 'ariaAllowedRole_hint_fail',
94
96
  params: { role, element: tag }
95
97
  },
98
+ uncertainty: {
99
+ code: 'spec-only',
100
+ needed: 'Whether the role misstates this element to assistive technology in practice.',
101
+ evidence: {
102
+ role,
103
+ element: tag,
104
+ source: 'ARIA in HTML permitted-roles table',
105
+ wcagSc: []
106
+ }
107
+ },
96
108
  data: {
97
109
  details: { reasonCode: 'ARIA_ROLE_NOT_ALLOWED_FOR_ELEMENT', role, element: tag }
98
110
  }
@@ -103,15 +115,12 @@ function runInPage(ctx) {
103
115
  if (applicableCount === 0) {
104
116
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
105
117
  }
106
- if (occurrences.length) {
107
- return {
108
- ruleId: rule.ruleId,
109
- outcome: 'fail',
110
- severity: rule.defaultSeverity || 'moderate',
111
- occurrences
112
- };
113
- }
114
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
118
+ const resolved = helpers.resolveTieredOutcome(
119
+ [],
120
+ occurrences,
121
+ rule.defaultSeverity || 'moderate'
122
+ );
123
+ return { ruleId: rule.ruleId, ...resolved };
115
124
  }
116
125
 
117
126
  module.exports = { id, meta, runInPage };
@@ -23,6 +23,11 @@
23
23
  * Using either braille-specific attribute as the ONLY naming mechanism
24
24
  * leaves non-braille assistive technology (most screen readers, voice
25
25
  * control, etc.) with no accessible name/role description at all.
26
+ * Reported at CANTTELL rather than FAIL: aria-brailleroledescription
27
+ * without aria-roledescription reaches no user at all, and a missing
28
+ * accessible name is the naming rules' decision for the roles that require
29
+ * one. The braille attribute being unpaired is worth surfacing, but it is
30
+ * not itself a criterion failing.
26
31
  * @implementation-notes
27
32
  * - `aria-braillelabel`/`aria-brailleroledescription` do not participate
28
33
  * in the standard accessible-name computation, so
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -108,13 +113,28 @@ function runInPage(ctx) {
108
113
  nameInfo && typeof nameInfo.value === 'string' ? nameInfo.value : ''
109
114
  );
110
115
  const name = programmaticName || getConservativeSubtreeText(el);
111
- if (!name) missing.push({ attr: 'aria-braillelabel', requires: 'an accessible name' });
116
+ if (!name)
117
+ missing.push({
118
+ attr: 'aria-braillelabel',
119
+ requires: 'an accessible name',
120
+ messageKey: 'label',
121
+ summary:
122
+ 'This element has aria-braillelabel but no accessible name, its non-braille equivalent.',
123
+ hint: 'aria-braillelabel is a Braille-specific supplement, not a replacement, so also give the element an accessible name (for example visible text, aria-label or aria-labelledby).'
124
+ });
112
125
  }
113
126
 
114
127
  if (brailleRoleDesc) {
115
128
  const roleDesc = trim(el.getAttribute('aria-roledescription'));
116
129
  if (!roleDesc)
117
- missing.push({ attr: 'aria-brailleroledescription', requires: 'aria-roledescription' });
130
+ missing.push({
131
+ attr: 'aria-brailleroledescription',
132
+ requires: 'aria-roledescription',
133
+ messageKey: 'roleDescription',
134
+ summary:
135
+ 'This element has aria-brailleroledescription but not aria-roledescription, its non-braille equivalent.',
136
+ hint: 'aria-brailleroledescription is a Braille-specific supplement, not a replacement, so also provide aria-roledescription.'
137
+ });
118
138
  }
119
139
 
120
140
  if (!missing.length) continue;
@@ -124,12 +144,21 @@ function runInPage(ctx) {
124
144
  for (const m of missing) {
125
145
  occurrences.push(
126
146
  helpers.reportOccurrence(el, {
127
- summary: `This element has ${m.attr} but no ${m.requires}, its non-braille equivalent.`,
128
- hint: `${m.attr} is a Braille-specific supplement, not a replacement, so also provide ${m.requires}.`,
147
+ // One message per missing equivalent rather than a shared template:
148
+ // `requires` is English prose for the label case, and interpolating
149
+ // it put English into every translated sentence.
150
+ summary: m.summary,
151
+ hint: m.hint,
129
152
  i18n: {
130
- summaryKey: 'ariaBrailleEquivalent_summary_fail',
131
- hintKey: 'ariaBrailleEquivalent_hint_fail',
132
- params: { element: tag, attr: m.attr, requires: m.requires }
153
+ summaryKey: `ariaBrailleEquivalent_summary_fail_${m.messageKey}`,
154
+ hintKey: `ariaBrailleEquivalent_hint_fail_${m.messageKey}`,
155
+ params: { element: tag, attr: m.attr }
156
+ },
157
+ uncertainty: {
158
+ code: 'spec-only',
159
+ needed:
160
+ 'Whether any user reaches this attribute, given no braille equivalent is exposed.',
161
+ evidence: { element: tag, attribute: m.attr, requires: m.requires }
133
162
  },
134
163
  data: {
135
164
  details: {
@@ -146,15 +175,12 @@ function runInPage(ctx) {
146
175
  if (applicableCount === 0) {
147
176
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
148
177
  }
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: [] };
178
+ const resolved = helpers.resolveTieredOutcome(
179
+ [],
180
+ occurrences,
181
+ rule.defaultSeverity || 'moderate'
182
+ );
183
+ return { ruleId: rule.ruleId, ...resolved };
158
184
  }
159
185
 
160
186
  module.exports = { id, meta, runInPage };
@@ -17,6 +17,11 @@
17
17
  * An element with `aria-errormessage` but `aria-invalid` absent or
18
18
  * `"false"` silently drops the error message from the accessibility
19
19
  * tree, authors almost always intend it to be exposed.
20
+ * Reported at CANTTELL rather than FAIL: aria-errormessage is only
21
+ * exposed once aria-invalid is set, so the reference is currently inert.
22
+ * Whether that costs the user anything depends on whether the message is
23
+ * conveyed some other way (visible text next to the field, aria-describedby),
24
+ * which static markup does not settle.
20
25
  * @implementation-notes
21
26
  * - This is narrow: the broader space is a table of many
22
27
  * attribute/condition pairs. This rule implements only the one pairing
@@ -52,7 +57,7 @@ const meta = {
52
57
  conformanceLevel: 'A'
53
58
  }
54
59
  ],
55
- defaultSeverity: 'serious',
60
+ defaultSeverity: 'moderate',
56
61
  category: 'robust',
57
62
  type: 'automatic',
58
63
  defaultConfidence: 'high',
@@ -98,6 +103,11 @@ function runInPage(ctx) {
98
103
  hintKey: 'ariaConditionalAttr_hint_fail',
99
104
  params: { element: tag, ariaInvalid: invalidValue || '(absent)' }
100
105
  },
106
+ uncertainty: {
107
+ code: 'spec-only',
108
+ needed: 'Whether the field is ever in an invalid state that should expose this message.',
109
+ evidence: { element: tag, ariaInvalid: invalidValue || null }
110
+ },
101
111
  data: {
102
112
  details: {
103
113
  reasonCode: 'ARIA_ERRORMESSAGE_WITHOUT_TRUTHY_INVALID',
@@ -111,15 +121,12 @@ function runInPage(ctx) {
111
121
  if (applicableCount === 0) {
112
122
  return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
113
123
  }
114
- if (occurrences.length) {
115
- return {
116
- ruleId: rule.ruleId,
117
- outcome: 'fail',
118
- severity: rule.defaultSeverity || 'serious',
119
- occurrences
120
- };
121
- }
122
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
124
+ const resolved = helpers.resolveTieredOutcome(
125
+ [],
126
+ occurrences,
127
+ rule.defaultSeverity || 'moderate'
128
+ );
129
+ return { ruleId: rule.ruleId, ...resolved };
123
130
  }
124
131
 
125
132
  module.exports = { id, meta, runInPage };
@@ -161,6 +161,12 @@ function runInPage(ctx) {
161
161
  hintKey: guidance.key,
162
162
  params: { role }
163
163
  },
164
+ uncertainty: {
165
+ code: 'spec-only',
166
+ needed:
167
+ 'Whether this user-agent-reserved role misleads the assistive technology in use.',
168
+ evidence: { role, ariaStatus: 'discouraged-for-authors' }
169
+ },
164
170
  data: {
165
171
  details: { reasonCode: 'ARIA_ROLE_AUTHOR_DISCOURAGED', role, guidance: guidance.text }
166
172
  }
@@ -178,6 +184,12 @@ function runInPage(ctx) {
178
184
  hintKey: guidance.key,
179
185
  params: { role }
180
186
  },
187
+ uncertainty: {
188
+ code: 'spec-only',
189
+ needed:
190
+ 'Whether the deprecated role is still honoured, and what it should be replaced with.',
191
+ evidence: { role, ariaStatus: 'deprecated' }
192
+ },
181
193
  data: {
182
194
  details: { reasonCode: 'ARIA_ROLE_DEPRECATED', role, guidance: guidance.text }
183
195
  }
@@ -33,7 +33,7 @@ const meta = {
33
33
  descriptionKey: 'ariaHiddenBody_description'
34
34
  },
35
35
  helpUrl: null,
36
- tags: ['wcag2a', 'wcag131', 'wcag412', 'structure', 'atomic', 'automatic'],
36
+ tags: ['wcag2a', 'wcag131', 'wcag412', 'aria', 'structure', 'atomic', 'automatic'],
37
37
  wcagSc: ['1.3.1', '4.1.2'],
38
38
  normativeMappings: [
39
39
  {