pour-engine 1.40.0 → 1.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +17 -15
  4. package/engine/README.md +10 -5
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +69 -63
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +5 -2
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/region.js +1 -1
  12. package/engine/rules/index.js +29 -0
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.2.3-video-audio-description.js +42 -0
  17. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  18. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  19. package/engine/rules/wcag/1.3.1-form-label.js +5 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.3.6-region-purpose.js +35 -0
  25. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  26. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  27. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  28. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  29. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  30. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  31. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  32. package/engine/rules/wcag/1.4.8-text-justified.js +49 -0
  33. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  34. package/engine/rules/wcag/2.2.1-meta-refresh.js +10 -4
  35. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  36. package/engine/rules/wcag/2.2.4-meta-refresh-no-exceptions.js +50 -0
  37. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  38. package/engine/rules/wcag/2.4.10-section-heading.js +86 -0
  39. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +134 -78
  40. package/engine/rules/wcag/2.4.12-focus-not-obscured-enhanced.js +17 -0
  41. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  42. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  43. package/engine/rules/wcag/2.4.6-heading-label-placeholder.js +69 -0
  44. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  45. package/engine/rules/wcag/2.5.1-pointer-gesture-alternative.js +98 -0
  46. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  47. package/engine/rules/wcag/3.3.4-financial-form-confirmation.js +32 -0
  48. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +10 -13
  49. package/engine/rules/wcag/3.3.9-captcha-alternative.js +35 -0
  50. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  51. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  52. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  53. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  54. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  55. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  56. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  57. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  58. package/engine/wcag22.js +19 -19
  59. package/package.json +1 -1
@@ -5,6 +5,7 @@
5
5
  import {
6
6
  parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
7
7
  backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
8
+ hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
8
9
  } from '../../lib/contrast.js';
9
10
 
