pour-engine 1.38.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 (50) 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 +81 -64
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +18 -3
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/region.js +10 -2
  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.3.1-aria-required-children.js +20 -8
  17. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  18. package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
  19. package/engine/rules/wcag/1.3.1-form-label.js +12 -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.4.1-link-in-text-block.js +38 -8
  25. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  26. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  27. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  28. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  29. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  30. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  31. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  32. package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
  33. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  34. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  35. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  36. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  37. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  38. package/engine/rules/wcag/2.5.8-target-size.js +201 -47
  39. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  40. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  41. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  42. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  43. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  44. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  45. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  46. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  47. package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
  48. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  49. package/engine/wcag22.js +7 -7
  50. package/package.json +1 -1
@@ -65,7 +65,7 @@ export default {
65
65
  const headers = cell.getAttribute('headers');
66
66
  return headers && headers.trim().split(/\s+/).filter(Boolean).some((id) => {
67
67
  const target = element.getRootNode().getElementById?.(id);
68
- return !target || !cells.includes(target) || !(/^T[HD]$/.test(target.tagName) || isHeaderCell(target));
68
+ return !target || !cells.includes(target) || !isHeaderCell(target);
69
69
  });
70
70
  });
71
71
  if (badRefs.length) {
@@ -84,13 +84,34 @@ export default {
84
84
  message: 'This table has no header cells and its cells hold block content (headings, paragraphs, lists or forms), which is a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
85
85
  };
86
86
  }
87
+ // A grid whose every filled cell is nothing but a link or a button is a
88
+ // navigation block laid out as a table, not data with labels: asked,
89
+ // not asserted (2026-09-12).
90
+ const filled = cells.filter((cell) => cell.textContent.trim());
91
+ const navigational = filled.every((cell) => {
92
+ const controls = [...cell.querySelectorAll('a, button')];
93
+ return controls.length
94
+ && controls.map((control) => control.textContent.trim()).join(' ').replace(/\s+/g, ' ')
95
+ === cell.textContent.trim().replace(/\s+/g, ' ');
96
+ });
97
+ if (navigational) {
98
+ return {
99
+ status: 'incomplete',
100
+ message: 'This table has no header cells and every cell holds only a link or a button, which is the shape of a navigation block laid out as a table. If it presents data, mark its header cells with <th>; if it is layout, add role="presentation".',
101
+ };
102
+ }
103
+ // Everything that remains is a grid of at least four short text cells
104
+ // with no nested table, no block content and no layout role: a data
105
+ // table by every signal the markup gives, which is the determination
106
+ // F91 asks for before it applies. Asserted, as the shipped engine
107
+ // asserted it; the message says what to do if it is layout after all.
87
108
  // A two-column table is a list of label-and-value pairs; its first
88
109
  // column is the row headers, and that is the whole fix (gov.uk
89
110
  // inheritance tax guidance, 2026-09-01: five such tables, all td).
90
111
  const twoColumns = Math.max(0, ...rows.map((row) => row.cells.length)) === 2;
