pour-engine 1.34.0 → 1.34.2

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.34.0',
9
+ version: '1.34.2',
10
10
  },
11
11
  };
@@ -1191,8 +1191,8 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
1191
1191
  // low-vision readers — the one audience the attribute cannot reach. Every
1192
1192
  // fail path (solid, image-range, translucent blend, halo) gets the note,
1193
1193
  // and both criteria built from this factory inherit it. Adjudicated
1194
- // 2026-08-22 against a challenged mockup finding; axe reaches the same
1195
- // verdict on the same node.
1194
+ // 2026-08-22 against a challenged mockup finding, and upheld: the
1195
+ // criterion is written for the people who see the text.
1196
1196
  const judge = rule.evaluate.bind(rule);
1197
1197
  rule.evaluate = async (element, helpers) => {
1198
1198
  const verdict = await judge(element, helpers);
@@ -570,6 +570,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
570
570
  // fragment IS its box, so genuine overlays are dismissed exactly
571
571
  // as before.
572
572
  if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
573
+ // Crowding across fixed/sticky contexts is scroll state, not
574
+ // layout, exactly as the obscured-target test reads overlap: a
575
+ // consent banner pinned to the bottom of the viewport lays its
576
+ // "Privacy Policy" link on whichever headline happens to sit
577
+ // under it right now, and that headline has clear space the
578
+ // moment the page scrolls. Asserting it as crowded invents a
579
+ // defect the layout does not have (measured live on two news
580
+ // homes carrying the same consent banner, 2026-08-27).
581
+ if (fixedContextOf(other) !== fixedContextOf(element)) return false;
573
582
  const within = (box) => {
574
583
  if (undersized[j]) {
575
584
  const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
@@ -1,6 +1,9 @@
1
1
  // WCAG SC 4.1.2 Name, Role, Value (Level A)
2
2
  // aria-activedescendant and aria-owns matter most: a dangling
3
3
  // activedescendant breaks composite-widget focus reporting entirely.
4
+ import { effectiveRole } from '../../lib/roles.js';
5
+ import { labelledByName } from '../../lib/accessible-name.js';
6
+
4
7
  const REF_ATTRIBUTES = [
5
8
  'aria-labelledby', 'aria-describedby', 'aria-controls',
6
9
  'aria-activedescendant', 'aria-owns', 'aria-errormessage', 'aria-details',
@@ -17,6 +20,50 @@ const REF_ATTRIBUTES = [
17
20
  // read-only. The deferred-tooltip pattern is now named in the review
18
21
  // message instead, for the human to confirm.
19
22
 
23
+ // 4.1.2 is scoped to user interface components ("a part of the content
24
+ // that is perceived by users as a single control for a distinct function").
25
+ // A nameless labelledby is only the 4.1.2 failure proper on one of those: a
26
+ // landmark, list or group whose reference died is left unnamed, which WCAG
27
+ // permits for those roles, so the DOM proves an authoring error, not a
28
+ // component without a name. Same reading label-for-valid took on 2026-08-25.
29
+ // Corpus evidence (parity 2026-08-27): the one assert on 137 sites was an
30
+ // empty <aside tabindex="-1"> whose heading never rendered, while identical
31
+ // dead references on two visible <nav>s and a footer list passed only because the name
32
+ // computation falls through to their link text. The verdict must not turn
33
+ // on whether a landmark happens to contain words.
34
+ const WIDGET_ROLES = new Set([
35
+ // ARIA 1.2 widget roles
36
+ 'button', 'checkbox', 'gridcell', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio',
37
+ 'option', 'progressbar', 'radio', 'scrollbar', 'searchbox', 'separator', 'slider',
38
+ 'spinbutton', 'switch', 'tab', 'tabpanel', 'textbox', 'treeitem',
39
+ // ARIA 1.2 composite widget roles
40
+ 'combobox', 'grid', 'listbox', 'menu', 'menubar', 'radiogroup', 'tablist', 'tree', 'treegrid',
41
+ // window roles: a dialog is operated as one thing, and dialog-name asserts on it anyway
42
+ 'dialog', 'alertdialog',
43
+ ]);
44
+ const NATIVE_CONTROLS = 'a[href], button, input:not([type="hidden"]), select, textarea, summary, audio[controls], video[controls], [contenteditable="true"]';
45
+
46
+ /** accname step 2D, the host-language naming that every role keeps
47
+ * (name-from-content is the only role-gated step): an image's alt, an
48
+ * svg's own <title>, a table's caption, a fieldset's legend, a figure's
49
+ * figcaption. The Tate footer's social icons are <svg aria-labelledby="title">
50
+ * with a <title>Facebook</title> child and no element with that id: named. */
51
+ function nativeName(element) {
52
+ const tag = element.tagName.toLowerCase();
53
+ if (tag === 'img' || tag === 'area') return element.getAttribute('alt')?.trim() ?? '';
54
+ const child = { svg: 'title', table: 'caption', fieldset: 'legend', figure: 'figcaption' }[tag];
55
+ if (!child) return '';
56
+ return [...element.children].find((c) => c.tagName.toLowerCase() === child)?.textContent.trim() ?? '';
57
+ }
58
+
59
+ function isUserInterfaceComponent(element) {
60
+ if (element.matches(NATIVE_CONTROLS)) return true;
61
+ if (WIDGET_ROLES.has(effectiveRole(element))) return true;
62
+ // A tabbable element is operated as a control whatever its role;
63
+ // tabindex="-1" (a skip-link or scroll target) is not.
64
+ return element.tabIndex >= 0;
65
+ }
66
+
20
67
  function inspect(element) {
21
68
  // ARIA id references cannot cross shadow boundaries: they resolve only
22
69
  // within the element's own root (document or shadow tree).
@@ -48,28 +95,38 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
48
95
  if (missing.length) {
49
96
  // A dangling ref is skipped by the accname computation, so the HARM
50
97
  // 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):
98
+ // user-settable value (re-decided by David 2026-08-26 against measured
99
+ // verdicts on the corpus; the old "fragile, validator-flagged"
100
+ // argument was 4.1.1's, and 2.2 retired it):
54
101
  // - aria-labelledby that dangles while the element still names
55
102
  // itself from elsewhere: the name is fully determinable (accname
56
103
  // 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;
104
+ // - aria-labelledby that leaves a USER INTERFACE COMPONENT nameless,
105
+ // and aria-activedescendant (a focused composite reports the wrong
106
+ // active item): the 4.1.2 failure proper, asserted. The same dead
107
+ // reference on a landmark, list or group is asked about (2026-08-27,
108
+ // see isUserInterfaceComponent);
60
109
  // - aria-describedby that points nowhere at rest: a description is
61
110
  // not name, role or a user-set value, and tooltip libraries create
62
111
  // the target on hover or focus, so the DOM proves an authoring
63
112
  // error at most. Asked, never asserted;
64
113
  // - aria-controls, aria-owns, aria-details, aria-errormessage: the
65
114
  // standing policy (asserted; the comparator asserts these too).
66
- const restingName = (accessibleName?.(element) ?? '').trim();
115
+ const component = isUserInterfaceComponent(element);
116
+ // The resting name for a component is the full computation (a button
117
+ // names itself from its contents). A landmark, list or group is not
118
+ // allowed name-from-content (accname step 2F), so for those only the
119
+ // surviving labelledby ids, aria-label and title count: otherwise a
120
+ // <nav> full of links would read as named and the dead reference vanish.
121
+ const restingName = component
122
+ ? (accessibleName?.(element) ?? '').trim()
123
+ : (labelledByName(element) || element.getAttribute('aria-label') || nativeName(element) || element.getAttribute('title') || '').trim();
67
124
  const relevant = missing.filter(({ attr }) => !(attr === 'aria-labelledby' && restingName));
68
125
  if (!relevant.length) return { status: 'pass' };
69
126
  const line = ({ attr, id }) => attr === 'aria-labelledby'
70
127
  ? `aria-labelledby="${id}" points to nothing and leaves this element without an accessible name`
71
128
  : `${attr}="${id}" points to nothing — assistive technology silently ignores it`;
72
- const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby');
129
+ const asserted = relevant.filter(({ attr }) => attr !== 'aria-describedby' && (attr !== 'aria-labelledby' || component));
73
130
  if (asserted.length) {
74
131
  return {
75
132
  status: 'fail',
@@ -77,6 +134,15 @@ function outcome({ missing, ambiguous }, element, accessibleName) {
77
134
  fix: 'Correct or remove the reference, or give the target element that id.',
78
135
  };
79
136
  }
137
+ const unnamed = relevant.filter(({ attr }) => attr === 'aria-labelledby');
138
+ if (unnamed.length) {
139
+ const tag = element.tagName.toLowerCase();
140
+ return {
141
+ status: 'incomplete',
142
+ message: `${unnamed.map(({ id }) => `aria-labelledby="${id}" points to nothing`).join('; ')}, so this <${tag}> has no accessible name. It is not a user interface component, so 4.1.2 does not require one, but the reference was meant to label it: correct it, or remove it if the label was never needed.`,
143
+ fix: 'Correct or remove the reference, or give the target element that id.',
144
+ };
145
+ }
80
146
  return {
81
147
  status: 'incomplete',
82
148
  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.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.34.0",
3
+ "version": "1.34.2",
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",