10
11
  export default {
@@ -56,10 +57,26 @@ export default {
56
57
  // far less contrast than its declared colours suggest.
57
58
  const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
58
59
  if (opacity < 0.05) return { status: 'pass' };
60
+ if (hasPaintEffects(element)) {
61
+ return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented inside this field. Check the resulting text contrast by eye.' };
62
+ }
59
63
  // The control's own background wins; else whatever shows through it.
60
64
  const own = parseColor(style.backgroundColor);
61
65
  let background;
62
- if (own && own.a >= 1) {
66
+ if (style.backgroundImage !== 'none') {
67
+ const layers = splitBackgroundLayers(style.backgroundImage);
68
+ const range = layers.length === 1 && layers[0].includes('gradient(')
69
+ ? sampledGradientRange(layers[0]) : null;
70
+ // A constant opaque gradient has exactly one painted background.
71
+ // Other image paint needs spatial sampling, not its fallback colour.
72
+ const fullBox = /^(auto|auto auto)$/.test(style.backgroundSize)
73
+ && style.backgroundPosition === '0% 0%'
74
+ && ['repeat', 'no-repeat'].includes(style.backgroundRepeat);
75
+ if (!range || range.min !== range.max || opacity < 1 || !fullBox) {
76
+ return { status: 'incomplete', message: 'An image or gradient paints this field background. Check its text against the pixels behind it.' };
77
+ }
78
+ background = range.minColor;
79
+ } else if (own && own.a >= 1) {
63
80
  background = own;
64
81
  } else {
65
82
  // An image or gradient painted under a see-through control is a
@@ -73,18 +90,27 @@ export default {
73
90
  };
74
91
  }
75
92
  const behind = effectiveBackground(element);
76
- background = behind && own && own.a > 0 ? composite(own, behind) : behind;
93
+ background = behind; // effectiveBackground already composites the field fill
77
94
  }
78
95
  if (!background) {
79
96
  return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
80
97
  }
81
98
  const required = isLargeText(style) ? 3 : 4.5;
82
99
 
100
+ let unresolvedGroup = false;
83
101
  const judge = (color, what, ownOpacity = 1) => {
84
102
  const parsed = parseColor(color);
85
103
  if (!parsed || parsed.a === 0) return null;
86
104
  // Same treatment the main rule gives faded text: what reaches the eye
87
105
  // is the declared colour thinned by the opacity it is painted at.
106
+ if (opacity < 1) {
107
+ const group = opacityGroupPaint(element, { ...parsed, a: parsed.a * ownOpacity });
108
+ if (group?.unresolved) { unresolvedGroup = true; return null; }
109
+ if (group) {
110
+ const ratio = contrastRatio(group.foreground, group.background);
111
+ return ratio >= required ? null : { what, ratio };
112
+ }
113
+ }
88
114
  const painted = opacity * ownOpacity;
89
115
  const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
90
116
  const fg = faded.a < 1 ? composite(faded, background) : faded;
@@ -94,16 +120,18 @@ export default {
94
120
  };
95
121
 
96
122
  const failures = [];
97
- const valueVerdict = judge(style.color, 'value text');
123
+ const valueVerdict = judge(style.webkitTextFillColor || style.color, 'value text');
98
124
  if (valueVerdict) failures.push(valueVerdict);
99
125
  // Placeholder text is real text users must read; browsers expose its
100
- // computed colour via the ::placeholder pseudo-element.
101
- if (element.getAttribute('placeholder')?.trim()) {
126
+ // computed colour via the ::placeholder pseudo-element. Selectors 4
127
+ // defines :placeholder-shown for controls actually showing that text;
128
+ // a populated field's unpainted hint must not create a violation.
129
+ if (element.getAttribute('placeholder')?.trim() && element.matches(':placeholder-shown')) {
102
130
  let placeholderColor = null;
103
131
  let placeholderOpacity = 1;
104
132
  try {
105
133
  const placeholderStyle = getComputedStyle(element, '::placeholder');
106
- placeholderColor = placeholderStyle.color;
134
+ placeholderColor = placeholderStyle.webkitTextFillColor || placeholderStyle.color;
107
135
  // ::placeholder { opacity: .3 } is a widespread idiom (framework
108
136
  // resets, Firefox's own UA default of 0.54), and the criterion is on
109
137
  // the visual presentation of the text, which is the faded colour.
@@ -117,6 +145,7 @@ export default {
117
145
  if (verdict) failures.push(verdict);
118
146
  }
119
147
  }
148
+ if (unresolvedGroup) return { status: 'incomplete', message: 'The field and its text fade together over an unresolved background. Check the presented text contrast by eye.' };
120
149
  if (!failures.length) return { status: 'pass' };
121
150
  const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
122
151
  return {
@@ -20,10 +20,15 @@ export default {
20
20
  const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
21
21
  const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
22
22
  if (!disablesZoom && !cappedZoom) return { status: 'pass' };
23
+ // Asserted the way ACT rule b4f0c3 asserts it, under its assumption that
24
+ // the page provides no other way to enlarge the text (technique G178).
25
+ // The message names that assumption so a reviewer who knows the page
26
+ // has its own text-size control can dismiss the finding.
27
+ // https://www.w3.org/WAI/standards-guidelines/act/rules/b4f0c3/
23
28
  return {
24
29
  status: 'fail',
25
- message: 'This viewport meta tag stops low-vision users from zooming the page.',
26
- fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
30
+ message: 'This viewport meta tag stops users zooming the page, so text cannot be enlarged with the browser. 1.4.4 is met only if the page provides its own control that enlarges all text to 200% (technique G178); this finding assumes it does not, since the tag cannot show it.',
31
+ fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag. If the page has its own text-size control that reaches 200%, record that and dismiss this finding.',
27
32
  };
28
33
  },
29
34
  };
@@ -0,0 +1,49 @@
1
+ // WCAG SC 1.4.8 Visual Presentation (Level AAA) — the justified-text slice
2
+ //
3
+ // 1.4.8 lists five things a reader must be able to achieve for blocks of
4
+ // text; the third is "text is not justified (aligned to both the left and
5
+ // the right margins)". Justified text is the one of the five that the page
6
+ // itself imposes and that shows in a computed style; F88 ("using text that
7
+ // is justified") is its failure technique. The criterion is met when a
8
+ // mechanism lets the reader undo it, and the Understanding says the
9
+ // browser may provide that mechanism, so a justified block is asked about,
10
+ // not failed: the DOM cannot show whether a style switcher or a reader
11
+ // mode is on offer. The other four requirements (colour selection, 80
12
+ // character measure, line spacing, 200% resize) are met by browser
13
+ // mechanisms on ordinary pages and are not judged here.
14
+ //
15
+ // Only blocks with real running text count: the block's own text and the
16
+ // text of its inline children, at least 120 characters, so a justified
17
+ // wrapper is reported once at the paragraph that carries the words, not at
18
+ // every ancestor that inherits the alignment.
19
+ const BLOCK = /^(?:block|list-item|table-cell|flow-root)$/;
20
+
21
+ function inlineText(element) {
22
+ let text = '';
23
+ for (const node of element.childNodes) {
24
+ if (node.nodeType === 3) text += node.textContent;
25
+ else if (node.nodeType === 1 && getComputedStyle(node).display.startsWith('inline')) text += node.textContent;
26
+ }
27
+ return text.replace(/\s+/g, ' ').trim();
28
+ }
29
+
30
+ export default {
31
+ id: 'text-justified',
32
+ name: 'Justified text',
33
+ impact: 'moderate',
34
+ tags: ['wcag2aaa', 'wcag148'],
35
+ help: 'Blocks of text should not be justified to both margins (AAA)',
36
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html',
37
+ selector: 'p, li, dd, dt, td, th, blockquote, figcaption, summary, address, div, section, article, main, aside, header, footer',
38
+ evaluate(element) {
39
+ const style = getComputedStyle(element);
40
+ if (style.textAlign !== 'justify' && style.textAlign !== 'justify-all') return { status: 'pass' };
41
+ if (!BLOCK.test(style.display)) return { status: 'pass' };
42
+ if (inlineText(element).length < 120) return { status: 'pass' };
43
+ return {
44
+ status: 'incomplete',
45
+ message: 'This block of text is justified to both margins. The uneven spaces between words form "rivers of white" down the page that some readers with dyslexia or low vision cannot read across (failure F88). 1.4.8 allows justified text only where a mechanism lets the reader switch it off; check that one exists, in the page or the browser.',
46
+ fix: 'Set text-align: start (or left) on running text, or offer a control that turns justification off.',
47
+ };
48
+ },
49
+ };
@@ -92,13 +92,13 @@ export default {
92
92
  // An explicit negative tabindex is the author OPTING OUT of the tab
93
93
  // order, and the browser honours it: measured in Chrome 151, a scroller
94
94
  // with tabindex="-1" is skipped by Tab even though the same scroller
95
- // without the attribute would be auto-focused. Nothing inside is
96
- // tabbable either, so the overflowed content is unreachable by keyboard
97
- // in every engine — assertable, not browser-dependent.
95
+ // without the attribute would be auto-focused. That does not prove
96
+ // keyboard inoperability: external controls may scroll the region or
97
+ // move focus into it programmatically.
98
98
  if (element.hasAttribute('tabindex')) {
99
99
  return {
100
- status: 'fail',
101
- message: 'This region scrolls, holds nothing tabbable, and its tabindex="-1" removes the region itself from the tab order — keyboard users cannot reach the overflowed content in any browser.',
100
+ status: 'incomplete',
101
+ message: 'This region scrolls, has no detected tabbable content and is removed from sequential focus by a negative tabindex. Check whether keyboard-operable controls elsewhere scroll it or move focus into it; without such a mechanism, the overflowed content may be unreachable.',
102
102
  fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
103
103
  };
104
104
  }
@@ -10,7 +10,7 @@
10
10
  * the old test demanded the literal "url=", so content="0;https://…" was
11
11
  * asserted as a reload loop while the browser redirected from it.)
12
12
  */
13
- function refreshDestination(content) {
13
+ export function refreshDestination(content) {
14
14
  let rest = content.replace(/^\s*[\d.]*\s*(?:[;,]\s*)?/, '');
15
15
  // "url" is consumed letter by letter, then optional whitespace and "=".
16
16
  rest = rest.replace(/^url\s*(?:=\s*)?/i, '');
@@ -33,7 +33,7 @@ function refreshDestination(content) {
33
33
  * rule asserted a time limit on pages the browser never moves. Verified in
34
34
  * Chromium: both of those pages stay put while content="1; url=…" navigates.
35
35
  */
36
- function refreshDelay(content) {
36
+ export function refreshDelay(content) {
37
37
  const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
38
38
  const isDigit = (c) => c >= '0' && c <= '9';
39
39
  let i = 0;
@@ -80,10 +80,16 @@ export default {
80
80
  // 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
81
81
  // is still inside the criterion.
82
82
  if (delay > 72000) return { status: 'pass' };
83
+ // Asserted the way ACT rule bc659a asserts it: a meta refresh gives the
84
+ // user no way to turn the limit off, adjust it or extend it, so the only
85
+ // escapes are the real-time and essential exceptions, which the tag
86
+ // cannot show either way. The message names that assumption so a
87
+ // reviewer who knows the page offers a control can dismiss the finding.
88
+ // https://www.w3.org/WAI/standards-guidelines/act/rules/bc659a/
83
89
  return {
84
90
  status: 'fail',
85
- message: `The page refreshes/redirects after ${delay}s — slow readers lose their place (or the whole page) with no control.`,
86
- fix: 'Remove the timed refresh; let users act in their own time.',
91
+ message: `The page refreshes or redirects itself after ${delay}s. Slow readers lose their place, or the whole page, and a meta refresh offers no way to turn that off, adjust it or extend it. 2.2.1 excuses a time limit only where it is part of a real-time event or essential; this finding assumes neither applies, since the tag cannot show it.`,
92
+ fix: 'Remove the timed refresh; let users act in their own time. If the page offers a control that turns the refresh off, or the refresh is essential, record that and dismiss this finding.',
87
93
  };
88
94
  },
89
95
  };
@@ -6,8 +6,8 @@
6
6
  //
7
7
  // Two shapes are checkable, and they need different verdicts.
8
8
  //
9
- // <marquee> and <blink> exist only to move, ship no control of their own,
10
- // and are obsolete: a definite failure.
9
+ // Legacy markup is not proof of motion: blink has no native animation in
10
+ // modern browsers, and a marquee may be stopped or controlled by script.
11
11
  //
12
12
  // A CSS animation set to repeat for ever is the modern ticker. Clauses 1
13
13
  // and 2 are settled by the CSS itself — it starts with the page and never
@@ -117,15 +117,15 @@ export default {
117
117
  if (!elements.length) return [];
118
118
  const moving = endlesslyMoving(elements[0].ownerDocument);
119
119
  return elements.map((element) => {
120
- if (element.tagName === 'MARQUEE' || element.tagName === 'BLINK') {
120
+ if (element.tagName === 'MARQUEE') {
121
121
  // scrollamount="0" is the documented way to ship a <marquee> that
122
122
  // doesn't actually move.
123
123
  if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
124
124
  return { status: 'pass' };
125
125
  }
126
126
  return {
127
- status: 'fail',
128
- message: `<${element.tagName.toLowerCase()}> scrolls or blinks with no way to pause it, which is unusable for people with attention or vestibular conditions.`,
127
+ status: 'incomplete',
128
+ message: 'This marquee can scroll content. Check whether it actually starts automatically, continues for more than five seconds alongside other content, and has a working pause, stop or hide mechanism. The element alone does not establish those conditions.',
129
129
  fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
130
130
  };
131
131
  }
@@ -0,0 +1,50 @@
1
+ // WCAG SC 2.2.4 Interruptions (Level AAA) and SC 3.2.5 Change on Request
2
+ // (Level AAA) — the timed meta refresh, with none of 2.2.1's escapes.
3
+ //
4
+ // Both criteria list the same two failure techniques: F40 (a meta redirect
5
+ // with a time limit) and F41 (a meta refresh that reloads the page). 2.2.1
6
+ // excuses a limit longer than 20 hours; neither AAA criterion does. An
7
+ // interruption the reader cannot postpone or suppress breaks 2.2.4, and a
8
+ // change of context the reader did not ask for breaks 3.2.5, however long
9
+ // the timer. The instant redirect (zero seconds with a destination) stays
10
+ // allowed: G110 and H76 are sufficient techniques of 3.2.5 itself.
11
+ //
12
+ // One rule, two tags, on the twin-tag precedent (2.1.1/2.1.3): the same
13
+ // fact fails both. It runs beside 2.2.1's rule at AAA scope, so a refresh
14
+ // inside 20 hours is reported by both; each names its own criterion.
15
+ // 2.2.4 exempts emergencies, which a meta tag cannot show, so the message
16
+ // names that assumption the way the 2.2.1 rule names its own.
17
+ import { refreshDelay, refreshDestination } from './2.2.1-meta-refresh.js';
18
+
19
+ export default {
20
+ id: 'meta-refresh-no-exceptions',
21
+ name: 'Timed refresh (AAA)',
22
+ impact: 'serious',
23
+ tags: ['wcag2aaa', 'wcag224', 'wcag325'],
24
+ help: 'The page must not refresh or redirect itself on any timer (AAA)',
25
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html',
26
+ selector: 'meta[http-equiv="refresh" i]',
27
+ visibleOnly: false,
28
+ evaluate(element) {
29
+ const content = element.getAttribute('content') ?? '';
30
+ const delay = refreshDelay(content);
31
+ // The browser discards the directive: nothing is scheduled.
32
+ if (delay === null) return { status: 'pass' };
33
+ const hasDestination = refreshDestination(content) !== '';
34
+ // Zero seconds with a destination is the instant client-side redirect
35
+ // that G110 and H76 permit.
36
+ if (delay === 0 && hasDestination) return { status: 'pass' };
37
+ if (!hasDestination) {
38
+ return {
39
+ status: 'fail',
40
+ message: `The page reloads itself${delay > 0 ? ` every ${delay}s` : ' immediately, over and over'} (failure F41). The reader cannot postpone or suppress the reload, which 2.2.4 requires, and it changes the page without being asked, which 3.2.5 forbids. Neither AAA criterion has 2.2.1's 20-hour allowance.`,
41
+ fix: 'Remove the timed refresh and update the content in place, or let the reader request an update with a control.',
42
+ };
43
+ }
44
+ return {
45
+ status: 'fail',
46
+ message: `The page redirects itself after ${delay}s (failure F40). The reader cannot postpone or suppress the move, which 2.2.4 requires, and the change of context is not requested, which 3.2.5 forbids; the AAA criteria allow no 20-hour exemption. 2.2.4 excuses only an emergency, which the tag cannot show, so this finding assumes there is none.`,
47
+ fix: 'Redirect instantly (content="0; url=…") or on the server, or give the reader a link and let them choose when to move.',
48
+ };
49
+ },
50
+ };
@@ -80,8 +80,8 @@ export default {
80
80
  const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
81
81
  if (linkCount < 4) return { status: 'pass' };
82
82
  return {
83
- status: 'fail',
84
- message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link, or titled frame — keyboard and screen-reader users must go through the whole header and nav to reach anything.`,
83
+ status: 'incomplete',
84
+ message: `This page has ${linkCount} links and buttons but no detected landmark, heading, skip link or titled frame. Check whether it contains blocks repeated on other pages and, if so, whether a mechanism lets users bypass them. A single page cannot establish repetition.`,
85
85
  fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
86
86
  };
87
87
  },
@@ -0,0 +1,86 @@
1
+ // WCAG SC 2.4.10 Section Headings (Level AAA)
2
+ // "Section headings are used to organize the content." The Understanding
3
+ // defines a section as a self-contained portion of written content, says
4
+ // the criterion covers sections within writing rather than UI components,
5
+ // and gives H69 (a heading element at the beginning of each section) as
6
+ // the technique. Where a page's sections begin is a judgement about the
7
+ // writing, except in one case the author has made for us: an HTML
8
+ // <section> element is a declared section. A declared section that holds
9
+ // running text and starts with no heading is the honest candidate; whether
10
+ // it is really a section of writing or a layout wrapper stays the
11
+ // reader's call, so this asks and never asserts.
12
+ //
13
+ // A section has its heading when one of its own sits anywhere inside it
14
+ // (a nested section's or article's heading is that element's), when it
15
+ // BEGINS with one however the markup nests it (inside a header, a div or a
16
+ // nested article), or when the element immediately before it is that
17
+ // heading or ends with it: the blog pattern of an article's title followed by a
18
+ // section holding the post body (measured on the corpus: 292 such bodies
19
+ // on one feed page, none of them a missing heading). A section named by
20
+ // aria-labelledby or aria-label has a title by another route and is left
21
+ // alone. Sections with little text, or none, are the UI-component case the
22
+ // criterion excludes.
23
+ const HEADING = 'h1, h2, h3, h4, h5, h6, [role="heading"]';
24
+ const SUBSTANTIVE = 'img, svg, video, audio, canvas, iframe, input, select, textarea, button';
25
+ const SKIP = 'script, style, template, noscript';
26
+
27
+ /** The first (or, reversed, the last) substantive thing inside an element,
28
+ * in document order: a heading, or text or media that is not one. */
29
+ function edgeIsHeading(element, fromEnd) {
30
+ const children = (node) => (fromEnd ? [...node.childNodes].reverse() : [...node.childNodes]);
31
+ const stack = children(element).reverse();
32
+ while (stack.length) {
33
+ const node = stack.pop();
34
+ if (node.nodeType === 3) { if (node.textContent.trim()) return false; continue; }
35
+ if (node.nodeType !== 1) continue;
36
+ if (node.matches(HEADING)) return true;
37
+ if (node.matches(SKIP)) continue;
38
+ if (node.matches(SUBSTANTIVE)) return false;
39
+ for (const child of children(node).reverse()) stack.push(child);
40
+ }
41
+ return false;
42
+ }
43
+
44
+ export default {
45
+ id: 'section-heading',
46
+ name: 'Section headings',
47
+ impact: 'moderate',
48
+ tags: ['wcag2aaa', 'wcag2410'],
49
+ help: 'Sections of written content should begin with a heading (AAA)',
50
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html',
51
+ selector: 'section',
52
+ evaluate(element) {
53
+ if (element.hasAttribute('aria-label') || element.hasAttribute('aria-labelledby')) return { status: 'pass' };
54
+ const role = element.getAttribute('role');
55
+ if (role && role !== 'region') return { status: 'pass' };
56
+ // Any heading of the section's own (not a nested section's or article's)
57
+ // organises it, wherever it sits; a section that BEGINS with a heading,
58
+ // however nested, is headed too.
59
+ const ownHeading = [...element.querySelectorAll(HEADING)]
60
+ .some((heading) => heading.closest('section, article') === element);
61
+ if (ownHeading || edgeIsHeading(element, false)) return { status: 'pass' };
62
+ const before = element.previousElementSibling;
63
+ if (before && (before.matches(HEADING) || (before.matches('header, hgroup') && before.querySelector(HEADING)) || edgeIsHeading(before, true))) {
64
+ return { status: 'pass' };
65
+ }
66
+ // A feed of untitled notes: the section is the body of an article with
67
+ // no heading of its own, in a run of at least three such items. Whether
68
+ // the notes of a feed need headings is one question about the feed, for
69
+ // the reviewer to ask once; asking it per item put 292 findings on one
70
+ // corpus page and helped nobody.
71
+ const item = element.closest('article');
72
+ if (item && item.parentElement && !item.querySelector(HEADING)) {
73
+ const items = (node) => [...node.children].filter((child) => child.matches('article') || child.querySelector(':scope > article')).length;
74
+ const container = item.parentElement;
75
+ if (items(container) >= 3 || (container.parentElement && items(container.parentElement) >= 3)) return { status: 'pass' };
76
+ }
77
+ const text = (element.textContent ?? '').replace(/\s+/g, ' ').trim();
78
+ const paragraphs = element.querySelectorAll('p').length;
79
+ if (text.length < 200 && paragraphs < 2) return { status: 'pass' };
80
+ return {
81
+ status: 'incomplete',
82
+ message: 'This section holds written content but begins with no heading of its own, so readers scanning by headings cannot find or skip it. 2.4.10 asks for a heading at the start of each section of writing; check whether this is a section of content that needs one, or a purely structural wrapper.',
83
+ fix: 'Start the section with a heading that names its topic, at the level below its parent heading, or name it with aria-labelledby pointing at its visible title.',
84
+ };
85
+ },
86
+ };
@@ -2,7 +2,8 @@
2
2
  // An opaque fixed/sticky overlay covering a focusable element is the
3
3
  // criterion's core case (bottom cookie bars over footer links). Partial
4
4
  // overlap passes by definition ("not entirely hidden"), so only full
5
- // containment counts.
5
+ // containment counts. The AAA sibling built from the same factory (2.4.12,
6
+ // "no part of the component is hidden") counts any overlap at all.
6
7
  //
7
8
  // This REVIEWS rather than fails, deliberately. What a snapshot can see is
8
9
  // "element X is under the panel right now", and that is a fact about the
@@ -31,85 +32,140 @@ function edgeReserved(doc, edge, needed) {
31
32
  return px >= needed - 1;
32
33
  }
33
34
 
34
- export default {
35
+ /**
36
+ * Build the minimum (full containment) or enhanced (any overlap) rule.
37
+ * `partial: false` is 2.4.11 exactly as it has always run: one hit-test at
38
+ * the element's centre, and a blocker only counts when its box contains the
39
+ * whole element. `partial: true` is 2.4.12: the centre plus the four corners
40
+ * and four edge midpoints are hit-tested, and a blocker counts when it paints
41
+ * above the element at any of them and overlaps its box at all.
42
+ */
43
+ export function createFocusObscuredRule({ id, name, tags, help, helpUrl, partial }) {
44
+ return {
45
+ id,
46
+ name,
47
+ impact: 'serious',
48
+ tags,
49
+ help,
50
+ helpUrl,
51
+ selector: FOCUSABLE,
52
+ visibility: 'visual',
53
+ evaluateAll(elements) {
54
+ const doc = elements[0]?.ownerDocument ?? document;
55
+ const win = doc.defaultView;
56
+ // While a modal (cookie-consent dialog etc.) is open, the page behind
57
+ // it is INTENTIONALLY covered and focus is supposed to be trapped in
58
+ // the modal — a snapshot in that state proves nothing about the
59
+ // criterion. The backdrop is often a separate sibling of the dialog,
60
+ // so the modal state is checked document-wide, not per overlay.
61
+ const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
62
+ .some((el) => {
63
+ const rect = el.getBoundingClientRect();
64
+ return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
65
+ });
66
+ if (modal) return elements.map(() => ({ status: 'pass' }));
67
+
68
+ // Hit-test per IN-VIEWPORT focusable instead of style-scanning the
69
+ // whole document for overlays: elementsFromPoint at the target's
70
+ // centre surfaces whatever paints above it, and only the handful of
71
+ // on-screen targets pay any cost — a 200k-node spec page costs the
72
+ // same as a landing page. Per-overlay verdicts are memoized.
73
+ const overlayVerdict = new Map();
74
+ const isObscuringOverlay = (layer) => {
75
+ if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
76
+ let verdict = false;
77
+ const style = getComputedStyle(layer);
78
+ if (style.position === 'fixed' || style.position === 'sticky') {
79
+ const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
80
+ const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
81
+ const rect = layer.getBoundingClientRect();
82
+ verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
83
+ }
84
+ overlayVerdict.set(layer, verdict);
85
+ return verdict;
86
+ };
87
+ const contained = (rect, overlay) =>
88
+ rect.left >= overlay.left && rect.right <= overlay.right
89
+ && rect.top >= overlay.top && rect.bottom <= overlay.bottom;
90
+ const overlaps = (rect, overlay) =>
91
+ Math.min(rect.right, overlay.right) - Math.max(rect.left, overlay.left) > 0
92
+ && Math.min(rect.bottom, overlay.bottom) - Math.max(rect.top, overlay.top) > 0;
93
+ const clampX = (x) => Math.min(Math.max(x, 0), win.innerWidth - 1);
94
+ const clampY = (y) => Math.min(Math.max(y, 0), win.innerHeight - 1);
95
+ // The layer painting above the element at one point, if it is an
96
+ // obscuring overlay whose box relates to the element's as the mode
97
+ // demands. elementsFromPoint lists the whole stack, covered elements
98
+ // included, so an element ABSENT from it does not paint at that point
99
+ // at all (a rounded corner, a clip-path): nothing is hidden there.
100
+ // Measured on the corpus: consent-dialog buttons with rounded corners
101
+ // over their own opaque backdrop were flagged from exactly that.
102
+ const blockerAt = (element, rect, x, y) => {
103
+ const stack = doc.elementsFromPoint(clampX(x), clampY(y));
104
+ const index = stack.indexOf(element);
105
+ if (index <= 0) return null;
106
+ const above = stack.slice(0, index);
107
+ return above.find((layer) =>
108
+ !layer.contains(element) && !element.contains(layer)
109
+ && isObscuringOverlay(layer)
110
+ && (partial ? overlaps : contained)(rect, layer.getBoundingClientRect())) ?? null;
111
+ };
112
+
113
+ return elements.map((element) => {
114
+ // `:disabled` catches controls disabled by an ancestor fieldset, which
115
+ // the `disabled` property does not reflect — those never take focus.
116
+ if (element.matches(':disabled')) return { status: 'pass' };
117
+ // Inert content cannot be focused, so nothing can obscure its focus.
118
+ if (isInert(element)) return { status: 'pass' };
119
+ const rect = element.getBoundingClientRect();
120
+ if (!rect.width || !rect.height) return { status: 'pass' };
121
+ // Only in-viewport geometry is trustworthy (or cheap).
122
+ if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
123
+ return { status: 'pass' };
124
+ }
125
+ const cx = rect.left + rect.width / 2;
126
+ const cy = rect.top + rect.height / 2;
127
+ let blocker = blockerAt(element, rect, cx, cy);
128
+ if (!blocker && partial) {
129
+ // A strip of cookie bar over the bottom of a link leaves its
130
+ // centre clear; the corners and edge midpoints see it.
131
+ const inset = 1;
132
+ const xs = [rect.left + inset, cx, rect.right - inset];
133
+ const ys = [rect.top + inset, cy, rect.bottom - inset];
134
+ for (const x of xs) {
135
+ for (const y of ys) {
136
+ if (x === cx && y === cy) continue;
137
+ blocker = blockerAt(element, rect, x, y);
138
+ if (blocker) break;
139
+ }
140
+ if (blocker) break;
141
+ }
142
+ }
143
+ if (!blocker) return { status: 'pass' };
144
+ // Which viewport edge is the panel pinned to? That is the edge the
145
+ // browser's scroll-into-view has to clear.
146
+ const panel = blocker.getBoundingClientRect();
147
+ const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
148
+ : panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
149
+ if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
150
+ return partial ? {
151
+ status: 'incomplete',
152
+ message: 'Part of this element is currently underneath an opaque fixed panel. 2.4.12 (AAA) allows no part of a focused component to be hidden by author content, and whether that happens depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely overlapped, so focus can land half-hidden. Tab through the page and check the whole element, indicator included, stays clear of the panel.',
153
+ fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
154
+ } : {
155
+ status: 'incomplete',
156
+ message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
157
+ fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
158
+ };
159
+ });
160
+ },
161
+ };
162
+ }
163
+
164
+ export default createFocusObscuredRule({
35
165
  id: 'focus-not-obscured',
36
166
  name: 'Unobscured focus',
37
- impact: 'serious',
38
167
  tags: ['wcag22aa', 'wcag2411'],
39
168
  help: 'Focused elements must not be fully hidden behind overlays',
40
169
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html',
41
- selector: FOCUSABLE,
42
- visibility: 'visual',
43
- evaluateAll(elements) {
44
- const doc = elements[0]?.ownerDocument ?? document;
45
- const win = doc.defaultView;
46
- // While a modal (cookie-consent dialog etc.) is open, the page behind
47
- // it is INTENTIONALLY covered and focus is supposed to be trapped in
48
- // the modal — a snapshot in that state proves nothing about 2.4.11.
49
- // The backdrop is often a separate sibling of the dialog, so the modal
50
- // state is checked document-wide, not per overlay.
51
- const modal = [...doc.querySelectorAll('dialog[open], [role="dialog"], [role="alertdialog"], [aria-modal="true"]')]
52
- .some((el) => {
53
- const rect = el.getBoundingClientRect();
54
- return rect.width > 0 && rect.height > 0 && getComputedStyle(el).visibility !== 'hidden';
55
- });
56
- if (modal) return elements.map(() => ({ status: 'pass' }));
57
-
58
- // Hit-test per IN-VIEWPORT focusable instead of style-scanning the
59
- // whole document for overlays: elementsFromPoint at the target's
60
- // centre surfaces whatever paints above it, and only the handful of
61
- // on-screen targets pay any cost — a 200k-node spec page costs the
62
- // same as a landing page. Per-overlay verdicts are memoized.
63
- const overlayVerdict = new Map();
64
- const isObscuringOverlay = (layer) => {
65
- if (overlayVerdict.has(layer)) return overlayVerdict.get(layer);
66
- let verdict = false;
67
- const style = getComputedStyle(layer);
68
- if (style.position === 'fixed' || style.position === 'sticky') {
69
- const bg = style.backgroundColor.match(/rgba?\(([^)]+)\)/)?.[1]?.split(',');
70
- const alpha = bg?.[3] === undefined ? 1 : parseFloat(bg[3]);
71
- const rect = layer.getBoundingClientRect();
72
- verdict = alpha >= 0.9 && rect.width >= 40 && rect.height >= 24;
73
- }
74
- overlayVerdict.set(layer, verdict);
75
- return verdict;
76
- };
77
- const covered = (rect, overlay) =>
78
- rect.left >= overlay.left && rect.right <= overlay.right
79
- && rect.top >= overlay.top && rect.bottom <= overlay.bottom;
80
-
81
- return elements.map((element) => {
82
- // `:disabled` catches controls disabled by an ancestor fieldset, which
83
- // the `disabled` property does not reflect — those never take focus.
84
- if (element.matches(':disabled')) return { status: 'pass' };
85
- // Inert content cannot be focused, so nothing can obscure its focus.
86
- if (isInert(element)) return { status: 'pass' };
87
- const rect = element.getBoundingClientRect();
88
- if (!rect.width || !rect.height) return { status: 'pass' };
89
- // Only in-viewport geometry is trustworthy (or cheap).
90
- if (rect.bottom < 0 || rect.right < 0 || rect.top > win.innerHeight || rect.left > win.innerWidth) {
91
- return { status: 'pass' };
92
- }
93
- const x = Math.min(Math.max(rect.left + rect.width / 2, 0), win.innerWidth - 1);
94
- const y = Math.min(Math.max(rect.top + rect.height / 2, 0), win.innerHeight - 1);
95
- const stack = doc.elementsFromPoint(x, y);
96
- const index = stack.indexOf(element);
97
- if (index <= 0) return { status: 'pass' }; // topmost, or not hit-testable
98
- const blocker = stack.slice(0, index).find((layer) =>
99
- !layer.contains(element) && !element.contains(layer)
100
- && isObscuringOverlay(layer) && covered(rect, layer.getBoundingClientRect()));
101
- if (!blocker) return { status: 'pass' };
102
- // Which viewport edge is the panel pinned to? That is the edge the
103
- // browser's scroll-into-view has to clear.
104
- const panel = blocker.getBoundingClientRect();
105
- const edge = panel.top <= 1 && panel.bottom < win.innerHeight ? 'top'
106
- : panel.bottom >= win.innerHeight - 1 ? 'bottom' : null;
107
- if (edge && edgeReserved(doc, edge, panel.height)) return { status: 'pass' };
108
- return {
109
- status: 'incomplete',
110
- message: 'This element is currently underneath an opaque fixed panel. Whether that breaks 2.4.11 depends on where the page sits when focus reaches it — the browser does not scroll an element that is already in the viewport, merely covered, so focus can land invisibly. Tab through the page and check the focus indicator is never entirely hidden.',
111
- fix: `Reserve room for the panel with scroll-padding-${edge ?? 'bottom'} on the scrolling container, or move focus clear of it when the panel is up.`,
112
- };
113
- });
114
- },
115
- };
170
+ partial: false,
171
+ });