pour-engine 1.38.0 → 1.41.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 (50) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/AUDIT.html +24 -690
  3. package/engine/COVERAGE.md +4 -2
  4. package/engine/README.md +9 -4
  5. package/engine/index.js +34 -8
  6. package/engine/lib/accessible-name.js +81 -64
  7. package/engine/lib/contrast.js +111 -10
  8. package/engine/lib/dom.js +78 -18
  9. package/engine/lib/roles.js +18 -3
  10. package/engine/rules/best-practice/fieldset-legend.js +11 -5
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/region.js +10 -2
  13. package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
  14. package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
  15. package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
  16. package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
  17. package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
  18. package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
  19. package/engine/rules/wcag/1.3.1-form-label.js +12 -29
  20. package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
  21. package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
  22. package/engine/rules/wcag/1.3.4-orientation.js +117 -48
  23. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
  24. package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
  25. package/engine/rules/wcag/1.4.10-reflow.js +3 -3
  26. package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
  27. package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
  28. package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
  29. package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
  30. package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
  31. package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
  32. package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
  33. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
  34. package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
  35. package/engine/rules/wcag/2.4.2-document-title.js +7 -8
  36. package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
  37. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
  38. package/engine/rules/wcag/2.5.8-target-size.js +201 -47
  39. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
  40. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
  41. package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
  42. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
  43. package/engine/rules/wcag/4.1.2-button-name.js +1 -1
  44. package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
  45. package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
  46. package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
  47. package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
  48. package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
  49. package/engine/wcag22.js +7 -7
  50. package/package.json +1 -1
@@ -1,4 +1,5 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { isInert } from '../../lib/dom.js';
2
3
  // Deliberately excludes bare [tabindex]: a focusable container (e.g. a
3
4
  // scrollable region with tabindex="0") holding links is correct markup.
4
5
  const INTERACTIVE =
