pour-engine 1.33.0 → 1.34.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 (65) hide show
  1. package/config/project.config.js +1 -1
  2. package/engine/COVERAGE.md +5 -5
  3. package/engine/index.js +11 -4
  4. package/engine/lib/accessible-name.js +15 -4
  5. package/engine/lib/contrast.js +316 -11
  6. package/engine/lib/roles.js +30 -6
  7. package/engine/rules/best-practice/button-type.js +23 -2
  8. package/engine/rules/best-practice/fieldset-legend.js +7 -2
  9. package/engine/rules/best-practice/heading-order.js +2 -1
  10. package/engine/rules/best-practice/landmark-top-level.js +10 -0
  11. package/engine/rules/best-practice/landmark-unique.js +9 -1
  12. package/engine/rules/best-practice/new-window-link.js +34 -1
  13. package/engine/rules/best-practice/no-autofocus.js +15 -3
  14. package/engine/rules/best-practice/page-heading-one.js +15 -1
  15. package/engine/rules/best-practice/redundant-alt-phrase.js +9 -1
  16. package/engine/rules/best-practice/redundant-aria-label.js +24 -0
  17. package/engine/rules/best-practice/redundant-role.js +71 -20
  18. package/engine/rules/best-practice/region.js +6 -2
  19. package/engine/rules/index.js +13 -0
  20. package/engine/rules/wcag/1.1.1-input-image-alt.js +4 -1
  21. package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
  22. package/engine/rules/wcag/1.2.1-audio-transcript.js +47 -0
  23. package/engine/rules/wcag/1.2.2-media-captions.js +16 -1
  24. package/engine/rules/wcag/1.3.1-aria-required-children.js +30 -3
  25. package/engine/rules/wcag/1.3.1-aria-required-parent.js +25 -9
  26. package/engine/rules/wcag/1.3.1-definition-list.js +15 -3
  27. package/engine/rules/wcag/1.3.1-dlitem-parent.js +14 -1
  28. package/engine/rules/wcag/1.3.1-form-label.js +39 -1
  29. package/engine/rules/wcag/1.3.1-listitem-parent.js +23 -7
  30. package/engine/rules/wcag/1.3.1-table-headers.js +34 -3
  31. package/engine/rules/wcag/1.3.2-reading-order-divergence.js +69 -0
  32. package/engine/rules/wcag/1.3.4-orientation.js +21 -7
  33. package/engine/rules/wcag/1.3.5-autocomplete-valid.js +87 -13
  34. package/engine/rules/wcag/1.4.1-link-in-text-block.js +224 -17
  35. package/engine/rules/wcag/1.4.11-non-text-contrast.js +27 -3
  36. package/engine/rules/wcag/1.4.2-audio-control.js +45 -14
  37. package/engine/rules/wcag/1.4.3-color-contrast.js +337 -47
  38. package/engine/rules/wcag/1.4.3-control-contrast.js +26 -5
  39. package/engine/rules/wcag/2.2.1-meta-refresh.js +25 -1
  40. package/engine/rules/wcag/2.2.2-pause-stop-hide.js +26 -3
  41. package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -5
  42. package/engine/rules/wcag/2.4.2-document-title.js +8 -2
  43. package/engine/rules/wcag/2.4.3-visual-order-divergence.js +6 -4
  44. package/engine/rules/wcag/2.4.4-link-name.js +11 -0
  45. package/engine/rules/wcag/2.4.4-link-text-generic.js +22 -1
  46. package/engine/rules/wcag/2.4.7-focus-visible.js +47 -10
  47. package/engine/rules/wcag/2.4.9-link-text-generic-only.js +36 -5
  48. package/engine/rules/wcag/2.5.3-label-in-name.js +14 -5
  49. package/engine/rules/wcag/2.5.5-target-size-enhanced.js +3 -1
  50. package/engine/rules/wcag/2.5.7-drag-alternative.js +38 -0
  51. package/engine/rules/wcag/2.5.8-target-size.js +122 -3
  52. package/engine/rules/wcag/3.1.1-html-lang.js +12 -1
  53. package/engine/rules/wcag/3.2.2-on-input-navigation.js +18 -8
  54. package/engine/rules/wcag/3.3.7-redundant-entry.js +55 -0
  55. package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +50 -20
  56. package/engine/rules/wcag/4.1.2-aria-attr-valid.js +49 -13
  57. package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +6 -1
  58. package/engine/rules/wcag/4.1.2-aria-valid-refs.js +44 -73
  59. package/engine/rules/wcag/4.1.2-button-name.js +11 -0
  60. package/engine/rules/wcag/4.1.2-composite-widget-name.js +12 -6
  61. package/engine/rules/wcag/4.1.2-label-for-valid.js +41 -8
  62. package/engine/rules/wcag/4.1.2-nested-interactive.js +18 -1
  63. package/engine/rules/wcag/4.1.2-valid-role.js +16 -0
  64. package/engine/wcag22.js +4 -4
  65. package/package.json +1 -1
