pour-engine 1.35.3 → 1.35.5
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.1.1-image-alt.js +10 -7
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -1
- 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
|
}
|
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
// WCAG SC 1.1.1 Non-text Content (Level A)
|
|
2
2
|
// alt="" (exactly empty) is the CONFORMING decorative marker (H67) — it
|
|
3
3
|
// hides the image from screen readers on purpose and passes. A
|
|
4
|
-
// whitespace-only alt is neither a text alternative nor that marker
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
4
|
+
// whitespace-only alt is neither a text alternative nor that marker by the
|
|
5
|
+
// letter of the spec, but the accessible name computes to empty and current
|
|
6
|
+
// screen readers skip the image as if it were alt="", and a single space
|
|
7
|
+
// was once a documented workaround for old JAWS reading the file name. Its
|
|
8
|
+
// authorship is ambiguous rather than a proven barrier, so since 2026-09-01
|
|
9
|
+
// it is flagged for eyes, not failed at critical alongside a missing alt.
|
|
10
|
+
// Filename/generic alts technically satisfy presence but tell the user
|
|
11
|
+
// nothing (F30/F39) — flagged for eyes.
|
|
8
12
|
const FILENAME_ALT = /\.(png|jpe?g|gif|webp|svg|avif|bmp|ico)([?#].*)?$/i;
|
|
9
13
|
const GENERIC_ALT = /^(image|img|photo|photograph|picture|graphic|icon|untitled|placeholder|spacer|\d+)$/i;
|
|
10
14
|
|
|
@@ -25,9 +29,8 @@ export default {
|
|
|
25
29
|
if (alt === '') return { status: 'pass' };
|
|
26
30
|
if (!alt.trim()) {
|
|
27
31
|
return {
|
|
28
|
-
status: '
|
|
29
|
-
message: `alt="${alt}" is only whitespace —
|
|
30
|
-
fix: 'Use alt="" (nothing between the quotes) for decorative images, or write a real description.',
|
|
32
|
+
status: 'incomplete',
|
|
33
|
+
message: `alt="${alt}" is only whitespace — not a description, and not the decorative marker either (that is alt="" with nothing between the quotes). Screen readers skip it today, so check the image is really decorative; if it carries meaning, describe it, and either way use alt="".`,
|
|
31
34
|
};
|
|
32
35
|
}
|
|
33
36
|
const trimmed = alt.trim();
|
|
@@ -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) => {
|
|
@@ -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