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 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.5',
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 |
@@ -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 · 27 partially automated · 57 manual — with 72 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,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
- // 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.
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-07-29, see note at the import
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 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', '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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.1",
3
+ "version": "1.1.5",
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",