@@ -6,39 +6,16 @@ const REF_ATTRIBUTES = [
6
6
  'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
7
7
  ];
8
8
 
9
- // Deferred-tooltip pattern: libraries (Deque's own docs component included)
10
- // write aria-describedby="tooltip7" at rest and only CREATE #tooltip7 when
11
- // the trigger is hovered or focused — which is exactly when a screen reader
12
- // announces the description, so the pattern works. Only DESCRIPTIVE refs
13
- // get this benefit of the doubt: a NAME that exists only on hover is broken
14
- // for rotor navigation even when the deferral works, so aria-labelledby
15
- // (and the rest) keep failing at rest.
16
- const PROBEABLE = new Set(['aria-describedby', 'aria-details']);
17
- // A handful of probes covers any real page; a template bug that dangles
18
- // fifty refs is broken enough that the un-probed remainder failing as
19
- // before is the right answer.
20
- const PROBE_BUDGET = 8;
21
- const PROBE_WAIT_MS = 250;
22
-
23
- /** Fire the events a tooltip library listens for, wait for it to react,
24
- * check whether the ids materialised, then put the page back. */
25
- async function probeDeferred(element, ids) {
26
- const over = ['pointerover', 'mouseover', 'mouseenter'];
27
- const out = ['pointerout', 'mouseout', 'mouseleave'];
28
- for (const type of over) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseenter' }));
29
- element.dispatchEvent(new FocusEvent('focusin', { bubbles: true }));
30
- try { element.focus?.({ preventScroll: true }); } catch { /* non-focusable */ }
31
- await new Promise((resolve) => setTimeout(resolve, PROBE_WAIT_MS));
32
- const root = element.getRootNode();
33
- const appeared = ids.every((id) => root.getElementById?.(id));
34
- // Restore: the audit must leave the page as it found it, and later rules
35
- // must not meet a tooltip that only exists because we hovered.
36
- for (const type of out) element.dispatchEvent(new MouseEvent(type, { bubbles: type !== 'mouseleave' }));
37
- element.dispatchEvent(new FocusEvent('focusout', { bubbles: true }));
38
- try { element.blur?.(); } catch { /* ignore */ }
39
- await new Promise((resolve) => setTimeout(resolve, 50));
40
- return appeared;
41
- }
9
+ // No hover/focus probe (removed 2026-08-26, David). From 1.2.81 to this
10
+ // change the rule dispatched hover and focus events at elements whose
11
+ // aria-describedby pointed nowhere, to catch tooltip libraries that create
12
+ // the target on demand. That mutated the page under audit: focus was taken
13
+ // and not given back, hover-driven menus opened, page scripts ran, mobile
14
+ // keyboards popped, and 300 ms went by per element. Once a dead describedby
15
+ // became a review rather than a fail (same morning) the probe only bought
16
+ // fewer review rows, which is not worth an audit that is no longer
17
+ // read-only. The deferred-tooltip pattern is now named in the review
18
+ // message instead, for the human to confirm.
42
19
 
