pour-engine 1.33.0 → 1.34.1

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 +109 -72
  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
@@ -1,4 +1,28 @@
1
1
  // WCAG SC 2.2.1 Timing Adjustable (Level A)
2
+
3
+ /**
4
+ * The destination the browser would navigate to, read the way the HTML
5
+ * "shared declarative refresh steps" read it: after the time and its
6
+ * separator (";", "," or whitespace) everything that remains is the URL,
7
+ * with or without a "url" keyword, an "=" and quotes, none of which the
8
+ * parser requires. Only a bare number, or a number followed by nothing,
9
+ * points the refresh back at the page itself. (2026-08-25 overnight audit:
10
+ * the old test demanded the literal "url=", so content="0;https://…" was
11
+ * asserted as a reload loop while the browser redirected from it.)
12
+ */
13
+ function refreshDestination(content) {
14
+ let rest = content.replace(/^\s*[\d.]*\s*(?:[;,]\s*)?/, '');
15
+ // "url" is consumed letter by letter, then optional whitespace and "=".
16
+ rest = rest.replace(/^url\s*(?:=\s*)?/i, '');
17
+ const quote = rest[0] === '"' || rest[0] === "'" ? rest[0] : '';
18
+ if (quote) {
19
+ rest = rest.slice(1);
20
+ const close = rest.indexOf(quote);
21
+ if (close !== -1) rest = rest.slice(0, close);
22
+ }
23
+ return rest.trim();
24
+ }
25
+
2
26
  export default {
3
27
  id: 'meta-refresh',
4
28
  name: 'Timed page refresh',
@@ -14,7 +38,7 @@ export default {
14
38
  // A refresh with no destination reloads THIS page. At zero seconds that
15
39
  // is not the allowed instant redirect — it is a loop that throws the
16
40
  // user back to the top of the page over and over (failure F41).
17
- const hasDestination = /;\s*url\s*=/i.test(content);
41
+ const hasDestination = refreshDestination(content) !== '';
18
42
  if (delay === 0 && !hasDestination) {
19
43
  return {
20
44
  status: 'fail',
@@ -43,6 +43,17 @@ function keyframesBlink(keyframes) {
43
43
  return opacities.some((value) => value <= 0.25) && opacities.some((value) => value >= 0.75);
44
44
  }
45
45
 
46
+ /** Does a pseudo-element's generated content put TEXT on the screen? A
47
+ * quoted string with something in it, an attr() or counter() value, or
48
+ * quotes do; "none", "normal", the empty string and url() images do not. */
49
+ function generatedText(content) {
50
+ if (!content || content === 'none' || content === 'normal') return false;
51
+ const withoutImages = content.replace(/url\([^)]*\)/g, '');
52
+ const strings = withoutImages.match(/"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/g) ?? [];
53
+ if (strings.some((string) => string.slice(1, -1).trim())) return true;
54
+ return /\b(?:attr|counters?)\(|(?:open|close)-quote/.test(withoutImages);
55
+ }
56
+
46
57
  /**
47
58
  * Elements that an endlessly repeating animation is currently moving,
48
59
  * mapped to the evidence. One document-wide getAnimations() call, never one
@@ -69,9 +80,21 @@ function endlesslyMoving(doc) {
69
80
  try { keyframes = effect.getKeyframes(); } catch { continue; }
70
81
  const moves = keyframesMove(keyframes);
71
82
  if (!moves && !keyframesBlink(keyframes)) continue;
72
- // A pseudo-element's motion is still the host element's motion.
83
+ // A pseudo-element's motion is still the host element's motion, but
84
+ // the text gate belongs to the pseudo-element: a ::after spinner on a
85
+ // button labelled "Saving" carries no text of its own, so it is the
86
+ // text-free indicator this rule leaves alone, whatever the host says.
87
+ // One getComputedStyle per endless pseudo animation, never per element
88
+ // (2026-08-25 overnight audit).
89
+ let pseudoText = false;
90
+ if (effect.pseudoElement) {
91
+ let content = '';
92
+ try { content = target.ownerDocument.defaultView.getComputedStyle(target, effect.pseudoElement).content; } catch { /* detached */ }
93
+ if (!generatedText(content)) continue;
94
+ pseudoText = true;
95
+ }
73
96
  if (!moving.has(target)) {
74
- moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks' });
97
+ moving.set(target, { name: animation.animationName, verb: moves ? 'moves' : 'blinks', pseudoText });
75
98
  }
76
99
  }
77
100
  return moving;
@@ -107,7 +130,7 @@ export default {
107
130
  };
108
131
  }
109
132
  const animation = moving.get(element);
110
- if (!animation || !element.textContent.trim()) return { status: 'pass' };
133
+ if (!animation || !(animation.pseudoText || element.textContent.trim())) return { status: 'pass' };
111
134
  const named = animation.name ? ` (the “${animation.name}” animation)` : '';
112
135
  return {
113
136
  status: 'incomplete',
@@ -1,5 +1,24 @@
1
1
  // WCAG SC 2.4.1 Bypass Blocks (Level A)
2
2
  import { collectRoots, isEmbeddedDocument } from '../../lib/dom.js';
3
+ import { effectiveRole } from '../../lib/roles.js';
4
+
5
+ // ARIA11 lists the landmark roles: banner, complementary, contentinfo,
6
+ // form, main, navigation, region, search. Judged by EFFECTIVE role, not tag
7
+ // (2026-08-25 overnight audit): HTML-AAM makes a <header>/<footer> inside
8
+ // article, aside, main, nav or section generic rather than a landmark, and
9
+ // a named <section> or <form> IS one. The tag selector counted the first
10
+ // and missed the second. region and form are landmarks only when named
11
+ // (ARIA 1.2 requires an accessible name for region; HTML-AAM maps form to
12
+ // the form landmark only with one), so the name is checked here.
13
+ const LANDMARK_ROLES = new Set(['banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search']);
14
+ const NAMED_ONLY = new Set(['form', 'region']);
15
+ const LANDMARK_CANDIDATES = 'main, header, footer, nav, aside, section, form, [role]';
16
+ const hasName = (el) => Boolean(
17
+ el.getAttribute('aria-label')?.trim() || el.getAttribute('aria-labelledby')?.trim() || el.getAttribute('title')?.trim());
18
+ const isLandmark = (el) => {
19
+ const role = effectiveRole(el);
20
+ return LANDMARK_ROLES.has(role) && (!NAMED_ONLY.has(role) || hasName(el));
21
+ };
3
22
 
4
23
  export default {
5
24
  id: 'bypass-blocks',
@@ -10,7 +29,7 @@ export default {
10
29
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/bypass-blocks.html',
11
30
  selector: 'html',
12
31
  visibleOnly: false,
13
- evaluate(element) {
32
+ evaluate(element, { isRendered }) {
14
33
  // 2.4.1 governs blocks "repeated on multiple Web pages", and a WCAG
15
34
  // Web page is a non-embedded resource: an embedded frame's document
16
35
  // owes no skip link of its own (the frame's title is itself the bypass
@@ -28,17 +47,22 @@ export default {
28
47
  // banner and a navigation region are exactly what landmark navigation
29
48
  // skips between. Requiring `main` failed pages that were already using
30
49
  // the technique the spec names.
31
- const LANDMARKS = 'main, [role="main"], header, [role="banner"], nav, [role="navigation"],'
32
- + ' aside, [role="complementary"], footer, [role="contentinfo"], [role="search"], form[role="search"]';
33
- if (inAnyRoot(LANDMARKS)) return { status: 'pass' };
50
+ if (roots.some((root) => [...(root.querySelectorAll?.(LANDMARK_CANDIDATES) ?? [])].some(isLandmark))) {
51
+ return { status: 'pass' };
52
+ }
34
53
  if (inAnyRoot('h1, h2, h3, h4, h5, h6, [role="heading"]')) return { status: 'pass' };
35
54
  // G1/G123/G124: ANY in-page link that resolves is a bypass mechanism.
36
55
  // Reading only the first one hid a real skip link behind the href="#"
37
- // that scripts use as a button.
56
+ // that scripts use as a button. The link must be RENDERED to count:
57
+ // G1's test is that it is visible, or visible on keyboard focus, and
58
+ // a display:none link is never in the tab order, so nobody can use it.
59
+ // Clip/sr-only skip links are rendered and still count (2026-08-25
60
+ // overnight audit).
38
61
  for (const root of roots) {
39
62
  for (const link of root.querySelectorAll?.('a[href^="#"]') ?? []) {
40
63
  const id = link.getAttribute('href').slice(1);
41
64
  if (!id) continue;
65
+ if (isRendered && !isRendered(link)) continue;
42
66
  if (roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
43
67
  return { status: 'pass' };
44
68
  }
@@ -10,6 +10,11 @@
10
10
  // defaults. Almost always a leftover, but a gallery page really can be about
11
11
  // an artwork called "Untitled", and "Example Domain" really is the subject
12
12
  // of example.com. Those are somebody's call to make, not the engine's.
13
+ // "New document", "New page" and "New tab" sit in tier two as well: a
14
+ // document editor's creation screen, a wiki's new-page form or a
15
+ // browser-style app's new-tab page is about exactly that, so only the
16
+ // numbered editor default ("New Page 1", F25's example) is a certain
17
+ // placeholder (2026-08-25 overnight audit).
13
18
  //
14
19
  // Deliberately NOT judged: whether a real-looking title matches the page it
15
20
  // sits on. That needs someone who can read the page, and guessing at it
@@ -18,12 +23,13 @@ import { isEmbeddedDocument } from '../../lib/dom.js';
18
23
 
19
24
  const PLACEHOLDER = new Set([
20
25
  'untitled document', 'untitled page', 'untitled-1', 'untitled 1',
21
- 'new document', 'new page', 'new tab', 'no title',
26
+ 'no title',
22
27
  'insert title here', 'title here', 'page title', 'document title',
23
28
  'web page', 'webpage',
24
29
  ]);
25
30
  const UNDESCRIPTIVE = new Set([
26
31
  'untitled', 'document', 'page', 'title', 'default', 'index',
32
+ 'new document', 'new page', 'new tab',
27
33
  'react app', 'create react app', 'vite app', 'vite + react', 'vite + vue', 'vite + svelte',
28
34
  'next app', 'create next app', 'vue app', 'nuxt app', 'svelte app', 'angular app',
29
35
  'my app', 'my site', 'my website', 'site title', 'your site title',
@@ -55,7 +61,7 @@ export default {
55
61
  }
56
62
  // Collapsed and lowercased so "Untitled Document" is caught too.
57
63
  const normalized = title.replace(/\s+/g, ' ').toLowerCase();
58
- if (PLACEHOLDER.has(normalized) || /^untitled[\s-]*\d+$/.test(normalized)) {
64
+ if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
59
65
  return {
60
66
  status: 'fail',
61
67
  message: `“${title}” is a placeholder left by an editor or template. It gives no topic and no purpose, so a screen reader announces nothing useful when the page loads, and a row of open tabs, bookmarks or history entries becomes impossible to tell apart.`,
@@ -22,17 +22,19 @@
22
22
  // grid-auto-flow: dense) reorders without `order` or `*-reverse` and is not
23
23
  // treated as evidence. Both are honest gaps to widen later with measured
24
24
  // cases, not silently.
25
- const FLEXGRID = /^(inline-)?(flex|grid)$/;
25
+ export const FLEXGRID = /^(inline-)?(flex|grid)$/;
26
26
 
27
27
  /** DOM-order focusable children grouped per flex/grid parent. */
28
- function tabbable(element) {
28
+ export function tabbable(element) {
29
29
  if (element.disabled || element.tabIndex < 0) return false;
30
30
  return true;
31
31
  }
32
32
 
33
33
  /** Visual reading order of sibling rects: group into rows by vertical
34
- * overlap, rows top-to-bottom, within a row along the inline direction. */
35
- function visualSequence(entries, rightToLeft) {
34
+ * overlap, rows top-to-bottom, within a row along the inline direction.
35
+ * Shared with 1.3.2's reading-order rule — same geometry, different
36
+ * audience (Tab order there, screen-reader reading order here). */
37
+ export function visualSequence(entries, rightToLeft) {
36
38
  const rows = [];
37
39
  for (const entry of [...entries].sort((a, b) => a.rect.top - b.rect.top)) {
38
40
  const row = rows.find((candidates) => {
@@ -1,4 +1,6 @@
1
1
  // WCAG SC 2.4.4 Link Purpose (In Context) (Level A) · 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: 'link-name',
4
6
  name: 'Link names',
@@ -10,6 +12,15 @@ export default {
10
12
  // (Links without href have no link role and are correctly excluded.)
11
13
  selector: 'a[href], [role="link"]',
12
14
  evaluate(element, { accessibleName }) {
15
+ // Route by EFFECTIVE role (2026-08-25 overnight audit): an <a href
16
+ // role="button"> is exposed as a button, not a link (ARIA 1.2 section
17
+ // 9.1, the role attribute overrides the host role), so its missing name
18
+ // is button-name's finding, worded for a button; <a href role="tab"> is
19
+ // composite-widget-name's. One nameless control, one finding. An
20
+ // unknown explicit role is ignored by the browser and the native link
21
+ // role survives, which is what the implicitRole fallback answers.
22
+ const role = effectiveRole(element) ?? implicitRole(element);
23
+ if (role !== 'link') return { status: 'pass' };
13
24
  if (accessibleName(element)) return { status: 'pass' };
14
25
  return {
15
26
  status: 'fail',
@@ -55,6 +55,22 @@ export function normalizeName(name) {
55
55
  return name.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim();
56
56
  }
57
57
 
58
+ // A name WRAPPED in a paired bracket or quote reads as a literal token —
59
+ // "<details>" names an element, and the brackets are the signal — so the
60
+ // phrase inside it is not provably generic. One-sided decoration cannot
61
+ // change a phrase's meaning and stays provable: python.org's links are
62
+ // named ">>>More" because a CSS-generated REPL prompt joins the accessible
63
+ // name, and "Read more →" identifies no more than "Read more". Apostrophes
64
+ // fold to spaces so the French entries compare in their listed form.
65
+ function provablyGeneric(name) {
66
+ const trimmed = name.trim();
67
+ if (/^[<\[({"'«‹「『].*[>\])}"'»›」』]$/su.test(trimmed)) return false;
68
+ const plain = trimmed.toLowerCase().replace(/[’']/gu, ' ')
69
+ .replace(/^[^\p{L}\p{N}\s]+\s*/u, '').replace(/[^\p{L}\p{N}\s]+\s*$/u, '')
70
+ .replace(/\s+/g, ' ').trim();
71
+ return GENERIC.has(plain);
72
+ }
73
+
58
74
  export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict }) {
59
75
  return {
60
76
  id,
@@ -71,7 +87,12 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
71
87
  if (!name) return { status: 'pass' }; // nameless: link-name reports it
72
88
  const normalized = normalizeName(name);
73
89
  if (!GENERIC.has(normalized)) return { status: 'pass' };
74
- return verdict(name);
90
+ // The link's declared language (closest lang attribute, falling back
91
+ // to the document's), so a verdict can tell whether a French-only
92
+ // entry sits on a French page. Reached only for list matches, so the
93
+ // ancestor walk costs nothing on the page as a whole (2026-08-25).
94
+ const lang = (element.closest('[lang]')?.getAttribute('lang') ?? element.ownerDocument.documentElement.getAttribute('lang') ?? '').trim();
95
+ return verdict(name, { provable: provablyGeneric(name), normalized, lang });
75
96
  },
76
97
  };
77
98
  }
@@ -3,12 +3,35 @@
3
3
  // indicator is the classic 2.4.7 failure (F78). Cross-origin sheets are
4
4
  // unreadable and skipped; a replacement indicator can live in a different
5
5
  // rule entirely — so this flags for review, it never asserts a fail.
6
+ // The focus pseudo-classes themselves, and nothing else: :focus-within is a
7
+ // PARENT's state and stripping ":focus" out of it would leave "-within".
8
+ const FOCUS_TOKEN = /:focus(?:-visible)?(?![\w-])/g;
9
+ const HAS_FOCUS = /:focus(?:-visible)?(?![\w-])/;
10
+
6
11
  function focusSuppressors(doc) {
7
12
  const suspects = [];
8
13
  const replacementIn = (style) => style.getPropertyValue('box-shadow') || style.getPropertyValue('border')
9
14
  || style.getPropertyValue('border-color') || style.getPropertyValue('background')
10
15
  || style.getPropertyValue('background-color') || style.getPropertyValue('text-decoration');
11
-
16
+ const outlineOf = (style) => `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
17
+ const removesOutline = (outline) => /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
18
+ // The elements a focus selector reaches, with the focus state itself
19
+ // taken off, so `a:focus` and `a:focus-visible` both read "a".
20
+ const subjects = (selectorText) => selectorText.split(',')
21
+ .map((part) => part.replace(FOCUS_TOKEN, '').replace(/\s+/g, ' ').trim());
22
+ // Per sheet: the suppressors found, and the later rules that give the
23
+ // same elements an indicator back. A suspect is dismissed only when a
24
+ // LATER rule in the SAME sheet, keyed on :focus or :focus-visible, covers
25
+ // every subject it names and declares a visible outline or a replacement.
26
+ // That is the keyboard-only pattern in its other spelling
27
+ // (`a:focus{outline:0}` then `a:focus-visible{outline:3px solid}`) and
28
+ // the plain two-rule split (`a:focus{outline:none}` then
29
+ // `a:focus{box-shadow:...}`): both provably provide the mode of operation
30
+ // the criterion asks for, and both reviewed until 2026-08-25 (overnight
31
+ // audit). A replacement on a DIFFERENT element (a :focus-within parent)
32
+ // stays a review; whether that box reads as the focused control is a
33
+ // human question.
34
+ let sheet = null;
12
35
  const inspect = (rule) => {
13
36
  if (!rule.selectorText || !/:focus/.test(rule.selectorText)) return;
14
37
  // `:focus:not(:focus-visible) { outline: none }` is the PUBLISHED pattern
@@ -19,18 +42,29 @@ function focusSuppressors(doc) {
19
42
  if (/:not\(\s*:focus-visible\s*\)/.test(rule.selectorText)) return;
20
43
  const style = rule.style;
21
44
  if (!style) return;
22
- const outline = `${style.getPropertyValue('outline')} ${style.getPropertyValue('outline-style')} ${style.getPropertyValue('outline-width')}`;
23
- const removesOutline = /\bnone\b/.test(outline) || /(^|\s)0(px)?(\s|$)/.test(outline);
24
- if (!removesOutline) return;
25
45
  // A replacement indicator may not sit in the rule's own style block: any
26
46
  // declaration written AFTER a nested rule is parsed into a separate
27
47
  // CSSNestedDeclarations child (a style-bearing rule with no selector), so
28
48
  // reading only rule.style would report a suppressor that is in fact
29
49
  // replaced two lines further down.
30
- const nested = [...(rule.cssRules ?? [])]
31
- .filter((child) => child.style && !child.selectorText);
32
- if (replacementIn(style) || nested.some((child) => replacementIn(child.style))) return;
33
- suspects.push(rule.selectorText);
50
+ const styles = [style, ...[...(rule.cssRules ?? [])]
51
+ .filter((child) => child.style && !child.selectorText).map((child) => child.style)];
52
+ const replaced = styles.some(replacementIn);
53
+ const outlines = styles.map(outlineOf).filter((o) => o.trim());
54
+ const showsOutline = outlines.some((o) => !removesOutline(o));
55
+ if (HAS_FOCUS.test(rule.selectorText) && (replaced || showsOutline)) {
56
+ sheet.providers.push({ order: sheet.order, subjects: subjects(rule.selectorText) });
57
+ }
58
+ if (!outlines.some(removesOutline) || replaced) return;
59
+ sheet.suspects.push({ order: sheet.order, selector: rule.selectorText, subjects: subjects(rule.selectorText) });
60
+ };
61
+ const covered = (subject, provider) => provider.subjects.some((p) => p === subject || p === '*' || p === '');
62
+ const settle = () => {
63
+ for (const suspect of sheet.suspects) {
64
+ const restored = suspect.subjects.every((subject) => sheet.providers.some((provider) =>
65
+ provider.order > suspect.order && covered(subject, provider)));
66
+ if (!restored) suspects.push(suspect.selector);
67
+ }
34
68
  };
35
69
  // Inspect each rule, THEN descend into any children. Testing `rule.cssRules`
36
70
  // first and skipping used to walk straight past every plain style rule:
@@ -40,12 +74,15 @@ function focusSuppressors(doc) {
40
74
  // A style rule can both declare and nest, so both halves have to run.
41
75
  const scan = (rules) => {
42
76
  for (const rule of rules) {
77
+ sheet.order++;
43
78
  inspect(rule);
44
79
  if (rule.cssRules?.length) scan(rule.cssRules); // @media/@supports/nesting
45
80
  }
46
81
  };
47
- for (const sheet of doc.styleSheets) {
48
- try { scan(sheet.cssRules); } catch { /* cross-origin sheet */ }
82
+ for (const styleSheet of doc.styleSheets) {
83
+ sheet = { order: 0, suspects: [], providers: [] };
84
+ try { scan(styleSheet.cssRules); } catch { /* cross-origin sheet */ }
85
+ settle();
49
86
  }
50
87
  return suspects;
51
88
  }
@@ -10,8 +10,32 @@
10
10
  // everyone — which is the exception failing to apply rather than rescuing
11
11
  // it. Nothing about the surrounding page can change that reading, so there
12
12
  // is nothing here for a human to settle.
13
+ //
14
+ // Two matches stay in the review lane because the failure is not provable
15
+ // (2026-08-25 corpus adjudication):
16
+ // · a name wrapped in a paired bracket or quote — "<details>" is a code
17
+ // term that names its destination exactly (one-sided decoration like
18
+ // python.org's CSS-generated ">>>More" prompt still asserts);
19
+ // · a name that is exactly "go" — every corpus hit was a brand logo
20
+ // ("Go" is the language's name), and a brand name identifies the link.
21
+ //
22
+ // A third, beside "go" (2026-08-25 overnight audit): the French entries
23
+ // "plus" and "suite" are ordinary English words and, more to the point,
24
+ // common English plan and product names (a "Plus" tier, a "Suite"). A
25
+ // pricing link named "Plus" identifies its destination the way the Go logo
26
+ // does, so outside a page declared French they review rather than assert.
27
+ // On a link whose closest lang is French the generic reading ("more",
28
+ // "continued") is the only one and the assertion stands.
13
29
  import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
14
30
 
31
+ const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
32
+ const FRENCH = /^fr(-|$)/i;
33
+
34
+ function mayBeBrandName(normalized, lang) {
35
+ if (normalized === 'go') return true;
36
+ return FRENCH_ONLY_ENGLISH_NAMES.has(normalized) && !FRENCH.test(lang);
37
+ }
38
+
15
39
  export default createLinkPurposeRule({
16
40
  id: 'link-text-generic-only',
17
41
  name: 'Self-sufficient link text',
@@ -19,9 +43,16 @@ export default createLinkPurposeRule({
19
43
  tags: ['wcag2aaa', 'wcag249'],
20
44
  help: 'Link text alone must identify where the link goes (AAA)',
21
45
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/link-purpose-link-only.html',
22
- verdict: (name) => ({
23
- status: 'fail',
24
- 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.`,
25
- fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
26
- }),
46
+ verdict: (name, { provable, normalized, lang }) => {
47
+ if (!provable || mayBeBrandName(normalized, lang)) return {
48
+ status: 'incomplete',
49
+ message: `“${name}” matches a generic link phrase, but may identify its destination anyway: a code term keeps its meaning through the punctuation this comparison drops, and a brand or product name (a logo link named “Go”, a plan named “Plus”) is the destination. Check whether this name, read alone in a list of the page's links, says where it goes.`,
50
+ fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
51
+ };
52
+ return {
53
+ status: 'fail',
54
+ 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.`,
55
+ fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
56
+ };
57
+ },
27
58
  });
@@ -50,21 +50,30 @@ function visuallyErased(element) {
50
50
  return false;
51
51
  }
52
52
 
53
- /** The text a sighted user actually sees inside the component (slot-aware). */
54
- function visibleText(nodes) {
53
+ // Form controls a wrapping <label> may contain. Their subtrees are the
54
+ // control's VALUES (a <select>'s options, a <button>'s caption), not the
55
+ // label's text, so a label walk skips them (2026-08-25 overnight audit:
56
+ // "Country <select>" used to read as "Country United KingdomFrance").
57
+ const CONTROL_TAGS = new Set(['input', 'select', 'textarea', 'button', 'meter', 'progress', 'output']);
58
+
59
+ /** The text a sighted user actually sees inside the component (slot-aware).
60
+ * With skipControls, form controls inside the walk are left out: that is
61
+ * the wrapping-<label> case, where only the label's own text is the label. */
62
+ function visibleText(nodes, skipControls = false) {
55
63
  let text = '';
56
64
  for (const node of nodes) {
57
65
  if (node.nodeType === 3 /* TEXT_NODE */) { text += node.textContent; continue; }
58
66
  if (node.nodeType !== 1 /* ELEMENT_NODE */) continue;
59
67
  const tag = node.tagName.toLowerCase();
60
68
  if (tag === 'script' || tag === 'style' || tag === 'noscript' || tag === 'template') continue;
69
+ if (skipControls && CONTROL_TAGS.has(tag)) continue;
61
70
  if (node.hasAttribute('hidden') || visuallyErased(node)) continue;
62
71
  if (tag === 'slot') {
63
72
  const assigned = node.assignedNodes?.() ?? [];
64
- text += visibleText(assigned.length ? assigned : node.childNodes);
73
+ text += visibleText(assigned.length ? assigned : node.childNodes, skipControls);
65
74
  continue;
66
75
  }
67
- text += visibleText(node.childNodes);
76
+ text += visibleText(node.childNodes, skipControls);
68
77
  }
69
78
  return text;
70
79
  }
@@ -90,7 +99,7 @@ export default {
90
99
  // form-label's finding, not a 2.5.3 mismatch.
91
100
  rawVisible = [...(element.labels ?? [])]
92
101
  .filter((label) => !visuallyErased(label))
93
- .map((label) => visibleText(label.childNodes))
102
+ .map((label) => visibleText(label.childNodes, true))
94
103
  .join(' ');
95
104
  } else {
96
105
  rawVisible = visibleText(element.childNodes);
@@ -7,7 +7,9 @@ import { createTargetSizeRule } from './2.5.8-target-size.js';
7
7
  export default createTargetSizeRule({
8
8
  id: 'target-size-enhanced',
9
9
  name: 'Enhanced target size',
10
- tags: ['wcag2aaa', 'wcag255'],
10
+ // 2.5.5 was introduced in WCAG 2.1, so the versioned AAA tag is the true
11
+ // one (retagged 2026-08-25 overnight audit).
12
+ tags: ['wcag21aaa', 'wcag255'],
11
13
  help: 'Interactive targets must be at least 44×44 pixels (AAA)',
12
14
  helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html',
13
15
  min: 44,
@@ -0,0 +1,38 @@
1
+ // WCAG SC 2.5.7 Dragging Movements (Level AA, new in 2.2)
2
+ //
3
+ // Functionality operated by dragging must also work through single-pointer
4
+ // clicks or taps, unless dragging is essential or the behaviour is the
5
+ // user agent's. Custom drag logic is script and mostly invisible to a
6
+ // scan, but one author signal is explicit in the DOM: draggable="true"
7
+ // written on an element. Whether an equivalent no-drag path exists (an
8
+ // up/down button beside a sortable row, a picker beside a slider) is not
9
+ // decidable from markup, so this asks, it never asserts.
10
+ //
11
+ // Scoped to keep the question honest:
12
+ // - only the EXPLICIT attribute counts. Images, links and selections
13
+ // are draggable by browser default (drag-to-desktop, drag-to-text) —
14
+ // that is user-agent behaviour, which the SC itself exempts — so <img>
15
+ // and <a href> are skipped even with the attribute written out: authors
16
+ // write draggable="true" there to re-state or fine-tune a UA default,
17
+ // not to build an interaction.
18
+ // - native range inputs are UA controls: a single click on the track
19
+ // moves the thumb without dragging, which is the alternative already
20
+ // existing. They never carried the attribute anyway.
21
+ // Drag interactions wired purely through pointer events with no draggable
22
+ // attribute are a DISCLOSED gap: listeners are not enumerable from a page.
23
+ export default {
24
+ id: 'drag-alternative',
25
+ name: 'Dragging alternative',
26
+ impact: 'moderate',
27
+ tags: ['wcag22aa', 'wcag257'],
28
+ help: 'Dragging must have a single-pointer alternative',
29
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/dragging-movements.html',
30
+ selector: '[draggable="true" i]:not(img):not(a[href])',
31
+ evaluate() {
32
+ return {
33
+ status: 'incomplete',
34
+ message: 'This element declares itself draggable, and 2.5.7 requires everything achievable by dragging to also work with single clicks or taps — tremors, switch access and head pointers can press but not drag. Check an equivalent no-drag path exists (buttons to reorder, a menu to move, a field to set the value), unless dragging itself is essential here.',
35
+ fix: 'Add a single-pointer way to do the same thing, e.g. up/down buttons beside a sortable item or a "move to" action in a menu.',
36
+ };
37
+ },
38
+ };