@@ -16,8 +17,7 @@ export default {
16
17
  selector: INTERACTIVE,
17
18
  evaluate(element, { isRendered }) {
18
19
  // Only descendants a user can actually reach count: hidden inputs,
19
- // disabled controls, display:none templates, and tabindex="-1" nodes
20
- // inside a control are inert markup, not competing tab stops.
20
+ // disabled controls and display:none templates are excluded.
21
21
  // Two more shapes are unreachable by the same principle: aria-hidden
22
22
  // content (AT ignores it wholesale — a decorative svg role="button"
23
23
  // aria-hidden="true" inside a play button competes with nothing; if it
@@ -26,16 +26,28 @@ export default {
26
26
  // role="button" with no tabindex is not in the tab order — keyboard
27
27
  // users cannot reach it, so it cannot be a competing stop).
28
28
  const NATIVE = 'a[href], button, input, select, textarea, summary, audio[controls], video[controls]';
29
- // Any negative tabindex is unreachable by Tab (HTML: "a negative
30
- // integer"), not only the literal "-1"; hasAttribute guards the default
31
- // -1 Chromium reports for attribute-less contenteditable elements.
32
- // 2026-08-25 overnight audit.
33
- const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
34
- !el.matches(':disabled') && !(el.hasAttribute('tabindex') && el.tabIndex < 0)
29
+ // Negative tabindex removes sequential focus navigation, not focusability.
30
+ // https://html.spec.whatwg.org/multipage/interaction.html#attr-tabindex
31
+ const candidates = [...element.querySelectorAll(INTERACTIVE)].filter((el) =>
32
+ !el.matches(':disabled') && !isInert(el)
35
33
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
36
34
  && !el.closest('[aria-hidden="true"]')
37
35
  && (el.matches(NATIVE) || el.hasAttribute('tabindex')));
36
+ // A child requiring review must not conceal a later definite finding.
37
+ const nested = candidates.find((el) => !(el.hasAttribute('tabindex') && el.tabIndex < 0)) ?? candidates[0];
38
38
  if (!nested) return { status: 'pass' };
39
+ // A programmatically focusable child must not silently pass. In an
40
+ // ARIA widget its name and role can survive presentational-descendant
41
+ // processing, so nesting alone does not prove a 4.1.2 failure.
42
+ // Native links/buttons separately forbid interactive descendants.
43
+ // https://www.w3.org/TR/wai-aria-1.2/#tree_exclusion
44
+ if (nested.hasAttribute('tabindex') && nested.tabIndex < 0
45
+ && !element.matches('a[href], button')) {
46
+ return {
47
+ status: 'incomplete',
48
+ message: `This control contains an element (<${nested.tagName.toLowerCase()}>) with a negative tabindex. It can still receive focus. Check that both controls expose the intended name and role, and that focusing and activating the child works correctly.`,
49
+ };
50
+ }
39
51
  // <summary> is the one outer control HTML does not forbid this on: its
40
52
  // content model is phrasing content with no "no interactive content
41
53
  // descendant" clause (a and button both carry one), it maps to no ARIA
@@ -1,5 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  import { attributesOf } from '../../lib/dom.js';
3
+ import { implicitRole } from '../../lib/roles.js';
3
4
  // Concrete, usable roles — abstract roles are invalid in markup.
4
5
  export const VALID_ROLES = new Set([
5
6
  'alert', 'alertdialog', 'application', 'article', 'banner', 'blockquote', 'button',
@@ -108,7 +109,11 @@ export default {
108
109
  // this is a question, not an assertion. Focusable or state-bearing
109
110
  // elements keep failing: a focusable element left generic, or a state
110
111
  // riding on a role the browser threw away, is the real 4.1.2 harm.
111
- if (!focusable && !hasAriaProps) {
112
+ // An invalid role does not erase a native control's semantics. A
113
+ // misspelling on a button is a review question; its actual missing
114
+ // name or state, if any, belongs to the corresponding semantic rule.
115
+ const nativeRole = implicitRole(element);
116
+ if ((!focusable && !hasAriaProps) || (nativeRole && nativeRole !== 'generic')) {
112
117
  return {
113
118
  status: 'incomplete',
114
119
  message: `role="${element.getAttribute('role')}" is not a valid ARIA role${hint}, so assistive technology ignores it and exposes the element's native <${tag}> semantics. Nothing is announced wrongly, but the author reached for a role that does not exist. Is the native <${tag}> role the right one here? If a different role was intended, that role is missing.`,
package/engine/wcag22.js CHANGED
@@ -4,8 +4,8 @@
4
4
  //
5
5
  // `automation` is an honest assessment:
6
6
  // 'auto' — rules can meaningfully test this
7
- // 'partial' — rules catch some failures; humans must still review
8
- // 'manual' — requires human judgment (most of the spec!)
7
+ // 'partial' — rules check aspects or nominate review candidates; humans still review
8
+ // 'manual' — no active rule; assessed through the human checklist
9
9
  // The coverage report (scripts/wcag-coverage.js → src/engine/COVERAGE.md) joins
10
10
  // this catalog with the rule registry to show real coverage.
11
11
 
@@ -37,11 +37,11 @@ export default [
37
37
  sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
38
38
  sc('1.3.2', 'Meaningful Sequence', 'A', 'partial'), // partial: CSS-reorder divergence is measurable; whether meaning survives is the reader's call
39
39
  sc('1.3.3', 'Sensory Characteristics', 'A', 'manual'),
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
- sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are provable; MISSING autocomplete on identity fields needs judgment
40
+ sc('1.3.4', 'Orientation', 'AA', 'partial', '2.1'), // orientation-lock asserts CSS root hides/rotations as ACT b33eff does, essential exception assumed absent; script locks stay a human check
41
+ sc('1.3.5', 'Identify Input Purpose', 'AA', 'partial', '2.1'), // partial: wrong tokens are asserted as ACT 73f2c2 does (search boxes asked); 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
44
- sc('1.4.2', 'Audio Control', 'A', 'partial'), // partial: declarative autoplay is provable; JS-initiated audio is not
44
+ sc('1.4.2', 'Audio Control', 'A', 'partial'), // declarative autoplay identifies candidates; audibility and alternative controls need review
45
45
  sc('1.4.3', 'Contrast (Minimum)', 'AA', 'partial'),
46
46
  sc('1.4.4', 'Resize Text', 'AA', 'partial'),
47
47
  sc('1.4.5', 'Images of Text', 'AA', 'manual'),
@@ -75,9 +75,9 @@ export default [
75
75
  sc('2.4.6', 'Headings and Labels', 'AA', 'manual'),
76
76
  sc('2.4.7', 'Focus Visible', 'AA', 'partial'), // partial: focus-visible flags outline suppression; the indicator itself needs eyes
77
77
  sc('2.4.8', 'Location', 'AAA', 'manual'),
78
- sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), // partial: contentless link names are provable; whether a real name describes its destination needs a reader
78
+ sc('2.4.9', 'Link Purpose (Link Only)', 'AAA', 'partial'), // generic wording nominates candidates; whether wording explains the destination needs a reader
79
79
  sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
80
- sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // partial: focus-not-obscured catches fully-covered targets at rest
80
+ sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // covered resting targets are candidates; actual focus/scroll behavior needs review
81
81
  sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
82
82
  sc('2.4.13', 'Focus Appearance', 'AAA', 'manual', '2.2'),
83
83
  sc('2.5.1', 'Pointer Gestures', 'A', 'manual', '2.1'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.38.0",
3
+ "version": "1.41.0",
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",