pour-engine 1.40.0 → 1.41.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 (46) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  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/wcag/1.1.1-area-alt.js +6 -1
  12. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  13. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  14. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  15. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  16. package/engine/rules/wcag/1.3.1-form-label.js +5 -29
  17. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  18. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  19. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  20. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  21. package/engine/rules/wcag/1.4.1-link-in-text-block.js +19 -3
  22. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  23. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  24. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  25. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  26. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  27. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  28. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  29. package/engine/rules/wcag/2.2.1-meta-refresh.js +8 -2
  30. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  31. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  32. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  33. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  34. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  35. package/engine/rules/wcag/2.5.8-target-size.js +161 -45
  36. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  37. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  38. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  39. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  40. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  41. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  42. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  43. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  44. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  45. package/engine/wcag22.js +7 -7
  46. 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
  };
@@ -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
  }
@@ -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
  }
@@ -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
  },
@@ -3,18 +3,17 @@
3
3
  // merely PRESENT satisfies the markup, not the criterion, so the two
4
4
  // well-known undescriptive shapes are checked as well.
5
5
  //
6
- // Tier one, failed: strings that are a stand-in under every reading.
7
- // "Untitled Document" is what an editor writes when the author wrote
8
- // nothing, and no page is ever about that.
6
+ // Familiar template strings are review candidates, not proof of a missing
7
+ // topic: a document can genuinely describe the HTML document title or an
8
+ // artwork named Untitled 1.
9
9
  // Tier two, sent to a human: single generic words and framework scaffold
10
10
  // defaults. Almost always a leftover, but a gallery page really can be about
11
11
  // an artwork called "Untitled", and "Example Domain" really is the subject
12
12
  // of example.com. Those are somebody's call to make, not the engine's.
13
13
  // "New document", "New page" and "New tab" sit in tier two as well: a
14
14
  // document editor's creation screen, a wiki's new-page form or a
15
- // browser-style app's new-tab page is about exactly that, so only the
16
- // numbered editor default ("New Page 1", F25's example) is a certain
17
- // placeholder (2026-08-25 overnight audit).
15
+ // browser-style app's new-tab page is about exactly that. Even numbered
16
+ // editor-style titles remain a question about the actual subject.
18
17
  //
19
18
  // Deliberately NOT judged: whether a real-looking title matches the page it
20
19
  // sits on. That needs someone who can read the page, and guessing at it
@@ -63,8 +62,8 @@ export default {
63
62
  const normalized = title.replace(/\s+/g, ' ').toLowerCase();
64
63
  if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
65
64
  return {
66
- status: 'fail',
67
- message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
65
+ status: 'incomplete',
66
+ message: `“${title}” resembles a template title. Check whether it describes this page's actual topic or purpose; the same words can also be a legitimate subject or name.`,
68
67
  fix: 'Put this page\'s own subject in the title first, then the site name.',
69
68
  };
70
69
  }
@@ -13,11 +13,9 @@
13
13
  // it sits in. A "Read more" under a headline naming the
14
14
  // article is a judgment call about whether that context really
15
15
  // reaches the user, so this REVIEWS. It never asserts.
16
- // 2.4.9 (AAA) purpose must come from the link text ALONE, with the only
17
- // exception being links whose purpose "would be ambiguous to
18
- // users in general". A link named nothing but "Read more" is
19
- // ambiguous to everyone, which is the exception failing to
20
- // apply rather than saving it, so this FAILS.
16
+ // 2.4.9 (AAA) requires a mechanism for purpose to be available from link
17
+ // text alone. A page may let users expand its link names,
18
+ // so a generic name in this snapshot still needs review.
21
19
  //
22
20
  // Judged on the ACCESSIBLE NAME, not the text content: a link written as
23
21
  // <a href="…" aria-label="Read more about the budget">Read more</a> is
@@ -31,6 +29,8 @@
31
29
  // normalizeName below): apostrophes become spaces, so "plus d informations"
32
30
  // is how "plus d'informations" — straight or typographic — must be listed.
33
31
 
32
+ import { effectiveRole, implicitRole } from '../../lib/roles.js';
33
+
34
34
  const GENERIC = new Set([
35
35
  // English
36
36
  'read more', 'read more about this', 'more', 'much more', 'learn more',
@@ -83,6 +83,7 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
83
83
  // link-name's finding, not this one.
84
84
  selector: 'a[href], [role="link"]',
85
85
  evaluate(element, { accessibleName }) {
86
+ if ((effectiveRole(element) ?? implicitRole(element)) !== 'link') return { status: 'pass' };
86
87
  const name = accessibleName(element);
87
88
  if (!name) return { status: 'pass' }; // nameless: link-name reports it
88
89
  const normalized = normalizeName(name);
@@ -4,28 +4,13 @@
4
4
  // threshold: the link text ALONE has to identify the purpose, and context
5
5
  // is not allowed to make up the difference.
6
6
  //
7
- // This one asserts where the AA rule reviews. The criterion's only escape is
8
- // links "where the purpose of the link would be ambiguous to users in
9
- // general", and a link named nothing but "Read more" is ambiguous to
10
- // everyone — which is the exception failing to apply rather than rescuing
11
- // it. Nothing about the surrounding page can change that reading, so there
12
- // is nothing here for a human to settle.
7
+ // The criterion allows a mechanism that expands link names. F84 requires
8
+ // checking that no such mechanism exists, which a name-only snapshot cannot
9
+ // prove. Semantic ambiguity and alternative presentation remain review.
13
10
  //
14
- // Two matches stay in the review lane because the failure is not provable
15
- // (2026-08-25 corpus adjudication):
16
- // · a name wrapped in a paired bracket or quote — "<details>" is a code
17
- // term that names its destination exactly (one-sided decoration like
18
- // python.org's CSS-generated ">>>More" prompt still asserts);
19
- // · a name that is exactly "go" — every corpus hit was a brand logo
20
- // ("Go" is the language's name), and a brand name identifies the link.
21
- //
22
- // A third, beside "go" (2026-08-25 overnight audit): the French entries
23
- // "plus" and "suite" are ordinary English words and, more to the point,
24
- // common English plan and product names (a "Plus" tier, a "Suite"). A
25
- // pricing link named "Plus" identifies its destination the way the Go logo
26
- // does, so outside a page declared French they review rather than assert.
27
- // On a link whose closest lang is French the generic reading ("more",
28
- // "continued") is the only one and the assertion stands.
11
+ // The review wording distinguishes recognizable proper-name ambiguities
12
+ // (Go, Plus, Suite and quoted code terms) from ordinary generic phrases.
13
+ // Neither branch treats a phrase-list match as semantic proof.
29
14
  import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
30
15
 
31
16
  const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
@@ -50,8 +35,8 @@ export default createLinkPurposeRule({
50
35
  fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
51
36
  };
52
37
  return {
53
- status: 'fail',
54
- message: `“${name}” gives no purpose on its own. 2.4.9 asks that the link text alone identify where a link goes, without relying on the sentence or heading around it, so anyone reading the page's links as a list can tell them apart.`,
38
+ status: 'incomplete',
39
+ message: `“${name}” appears generic when read alone. Check whether it identifies the destination in this context or whether a mechanism makes the link text descriptive. 2.4.9 permits such a mechanism; the initial link name alone cannot establish a failure.`,
55
40
  fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
56
41
  };
57
42
  },