@surea11y/core 1.5.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 (157) hide show
  1. package/CHANGELOG.md +240 -149
  2. package/README.md +51 -44
  3. package/docs/ACT_RULE_MAPPING.md +245 -0
  4. package/docs/API_STABILITY.md +53 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +106 -4
  6. package/docs/DESIGN_CHALLENGES.md +367 -0
  7. package/docs/EARL.md +100 -0
  8. package/docs/ENGINE_OPTIONS.md +42 -4
  9. package/docs/I18N.md +4 -4
  10. package/docs/INTEGRATION.md +4 -2
  11. package/docs/LIMITATIONS.md +9 -5
  12. package/docs/OUTPUT_SCHEMA.md +44 -6
  13. package/docs/POLICY.md +1 -1
  14. package/docs/REPORT.md +1 -1
  15. package/docs/RULE_AUTHORING.md +63 -36
  16. package/docs/RULE_CATALOG.md +1928 -169
  17. package/docs/RULE_HELPERS.md +333 -0
  18. package/docs/RULE_TAXONOMY.md +27 -6
  19. package/docs/SARIF.md +21 -2
  20. package/docs/TROUBLESHOOTING.md +2 -2
  21. package/docs/WCAG_CONFORMANCE.md +34 -10
  22. package/package.json +11 -9
  23. package/src/baseline.js +3 -3
  24. package/src/checks/automatic/area-alt-present.js +2 -2
  25. package/src/checks/automatic/aria-allowed-attr.js +74 -10
  26. package/src/checks/automatic/aria-allowed-role.js +34 -25
  27. package/src/checks/automatic/aria-braille-equivalent.js +21 -13
  28. package/src/checks/automatic/aria-conditional-attr.js +22 -15
  29. package/src/checks/automatic/aria-deprecated-role.js +13 -1
  30. package/src/checks/automatic/aria-hidden-body.js +3 -3
  31. package/src/checks/automatic/aria-hidden-focus.js +5 -5
  32. package/src/checks/automatic/aria-prohibited-attr.js +23 -18
  33. package/src/checks/automatic/aria-prohibited-children.js +136 -43
  34. package/src/checks/automatic/aria-required-attr.js +119 -24
  35. package/src/checks/automatic/aria-required-children.js +54 -30
  36. package/src/checks/automatic/aria-required-parent.js +93 -15
  37. package/src/checks/automatic/aria-role-name-present.js +37 -23
  38. package/src/checks/automatic/aria-roles-valid.js +52 -21
  39. package/src/checks/automatic/aria-valid-attr-value.js +89 -33
  40. package/src/checks/automatic/aria-valid-attr.js +15 -10
  41. package/src/checks/automatic/autocomplete-valid.js +2 -2
  42. package/src/checks/automatic/avoid-inline-spacing.js +133 -6
  43. package/src/checks/automatic/binary-control-name-present.js +27 -5
  44. package/src/checks/automatic/button-name-present.js +92 -6
  45. package/src/checks/automatic/combobox-name-present.js +26 -6
  46. package/src/checks/automatic/contrast-computable.js +42 -0
  47. package/src/checks/automatic/contrast-enhanced.js +33 -1
  48. package/src/checks/automatic/contrast-minimum.js +33 -1
  49. package/src/checks/automatic/css-orientation-lock.js +138 -24
  50. package/src/checks/automatic/definition-list-children-valid.js +7 -8
  51. package/src/checks/automatic/deprecated-elements-not-used.js +1 -1
  52. package/src/checks/automatic/dialog-name-present.js +20 -2
  53. package/src/checks/automatic/duplicate-id-aria.js +10 -3
  54. package/src/checks/automatic/duplicate-id.js +203 -0
  55. package/src/checks/automatic/embed-text-alternative-present.js +2 -2
  56. package/src/checks/automatic/form-control-programmatic-label-present.js +19 -2
  57. package/src/checks/automatic/form-control-single-label.js +10 -1
  58. package/src/checks/automatic/identical-iframes-same-purpose.js +229 -0
  59. package/src/checks/automatic/iframe-focusable-content.js +68 -7
  60. package/src/checks/automatic/iframe-name-present.js +37 -3
  61. package/src/checks/automatic/iframe-title-unique.js +1 -1
  62. package/src/checks/automatic/img-alt-present.js +12 -4
  63. package/src/checks/automatic/label-in-name.js +204 -68
  64. package/src/checks/automatic/link-in-text-block.js +285 -29
  65. package/src/checks/automatic/link-name-present.js +22 -1
  66. package/src/checks/automatic/list-children-valid.js +6 -6
  67. package/src/checks/automatic/listbox-name-present.js +28 -8
  68. package/src/checks/automatic/listitem-parent-valid.js +4 -4
  69. package/src/checks/automatic/menuitem-name-present.js +20 -2
  70. package/src/checks/automatic/meta-refresh-no-exceptions.js +25 -14
  71. package/src/checks/automatic/meta-refresh-timing-absent.js +14 -7
  72. package/src/checks/automatic/meter-name-present.js +23 -4
  73. package/src/checks/automatic/nested-interactive-controls-absent.js +5 -5
  74. package/src/checks/automatic/option-name-present.js +23 -4
  75. package/src/checks/automatic/page-title-present.js +21 -3
  76. package/src/checks/automatic/presentational-children-focusable-absent.js +330 -0
  77. package/src/checks/automatic/progressbar-name-present.js +23 -4
  78. package/src/checks/automatic/{role-img-alt-present.js → role-img-text-alternative-present.js} +64 -16
  79. package/src/checks/automatic/searchbox-name-present.js +28 -8
  80. package/src/checks/automatic/server-side-image-map-absent.js +1 -1
  81. package/src/checks/automatic/slider-name-present.js +27 -6
  82. package/src/checks/automatic/spinbutton-name-present.js +28 -8
  83. package/src/checks/automatic/summary-name-present.js +18 -2
  84. package/src/checks/automatic/svg-image-text-alternative-present.js +1 -1
  85. package/src/checks/automatic/svg-text-alternative-present.js +13 -10
  86. package/src/checks/automatic/tab-name-present.js +21 -2
  87. package/src/checks/automatic/table-headers-attr-valid.js +43 -8
  88. package/src/checks/automatic/table-th-has-data-cells.js +61 -5
  89. package/src/checks/automatic/target-size-minimum.js +155 -58
  90. package/src/checks/automatic/td-has-header.js +24 -23
  91. package/src/checks/automatic/textbox-name-present.js +28 -8
  92. package/src/checks/automatic/tooltip-name-present.js +21 -2
  93. package/src/checks/automatic/treeitem-name-present.js +23 -4
  94. package/src/checks/automatic/valid-lang.js +92 -7
  95. package/src/checks/automatic/video-poster-text-alternative-present.js +1 -1
  96. package/src/checks/manual/accesskeys-manual.js +3 -3
  97. package/src/checks/manual/area-alt-decorative-manual.js +7 -0
  98. package/src/checks/manual/area-alt-quality-manual.js +6 -0
  99. package/src/checks/manual/aria-checked-state-mismatch-manual.js +5 -5
  100. package/src/checks/manual/aria-text-manual.js +4 -4
  101. package/src/checks/manual/bypass-blocks-present-manual.js +44 -26
  102. package/src/checks/manual/canvas-text-alternative-quality-manual.js +8 -0
  103. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +444 -0
  104. package/src/checks/manual/embed-text-alternative-quality-manual.js +8 -0
  105. package/src/checks/manual/empty-heading-manual.js +58 -11
  106. package/src/checks/manual/empty-table-header-manual.js +8 -8
  107. package/src/checks/manual/focus-order-semantics-manual.js +15 -15
  108. package/src/checks/manual/form-control-label-quality-manual.js +563 -0
  109. package/src/checks/manual/form-control-programmatic-label-quality-manual.js +2 -2
  110. package/src/checks/manual/heading-order-manual.js +3 -3
  111. package/src/checks/manual/heading-quality-manual.js +338 -0
  112. package/src/checks/manual/identical-links-same-purpose-manual.js +73 -12
  113. package/src/checks/manual/image-redundant-alt-manual.js +4 -4
  114. package/src/checks/manual/img-alt-decorative-manual.js +211 -52
  115. package/src/checks/manual/img-alt-quality-manual.js +7 -0
  116. package/src/checks/manual/input-image-alt-decorative-manual.js +8 -0
  117. package/src/checks/manual/input-image-alt-quality-manual.js +5 -0
  118. package/src/checks/manual/label-title-only-manual.js +4 -4
  119. package/src/checks/manual/landmark-banner-is-top-level-manual.js +7 -7
  120. package/src/checks/manual/landmark-complementary-is-top-level-manual.js +231 -0
  121. package/src/checks/manual/landmark-contentinfo-is-top-level-manual.js +9 -9
  122. package/src/checks/manual/landmark-main-is-top-level-manual.js +6 -6
  123. package/src/checks/manual/landmark-no-duplicate-banner-manual.js +6 -6
  124. package/src/checks/manual/landmark-no-duplicate-contentinfo-manual.js +6 -6
  125. package/src/checks/manual/landmark-no-duplicate-main-manual.js +2 -2
  126. package/src/checks/manual/landmark-one-main-manual.js +6 -6
  127. package/src/checks/manual/landmark-unique-manual.js +9 -9
  128. package/src/checks/manual/link-name-quality-manual.js +161 -32
  129. package/src/checks/manual/media-transcript-present-manual.js +2 -3
  130. package/src/checks/manual/meta-viewport-large-manual.js +2 -2
  131. package/src/checks/manual/mouse-only-event-handlers-manual.js +9 -9
  132. package/src/checks/manual/no-autoplay-audio-manual.js +3 -3
  133. package/src/checks/manual/object-text-alternative-quality-manual.js +8 -0
  134. package/src/checks/manual/p-as-heading-manual.js +4 -4
  135. package/src/checks/manual/page-has-heading-one-manual.js +6 -6
  136. package/src/checks/manual/page-title-patterns-manual.js +26 -3
  137. package/src/checks/manual/password-paste-enabled-manual.js +255 -0
  138. package/src/checks/manual/presentation-role-conflict-manual.js +55 -25
  139. package/src/checks/manual/region-manual.js +19 -19
  140. package/src/checks/manual/scope-attr-valid-manual.js +2 -2
  141. package/src/checks/manual/scrollable-region-focusable-manual.js +7 -7
  142. package/src/checks/manual/skip-link-manual.js +5 -5
  143. package/src/checks/manual/svg-text-alternative-quality-manual.js +9 -0
  144. package/src/checks/manual/tabindex-manual.js +2 -2
  145. package/src/checks/manual/table-duplicate-name-manual.js +2 -2
  146. package/src/checks/manual/table-fake-caption-manual.js +2 -2
  147. package/src/checks/manual/video-caption-manual.js +3 -3
  148. package/src/checks/manual-review.js +17 -1
  149. package/src/core.js +8880 -41883
  150. package/src/earl.js +144 -0
  151. package/src/report.js +2 -2
  152. package/src/sarif.js +22 -2
  153. package/surea11y.browser.js +10 -37882
  154. package/surea11y.i18n.de.js +2 -21
  155. package/surea11y.i18n.es.js +2 -21
  156. package/surea11y.i18n.fr.js +2 -21
  157. package/bin/surea11y-core.js +0 -20
