@surea11y/core 1.7.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 (103) hide show
  1. package/CHANGELOG.md +94 -1
  2. package/README.md +157 -54
  3. package/docs/ACT_RULE_MAPPING.md +2 -2
  4. package/docs/API_STABILITY.md +18 -5
  5. package/docs/BINDING_AUTHORS_GUIDE.md +2 -2
  6. package/docs/CI_INTEGRATIONS.md +43 -0
  7. package/docs/DESIGN_CHALLENGES.md +97 -3
  8. package/docs/EARL.md +1 -1
  9. package/docs/ENGINE_OPTIONS.md +81 -3
  10. package/docs/I18N.md +62 -20
  11. package/docs/JUNIT.md +73 -0
  12. package/docs/LIMITATIONS.md +1 -0
  13. package/docs/OUTPUT_SCHEMA.md +19 -6
  14. package/docs/REPORT.md +7 -2
  15. package/docs/RULE_AUTHORING.md +73 -6
  16. package/docs/RULE_CATALOG.md +139 -116
  17. package/docs/RULE_EXAMPLES.md +2189 -0
  18. package/docs/RULE_HELPERS.md +62 -5
  19. package/docs/RULE_TAXONOMY.md +2 -2
  20. package/docs/SARIF.md +2 -1
  21. package/docs/WCAG_CONFORMANCE.md +56 -3
  22. package/package.json +34 -11
  23. package/profiles/index.js +14 -0
  24. package/src/checks/automatic/area-alt-present.js +87 -31
  25. package/src/checks/automatic/aria-braille-equivalent.js +25 -7
  26. package/src/checks/automatic/aria-hidden-focus.js +74 -18
  27. package/src/checks/automatic/aria-prohibited-attr.js +17 -4
  28. package/src/checks/automatic/aria-required-attr.js +29 -0
  29. package/src/checks/automatic/aria-role-name-present.js +19 -2
  30. package/src/checks/automatic/aria-valid-attr-value.js +28 -16
  31. package/src/checks/automatic/autocomplete-valid.js +152 -26
  32. package/src/checks/automatic/avoid-inline-spacing.js +105 -40
  33. package/src/checks/automatic/button-name-present.js +2 -1
  34. package/src/checks/automatic/canvas-text-alternative-present.js +105 -14
  35. package/src/checks/automatic/combobox-name-present.js +34 -51
  36. package/src/checks/automatic/contrast-computable.js +35 -4
  37. package/src/checks/automatic/contrast-enhanced.js +4 -4
  38. package/src/checks/automatic/contrast-minimum.js +45 -11
  39. package/src/checks/automatic/css-orientation-lock.js +152 -30
  40. package/src/checks/automatic/definition-list-children-valid.js +67 -23
  41. package/src/checks/automatic/deprecated-elements-not-used.js +43 -38
  42. package/src/checks/automatic/dialog-name-present.js +28 -9
  43. package/src/checks/automatic/duplicate-id.js +6 -2
  44. package/src/checks/automatic/identical-iframes-same-purpose.js +4 -4
  45. package/src/checks/automatic/iframe-focusable-content.js +7 -4
  46. package/src/checks/automatic/iframe-title-unique.js +36 -81
  47. package/src/checks/automatic/input-image-alt-present.js +32 -20
  48. package/src/checks/automatic/label-in-name.js +40 -13
  49. package/src/checks/automatic/language-page-present.js +12 -6
  50. package/src/checks/automatic/link-in-text-block.js +272 -60
  51. package/src/checks/automatic/link-name-present.js +13 -5
  52. package/src/checks/automatic/list-children-valid.js +18 -1
  53. package/src/checks/automatic/listbox-name-present.js +19 -49
  54. package/src/checks/automatic/listitem-parent-valid.js +4 -3
  55. package/src/checks/automatic/meta-refresh-no-exceptions.js +3 -3
  56. package/src/checks/automatic/page-title-present.js +16 -4
  57. package/src/checks/automatic/progressbar-name-present.js +11 -1
  58. package/src/checks/automatic/role-img-text-alternative-present.js +9 -5
  59. package/src/checks/automatic/searchbox-name-present.js +32 -49
  60. package/src/checks/automatic/server-side-image-map-absent.js +48 -28
  61. package/src/checks/automatic/slider-name-present.js +38 -52
  62. package/src/checks/automatic/spinbutton-name-present.js +32 -49
  63. package/src/checks/automatic/target-size-minimum.js +0 -11
  64. package/src/checks/automatic/td-has-header.js +41 -5
  65. package/src/checks/automatic/text-spacing-content-loss.js +548 -0
  66. package/src/checks/automatic/textbox-name-present.js +32 -49
  67. package/src/checks/automatic/valid-lang.js +15 -10
  68. package/src/checks/manual/area-alt-quality-manual.js +113 -31
  69. package/src/checks/manual/canvas-text-alternative-quality-manual.js +0 -2
  70. package/src/checks/manual/css-focus-indicator-suppressed-manual.js +23 -10
  71. package/src/checks/manual/css-hidden-focus.js +215 -7
  72. package/src/checks/manual/form-control-label-quality-manual.js +109 -5
  73. package/src/checks/manual/heading-order-manual.js +9 -1
  74. package/src/checks/manual/heading-quality-manual.js +143 -9
  75. package/src/checks/manual/img-alt-decorative-manual.js +6 -3
  76. package/src/checks/manual/input-image-alt-quality-manual.js +81 -13
  77. package/src/checks/manual/link-name-quality-manual.js +130 -4
  78. package/src/checks/manual/media-transcript-present-manual.js +65 -8
  79. package/src/checks/manual/mouse-only-event-handlers-manual.js +66 -5
  80. package/src/checks/manual/no-autoplay-audio-manual.js +93 -6
  81. package/src/checks/manual/p-as-heading-manual.js +89 -44
  82. package/src/checks/manual/page-title-patterns-manual.js +77 -8
  83. package/src/checks/manual/skip-link-manual.js +42 -14
  84. package/src/checks/manual/table-fake-caption-manual.js +32 -1
  85. package/src/checks/manual/video-caption-manual.js +47 -24
  86. package/src/checks/manual-review.js +0 -4
  87. package/src/core.js +14419 -2231
  88. package/src/coverage/en301549-map.js +187 -0
  89. package/src/coverage/standards.js +279 -0
  90. package/src/coverage/wcag-facets.js +1119 -0
  91. package/src/coverage/wcag-version-map.js +101 -0
  92. package/src/en301549.js +33 -0
  93. package/src/junit.js +321 -0
  94. package/src/profile-kit.js +163 -0
  95. package/src/report.js +343 -74
  96. package/src/sarif.js +34 -3
  97. package/src/wcag.js +105 -0
  98. package/surea11y.browser.js +5 -4
  99. package/surea11y.i18n.de.js +1 -1
  100. package/surea11y.i18n.es.js +1 -1
  101. package/surea11y.i18n.fr.js +1 -1
  102. package/surea11y.i18n.ja.js +3 -0
  103. package/src/checks/manual/area-alt-decorative-manual.js +0 -255
