pour-engine 1.31.3 → 1.33.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.31.3',
9
+ version: '1.33.0',
10
10
  },
11
11
  };
@@ -11,12 +11,28 @@ export default {
11
11
  selector: 'dl:not([role])', // a role attribute replaces the dl semantics
12
12
  evaluate(element) {
13
13
  const invalid = [...element.children].filter((child) => !ALLOWED.has(child.tagName));
14
- if (!invalid.length) return { status: 'pass' };
15
- const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
16
- return {
17
- status: 'fail',
18
- message: `This <dl> contains ${tags} directly — only <dt>, <dd> (optionally grouped in <div>) are allowed, otherwise the term/description pairing breaks.`,
19
- fix: 'Restructure the list into <dt>/<dd> pairs, or use a different element.',
20
- };
14
+ if (invalid.length) {
15
+ const tags = [...new Set(invalid.map((child) => `<${child.tagName.toLowerCase()}>`))].join(', ');
16
+ return {
17
+ status: 'fail',
18
+ message: `This <dl> contains ${tags} directly — only <dt>, <dd> (optionally grouped in <div>) are allowed, otherwise the term/description pairing breaks.`,
19
+ fix: 'Restructure the list into <dt>/<dd> pairs, or use a different element.',
20
+ };
21
+ }
22
+ // A <div> child is only a valid wrapper when it holds the dt/dd group
23
+ // ITSELF (HTML: each div contains one or more dt followed by one or
24
+ // more dd). Pairs buried another level down (dl > div > div > dt) fall
25
+ // out of the dl's content model, so the term/description association is
26
+ // no longer guaranteed to assistive technology.
27
+ const emptyWrappers = [...element.children].filter((child) =>
28
+ child.tagName === 'DIV' && ![...child.children].some((inner) => inner.tagName === 'DT' || inner.tagName === 'DD'));
29
+ if (emptyWrappers.length) {
30
+ return {
31
+ status: 'fail',
32
+ message: `${emptyWrappers.length} <div> wrapper(s) in this <dl> hold no <dt>/<dd> directly — the term/description pairing breaks when the pairs sit deeper than the wrapper.`,
33
+ fix: 'Make each <div> child of the <dl> contain its <dt>/<dd> pair directly, or flatten the pairs into the <dl> itself.',
34
+ };
35
+ }
36
+ return { status: 'pass' };
21
37
  },
22
38
  };
