pour-engine 1.31.2 → 1.32.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.2',
9
+ version: '1.32.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
 
@@ -26,20 +26,31 @@ export default {
26
26
  if (ownText(parent).replace(/\s+/g, '').length < 10) return { status: 'pass' };
27
27
 
28
28
  const style = getComputedStyle(element);
29
- if ((style.textDecorationLine ?? style.textDecoration ?? '').includes('underline')) return { status: 'pass' };
30
- // Underline substitutes count as non-colour cues too: border-bottom
31
- // "underlines", box-shadow underlines, and background chips/pills all
32
- // mark the link by shape, not colour alone.
33
- if (parseFloat(style.borderBottomWidth) > 0 && style.borderBottomStyle !== 'none') return { status: 'pass' };
34
- if (style.boxShadow && style.boxShadow !== 'none') return { status: 'pass' };
35
- const ownBackground = parseColor(style.backgroundColor);
36
- if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
37
- if (style.backgroundImage !== 'none') return { status: 'pass' }; // gradient/image underline technique
38
-
39
- // A clear weight difference is a non-colour distinction too.
40
29
  const parentStyle = getComputedStyle(parent);
41
30
  const weight = (s) => parseInt(s.fontWeight, 10) || 400;
42
- if (Math.abs(weight(style) - weight(parentStyle)) >= 300) return { status: 'pass' };
31
+ // A cue counts wherever it is painted: sites routinely reset
32
+ // text-decoration on the anchor and underline (or embolden) an inner
33
+ // span that carries the text, so every visible text-bearing element in
34
+ // the link vouches, not just the anchor. The >1px rect floor keeps
35
+ // 1×1 sr-only clip spans from vouching for a cue nobody can see.
36
+ const cueBearers = [style];
37
+ for (const el of element.querySelectorAll('*')) {
38
+ if (!el.textContent.trim()) continue;
39
+ if ([...el.getClientRects()].some((r) => r.width > 1 && r.height > 1)) cueBearers.push(getComputedStyle(el));
40
+ }
41
+ for (const s of cueBearers) {
42
+ if ((s.textDecorationLine ?? s.textDecoration ?? '').includes('underline')) return { status: 'pass' };
43
+ // Underline substitutes count as non-colour cues too: border-bottom
44
+ // "underlines", box-shadow underlines, and background chips/pills all
45
+ // mark the link by shape, not colour alone.
46
+ if (parseFloat(s.borderBottomWidth) > 0 && s.borderBottomStyle !== 'none') return { status: 'pass' };
47
+ if (s.boxShadow && s.boxShadow !== 'none') return { status: 'pass' };
48
+ const ownBackground = parseColor(s.backgroundColor);
49
+ if (ownBackground && ownBackground.a > 0) return { status: 'pass' };
50
+ if (s.backgroundImage !== 'none') return { status: 'pass' }; // gradient/image underline technique
51
+ // A clear weight difference is a non-colour distinction too.
52
+ if (Math.abs(weight(s) - weight(parentStyle)) >= 300) return { status: 'pass' };
53
+ }
43
54
 
44
55
  const linkColor = parseColor(style.color);
45
56
  const textColor = parseColor(parentStyle.color);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.31.2",
3
+ "version": "1.32.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",