pour-engine 1.1.1 → 1.1.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/README.md +12 -0
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +3 -3
- package/engine/lib/roles.js +2 -2
- package/engine/rules/index.js +8 -4
- package/engine/rules/wcag/1.3.4-orientation.js +70 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +23 -3
- package/engine/wcag22.js +2 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -51,6 +51,18 @@ invoked before and after each rule — useful for live progress UI. Results
|
|
|
51
51
|
include `violations`, `passes`, `incomplete`, `inapplicable`,
|
|
52
52
|
`manualReview`, and per-rule timings.
|
|
53
53
|
|
|
54
|
+
## Examples
|
|
55
|
+
|
|
56
|
+
Minimal, console-only examples live in [`examples/`](examples/):
|
|
57
|
+
|
|
58
|
+
- [`examples/browser.html`](examples/browser.html) — a page that audits
|
|
59
|
+
itself; the dependency-free ES modules load straight from a CDN, no
|
|
60
|
+
bundler.
|
|
61
|
+
- [`examples/puppeteer.mjs`](examples/puppeteer.mjs) — audit any URL:
|
|
62
|
+
`node examples/puppeteer.mjs https://example.com`
|
|
63
|
+
- [`examples/playwright.mjs`](examples/playwright.mjs) — the same with
|
|
64
|
+
Playwright.
|
|
65
|
+
|
|
54
66
|
## Philosophy
|
|
55
67
|
|
|
56
68
|
- **Spec-first.** Every rule and helper was written from the W3C
|
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -23,7 +23,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
23
23
|
| 1.3.1 | [Info and Relationships](https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html) | A | Partially automated | aria-required-children, aria-required-parent, definition-list, dlitem-parent, form-label, list-structure, listitem-parent, p-as-heading, table-headers |
|
|
24
24
|
| 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Manual review | — |
|
|
25
25
|
| 1.3.3 | [Sensory Characteristics](https://www.w3.org/WAI/WCAG22/Understanding/sensory-characteristics.html) | A | Manual review | — |
|
|
26
|
-
| 1.3.4 | [Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA |
|
|
26
|
+
| 1.3.4 | [Orientation](https://www.w3.org/WAI/WCAG22/Understanding/orientation.html) | AA | Partially automated | orientation-lock |
|
|
27
27
|
| 1.3.5 | [Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html) | AA | Partially automated | autocomplete-valid |
|
|
28
28
|
| 1.3.6 | [Identify Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html) | AAA | Manual review | — |
|
|
29
29
|
| 1.4.1 | [Use of Color](https://www.w3.org/WAI/WCAG22/Understanding/use-of-color.html) | A | Partially automated | link-in-text-block |
|
|
@@ -72,7 +72,7 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
72
72
|
| 2.4.13 | [Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) | AAA | Manual review | — |
|
|
73
73
|
| 2.5.1 | [Pointer Gestures](https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html) | A | Manual review | — |
|
|
74
74
|
| 2.5.2 | [Pointer Cancellation](https://www.w3.org/WAI/WCAG22/Understanding/pointer-cancellation.html) | A | Manual review | — |
|
|
75
|
-
| 2.5.3 | [Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | A |
|
|
75
|
+
| 2.5.3 | [Label in Name](https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html) | A | Partially automated | label-in-name |
|
|
76
76
|
| 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
|
|
77
77
|
| 2.5.5 | [Target Size (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html) | AAA | Partially automated | target-size-enhanced |
|
|
78
78
|
| 2.5.6 | [Concurrent Input Mechanisms](https://www.w3.org/WAI/WCAG22/Understanding/concurrent-input-mechanisms.html) | AAA | Manual review | — |
|
|
@@ -112,4 +112,4 @@ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
|
|
|
112
112
|
| 4.1.2 | [Name, Role, Value](https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html) | A | Partially automated | input-image-alt, form-label, link-name, aria-allowed-attr, aria-attr-valid, aria-field-name, aria-hidden-focus, aria-valid-refs, button-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, valid-role |
|
|
113
113
|
| 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
|
|
114
114
|
|
|
115
|
-
**Totals:** 2 automated ·
|
|
115
|
+
**Totals:** 2 automated · 28 partially automated · 56 manual — with 73 rules.
|
package/engine/lib/roles.js
CHANGED
|
@@ -28,8 +28,8 @@ export const GLOBAL_ARIA = new Set([
|
|
|
28
28
|
export const ROLE_ARIA = {
|
|
29
29
|
link: ['disabled', 'expanded', 'haspopup'],
|
|
30
30
|
button: ['disabled', 'expanded', 'haspopup', 'pressed'],
|
|
31
|
-
checkbox: ['checked', 'disabled', 'errormessage', 'invalid', 'readonly', 'required'],
|
|
32
|
-
switch: ['checked', 'disabled', 'errormessage', 'invalid', 'readonly', 'required'],
|
|
31
|
+
checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
|
|
32
|
+
switch: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
|
|
33
33
|
radio: ['checked', 'disabled', 'posinset', 'setsize'],
|
|
34
34
|
option: ['selected', 'checked', 'disabled', 'posinset', 'setsize'],
|
|
35
35
|
tab: ['selected', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
|
package/engine/rules/index.js
CHANGED
|
@@ -15,11 +15,14 @@ import imageAlt from './wcag/1.1.1-image-alt.js';
|
|
|
15
15
|
import svgImgAlt from './wcag/1.1.1-svg-img-alt.js';
|
|
16
16
|
import buttonName from './wcag/4.1.2-button-name.js';
|
|
17
17
|
import linkName from './wcag/2.4.4-link-name.js';
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
18
|
+
// Un-parked 2026-08-01 (was parked 2026-07-29): benchmarking against other
|
|
19
|
+
// engines showed real 2.5.3 misses on mainstream sites, and the rule now
|
|
20
|
+
// also covers labelable fields whose associated <label> text is missing
|
|
21
|
+
// from an overriding aria-name.
|
|
22
|
+
import labelInName from './wcag/2.5.3-label-in-name.js';
|
|
21
23
|
import formLabel from './wcag/1.3.1-form-label.js';
|
|
22
24
|
import autocompleteValid from './wcag/1.3.5-autocomplete-valid.js';
|
|
25
|
+
import orientationLock from './wcag/1.3.4-orientation.js';
|
|
23
26
|
import metaViewport from './wcag/1.4.4-meta-viewport.js';
|
|
24
27
|
import colorContrast from './wcag/1.4.3-color-contrast.js';
|
|
25
28
|
import frameTitle from './wcag/4.1.2-frame-title.js';
|
|
@@ -107,9 +110,10 @@ export default [
|
|
|
107
110
|
svgImgAlt,
|
|
108
111
|
buttonName,
|
|
109
112
|
linkName,
|
|
110
|
-
|
|
113
|
+
labelInName,
|
|
111
114
|
formLabel,
|
|
112
115
|
autocompleteValid,
|
|
116
|
+
orientationLock,
|
|
113
117
|
metaViewport,
|
|
114
118
|
colorContrast,
|
|
115
119
|
frameTitle,
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// WCAG SC 1.3.4 Orientation (Level AA, added in WCAG 2.1)
|
|
2
|
+
// "Content does not restrict its view and operation to a single display
|
|
3
|
+
// orientation, such as portrait or landscape, unless a specific display
|
|
4
|
+
// orientation is essential."
|
|
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.
|
|
13
|
+
//
|
|
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
|
+
const ROOT_PART = /^(html|body|:root)(::?[a-z-]+(\(.*\))?)?$/i;
|
|
20
|
+
const LOCK_ROTATE = /rotate(?:3d\([^)]*,\s*)?\(?\s*(?:-?(?:90|270)deg|0\.25turn|-0\.25turn|100grad|-100grad)/i;
|
|
21
|
+
|
|
22
|
+
function rootSelector(selectorText) {
|
|
23
|
+
return (selectorText ?? '').split(',').some((part) => ROOT_PART.test(part.trim()));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function scanRules(rules, insideOrientation, findings) {
|
|
27
|
+
for (const rule of rules ?? []) {
|
|
28
|
+
const condition = rule.conditionText ?? rule.media?.mediaText;
|
|
29
|
+
if (condition !== undefined && rule.cssRules) {
|
|
30
|
+
const orientation = /orientation\s*:\s*(portrait|landscape)/i.exec(condition)?.[1];
|
|
31
|
+
scanRules(rule.cssRules, orientation ? { condition, orientation } : insideOrientation, findings);
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (insideOrientation && rule.style && rootSelector(rule.selectorText)) {
|
|
35
|
+
const { display, visibility, transform, rotate } = rule.style;
|
|
36
|
+
if (display === 'none' || visibility === 'hidden') {
|
|
37
|
+
findings.push({ kind: 'hidden', selector: rule.selectorText, ...insideOrientation });
|
|
38
|
+
} else if (LOCK_ROTATE.test(transform ?? '') || LOCK_ROTATE.test(rotate ?? '')) {
|
|
39
|
+
findings.push({ kind: 'rotated', selector: rule.selectorText, ...insideOrientation });
|
|
40
|
+
}
|
|
41
|
+
} else if (rule.cssRules) {
|
|
42
|
+
scanRules(rule.cssRules, insideOrientation, findings);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export default {
|
|
48
|
+
id: 'orientation-lock',
|
|
49
|
+
impact: 'serious',
|
|
50
|
+
tags: ['wcag21aa', 'wcag134'],
|
|
51
|
+
help: 'Content must work in both portrait and landscape',
|
|
52
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/orientation.html',
|
|
53
|
+
selector: 'html',
|
|
54
|
+
evaluate(element) {
|
|
55
|
+
const findings = [];
|
|
56
|
+
for (const sheet of element.ownerDocument.styleSheets) {
|
|
57
|
+
let rules;
|
|
58
|
+
try { rules = sheet.cssRules; } catch { continue; } // cross-origin: unreadable, skip
|
|
59
|
+
scanRules(rules, null, findings);
|
|
60
|
+
}
|
|
61
|
+
if (!findings.length) return { status: 'pass' };
|
|
62
|
+
const f = findings[0];
|
|
63
|
+
const verb = f.kind === 'hidden' ? 'hides the page' : 'rotates the page to force the other orientation';
|
|
64
|
+
return {
|
|
65
|
+
status: 'fail',
|
|
66
|
+
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.`,
|
|
67
|
+
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.',
|
|
68
|
+
};
|
|
69
|
+
},
|
|
70
|
+
};
|
|
@@ -17,6 +17,12 @@ const BASES = [
|
|
|
17
17
|
'input[type="button"]', 'input[type="submit"]', 'input[type="reset"]',
|
|
18
18
|
'[role="button"]', '[role="link"]', '[role="menuitem"]', '[role="tab"]',
|
|
19
19
|
'[role="checkbox"]', '[role="radio"]', '[role="switch"]',
|
|
20
|
+
// Labelable form fields: their visible label is the associated <label>
|
|
21
|
+
// element, and an aria-name that omits its text breaks voice control just
|
|
22
|
+
// as surely as it does on a button (SC text scopes to any UI component
|
|
23
|
+
// whose label includes text, not only content-named controls).
|
|
24
|
+
'input:not([type="hidden"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"])',
|
|
25
|
+
'select', 'textarea',
|
|
20
26
|
];
|
|
21
27
|
const COMPONENTS = BASES.flatMap((base) => [`${base}[aria-label]`, `${base}[aria-labelledby]`]).join(', ');
|
|
22
28
|
|
|
@@ -71,9 +77,23 @@ export default {
|
|
|
71
77
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/label-in-name.html',
|
|
72
78
|
selector: COMPONENTS,
|
|
73
79
|
evaluate(element, { accessibleName }) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
const tag = element.tagName.toLowerCase();
|
|
81
|
+
const buttonLike = tag === 'input' && ['button', 'submit', 'reset'].includes(element.type);
|
|
82
|
+
let rawVisible;
|
|
83
|
+
if (buttonLike) {
|
|
84
|
+
rawVisible = element.getAttribute('value') ?? '';
|
|
85
|
+
} else if (tag === 'input' || tag === 'select' || tag === 'textarea') {
|
|
86
|
+
// A field's visible label is its associated <label> (for= or wrapping,
|
|
87
|
+
// via the DOM `labels` list) — the part a voice-control user reads out.
|
|
88
|
+
// No visible label, nothing to match: label-less fields are
|
|
89
|
+
// form-label's finding, not a 2.5.3 mismatch.
|
|
90
|
+
rawVisible = [...(element.labels ?? [])]
|
|
91
|
+
.filter((label) => !visuallyErased(label))
|
|
92
|
+
.map((label) => visibleText(label.childNodes))
|
|
93
|
+
.join(' ');
|
|
94
|
+
} else {
|
|
95
|
+
rawVisible = visibleText(element.childNodes);
|
|
96
|
+
}
|
|
77
97
|
const visible = rawVisible.replace(/\s+/g, ' ').trim();
|
|
78
98
|
// Icon/symbol-only or nothing visible: there is no text label to match.
|
|
79
99
|
if (!visible || !HAS_LETTERS.test(visible)) return { status: 'pass' };
|
package/engine/wcag22.js
CHANGED
|
@@ -37,7 +37,7 @@ export default [
|
|
|
37
37
|
sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
|
|
38
38
|
sc('1.3.2', 'Meaningful Sequence', 'A', 'manual'),
|
|
39
39
|
sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
|
|
40
|
-
sc('1.3.4', 'Orientation', 'AA', '
|
|
40
|
+
sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock (2026-08-01) proves CSS root hides/rotations; script locks stay a human check
|
|
41
41
|
sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are provable; MISSING autocomplete on identity fields needs judgment
|
|
42
42
|
sc('1.3.6', 'Identify Purpose', 'AAA', 'manual', '2.1'),
|
|
43
43
|
sc('1.4.1', 'Use of Color', 'A', 'partial'), // link-in-text-block automates the link case
|
|
@@ -82,7 +82,7 @@ export default [
|
|
|
82
82
|
sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
|
|
83
83
|
sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
|
|
84
84
|
sc('2.5.2', 'Pointer Cancellation', 'A', 'manual', '2.1'),
|
|
85
|
-
sc('2.5.3', 'Label in Name', 'A', '
|
|
85
|
+
sc('2.5.3', 'Label in Name', 'A', 'partial', '2.1'), // label-in-name un-parked 2026-08-01: catches aria-name/visible-label mismatches on controls and labelled fields; can't prove every naming path, so partial
|
|
86
86
|
sc('2.5.4', 'Motion Actuation', 'A', 'manual', '2.1'),
|
|
87
87
|
sc('2.5.5', 'Target Size (Enhanced)', 'AAA', 'partial', '2.1'),
|
|
88
88
|
sc('2.5.6', 'Concurrent Input Mechanisms', 'AAA', 'manual', '2.1'),
|
package/package.json
CHANGED