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.
- package/config/project.config.js +1 -1
- package/engine/AUDIT.html +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +81 -64
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +18 -3
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/region.js +10 -2
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
- package/engine/rules/wcag/1.3.1-form-label.js +12 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +201 -47
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- 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) || !
|
|
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
|
|
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
|
|
7
|
-
// block that hides or counter-rotates the page root
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// (body { transform: rotate(90deg) }).
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
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
|
|
29
|
-
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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 (
|
|
84
|
+
if (orientationContext && rule.style) {
|
|
49
85
|
const { display, visibility, transform, rotate } = rule.style;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
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
|
|
75
|
-
|
|
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,
|
|
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
|
|
82
|
-
const verb =
|
|
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 (${
|
|
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
|
-
|
|
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: '
|
|
103
|
-
message:
|
|
104
|
-
fix
|
|
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:
|
|
112
|
-
fix
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
202
|
-
|
|
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
|
-
//
|
|
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
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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;
|