@@ -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,
@@ -228,10 +251,10 @@ function runInPage(ctx) {
228
251
  selector: '',
229
252
  html: '',
230
253
  summary: '',
231
- hint: '',
254
+ hint: `Change the text color, the background color, or both, so the contrast ratio reaches at least ${params && params.threshold}:1.`,
232
255
  i18n: {
233
256
  summaryKey: 'contrastMinimum_fail_belowThreshold',
234
- hintKey: '',
257
+ hintKey: 'contrastMinimum_hint_fail',
235
258
  params: params && typeof params === 'object' ? params : {}
236
259
  },
237
260
  ...(uncertainty ? { uncertainty } : {}),
@@ -278,9 +301,10 @@ function runInPage(ctx) {
278
301
  let __elAnalysisCache = new WeakMap();
279
302
  if (__contrastSharedCache) {
280
303
  try {
281
- if (!__contrastSharedCache.__elAnalysisCacheAA)
282
- __contrastSharedCache.__elAnalysisCacheAA = new WeakMap();
283
- __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];
284
308
  } catch {
285
309
  __elAnalysisCache = new WeakMap();
286
310
  }
@@ -372,7 +396,12 @@ function runInPage(ctx) {
372
396
  const ratio = helpers.contrast.contrastRatio(fgOpaque, bgOpaque);
373
397
 
374
398
  const font = getFontInfo(el);
375
- 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;
376
405
 
377
406
  analysis = {
378
407
  computable: true,
@@ -491,11 +520,11 @@ function runInPage(ctx) {
491
520
  {
492
521
  selector: '',
493
522
  summary: '',
494
- 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).",
495
524
  html: '',
496
525
  i18n: {
497
526
  summaryKey: 'contrastMinimum_cantTell_engineFailure',
498
- hintKey: '',
527
+ hintKey: 'contrast_hint_cantTell_manual',
499
528
  params: { reasonCode: 'ENGINE_EXCEPTION' }
500
529
  },
501
530
  data: { details: { reasonCode: 'ENGINE_EXCEPTION' } }
@@ -564,4 +593,9 @@ function runInPage(ctx) {
564
593
  };
565
594
  }
566
595
 
567
- 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,17 +243,50 @@ 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;
243
291
  let unreadableSheetCount = 0;
244
292
 
@@ -261,7 +309,7 @@ function runInPage(ctx) {
261
309
  if (!rule2 || rule2.type !== CSS_MEDIA_RULE) continue;
262
310
  const mediaText = rule2.media ? rule2.media.mediaText : '';
263
311
  if (!isOrientationMedia(mediaText)) continue;
264
- scanRuleList(rule2.cssRules, mediaText, findings);
312
+ scanRuleList(rule2.cssRules, mediaText, findings, hidings);
265
313
  }
266
314
  }
267
315
  } catch {
@@ -270,8 +318,90 @@ function runInPage(ctx) {
270
318
 
271
319
  const scanTarget = document.documentElement || document.body || null;
272
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
+
273
391
  // A lock found in a readable sheet is still a lock, so `fail` outranks the
274
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
+ };
403
+ }
404
+
275
405
  if (!findings.length) {
276
406
  if (unreadableSheetCount > 0) {
277
407
  return {
@@ -279,28 +409,7 @@ function runInPage(ctx) {
279
409
  outcome: 'cantTell',
280
410
  severity: rule.defaultSeverity || 'serious',
281
411
  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
- ]
412
+ occurrences: [unreadableSheetsOccurrence(unreadableSheetCount)]
304
413
  };
305
414
  }
306
415
  if (sheetCount === 0) {
@@ -333,6 +442,19 @@ function runInPage(ctx) {
333
442
  })
334
443
  );
335
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
+
336
458
  return {
337
459
  ruleId: rule.ruleId,
338
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: {
@@ -5,34 +5,36 @@
5
5
  /**
6
6
  * @check deprecated-elements-not-used
7
7
  * @atomic true
8
- * @summary Obsolete non-stoppable elements must not be used
8
+ * @summary Scrolling <marquee> content needs a way to pause, stop, or hide it
9
9
  * @standard WCAG 2.2
10
10
  * @sc 2.2.2
11
11
  * @applicability
12
- * Applies to any <blink> or <marquee> element present in scope. These are
13
- * obsolete, non-standard HTML elements whose defining behavior (blinking
14
- * or auto-scrolling text) has no built-in user mechanism to pause, stop,
15
- * or hide it.
12
+ * Applies to any scan scope; whether it contains a <marquee> element is
13
+ * always an answerable question. <marquee> is an obsolete, non-standard
14
+ * HTML element that browsers still render as auto-scrolling text, with no
15
+ * built-in user mechanism to pause, stop, or hide it.
16
16
  * @expectation
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
19
- * rule has no partial-pass case (it reports only when the element is
20
- * found).
17
+ * Each <marquee> is reported as cantTell: the scrolling itself is certain,
18
+ * but a page can offer its own pause or stop control (a button calling the
19
+ * element's stop() method, for example), and then failure F16 does not
20
+ * apply. Whether such a control exists is for a person to check. A scope
21
+ * with no <marquee> passes; there is no separate not-applicable case.
21
22
  * @implementation-notes
22
- * - Not rule-gated on isAccTreeEligible: presence in markup is itself the
23
- * violation, independent of visibility (moving/blinking content inside a
24
- * hidden ancestor could still become visible later without a code
25
- * change, so hiding it today does not remove the underlying defect).
26
- * Engine-level hidden-subtree filtering still applies unless
27
- * engineOptions.includeHiddenElements is true.
23
+ * - <blink> is not reported. No current browser makes it blink: it renders
24
+ * as an unknown inline element, so it holds no blinking content for 2.2.2
25
+ * to govern.
26
+ * - Not rule-gated on isAccTreeEligible: presence in markup is what the rule
27
+ * asks about, independent of visibility. Engine-level hidden-subtree
28
+ * filtering still applies unless engineOptions.includeHiddenElements is
29
+ * true.
28
30
  */
29
31
 
30
32
  const id = 'deprecated-elements-not-used';
31
33
 
32
34
  const meta = {
33
- title: 'Obsolete non-stoppable elements (<blink>, <marquee>) must not be used',
35
+ title: 'Scrolling <marquee> content must be possible to pause, stop, or hide',
34
36
  description:
35
- 'Checks that deprecated, non-standard HTML elements whose blinking/scrolling content cannot be paused, stopped, or hidden by the user (<blink>, <marquee>) are not present.',
37
+ 'Asks, for each obsolete <marquee> element, whether the page offers a way to pause, stop, or hide its auto-scrolling content, since the element itself has none.',
36
38
  i18n: {
37
39
  titleKey: 'deprecatedElements_title',
38
40
  descriptionKey: 'deprecatedElements_description'
@@ -60,47 +62,50 @@ function runInPage(ctx) {
60
62
  const { helpers, rule } = ctx;
61
63
 
62
64
  const nodes = helpers.queryAllSmart
63
- ? helpers.queryAllSmart('blink, marquee')
64
- : helpers.queryAll('blink, marquee');
65
+ ? helpers.queryAllSmart('marquee')
66
+ : helpers.queryAll('marquee');
65
67
 
66
68
  const occurrences = [];
67
- let applicableCount = 0;
68
69
 
69
70
  for (const el of nodes) {
70
71
  if (!el || !el.tagName) continue;
71
72
 
72
- applicableCount += 1;
73
-
74
73
  const tag = el.tagName.toLowerCase();
75
74
 
76
75
  occurrences.push(
77
76
  helpers.reportOccurrence(el, {
78
- summary: 'This element’s content cannot be paused, stopped, or hidden by the user.',
79
- hint: 'Remove this element; use static content, or an animation with a user-facing pause/stop control, instead.',
77
+ summary:
78
+ 'This <marquee> scrolls its content, and the element itself gives the user no way to pause, stop, or hide it.',
79
+ hint: 'Check that the page offers a control that pauses, stops, or hides this content. Better: replace it with static content, or with an animation that has a pause/stop control.',
80
+ occurrenceOutcome: 'cantTell',
80
81
  i18n: {
81
- summaryKey: 'deprecatedElements_summary_fail',
82
- hintKey: 'deprecatedElements_hint_fail',
82
+ summaryKey: 'deprecatedElements_summary_cantTell',
83
+ hintKey: 'deprecatedElements_hint_cantTell',
83
84
  params: { element: tag }
84
85
  },
86
+ uncertainty: {
87
+ code: 'runtime-dependent',
88
+ needed:
89
+ 'Whether the page provides a control that pauses, stops, or hides the scrolling content.',
90
+ evidence: { element: tag }
91
+ },
85
92
  data: {
86
- details: { reasonCode: 'DEPRECATED_NON_STOPPABLE_ELEMENT', element: tag }
93
+ details: { reasonCode: 'MARQUEE_PAUSE_MECHANISM_UNKNOWN', element: tag }
87
94
  }
88
95
  })
89
96
  );
90
97
  }
91
98
 
92
- if (applicableCount === 0) {
93
- return { ruleId: rule.ruleId, outcome: 'notApplicable', severity: 'minor', occurrences: [] };
94
- }
95
- if (occurrences.length) {
96
- return {
97
- ruleId: rule.ruleId,
98
- outcome: 'fail',
99
- severity: rule.defaultSeverity || 'serious',
100
- occurrences
101
- };
99
+ // No <marquee> in scope is itself the passing case (see @expectation above).
100
+ if (!occurrences.length) {
101
+ return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
102
102
  }
103
- return { ruleId: rule.ruleId, outcome: 'pass', severity: 'minor', occurrences: [] };
103
+ return {
104
+ ruleId: rule.ruleId,
105
+ outcome: 'cantTell',
106
+ severity: rule.defaultSeverity || 'serious',
107
+ occurrences
108
+ };
104
109
  }
105
110
 
106
111
  module.exports = { id, meta, runInPage };