pour-engine 1.31.3 → 1.33.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/rules/wcag/1.3.1-definition-list.js +23 -7
- package/engine/rules/wcag/1.3.1-table-headers.js +5 -3
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +35 -1
- package/engine/rules/wcag/2.5.8-target-size.js +19 -4
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +1 -1
- package/engine/rules/wcag/4.1.2-aria-label-misuse.js +1 -1
- package/package.json +1 -1
package/config/project.config.js
CHANGED
|
@@ -11,12 +11,28 @@ export default {
|
|
|
11
11
|
selector: 'dl:not([role])', // a role attribute replaces the dl semantics
|
|
12
12
|
evaluate(element) {
|
|
13
13
|
const invalid = [...element.children].filter((child) => !ALLOWED.has(child.tagName));
|
|
14
|
-
if (
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
14
|
+
if (invalid.length) {
|
|
15
|
+
const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
|
|
16
|
+
return {
|
|
17
|
+
status: 'fail',
|
|
18
|
+
message: `This <dl> contains ${tags} directly — only <dt>, <dd> (optionally grouped in <div>) are allowed, otherwise the term/description pairing breaks.`,
|
|
19
|
+
fix: 'Restructure the list into <dt>/<dd> pairs, or use a different element.',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
// A <div> child is only a valid wrapper when it holds the dt/dd group
|
|
23
|
+
// ITSELF (HTML: each div contains one or more dt followed by one or
|
|
24
|
+
// more dd). Pairs buried another level down (dl > div > div > dt) fall
|
|
25
|
+
// out of the dl's content model, so the term/description association is
|
|
26
|
+
// no longer guaranteed to assistive technology.
|
|
27
|
+
const emptyWrappers = [...element.children].filter((child) =>
|
|
28
|
+
child.tagName === 'DIV' && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
|
|
29
|
+
if (emptyWrappers.length) {
|
|
30
|
+
return {
|
|
31
|
+
status: 'fail',
|
|
32
|
+
message: `${emptyWrappers.length} <div> wrapper(s) in this <dl> hold no <dt>/<dd> directly — the term/description pairing breaks when the pairs sit deeper than the wrapper.`,
|
|
33
|
+
fix: 'Make each <div> child of the <dl> contain its <dt>/<dd> pair directly, or flatten the pairs into the <dl> itself.',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { status: 'pass' };
|
|
21
37
|
},
|
|
22
38
|
};
|
|
@@ -26,11 +26,13 @@ export default {
|
|
|
26
26
|
if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
|
|
27
27
|
// Nested tables are the signature of legacy LAYOUT markup, not data —
|
|
28
28
|
// asserting "missing headers" there flags email-style scaffolding. A
|
|
29
|
-
// human can tell in a glance; the machine can't.
|
|
30
|
-
|
|
29
|
+
// human can tell in a glance; the machine can't. The signature reads in
|
|
30
|
+
// both directions: a table INSIDE another table (Hacker News's story
|
|
31
|
+
// list inside its page-scaffold table) is the same legacy layout.
|
|
32
|
+
if (!element.querySelector('th') && (element.querySelector('table') || element.parentElement?.closest('table'))) {
|
|
31
33
|
return {
|
|
32
34
|
status: 'incomplete',
|
|
33
|
-
message: 'This table has no header cells
|
|
35
|
+
message: 'This table has no header cells and is part of a nested-table structure — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
|
|
34
36
|
};
|
|
35
37
|
}
|
|
36
38
|
|
|
@@ -51,11 +51,38 @@ export default {
|
|
|
51
51
|
// A clear weight difference is a non-colour distinction too.
|
|
52
52
|
if (Math.abs(weight(s) - weight(parentStyle)) >= 300) return { status: 'pass' };
|
|
53
53
|
}
|
|
54
|
+
// A weight step of 200–299 (600-semibold links in 400 prose, the common
|
|
55
|
+
// case) is a REAL non-colour cue, just not a clear one: whether it reads
|
|
56
|
+
// as "bolded" (F73's word) depends on the face and the size, which is a
|
|
57
|
+
// judgment by eye, not arithmetic. Adjudicated on the 2026-08-16 corpus
|
|
58
|
+
// run (PostgreSQL ×32, visibly semibold): asserting fail overclaims,
|
|
59
|
+
// passing waves through faces where the step is invisible — so it goes
|
|
60
|
+
// to review. Below 200 the step is imperceptible at body sizes and earns
|
|
61
|
+
// nothing.
|
|
62
|
+
const weightStep = Math.max(...cueBearers.map((s) => Math.abs(weight(s) - weight(parentStyle))));
|
|
54
63
|
|
|
55
64
|
const linkColor = parseColor(style.color);
|
|
56
65
|
const textColor = parseColor(parentStyle.color);
|
|
57
66
|
if (!linkColor || !textColor) return { status: 'pass' };
|
|
58
67
|
|
|
68
|
+
// A link styled to look NO different from the surrounding prose is not
|
|
69
|
+
// a 1.4.1 failure — Understanding 1.4.1, in its own words: "a hyperlink
|
|
70
|
+
// which has been styled to appear no different than neighboring static
|
|
71
|
+
// text would not fail this success criterion, as there would be no
|
|
72
|
+
// color differentiation between the actionable hyperlink text and the
|
|
73
|
+
// adjacent static text." Colour conveys nothing when there is no colour
|
|
74
|
+
// difference. A terrible link, but no WCAG criterion requires links to
|
|
75
|
+
// look like links, and asserting one invents spec text (measured live:
|
|
76
|
+
// hsbc.co.uk's footer, rgb(215,216,214) on both link and prose).
|
|
77
|
+
// Channel equality, NOT the luminance ratio: a hue-only difference at
|
|
78
|
+
// equal luminance also lands at 1.00:1, and that is the clearest F73
|
|
79
|
+
// failure there is — the two cases are opposites and the ratio cannot
|
|
80
|
+
// tell them apart.
|
|
81
|
+
if (linkColor.r === textColor.r && linkColor.g === textColor.g
|
|
82
|
+
&& linkColor.b === textColor.b && (linkColor.a ?? 1) === (textColor.a ?? 1)) {
|
|
83
|
+
return { status: 'pass' };
|
|
84
|
+
}
|
|
85
|
+
|
|
59
86
|
const ratio = contrastRatio(linkColor, textColor);
|
|
60
87
|
// WCAG technique G183: 3:1 against the surrounding text is a sufficient
|
|
61
88
|
// colour difference for identifying a link.
|
|
@@ -89,6 +116,13 @@ export default {
|
|
|
89
116
|
}
|
|
90
117
|
if (!sharesLine) return { status: 'pass' };
|
|
91
118
|
}
|
|
119
|
+
if (weightStep >= 200) {
|
|
120
|
+
return {
|
|
121
|
+
status: 'incomplete',
|
|
122
|
+
message: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, but its font weight differs from the prose by ${weightStep}: a visible non-colour cue that falls short of a clear bold step. Judge by eye whether the weight alone identifies it as a link in this face and size; if it does not, this fails SC 1.4.1.`,
|
|
123
|
+
fix: 'Underline links inside text (text-decoration: underline), raise the weight difference to a clear bold step, or add another non-colour indicator.',
|
|
124
|
+
};
|
|
125
|
+
}
|
|
92
126
|
return {
|
|
93
127
|
status: 'fail',
|
|
94
128
|
// The measured fact is the ratio against G183's threshold, and that is
|
|
@@ -97,7 +131,7 @@ export default {
|
|
|
97
131
|
// staying perfectly distinct to most people, including most red-green
|
|
98
132
|
// colour blindness, where the red darkens and the gap widens.
|
|
99
133
|
message: ratio < 1.01
|
|
100
|
-
? 'This link has no underline and
|
|
134
|
+
? 'This link has no underline and its colour differs from the surrounding text in hue alone, with no luminance difference: the distinction disappears entirely without colour vision, the exact failure F73 describes.'
|
|
101
135
|
: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
|
|
102
136
|
fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
|
|
103
137
|
};
|
|
@@ -98,7 +98,22 @@ function reachableRects(element, rect) {
|
|
|
98
98
|
if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
|
|
99
99
|
const stack = doc.elementsFromPoint(x, y);
|
|
100
100
|
const index = stack.indexOf(element);
|
|
101
|
-
if (index < 0)
|
|
101
|
+
if (index < 0) {
|
|
102
|
+
// The probe ran and the stack came back without the element.
|
|
103
|
+
// elementsFromPoint reports covered elements too, so for a light-DOM
|
|
104
|
+
// element this is not don't-know but a definitive no: nothing at this
|
|
105
|
+
// point accepts a pointer for it. The sr-only WRAPPER pattern makes
|
|
106
|
+
// exactly this shape — the clipped 1×1 box is the wrapper, the skip
|
|
107
|
+
// link inside keeps its full-size rect, and every self-applied hiding
|
|
108
|
+
// check (own rect, own opacity, own clip) passes; that phantom rect
|
|
109
|
+
// crowded frame.work's 160×21 header wordmark out of the spacing
|
|
110
|
+
// exception its layout plainly earns. Shadow-tree elements are
|
|
111
|
+
// invisible to the document's hit-test by design (the stack shows
|
|
112
|
+
// their host), so absence proves nothing there and the fragment keeps
|
|
113
|
+
// the benefit of the doubt.
|
|
114
|
+
if (element.getRootNode() === doc) testable = true;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
102
117
|
testable = true;
|
|
103
118
|
// Anything above it that is part of the same control is not an
|
|
104
119
|
// obstruction: a button's own icon paints over the button, an overlay
|
|
@@ -107,9 +122,9 @@ function reachableRects(element, rect) {
|
|
|
107
122
|
reachable.push(fragment);
|
|
108
123
|
}
|
|
109
124
|
}
|
|
110
|
-
// Nothing could be probed at all (entirely out of viewport,
|
|
111
|
-
//
|
|
112
|
-
// unanswerable, and don't-know must not silence a finding — but the
|
|
125
|
+
// Nothing could be probed at all (entirely out of viewport, or a
|
|
126
|
+
// shadow-tree element the document's hit-test cannot see): the OCCLUSION
|
|
127
|
+
// question is unanswerable, and don't-know must not silence a finding — but the
|
|
113
128
|
// fragment GEOMETRY is measurable regardless, so the fragments stand,
|
|
114
129
|
// never the bounding box. A wrapped link's box is mostly leading and
|
|
115
130
|
// line-gap no pointer can activate; counting that empty space as
|
|
@@ -68,7 +68,7 @@ export default {
|
|
|
68
68
|
const names = disallowed.map((n) => `aria-${n}`);
|
|
69
69
|
return {
|
|
70
70
|
status: 'incomplete',
|
|
71
|
-
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected.`,
|
|
71
|
+
message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected. Invalid markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is whether the state reaches users, not the syntax.`,
|
|
72
72
|
fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
|
|
73
73
|
};
|
|
74
74
|
},
|
|
@@ -72,7 +72,7 @@ export default {
|
|
|
72
72
|
})();
|
|
73
73
|
return {
|
|
74
74
|
status: 'incomplete',
|
|
75
|
-
message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard.`,
|
|
75
|
+
message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
|
|
76
76
|
fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
|
|
77
77
|
};
|
|
78
78
|
},
|
package/package.json
CHANGED