43
20
  function inspect(element) {
44
21
  // ARIA id references cannot cross shadow boundaries: they resolve only
@@ -67,29 +44,42 @@ function inspect(element) {
67
44
  return { missing, ambiguous };
68
45
  }
69
46
 
70
- function outcome({ missing, ambiguous }, probed, element, accessibleName) {
47
+ function outcome({ missing, ambiguous }, element, accessibleName) {
71
48
  if (missing.length) {
72
49
  // A dangling ref is skipped by the accname computation, so the HARM
73
- // depends on what remains. A labelledby that dangles while the element
74
- // still names itself (contents, aria-label) is inert clutter — real,
75
- // fragile, validator-flagged, but announced correctly today. The same
76
- // dangle on an otherwise nameless element is the 4.1.2 failure proper:
77
- // names are consumed at REST (element lists, rotor), so the finding
78
- // says which of the two this is instead of one blanket accusation.
50
+ // depends on what remains, and 4.1.2 only cares about name, role and
51
+ // user-settable value (re-decided by David 2026-08-26 with axe-core's
52
+ // measured verdicts as the second opinion; the old "fragile,
53
+ // validator-flagged" argument was 4.1.1's, and 2.2 retired it):
54
+ // - aria-labelledby that dangles while the element still names
55
+ // itself from elsewhere: the name is fully determinable (accname
56
+ // step 2B processes the valid IDREFs), so it is not a finding here;
57
+ // - aria-labelledby that leaves the element nameless, and
58
+ // aria-activedescendant (a focused composite reports the wrong
59
+ // active item): the 4.1.2 failure proper, asserted;
60
+ // - aria-describedby that points nowhere at rest: a description is
61
+ // not name, role or a user-set value, and tooltip libraries create
62
+ // the target on hover or focus, so the DOM proves an authoring
63
+ // error at most. Asked, never asserted;
64
+ // - aria-controls, aria-owns, aria-details, aria-errormessage: the
65
+ // standing policy (asserted; the comparator asserts these too).
79
66
  const restingName = (accessibleName?.(element) ?? '').trim();
80
- const detail = missing
81
- .map(({ attr, id }) => {
82
- if (attr === 'aria-labelledby') {
83
- return restingName
84
- ? `aria-labelledby="${id}" points to nothing — the reference is ignored and this element's name ("${restingName.slice(0, 40)}") currently comes from elsewhere; announced correctly today, but the dead reference is fragile`
85
- : `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`;
86
- }
87
- return `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
88
- })
89
- .join('; ');
67
+ const relevant = missing.filter(({ attr }) => !(attr === 'aria-labelledby' && restingName));
68
+ if (!relevant.length) return { status: 'pass' };
69
+ const line = ({ attr, id }) => attr === 'aria-labelledby'
70
+ ? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
71
+ : `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
72
+ const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby');
73
+ if (asserted.length) {
74
+ return {
75
+ status: 'fail',
76
+ message: `Broken ARIA references: ${relevant.map(line).join('; ')}.`,
77
+ fix: 'Correct or remove the reference, or give the target element that id.',
78
+ };
79
+ }
90
80
  return {
91
- status: 'fail',
92
- message: `Broken ARIA references: ${detail}.${probed ? ' Probed with focus and hover in case the target is created on demand — it never appeared.' : ''}`,
81
+ status: 'incomplete',
82
+ message: `${relevant.map(line).join('; ')} at rest. The element keeps its name and role, so this is not a proven 4.1.2 failure: if the description is created when the element is hovered or focused (a tooltip library), this works; otherwise correct the reference.`,
93
83
  fix: 'Correct or remove the reference, or give the target element that id.',
94
84
  };
95
85
  }
@@ -116,26 +106,7 @@ export default {
116
106
  help: 'ARIA id references must point to elements that exist',
117
107
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
118
108
  selector: REF_ATTRIBUTES.map((attr) => `[${attr}]`).join(', '),
119
- async evaluateAll(elements, { accessibleName } = {}) {
120
- let budget = PROBE_BUDGET;
121
- const outcomes = new Array(elements.length);
122
- for (let i = 0; i < elements.length; i++) {
123
- const element = elements[i];
124
- const found = inspect(element);
125
- const deferredOnly = found.missing.length > 0
126
- && found.missing.every(({ attr }) => PROBEABLE.has(attr));
127
- if (deferredOnly && budget > 0) {
128
- budget--;
129
- if (await probeDeferred(element, found.missing.map(({ id }) => id))) {
130
- // The description materialises exactly when it would be announced.
131
- outcomes[i] = { status: 'pass' };
132
- continue;
133
- }
134
- outcomes[i] = outcome(found, true, element, accessibleName);
135
- continue;
136
- }
137
- outcomes[i] = outcome(found, false, element, accessibleName);
138
- }
139
- return outcomes;
109
+ evaluate(element, { accessibleName } = {}) {
110
+ return outcome(inspect(element), element, accessibleName);
140
111
  },
141
112
  };
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
+ import { effectiveRole, implicitRole } from '../../lib/roles.js';
3
+
2
4
  export default {
3
5
  id: 'button-name',
4
6
  name: 'Button names',
@@ -8,6 +10,15 @@ export default {
8
10
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
9
11
  selector: 'button, [role="button"], input[type="button"], input[type="submit"], input[type="reset"]',
10
12
  evaluate(element, { accessibleName }) {
13
+ // Route by EFFECTIVE role (2026-08-25 overnight audit): a <button
14
+ // role="tab"> or <button role="link"> is exposed under that role, so its
15
+ // missing name belongs to the rule that speaks for that role
16
+ // (composite-widget-name, link-name). This rule owns every element whose
17
+ // effective role is button, whatever its tag: <a href role="button">
18
+ // included, which used to draw a finding here AND in link-name. An
19
+ // unknown explicit role falls back to the native role.
20
+ const role = effectiveRole(element) ?? implicitRole(element);
21
+ if (role !== 'button') return { status: 'pass' };
11
22
  if (accessibleName(element)) return { status: 'pass' };
12
23
  return {
13
24
  status: 'fail',
@@ -8,6 +8,10 @@
8
8
  // The container roles (tablist, menu, listbox, tree) are name-recommended
9
9
  // rather than required and are deliberately not judged here; the items are
10
10
  // where the binary, assertable failure lives.
11
+ import { effectiveRole } from '../../lib/roles.js';
12
+
13
+ const ITEM_ROLES = new Set(['tab', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'treeitem']);
14
+
11
15
  export default {
12
16
  id: 'composite-widget-name',
13
17
  name: 'Widget item names',
@@ -17,13 +21,15 @@ export default {
17
21
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
18
22
  selector: '[role="tab"], [role="menuitem"], [role="menuitemcheckbox"], [role="menuitemradio"], [role="option"], [role="treeitem"]',
19
23
  evaluate(element, { accessibleName }) {
20
- // <button role="tab"> and <a href role="tab"> are already selected by
21
- // button-name/link-name (their selectors match the tag regardless of
22
- // the role override) — one nameless control must not become two
23
- // findings, so this rule owns only the div/span/li-style hosts.
24
- if (element.matches('button, a[href], input, select, textarea, summary')) return { status: 'pass' };
24
+ // This rule owns every element whose EFFECTIVE role is one of the six,
25
+ // <button role="tab"> and <a href role="tab"> included: button-name and
26
+ // link-name defer to the effective role since 2026-08-25 (overnight
27
+ // audit), so the tab wording now reaches the tab, and one nameless
28
+ // control still yields one finding. A host whose role resolves elsewhere
29
+ // (presentation discarded back to a native role, say) is not ours.
30
+ const role = effectiveRole(element);
31
+ if (!ITEM_ROLES.has(role)) return { status: 'pass' };
25
32
  if (accessibleName(element)) return { status: 'pass' };
26
- const role = element.getAttribute('role');
27
33
  return {
28
34
  status: 'fail',
29
35
  message: `This ${role} has no accessible name — a screen reader announces an unnamed ${role}, and users cannot tell what choosing it does.`,
@@ -1,5 +1,25 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
- const LABELABLE = 'input, select, textarea, button, meter, output, progress';
2
+ //
3
+ // Rewritten 2026-08-25 overnight audit, two decisions:
4
+ //
5
+ // 1. The browser decides what is labelable, not a tag list. HTML defines
6
+ // the labeled control as "the first element in tree order whose ID is
7
+ // equal to the value of the for attribute, if it is a labelable element",
8
+ // and exposes exactly that resolution as label.control. The list this
9
+ // rule used to keep missed form-associated custom elements (HTML lists
10
+ // them as labelable) and admitted input type=hidden (HTML does not).
11
+ //
12
+ // 2. A label is not a user interface component. 4.1.2 asks that every
13
+ // component's name and role be determinable; a caption whose for points
14
+ // at nothing, or at a paragraph, harms no component by itself. Where a
15
+ // real control was left nameless, form-label asserts on that control, so
16
+ // asserting here too was one fault reported twice, and where no control
17
+ // was ever meant, it was a fault reported that does not exist. The rule
18
+ // now asks. It still asserts the one shape the DOM proves: a label that
19
+ // WRAPS a labelable control while its for points elsewhere, because
20
+ // HTML's for rule overrides the wrapping and the wrapped control provably
21
+ // loses its label.
22
+ const WRAPPABLE = 'input:not([type="hidden"]), select, textarea, button, meter, output, progress';
3
23
 
4
24
  export default {
5
25
  id: 'label-for-valid',
@@ -11,16 +31,29 @@ export default {
11
31
  selector: 'label[for]',
12
32
  evaluate(element) {
13
33
  const id = element.getAttribute('for');
14
- // id references resolve within the element's own root — a label in a
15
- // shadow tree can only point at controls in that same tree.
34
+ // `control` is the browser's own for-resolution: null when the id is
35
+ // missing or names something that is not labelable. It resolves within
36
+ // the label's own root, so a label in a shadow tree can only point at
37
+ // controls in that same tree.
38
+ if (element.control) return { status: 'pass' };
16
39
  const target = element.getRootNode().getElementById?.(id);
17
- if (target?.matches(LABELABLE)) return { status: 'pass' };
40
+ // A hidden input is not labelable, but nothing perceivable is harmed by
41
+ // a label that points at one (standing policy: this shape passes).
42
+ if (target && target.tagName === 'INPUT' && target.type === 'hidden') return { status: 'pass' };
43
+ const wrapped = element.querySelector(WRAPPABLE);
44
+ if (wrapped) {
45
+ return {
46
+ status: 'fail',
47
+ message: `This label wraps a <${wrapped.tagName.toLowerCase()}> but its for="${id}" ${target ? `points at a <${target.tagName.toLowerCase()}>, which is not labelable` : 'points at nothing'}. The for attribute overrides the wrapping, so the wrapped control is not labelled by this text.`,
48
+ fix: `Point the for attribute at the wrapped control's id, or remove the for attribute so the wrapping labels it.`,
49
+ };
50
+ }
18
51
  return {
19
- status: 'fail',
52
+ status: 'incomplete',
20
53
  message: target
21
- ? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which cannot be labelled — the association is ignored.`
22
- : `for="${id}" points at nothing — the label is not associated with any field.`,
23
- fix: 'Point the for attribute at the id of the input/select/textarea it labels.',
54
+ ? `for="${id}" points at a <${target.tagName.toLowerCase()}>, which is not a labelable element, so the browser ignores the association. Was a form control meant to be here? If so it has no label from this text (form-label reports the control itself); if this text captions something that is not a control, nothing is affected.`
55
+ : `for="${id}" points at nothing, so this label is not associated with any control. Was a form control meant to be here? If so it has lost its label (form-label reports the control itself); if this is leftover markup, no one is affected.`,
56
+ fix: 'Point the for attribute at the id of the control it labels, or remove the attribute.',
24
57
  };
25
58
  },
26
59
  };
@@ -26,12 +26,29 @@ 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.
29
33
  const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
30
- !el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
34
+ !el.matches(':disabled') && !(el.hasAttribute('tabindex') && el.tabIndex < 0)
31
35
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el)
32
36
  && !el.closest('[aria-hidden="true"]')
33
37
  && (el.matches(NATIVE) || el.hasAttribute('tabindex')));
34
38
  if (!nested) return { status: 'pass' };
39
+ // <summary> is the one outer control HTML does not forbid this on: its
40
+ // content model is phrasing content with no "no interactive content
41
+ // descendant" clause (a and button both carry one), it maps to no ARIA
42
+ // role that is Children Presentational, and browsers keep a link inside
43
+ // it as a link. Both controls have their name and role, so nothing is
44
+ // announced wrongly; whether Enter on the link versus the disclosure
45
+ // confuses is a question for a person. 2026-08-25 overnight audit.
46
+ if (element.tagName === 'SUMMARY') {
47
+ return {
48
+ status: 'incomplete',
49
+ message: `This summary contains another interactive element (<${nested.tagName.toLowerCase()}>). HTML allows it and both controls keep their name and role, but the disclosure and the control inside it are separate keyboard stops in one label. Check that activating each does what a user expects.`,
50
+ };
51
+ }
35
52
  return {
36
53
  status: 'fail',
37
54
  message: `This ${element.tagName.toLowerCase()} contains another interactive element (<${nested.tagName.toLowerCase()}>) — focus order and announcements become unpredictable.`,
@@ -57,6 +57,22 @@ export default {
57
57
  const hasAriaProps = [...element.attributes].some(
58
58
  (attr) => attr.name.startsWith('aria-') && attr.name !== 'aria-hidden');
59
59
  if (genericFallback && !focusable && !hasAriaProps) return { status: 'pass' };
60
+ // The same two triggers decide for EVERY tag (2026-08-25 overnight
61
+ // audit). ARIA 1.2 section 9.1: an unrecognised role token is treated as
62
+ // if no role had been provided, so <h2 role="text"> is exposed exactly
63
+ // as <h2>. Name, role and value are all intact and determinable; what
64
+ // the DOM cannot tell is what the author meant, so when the element is
65
+ // not focusable and carries no ARIA state on the back of the bogus role
66
+ // this is a question, not an assertion. Focusable or state-bearing
67
+ // elements keep failing: a focusable element left generic, or a state
68
+ // riding on a role the browser threw away, is the real 4.1.2 harm.
69
+ if (!focusable && !hasAriaProps) {
70
+ return {
71
+ status: 'incomplete',
72
+ message: `role="${element.getAttribute('role')}" is not a valid ARIA role, 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.`,
73
+ fix: 'Use a valid role from the ARIA specification, or remove the attribute to keep the element’s native role.',
74
+ };
75
+ }
60
76
  return {
61
77
  status: 'fail',
62
78
  message: `role="${element.getAttribute('role')}" is not a valid ARIA role, so assistive technology ignores it${genericFallback ? '' : ` and falls back to the element’s native <${tag}> semantics`}.`,
package/engine/wcag22.js CHANGED
@@ -25,7 +25,7 @@ const sc = (num, name, level, automation, since = '2.0') => ({
25
25
  export default [
26
26
  // 1. Perceivable
27
27
  sc('1.1.1', 'Non-text Content', 'A', 'partial'),
28
- sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'manual'),
28
+ sc('1.2.1', 'Audio-only and Video-only (Prerecorded)', 'A', 'partial'), // partial: the audio-only transcript question is askable; silent-video detection is not
29
29
  sc('1.2.2', 'Captions (Prerecorded)', 'A', 'partial'),
30
30
  sc('1.2.3', 'Audio Description or Media Alternative (Prerecorded)', 'A', 'manual'),
31
31
  sc('1.2.4', 'Captions (Live)', 'AA', 'manual'),
@@ -35,7 +35,7 @@ export default [
35
35
  sc('1.2.8', 'Media Alternative (Prerecorded)', 'AAA', 'manual'),
36
36
  sc('1.2.9', 'Audio-only (Live)', 'AAA', 'manual'),
37
37
  sc('1.3.1', 'Info and Relationships', 'A', 'partial'),
38
- sc('1.3.2', 'Meaningful Sequence', 'A', 'manual'),
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
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
@@ -86,7 +86,7 @@ export default [
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'),
89
- sc('2.5.7', 'Dragging Movements', 'AA', 'manual', '2.2'),
89
+ sc('2.5.7', 'Dragging Movements', 'AA', 'partial', '2.2'), // partial: declared draggable UI is visible; pointer-event drag logic is not
90
90
  sc('2.5.8', 'Target Size (Minimum)', 'AA', 'partial', '2.2'), // partial: size/spacing are computable; the equivalent-control and essential exceptions need judgment
91
91
  // 3. Understandable
92
92
  sc('3.1.1', 'Language of Page', 'A', 'partial'), // partial: html-lang proves the tag is present and well-formed; whether it names the language actually written needs a reader
@@ -107,7 +107,7 @@ export default [
107
107
  sc('3.3.4', 'Error Prevention (Legal, Financial, Data)', 'AA', 'manual'),
108
108
  sc('3.3.5', 'Help', 'AAA', 'manual'),
109
109
  sc('3.3.6', 'Error Prevention (All)', 'AAA', 'manual'),
110
- sc('3.3.7', 'Redundant Entry', 'A', 'manual', '2.2'),
110
+ sc('3.3.7', 'Redundant Entry', 'A', 'partial', '2.2'), // partial: duplicate autocomplete purposes in one form are visible; cross-page processes are not
111
111
  sc('3.3.8', 'Accessible Authentication (Minimum)', 'AA', 'partial', '2.2'), // partial: auth-field-obstruction catches paste blocking; alternatives need judgment
112
112
  sc('3.3.9', 'Accessible Authentication (Enhanced)', 'AAA', 'manual', '2.2'),
113
113
  // 4. Robust (4.1.1 Parsing was removed in WCAG 2.2)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.33.0",
3
+ "version": "1.34.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",