pour-engine 1.19.0 → 1.21.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.
@@ -12,7 +12,7 @@ meaning that only a person can settle, even where a rule decides most real pages
12
12
 
13
13
  | SC | Name | Level | Coverage | Rules |
14
14
  |---|---|---|---|---|
15
- | 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
15
+ | 1.1.1 | [Non-text Content](https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html) | A | Partially automated | area-alt, canvas-alt, embed-alt, image-alt, input-image-alt, object-alt, svg-img-alt |
16
16
  | 1.2.1 | [Audio-only and Video-only (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-only-and-video-only-prerecorded.html) | A | Manual review | — |
17
17
  | 1.2.2 | [Captions (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/captions-prerecorded.html) | A | Partially automated | media-captions |
18
18
  | 1.2.3 | [Audio Description or Media Alternative (Prerecorded)](https://www.w3.org/WAI/WCAG22/Understanding/audio-description-or-media-alternative-prerecorded.html) | A | Manual review | — |
@@ -51,7 +51,7 @@ meaning that only a person can settle, even where a rule decides most real pages
51
51
  | 2.1.3 | [Keyboard (No Exception)](https://www.w3.org/WAI/WCAG22/Understanding/keyboard-no-exception.html) | AAA | Partially automated | scrollable-region-focusable |
52
52
  | 2.1.4 | [Character Key Shortcuts](https://www.w3.org/WAI/WCAG22/Understanding/character-key-shortcuts.html) | A | Manual review | — |
53
53
  | 2.2.1 | [Timing Adjustable](https://www.w3.org/WAI/WCAG22/Understanding/timing-adjustable.html) | A | Partially automated | meta-refresh |
54
- | 2.2.2 | [Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Partially automated | pause-stop-hide |
54
+ | 2.2.2 | [Pause, Stop, Hide](https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html) | A | Partially automated | pause-stop-hide, video-loop-motion |
55
55
  | 2.2.3 | [No Timing](https://www.w3.org/WAI/WCAG22/Understanding/no-timing.html) | AAA | Manual review | — |
56
56
  | 2.2.4 | [Interruptions](https://www.w3.org/WAI/WCAG22/Understanding/interruptions.html) | AAA | Manual review | — |
57
57
  | 2.2.5 | [Re-authenticating](https://www.w3.org/WAI/WCAG22/Understanding/re-authenticating.html) | AAA | Manual review | — |
@@ -61,7 +61,7 @@ meaning that only a person can settle, even where a rule decides most real pages
61
61
  | 2.3.3 | [Animation from Interactions](https://www.w3.org/WAI/WCAG22/Understanding/animation-from-interactions.html) | AAA | Manual review | — |
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
- | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Manual review | — |
64
+ | 2.4.3 | [Focus Order](https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html) | A | Partially automated | visual-order-divergence |
65
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 | — |
@@ -91,13 +91,13 @@ meaning that only a person can settle, even where a rule decides most real pages
91
91
  | 3.1.4 | [Abbreviations](https://www.w3.org/WAI/WCAG22/Understanding/abbreviations.html) | AAA | Manual review | — |
92
92
  | 3.1.5 | [Reading Level](https://www.w3.org/WAI/WCAG22/Understanding/reading-level.html) | AAA | Manual review | — |
93
93
  | 3.1.6 | [Pronunciation](https://www.w3.org/WAI/WCAG22/Understanding/pronunciation.html) | AAA | Manual review | — |
94
- | 3.2.1 | [On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html) | A | Manual review | — |
95
- | 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A | Manual review | — |
94
+ | 3.2.1 | [On Focus](https://www.w3.org/WAI/WCAG22/Understanding/on-focus.html) | A | Partially automated | on-input-navigation |
95
+ | 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A | Partially automated | on-input-navigation |
96
96
  | 3.2.3 | [Consistent Navigation](https://www.w3.org/WAI/WCAG22/Understanding/consistent-navigation.html) | AA | Manual review | — |
97
97
  | 3.2.4 | [Consistent Identification](https://www.w3.org/WAI/WCAG22/Understanding/consistent-identification.html) | AA | Manual review | — |
98
98
  | 3.2.5 | [Change on Request](https://www.w3.org/WAI/WCAG22/Understanding/change-on-request.html) | AAA | Manual review | — |
99
99
  | 3.2.6 | [Consistent Help](https://www.w3.org/WAI/WCAG22/Understanding/consistent-help.html) | A | Manual review | — |
100
- | 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A | Manual review | — |
100
+ | 3.3.1 | [Error Identification](https://www.w3.org/WAI/WCAG22/Understanding/error-identification.html) | A | Partially automated | error-message-linkage |
101
101
  | 3.3.2 | [Labels or Instructions](https://www.w3.org/WAI/WCAG22/Understanding/labels-or-instructions.html) | A | Partially automated | form-label |
102
102
  | 3.3.3 | [Error Suggestion](https://www.w3.org/WAI/WCAG22/Understanding/error-suggestion.html) | AA | Manual review | — |
103
103
  | 3.3.4 | [Error Prevention (Legal, Financial, Data)](https://www.w3.org/WAI/WCAG22/Understanding/error-prevention-legal-financial-data.html) | AA | Manual review | — |
@@ -111,7 +111,7 @@ meaning that only a person can settle, even where a rule decides most real pages
111
111
 
112
112
  | SC | Name | Level | Coverage | Rules |
113
113
  |---|---|---|---|---|
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 |
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-label-misuse, aria-valid-refs, button-name, composite-widget-name, dialog-name, frame-title, label-for-valid, nested-interactive, role-required-aria, summary-name, 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:** 30 partially automated · 56 manual — with 74 rules.
117
+ **Totals:** 34 partially automated · 52 manual — with 82 rules.
package/engine/README.md CHANGED
@@ -8,7 +8,11 @@ the product's only engine.
8
8
  Section 508, EN 301 549, or other standards. The full WCAG 2.2 catalog (all
9
9
  86 success criteria) lives in `wcag22.js`, and `COVERAGE.md` (generated by
10
10
  `node scripts/wcag-coverage.js`) shows exactly which criteria are automated,
11
- partially automated, or need human review.
11
+ partially automated, or need human review. `AUDIT.html` is the latest full
12
+ spec-sweep audit of every rule against the spec — over-violation,
13
+ under-violation, and what the engine cannot test. It is a self-contained
14
+ page: open it straight from disk in any browser. Regenerated by running
15
+ the `spec-sweep` skill.
12
16
 
13
17
  ## Provenance
14
18
 
@@ -153,7 +153,7 @@ export function resetAuditCaches() {
153
153
  */
154
154
  export function backgroundColorSource(element) {
155
155
  for (let node = element; node && node.nodeType === 1;
156
- node = node.parentElement ?? node.getRootNode()?.host) {
156
+ node = node.assignedSlot ?? node.parentElement ?? node.getRootNode()?.host) {
157
157
  const style = getComputedStyle(node);
158
158
  if (style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage)) return null;
159
159
  const color = parseColor(style.backgroundColor);
@@ -209,7 +209,13 @@ function resolveBackground(element, doc) {
209
209
  if (color && color.a >= 1) {
210
210
  result = color;
211
211
  } else {
212
- const behind = resolveBackground(element.parentElement ?? element.getRootNode()?.host, doc);
212
+ // The FLAT-tree parent: a slotted element's backdrop chain runs
213
+ // through its slot into the shadow tree it renders in — walking the
214
+ // light parent instead skipped every background painted inside the
215
+ // outer component's shadow (a consent banner's blue container lived
216
+ // there, and its slotted copy was judged against the page white).
217
+ const behind = resolveBackground(
218
+ element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host, doc);
213
219
  result = behind === null ? null : (color && color.a > 0 ? composite(color, behind) : behind);
214
220
  }
215
221
  }
@@ -531,7 +537,12 @@ function pseudoLayers(host) {
531
537
  // (position: fixed, the common full-page-texture pattern) must not
532
538
  // send every element on the page to a human.
533
539
  const alphaBound = Math.min(1, opacityFactor * (style.backgroundImage !== 'none' ? 1 : color.a));
534
- if (alphaBound > 0) layers.push({ film: alphaBound });
540
+ // Position rides along: a FIXED unplaceable pseudo is the full-page
541
+ // overlay pattern and dims everything below the walk; an ABSOLUTE one
542
+ // that defeated the geometry solver (rotation, percentage insets) is
543
+ // a decorative shape that nearer opaque paint covers in normal
544
+ // stacking — the walk needs to know which it is.
545
+ if (alphaBound > 0) layers.push({ film: alphaBound, fixed: style.position === 'fixed' });
535
546
  continue;
536
547
  }
537
548
  if (rect.empty) continue; // placed, and covers nothing
@@ -586,11 +597,33 @@ export function pseudoBackdropForText(element) {
586
597
  let image = null;
587
598
  let settled = false; // an opaque colour or an image hit: deeper layers are covered
588
599
  let film = 0;
600
+ // Paint (an opaque background-color or a background-image) on an element
601
+ // BETWEEN the text and a pseudo's host makes the pseudo's place in the
602
+ // stack unprovable from here: CSS paint order can put an out-of-flow
603
+ // ancestor pseudo above OR below that intermediate paint depending on
604
+ // positioning, z-index and tree order — the full stacking model this walk
605
+ // deliberately does not reimplement. Such layers are surfaced with
606
+ // `beyondPaint` so the caller brackets the verdict instead of asserting
607
+ // against paint that may never show behind the glyphs (a full-page
608
+ // decorative body::before wash under sections with their own solid
609
+ // backgrounds produced 90 false contrast failures on one university home).
610
+ let crossed = false;
611
+ let beyondPaint = false;
589
612
  for (let node = element; node && node.nodeType === 1; node = node.parentElement ?? node.getRootNode()?.host) {
590
613
  for (const layer of pseudoLayers(node)) {
591
- // Films accumulate over the WHOLE chain — even past an opaque hit,
592
- // because an ancestor's ::after can paint above a descendant's pill.
593
- if (layer.film) { film = 1 - (1 - film) * (1 - layer.film); continue; }
614
+ // Films accumulate over the whole chain — an ancestor's fixed ::after
615
+ // (grain overlays, veils) paints above everything below it. But an
616
+ // ABSOLUTE pseudo whose geometry could not be solved (a rotated or
617
+ // percent-positioned decorative shape) sits in normal flow stacking:
618
+ // once opaque paint lies between the text and its host, that paint
619
+ // covers it, and filming the verdict anyway sent every element under
620
+ // a decorative page shape to the review lane (127 reviews on one
621
+ // charity home whose buttons carry their own solid backgrounds).
622
+ if (layer.film) {
623
+ if (!layer.fixed && crossed) continue;
624
+ film = 1 - (1 - film) * (1 - layer.film);
625
+ continue;
626
+ }
594
627
  if (settled) continue;
595
628
  const { rect, color, imageCss } = layer;
596
629
  if (point.x < rect.left || point.x > rect.right || point.y < rect.top || point.y > rect.bottom) continue;
@@ -599,14 +632,28 @@ export function pseudoBackdropForText(element) {
599
632
  // and image geometry travel with it: covering the sample point is a
600
633
  // fact about the BOX, and a no-repeat icon may still paint nowhere
601
634
  // near the text.
602
- if (imageCss) { image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta }; settled = true; continue; }
635
+ if (imageCss) {
636
+ image = { css: imageCss, element: node, box: rect, meta: layer.imageMeta };
637
+ settled = true;
638
+ beyondPaint = beyondPaint || crossed;
639
+ continue;
640
+ }
603
641
  if (!color || color.a === 0) continue;
604
642
  acc = acc ? composite(acc, color) : color;
643
+ beyondPaint = beyondPaint || crossed;
605
644
  if (acc.a >= 1) settled = true;
606
645
  }
646
+ // Crossing updates AFTER the node's own pseudos: a host's ::before
647
+ // paints above its own background, so its own paint never makes its own
648
+ // pseudo ambiguous — only paint on elements nearer the text does.
649
+ if (!crossed) {
650
+ const style = getComputedStyle(node);
651
+ if ((style.backgroundImage !== 'none' && !paintsNothing(style.backgroundImage))
652
+ || (parseColor(style.backgroundColor)?.a ?? 0) >= 1) crossed = true;
653
+ }
607
654
  }
608
- if (image) return film > 0 ? { image, film } : { image };
609
- if (acc) return film > 0 ? { color: acc, film } : { color: acc };
655
+ if (image) return { image, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
656
+ if (acc) return { color: acc, ...(film > 0 && { film }), ...(beyondPaint && { beyondPaint }) };
610
657
  return film > 0 ? { film } : null;
611
658
  }
612
659
 
@@ -727,6 +774,13 @@ export function paintedBackdrop(element) {
727
774
  return { image: layer, scrim };
728
775
  }
729
776
  const style = getComputedStyle(layer);
777
+ // A shadow HOST in the stack hides its shadow tree from the hit-test
778
+ // (elementsFromPoint retargets internal elements to the host), so paint
779
+ // inside it is invisible here: compositing the host as "transparent"
780
+ // and reading layers behind it judged slotted banner text against the
781
+ // page white while the component's shadow painted a blue container
782
+ // between them. Whatever renders inside is unknowable to this walk.
783
+ if (layer.shadowRoot) return 'unresolved';
730
784
  // Blend modes and filters change the paint in ways flat colour math
731
785
  // can't follow — that's a human check, not a resolution.
732
786
  if ((style.mixBlendMode && style.mixBlendMode !== 'normal')
@@ -84,6 +84,16 @@ import reflow from './wcag/1.4.10-reflow.js';
84
84
  import focusVisible from './wcag/2.4.7-focus-visible.js';
85
85
  import focusNotObscured from './wcag/2.4.11-focus-not-obscured.js';
86
86
  import authFieldObstruction from './wcag/3.3.8-auth-field-obstruction.js';
87
+ // 2026-08-10 spec-sweep batch: the six missing failure families plus the two
88
+ // state-dependent heuristics the sweep judged automatable (see COVERAGE.md).
89
+ import embedAlt from './wcag/1.1.1-embed-alt.js';
90
+ import canvasAlt from './wcag/1.1.1-canvas-alt.js';
91
+ import videoLoopMotion from './wcag/2.2.2-video-loop-motion.js';
92
+ import visualOrderDivergence from './wcag/2.4.3-visual-order-divergence.js';
93
+ import onInputNavigation from './wcag/3.2.2-on-input-navigation.js';
94
+ import errorMessageLinkage from './wcag/3.3.1-error-message-linkage.js';
95
+ import compositeWidgetName from './wcag/4.1.2-composite-widget-name.js';
96
+ import summaryName from './wcag/4.1.2-summary-name.js';
87
97
 
88
98
  // --- Best practice ---
89
99
  import headingOrder from './best-practice/heading-order.js';
@@ -171,6 +181,14 @@ export default [
171
181
  focusVisible,
172
182
  focusNotObscured,
173
183
  authFieldObstruction,
184
+ embedAlt,
185
+ canvasAlt,
186
+ videoLoopMotion,
187
+ visualOrderDivergence,
188
+ onInputNavigation,
189
+ errorMessageLinkage,
190
+ compositeWidgetName,
191
+ summaryName,
174
192
  contrastEnhanced,
175
193
  targetSizeEnhanced,
176
194
  headingOrder,
@@ -0,0 +1,43 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ // A roleless <canvas> with no name and no fallback content is the default
3
+ // output of every chart library, and it reaches assistive technology as
4
+ // nothing at all. Whether that is a failure is exactly the question the
5
+ // roleless-<svg> branch of svg-img-alt already models: a WebGL backdrop is
6
+ // decorative, a revenue chart is the content of the page, and no static
7
+ // pass can tell them apart. Same shape, same verdict — review, with the
8
+ // same escape hatches (fallback content, a named enclosing control, or an
9
+ // explicit decorative/labelled role).
10
+ //
11
+ // <canvas role="img"> without a name is already a hard fail via
12
+ // svg-img-alt's [role="img"] arm; this rule only owns the silent default.
13
+ // A size floor keeps sparkline-and-effect noise out: tiny canvases are
14
+ // bullets and flourishes, and flagging them all would bury the chart that
15
+ // matters.
16
+ export default {
17
+ id: 'canvas-alt',
18
+ impact: 'moderate',
19
+ tags: ['wcag2a', 'wcag111'],
20
+ help: 'Meaningful <canvas> graphics need an accessible name or fallback',
21
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
22
+ selector: 'canvas:not([role])',
23
+ evaluate(element, { accessibleName }) {
24
+ if (accessibleName(element)) return { status: 'pass' };
25
+ // Fallback content inside <canvas> is what AT reads — but chart
26
+ // libraries put their bootstrap <script> inside the canvas too, and
27
+ // script text is not fallback anyone hears.
28
+ const fallback = [...element.childNodes]
29
+ .filter((node) => !/^(SCRIPT|STYLE|TEMPLATE)$/.test(node.nodeName))
30
+ .map((node) => node.textContent).join('').trim();
31
+ if (fallback) return { status: 'pass' };
32
+ // Icon-work inside a control the name already covers.
33
+ const control = element.closest('a[href], button, [role="button"], [role="link"]');
34
+ if (control && accessibleName(control)) return { status: 'pass' };
35
+ const rect = element.getBoundingClientRect();
36
+ if (rect.width < 32 || rect.height < 32) return { status: 'pass' };
37
+ return {
38
+ status: 'incomplete',
39
+ message: 'This canvas has no accessible name, no fallback content, and is not marked decorative — if it draws a chart or diagram, screen reader users get nothing at all.',
40
+ fix: 'If decorative, add aria-hidden="true". If meaningful, add role="img" with aria-label="…", or put a text alternative inside the <canvas> as fallback content.',
41
+ };
42
+ },
43
+ };
@@ -0,0 +1,42 @@
1
+ // WCAG SC 1.1.1 Non-text Content (Level A)
2
+ // <embed> is the last uncovered plugin-content element: img, svg, area,
3
+ // object and input[type=image] all have name rules, and a nameless <embed>
4
+ // is the same provable gap — assistive technology can tell users nothing
5
+ // about what the plugin renders.
6
+ //
7
+ // One honest asymmetry with object-alt: an embed that declares TEXT content
8
+ // (type="text/…") embeds a document, not non-text content, so 1.1.1's
9
+ // applicability is itself in question there. That case asks instead of
10
+ // asserting — the user still deserves to know what the embedded document
11
+ // is, but no criterion provably fails.
12
+ export default {
13
+ id: 'embed-alt',
14
+ impact: 'serious',
15
+ tags: ['wcag2a', 'wcag111'],
16
+ help: '<embed> needs an accessible name',
17
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html',
18
+ selector: 'embed',
19
+ evaluate(element, { accessibleName }) {
20
+ if (accessibleName(element)) return { status: 'pass' };
21
+ const type = (element.getAttribute('type') ?? '').trim().toLowerCase();
22
+ const src = (element.getAttribute('src') ?? '').split(/[?#]/)[0].toLowerCase();
23
+ // Document formats: text/*, PDF, XHTML — and typeless embeds whose src
24
+ // extension says document. All carry text, so 1.1.1's applicability is
25
+ // the question, and the question goes to a human.
26
+ const isDocument = type.startsWith('text/')
27
+ || type === 'application/pdf' || type === 'application/xhtml+xml'
28
+ || (!type && /\.(html?|xhtml|pdf|txt)$/.test(src));
29
+ if (isDocument) {
30
+ return {
31
+ status: 'incomplete',
32
+ message: 'This <embed> renders a text document but has no accessible name — assistive technology users get no indication of what the embedded document is. Check it is identified in the surrounding context.',
33
+ fix: 'Add title="…" naming the embedded document.',
34
+ };
35
+ }
36
+ return {
37
+ status: 'fail',
38
+ message: 'This <embed> has no accessible name — screen readers cannot tell users what the embedded content is.',
39
+ fix: 'Add title="…" (or aria-label) describing what the embed shows.',
40
+ };
41
+ },
42
+ };
@@ -9,6 +9,12 @@ import {
9
9
  showRatio,
10
10
  } from '../../lib/contrast.js';
11
11
 
12
+ /** The strict separator set for the pure-decoration exemption: pipes,
13
+ * interpuncts, slashes, dashes and guillemets — one glyph, optionally
14
+ * repeated ("···"). Deliberately excludes brackets, colons, stars and
15
+ * every character that carries grouping, rating or code meaning. */
16
+ const SEPARATOR_GLYPHS = /^([|¦·•∙‧/⁄\\‐‑‒–—―⁃«»‹›-])\1*$/;
17
+
12
18
  /** Serialise a judged colour for the report and the checker link. Composited
13
19
  * channels keep up to two decimals: rounding them to integers changes the
14
20
  * pair enough that the checker scores a different ratio than the audit
@@ -323,7 +329,49 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
323
329
  // The criterion exempts "inactive user interface components".
324
330
  if (element.closest(':disabled, [aria-disabled="true"]')) return { status: 'pass' };
325
331
 
326
- const style = getComputedStyle(element);
332
+ // A shadow HOST's own text renders through its shadow tree: when a
333
+ // <slot> projects it, the text inherits styles from the slot's
334
+ // shadow-side parent, NOT from the host — a consent banner's shadow
335
+ // <p> painted the slotted copy near-white while the host computed
336
+ // black, and the host-styled pair was one no user ever sees. With no
337
+ // slot to project it, the host's own text does not render at all.
338
+ // (Closed shadow roots are undetectable; those hosts keep the old
339
+ // behaviour.)
340
+ let styleSource = element;
341
+ if (element.shadowRoot) {
342
+ const ownTextNode = [...element.childNodes]
343
+ .find((node) => node.nodeType === 3 && node.textContent.trim());
344
+ const slot = ownTextNode?.assignedSlot ?? null;
345
+ if (!slot) return { status: 'pass' }; // unprojected: not rendered
346
+ styleSource = slot;
347
+ }
348
+ const style = getComputedStyle(styleSource);
349
+
350
+ // A lone separator glyph between inline items is 1.4.3 "pure
351
+ // decoration": it does the job a border does (and borders carry no
352
+ // text-contrast requirement), and the ARIA Authoring Practices tell
353
+ // authors to draw exactly these separators in CSS or aria-hidden them.
354
+ // Strictly bounded — one repeated glyph from a fixed separator set,
355
+ // inline, outside code and interactive content, with rendered content
356
+ // on BOTH sides — so Python's `>>>` prompt, code operators, maths in
357
+ // prose and meaning-bearing brackets all keep their verdicts.
358
+ if (SEPARATOR_GLYPHS.test(ownText(element))
359
+ && style.display.startsWith('inline')
360
+ && !element.closest('code, pre, samp, kbd, var, a[href], button, [role="button"], [role="link"], [role="menuitem"], [role="tab"], [role="option"]')) {
361
+ const hasContent = (start, dir) => {
362
+ for (let node = start; node; node = node[dir]) {
363
+ if (node.nodeType === 3 && node.textContent.trim()) return true;
364
+ if (node.nodeType === 1 && (node.textContent.trim()
365
+ || node.matches('img, svg, video, canvas, input, select, button'))) return true;
366
+ }
367
+ return false;
368
+ };
369
+ if (hasContent(element.previousSibling, 'previousSibling')
370
+ && hasContent(element.nextSibling, 'nextSibling')) {
371
+ return { status: 'pass' };
372
+ }
373
+ }
374
+
327
375
  // Controls conveying "disabled" without the attribute: pointer-events:
328
376
  // none on an interactive element is functional inertness — the low
329
377
  // contrast IS the disabled styling (carousel arrows at the end of
@@ -434,7 +482,11 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
434
482
  }
435
483
  }
436
484
 
437
- let background = effectiveBackground(element);
485
+ // For slotted host text, the walk starts at the slot: its flat-tree
486
+ // chain (slot → shadow parents → host → light ancestors) is the one the
487
+ // text actually paints in; effectiveBackground crosses the shadow
488
+ // boundary via getRootNode().host on its own.
489
+ let background = effectiveBackground(styleSource);
438
490
 
439
491
  // Browser-truth paint order at the text's own sample point: sees and
440
492
  // RESOLVES backdrops painted by non-ancestors (positioned siblings,
@@ -524,6 +576,18 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
524
576
  // over the pixels of every hero photo behind a cookie modal: these keep
525
577
  // the human call they have always had.
526
578
  if (veilPaint && (pseudoBack?.image || painted?.image)) return veiledIncomplete();
579
+ // A pseudo layer that sits BEYOND intermediate paint (an element between
580
+ // the text and the pseudo's host carries its own opaque background or
581
+ // image): whether the pseudo paints behind these glyphs or is covered by
582
+ // that nearer paint is a stacking-order question this engine deliberately
583
+ // does not model. Image paint can't be bracketed against the walked
584
+ // colour, so it stays a human call.
585
+ if (pseudoBack?.image && pseudoBack.beyondPaint) {
586
+ return {
587
+ status: 'incomplete',
588
+ message: 'An ancestor\'s pseudo-element paints an image or gradient where this text is, but other backgrounds sit between them — which one is behind the glyphs depends on paint order, so check the contrast by eye.',
589
+ };
590
+ }
527
591
  // Gradient/image paint from a pseudo-element: bracket it by sampling,
528
592
  // exactly as an element's own background-image is bracketed.
529
593
  if (pseudoBack?.image) {
@@ -580,7 +644,31 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
580
644
  }
581
645
  }
582
646
  }
583
- if (pseudoBack?.color) {
647
+ if (pseudoBack?.color && pseudoBack.beyondPaint) {
648
+ // Paint sits between this text and the pseudo's host, so the pseudo
649
+ // may equally be covered by it (the common case: a decorative
650
+ // full-bleed wash on body under sections with their own backgrounds).
651
+ // Bracket instead of choosing: a verdict is only provable when the
652
+ // walked background and the pseudo-as-backdrop land the same side of
653
+ // the threshold; disagreement is a genuine human call.
654
+ const candidate = pseudoBack.color.a >= 1 || !background
655
+ ? pseudoBack.color
656
+ : composite(pseudoBack.color, background);
657
+ if (!background) {
658
+ return { status: 'incomplete', message: 'The background could not be determined — check contrast by eye.' };
659
+ }
660
+ const overWalked = foreground.a < 1 ? composite(foreground, background) : foreground;
661
+ const overPseudo = foreground.a < 1 ? composite(foreground, candidate) : foreground;
662
+ if ((contrastRatio(overWalked, background) >= required)
663
+ !== (contrastRatio(overPseudo, candidate) >= required)) {
664
+ return {
665
+ status: 'incomplete',
666
+ message: 'An ancestor\'s pseudo-element paints where this text is, but other backgrounds sit between them — which colour is behind the glyphs depends on paint order, and the two candidates disagree on the verdict. Check the contrast by eye.',
667
+ };
668
+ }
669
+ // Both candidates agree: judge against the walked background below.
670
+ // The backdrop stays UNVERIFIED — the hazard bracket still applies.
671
+ } else if (pseudoBack?.color) {
584
672
  // Opaque pill: it IS the backdrop. Translucent paint composites over
585
673
  // whatever the walk resolved beneath it. Either way the geometry is
586
674
  // computed, not guessed, so this counts as verified paint truth.
@@ -679,6 +767,19 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
679
767
  const rect = element.getBoundingClientRect();
680
768
  // Zero-area text paints nothing (collapsed badges, empty counters).
681
769
  if (!rect.width || !rect.height) return { status: 'pass' };
770
+ // Text parked wholly left of or above the document is unreachable by any
771
+ // scroll position (skip links pre-focus, sr-only labels at left:-10000px,
772
+ // panels slid out at negative offsets): it is not visually presented, so
773
+ // 1.4.3 has nothing to judge. Scrolling can only reach coordinates >= 0
774
+ // in LTR documents; RTL pages legitimately extend leftward, so they keep
775
+ // the old behavior.
776
+ {
777
+ const win = doc.defaultView;
778
+ if (win && getComputedStyle(doc.documentElement).direction !== 'rtl'
779
+ && (rect.right + win.scrollX <= 0 || rect.bottom + win.scrollY <= 0)) {
780
+ return { status: 'pass' };
781
+ }
782
+ }
682
783
  // A text-shadow can supply the contrast (technique G18 measures the
683
784
  // letters against their halo): white text with a solid dark outline
684
785
  // passes however the fill compares to the backdrop. A parseable solid
@@ -27,6 +27,19 @@ export default {
27
27
  const rect = element.getBoundingClientRect();
28
28
  if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
29
29
  const style = getComputedStyle(element);
30
+ // Text rendered at font-size 0 (icon buttons whose value is a
31
+ // screen-reader label) is not visually presented — nothing to judge.
32
+ if (parseFloat(style.fontSize) === 0) return { status: 'pass' };
33
+ // Controls parked wholly left of or above the document are unreachable
34
+ // by scrolling (sr-only duplicates): same exemption as the main rule.
35
+ {
36
+ const doc = element.ownerDocument;
37
+ const win = doc.defaultView;
38
+ if (win && getComputedStyle(doc.documentElement).direction !== 'rtl'
39
+ && (rect.right + win.scrollX <= 0 || rect.bottom + win.scrollY <= 0)) {
40
+ return { status: 'pass' };
41
+ }
42
+ }
30
43
  // Judge the opacity the text RESTS at, matching the main contrast rule:
31
44
  // a control faded out by an ancestor (fade-in-on-scroll, a collapsed
32
45
  // panel) presents no text at all, and one held at a low opacity presents
@@ -0,0 +1,40 @@
1
+ // WCAG SC 2.2.2 Pause, Stop, Hide (Level A)
2
+ // The muted background hero video. audio-control (1.4.2) rightly passes it:
3
+ // that criterion is about SOUND, and a muted element makes none. But
4
+ // autoplay + loop is moving content that starts automatically and never
5
+ // ends, which settles clauses 1 and 2 of 2.2.2 from the markup alone —
6
+ // exactly as an infinite CSS animation does. Clause 3's pause mechanism may
7
+ // be any control anywhere on the page (a hero often has one), so the
8
+ // finding goes to a human with the evidence attached, never asserted: the
9
+ // same verdict discipline as pause-stop-hide's ticker branch.
10
+ //
11
+ // A <video controls> ships its own pause button and is out of the selector.
12
+ // Icon-scale playback (tiny inline previews) is left alone for the same
13
+ // reason pause-stop-hide skips spinners: flagging every small moving
14
+ // thumbnail would bury the full-bleed hero that matters. Silence there is
15
+ // abstaining, not clearing.
16
+ export default {
17
+ id: 'video-loop-motion',
18
+ impact: 'serious',
19
+ tags: ['wcag2a', 'wcag222'],
20
+ help: 'Looping autoplay video must be pausable',
21
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/pause-stop-hide.html',
22
+ selector: 'video[autoplay][loop]:not([controls])',
23
+ // Movement is a visual matter: an aria-hidden hero still moves on screen.
24
+ visibility: 'visual',
25
+ evaluate(element) {
26
+ // Icon scale means SMALL, not thin: a 1200×60 animated banner strip is
27
+ // a ticker, and only a video small on both axes is spinner-class.
28
+ const rect = element.getBoundingClientRect();
29
+ if (rect.width < 64 && rect.height < 64) return { status: 'pass' };
30
+ // Loaded but not playing: either the browser refused the autoplay or
31
+ // something on the page already paused it — in both cases nothing is
32
+ // moving, and a paused-by-a-control video is the mechanism working.
33
+ if (element.paused && element.readyState >= 2) return { status: 'pass' };
34
+ return {
35
+ status: 'incomplete',
36
+ message: 'This video auto-plays on a loop with no built-in controls: it starts by itself and never ends, so it runs well past the five seconds at which WCAG 2.2.2 requires a way to pause, stop or hide it. Check the page offers a pause control that reaches this video, or that the video is the only content presented.',
37
+ fix: 'Add controls, wire a visible pause button to it, or drop autoplay — and honour prefers-reduced-motion.',
38
+ };
39
+ },
40
+ };
@@ -0,0 +1,92 @@
1
+ // WCAG SC 2.4.3 Focus Order (Level A) — the CSS-reordering slice
2
+ // Whether a focus order "preserves meaning and operability" is a human
3
+ // judgment over the whole page, and stays in the manual checklist. But one
4
+ // documented failure shape is pure geometry: focusable siblings laid out by
5
+ // flex/grid where CSS `order` or a reversed flow direction makes the VISUAL
6
+ // order diverge from the DOM order that Tab actually follows. F44's
7
+ // substance — sequences that jump against the layout — with the evidence
8
+ // measurable.
9
+ //
10
+ // Deliberately narrow so it cannot fire on ordinary pages:
11
+ // - only siblings in the SAME flex/grid container are compared, so
12
+ // legitimate responsive reflow between separate regions never trips it;
13
+ // - only when the container/children actually use `order` or `*-reverse`
14
+ // — plain wrapping, floats and absolute positioning are not evidence;
15
+ // - the verdict is review, not violation: a reversed row of equivalent
16
+ // chips may preserve meaning perfectly well; that call is the human's.
17
+ //
18
+ // The narrowness has a known cost, accepted deliberately: focusables
19
+ // wrapped one level deep (cards — flex container > div > a) never meet the
20
+ // container as direct siblings and are not examined, and grid's native
21
+ // reordering (explicit grid-row/column placement, grid-template-areas,
22
+ // grid-auto-flow: dense) reorders without `order` or `*-reverse` and is not
23
+ // treated as evidence. Both are honest gaps to widen later with measured
24
+ // cases, not silently.
25
+ const FLEXGRID = /^(inline-)?(flex|grid)$/;
26
+
27
+ /** DOM-order focusable children grouped per flex/grid parent. */
28
+ function tabbable(element) {
29
+ if (element.disabled || element.tabIndex < 0) return false;
30
+ return true;
31
+ }
32
+
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) {
36
+ const rows = [];
37
+ for (const entry of [...entries].sort((a, b) => a.rect.top - b.rect.top)) {
38
+ const row = rows.find((candidates) => {
39
+ const first = candidates[0].rect;
40
+ const overlap = Math.min(first.bottom, entry.rect.bottom) - Math.max(first.top, entry.rect.top);
41
+ return overlap > Math.min(first.height, entry.rect.height) / 2;
42
+ });
43
+ if (row) row.push(entry); else rows.push([entry]);
44
+ }
45
+ return rows.flatMap((row) => row.sort((a, b) =>
46
+ rightToLeft ? b.rect.right - a.rect.right : a.rect.left - b.rect.left));
47
+ }
48
+
49
+ export default {
50
+ id: 'visual-order-divergence',
51
+ impact: 'moderate',
52
+ tags: ['wcag2a', 'wcag243'],
53
+ help: 'CSS reordering should not make Tab jump against the visual order',
54
+ helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/focus-order.html',
55
+ selector: 'a[href], button, input:not([type="hidden"]), select, textarea, summary, [tabindex]',
56
+ evaluateAll(elements) {
57
+ const outcomes = new Map();
58
+ const groups = new Map();
59
+ for (const element of elements) {
60
+ if (!tabbable(element)) continue;
61
+ const parent = element.parentElement;
62
+ if (!parent) continue;
63
+ if (!groups.has(parent)) groups.set(parent, []);
64
+ groups.get(parent).push(element);
65
+ }
66
+ for (const [parent, children] of groups) {
67
+ if (children.length < 2) continue;
68
+ const parentStyle = getComputedStyle(parent);
69
+ if (!FLEXGRID.test(parentStyle.display)) continue;
70
+ const reversed = /-reverse$/.test(parentStyle.flexDirection);
71
+ const entries = children
72
+ .map((element) => ({ element, style: getComputedStyle(element) }))
73
+ // Out-of-flow children have no place in the container's sequence.
74
+ .filter(({ style }) => style.position !== 'absolute' && style.position !== 'fixed')
75
+ .map((entry) => ({ ...entry, rect: entry.element.getBoundingClientRect() }))
76
+ .filter(({ rect }) => rect.width > 0 && rect.height > 0);
77
+ if (entries.length < 2) continue;
78
+ const usesOrder = entries.some(({ style }) => style.order !== '0');
79
+ if (!reversed && !usesOrder) continue;
80
+ const visual = visualSequence(entries, parentStyle.direction === 'rtl');
81
+ const diverges = visual.some((entry, i) => entry !== entries[i]);
82
+ if (!diverges) continue;
83
+ const cause = reversed ? `flex-direction: ${parentStyle.flexDirection}` : 'CSS order';
84
+ outcomes.set(entries[0].element, {
85
+ status: 'incomplete',
86
+ message: `Keyboard focus moves through these ${entries.length} controls in DOM order, but ${cause} arranges them differently on screen — Tab will jump against the visual layout. Check the focus sequence still preserves meaning and operability.`,
87
+ fix: 'Reorder the source to match the visual order instead of reordering with CSS, or confirm the divergence is harmless here.',
88
+ });
89
+ }
90
+ return elements.map((element) => outcomes.get(element) ?? { status: 'pass' });
91
+ },
92
+ };