91
112
  return {
92
113
  status: 'fail',
93
- message: 'This looks like a data table but has no header cells — screen reader users get the data with no way to tell what each row/column means.',
114
+ message: 'This looks like a data table but has no header cells, so screen reader users get the data with no way to tell what each row or column means. If it only arranges content, mark it role="presentation" and dismiss this finding.',
94
115
  fix: twoColumns
95
116
  ? 'Mark the first cell of each row as <th scope="row">: in a two-column table the first column names what the second holds.'
96
117
  : 'Mark header cells with <th> (add scope="col" or scope="row" when the table has both).',
@@ -3,58 +3,108 @@
3
3
  // orientation, such as portrait or landscape, unless a specific display
4
4
  // orientation is essential."
5
5
  //
6
- // What a stylesheet can PROVE: a rule inside an @media (orientation: …)
7
- // block that hides or counter-rotates the page root is restricting the
8
- // whole page to the other orientation — the "please rotate your device"
9
- // wall (body { display: none }) and the forced-landscape hack
10
- // (body { transform: rotate(90deg) }). The rotate hack even disproves the
11
- // "essential" escape by itself: content that renders rotated demonstrably
12
- // works in both orientations.
6
+ // What a stylesheet can show: a rule inside an @media (orientation: …)
7
+ // block that hides or counter-rotates the page root restricts the whole
8
+ // page to the other orientation, the "please rotate your device" wall
9
+ // (body { display: none }) and the forced-landscape hack
10
+ // (body { transform: rotate(90deg) }). Asserted the way ACT rule b33eff
11
+ // asserts a quarter turn between orientations, under its stated assumption
12
+ // that no orientation is essential; the message names that exception. Both
13
+ // orientations count: a rule for the orientation the device is not in right
14
+ // now is still a restriction, and ACT evaluates both.
13
15
  //
14
- // Deliberately NOT flagged: orientation media queries that restyle or hide
15
- // individual regions — that is ordinary responsive design, not a lock.
16
- // Script-driven locks (screen.orientation.lock()) and cross-origin
17
- // stylesheets (unreadable under CORS) are invisible to a static pass; the
18
- // rule asserts only what it can see and stays silent otherwise.
19
- // The root ELEMENT only, optionally qualified by pseudo-classes. A pseudo-
20
- // element (body::before, html::backdrop) is one generated box, and hiding
21
- // it in portrait is the region restyling this rule deliberately leaves
22
- // alone; matching it asserted "hides the page" over a decorative band
23
- // (2026-08-25 overnight audit). Pseudo-classes are kept, but only assert
24
- // when the live root actually matches them (see rootSelector).
16
+ // What the live page adds (2026-09-12): when the block IS active, computed
17
+ // style shows whether the declaration is in force, so an overridden or
18
+ // cancelled declaration is not a failure. When the block is dormant, a
19
+ // later declaration for the same root in the same orientation undoes an
20
+ // earlier one, in cascade order (display: block after display: none,
21
+ // transform: none after a rotation). Unsupported @supports branches and
22
+ // disabled sheets are skipped. Script locks (screen.orientation.lock()) and
23
+ // cross-origin sheets stay invisible to a static pass.
24
+ //
25
+ // Deliberately NOT flagged: orientation queries that restyle or hide
26
+ // individual regions (ordinary responsive design), and a root pseudo-element
27
+ // (body::before is one generated box). Root pseudo-class selectors only
28
+ // count when the live root matches them (body:not(.ready) once .ready is on
29
+ // locks nothing).
30
+ // https://www.w3.org/WAI/WCAG22/Understanding/orientation.html
31
+ // https://www.w3.org/WAI/standards-guidelines/act/rules/b33eff/
25
32
  const ROOT_PART = /^(html|body|:root)((?::[a-z-]+(?:\([^()]*\))?)*)$/i;
26
33
  const LOCK_ROTATE = /rotate(?:3d\([^)]*,\s*)?\(?\s*(?:-?(?:90|270)deg|0\.25turn|-0\.25turn|100grad|-100grad)/i;
27
34
 
28
- function rootSelector(selectorText, doc) {
29
- return (selectorText ?? '').split(',').some((part) => {
35
+ function matchingRoots(selectorText, doc) {
36
+ const roots = new Set();
37
+ for (const part of (selectorText ?? '').split(',')) {
30
38
  const match = ROOT_PART.exec(part.trim());
31
- if (!match) return false;
32
- if (!match[2]) return true; // bare root: always in force
33
- // body:not(.ready) { visibility: hidden } locks nothing once the class
34
- // is on; the selector must match the root as it stands to be a lock.
39
+ if (!match) continue;
35
40
  const root = /^body$/i.test(match[1]) ? doc.body : doc.documentElement;
36
- try { return !!root && root.matches(part.trim()); } catch { return false; }
37
- });
41
+ try { if (root?.matches(part.trim())) roots.add(root); } catch { /* invalid selector */ }
42
+ }
43
+ return [...roots];
44
+ }
45
+
46
+ // CSS transforms and the independent rotate property compose. A 90-degree
47
+ // declaration can be overridden, or cancelled by an opposite rotation.
48
+ function quarterTurn(style, win) {
49
+ let angle = 0;
50
+ if (style.transform && style.transform !== 'none') {
51
+ try {
52
+ const matrix = new win.DOMMatrixReadOnly(style.transform);
53
+ if (!matrix.is2D) return null;
54
+ angle += Math.atan2(matrix.b, matrix.a) * 180 / Math.PI;
55
+ } catch { return null; }
56
+ }
57
+ if (style.rotate && style.rotate !== 'none') {
58
+ const rotation = /^(?:(?:z|0\s+0\s+1)\s+)?(-?[\d.]+)(deg|rad|grad|turn)$/.exec(style.rotate);
59
+ if (!rotation) return null;
60
+ const scale = { deg: 1, rad: 180 / Math.PI, grad: 0.9, turn: 360 }[rotation[2]];
61
+ angle += Number(rotation[1]) * scale;
62
+ }
63
+ return Math.abs(((angle % 180) + 180) % 180 - 90) < 0.01;
38
64
  }
39
65
 
40
- function scanRules(rules, insideOrientation, findings, doc) {
66
+ /** Walk the sheet in cascade order, keeping the LAST relevant declaration
67
+ * per root, orientation and property, so a later declaration in the same
68
+ * orientation block undoes an earlier lock. */
69
+ function scanRules(rules, orientationContext, active, state, doc) {
70
+ const win = doc.defaultView;
41
71
  for (const rule of rules ?? []) {
42
- const condition = rule.conditionText ?? rule.media?.mediaText;
43
- if (condition !== undefined && rule.cssRules) {
72
+ if (rule.type === win.CSSRule.SUPPORTS_RULE) {
73
+ // An unsupported branch cannot apply in either orientation.
74
+ if (win.CSS.supports(rule.conditionText)) scanRules(rule.cssRules, orientationContext, active, state, doc);
75
+ continue;
76
+ }
77
+ if (rule.type === win.CSSRule.MEDIA_RULE) {
78
+ const condition = rule.conditionText ?? rule.media.mediaText;
44
79
  const orientation = /orientation\s*:\s*(portrait|landscape)/i.exec(condition)?.[1];
45
- scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings, doc);
80
+ const context = orientation ? { condition, orientation } : orientationContext;
81
+ scanRules(rule.cssRules, context, active && win.matchMedia(condition).matches, state, doc);
46
82
  continue;
47
83
  }
48
- if (insideOrientation && rule.style && rootSelector(rule.selectorText, doc)) {
84
+ if (orientationContext && rule.style) {
49
85
  const { display, visibility, transform, rotate } = rule.style;
50
- if (display === 'none' || visibility === 'hidden') {
51
- findings.push({ kind: 'hidden', selector: rule.selectorText, ...insideOrientation });
52
- } else if (LOCK_ROTATE.test(transform ?? '') || LOCK_ROTATE.test(rotate ?? '')) {
53
- findings.push({ kind: 'rotated', selector: rule.selectorText, ...insideOrientation });
86
+ const declared = {
87
+ display: display ? (display === 'none' ? 'hidden' : null) : undefined,
88
+ visibility: visibility ? (visibility === 'hidden' ? 'hidden' : null) : undefined,
89
+ transform: transform ? (LOCK_ROTATE.test(transform) ? 'rotated' : null) : undefined,
90
+ rotate: rotate ? (LOCK_ROTATE.test(`rotate(${rotate})`) ? 'rotated' : null) : undefined,
91
+ };
92
+ if (Object.values(declared).some((value) => value !== undefined)) {
93
+ for (const root of matchingRoots(rule.selectorText, doc)) {
94
+ const key = `${orientationContext.orientation.toLowerCase()}|${root === doc.body ? 'body' : 'html'}`;
95
+ if (!state.has(key)) state.set(key, {});
96
+ const entry = state.get(key);
97
+ for (const [property, kind] of Object.entries(declared)) {
98
+ if (kind === undefined) continue;
99
+ entry[property] = kind ? { kind, active, selector: rule.selectorText, ...orientationContext } : null;
100
+ }
101
+ }
54
102
  }
55
- } else if (rule.cssRules) {
56
- scanRules(rule.cssRules, insideOrientation, findings);
57
103
  }
104
+ // Layers and other grouping rules retain the surrounding media state.
105
+ // Unknown conditional groups cannot establish an active restriction.
106
+ if (rule.cssRules) scanRules(rule.cssRules, orientationContext,
107
+ active && rule.conditionText === undefined, state, doc);
58
108
  }
59
109
  }
60
110
 
@@ -66,24 +116,43 @@ export default {
66
116
  help: 'Content must work in both portrait and landscape',
67
117
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
68
118
  selector: 'html',
69
- // The evidence is in the stylesheet, not in the root's own rendering — and
70
- // the lock hides the root exactly when it is in force, so a visibility
71
- // filter here would silence the rule in the only state that matters.
72
- visibleOnly: false,
119
+ visibleOnly: false, // a restriction can hide the root itself
73
120
  evaluate(element) {
74
- const findings = [];
75
- for (const sheet of element.ownerDocument.styleSheets) {
121
+ const doc = element.ownerDocument;
122
+ const win = doc.defaultView;
123
+ const state = new Map();
124
+ for (const sheet of doc.styleSheets) {
125
+ if (sheet.disabled) continue;
126
+ const media = sheet.media?.mediaText;
127
+ if (media && !win.matchMedia(media).matches) continue;
76
128
  let rules;
77
129
  try { rules = sheet.cssRules; } catch { continue; } // cross-origin: unreadable, skip
78
- scanRules(rules, null, findings, element.ownerDocument);
130
+ scanRules(rules, null, true, state, doc);
131
+ }
132
+ const findings = [];
133
+ for (const [key, entry] of state) {
134
+ const root = key.endsWith('|body') ? doc.body : doc.documentElement;
135
+ for (const finding of Object.values(entry)) {
136
+ if (!finding) continue;
137
+ if (finding.active) {
138
+ // The block applies right now, so the computed style is the truth:
139
+ // an overridden or cancelled declaration locks nothing.
140
+ const computed = win.getComputedStyle(root);
141
+ const effective = finding.kind === 'hidden'
142
+ ? computed.display === 'none' || computed.visibility === 'hidden'
143
+ : quarterTurn(computed, win);
144
+ if (effective === false) continue;
145
+ }
146
+ findings.push(finding);
147
+ }
79
148
  }
80
149
  if (!findings.length) return { status: 'pass' };
81
- const f = findings[0];
82
- const verb = f.kind === 'hidden' ? 'hides the page' : 'rotates the page to force the other orientation';
150
+ const finding = findings.find((candidate) => candidate.active) ?? findings[0];
151
+ const verb = finding.kind === 'hidden' ? 'hides the page' : 'rotates the page to force the other orientation';
83
152
  return {
84
153
  status: 'fail',
85
- message: `A stylesheet rule (${f.selector} under @media ${f.condition}) ${verb} when the device is in ${f.orientation} — the content is locked to a single display orientation.`,
86
- fix: 'Let the layout adapt to both orientations instead of hiding or rotating the page. If a single orientation is genuinely essential (rare), document why.',
154
+ message: `A stylesheet rule (${finding.selector} under @media ${finding.condition}) ${verb} when the device is in ${finding.orientation}${finding.active ? ', and it is in effect now' : ''}: the content is locked to a single display orientation. 1.3.4 allows that only where one orientation is essential, which a stylesheet cannot show; this finding assumes it is not.`,
155
+ fix: 'Let the layout adapt to both orientations instead of hiding or rotating the page. If a single orientation is genuinely essential (rare), document why and dismiss this finding.',
87
156
  };
88
157
  },
89
158
  };
@@ -97,19 +97,32 @@ export default {
97
97
  .split(/\s+/)
98
98
  .filter(Boolean);
99
99
  const invalid = tokens.filter((token) => !isKnown(token));
100
- if (invalid.length) {
100
+ const fault = invalid.length ? null : (tokens.length ? grammarFault(tokens) : null);
101
+ if (!invalid.length && !fault) return { status: 'pass' };
102
+ // Asserted the way ACT rule 73f2c2 asserts it, under its assumption that
103
+ // an autocomplete attribute sits on a field collecting information about
104
+ // the user, which is what the attribute is for. A search box is the one
105
+ // common field that carries the attribute and collects nothing about the
106
+ // user, so it is asked about rather than failed. The message names the
107
+ // assumption so a reviewer can dismiss the finding on a field 1.3.5 does
108
+ // not cover. https://www.w3.org/WAI/standards-guidelines/act/rules/73f2c2/
109
+ const problem = invalid.length
110
+ ? `autocomplete contains unknown token(s): ${invalid.join(', ')}, so browsers and assistive tools cannot identify this field's purpose from it`
111
+ : `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed`;
112
+ const fix = invalid.length
113
+ ? 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".'
114
+ : 'Use one field name from the HTML autofill list, optionally preceded by section-*, then shipping or billing, and (for tel, email and impp only) a contact type, e.g. autocomplete="shipping tel" or autocomplete="home email".';
115
+ if (element.type === 'search') {
101
116
  return {
102
- status: 'fail',
103
- message: `autocomplete contains unknown token(s): ${invalid.join(', ')} — browsers and assistive tools can't identify this field's purpose.`,
104
- fix: 'Use tokens from the HTML input-purposes list, e.g. autocomplete="email" or autocomplete="given-name".',
117
+ status: 'incomplete',
118
+ message: `${problem}. 1.3.5 covers fields that collect information about the user, which a search box usually does not; if this one does, correct the value.`,
119
+ fix,
105
120
  };
106
121
  }
107
- const fault = tokens.length ? grammarFault(tokens) : null;
108
- if (!fault) return { status: 'pass' };
109
122
  return {
110
123
  status: 'fail',
111
- message: `autocomplete="${element.getAttribute('autocomplete').trim()}" is not a valid autofill value: ${fault}. Browsers discard the whole value, so no purpose is exposed.`,
112
- fix: 'Use one field name from the HTML autofill list, optionally preceded by section-*, then shipping or billing, and (for tel, email and impp only) a contact type, e.g. autocomplete="shipping tel" or autocomplete="home email".',
124
+ message: `${problem}. 1.3.5 covers fields that collect information about the user, which is what an autocomplete attribute is for; if this field collects nothing about the user, the criterion does not apply and this finding can be dismissed.`,
125
+ fix,
113
126
  };
114
127
  },
115
128
  };
@@ -121,16 +121,23 @@ export default {
121
121
  // embedded in prose exactly as 1.4.1 means it. Pure link lists (navs,
122
122
  // blogrolls) are still excluded, but by the ownText guard below rather than
123
123
  // by tag: in <li><a>Home</a></li> the item has no text of its own.
124
- // <div> was measured too and added nothing on any test site, so it stays
125
- // out rather than widening the blast radius for no gain.
126
- selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href]',
124
+ // Prose in a generic container has the same requirement. F73 does not
125
+ // limit colour-only links to paragraph markup. The text and shared-line
126
+ // checks below keep navigation and separate blocks out of this lane.
127
+ // https://www.w3.org/WAI/WCAG22/Techniques/failures/F73
128
+ selector: 'p a[href], dd a[href], blockquote a[href], td a[href], li a[href], div a[href]',
127
129
  visibility: 'visual', // colour distinction is a purely visual concern
128
130
  evaluate(element, { ownText }) {
129
- const parent = element.closest('p, dd, blockquote, td, li');
131
+ const parent = element.closest('p, dd, blockquote, td, li, div');
130
132
  if (!element.textContent.trim() || !parent) return { status: 'pass' };
131
133
  // Needs real surrounding text to blend into (1.4.1 is about links
132
134
  // embedded in prose, not a link that IS the content).
133
- if (ownText(parent).replace(/\s+/g, '').length < 10) return { status: 'pass' };
135
+ const surroundingText = ownText(parent).trim();
136
+ if (!/[\p{L}\p{N}]/u.test(surroundingText)) return { status: 'pass' };
137
+ // A short fragment may be prose ("See ..."), an equation or a label.
138
+ // F73 has no minimum character count. Keep evaluating the visual cues
139
+ // and ask about ambiguous context instead of silently passing it.
140
+ const shortContext = surroundingText.replace(/\s+/g, '').length < 10;
134
141
 
135
142
  const style = getComputedStyle(element);
136
143
  const parentStyle = getComputedStyle(parent);
@@ -169,7 +176,14 @@ export default {
169
176
  if (sidePaints(s, 'Bottom')) return { status: 'pass' };
170
177
  if (boxShadowPaints(s.boxShadow)) return { status: 'pass' };
171
178
  const ownBackground = parseColor(s.backgroundColor);
172
- if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
179
+ if (ownBackground && ownBackground.a > 0) {
180
+ const surroundings = effectiveBackground(parent);
181
+ if (!surroundings) unclear = true;
182
+ else {
183
+ const shown = composite(ownBackground, surroundings);
184
+ if (['r', 'g', 'b'].some((channel) => Math.round(shown[channel]) !== Math.round(surroundings[channel]))) return { status: 'pass' };
185
+ }
186
+ }
173
187
  const painted = backgroundImagePaints(s, el.getBoundingClientRect()); // gradient/image underline technique
174
188
  if (painted === 'yes') return { status: 'pass' };
175
189
  if (painted === 'unclear') unclear = true;
@@ -198,8 +212,17 @@ export default {
198
212
  const sizeStep = Math.max(...textBearers.map((s) => Math.abs((parseFloat(s.fontSize) || parentSize) - parentSize)));
199
213
  const sizeCue = sizeStep >= 2 && sizeStep / parentSize >= 0.1;
200
214
 
201
- let linkColor = parseColor(style.color);
202
- let textColor = parseColor(parentStyle.color);
215
+ // A wrapper's colour need not paint any glyphs: inline spans commonly
216
+ // override it. Compare actual own-text bearers, and review multicolour
217
+ // text rather than attributing an unpainted wrapper colour to it.
218
+ const glyphColours = cueBearers.filter(({ el }) => ownText(el))
219
+ .map(({ s }) => s.webkitTextFillColor || s.color);
220
+ const distinctColours = [...new Set(glyphColours)];
221
+ if (distinctColours.length > 1) {
222
+ return { status: 'incomplete', message: 'This link presents text in several colours without a detected non-colour cue. Check that each part is distinguishable from the surrounding text.' };
223
+ }
224
+ let linkColor = parseColor(distinctColours[0] || style.webkitTextFillColor || style.color);
225
+ let textColor = parseColor(parentStyle.webkitTextFillColor || parentStyle.color);
203
226
  if (!linkColor || !textColor) return { status: 'pass' };
204
227
  // 2026-08-25 overnight audit: a translucent colour is presented
205
228
  // composited over its backdrop (rgba(0,0,0,.3) in black prose on white
@@ -330,6 +353,13 @@ export default {
330
353
  fix: reviewFix,
331
354
  };
332
355
  }
356
+ if (shortContext) {
357
+ return {
358
+ status: 'incomplete',
359
+ message: `This link has no detected non-colour cue and only ${shown}:1 colour difference from nearby text. The surrounding text is a short fragment. Check whether it forms a sentence or other prose with the link; if it does, colour alone does not distinguish the link sufficiently.`,
360
+ fix: reviewFix,
361
+ };
362
+ }
333
363
  return {
334
364
  status: 'fail',
335
365
  // The measured fact is the ratio against G183's threshold, and that is
@@ -1,8 +1,8 @@
1
1
  // WCAG SC 1.4.10 Reflow (Level AA)
2
2
  // The full test renders at 320 CSS px; an audit runs at whatever viewport
3
3
  // the page is in. What IS knowable here: content that already forces
4
- // two-dimensional scrolling at the current width will only be worse at
5
- // 320px. Abstain-over-assert: this flags, it never fails — tables, maps,
4
+ // horizontal scrolling at the current width is a candidate to check at
5
+ // 320px; responsive breakpoints can change the layout. Abstain-over-assert: this flags, it never fails — tables, maps,
6
6
  // and images are exempt content the geometry can't distinguish.
7
7
  export default {
8
8
  id: 'reflow',
@@ -20,7 +20,7 @@ export default {
20
20
  if (overflow <= 1) return { status: 'pass' };
21
21
  return {
22
22
  status: 'incomplete',
23
- message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport — at the 320px reflow breakpoint it will be worse. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
23
+ message: `The page already scrolls horizontally by ${Math.round(overflow)}px at the current viewport. Verify content reflows at 320 CSS px width (data tables, maps and images are exempt).`,
24
24
  };
25
25
  },
26
26
  };
@@ -114,7 +114,15 @@ export default {
114
114
  const wrapperHasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((side) =>
115
115
  parseFloat(wrapperStyle[`border${side}Width`]) > 0 && wrapperStyle[`border${side}Style`] !== 'none'
116
116
  && (parseColor(wrapperStyle[`border${side}Color`])?.a ?? 0) > 0);
117
- if (wrapperHasBorder) return { status: 'pass' };
117
+ if (wrapperHasBorder) {
118
+ const around = effectiveBackground(wrapper.parentElement ?? wrapper);
119
+ const contrastingBorder = around && ['Top', 'Right', 'Bottom', 'Left'].some((side) => {
120
+ if (!(parseFloat(wrapperStyle[`border${side}Width`]) > 0) || wrapperStyle[`border${side}Style`] === 'none') return false;
121
+ const colour = parseColor(wrapperStyle[`border${side}Color`]);
122
+ return colour && contrastRatio(composite(colour, around), around) >= 3;
123
+ });
124
+ if (contrastingBorder) return { status: 'pass' };
125
+ }
118
126
  const wrapperFill = parseColor(wrapperStyle.backgroundColor);
119
127
  if (wrapperFill && wrapperFill.a >= 1) {
120
128
  const around = effectiveBackground(wrapper.parentElement ?? wrapper);
@@ -44,7 +44,11 @@ export default {
44
44
  // ruinous on 200k-node single-page specs, whose plain flowing text is
45
45
  // also the least likely content on the web to clip. Past a scale where
46
46
  // the probe's cost dwarfs its yield, abstain wholesale.
47
- if (elements.length > 20_000) return elements.map(() => ({ status: 'pass' }));
47
+ if (elements.length > 20_000) {
48
+ return elements.map((_, index) => index === 0
49
+ ? { status: 'incomplete', message: `The text-spacing probe was not run because this page has ${elements.length} candidate elements, exceeding its 20,000-element budget. Check the page with the WCAG spacing overrides applied.` }
50
+ : { status: 'skipped' });
51
+ }
48
52
  // Only containers that hide overflow and hold text can lose content.
49
53
  // Collapsed containers (accordion panels at height 0) show nothing —
50
54
  // there is no visible text for the override to clip.
@@ -64,13 +68,22 @@ export default {
64
68
  probe.dataset.pourAudit = 'probe';
65
69
  probe.textContent = override(doc.documentElement.scrollHeight);
66
70
  doc.documentElement.append(probe);
71
+ // Document selectors cannot cross a shadow boundary. Each candidate
72
+ // root receives the same override so the measured text is really probed.
73
+ const probes = [probe];
74
+ for (const root of new Set(candidates.filter(Boolean).map((element) => element.getRootNode()))) {
75
+ if (root === doc || !root.host) continue;
76
+ const scoped = probe.cloneNode(true);
77
+ root.append(scoped);
78
+ probes.push(scoped);
79
+ }
67
80
  let after;
68
81
  try {
69
82
  // Force the reflow the measurements depend on.
70
83
  void doc.documentElement.offsetHeight;
71
84
  after = candidates.map((element) => element && clipped(element));
72
85
  } finally {
73
- probe.remove();
86
+ for (const applied of probes) applied.remove();
74
87
  // The audit must never move the page: put the scroll back exactly
75
88
  // where the user had it, after the removal's own reflow settles.
76
89
  void doc.documentElement.offsetHeight;