@@ -26,11 +26,13 @@ export default {
26
26
  if (!isGrid || textCells < 4) return { status: 'pass' }; // likely layout or trivial
27
27
  // Nested tables are the signature of legacy LAYOUT markup, not data —
28
28
  // asserting "missing headers" there flags email-style scaffolding. A
29
- // human can tell in a glance; the machine can't.
30
- if (!element.querySelector('th') && element.querySelector('table')) {
29
+ // human can tell in a glance; the machine can't. The signature reads in
30
+ // both directions: a table INSIDE another table (Hacker News's story
31
+ // list inside its page-scaffold table) is the same legacy layout.
32
+ if (!element.querySelector('th') && (element.querySelector('table') || element.parentElement?.closest('table'))) {
31
33
  return {
32
34
  status: 'incomplete',
33
- message: 'This table has no header cells but contains nested tables — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
35
+ message: 'This table has no header cells and is part of a nested-table structure — a layout-table signature. If it really presents data, mark its header cells with <th>; if it is layout scaffolding, add role="presentation".',
34
36
  };
35
37
  }
36
38
 
@@ -51,11 +51,38 @@ export default {
51
51
  // A clear weight difference is a non-colour distinction too.
52
52
  if (Math.abs(weight(s) - weight(parentStyle)) >= 300) return { status: 'pass' };
53
53
  }
54
+ // A weight step of 200–299 (600-semibold links in 400 prose, the common
55
+ // case) is a REAL non-colour cue, just not a clear one: whether it reads
56
+ // as "bolded" (F73's word) depends on the face and the size, which is a
57
+ // judgment by eye, not arithmetic. Adjudicated on the 2026-08-16 corpus
58
+ // run (PostgreSQL ×32, visibly semibold): asserting fail overclaims,
59
+ // passing waves through faces where the step is invisible — so it goes
60
+ // to review. Below 200 the step is imperceptible at body sizes and earns
61
+ // nothing.
62
+ const weightStep = Math.max(...cueBearers.map((s) => Math.abs(weight(s) - weight(parentStyle))));
54
63
 
55
64
  const linkColor = parseColor(style.color);
56
65
  const textColor = parseColor(parentStyle.color);
57
66
  if (!linkColor || !textColor) return { status: 'pass' };
58
67
 
68
+ // A link styled to look NO different from the surrounding prose is not
69
+ // a 1.4.1 failure — Understanding 1.4.1, in its own words: "a hyperlink
70
+ // which has been styled to appear no different than neighboring static
71
+ // text would not fail this success criterion, as there would be no
72
+ // color differentiation between the actionable hyperlink text and the
73
+ // adjacent static text." Colour conveys nothing when there is no colour
74
+ // difference. A terrible link, but no WCAG criterion requires links to
75
+ // look like links, and asserting one invents spec text (measured live:
76
+ // hsbc.co.uk's footer, rgb(215,216,214) on both link and prose).
77
+ // Channel equality, NOT the luminance ratio: a hue-only difference at
78
+ // equal luminance also lands at 1.00:1, and that is the clearest F73
79
+ // failure there is — the two cases are opposites and the ratio cannot
80
+ // tell them apart.
81
+ if (linkColor.r === textColor.r && linkColor.g === textColor.g
82
+ && linkColor.b === textColor.b && (linkColor.a ?? 1) === (textColor.a ?? 1)) {
83
+ return { status: 'pass' };
84
+ }
85
+
59
86
  const ratio = contrastRatio(linkColor, textColor);
60
87
  // WCAG technique G183: 3:1 against the surrounding text is a sufficient
61
88
  // colour difference for identifying a link.
@@ -89,6 +116,13 @@ export default {
89
116
  }
90
117
  if (!sharesLine) return { status: 'pass' };
91
118
  }
119
+ if (weightStep >= 200) {
120
+ return {
121
+ status: 'incomplete',
122
+ message: `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, but its font weight differs from the prose by ${weightStep}: a visible non-colour cue that falls short of a clear bold step. Judge by eye whether the weight alone identifies it as a link in this face and size; if it does not, this fails SC 1.4.1.`,
123
+ fix: 'Underline links inside text (text-decoration: underline), raise the weight difference to a clear bold step, or add another non-colour indicator.',
124
+ };
125
+ }
92
126
  return {
93
127
  status: 'fail',
94
128
  // The measured fact is the ratio against G183's threshold, and that is
@@ -97,7 +131,7 @@ export default {
97
131
  // staying perfectly distinct to most people, including most red-green
98
132
  // colour blindness, where the red darkens and the gap widens.
99
133
  message: ratio < 1.01
100
- ? 'This link has no underline and the same colour as the surrounding text, so there is nothing at all to mark it as a link.'
134
+ ? 'This link has no underline and its colour differs from the surrounding text in hue alone, with no luminance difference: the distinction disappears entirely without colour vision, the exact failure F73 describes.'
101
135
  : `This link has no underline and only ${ratio.toFixed(2)}:1 colour difference from the surrounding text, below the 3:1 WCAG technique G183 asks for when colour is the only thing marking a link.`,
102
136
  fix: 'Underline links inside text (text-decoration: underline), or add a non-colour indicator.',
103
137
  };
@@ -98,7 +98,22 @@ function reachableRects(element, rect) {
98
98
  if (x < 0 || y < 0 || x >= win.innerWidth || y >= win.innerHeight) continue;
99
99
  const stack = doc.elementsFromPoint(x, y);
100
100
  const index = stack.indexOf(element);
101
- if (index < 0) continue; // this fragment isn't hit-testable; try the next
101
+ if (index < 0) {
102
+ // The probe ran and the stack came back without the element.
103
+ // elementsFromPoint reports covered elements too, so for a light-DOM
104
+ // element this is not don't-know but a definitive no: nothing at this
105
+ // point accepts a pointer for it. The sr-only WRAPPER pattern makes
106
+ // exactly this shape — the clipped 1×1 box is the wrapper, the skip
107
+ // link inside keeps its full-size rect, and every self-applied hiding
108
+ // check (own rect, own opacity, own clip) passes; that phantom rect
109
+ // crowded frame.work's 160×21 header wordmark out of the spacing
110
+ // exception its layout plainly earns. Shadow-tree elements are
111
+ // invisible to the document's hit-test by design (the stack shows
112
+ // their host), so absence proves nothing there and the fragment keeps
113
+ // the benefit of the doubt.
114
+ if (element.getRootNode() === doc) testable = true;
115
+ continue;
116
+ }
102
117
  testable = true;
103
118
  // Anything above it that is part of the same control is not an
104
119
  // obstruction: a button's own icon paints over the button, an overlay
@@ -107,9 +122,9 @@ function reachableRects(element, rect) {
107
122
  reachable.push(fragment);
108
123
  }
109
124
  }
110
- // Nothing could be probed at all (entirely out of viewport,
111
- // pointer-events: none, nothing laid out): the OCCLUSION question is
112
- // unanswerable, and don't-know must not silence a finding — but the
125
+ // Nothing could be probed at all (entirely out of viewport, or a
126
+ // shadow-tree element the document's hit-test cannot see): the OCCLUSION
127
+ // question is unanswerable, and don't-know must not silence a finding — but the
113
128
  // fragment GEOMETRY is measurable regardless, so the fragments stand,
114
129
  // never the bounding box. A wrapped link's box is mostly leading and
115
130
  // line-gap no pointer can activate; counting that empty space as
@@ -68,7 +68,7 @@ export default {
68
68
  const names = disallowed.map((n) => `aria-${n}`);
69
69
  return {
70
70
  status: 'incomplete',
71
- message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected.`,
71
+ message: `${names.join(', ')} is not supported on role "${role}", so the browser drops it and assistive technology never sees it. Nothing is announced wrongly, but nothing is announced at all. Does this element really have that state? If it does, it is invisible to a screen reader and this is a 4.1.2 failure. If it does not, the attribute is simply stray and no one is affected. Invalid markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is whether the state reaches users, not the syntax.`,
72
72
  fix: `Move ${names.join('/')} to the element whose role supports it, usually the control that toggles this one, or remove it if the element has no such state. Adding a role to this element to make the attribute legal is rarely right: the host language restricts which roles each element may take.`,
73
73
  };
74
74
  },
@@ -72,7 +72,7 @@ export default {
72
72
  })();
73
73
  return {
74
74
  status: 'incomplete',
75
- message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard.`,
75
+ message: `${attempt} on a plain <${element.tagName.toLowerCase()}> is prohibited by ARIA: this element's role does not support naming, so although many browser and screen reader pairings announce the label today, that support is not guaranteed anywhere and can differ between assistive technologies. Where it is announced it replaces the element's visible text; where it is not, users get the visible text alone. Check both presentations read correctly, and that nothing here relies on the label being heard. Prohibited markup is not by itself a WCAG failure: the criterion that policed validity, 4.1.1 Parsing, was removed in WCAG 2.2 (w3.org/WAI/WCAG22/Understanding/parsing.html), so what decides this is what users actually hear.`,
76
76
  fix: 'If this element must reliably announce something of its own, give it the role it is playing (a landmark, group, or widget role) so the name is legal and exposure is guaranteed — or move the spoken text into visible or visually-hidden real text. If the label is stray, remove it. Do not add a role solely to legalise the label.',
77
77
  };
78
78
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.31.3",
3
+ "version": "1.33.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",