pour-engine 1.35.2 → 1.35.4
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/lib/dom.js +13 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -1
- package/engine/rules/wcag/1.4.3-color-contrast.js +41 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +2 -1
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +2 -1
- package/engine/rules/wcag/4.1.2-valid-role.js +2 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/dom.js
CHANGED
|
@@ -96,13 +96,25 @@ export function cssPath(element) {
|
|
|
96
96
|
return path;
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/** An element's attributes read through the prototype, not the instance:
|
|
100
|
+
* a form whose control is named "attributes" shadows the property with
|
|
101
|
+
* that control (byggmax.se, 2026-08-30), and the same goes for any named
|
|
102
|
+
* form control. Anything that is not a NamedNodeMap is looked up on the
|
|
103
|
+
* prototype the element was born with. */
|
|
104
|
+
const attributesGetter = typeof Element !== 'undefined' ? Object.getOwnPropertyDescriptor(Element.prototype, 'attributes')?.get : null;
|
|
105
|
+
export function attributesOf(element) {
|
|
106
|
+
const own = element.attributes;
|
|
107
|
+
if (own && typeof own.length === 'number' && typeof own.item === 'function') return own;
|
|
108
|
+
return attributesGetter ? attributesGetter.call(element) : [];
|
|
109
|
+
}
|
|
110
|
+
|
|
99
111
|
/** Element's opening markup (attributes) plus a snippet of its own text —
|
|
100
112
|
* enough to recognise it in a table. Built WITHOUT element.outerHTML, which
|
|
101
113
|
* serializes the whole subtree and turns audits quadratic on huge pages
|
|
102
114
|
* (container divs would each stringify most of the document). */
|
|
103
115
|
export function htmlSnippet(element, maxLength = 300) {
|
|
104
116
|
let html = `<${element.tagName.toLowerCase()}`;
|
|
105
|
-
for (const { name, value } of element
|
|
117
|
+
for (const { name, value } of attributesOf(element)) {
|
|
106
118
|
if (html.length >= maxLength) break;
|
|
107
119
|
html += ` ${name}="${value}"`;
|
|
108
120
|
}
|
|
@@ -4,7 +4,14 @@
|
|
|
4
4
|
// The override is injected and removed inside one synchronous pass — the
|
|
5
5
|
// engine only yields to the renderer between rules, so the page never
|
|
6
6
|
// paints the probed state.
|
|
7
|
-
|
|
7
|
+
// The probe reflows the whole page for a moment. Two guards keep that
|
|
8
|
+
// invisible to the user: scroll anchoring is disabled while the override
|
|
9
|
+
// is in (or Chrome re-anchors during the inflated layout and leaves the
|
|
10
|
+
// page scrolled somewhere else when the probe lifts — measured at 884px
|
|
11
|
+
// of drift on a long report page), and the window scroll is restored
|
|
12
|
+
// after removal in case the browser clamped it anyway.
|
|
13
|
+
const OVERRIDE = `html { overflow-anchor: none !important; }
|
|
14
|
+
* {
|
|
8
15
|
line-height: 1.5 !important;
|
|
9
16
|
letter-spacing: 0.12em !important;
|
|
10
17
|
word-spacing: 0.16em !important;
|
|
@@ -44,6 +51,9 @@ export default {
|
|
|
44
51
|
if (!candidates.some(Boolean)) return elements.map(() => ({ status: 'pass' }));
|
|
45
52
|
|
|
46
53
|
const before = candidates.map((element) => element && clipped(element));
|
|
54
|
+
const win = doc.defaultView;
|
|
55
|
+
const scrollX = win?.scrollX ?? 0;
|
|
56
|
+
const scrollY = win?.scrollY ?? 0;
|
|
47
57
|
const probe = doc.createElement('style');
|
|
48
58
|
probe.dataset.pourAudit = 'probe';
|
|
49
59
|
probe.textContent = OVERRIDE;
|
|
@@ -55,6 +65,10 @@ export default {
|
|
|
55
65
|
after = candidates.map((element) => element && clipped(element));
|
|
56
66
|
} finally {
|
|
57
67
|
probe.remove();
|
|
68
|
+
// The audit must never move the page: put the scroll back exactly
|
|
69
|
+
// where the user had it, after the removal's own reflow settles.
|
|
70
|
+
void doc.documentElement.offsetHeight;
|
|
71
|
+
if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo(scrollX, scrollY);
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
return elements.map((element, i) => {
|
|
@@ -1014,6 +1014,47 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
1014
1014
|
message: 'This text overlaps a coloured block that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
|
|
1015
1015
|
};
|
|
1016
1016
|
}
|
|
1017
|
+
// The panel scan keeps to positioned and floated paint because only
|
|
1018
|
+
// those cross sibling boundaries. The TEXT can be the one crossing:
|
|
1019
|
+
// an absolutely positioned floating label sits on the static <input>
|
|
1020
|
+
// beside it, and the walk, which never leaves the label's ancestors,
|
|
1021
|
+
// read the grey section behind the form (worldbank.org sign-up,
|
|
1022
|
+
// 2026-08-27, asserted 4.44:1 against the wrapper while the glyphs
|
|
1023
|
+
// sit on the input's white, 4.67:1; the same label passed once
|
|
1024
|
+
// scrolled into view and hit-tested). When the text or an ancestor
|
|
1025
|
+
// up to its containing block is out of flow, the static opaque
|
|
1026
|
+
// boxes inside that containing block are candidates too. Bounded by
|
|
1027
|
+
// the containing block's subtree; a huge one keeps the walk-only
|
|
1028
|
+
// behaviour rather than paying a full-page style pass per element.
|
|
1029
|
+
if (!blindOnly) {
|
|
1030
|
+
let outOfFlow = false;
|
|
1031
|
+
let block = null;
|
|
1032
|
+
for (let node = element; node && node.nodeType === 1 && node !== doc.body; node = node.parentElement ?? node.getRootNode()?.host) {
|
|
1033
|
+
const position = getComputedStyle(node).position;
|
|
1034
|
+
if (node !== element && position !== 'static') { block = node; break; }
|
|
1035
|
+
if (position === 'absolute' || position === 'fixed') outOfFlow = true;
|
|
1036
|
+
}
|
|
1037
|
+
if (outOfFlow && block) {
|
|
1038
|
+
const inside = block.querySelectorAll('*');
|
|
1039
|
+
if (inside.length <= 2000) {
|
|
1040
|
+
for (const sibling of inside) {
|
|
1041
|
+
if (sibling === element || sibling.contains(element) || element.contains(sibling)) continue;
|
|
1042
|
+
const siblingStyle = getComputedStyle(sibling);
|
|
1043
|
+
if (siblingStyle.position !== 'static' || siblingStyle.visibility === 'hidden') continue;
|
|
1044
|
+
const paint = parseColor(siblingStyle.backgroundColor);
|
|
1045
|
+
if (!paint || paint.a < 1) continue;
|
|
1046
|
+
const siblingRect = sibling.getBoundingClientRect();
|
|
1047
|
+
if (siblingRect.width < 24 || siblingRect.height < 12 || !near(siblingRect) || !textIntersects(element, siblingRect)) continue;
|
|
1048
|
+
if ((contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
|
|
1049
|
+
return {
|
|
1050
|
+
status: 'incomplete',
|
|
1051
|
+
message: 'This text is positioned over a coloured box that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1017
1058
|
return null;
|
|
1018
1059
|
};
|
|
1019
1060
|
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// and was narrowed in 1.2 (narrowed again in the 1.3 draft). Markup written
|
|
32
32
|
// against 1.1 is not conforming under 1.2, and a tool on the older baseline
|
|
33
33
|
// stays silent on it. Neither reading is a bug; they are different editions.
|
|
34
|
+
import { attributesOf } from '../../lib/dom.js';
|
|
34
35
|
import { GLOBAL_ARIA, ROLE_ARIA, KNOWN_ARIA, effectiveRole } from '../../lib/roles.js';
|
|
35
36
|
|
|
36
37
|
// aria-label/labelledby misuse on generic elements has its own rule
|
|
@@ -50,7 +51,7 @@ export default {
|
|
|
50
51
|
// Plain loop, no intermediate arrays: this runs on every element of the
|
|
51
52
|
// page, and nearly all have no aria-* attributes at all.
|
|
52
53
|
const ariaAttrs = [];
|
|
53
|
-
for (const { name } of element
|
|
54
|
+
for (const { name } of attributesOf(element)) {
|
|
54
55
|
if (!name.startsWith('aria-')) continue;
|
|
55
56
|
const attr = name.slice(5);
|
|
56
57
|
// Unknown/misspelled attributes are aria-attr-valid's finding — judging
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
2
|
// ARIA 1.2 attribute names (shared vocabulary in lib/roles.js), and the
|
|
3
3
|
// allowed values for enumerated ones.
|
|
4
|
+
import { attributesOf } from '../../lib/dom.js';
|
|
4
5
|
import { KNOWN_ARIA as KNOWN, GLOBAL_ARIA, ROLE_ARIA, effectiveRole } from '../../lib/roles.js';
|
|
5
6
|
|
|
6
7
|
// Note: "undefined" is a legal token for the tristate/undefinable attributes
|
|
@@ -64,7 +65,7 @@ export default {
|
|
|
64
65
|
const invalid = [];
|
|
65
66
|
const empty = [];
|
|
66
67
|
let role;
|
|
67
|
-
for (const { name, value } of element
|
|
68
|
+
for (const { name, value } of attributesOf(element)) {
|
|
68
69
|
if (!name.startsWith('aria-')) continue;
|
|
69
70
|
const attr = name.slice(5);
|
|
70
71
|
if (!KNOWN.has(attr)) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// WCAG SC 4.1.2 Name, Role, Value (Level A)
|
|
2
|
+
import { attributesOf } from '../../lib/dom.js';
|
|
2
3
|
// Concrete, usable roles — abstract roles are invalid in markup.
|
|
3
4
|
const VALID_ROLES = new Set([
|
|
4
5
|
'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
|
|
@@ -95,7 +96,7 @@ export default {
|
|
|
95
96
|
const genericFallback = tag === 'div' || tag === 'span' || tag === 'svg';
|
|
96
97
|
const focusable = element.tabIndex >= 0
|
|
97
98
|
|| element.matches('a[href], button, input, select, textarea, summary');
|
|
98
|
-
const hasAriaProps = [...element
|
|
99
|
+
const hasAriaProps = [...attributesOf(element)].some(
|
|
99
100
|
(attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
|
|
100
101
|
if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
|
|
101
102
|
// The same two triggers decide for EVERY tag (2026-08-25 overnight
|
package/package.json
CHANGED