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 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
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.1.1',
9
+ version: '1.1.4',
10
10
  },
11
11
  };
@@ -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 | Manual review | — |
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 | Manual review | — |
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 · 26 partially automated · 58 manual — with 71 rules.
115
+ **Totals:** 2 automated · 28 partially automated · 56 manual — with 73 rules.
@@ -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'],
@@ -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
- // Parked by product decision (2026-07-29): label-in-name excluded from runs
19
- // for now — restore the import and registry entry below to bring it back.
20
- // import labelInName from './wcag/2.5.3-label-in-name.js';
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
- // labelInName, — parked 2026-07-29, see note at the import
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 rawVisible = element.tagName === 'INPUT'
75
- ? (element.getAttribute('value') ?? '')
76
- : visibleText(element.childNodes);
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', 'manual', '2.1'),
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 the label-in-name rule is parked (see rules/index.js) — 'auto' with no active rule made this SC vanish from both the results AND the manual checklist
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.1",
3
+ "version": "1.1.4",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",