pour-engine 1.1.0

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.
Files changed (86) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/config/project.config.js +11 -0
  4. package/engine/COVERAGE.md +115 -0
  5. package/engine/README.md +115 -0
  6. package/engine/index.js +258 -0
  7. package/engine/lib/accessible-name.js +126 -0
  8. package/engine/lib/contrast.js +628 -0
  9. package/engine/lib/dom.js +116 -0
  10. package/engine/lib/roles.js +125 -0
  11. package/engine/rules/best-practice/accesskeys.js +29 -0
  12. package/engine/rules/best-practice/aria-label-misuse.js +22 -0
  13. package/engine/rules/best-practice/button-type.js +15 -0
  14. package/engine/rules/best-practice/empty-heading.js +19 -0
  15. package/engine/rules/best-practice/fieldset-legend.js +35 -0
  16. package/engine/rules/best-practice/heading-order.js +30 -0
  17. package/engine/rules/best-practice/landmark-one-main.js +22 -0
  18. package/engine/rules/best-practice/landmark-top-level.js +46 -0
  19. package/engine/rules/best-practice/landmark-unique.js +84 -0
  20. package/engine/rules/best-practice/multiple-labels.js +23 -0
  21. package/engine/rules/best-practice/new-window-link.js +21 -0
  22. package/engine/rules/best-practice/no-autofocus.js +16 -0
  23. package/engine/rules/best-practice/page-heading-one.js +17 -0
  24. package/engine/rules/best-practice/positive-tabindex.js +17 -0
  25. package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
  26. package/engine/rules/best-practice/redundant-aria-label.js +18 -0
  27. package/engine/rules/best-practice/redundant-aria.js +32 -0
  28. package/engine/rules/best-practice/redundant-image-alt.js +20 -0
  29. package/engine/rules/best-practice/redundant-role.js +62 -0
  30. package/engine/rules/best-practice/redundant-tabindex.js +15 -0
  31. package/engine/rules/best-practice/region.js +73 -0
  32. package/engine/rules/index.js +179 -0
  33. package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
  34. package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
  35. package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
  36. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  37. package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
  38. package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
  39. package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
  40. package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
  41. package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
  42. package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
  43. package/engine/rules/wcag/1.3.1-form-label.js +81 -0
  44. package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
  45. package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
  46. package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
  47. package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
  48. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
  49. package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
  50. package/engine/rules/wcag/1.4.10-reflow.js +25 -0
  51. package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
  52. package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
  53. package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
  54. package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
  55. package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
  56. package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
  57. package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
  58. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
  59. package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
  60. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
  61. package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
  62. package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
  63. package/engine/rules/wcag/2.4.2-document-title.js +18 -0
  64. package/engine/rules/wcag/2.4.4-link-name.js +19 -0
  65. package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
  66. package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
  67. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
  68. package/engine/rules/wcag/2.5.8-target-size.js +159 -0
  69. package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
  70. package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
  71. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
  72. package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
  73. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
  74. package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
  75. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
  76. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
  77. package/engine/rules/wcag/4.1.2-button-name.js +17 -0
  78. package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
  79. package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
  80. package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
  81. package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
  82. package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
  83. package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
  84. package/engine/wcag22.js +116 -0
  85. package/engine/wcag30-draft.js +149 -0
  86. package/package.json +34 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 David Yarham
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,99 @@
1
+ # pour engine
2
+
3
+ The accessibility audit engine behind [pour.dev](https://pour.dev) — a
4
+ clean-room WCAG engine written from the W3C specifications. Zero
5
+ dependencies, runs entirely in the browser, returns structured per-rule
6
+ results with the exact failing elements and the evidence for each verdict.
7
+
8
+ ## Rules & coverage
9
+
10
+ **WCAG only, by design.** The engine tracks the WCAG spec exclusively — no
11
+ Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
12
+ 86 success criteria) lives in `engine/wcag22.js`, and
13
+ [`engine/COVERAGE.md`](engine/COVERAGE.md) shows exactly which criteria are
14
+ automated, partially automated, or need human review. Rules pierce open
15
+ shadow DOM, callers can cover same-origin frames by running the engine per
16
+ document, and every result carries its WCAG criterion, severity, a CSS path
17
+ to the element, and an HTML snippet.
18
+
19
+ Automation has limits, and the engine is honest about them: criteria no tool
20
+ can verify are returned as a `manualReview` checklist instead of being
21
+ silently skipped, and findings the engine can't judge conclusively land in
22
+ `incomplete` rather than being guessed at.
23
+
24
+ An experimental WCAG 3.0 draft mode (`engine/wcag30-draft.js`) reframes
25
+ results against the draft's guidelines — clearly stamped as a draft, because
26
+ no tool can honestly claim conformance to an unfinished standard.
27
+
28
+ ## Getting started
29
+
30
+ ```sh
31
+ npm install pour-engine
32
+ ```
33
+
34
+ > Not yet published to npm — `package.json` is marked `"private": true` while
35
+ > this repository stays private. Remove that flag when open-sourcing.
36
+
37
+ ```js
38
+ import { run, name, version } from 'pour-engine';
39
+
40
+ // Audit the current document (browser, extension content script, bookmarklet…)
41
+ const results = await run(document, {
42
+ // optional: filter rules by tag. Tags are cumulative — WCAG 2.2 AA means
43
+ // every A/AA rule from 2.0 through 2.2. Omit to run everything.
44
+ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],
45
+ // standard: 'wcag30-draft', // optional: experimental WCAG 3.0 draft mode
46
+ });
47
+
48
+ console.log(results.violations, results.passes, results.incomplete);
49
+ ```
50
+
51
+ `run(context, options, onProgress)` accepts any Document (the top document
52
+ or an iframe's), an options object, and an optional progress callback
53
+ invoked before and after each rule — useful for live progress UI. Results
54
+ include `violations`, `passes`, `incomplete`, `inapplicable`,
55
+ `manualReview`, and per-rule timings.
56
+
57
+ ## Philosophy
58
+
59
+ - **Spec-first.** Every rule and helper was written from the W3C
60
+ specifications (WCAG, ARIA, accname), not from other tools' behavior.
61
+ When a rule tightens, it tightens toward the spec.
62
+ - **Zero dependencies.** The engine ships nothing but its own code, so it
63
+ can run anywhere a DOM exists — content scripts, bookmarklets, test
64
+ harnesses — without bundling baggage or supply-chain surface.
65
+ - **No false authority.** A result is a violation only when the spec says
66
+ so. Everything uncertain is `incomplete` or `manualReview`, never a guess
67
+ dressed up as a finding.
68
+
69
+ ## Supported environments
70
+
71
+ Any evergreen browser (the pour extension ships to Chromium; the bookmarklet
72
+ runs wherever it's dropped). The engine needs a live DOM — it does not parse
73
+ static HTML strings. For Node-based testing, drive it through a real browser
74
+ (Puppeteer/Playwright).
75
+
76
+ ## Package contents
77
+
78
+ - `engine/index.js` — the runner: rule selection, scoping, timings, results.
79
+ - `engine/rules/` — one file per rule, named by criterion
80
+ (`1.1.1-image-alt.js`), plus the catalog index.
81
+ - `engine/lib/` — shared helpers: DOM traversal, accessible-name
82
+ computation, contrast math, role model.
83
+ - `engine/wcag22.js`, `engine/wcag30-draft.js` — the WCAG catalogs.
84
+ - `config/project.config.js` — package-local stand-in for the pour
85
+ monorepo's project config (engine name + version only).
86
+
87
+ The `engine/` directory is a byte-identical copy of `src/engine/` in the
88
+ pour monorepo, which is the current source of truth — make changes there and
89
+ sync them here. The test suite (annotated testbed pages and the verification
90
+ runner) also lives in the monorepo and is not part of this package yet.
91
+
92
+ ## License
93
+
94
+ Every rule and helper was written from the W3C specifications (WCAG 2.2,
95
+ WAI-ARIA, accname). Rule ids and tag names follow the vocabulary the
96
+ accessibility-tooling ecosystem already shares, so reports interoperate
97
+ with existing pipelines.
98
+
99
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,11 @@
1
+ // Package-local stand-in for the pour monorepo's project config. The engine
2
+ // source under engine/ is a byte-identical copy of the monorepo's src/engine/
3
+ // — it imports '../config/project.config.js', and this file satisfies that
4
+ // import so the copy needs no edits. Keep engine.version in step with
5
+ // package.json's version.
6
+ export default {
7
+ engine: {
8
+ name: 'pour engine',
9
+ version: '1.1.0',
10
+ },
11
+ };
@@ -0,0 +1,115 @@
1
+ # WCAG 2.2 coverage
2
+
3
+ Generated by `node scripts/wcag-coverage.js` — do not edit by hand.
4
+
5
+ All 86 success criteria of WCAG 2.2, and how the native engine covers them.
6
+ "Partially automated" means our rules catch common failures but cannot prove conformance;
7
+ "Manual review" criteria need a human (most of the spec — true for every automated tool).
8
+
9
+ ## Perceivable
10
+
11
+ | SC | Name | Level | Coverage | Rules |
12
+ |---|---|---|---|---|
13
+ | 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
14
+ | 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Manual review | — |
15
+ | 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
16
+ | 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Manual review | — |
17
+ | 1.2.4 | [Captions (Live)](https://www.w3.org/WAI/WCAG22/Understanding/captions-live.html) | AA | Manual review | — |
18
+ | 1.2.5 | [Audio Description (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-prerecorded.html) | AA | Manual review | — |
19
+ | 1.2.6 | [Sign Language (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/sign-language-prerecorded.html) | AAA | Manual review | — |
20
+ | 1.2.7 | [Extended Audio Description (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/extended-audio-description-prerecorded.html) | AAA | Manual review | — |
21
+ | 1.2.8 | [Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/media-alternative-prerecorded.html) | AAA | Manual review | — |
22
+ | 1.2.9 | [Audio-only (Live)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-live.html) | AAA | Manual review | — |
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
+ | 1.3.2 | [Meaningful Sequence](https://www.w3.org/WAI/WCAG22/Understanding/meaningful-sequence.html) | A | Manual review | — |
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 | — |
27
+ | 1.3.5 | [Identify Input Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-input-purpose.html) | AA | Partially automated | autocomplete-valid |
28
+ | 1.3.6 | [Identify Purpose](https://www.w3.org/WAI/WCAG22/Understanding/identify-purpose.html) | AAA | Manual review | — |
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 |
30
+ | 1.4.2 | [Audio Control](https://www.w3.org/WAI/WCAG22/Understanding/audio-control.html) | A | Partially automated | audio-control |
31
+ | 1.4.3 | [Contrast (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html) | AA | Partially automated | color-contrast, control-contrast |
32
+ | 1.4.4 | [Resize Text](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html) | AA | Partially automated | meta-viewport |
33
+ | 1.4.5 | [Images of Text](https://www.w3.org/WAI/WCAG22/Understanding/images-of-text.html) | AA | Manual review | — |
34
+ | 1.4.6 | [Contrast (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/contrast-enhanced.html) | AAA | Partially automated | color-contrast-enhanced |
35
+ | 1.4.7 | [Low or No Background Audio](https://www.w3.org/WAI/WCAG22/Understanding/low-or-no-background-audio.html) | AAA | Manual review | — |
36
+ | 1.4.8 | [Visual Presentation](https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html) | AAA | Manual review | — |
37
+ | 1.4.9 | [Images of Text (No Exception)](https://www.w3.org/WAI/WCAG22/Understanding/images-of-text-no-exception.html) | AAA | Manual review | — |
38
+ | 1.4.10 | [Reflow](https://www.w3.org/WAI/WCAG22/Understanding/reflow.html) | AA | Partially automated | reflow |
39
+ | 1.4.11 | [Non-text Contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) | AA | Partially automated | non-text-contrast |
40
+ | 1.4.12 | [Text Spacing](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html) | AA | Partially automated | text-spacing |
41
+ | 1.4.13 | [Content on Hover or Focus](https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html) | AA | Manual review | — |
42
+
43
+ ## Operable
44
+
45
+ | SC | Name | Level | Coverage | Rules |
46
+ |---|---|---|---|---|
47
+ | 2.1.1 | [Keyboard](https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html) | A | Partially automated | scrollable-region-focusable |
48
+ | 2.1.2 | [No Keyboard Trap](https://www.w3.org/WAI/WCAG22/Understanding/no-keyboard-trap.html) | A | Manual review | — |
49
+ | 2.1.3 | [Keyboard (No Exception)](https://www.w3.org/WAI/WCAG22/Understanding/keyboard-no-exception.html) | AAA | Partially automated | scrollable-region-focusable |
50
+ | 2.1.4 | [Character Key Shortcuts](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html) | A | Manual review | — |
51
+ | 2.2.1 | [Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html) | A | Partially automated | meta-refresh |
52
+ | 2.2.2 | [Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Partially automated | pause-stop-hide |
53
+ | 2.2.3 | [No Timing](https://www.w3.org/WAI/WCAG22/Understanding/no-timing.html) | AAA | Manual review | — |
54
+ | 2.2.4 | [Interruptions](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html) | AAA | Manual review | — |
55
+ | 2.2.5 | [Re-authenticating](https://www.w3.org/WAI/WCAG22/Understanding/re-authenticating.html) | AAA | Manual review | — |
56
+ | 2.2.6 | [Timeouts](https://www.w3.org/WAI/WCAG22/Understanding/timeouts.html) | AAA | Manual review | — |
57
+ | 2.3.1 | [Three Flashes or Below Threshold](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes-or-below-threshold.html) | A | Manual review | — |
58
+ | 2.3.2 | [Three Flashes](https://www.w3.org/WAI/WCAG22/Understanding/three-flashes.html) | AAA | Manual review | — |
59
+ | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
60
+ | 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
61
+ | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Automated | document-title |
62
+ | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
63
+ | 2.4.4 | [Link Purpose (In Context)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html) | A | Partially automated | area-alt, link-name |
64
+ | 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
65
+ | 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA | Manual review | — |
66
+ | 2.4.7 | [Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | AA | Partially automated | focus-visible |
67
+ | 2.4.8 | [Location](https://www.w3.org/WAI/WCAG22/Understanding/location.html) | AAA | Manual review | — |
68
+ | 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA | Manual review | — |
69
+ | 2.4.10 | [Section Headings](https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html) | AAA | Manual review | — |
70
+ | 2.4.11 | [Focus Not Obscured (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-minimum.html) | AA | Partially automated | focus-not-obscured |
71
+ | 2.4.12 | [Focus Not Obscured (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html) | AAA | Manual review | — |
72
+ | 2.4.13 | [Focus Appearance](https://www.w3.org/WAI/WCAG22/Understanding/focus-appearance.html) | AAA | Manual review | — |
73
+ | 2.5.1 | [Pointer Gestures](https://www.w3.org/WAI/WCAG22/Understanding/pointer-gestures.html) | A | Manual review | — |
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 | — |
76
+ | 2.5.4 | [Motion Actuation](https://www.w3.org/WAI/WCAG22/Understanding/motion-actuation.html) | A | Manual review | — |
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
+ | 2.5.6 | [Concurrent Input Mechanisms](https://www.w3.org/WAI/WCAG22/Understanding/concurrent-input-mechanisms.html) | AAA | Manual review | — |
79
+ | 2.5.7 | [Dragging Movements](https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html) | AA | Manual review | — |
80
+ | 2.5.8 | [Target Size (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/target-size-minimum.html) | AA | Partially automated | target-size |
81
+
82
+ ## Understandable
83
+
84
+ | SC | Name | Level | Coverage | Rules |
85
+ |---|---|---|---|---|
86
+ | 3.1.1 | [Language of Page](https://www.w3.org/WAI/WCAG22/Understanding/language-of-page.html) | A | Automated | html-lang |
87
+ | 3.1.2 | [Language of Parts](https://www.w3.org/WAI/WCAG22/Understanding/language-of-parts.html) | AA | Partially automated | valid-lang-parts |
88
+ | 3.1.3 | [Unusual Words](https://www.w3.org/WAI/WCAG22/Understanding/unusual-words.html) | AAA | Manual review | — |
89
+ | 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
90
+ | 3.1.5 | [Reading Level](https://www.w3.org/WAI/WCAG22/Understanding/reading-level.html) | AAA | Manual review | — |
91
+ | 3.1.6 | [Pronunciation](https://www.w3.org/WAI/WCAG22/Understanding/pronunciation.html) | AAA | Manual review | — |
92
+ | 3.2.1 | [On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html) | A | Manual review | — |
93
+ | 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A | Manual review | — |
94
+ | 3.2.3 | [Consistent Navigation](https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html) | AA | Manual review | — |
95
+ | 3.2.4 | [Consistent Identification](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html) | AA | Manual review | — |
96
+ | 3.2.5 | [Change on Request](https://www.w3.org/WAI/WCAG22/Understanding/change-on-request.html) | AAA | Manual review | — |
97
+ | 3.2.6 | [Consistent Help](https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html) | A | Manual review | — |
98
+ | 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A | Manual review | — |
99
+ | 3.3.2 | [Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) | A | Partially automated | form-label |
100
+ | 3.3.3 | [Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html) | AA | Manual review | — |
101
+ | 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Manual review | — |
102
+ | 3.3.5 | [Help](https://www.w3.org/WAI/WCAG22/Understanding/help.html) | AAA | Manual review | — |
103
+ | 3.3.6 | [Error Prevention (All)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-all.html) | AAA | Manual review | — |
104
+ | 3.3.7 | [Redundant Entry](https://www.w3.org/WAI/WCAG22/Understanding/redundant-entry.html) | A | Manual review | — |
105
+ | 3.3.8 | [Accessible Authentication (Minimum)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html) | AA | Partially automated | auth-field-obstruction |
106
+ | 3.3.9 | [Accessible Authentication (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-enhanced.html) | AAA | Manual review | — |
107
+
108
+ ## Robust
109
+
110
+ | SC | Name | Level | Coverage | Rules |
111
+ |---|---|---|---|---|
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
+ | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
114
+
115
+ **Totals:** 2 automated · 26 partially automated · 58 manual — with 71 rules.
@@ -0,0 +1,115 @@
1
+ # pour engine
2
+
3
+ Our own accessibility engine (name/version defined in
4
+ `src/config/project.config.js`) — written from the W3C specifications, and
5
+ the product's only engine.
6
+
7
+ **WCAG only, by design.** pour engine tracks the WCAG spec exclusively — no
8
+ Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
9
+ 86 success criteria) lives in `wcag22.js`, and `COVERAGE.md` (generated by
10
+ `node scripts/wcag-coverage.js`) shows exactly which criteria are automated,
11
+ partially automated, or need human review.
12
+
13
+ ## Provenance
14
+
15
+ Every rule and helper is written from the W3C specifications — WCAG 2.2,
16
+ WAI-ARIA, and the accessible-name computation spec. Where a threshold
17
+ appears in the code (4.5:1, 24px, 72000 s), it comes from the spec text
18
+ itself.
19
+
20
+ The result vocabulary follows the conventions the accessibility-tooling
21
+ ecosystem already speaks, so reports interoperate with existing pipelines:
22
+ rule ids like `image-alt`, the `wcag2a`/`wcag111` tag scheme, the
23
+ `critical/serious/moderate/minor` impact scale, and the result schema
24
+ (`violations/passes/incomplete/inapplicable`, `helpUrl`, `failureSummary`).
25
+ All user-facing prose (`help`, `message`, `fix`) is original.
26
+
27
+ ## What it aims to do well
28
+
29
+ 1. **Actionable messages.** Every finding says *how to fix it*, with
30
+ concrete suggested code for the specific element (see any rule's
31
+ `fix()`), not just what failed in spec language.
32
+ 2. **Readable source.** Each rule is one small file a contributor can read
33
+ in a minute — no rule → checks → metadata indirection.
34
+ 3. **Resolved verdicts over "incomplete" noise.** The engine works to
35
+ resolve backgrounds, backdrops, and paint order before giving up — and
36
+ when it genuinely can't judge, it says so plainly instead of guessing.
37
+ 4. **Size.** The engine is a few KB, so the extension and bookmarklet stay
38
+ light.
39
+ 5. **Filters integration.** Vision/sensory filters and the engine share
40
+ colour math and DOM helpers.
41
+ 6. **"Overdoing it" rules.** Best-practice rules that flag *redundant*
42
+ accessibility — role="list" on a `<ul>`, aria-required duplicating
43
+ required, aria-label repeating visible text, alt text starting with
44
+ "image of", tabindex="0" on a `<button>`. Leaner markup is better
45
+ markup. (Enable "Best practices" in settings to see them.)
46
+
47
+ ## Architecture
48
+
49
+ ```
50
+ src/engine/
51
+ index.js run(context, {tags}, onProgress) → results
52
+ wcag22.js the full WCAG 2.2 success-criteria catalog
53
+ lib/
54
+ dom.js visibility, CSS-path selectors, html snippets
55
+ accessible-name.js accessible-name computation
56
+ contrast.js colour parsing, luminance, contrast + pixel sampling
57
+ roles.js ARIA role model (implicit roles, allowed attributes)
58
+ rules/
59
+ index.js rule registry
60
+ wcag/<rule>.js spec-required rules (tagged with wcag* criteria)
61
+ best-practice/<rule>.js opt-in extras (tagged 'best-practice')
62
+ ```
63
+
64
+ A rule is a small object:
65
+
66
+ ```js
67
+ export default {
68
+ id: 'image-alt',
69
+ impact: 'critical', // critical | serious | moderate | minor
70
+ tags: ['wcag2a', 'wcag111'], // version/level tags + criterion tags
71
+ help: 'Every <img> needs a text alternative',
72
+ helpUrl: 'https://…',
73
+ selector: 'img', // which elements the rule applies to
74
+ visibleOnly: true, // skip hidden elements (default true)
75
+ evaluate(element, helpers) {
76
+ // → { status: 'pass' } or { status: 'fail'|'incomplete', message, fix }
77
+ },
78
+ };
79
+ ```
80
+
81
+ `run(context, { tags })` selects rules by tag, evaluates each matching
82
+ element, and buckets results into `violations / passes / incomplete /
83
+ inapplicable`, each node carrying its target selector, html snippet, and a
84
+ plain-language failure summary with a concrete fix.
85
+
86
+ Rules that need to see the whole page at once (e.g. heading order) implement
87
+ `evaluateAll(elements, helpers)` instead of `evaluate`.
88
+
89
+ ## The spec is the authority
90
+
91
+ Every rule is adjudicated against the WCAG, accname, and ARIA specs — those
92
+ documents decide what is a violation. The engine is also validated
93
+ continuously against pixel-measured ground truth on real sites (see the
94
+ repository's engine lab) so its verdicts answer to rendered reality, not
95
+ just to static analysis.
96
+
97
+ ## Full-spec coverage model
98
+
99
+ Rules alone can never "cover WCAG" — most criteria need human judgment.
100
+ pour engine therefore reports on **every** in-scope criterion, one of two ways:
101
+
102
+ 1. **Automated/partial** — the rules in `rules/`.
103
+ 2. **Manual review** — every audit's results include `manualReview`: the
104
+ in-scope criteria (filtered by the requested WCAG version and level)
105
+ that automation can't judge, rendered as a checklist in the extension.
106
+
107
+ Nothing in the spec is silently skipped — that claim is the product.
108
+
109
+ ## Honest current state
110
+
111
+ 61 rules, hardened on real sites and the `testbeds/` pages. Known gaps:
112
+ shadow DOM and iframe traversal, the full accname spec (ours is a close
113
+ approximation), and the language subtag registry (BCP 47 shape check only).
114
+ Grow it rule by rule; the test beds' `expect:` annotations are the
115
+ regression suite.
@@ -0,0 +1,258 @@
1
+ // DY Engine runner: evaluates every applicable rule against the page and
2
+ // returns structured results (violations / passes / incomplete /
3
+ // inapplicable, plus the manual-review criteria and per-rule timings).
4
+ import config from '../config/project.config.js';
5
+ import rules from './rules/index.js';
6
+ import wcagCatalog from './wcag22.js';
7
+ import wcag30Draft from './wcag30-draft.js';
8
+ import { isVisible, isRendered, cssPath, htmlSnippet, ownText, collectRoots } from './lib/dom.js';
9
+ import { accessibleName } from './lib/accessible-name.js';
10
+ import { resetAuditCaches } from './lib/contrast.js';
11
+
12
+ export const name = config.engine.name;
13
+ export const version = config.engine.version;
14
+
15
+ const ruleHelpers = { isVisible, isRendered, cssPath, htmlSnippet, ownText, accessibleName };
16
+
17
+ /** Does this rule match the requested tag selection? Empty/absent = all rules. */
18
+ function ruleMatchesTags(rule, tags) {
19
+ if (!tags?.length) return true;
20
+ return rule.tags.some((tag) => tags.includes(tag));
21
+ }
22
+
23
+ /** Which WCAG version/level was requested, judged from the selected tags. */
24
+ function scopeFromTags(tags) {
25
+ if (!tags?.length) return { version: '2.2', level: 'AAA' }; // no filter → everything
26
+ const version = tags.some((t) => t.startsWith('wcag22')) ? '2.2'
27
+ : tags.some((t) => t.startsWith('wcag21')) ? '2.1' : '2.0';
28
+ const level = tags.some((t) => /aaa$/.test(t)) ? 'AAA'
29
+ : tags.some((t) => /aa$/.test(t)) ? 'AA' : 'A';
30
+ return { version, level };
31
+ }
32
+
33
+ const VERSION_ORDER = { '2.0': 0, '2.1': 1, '2.2': 2 };
34
+ const LEVEL_ORDER = { A: 0, AA: 1, AAA: 2 };
35
+
36
+ /**
37
+ * The WCAG criteria in scope that automation cannot fully judge — reported
38
+ * with every audit so that "full spec coverage" is real: rules handle what
39
+ * they can, and the rest is an explicit human checklist, never silence.
40
+ */
41
+ function manualReviewCriteria(tags) {
42
+ const scope = scopeFromTags(tags);
43
+ return wcagCatalog
44
+ .filter((criterion) => criterion.automation !== 'auto')
45
+ .filter((criterion) => VERSION_ORDER[criterion.since] <= VERSION_ORDER[scope.version])
46
+ .filter((criterion) => LEVEL_ORDER[criterion.level] <= LEVEL_ORDER[scope.level])
47
+ .map(({ num, name: criterionName, level, principle, url, automation }) =>
48
+ ({ num, name: criterionName, level, principle, url, automation }));
49
+ }
50
+
51
+ /**
52
+ * True when the element, or any flat-tree ancestor (crossing shadow
53
+ * boundaries host-ward), matches the exclude selector. Lets a testing tool
54
+ * keep its own injected UI out of the audit — a panel drawn over the page
55
+ * must never appear in the page's results.
56
+ */
57
+ function isExcluded(element, selector) {
58
+ let node = element;
59
+ while (node) {
60
+ if (node.closest?.(selector)) return true;
61
+ node = node.getRootNode()?.host ?? null;
62
+ }
63
+ return false;
64
+ }
65
+
66
+ /** Serialize one evaluated element into a result node. */
67
+ function toResultNode(element, outcome) {
68
+ const node = {
69
+ target: [cssPath(element)],
70
+ html: htmlSnippet(element),
71
+ failureSummary: [outcome.message, outcome.fix && `Fix: ${outcome.fix}`].filter(Boolean).join('\n'),
72
+ };
73
+ // Structured extras from the rule (e.g. the exact colour pair behind a
74
+ // contrast failure) ride along for UIs that can use them.
75
+ if (outcome.data) node.data = outcome.data;
76
+ return node;
77
+ }
78
+
79
+ /**
80
+ * Run the audit.
81
+ * @param {Document|Element} context - what to scan
82
+ * @param {{ tags?: string[], exclude?: string, standard?: string, signal?: AbortSignal }} options -
83
+ * rule selection by tag (wcag2a…wcag22aa, best-practice), empty selects
84
+ * every rule; exclude is a CSS selector — elements matching it, or inside
85
+ * a match (including across shadow boundaries), are left out of every
86
+ * rule; standard 'wcag30-draft' reframes the report against the WCAG 3.0
87
+ * Working Draft (results.standard is stamped draft, the manual checklist
88
+ * becomes the draft guidelines, and each rule result carries the draft
89
+ * guideline it maps to) — the rules themselves stay WCAG 2.x, which is
90
+ * the only honest claim a tool can make about a draft;
91
+ * signal aborts the run between rules (throws AbortError)
92
+ * @param {(progress: object) => void} [onProgress] - called before each rule
93
+ * starts ({rule, running}), during a rule at each renderer yield with a
94
+ * fractional done (completed rules + this rule's evaluated-element share,
95
+ * so a bar fills smoothly through big element sets), and after it
96
+ * finishes ({rule, ms, counts})
97
+ */
98
+ export async function run(context = document, options = {}, onProgress) {
99
+ const auditStarted = performance.now();
100
+ const draft30 = options.standard === 'wcag30-draft';
101
+ const results = {
102
+ testEngine: { name, version },
103
+ timestamp: new Date().toISOString(),
104
+ url: context.location?.href ?? context.ownerDocument?.location?.href ?? '',
105
+ standard: draft30 ? wcag30Draft.draftInfo : null,
106
+ violations: [],
107
+ passes: [],
108
+ incomplete: [],
109
+ inapplicable: [],
110
+ manualReview: draft30 ? wcag30Draft.manualReviewChecklist() : manualReviewCriteria(options.tags),
111
+ ruleTimings: [],
112
+ };
113
+
114
+ // `disabled: true` keeps a rule in the codebase (and coverage docs) but
115
+ // out of every run — for rules parked pending quality review.
116
+ const activeRules = rules.filter((rule) => !rule.disabled && ruleMatchesTags(rule, options.tags));
117
+ // Per-audit caches (resolved backgrounds, opacities) must start fresh —
118
+ // the page may have changed since the last run.
119
+ resetAuditCaches();
120
+ // Every queryable root — the page plus all open shadow trees — collected
121
+ // once so rules see web-component content without re-walking per rule.
122
+ const roots = collectRoots(context.querySelectorAll ? context : document);
123
+ // Universal-selector rules (aria attribute checks) share one full scan
124
+ // instead of each re-enumerating every element on the page.
125
+ let universalScan = null;
126
+ const elementsFor = (selector) => {
127
+ if (selector === '*') {
128
+ universalScan ??= roots.flatMap((root) => [...root.querySelectorAll('*')]);
129
+ return universalScan;
130
+ }
131
+ return roots.flatMap((root) => [...root.querySelectorAll(selector)]);
132
+ };
133
+ let completed = 0;
134
+ // Running totals so the UI can count issues up live as rules finish —
135
+ // both affected elements and distinct rules ("types") per severity.
136
+ const liveCounts = { critical: 0, serious: 0, moderate: 0, minor: 0, review: 0 };
137
+ const liveTypeCounts = { critical: 0, serious: 0, moderate: 0, minor: 0, review: 0 };
138
+
139
+ // The audit shares the page's main thread. Rules only await microtasks,
140
+ // so without a real yield the whole run is one long task and the browser
141
+ // never paints — in-page progress UI (the bookmarklet panel) would appear
142
+ // only when everything is done, and the audited page itself freezes.
143
+ // Yield about once a frame; between rules is the granularity we have.
144
+ let lastYield = performance.now();
145
+ // Yield via MessageChannel: a normal macrotask like setTimeout(0) — fair
146
+ // FIFO with the audited page's own tasks (deliberately NOT
147
+ // scheduler.yield(), whose continuations outprioritize and starve them) —
148
+ // but WITHOUT the ~4ms nested-timer clamp, which added phantom wall time
149
+ // across the hundreds of yields of a large audit.
150
+ const yieldChannel = typeof MessageChannel === 'function' ? new MessageChannel() : null;
151
+ const nextTask = () => new Promise((resolve) => {
152
+ if (!yieldChannel) { setTimeout(resolve, 0); return; }
153
+ yieldChannel.port1.onmessage = resolve; // one yield in flight at a time
154
+ yieldChannel.port2.postMessage(0);
155
+ });
156
+ const yieldToRenderer = async (beforeYield) => {
157
+ if (performance.now() - lastYield < 16) return;
158
+ // Runs only when a real yield (and so a paint) follows — the natural
159
+ // throttle for mid-rule progress reports.
160
+ beforeYield?.();
161
+ await nextTask();
162
+ lastYield = performance.now();
163
+ };
164
+
165
+ for (const rule of activeRules) {
166
+ // Between rules is the abort granularity we have — the same boundary
167
+ // the renderer yield uses.
168
+ if (options.signal?.aborted) throw new DOMException('Audit stopped', 'AbortError');
169
+ await yieldToRenderer();
170
+ // Announce BEFORE running: the UI label must show the rule currently
171
+ // under test, not the one that just finished (a slow rule would
172
+ // otherwise sit blamed on its predecessor's name).
173
+ onProgress?.({ done: completed, total: activeRules.length, rule: rule.id, running: true });
174
+ const ruleStarted = performance.now();
175
+
176
+ let elements = elementsFor(rule.selector);
177
+ if (options.exclude) {
178
+ elements = elements.filter((element) => !isExcluded(element, options.exclude));
179
+ }
180
+ if (rule.visibleOnly !== false) {
181
+ // visibility: 'visual' → rendered on screen counts even inside
182
+ // aria-hidden (visual rules); default → must be exposed to AT too.
183
+ elements = elements.filter(rule.visibility === 'visual' ? isRendered : isVisible);
184
+ }
185
+
186
+ let outcomes;
187
+ if (rule.evaluateAll) {
188
+ outcomes = rule.evaluateAll(elements, ruleHelpers);
189
+ } else {
190
+ // Evaluate sequentially with periodic yields so ONE heavy rule on a
191
+ // huge element set can't freeze the page for its whole duration —
192
+ // and so a Stop lands mid-rule, not only between rules.
193
+ outcomes = new Array(elements.length);
194
+ for (let i = 0; i < elements.length; i++) {
195
+ if ((i & 31) === 0 && i > 0) {
196
+ if (options.signal?.aborted) throw new DOMException('Audit stopped', 'AbortError');
197
+ // Fractional done: this rule's slice of the bar fills as its
198
+ // elements are evaluated instead of jumping on completion.
199
+ await yieldToRenderer(() => onProgress?.({
200
+ done: completed + i / elements.length,
201
+ total: activeRules.length,
202
+ rule: rule.id,
203
+ running: true,
204
+ }));
205
+ }
206
+ outcomes[i] = await rule.evaluate(elements[i], ruleHelpers);
207
+ }
208
+ }
209
+
210
+ // Only failures and review findings serialize element details — pass
211
+ // nodes are counted, not rendered: nothing consumes them, and building
212
+ // cssPath/snippets for every passing element dominated audit time on
213
+ // large pages.
214
+ const buckets = { fail: [], incomplete: [] };
215
+ let passCount = 0;
216
+ elements.forEach((element, i) => {
217
+ const status = outcomes[i].status;
218
+ if (status === 'pass') passCount += 1;
219
+ else buckets[status]?.push(toResultNode(element, outcomes[i]));
220
+ });
221
+
222
+ const ruleResult = {
223
+ id: rule.id,
224
+ impact: rule.impact,
225
+ tags: rule.tags,
226
+ help: rule.help,
227
+ description: rule.help,
228
+ helpUrl: rule.helpUrl,
229
+ };
230
+ if (draft30) {
231
+ // Which draft guideline this 2.x rule speaks to (null for rules with
232
+ // no draft counterpart, e.g. best-practice extras).
233
+ ruleResult.wcag30 = wcag30Draft.guidelineForTags(rule.tags);
234
+ }
235
+ if (!elements.length) results.inapplicable.push({ ...ruleResult, nodes: [] });
236
+ if (buckets.fail.length) results.violations.push({ ...ruleResult, nodes: buckets.fail });
237
+ if (buckets.incomplete.length) results.incomplete.push({ ...ruleResult, nodes: buckets.incomplete });
238
+ if (passCount) results.passes.push({ ...ruleResult, nodes: [], nodeCount: passCount });
239
+
240
+ liveCounts[rule.impact] = (liveCounts[rule.impact] ?? 0) + buckets.fail.length;
241
+ liveCounts.review += buckets.incomplete.length;
242
+ if (buckets.fail.length) liveTypeCounts[rule.impact] = (liveTypeCounts[rule.impact] ?? 0) + 1;
243
+ if (buckets.incomplete.length) liveTypeCounts.review += 1;
244
+ const ms = Math.round((performance.now() - ruleStarted) * 10) / 10;
245
+ results.ruleTimings.push({ rule: rule.id, ms, elements: elements.length });
246
+ onProgress?.({
247
+ done: ++completed,
248
+ total: activeRules.length,
249
+ rule: rule.id,
250
+ ms,
251
+ counts: { ...liveCounts },
252
+ typeCounts: { ...liveTypeCounts },
253
+ });
254
+ }
255
+
256
+ results.durationMs = Math.round(performance.now() - auditStarted);
257
+ return results;
258
+ }