@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
@@ -216,16 +216,28 @@ 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: '',
222
233
  summary: '',
223
- hint: '',
234
+ hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
224
235
  i18n: {
225
236
  summaryKey: 'contrastEnhanced_fail_belowThreshold',
226
- hintKey: '',
237
+ hintKey: 'contrastEnhanced_hint_fail',
227
238
  params: params && typeof params === 'object' ? params : {}
228
239
  },
240
+ ...(uncertainty ? { uncertainty } : {}),
229
241
  data: { details: det }
230
242
  };
231
243
 
@@ -486,11 +498,11 @@ function runInPage(ctx) {
486
498
  {
487
499
  selector: '',
488
500
  summary: '',
489
- hint: '',
501
+ hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
490
502
  html: '',
491
503
  i18n: {
492
504
  summaryKey: 'contrastEnhanced_cantTell_engineFailure',
493
- hintKey: '',
505
+ hintKey: 'contrast_hint_cantTell_manual',
494
506
  params: { reasonCode: 'ENGINE_EXCEPTION' }
495
507
  },
496
508
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
@@ -20,6 +20,10 @@
20
20
  * size: 3:1 for large text, 4.5:1 for everything else. Text is large at
21
21
  * 24px or more, or at 14pt (about 18.667px) or more when the computed font
22
22
  * weight is 700 or higher.
23
+ * @implementation-notes
24
+ * - Its thresholds are settings (`settings` below), so another standard's
25
+ * contrast requirement is a variant of this rule rather than a copy
26
+ * (docs/RULE_AUTHORING.md, "Rule variants"). Without settings it is WCAG's.
23
27
  */
24
28
 
25
29
  const id = 'contrast-minimum';
@@ -65,6 +69,21 @@ function runInPage(ctx) {
65
69
  }
66
70
  }
67
71
 
72
+ // Thresholds (see `settings` below): WCAG 1.4.3's by default. A variant of
73
+ // this rule, another standard's contrast requirement, passes its own in
74
+ // ctx.config (docs/RULE_AUTHORING.md, "Rule variants").
75
+ const cfg = ctx.config && typeof ctx.config === 'object' ? ctx.config : {};
76
+ const setting = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
77
+ const BOLD_LARGE_MIN_PX = setting(cfg.boldLargeMinPx, null);
78
+ const LARGE_TEXT_RATIO = setting(cfg.largeTextRatio, 3);
79
+ const NORMAL_TEXT_RATIO = setting(cfg.normalTextRatio, 4.5);
80
+ // The font and analysis caches hold verdicts that depend on those
81
+ // thresholds, so other thresholds get caches of their own.
82
+ const SETTINGS_KEY =
83
+ BOLD_LARGE_MIN_PX === null && LARGE_TEXT_RATIO === 3 && NORMAL_TEXT_RATIO === 4.5
84
+ ? ''
85
+ : '|' + [BOLD_LARGE_MIN_PX, LARGE_TEXT_RATIO, NORMAL_TEXT_RATIO].join('|');
86
+
68
87
  const __contrastSharedCache =
69
88
  helpers && helpers.contrast && helpers.contrast.sharedCache
70
89
  ? helpers.contrast.sharedCache
@@ -82,8 +101,9 @@ function runInPage(ctx) {
82
101
  ? __contrastSharedCache.__elFgCache || (__contrastSharedCache.__elFgCache = new WeakMap())
83
102
  : null;
84
103
 
104
+ const FONT_CACHE = '__elFontCache' + SETTINGS_KEY;
85
105
  const __elFontCache = __contrastSharedCache
86
- ? __contrastSharedCache.__elFontCache || (__contrastSharedCache.__elFontCache = new WeakMap())
106
+ ? __contrastSharedCache[FONT_CACHE] || (__contrastSharedCache[FONT_CACHE] = new WeakMap())
87
107
  : new WeakMap();
88
108
 
89
109
  function safeComputedStyle(el) {
@@ -124,7 +144,10 @@ function runInPage(ctx) {
124
144
 
125
145
  const sizePx = Number.isFinite(fontSizePx) ? fontSizePx : 0;
126
146
  const isBold = Number.isFinite(fontWeightNum) && fontWeightNum >= 700;
127
- const isLarge = helpers.contrast.isLargeText(sizePx, fontWeightNum);
147
+ const isLarge =
148
+ BOLD_LARGE_MIN_PX === null
149
+ ? helpers.contrast.isLargeText(sizePx, fontWeightNum)
150
+ : helpers.contrast.isLargeText(sizePx, fontWeightNum, BOLD_LARGE_MIN_PX);
128
151
 
129
152
  const out = {
130
153
  fontSizePx: sizePx,
@@ -213,16 +236,28 @@ function runInPage(ctx) {
213
236
 
214
237
  const det = details && typeof details === 'object' ? details : { reasonCode: 'UNKNOWN' };
215
238
 
239
+ // The background is the only input this rule can fail to resolve; every other
240
+ // reason code here describes a ratio it did compute.
241
+ const uncertainty =
242
+ det.reasonCode === 'BACKGROUND_NOT_COMPUTABLE'
243
+ ? {
244
+ code: 'not-computable',
245
+ needed: 'The effective background colour behind this text.',
246
+ evidence: { reasonCode: det.reasonCode, foreground: det.fg || null }
247
+ }
248
+ : null;
249
+
216
250
  const occBase = {
217
251
  selector: '',
218
252
  html: '',
219
253
  summary: '',
220
- hint: '',
254
+ hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
221
255
  i18n: {
222
256
  summaryKey: 'contrastMinimum_fail_belowThreshold',
223
- hintKey: '',
257
+ hintKey: 'contrastMinimum_hint_fail',
224
258
  params: params && typeof params === 'object' ? params : {}
225
259
  },
260
+ ...(uncertainty ? { uncertainty } : {}),
226
261
  data: { details: det }
227
262
  };
228
263
 
@@ -266,9 +301,10 @@ function runInPage(ctx) {
266
301
  let __elAnalysisCache = new WeakMap();
267
302
  if (__contrastSharedCache) {
268
303
  try {
269
- if (!__contrastSharedCache.__elAnalysisCacheAA)
270
- __contrastSharedCache.__elAnalysisCacheAA = new WeakMap();
271
- __elAnalysisCache = __contrastSharedCache.__elAnalysisCacheAA;
304
+ const ANALYSIS_CACHE = '__elAnalysisCacheAA' + SETTINGS_KEY;
305
+ if (!__contrastSharedCache[ANALYSIS_CACHE])
306
+ __contrastSharedCache[ANALYSIS_CACHE] = new WeakMap();
307
+ __elAnalysisCache = __contrastSharedCache[ANALYSIS_CACHE];
272
308
  } catch {
273
309
  __elAnalysisCache = new WeakMap();
274
310
  }
@@ -360,7 +396,12 @@ function runInPage(ctx) {
360
396
  const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
361
397
 
362
398
  const font = getFontInfo(el);
363
- const threshold = helpers.contrast.requiredRatio('AA', font.isLargeText);
399
+ const threshold =
400
+ SETTINGS_KEY === ''
401
+ ? helpers.contrast.requiredRatio('AA', font.isLargeText)
402
+ : font.isLargeText
403
+ ? LARGE_TEXT_RATIO
404
+ : NORMAL_TEXT_RATIO;
364
405
 
365
406
  analysis = {
366
407
  computable: true,
@@ -479,11 +520,11 @@ function runInPage(ctx) {
479
520
  {
480
521
  selector: '',
481
522
  summary: '',
482
- hint: '',
523
+ hint: "Measure this text's contrast by hand on the rendered page. Normal text needs at least 4.5:1 and large text 3:1 (7:1 and 4.5:1 for AAA).",
483
524
  html: '',
484
525
  i18n: {
485
526
  summaryKey: 'contrastMinimum_cantTell_engineFailure',
486
- hintKey: '',
527
+ hintKey: 'contrast_hint_cantTell_manual',
487
528
  params: { reasonCode: 'ENGINE_EXCEPTION' }
488
529
  },
489
530
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
@@ -552,4 +593,9 @@ function runInPage(ctx) {
552
593
  };
553
594
  }
554
595
 
555
- module.exports = { id, meta, runInPage };
596
+ // The thresholds a variant may change (docs/RULE_AUTHORING.md, "Rule
597
+ // variants"), with WCAG 1.4.3's as defaults: the size from which bold text is
598
+ // large (null: WCAG's 14pt), and the ratios large and other text need.
599
+ const settings = { boldLargeMinPx: null, largeTextRatio: 3, normalTextRatio: 4.5 };
600
+
601
+ module.exports = { id, meta, runInPage, settings };
@@ -20,6 +20,16 @@
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
22
22
  * display orientation unless that orientation is essential.
23
+ * Such a rotation fails.
24
+ *
25
+ * A second shape is asked about (cantTell): an orientation media block
26
+ * that hides the page's content with `display: none` or `visibility:
27
+ * hidden`, the usual form of WCAG F100 (content replaced by a "rotate
28
+ * your device" message in one orientation). The hidden element counts as
29
+ * the page's content when it is `html` or `body`, the `main` landmark, an
30
+ * ancestor of it, or, on a page without a main landmark, an element
31
+ * holding at least half of the body's text. Whether the orientation is
32
+ * essential, and whether the page stays usable, is left to a person.
23
33
  * @implementation-notes
24
34
  * - The rotation DEGREE is what makes this the exploit signature, not
25
35
  * merely the presence of a `rotate()` function: a small decorative icon
@@ -38,11 +48,16 @@
38
48
  * security model) and are skipped, same class of limitation as any
39
49
  * check that can only see same-origin/inspectable content (compare
40
50
  * `iframe-focusable-content`).
41
- * - Not per-element: this is a whole-document/whole-stylesheet concern,
42
- * so occurrences are reported against `document.documentElement`,
51
+ * - A rotation lock is not decided per element: this is a
52
+ * whole-document/whole-stylesheet concern, so its occurrences are
53
+ * reported against `document.documentElement`,
43
54
  * matching the "whole-document checks" precedent documented in
44
55
  * `docs/RULE_AUTHORING.md` §11.2 (e.g. `aria-hidden-body`,
45
- * `meta-viewport-zoom-enabled`).
56
+ * `meta-viewport-zoom-enabled`). A hidden-content finding is reported
57
+ * against the hidden element.
58
+ * - The rotation lock stays a failure, as in ACT b33eff, which assumes the
59
+ * orientation is not essential; a person reviewing an essential lock (a
60
+ * piano keyboard, a cheque scan) can set that finding aside.
46
61
  */
47
62
 
48
63
  const id = 'css-orientation-lock';
@@ -50,7 +65,7 @@ const id = 'css-orientation-lock';
50
65
  const meta = {
51
66
  title: 'CSS must not lock the page to a single orientation',
52
67
  description:
53
- 'Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation.',
68
+ "Checks that no @media (orientation: portrait|landscape) rule sets a transform: rotate(...) on the page, a known technique for defeating device orientation, and asks about any such rule that hides the page's main content.",
54
69
  i18n: {
55
70
  titleKey: 'cssOrientationLock_title',
56
71
  descriptionKey: 'cssOrientationLock_description'
@@ -228,18 +243,52 @@ function runInPage(ctx) {
228
243
  return m.includes('orientation') && (m.includes('portrait') || m.includes('landscape'));
229
244
  }
230
245
 
231
- function scanRuleList(rules, mediaText, findings) {
246
+ function hidesContent(styleDecl) {
247
+ if (!styleDecl || typeof styleDecl.getPropertyValue !== 'function') return false;
248
+ const display = trim(styleDecl.getPropertyValue('display')).toLowerCase();
249
+ const visibility = trim(styleDecl.getPropertyValue('visibility')).toLowerCase();
250
+ return display === 'none' || visibility === 'hidden' || visibility === 'collapse';
251
+ }
252
+
253
+ function scanRuleList(rules, mediaText, findings, hidings) {
232
254
  if (!rules) return;
233
255
  for (const r of rules) {
234
- if (!r) continue;
235
- if (r.type === CSS_STYLE_RULE && isLockingRotation(r.style)) {
256
+ if (!r || r.type !== CSS_STYLE_RULE) continue;
257
+ if (isLockingRotation(r.style)) {
236
258
  findings.push({ mediaText, selectorText: trim(r.selectorText) });
237
259
  }
260
+ if (r.selectorText && hidesContent(r.style)) {
261
+ hidings.push({ mediaText, selectorText: trim(r.selectorText) });
262
+ }
238
263
  }
239
264
  }
240
265
 
266
+ // Whether an element hidden by an orientation block holds the page's
267
+ // content: the root, the body, the main landmark or an ancestor of it, or,
268
+ // with no main landmark, most of the body's text.
269
+ function textLength(el) {
270
+ return String((el && el.textContent) || '').replace(/\s+/g, '').length;
271
+ }
272
+ let mainEl = null;
273
+ try {
274
+ mainEl = document.querySelector('main, [role="main"]');
275
+ } catch {
276
+ mainEl = null;
277
+ }
278
+ const bodyTextLength = document.body ? textLength(document.body) : 0;
279
+
280
+ function holdsPageContent(el) {
281
+ if (!el || el.nodeType !== 1) return false;
282
+ const tag = String(el.localName || '').toLowerCase();
283
+ if (tag === 'html' || tag === 'body') return true;
284
+ if (mainEl) return el === mainEl || (typeof el.contains === 'function' && el.contains(mainEl));
285
+ return bodyTextLength > 0 && textLength(el) * 2 >= bodyTextLength;
286
+ }
287
+
241
288
  const findings = [];
289
+ const hidings = [];
242
290
  let sheetCount = 0;
291
+ let unreadableSheetCount = 0;
243
292
 
244
293
  try {
245
294
  const sheets = document.styleSheets || [];
@@ -248,7 +297,10 @@ function runInPage(ctx) {
248
297
  try {
249
298
  rules = sheet && sheet.cssRules ? sheet.cssRules : null;
250
299
  } catch {
251
- continue; // cross-origin stylesheet, not inspectable
300
+ // Cross-origin, not inspectable. Counted, since a lock could be
301
+ // declared there and a `pass` would claim more than was checked.
302
+ unreadableSheetCount += 1;
303
+ continue;
252
304
  }
253
305
  if (!rules) continue;
254
306
  sheetCount += 1;
@@ -257,22 +309,116 @@ function runInPage(ctx) {
257
309
  if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
258
310
  const mediaText = rule2.media ? rule2.media.mediaText : '';
259
311
  if (!isOrientationMedia(mediaText)) continue;
260
- scanRuleList(rule2.cssRules, mediaText, findings);
312
+ scanRuleList(rule2.cssRules, mediaText, findings, hidings);
261
313
  }
262
314
  }
263
315
  } catch {
264
316
  // no-throw: treat as no accessible stylesheets
265
317
  }
266
318
 
267
- if (sheetCount === 0) {
268
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
319
+ const scanTarget = document.documentElement || document.body || null;
320
+
321
+ function unreadableSheetsOccurrence(count) {
322
+ return helpers.reportOccurrence(scanTarget, {
323
+ summary: `${count} stylesheet(s) could not be read, so whether this page locks its orientation could not be determined.`,
324
+ 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.',
325
+ i18n: {
326
+ summaryKey: 'cssOrientationLock_summary_cantTell_unreadableSheets',
327
+ hintKey: 'cssOrientationLock_hint_cantTell_unreadableSheets',
328
+ params: { count: String(count) }
329
+ },
330
+ uncertainty: {
331
+ code: 'not-computable',
332
+ needed: 'The contents of the stylesheets this scan could not read.',
333
+ evidence: { unreadableSheetCount: count, reasonCode: 'STYLESHEETS_NOT_READABLE' }
334
+ },
335
+ data: {
336
+ details: {
337
+ reasonCode: 'STYLESHEETS_NOT_READABLE',
338
+ unreadableSheetCount: count
339
+ }
340
+ }
341
+ });
342
+ }
343
+
344
+ // Orientation blocks hiding the page's content (F100): one question per
345
+ // hidden element.
346
+ const hiddenContent = [];
347
+ const seenHidden = new Set();
348
+ for (const h of hidings) {
349
+ let matched;
350
+ try {
351
+ matched = Array.from(document.querySelectorAll(h.selectorText));
352
+ } catch {
353
+ matched = [];
354
+ }
355
+ for (const el of matched) {
356
+ if (seenHidden.has(el) || !holdsPageContent(el)) continue;
357
+ seenHidden.add(el);
358
+ hiddenContent.push({ el, mediaText: h.mediaText, selectorText: h.selectorText });
359
+ }
360
+ }
361
+ const hiddenContentOccurrences = hiddenContent.map((f) =>
362
+ helpers.reportOccurrence(f.el, {
363
+ occurrenceOutcome: 'cantTell',
364
+ summary: `A "${f.mediaText}" media query hides "${f.selectorText}", which holds the page's main content, so the page may not be usable in that orientation.`,
365
+ hint: 'Check that the page can be viewed and operated in both portrait and landscape. If this media query replaces the content with a message asking the user to rotate the device, show the content instead, unless one orientation is essential.',
366
+ i18n: {
367
+ summaryKey: 'cssOrientationLock_summary_cantTell_hidesContent',
368
+ hintKey: 'cssOrientationLock_hint_cantTell_hidesContent',
369
+ params: { mediaText: f.mediaText, selectorText: f.selectorText }
370
+ },
371
+ uncertainty: {
372
+ code: 'judgement-required',
373
+ needed:
374
+ 'Whether the page stays usable in that orientation, and whether one orientation is essential.',
375
+ evidence: {
376
+ mediaText: f.mediaText,
377
+ selectorText: f.selectorText,
378
+ reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT'
379
+ }
380
+ },
381
+ data: {
382
+ details: {
383
+ reasonCode: 'ORIENTATION_MEDIA_HIDES_CONTENT',
384
+ mediaText: f.mediaText,
385
+ selectorText: f.selectorText
386
+ }
387
+ }
388
+ })
389
+ );
390
+
391
+ // A lock found in a readable sheet is still a lock, so `fail` outranks the
392
+ // uncertainty below.
393
+ if (!findings.length && hiddenContentOccurrences.length) {
394
+ const unreadable =
395
+ unreadableSheetCount > 0 ? [unreadableSheetsOccurrence(unreadableSheetCount)] : [];
396
+ return {
397
+ ruleId: rule.ruleId,
398
+ outcome: 'cantTell',
399
+ severity: rule.defaultSeverity || 'serious',
400
+ confidence: 'low',
401
+ occurrences: hiddenContentOccurrences.concat(unreadable)
402
+ };
269
403
  }
270
404
 
271
405
  if (!findings.length) {
406
+ if (unreadableSheetCount > 0) {
407
+ return {
408
+ ruleId: rule.ruleId,
409
+ outcome: 'cantTell',
410
+ severity: rule.defaultSeverity || 'serious',
411
+ confidence: 'low',
412
+ occurrences: [unreadableSheetsOccurrence(unreadableSheetCount)]
413
+ };
414
+ }
415
+ if (sheetCount === 0) {
416
+ return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
417
+ }
272
418
  return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
273
419
  }
274
420
 
275
- const target = document.documentElement || document.body || null;
421
+ const target = scanTarget;
276
422
  const occurrences = findings.map((f) =>
277
423
  helpers.reportOccurrence(target, {
278
424
  summary: f.selectorText
@@ -296,6 +442,19 @@ function runInPage(ctx) {
296
442
  })
297
443
  );
298
444
 
445
+ if (hiddenContentOccurrences.length) {
446
+ // See helpers.resolveTieredOutcome: the lock fails, and the hidden-content
447
+ // questions are kept beside it.
448
+ return {
449
+ ruleId: rule.ruleId,
450
+ ...helpers.resolveTieredOutcome(
451
+ occurrences,
452
+ hiddenContentOccurrences,
453
+ rule.defaultSeverity || 'serious'
454
+ )
455
+ };
456
+ }
457
+
299
458
  return {
300
459
  ruleId: rule.ruleId,
301
460
  outcome: 'fail',
@@ -9,16 +9,21 @@
9
9
  * @standard WCAG 2.2
10
10
  * @sc 1.3.1
11
11
  * @applicability
12
- * Applies to <dl> elements that have at least one direct element child.
12
+ * Applies to <dl> elements that have at least one direct element child or
13
+ * non-whitespace text directly inside them.
13
14
  * @expectation
14
15
  * Every direct element child is <dt>, <dd>, <script>, <template>, <style>,
15
16
  * or a <div> whose own children are drawn from that same set (a single
16
17
  * level of wrapping div is allowed, matching how authors commonly group
17
- * dt/dd pairs). If the flattened set contains any <dt> or <dd> at all, it
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
20
- * violation (see implementation-notes). Any other direct or wrapped child
21
- * breaks the description-list semantics assistive technologies rely on.
18
+ * dt/dd pairs). Non-whitespace text directly inside the <dl> or a wrapping
19
+ * <div> is an invalid child too. If the flattened set contains any <dt> or
20
+ * <dd> at all, it must contain BOTH (an unbalanced dt-without-dd or
21
+ * dd-without-dt is invalid), and read in order it must be groups of one
22
+ * or more <dt> followed by one or more <dd>: a <dd> before the first <dt>
23
+ * has no term, and a <dt> after the last <dd> has no definition. A
24
+ * flattened set with neither is vacuously fine, not a violation (see
25
+ * implementation-notes). Any other direct or wrapped child breaks the
26
+ * description-list semantics assistive technologies rely on.
22
27
  * @implementation-notes
23
28
  * - Only one level of <div> wrapping is flattened, a <div> nested inside
24
29
  * another wrapping <div> is not flattened further and its contents are
@@ -27,6 +32,11 @@
27
32
  * with NEITHER dt nor dd, whether from an empty wrapping <div>, only
28
33
  * <script>/<template>/<style> content, or a childless <dl>, is not flagged; only
29
34
  * an unbalanced dt/dd pairing is a real structural problem.
35
+ * - The order check reads the flattened dt/dd sequence across wrapping
36
+ * <div> boundaries, so a term and its definition split into two sibling
37
+ * <div>s still pass: assistive technologies get the same terms and
38
+ * definitions. The stricter HTML content model (each <div> holds a whole
39
+ * group) is not checked here.
30
40
  * - Distinct, atomic decision from dlitem-parent-valid (the
31
41
  * inverse relationship: does a given <dt>/<dd> have a valid parent).
32
42
  */
@@ -72,17 +82,29 @@ function runInPage(ctx) {
72
82
  const occurrences = [];
73
83
  let applicableCount = 0;
74
84
 
85
+ // Non-whitespace text directly inside `parent` (the <dl> or a wrapping
86
+ // <div>).
87
+ function hasDirectText(parent) {
88
+ for (const node of parent.childNodes || []) {
89
+ if (node && node.nodeType === 3 && /\S/.test(node.nodeValue || '')) return true;
90
+ }
91
+ return false;
92
+ }
93
+
75
94
  for (const el of nodes) {
76
95
  if (!el || !el.children) continue;
77
- if (!el.children.length) continue;
96
+ const dlHasText = hasDirectText(el);
97
+ if (!el.children.length && !dlHasText) continue;
78
98
 
79
99
  applicableCount += 1;
80
100
 
81
101
  // Flatten one level of wrapping <div> (common dt/dd grouping pattern).
82
102
  const flattened = [];
103
+ let hasText = dlHasText;
83
104
  for (const child of el.children) {
84
105
  if (!child || !child.tagName) continue;
85
106
  if (child.tagName.toLowerCase() === 'div') {
107
+ if (hasDirectText(child)) hasText = true;
86
108
  for (const grandchild of child.children || []) {
87
109
  if (grandchild && grandchild.tagName) flattened.push(grandchild);
88
110
  }
@@ -94,50 +116,72 @@ function runInPage(ctx) {
94
116
  let hasDt = false;
95
117
  let hasDd = false;
96
118
  const invalidTags = [];
119
+ // The dt/dd sequence in document order, for the group-order check.
120
+ const sequence = [];
97
121
  for (const node of flattened) {
98
122
  const tag = node.tagName.toLowerCase();
99
123
  if (tag === 'dt') {
100
124
  hasDt = true;
125
+ sequence.push(tag);
101
126
  continue;
102
127
  }
103
128
  if (tag === 'dd') {
104
129
  hasDd = true;
130
+ sequence.push(tag);
105
131
  continue;
106
132
  }
107
133
  if (!PASSTHROUGH_TAGS.has(tag)) invalidTags.push(tag);
108
134
  }
135
+ if (hasText) invalidTags.push('#text');
109
136
  const dedupedInvalidTags = [...new Set(invalidTags)];
110
137
 
138
+ // Groups of one or more dt then one or more dd: the sequence starts
139
+ // with a dt and ends with a dd.
140
+ const badOrder =
141
+ hasDt && hasDd && (sequence[0] !== 'dt' || sequence[sequence.length - 1] !== 'dd');
142
+
111
143
  // The dt/dd pairing is only required "when not empty", a <dl> with
112
144
  // NEITHER dt nor dd (whether childless after flattening, only
113
145
  // passthrough script/template/style content, or an empty wrapping div)
114
146
  // is vacuously fine, not a violation. Only an UNBALANCED pairing (dt
115
- // present without any dd, or vice versa) is a real structural problem.
147
+ // present without any dd, or vice versa) or a misordered one is a
148
+ // real structural problem.
116
149
  const reasonCode = invalidTags.length
117
150
  ? 'DL_INVALID_CHILD'
118
151
  : (hasDt || hasDd) && !(hasDt && hasDd)
119
152
  ? 'DL_NO_DT_DD'
120
- : null;
153
+ : badOrder
154
+ ? 'DL_DT_DD_ORDER'
155
+ : null;
121
156
  if (!reasonCode) continue;
122
157
 
123
- const summary = invalidTags.length
124
- ? 'This description list contains a direct or wrapped child that is not part of a dt/dd group.'
125
- : 'This description list has no <dt>/<dd> term-definition group.';
126
- const hint = invalidTags.length
127
- ? 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.'
128
- : 'Add at least one <dt>/<dd> pair inside this <dl>.';
158
+ const TEXT = {
159
+ DL_INVALID_CHILD: {
160
+ summary:
161
+ 'This description list contains a direct or wrapped child that is not part of a dt/dd group.',
162
+ hint: 'Only use <dt>/<dd> (optionally wrapped in one <div>), <script>, <template>, or <style> inside <dl>.',
163
+ key: 'invalidChild'
164
+ },
165
+ DL_NO_DT_DD: {
166
+ summary: 'This description list has no <dt>/<dd> term-definition group.',
167
+ hint: 'Add at least one <dt>/<dd> pair inside this <dl>.',
168
+ key: 'noDtDd'
169
+ },
170
+ DL_DT_DD_ORDER: {
171
+ summary:
172
+ 'In this description list, a <dd> comes before any <dt>, or the last <dt> has no <dd> after it.',
173
+ hint: 'Order each group as one or more <dt> followed by one or more <dd>.',
174
+ key: 'order'
175
+ }
176
+ }[reasonCode];
129
177
 
130
178
  occurrences.push(
131
179
  helpers.reportOccurrence(el, {
132
- summary,
133
- hint,
180
+ summary: TEXT.summary,
181
+ hint: TEXT.hint,
134
182
  i18n: {
135
- summaryKey: invalidTags.length
136
- ? 'definitionListChildrenValid_summary_fail_invalidChild'
137
- : 'definitionListChildrenValid_summary_fail_noDtDd',
138
- hintKey: invalidTags.length
139
- ? 'definitionListChildrenValid_hint_fail_invalidChild'
140
- : 'definitionListChildrenValid_hint_fail_noDtDd',
183
+ summaryKey: `definitionListChildrenValid_summary_fail_${TEXT.key}`,
184
+ hintKey: `definitionListChildrenValid_hint_fail_${TEXT.key}`,
141
185
  params: { invalidChildren: dedupedInvalidTags.join(', ') }
142
186
  },
143
187
  data: {