@@ -2,6 +2,26 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check combobox-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="combobox" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="combobox" (the attribute must name
13
+ * that role alone, not a fallback list) that are included in the
14
+ * accessibility tree. An element with the matching implicit role but no
15
+ * role attribute is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title.
19
+ * role="combobox" is name-from-author-only, so subtree text is never
20
+ * accepted: text sitting inside a custom combobox widget is not reliably
21
+ * exposed as its name. On a labelable element (<input role="combobox">) an
22
+ * associated <label> counts as well.
23
+ */
24
+
5
25
  const id = 'combobox-name-present';
6
26
 
7
27
  const meta = {
@@ -66,7 +86,7 @@ function runInPage(ctx) {
66
86
  }
67
87
 
68
88
  function getConservativeSubtreeText(document, container) {
69
- // "Name from content" — recurses into descendants and uses each one's
89
+ // "Name from content", recurses into descendants and uses each one's
70
90
  // own accessible name (img alt, aria-label/aria-labelledby, title) when
71
91
  // it has one, not just literal text nodes. See getContentNameInfo's
72
92
  // header comment in src/core/dom-helpers.js for the full rationale
@@ -83,7 +103,7 @@ function runInPage(ctx) {
83
103
 
84
104
  // A <label> contributes a name via its own aria-label/aria-labelledby
85
105
  // (checked first, same ARIA-over-content precedence any element's
86
- // accessible name gives — e.g. <label aria-label="Search"><svg
106
+ // accessible name gives, e.g. <label aria-label="Search"><svg
87
107
  // aria-hidden="true">...</svg></label> names its control "Search" even
88
108
  // though the label's only child content is aria-hidden) or, failing
89
109
  // that, its rendered content (getConservativeSubtreeText).
@@ -98,10 +118,10 @@ function runInPage(ctx) {
98
118
  if (content) return content;
99
119
  // Final fallback per the general accname text-alternative algorithm,
100
120
  // which applies to any element being asked for its name regardless of
101
- // why (own aria-label, an aria-labelledby reference, or — here — native
121
+ // why (own aria-label, an aria-labelledby reference, or, here, native
102
122
  // <label for> association): title, when nothing else yields a name.
103
123
  // Purely additive (only fills in a name where there was none before),
104
- // so it carries no false-positive risk — see dialog-name-present.js's
124
+ // so it carries no false-positive risk, see dialog-name-present.js's
105
125
  // identical <iframe>-title-fallback fix for the concrete real-world
106
126
  // trigger this same accname step covers elsewhere.
107
127
  return getAttr(lab, 'title');
@@ -111,7 +131,7 @@ function runInPage(ctx) {
111
131
  const raw = getAttr(el, 'aria-labelledby');
112
132
  if (!raw) return '';
113
133
  // Delegates to the shared getTextFromIdRefs helper instead of computing
114
- // name-from-content of the referenced element — see dialog-name-
134
+ // name-from-content of the referenced element, see dialog-name-
115
135
  // present.js's identical fix for the full rationale (an <iframe>
116
136
  // aria-labelledby target's only name source is its title attribute,
117
137
  // which name-from-content alone can never see).
@@ -235,7 +255,7 @@ function runInPage(ctx) {
235
255
  occurrences.push(
236
256
  helpers.reportOccurrence(el, {
237
257
  summary: 'This combobox has no accessible name.',
238
- hint: "Provide aria-label, aria-labelledby, or a title attribute — visible text content is not exposed as this combobox's accessible name.",
258
+ hint: "Provide aria-label, aria-labelledby, or a title attribute. Visible text content is not exposed as this combobox's accessible name.",
239
259
  i18n: {
240
260
  summaryKey: 'comboboxNamePresent_summary_fail',
241
261
  hintKey: 'comboboxNamePresent_hint_fail',
@@ -2,6 +2,37 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check contrast-computable
7
+ * @atomic true
8
+ * @summary Color contrast must be computable from CSS for rendered text
9
+ * @standard WCAG 2.2
10
+ * @sc 1.4.3
11
+ * @sc 1.4.6
12
+ * @applicability
13
+ * Applies to every visible text node in scope, plus the label of <input
14
+ * type="button">/[type="submit"]/[type="reset"], which is rendered from
15
+ * the value attribute and so is invisible to a text-node walk. Text counts
16
+ * only when its element is DOM-visible under the run's visibility mode, is
17
+ * not clipped out of sight by the sr-only technique (clip or clip-path),
18
+ * and belongs neither to a disabled control nor to the label of one,
19
+ * WCAG's inactive-user-interface-component exception. Subtrees excluded
20
+ * via engineOptions.excludeSelectors are skipped, and open shadow roots
21
+ * are walked as roots in their own right.
22
+ * @expectation
23
+ * Both sides of the contrast calculation can be established from CSS for
24
+ * every applicable text node: an effective background resolving to an
25
+ * opaque color, and a parsable foreground color. Where either cannot be,
26
+ * a background image or gradient, mix-blend-mode, a filter or
27
+ * backdrop-filter, a text-shadow (which may add contrast this engine has
28
+ * no glyph-rendering model to account for), ancestor opacity, a root
29
+ * background that never becomes opaque, or a color that does not parse,
30
+ * the result is cantTell naming the blocker. This rule is the one that
31
+ * reports that uncertainty, which is what lets contrast-minimum and
32
+ * contrast-enhanced stay silent on the same text instead of guessing at
33
+ * a ratio.
34
+ */
35
+
5
36
  const id = 'contrast-computable';
6
37
 
7
38
  const meta = {
@@ -118,6 +149,7 @@ function runInPage(ctx) {
118
149
  else summaryKey = 'contrastComputable_cantTell_filterOrBackdropFilter';
119
150
  } else if (rc === 'BACKGROUND_NOT_OPAQUE_AT_ROOT')
120
151
  summaryKey = 'contrastComputable_cantTell_rootNotOpaque';
152
+ else if (rc === 'TEXT_SHADOW') summaryKey = 'contrastComputable_cantTell_textShadow';
121
153
 
122
154
  const details = Object.assign(
123
155
  { reasonCode: rc },
@@ -146,6 +178,16 @@ function runInPage(ctx) {
146
178
  : {}
147
179
  )
148
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
+ },
149
191
  data: { details }
150
192
  };
151
193
 
@@ -2,10 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check contrast-enhanced
7
+ * @atomic true
8
+ * @summary Text must meet the WCAG AAA contrast ratio for its size
9
+ * @standard WCAG 2.2
10
+ * @sc 1.4.6
11
+ * @applicability
12
+ * Applies to the visible text contrast-computable applies to, see that
13
+ * rule for the eligibility gates, narrowed to text whose background and
14
+ * foreground are actually computable. Eligible text that is not computable
15
+ * leaves this rule notApplicable rather than cantTell: reporting that
16
+ * uncertainty belongs to contrast-computable, so the two never report the
17
+ * same text twice.
18
+ * @expectation
19
+ * Every computable text node reaches the ratio SC 1.4.6 requires for its
20
+ * size: 4.5:1 for large text, 7:1 for everything else. Text is large at
21
+ * 24px or more, or at 14pt (about 18.667px) or more when the computed font
22
+ * weight is 700 or higher.
23
+ */
24
+
5
25
  const id = 'contrast-enhanced';
6
26
 
7
27
  const meta = {
8
- title: 'Text must meet the enhanced color contrast ratio',
28
+ title: 'Text meets enhanced color contrast (AAA)',
9
29
  description:
10
30
  'Checks that visible text has a contrast ratio of at least 7:1 (normal) or 4.5:1 (large), when contrast is computable from CSS.',
11
31
  i18n: {
@@ -196,6 +216,17 @@ function runInPage(ctx) {
196
216
 
197
217
  const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
198
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
+
199
230
  const occBase = {
200
231
  selector: '',
201
232
  html: '',
@@ -206,6 +237,7 @@ function runInPage(ctx) {
206
237
  hintKey: '',
207
238
  params: params && typeof params === 'object' ? params : {}
208
239
  },
240
+ ...(uncertainty ? { uncertainty } : {}),
209
241
  data: { details: det }
210
242
  };
211
243
 
@@ -2,10 +2,30 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check contrast-minimum
7
+ * @atomic true
8
+ * @summary Text must meet the WCAG AA contrast ratio for its size
9
+ * @standard WCAG 2.2
10
+ * @sc 1.4.3
11
+ * @applicability
12
+ * Applies to the visible text contrast-computable applies to, see that
13
+ * rule for the eligibility gates, narrowed to text whose background and
14
+ * foreground are actually computable. Eligible text that is not computable
15
+ * leaves this rule notApplicable rather than cantTell: reporting that
16
+ * uncertainty belongs to contrast-computable, so the two never report the
17
+ * same text twice.
18
+ * @expectation
19
+ * Every computable text node reaches the ratio SC 1.4.3 requires for its
20
+ * size: 3:1 for large text, 4.5:1 for everything else. Text is large at
21
+ * 24px or more, or at 14pt (about 18.667px) or more when the computed font
22
+ * weight is 700 or higher.
23
+ */
24
+
5
25
  const id = 'contrast-minimum';
6
26
 
7
27
  const meta = {
8
- title: 'Text must meet the minimum color contrast ratio',
28
+ title: 'Text meets minimum color contrast (AA)',
9
29
  description:
10
30
  'Checks that visible text has a contrast ratio of at least 4.5:1 (normal) or 3:1 (large), when contrast is computable from CSS.',
11
31
  i18n: {
@@ -193,6 +213,17 @@ function runInPage(ctx) {
193
213
 
194
214
  const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
195
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
+
196
227
  const occBase = {
197
228
  selector: '',
198
229
  html: '',
@@ -203,6 +234,7 @@ function runInPage(ctx) {
203
234
  hintKey: '',
204
235
  params: params && typeof params === 'object' ? params : {}
205
236
  },
237
+ ...(uncertainty ? { uncertainty } : {}),
206
238
  data: { details: det }
207
239
  };
208
240
 
@@ -9,13 +9,13 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.4
11
11
  * @applicability
12
- * Any accessible (same-document, non-cross-origin) stylesheet — inline
12
+ * Any accessible (same-document, non-cross-origin) stylesheet, inline
13
13
  * `<style>` blocks and same-origin `<link>` stylesheets already loaded
14
14
  * into `document.styleSheets`.
15
15
  * @expectation
16
16
  * No `@media (orientation: portrait)` or `@media (orientation:
17
17
  * landscape)` block sets a `transform`/`-webkit-transform`/`rotate`
18
- * rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270) —
18
+ * rotation of approximately 90 degrees (mod 180, i.e. ~90 or ~270),
19
19
  * the well-known technique for visually forcing one orientation
20
20
  * regardless of the device's actual orientation, which defeats WCAG
21
21
  * 1.3.4's requirement that content not restrict its view to a single
@@ -27,14 +27,15 @@
27
27
  * lock. Compute the actual rotation angle and only flag ~90/~270 degrees,
28
28
  * excluding ~0/~180 (a no-op or a flip, neither of which changes
29
29
  * portrait<->landscape).
30
- * - Only `rotate`/`rotateZ` (transform functions) and the standalone CSS
31
- * `rotate` property are parsed for degrees, from the two sources
32
- * `style.transform`/`style.rotate`; `matrix()`/`matrix3d()`/`rotate3d()`
33
- * are not decomposed into an equivalent angle (deliberately deferred as
34
- * the same class of higher-complexity/lower-value work deferred elsewhere,
35
- * e.g. `table-th-has-data-cells`'s narrower positional-header algorithm).
30
+ * - `rotate`/`rotateZ`, the standalone CSS `rotate` property, and
31
+ * `rotate3d(x, y, z, angle)`/`matrix()`/`matrix3d()` are all parsed for
32
+ * degrees. The matrix forms only yield an angle when they resolve to a
33
+ * pure rotation about the Z axis (no scale, skew, translation, or
34
+ * rotation combined with another axis). Anything else contributes 0,
35
+ * same as an unrecognized value, rather than guessing at an angle a
36
+ * general 3D matrix doesn't uniquely have.
36
37
  * - Cross-origin stylesheets throw on `.cssRules` access (browser
37
- * security model) and are skipped — same class of limitation as any
38
+ * security model) and are skipped, same class of limitation as any
38
39
  * check that can only see same-origin/inspectable content (compare
39
40
  * `iframe-focusable-content`).
40
41
  * - Not per-element: this is a whole-document/whole-stylesheet concern,
@@ -107,25 +108,98 @@ function runInPage(ctx) {
107
108
  return value; // deg
108
109
  }
109
110
 
110
- // Sums the degrees of every rotate()/rotateZ() function found in a
111
- // transform value (a real transform can legitimately compose more than
112
- // one, e.g. "translate(-50%) rotate(90deg)").
111
+ function parseNumberList(argsStr) {
112
+ return trim(argsStr)
113
+ .split(',')
114
+ .map((s) => parseFloat(s.trim()));
115
+ }
116
+
117
+ // A 2D linear map (a, b, c, d) -- the first four arguments of
118
+ // `matrix(a, b, c, d, e, f)`, or the top-left of `matrix3d`'s 4x4 --
119
+ // is a pure rotation only when its columns are unit length and
120
+ // orthogonal (no scale/skew/reflection): a = cos(theta), b = sin(theta),
121
+ // c = -sin(theta), d = cos(theta). Returns degrees, or null when the
122
+ // map isn't a pure rotation.
123
+ function decomposeMatrix2dRotation(a, b, c, d) {
124
+ const EPS = 1e-3;
125
+ if (Math.abs(a - d) > EPS || Math.abs(b + c) > EPS) return null;
126
+ if (Math.abs(a * a + b * b - 1) > EPS) return null;
127
+ if (Math.abs(a * d - b * c - 1) > EPS) return null;
128
+ return (Math.atan2(b, a) * 180) / Math.PI;
129
+ }
130
+
131
+ // The 16 column-major values of `matrix3d(...)` represent a pure Z-axis
132
+ // rotation only when the 3rd/4th columns still match the identity (no
133
+ // translation, perspective, or rotation around another axis) and the
134
+ // top-left 2x2 block is a pure 2D rotation.
135
+ function decomposeMatrix3dZRotation(v) {
136
+ if (v.length !== 16 || !v.every(Number.isFinite)) return null;
137
+ const EPS = 1e-3;
138
+ const near = (x, t) => Math.abs(x - t) < EPS;
139
+ if (!(near(v[2], 0) && near(v[3], 0) && near(v[6], 0) && near(v[7], 0))) return null;
140
+ if (!(near(v[8], 0) && near(v[9], 0) && near(v[10], 1) && near(v[11], 0))) return null;
141
+ if (!(near(v[12], 0) && near(v[13], 0) && near(v[14], 0) && near(v[15], 1))) return null;
142
+ return decomposeMatrix2dRotation(v[0], v[1], v[4], v[5]);
143
+ }
144
+
145
+ // Sums the degrees of every rotate()/rotateZ()/rotate3d()/matrix()/
146
+ // matrix3d() function found in a transform value (a real transform can
147
+ // legitimately compose more than one, e.g. "translate(-50%)
148
+ // rotate(90deg)"). The matrix forms only contribute when they decompose
149
+ // to a pure Z rotation; anything else contributes 0, same as no match.
113
150
  function rotateDegreesFromTransform(t) {
114
151
  if (!t) return 0;
115
152
  let total = 0;
116
- const re = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
117
153
  let m;
118
- while ((m = re.exec(t)) !== null) {
154
+
155
+ const rotateRe = /rotate(?:Z)?\s*\(([^)]*)\)/gi;
156
+ while ((m = rotateRe.exec(t)) !== null) {
119
157
  total += angleToDegrees(m[1]);
120
158
  }
159
+
160
+ const rotate3dRe = /rotate3d\s*\(([^)]*)\)/gi;
161
+ while ((m = rotate3dRe.exec(t)) !== null) {
162
+ const parts = m[1].split(',');
163
+ if (parts.length !== 4) continue;
164
+ const x = parseFloat(parts[0]);
165
+ const y = parseFloat(parts[1]);
166
+ const z = parseFloat(parts[2]);
167
+ const deg = angleToDegrees(parts[3]);
168
+ if (!Number.isFinite(x) || !Number.isFinite(y) || !Number.isFinite(z) || !deg) continue;
169
+ const EPS = 1e-3;
170
+ if (Math.abs(x) < EPS && Math.abs(y) < EPS && Math.abs(Math.abs(z) - 1) < EPS) {
171
+ total += z < 0 ? -deg : deg;
172
+ }
173
+ }
174
+
175
+ const matrixRe = /matrix\s*\(([^)]*)\)/gi;
176
+ while ((m = matrixRe.exec(t)) !== null) {
177
+ const vals = parseNumberList(m[1]);
178
+ if (vals.length === 6 && vals.every(Number.isFinite)) {
179
+ const deg = decomposeMatrix2dRotation(vals[0], vals[1], vals[2], vals[3]);
180
+ if (deg != null) total += deg;
181
+ }
182
+ }
183
+
184
+ const matrix3dRe = /matrix3d\s*\(([^)]*)\)/gi;
185
+ while ((m = matrix3dRe.exec(t)) !== null) {
186
+ const deg = decomposeMatrix3dZRotation(parseNumberList(m[1]));
187
+ if (deg != null) total += deg;
188
+ }
189
+
121
190
  return total;
122
191
  }
123
192
 
124
193
  // Whole-page-orientation-lock detection (see @implementation-notes): a
125
194
  // rotation near 0 or 180 degrees (mod 180) is a no-op or a flip, neither
126
195
  // of which changes portrait<->landscape, so it's NOT a lock; only a
127
- // rotation near 90 or 270 degrees (mod 90, once the 0/180 case is
128
- // excluded) is.
196
+ // rotation near 90 or 270 degrees is. "Near" is a tolerance window, not
197
+ // exact equality: a `rad`/`grad`/`turn` value converts to a 90-degree
198
+ // rotation with floating-point remainder (e.g. `1.5708rad` is
199
+ // 90.0000210...deg, never exactly 90), and ACT's own failed examples
200
+ // include a -inexact 92.5deg, both must still register as a
201
+ // lock, which exact-modulo-equality (`% 90 === 0`) never does.
202
+ const LOCK_TOLERANCE_DEG = 5;
129
203
  function isLockingRotation(styleDecl) {
130
204
  if (!styleDecl) return false;
131
205
  const transformVal =
@@ -141,9 +215,12 @@ function runInPage(ctx) {
141
215
 
142
216
  if (!degrees) return false;
143
217
 
144
- const abs = Math.abs(degrees);
145
- if (Math.abs(abs - 180) % 180 <= 0) return false; // near 0/180: not a lock
146
- return Math.abs(abs - 90) % 90 <= 0; // near 90/270: a lock
218
+ // Normalize into [0, 180): a rotation and its mirror (rotation + 180)
219
+ // swap the same two axes, so only the position within one half-turn
220
+ // matters. A value near the 90-degree midpoint of that range is near
221
+ // 90 OR 270 in the original full-turn range.
222
+ const normalized = ((degrees % 180) + 180) % 180;
223
+ return Math.abs(normalized - 90) <= LOCK_TOLERANCE_DEG;
147
224
  }
148
225
 
149
226
  function isOrientationMedia(mediaText) {
@@ -163,6 +240,7 @@ function runInPage(ctx) {
163
240
 
164
241
  const findings = [];
165
242
  let sheetCount = 0;
243
+ let unreadableSheetCount = 0;
166
244
 
167
245
  try {
168
246
  const sheets = document.styleSheets || [];
@@ -171,7 +249,10 @@ function runInPage(ctx) {
171
249
  try {
172
250
  rules = sheet && sheet.cssRules ? sheet.cssRules : null;
173
251
  } catch {
174
- continue; // cross-origin stylesheet, not inspectable
252
+ // Cross-origin, not inspectable. Counted, since a lock could be
253
+ // declared there and a `pass` would claim more than was checked.
254
+ unreadableSheetCount += 1;
255
+ continue;
175
256
  }
176
257
  if (!rules) continue;
177
258
  sheetCount += 1;
@@ -187,15 +268,48 @@ function runInPage(ctx) {
187
268
  // no-throw: treat as no accessible stylesheets
188
269
  }
189
270
 
190
- if (sheetCount === 0) {
191
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
192
- }
271
+ const scanTarget = document.documentElement || document.body || null;
193
272
 
273
+ // A lock found in a readable sheet is still a lock, so `fail` outranks the
274
+ // uncertainty below.
194
275
  if (!findings.length) {
276
+ if (unreadableSheetCount > 0) {
277
+ return {
278
+ ruleId: rule.ruleId,
279
+ outcome: 'cantTell',
280
+ severity: rule.defaultSeverity || 'serious',
281
+ confidence: 'low',
282
+ occurrences: [
283
+ helpers.reportOccurrence(scanTarget, {
284
+ summary: `${unreadableSheetCount} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
285
+ hint: 'Cross-origin stylesheets are not inspectable from the page. Check any third-party CSS for an orientation media query containing a rotate() transform, or re-run the scan with those stylesheets served same-origin.',
286
+ i18n: {
287
+ summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
288
+ hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
289
+ params: { count: String(unreadableSheetCount) }
290
+ },
291
+ uncertainty: {
292
+ code: 'not-computable',
293
+ needed: 'The contents of the stylesheets this scan could not read.',
294
+ evidence: { unreadableSheetCount, reasonCode: 'STYLESHEETS_NOT_READABLE' }
295
+ },
296
+ data: {
297
+ details: {
298
+ reasonCode: 'STYLESHEETS_NOT_READABLE',
299
+ unreadableSheetCount
300
+ }
301
+ }
302
+ })
303
+ ]
304
+ };
305
+ }
306
+ if (sheetCount === 0) {
307
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
308
+ }
195
309
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
196
310
  }
197
311
 
198
- const target = document.documentElement || document.body || null;
312
+ const target = scanTarget;
199
313
  const occurrences = findings.map((f) =>
200
314
  helpers.reportOccurrence(target, {
201
315
  summary: f.selectorText
@@ -16,17 +16,16 @@
16
16
  * level of wrapping div is allowed, matching how authors commonly group
17
17
  * dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
18
18
  * must contain BOTH (an unbalanced dt-without-dd or dd-without-dt is
19
- * invalid) — a flattened set with neither is vacuously fine, not a
19
+ * invalid). A flattened set with neither is vacuously fine, not a
20
20
  * violation (see implementation-notes). Any other direct or wrapped child
21
21
  * breaks the description-list semantics assistive technologies rely on.
22
22
  * @implementation-notes
23
- * - Only one level of <div> wrapping is flattened — a <div> nested inside
23
+ * - Only one level of <div> wrapping is flattened, a <div> nested inside
24
24
  * another wrapping <div> is not flattened further and its contents are
25
25
  * reported invalid.
26
26
  * - The dt/dd pairing is only required "when not empty". A flattened set
27
- * with NEITHER dt nor dd
28
- * — whether from an empty wrapping <div>, only <script>/<template>/
29
- * <style> content, or a genuinely childless <dl> — is not flagged; only
27
+ * with NEITHER dt nor dd, whether from an empty wrapping <div>, only
28
+ * <script>/<template>/<style> content, or a childless <dl>, is not flagged; only
30
29
  * an unbalanced dt/dd pairing is a real structural problem.
31
30
  * - Distinct, atomic decision from dlitem-parent-valid (the
32
31
  * inverse relationship: does a given <dt>/<dd> have a valid parent).
@@ -64,7 +63,7 @@ const meta = {
64
63
  function runInPage(ctx) {
65
64
  const { helpers, rule } = ctx;
66
65
 
67
- // Declared inside runInPage — see scripts/build-core.js header
66
+ // Declared inside runInPage, see scripts/build-core.js header
68
67
  // ("runInPage MUST be self-contained").
69
68
  const PASSTHROUGH_TAGS = new Set(['dt', 'dd', 'script', 'template', 'style']);
70
69
 
@@ -109,8 +108,8 @@ function runInPage(ctx) {
109
108
  }
110
109
  const dedupedInvalidTags = [...new Set(invalidTags)];
111
110
 
112
- // The dt/dd pairing is only required "when not empty" — a <dl> with
113
- // NEITHER dt nor dd (whether genuinely childless after flattening, only
111
+ // The dt/dd pairing is only required "when not empty", a <dl> with
112
+ // NEITHER dt nor dd (whether childless after flattening, only
114
113
  // passthrough script/template/style content, or an empty wrapping div)
115
114
  // is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
116
115
  // present without any dd, or vice versa) is a real structural problem.
@@ -15,7 +15,7 @@
15
15
  * or hide it.
16
16
  * @expectation
17
17
  * Neither element is present. Since their movement can never be paused,
18
- * stopped, or hidden by the user, presence is itself the violation — this
18
+ * stopped, or hidden by the user, presence is itself the violation. This
19
19
  * rule has no partial-pass case (it reports only when the element is
20
20
  * found).
21
21
  * @implementation-notes
@@ -2,6 +2,24 @@
2
2
 
3
3
  'use strict';
4
4
 
5
+ /**
6
+ * @check dialog-name-present
7
+ * @atomic true
8
+ * @summary Elements with role="dialog"/"alertdialog" must have an accessible name
9
+ * @standard WCAG 2.2
10
+ * @sc 4.1.2
11
+ * @applicability
12
+ * Applies to elements carrying role="dialog" or role="alertdialog" (the
13
+ * attribute must name one of those roles alone, not a fallback list) that
14
+ * are included in the accessibility tree. A native <dialog> without an
15
+ * explicit role is out of scope.
16
+ * @expectation
17
+ * The element has a non-empty accessible name from aria-label, from an
18
+ * aria-labelledby that resolves to non-empty text, or from title. Both
19
+ * roles are name-from-author-only, so the heading or body text inside the
20
+ * dialog is not accepted as its name unless aria-labelledby points at it.
21
+ */
22
+
5
23
  const id = 'dialog-name-present';
6
24
 
7
25
  const meta = {
@@ -54,13 +72,13 @@ function runInPage(ctx) {
54
72
  function resolveAriaLabelledbyText(document, el, maxRefs) {
55
73
  const raw = getAttr(el, 'aria-labelledby');
56
74
  if (!raw) return '';
57
- // Resolve via the shared getTextFromIdRefs helper — computes each
75
+ // Resolve via the shared getTextFromIdRefs helper, computes each
58
76
  // referenced element's own ACCESSIBLE NAME (aria-label, then
59
77
  // aria-labelledby, then a value-like name, then content, then title),
60
78
  // not just its content text. This matters when aria-labelledby points
61
79
  // at an <iframe title="..."> (e.g. BBC News' cookie-consent dialog,
62
80
  // pointing at an `<iframe title="SP Consent Message">`), whose only
63
- // name source is its title attribute — an iframe's content is
81
+ // name source is its title attribute, an iframe's content is
64
82
  // opaque/cross-origin per HTML-AAM, so "name from content" is always
65
83
  // empty. Computing only name-from-content of the referenced node would
66
84
  // silently miss the title fallback and report no accessible name at
@@ -20,8 +20,10 @@
20
20
  * still computed. Whether that element is the intended target depends on
21
21
  * author intent, which markup does not carry, so the outcome is cantTell.
22
22
  * @implementation-notes
23
- * - Scoped deliberately to ids referenced by ARIA, not the broader/
24
- * deprecated page-wide duplicate-id check (see ROADMAP.md's "Skip" list).
23
+ * - Scoped to ids referenced by ARIA. The page-wide check
24
+ * lives in its own rule, `duplicate-id`, mapped to the WCAG 2.0/2.1 SC
25
+ * 4.1.1 that WCAG 2.2 removed; the two overlap on referenced ids and
26
+ * answer different questions there (see that rule's header).
25
27
  * - Document-wide by design: id uniqueness and ARIA id references are a
26
28
  * whole-document property, not scoped to a sub-root. Reported occurrences are
27
29
  * limited to the scanned scope.
@@ -59,7 +61,7 @@ const meta = {
59
61
  function runInPage(ctx) {
60
62
  const { document, helpers, rule } = ctx;
61
63
 
62
- // Declared inside runInPage — see scripts/build-core.js header
64
+ // Declared inside runInPage, see scripts/build-core.js header
63
65
  // ("runInPage MUST be self-contained").
64
66
  const IDREF_ATTRS = [
65
67
  'aria-labelledby',
@@ -126,6 +128,11 @@ function runInPage(ctx) {
126
128
  hintKey: 'duplicateIdAria_hint_cantTell',
127
129
  params: { id: refId, duplicateCount: String(els.length) }
128
130
  },
131
+ uncertainty: {
132
+ code: 'judgement-required',
133
+ needed: 'Whether the first element carrying this id is the intended target.',
134
+ evidence: { id: refId, duplicateCount: els.length, resolvesTo: 'first' }
135
+ },
129
136
  data: {
130
137
  details: {
131
138
  reasonCode: 'DUPLICATE_ID_ARIA_REFERENCED',