pour-engine 1.4.0 → 1.6.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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.4.0',
9
+ version: '1.6.0',
10
10
  },
11
11
  };
@@ -62,12 +62,12 @@ meaning that only a person can settle, even where a rule decides most real pages
62
62
  | 2.4.1 | [Bypass Blocks](https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html) | A | Partially automated | bypass-blocks |
63
63
  | 2.4.2 | [Page Titled](https://www.w3.org/WAI/WCAG22/Understanding/page-titled.html) | A | Partially automated | document-title |
64
64
  | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
65
- | 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 |
65
+ | 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, link-text-generic |
66
66
  | 2.4.5 | [Multiple Ways](https://www.w3.org/WAI/WCAG22/Understanding/multiple-ways.html) | AA | Manual review | — |
67
67
  | 2.4.6 | [Headings and Labels](https://www.w3.org/WAI/WCAG22/Understanding/headings-and-labels.html) | AA | Manual review | — |
68
68
  | 2.4.7 | [Focus Visible](https://www.w3.org/WAI/WCAG22/Understanding/focus-visible.html) | AA | Partially automated | focus-visible |
69
69
  | 2.4.8 | [Location](https://www.w3.org/WAI/WCAG22/Understanding/location.html) | AAA | Manual review | — |
70
- | 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA | Manual review | — |
70
+ | 2.4.9 | [Link Purpose (Link Only)](https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html) | AAA | Partially automated | link-text-generic-only |
71
71
  | 2.4.10 | [Section Headings](https://www.w3.org/WAI/WCAG22/Understanding/section-headings.html) | AAA | Manual review | — |
72
72
  | 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 |
73
73
  | 2.4.12 | [Focus Not Obscured (Enhanced)](https://www.w3.org/WAI/WCAG22/Understanding/focus-not-obscured-enhanced.html) | AAA | Manual review | — |
@@ -114,4 +114,4 @@ meaning that only a person can settle, even where a rule decides most real pages
114
114
  | 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 |
115
115
  | 4.1.3 | [Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html) | AA | Manual review | — |
116
116
 
117
- **Totals:** 29 partially automated · 57 manual — with 72 rules.
117
+ **Totals:** 30 partially automated · 56 manual — with 74 rules.
package/engine/README.md CHANGED
@@ -108,7 +108,7 @@ Nothing in the spec is silently skipped — that claim is the product.
108
108
 
109
109
  ## Honest current state
110
110
 
111
- 72 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
111
+ 74 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
112
112
  open shadow roots (targets come back as `host >>> inner` selector chains);
113
113
  iframe traversal is the host's job, and the extension does it. Known gaps:
114
114
  the full accname spec (ours is a close approximation) and the language
@@ -2,19 +2,45 @@
2
2
  // the ARIA accname spec: aria-labelledby → aria-label → native labelling
3
3
  // (alt, <label>, text content, value) → title/placeholder fallbacks.
4
4
  //
5
- // Deliberately simpler than the full spec (no recursion into referenced
6
- // hidden subtrees, no CSS content). Good enough for name-presence rules;
7
- // grow it as rules need more.
5
+ // Deliberately simpler than the full spec. Good enough for name-presence
6
+ // rules; grow it as rules need more.
7
+
8
+ /** Input types whose `.value` is user-entered text rather than a state. */
9
+ const TEXT_INPUT = new Set(['text', 'search', 'url', 'tel', 'email', 'password', 'number', 'date',
10
+ 'datetime-local', 'month', 'time', 'week', '']);
8
11
 
9
12
  export function accessibleName(element) {
10
- const labelledby = element.getAttribute('aria-labelledby');
11
- if (labelledby) {
12
- const text = labelledby
13
- .split(/\s+/)
14
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
15
- .join(' ')
16
- .trim();
17
- if (text) return text;
13
+ return computeName(element, false);
14
+ }
15
+
16
+ /** The name aria-labelledby contributes, resolved the way accname requires:
17
+ * each referenced element's NAME, not its raw text. A reference whose target
18
+ * is named by an img alt, an aria-label or a control value names the referrer
19
+ * too, and reading textContent instead reports those elements as nameless.
20
+ * Rules that only need "is it labelled?" call this directly. */
21
+ export function labelledByName(element) {
22
+ const refs = element.getAttribute?.('aria-labelledby');
23
+ if (!refs) return '';
24
+ const root = element.getRootNode();
25
+ return refs
26
+ .split(/\s+/)
27
+ .filter(Boolean)
28
+ .map((id) => {
29
+ const target = root.getElementById?.(id);
30
+ return target ? computeName(target, true) : '';
31
+ })
32
+ .join(' ')
33
+ .replace(/\s+/g, ' ')
34
+ .trim();
35
+ }
36
+
37
+ function computeName(element, inLabelledBy) {
38
+ // accname step 2B: a referenced element's own aria-labelledby is not
39
+ // followed a second time. That is also what stops <p id="a"
40
+ // aria-labelledby="a"> recursing forever.
41
+ if (!inLabelledBy) {
42
+ const fromLabelledBy = labelledByName(element);
43
+ if (fromLabelledBy) return fromLabelledBy;
18
44
  }
19
45
 
20
46
  const ariaLabel = element.getAttribute('aria-label')?.trim();
@@ -42,6 +68,14 @@ export function accessibleName(element) {
42
68
  const alt = element.getAttribute('alt')?.trim();
43
69
  if (alt) return alt;
44
70
  }
71
+ // accname step 2E: an embedded control contributes its value, so
72
+ // aria-labelledby pointing at a filled text box names the referrer with
73
+ // what the user typed. Text-like fields only: a checkbox's `.value` is
74
+ // the string "on", which is a state, not a name.
75
+ if (inLabelledBy && (tag === 'textarea' || TEXT_INPUT.has(element.type))) {
76
+ const value = (element.value ?? '').trim();
77
+ if (value) return value;
78
+ }
45
79
  // HTML-AAM: value-less submit/reset buttons get a UA-default name —
46
80
  // browsers announce "Submit"/"Reset button" without any author text.
47
81
  if (element.type === 'submit') return 'Submit';
@@ -51,18 +85,21 @@ export function accessibleName(element) {
51
85
  // Buttons, links, headings etc. — name from contents, including image
52
86
  // alts, but EXCLUDING hidden content: text inside aria-hidden or
53
87
  // display:none/visibility:hidden subtrees does not name an element
54
- // (per the accname spec).
55
- const fromContents = visibleContentText(element).replace(/\s+/g, ' ').trim();
88
+ // (per the accname spec). The one exception is a subtree reached THROUGH
89
+ // aria-labelledby, where accname ignores the hidden state.
90
+ const fromContents = visibleContentText(element, inLabelledBy).replace(/\s+/g, ' ').trim();
56
91
  if (fromContents) return fromContents;
57
92
 
58
93
  return (element.getAttribute('title') ?? element.getAttribute('placeholder') ?? '').trim();
59
94
  }
60
95
 
61
- function visibleContentText(element) {
96
+ function visibleContentText(element, includeHidden) {
62
97
  // A custom element with a shadow root renders its shadow content — name
63
98
  // from contents follows the FLAT tree (slots inside pull light DOM back).
64
99
  const nodes = element.shadowRoot ? element.shadowRoot.childNodes : element.childNodes;
65
- return generatedContent(element, '::before') + textFromNodes(nodes) + generatedContent(element, '::after');
100
+ return generatedContent(element, '::before')
101
+ + textFromNodes(nodes, includeHidden)
102
+ + generatedContent(element, '::after');
66
103
  }
67
104
 
68
105
  /** CSS generated content contributes to name-from-contents per the accname
@@ -75,7 +112,10 @@ function generatedContent(element, pseudo) {
75
112
  return match ? match[1].replace(/\\(.)/g, '$1') : '';
76
113
  }
77
114
 
78
- function textFromNodes(nodes) {
115
+ /** `includeHidden` is set while resolving an aria-labelledby target: accname
116
+ * ignores the hidden state of referenced content, which is what makes the
117
+ * visually-hidden label span (and the display:none one) work in browsers. */
118
+ function textFromNodes(nodes, includeHidden) {
79
119
  let text = '';
80
120
  for (const node of nodes) {
81
121
  if (node.nodeType === 3 /* TEXT_NODE */) {
@@ -89,15 +129,17 @@ function textFromNodes(nodes) {
89
129
  // on, and sites can override the UA's display:none (seen in the wild
90
130
  // with lazy-load <noscript> fallbacks leaking "<img …>" into names).
91
131
  if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
92
- if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
93
- const style = getComputedStyle(node);
94
- if (style.display === 'none' || style.visibility === 'hidden') continue;
132
+ if (!includeHidden) {
133
+ if (node.getAttribute('aria-hidden') === 'true' || node.hasAttribute('hidden')) continue;
134
+ const style = getComputedStyle(node);
135
+ if (style.display === 'none' || style.visibility === 'hidden') continue;
136
+ }
95
137
  // A slot renders its assigned light-DOM nodes (fallback children when
96
138
  // nothing is slotted) — a shadow <button><slot></slot></button> is named
97
139
  // by the text the page slots in.
98
140
  if (tag === 'slot') {
99
141
  const assigned = node.assignedNodes?.() ?? [];
100
- text += textFromNodes(assigned.length ? assigned : node.childNodes);
142
+ text += textFromNodes(assigned.length ? assigned : node.childNodes, includeHidden);
101
143
  continue;
102
144
  }
103
145
  if (tag === 'img' || tag === 'area') {
@@ -111,7 +153,7 @@ function textFromNodes(nodes) {
111
153
  }
112
154
  // Flat-tree descent: shadow content renders in place of a host's
113
155
  // light children (archive.org-style nested web components).
114
- const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes);
156
+ const fromSubtree = textFromNodes(node.shadowRoot ? node.shadowRoot.childNodes : node.childNodes, includeHidden);
115
157
  if (fromSubtree.trim()) {
116
158
  text += fromSubtree;
117
159
  continue;
@@ -24,57 +24,98 @@ export const GLOBAL_ARIA = new Set([
24
24
  'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
25
25
  ]);
26
26
 
27
- /** Attributes each role supports IN ADDITION to the globals. */
27
+ /** Attributes each role supports IN ADDITION to the globals.
28
+ *
29
+ * Includes the four attributes ARIA 1.2 removed from the GLOBAL set
30
+ * (aria-disabled, aria-errormessage, aria-haspopup, aria-invalid). The spec
31
+ * still lists them on every role that used to inherit them, marked deprecated
32
+ * on that role. Deprecated is not forbidden: the markup conforms and browsers
33
+ * map it, so rejecting it reports a fault on a correct page. Leaving them out
34
+ * cost 74 of the 81 roles here at least one false rejection. If deprecated
35
+ * usage is ever worth surfacing, it belongs in best-practice, not in a
36
+ * critical 4.1.2 failure. */
28
37
  export const ROLE_ARIA = {
29
- link: ['disabled', 'expanded', 'haspopup'],
30
- button: ['disabled', 'expanded', 'haspopup', 'pressed'],
31
- checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
32
- switch: ['checked', 'disabled', 'errormessage', 'expanded', 'invalid', 'readonly', 'required'],
33
- radio: ['checked', 'disabled', 'posinset', 'setsize'],
34
- option: ['selected', 'checked', 'disabled', 'posinset', 'setsize'],
35
- tab: ['selected', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
36
- menuitem: ['disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
37
- menuitemcheckbox: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
38
- menuitemradio: ['checked', 'disabled', 'expanded', 'haspopup', 'posinset', 'setsize'],
38
+ link: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
39
+ button: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'pressed'],
40
+ checkbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
41
+ switch: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
42
+ radio: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
43
+ option: ['checked', 'disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
44
+ tab: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'selected', 'setsize'],
45
+ menuitem: ['disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
46
+ menuitemcheckbox: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
47
+ menuitemradio: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'posinset', 'setsize'],
39
48
  textbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
40
49
  searchbox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiline', 'placeholder', 'readonly', 'required'],
41
50
  combobox: ['activedescendant', 'autocomplete', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required'],
42
- listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
51
+ listbox: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required'],
43
52
  slider: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
44
- spinbutton: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
45
- progressbar: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
46
- meter: ['valuemax', 'valuemin', 'valuenow', 'valuetext'],
47
- scrollbar: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
48
- heading: ['level'],
49
- list: [],
50
- listitem: ['level', 'posinset', 'setsize'],
51
- row: ['colindex', 'disabled', 'expanded', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
52
- rowgroup: [],
53
- cell: ['colindex', 'colspan', 'rowindex', 'rowspan'],
54
- gridcell: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected'],
55
- columnheader: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected', 'sort'],
56
- rowheader: ['colindex', 'colspan', 'rowindex', 'rowspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'selected', 'sort'],
57
- table: ['colcount', 'rowcount'],
58
- grid: ['activedescendant', 'colcount', 'disabled', 'multiselectable', 'readonly', 'rowcount'],
59
- treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
60
- tablist: ['activedescendant', 'disabled', 'multiselectable', 'orientation'],
61
- menu: ['activedescendant', 'disabled', 'orientation'],
62
- menubar: ['activedescendant', 'disabled', 'orientation'],
63
- tree: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'multiselectable', 'orientation', 'required'],
64
- treeitem: ['checked', 'disabled', 'expanded', 'haspopup', 'level', 'posinset', 'selected', 'setsize'],
65
- radiogroup: ['activedescendant', 'disabled', 'errormessage', 'invalid', 'orientation', 'readonly', 'required'],
66
- group: ['activedescendant', 'disabled'],
67
- separator: ['disabled', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
68
- toolbar: ['activedescendant', 'disabled', 'orientation'],
69
- dialog: ['modal'],
70
- alertdialog: ['modal'],
71
- application: ['activedescendant', 'disabled', 'expanded', 'haspopup'],
72
- article: ['posinset', 'setsize'],
73
- img: [], figure: [], document: [], feed: [], math: [], note: [], presentation: [], none: [],
74
- banner: [], complementary: [], contentinfo: [], form: [], main: [], navigation: [], region: [], search: [],
75
- alert: [], log: [], marquee: [], status: [], timer: [], tabpanel: [], tooltip: [],
76
- definition: [], term: [], paragraph: [], generic: [], blockquote: [], caption: [], code: [],
77
- emphasis: [], strong: [], time: [], deletion: [], insertion: [], subscript: [], superscript: [],
53
+ spinbutton: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'readonly', 'required', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
54
+ progressbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
55
+ meter: ['disabled', 'errormessage', 'haspopup', 'invalid', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
56
+ scrollbar: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
57
+ heading: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level'],
58
+ list: ['disabled', 'errormessage', 'haspopup', 'invalid'],
59
+ listitem: ['disabled', 'errormessage', 'haspopup', 'invalid', 'level', 'posinset', 'setsize'],
60
+ row: ['activedescendant', 'colindex', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'rowindex', 'selected', 'setsize'],
61
+ rowgroup: ['disabled', 'errormessage', 'haspopup', 'invalid'],
62
+ cell: ['colindex', 'colspan', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowindex', 'rowspan'],
63
+ gridcell: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected'],
64
+ columnheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
65
+ rowheader: ['colindex', 'colspan', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'readonly', 'required', 'rowindex', 'rowspan', 'selected', 'sort'],
66
+ table: ['colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'rowcount'],
67
+ grid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'readonly', 'rowcount'],
68
+ treegrid: ['activedescendant', 'colcount', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'readonly', 'required', 'rowcount'],
69
+ tablist: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation'],
70
+ menu: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
71
+ menubar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
72
+ tree: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'multiselectable', 'orientation', 'required'],
73
+ treeitem: ['checked', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid', 'level', 'posinset', 'selected', 'setsize'],
74
+ radiogroup: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'readonly', 'required'],
75
+ group: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid'],
76
+ separator: ['disabled', 'errormessage', 'haspopup', 'invalid', 'orientation', 'valuemax', 'valuemin', 'valuenow', 'valuetext'],
77
+ toolbar: ['activedescendant', 'disabled', 'errormessage', 'haspopup', 'invalid', 'orientation'],
78
+ dialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
79
+ alertdialog: ['disabled', 'errormessage', 'haspopup', 'invalid', 'modal'],
80
+ application: ['activedescendant', 'disabled', 'errormessage', 'expanded', 'haspopup', 'invalid'],
81
+ article: ['disabled', 'errormessage', 'haspopup', 'invalid', 'posinset', 'setsize'],
82
+ img: ['disabled', 'errormessage', 'haspopup', 'invalid'],
83
+ figure: ['disabled', 'errormessage', 'haspopup', 'invalid'],
84
+ document: ['disabled', 'errormessage', 'haspopup', 'invalid'],
85
+ feed: ['disabled', 'errormessage', 'haspopup', 'invalid'],
86
+ math: ['disabled', 'errormessage', 'haspopup', 'invalid'],
87
+ note: ['disabled', 'errormessage', 'haspopup', 'invalid'],
88
+ presentation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
89
+ none: ['disabled', 'errormessage', 'haspopup', 'invalid'],
90
+ banner: ['disabled', 'errormessage', 'haspopup', 'invalid'],
91
+ complementary: ['disabled', 'errormessage', 'haspopup', 'invalid'],
92
+ contentinfo: ['disabled', 'errormessage', 'haspopup', 'invalid'],
93
+ form: ['disabled', 'errormessage', 'haspopup', 'invalid'],
94
+ main: ['disabled', 'errormessage', 'haspopup', 'invalid'],
95
+ navigation: ['disabled', 'errormessage', 'haspopup', 'invalid'],
96
+ region: ['disabled', 'errormessage', 'haspopup', 'invalid'],
97
+ search: ['disabled', 'errormessage', 'haspopup', 'invalid'],
98
+ alert: ['disabled', 'errormessage', 'haspopup', 'invalid'],
99
+ log: ['disabled', 'errormessage', 'haspopup', 'invalid'],
100
+ marquee: ['disabled', 'errormessage', 'haspopup', 'invalid'],
101
+ status: ['disabled', 'errormessage', 'haspopup', 'invalid'],
102
+ timer: ['disabled', 'errormessage', 'haspopup', 'invalid'],
103
+ tabpanel: ['disabled', 'errormessage', 'haspopup', 'invalid'],
104
+ tooltip: ['disabled', 'errormessage', 'haspopup', 'invalid'],
105
+ definition: ['disabled', 'errormessage', 'haspopup', 'invalid'],
106
+ term: ['disabled', 'errormessage', 'haspopup', 'invalid'],
107
+ paragraph: ['disabled', 'errormessage', 'haspopup', 'invalid'],
108
+ generic: ['disabled', 'errormessage', 'haspopup', 'invalid'],
109
+ blockquote: ['disabled', 'errormessage', 'haspopup', 'invalid'],
110
+ caption: ['disabled', 'errormessage', 'haspopup', 'invalid'],
111
+ code: ['disabled', 'errormessage', 'haspopup', 'invalid'],
112
+ emphasis: ['disabled', 'errormessage', 'haspopup', 'invalid'],
113
+ strong: ['disabled', 'errormessage', 'haspopup', 'invalid'],
114
+ time: ['disabled', 'errormessage', 'haspopup', 'invalid'],
115
+ deletion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
116
+ insertion: ['disabled', 'errormessage', 'haspopup', 'invalid'],
117
+ subscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
118
+ superscript: ['disabled', 'errormessage', 'haspopup', 'invalid'],
78
119
  };
79
120
 
80
121
  const INPUT_ROLES = {
@@ -2,6 +2,7 @@
2
2
  // labels, and banner / contentinfo / main must be unique per page outright
3
3
  // (two "banners" are ambiguous however well they're labelled).
4
4
  import { implicitRole } from '../../lib/roles.js';
5
+ import { labelledByName } from '../../lib/accessible-name.js';
5
6
 
6
7
  const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
7
8
 
@@ -13,18 +14,11 @@ const landmarkRole = (element) =>
13
14
 
14
15
  // Landmarks are not name-from-content roles: only author-provided labels
15
16
  // distinguish them (the text inside a nav is its content, not its name).
16
- const landmarkName = (element) => {
17
- const labelledby = element.getAttribute('aria-labelledby');
18
- if (labelledby) {
19
- const text = labelledby
20
- .split(/\s+/)
21
- .map((id) => element.ownerDocument.getElementById(id)?.textContent ?? '')
22
- .join(' ')
23
- .trim();
24
- if (text) return text;
25
- }
26
- return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
27
- };
17
+ const landmarkName = (element) =>
18
+ labelledByName(element)
19
+ || element.getAttribute('aria-label')?.trim()
20
+ || element.getAttribute('title')?.trim()
21
+ || '';
28
22
 
29
23
  export default {
30
24
  id: 'landmark-unique',
@@ -15,6 +15,8 @@ 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
+ import linkTextGeneric from './wcag/2.4.4-link-text-generic.js';
19
+ import linkTextGenericOnly from './wcag/2.4.9-link-text-generic-only.js';
18
20
  // Re-parked by product decision (2026-08-01, David, after a one-day
19
21
  // un-parking): 2.5.3 is normative Level A but chiefly serves speech-input
20
22
  // users, and the findings volume isn't wanted in results for now. The SC is
@@ -113,6 +115,8 @@ export default [
113
115
  svgImgAlt,
114
116
  buttonName,
115
117
  linkName,
118
+ linkTextGeneric,
119
+ linkTextGenericOnly,
116
120
  // labelInName, — re-parked 2026-08-01, see note at the import
117
121
  formLabel,
118
122
  autocompleteValid,
@@ -2,6 +2,8 @@
2
2
  // Instructions (Level A) · SC 4.1.2 Name, Role, Value (Level A)
3
3
  // 3.3.2 mapping: a field with no label at all, or only a vanishing
4
4
  // placeholder, is failure F82 — the classic 3.3.2 pattern.
5
+ import { labelledByName } from '../../lib/accessible-name.js';
6
+
5
7
  export default {
6
8
  id: 'form-label',
7
9
  impact: 'critical',
@@ -30,12 +32,7 @@ export default {
30
32
  .join(' ')
31
33
  .trim();
32
34
  const ariaLabel = element.getAttribute('aria-label')?.trim();
33
- const labelledby = (element.getAttribute('aria-labelledby') ?? '')
34
- .split(/\s+/)
35
- .filter(Boolean)
36
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
37
- .join(' ')
38
- .trim();
35
+ const labelledby = labelledByName(element);
39
36
  if (labelsText || ariaLabel || labelledby) return { status: 'pass' };
40
37
 
41
38
  const id = element.id ? ` (it already has id="${element.id}")` : ' (give it an id first)';
@@ -22,7 +22,10 @@ export default {
22
22
  visibility: 'visual',
23
23
  evaluate(element) {
24
24
  // Inactive controls are exempt (same carve-out 1.4.3 makes).
25
- if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
25
+ // `:disabled`, not `element.disabled`: the property misses a control
26
+ // disabled by an ancestor <fieldset disabled>, which 1.4.11 exempts as an
27
+ // inactive user interface component.
28
+ if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
26
29
  // Zero-area controls (JS-widget hidden duplicates) paint no boundary
27
30
  // to judge.
28
31
  const ownRect = element.getBoundingClientRect();
@@ -20,7 +20,10 @@ export default {
20
20
  visibility: 'visual',
21
21
  evaluate(element) {
22
22
  // 1.4.3 exempts inactive controls.
23
- if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
23
+ // `:disabled`, not `element.disabled`: the property reflects only the
24
+ // element's own attribute, so a control inside <fieldset disabled> reads
25
+ // false while the browser has it inactive and 1.4.3 exempts it.
26
+ if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
24
27
  // Zero-area controls (JS-widget hidden duplicates) present no text.
25
28
  const rect = element.getBoundingClientRect();
26
29
  if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
@@ -76,7 +76,9 @@ export default {
76
76
  && rect.top >= overlay.top && rect.bottom <= overlay.bottom;
77
77
 
78
78
  return elements.map((element) => {
79
- if (element.disabled) return { status: 'pass' };
79
+ // `:disabled` catches controls disabled by an ancestor fieldset, which
80
+ // the `disabled` property does not reflect — those never take focus.
81
+ if (element.matches(':disabled')) return { status: 'pass' };
80
82
  const rect = element.getBoundingClientRect();
81
83
  if (!rect.width || !rect.height) return { status: 'pass' };
82
84
  // Only in-viewport geometry is trustworthy (or cheap).
@@ -0,0 +1,80 @@
1
+ // WCAG SC 2.4.4 Link Purpose (In Context) (Level A)
2
+ // · SC 2.4.9 Link Purpose (Link Only) (Level AAA)
3
+ //
4
+ // A link named "Read more" carries no purpose in its name. Screen reader
5
+ // users routinely pull up a list of every link on a page and navigate from
6
+ // it, and nine identical "Read more" entries in that list are worthless —
7
+ // the technique WCAG names for this is F63, and G91 is the fix.
8
+ //
9
+ // The two criteria differ in exactly one way, and it decides the verdict:
10
+ //
11
+ // 2.4.4 (A) purpose may come from CONTEXT — the sentence around the
12
+ // link, its list item, its table cell, the heading of the card
13
+ // it sits in. A "Read more" under a headline naming the
14
+ // article is a judgment call about whether that context really
15
+ // reaches the user, so this REVIEWS. It never asserts.
16
+ // 2.4.9 (AAA) purpose must come from the link text ALONE, with the only
17
+ // exception being links whose purpose "would be ambiguous to
18
+ // users in general". A link named nothing but "Read more" is
19
+ // ambiguous to everyone, which is the exception failing to
20
+ // apply rather than saving it, so this FAILS.
21
+ //
22
+ // Judged on the ACCESSIBLE NAME, not the text content: a link written as
23
+ // <a href="…" aria-label="Read more about the budget">Read more</a> is
24
+ // already fixed, and must not be flagged. That also means an aria-label
25
+ // which is itself generic is caught, which is the more common mistake.
26
+ //
27
+ // English only, deliberately. A phrase list cannot be quietly extended to
28
+ // other languages without a speaker of each checking it, and a wrong entry
29
+ // would fail correct links. Non-English pages get no findings here rather
30
+ // than bad ones.
31
+
32
+ const GENERIC = new Set([
33
+ 'read more', 'read more about this', 'more', 'much more', 'learn more',
34
+ 'click here', 'click', 'click this', 'tap here', 'press here',
35
+ 'here', 'this', 'this link', 'link', 'this page', 'go', 'go here',
36
+ 'more info', 'more information', 'further information',
37
+ 'see more', 'view more', 'show more', 'find out more', 'discover more',
38
+ 'continue', 'continue reading', 'keep reading', 'full story', 'full article',
39
+ 'details', 'see details', 'view details', 'more details',
40
+ ]);
41
+
42
+ /** Lowercased, punctuation and arrows dropped, spaces collapsed, so
43
+ * "Read more →", "Click here!" and "READ MORE" all compare equal. */
44
+ export function normalizeName(name) {
45
+ return name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim();
46
+ }
47
+
48
+ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict }) {
49
+ return {
50
+ id,
51
+ impact,
52
+ tags,
53
+ help,
54
+ helpUrl,
55
+ // role="link" claims the link role explicitly; an <a> without href has no
56
+ // link role at all and is correctly out of scope. Links with NO name are
57
+ // link-name's finding, not this one.
58
+ selector: 'a[href], [role="link"]',
59
+ evaluate(element, { accessibleName }) {
60
+ const name = accessibleName(element);
61
+ if (!name) return { status: 'pass' }; // nameless: link-name reports it
62
+ const normalized = normalizeName(name);
63
+ if (!GENERIC.has(normalized)) return { status: 'pass' };
64
+ return verdict(name);
65
+ },
66
+ };
67
+ }
68
+
69
+ export default createLinkPurposeRule({
70
+ id: 'link-text-generic',
71
+ impact: 'moderate',
72
+ tags: ['wcag2a', 'wcag244'],
73
+ help: 'Link text should describe where the link goes',
74
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-in-context.html',
75
+ verdict: (name) => ({
76
+ status: 'incomplete',
77
+ message: `“${name}” does not say where this link goes. 2.4.4 lets the surrounding text supply that, so this passes only if the context a screen reader reaches — the same sentence, list item, table cell, or the heading of the card it sits in — makes the destination obvious. Check it does, and remember that anyone navigating by a list of the page's links sees this name on its own.`,
78
+ fix: 'Put the destination in the link text itself, e.g. "Read more about the 2026 budget", or extend the name with aria-label while keeping the visible words at the start of it.',
79
+ }),
80
+ });
@@ -0,0 +1,26 @@
1
+ // WCAG SC 2.4.9 Link Purpose (Link Only) (Level AAA)
2
+ //
3
+ // Same phrase list and same name computation as 2.4.4's rule, at the AAA
4
+ // threshold: the link text ALONE has to identify the purpose, and context
5
+ // is not allowed to make up the difference.
6
+ //
7
+ // This one asserts where the AA rule reviews. The criterion's only escape is
8
+ // links "where the purpose of the link would be ambiguous to users in
9
+ // general", and a link named nothing but "Read more" is ambiguous to
10
+ // everyone — which is the exception failing to apply rather than rescuing
11
+ // it. Nothing about the surrounding page can change that reading, so there
12
+ // is nothing here for a human to settle.
13
+ import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
14
+
15
+ export default createLinkPurposeRule({
16
+ id: 'link-text-generic-only',
17
+ impact: 'moderate',
18
+ tags: ['wcag2aaa', 'wcag249'],
19
+ help: 'Link text alone must identify where the link goes (AAA)',
20
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html',
21
+ verdict: (name) => ({
22
+ status: 'fail',
23
+ message: `“${name}” gives no purpose on its own. 2.4.9 asks that the link text alone identify where a link goes, without relying on the sentence or heading around it, so anyone reading the page's links as a list can tell them apart.`,
24
+ fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
25
+ }),
26
+ });
@@ -5,20 +5,16 @@
5
5
  // Spec note (accname): input-like roles are NOT "name from content" — the
6
6
  // text inside a listbox/textbox does not name it, only author-provided
7
7
  // naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
8
+ import { labelledByName } from '../../lib/accessible-name.js';
9
+
8
10
  const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
9
11
  const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
10
12
 
11
13
  function authorName(element) {
12
- const labelledby = element.getAttribute('aria-labelledby');
13
- if (labelledby) {
14
- const text = labelledby
15
- .split(/\s+/)
16
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
17
- .join(' ')
18
- .trim();
19
- if (text) return text;
20
- }
21
- return element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim() || '';
14
+ return labelledByName(element)
15
+ || element.getAttribute('aria-label')?.trim()
16
+ || element.getAttribute('title')?.trim()
17
+ || '';
22
18
  }
23
19
 
24
20
  export default {
@@ -23,7 +23,7 @@ export default {
23
23
  // property, so a faded WRAPPER (the standard off-canvas drawer) leaves
24
24
  // every control inside it computing opacity: 1 while none of them paint.
25
25
  const focusable = [element, ...element.querySelectorAll(FOCUSABLE)].filter(
26
- (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.disabled
26
+ (el) => el.matches?.(FOCUSABLE) && el.getAttribute('tabindex') !== '-1' && !el.matches(':disabled')
27
27
  // `inert` removes focusability outright — aria-hidden + inert is
28
28
  // the CORRECT modern pattern for off-canvas content, not a defect.
29
29
  && !el.closest('[inert]')
@@ -4,6 +4,8 @@
4
4
  // name. An open, unnamed modal announces as just "dialog", leaving
5
5
  // screen-reader users with no idea what interrupted them (cookie-consent
6
6
  // modals are the canonical offender).
7
+ import { labelledByName } from '../../lib/accessible-name.js';
8
+
7
9
  export default {
8
10
  id: 'dialog-name',
9
11
  impact: 'serious',
@@ -14,15 +16,7 @@ export default {
14
16
  // dialogs the user can actually encounter are judged.
15
17
  selector: 'dialog, [role="dialog"], [role="alertdialog"]',
16
18
  evaluate(element) {
17
- const labelledby = element.getAttribute('aria-labelledby');
18
- if (labelledby) {
19
- const text = labelledby
20
- .split(/\s+/)
21
- .map((id) => element.getRootNode().getElementById?.(id)?.textContent ?? '')
22
- .join(' ')
23
- .trim();
24
- if (text) return { status: 'pass' };
25
- }
19
+ if (labelledByName(element)) return { status: 'pass' };
26
20
  if (element.getAttribute('aria-label')?.trim() || element.getAttribute('title')?.trim()) {
27
21
  return { status: 'pass' };
28
22
  }
@@ -18,7 +18,7 @@ export default {
18
18
  // disabled controls, display:none templates, and tabindex="-1" nodes
19
19
  // inside a control are inert markup, not competing tab stops.
20
20
  const nested = [...element.querySelectorAll(INTERACTIVE)].find((el) =>
21
- !el.disabled && el.getAttribute('tabindex') !== '-1'
21
+ !el.matches(':disabled') && el.getAttribute('tabindex') !== '-1'
22
22
  && !(el.tagName === 'INPUT' && el.type === 'hidden') && isRendered(el));
23
23
  if (!nested) return { status: 'pass' };
24
24
  return {
package/engine/wcag22.js CHANGED
@@ -75,7 +75,7 @@ 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', '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
79
79
  sc('2.4.10', 'Section Headings', 'AAA', 'manual'),
80
80
  sc('2.4.11', 'Focus Not Obscured (Minimum)', 'AA', 'partial', '2.2'), // partial: focus-not-obscured catches fully-covered targets at rest
81
81
  sc('2.4.12', 'Focus Not Obscured (Enhanced)', 'AAA', 'manual', '2.2'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",