pour-engine 1.1.1 → 1.1.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/README.md +12 -0
- package/config/project.config.js +1 -1
- package/engine/COVERAGE.md +2 -2
- package/engine/lib/roles.js +2 -2
- package/engine/rules/index.js +10 -3
- 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 |
|
|
@@ -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 · 27 partially automated · 57 manual — with 72 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,17 @@ 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
|
-
//
|
|
18
|
+
// Re-parked by product decision (2026-08-01, David, after a one-day
|
|
19
|
+
// un-parking): 2.5.3 is normative Level A but chiefly serves speech-input
|
|
20
|
+
// users, and the findings volume isn't wanted in results for now. The SC is
|
|
21
|
+
// classed 'manual' in wcag22.js so it lands in the human checklist instead
|
|
22
|
+
// of being silently skipped. The rule itself (incl. the labelled-field
|
|
23
|
+
// widening) stays maintained here — restore the import + registry entry to
|
|
24
|
+
// bring it back.
|
|
20
25
|
// import labelInName from './wcag/2.5.3-label-in-name.js';
|
|
21
26
|
import formLabel from './wcag/1.3.1-form-label.js';
|
|
22
27
|
import autocompleteValid from './wcag/1.3.5-autocomplete-valid.js';
|
|
28
|
+
import orientationLock from './wcag/1.3.4-orientation.js';
|
|
23
29
|
import metaViewport from './wcag/1.4.4-meta-viewport.js';
|
|
24
30
|
import colorContrast from './wcag/1.4.3-color-contrast.js';
|
|
25
31
|
import frameTitle from './wcag/4.1.2-frame-title.js';
|
|
@@ -107,9 +113,10 @@ export default [
|
|
|
107
113
|
svgImgAlt,
|
|
108
114
|
buttonName,
|
|
109
115
|
linkName,
|
|
110
|
-
// labelInName, — parked 2026-
|
|
116
|
+
// labelInName, — re-parked 2026-08-01, see note at the import
|
|
111
117
|
formLabel,
|
|
112
118
|
autocompleteValid,
|
|
119
|
+
orientationLock,
|
|
113
120
|
metaViewport,
|
|
114
121
|
colorContrast,
|
|
115
122
|
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', 'manual', '2.1'), // manual WHILE
|
|
85
|
+
sc('2.5.3', 'Label in Name', 'A', 'manual', '2.1'), // manual WHILE label-in-name is parked (re-parked 2026-08-01, see rules/index.js) — 'auto' with no active rule would make this SC vanish from results AND the manual checklist
|
|
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