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.
- package/config/project.config.js +1 -1
- package/engine/AUDIT.html +690 -0
- package/engine/COVERAGE.md +8 -8
- package/engine/README.md +5 -1
- package/engine/lib/contrast.js +63 -9
- package/engine/rules/index.js +18 -0
- package/engine/rules/wcag/1.1.1-canvas-alt.js +43 -0
- package/engine/rules/wcag/1.1.1-embed-alt.js +42 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +104 -3
- package/engine/rules/wcag/1.4.3-control-contrast.js +13 -0
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +40 -0
- package/engine/rules/wcag/2.4.3-visual-order-divergence.js +92 -0
- package/engine/rules/wcag/3.2.2-on-input-navigation.js +47 -0
- package/engine/rules/wcag/3.3.1-error-message-linkage.js +69 -0
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +32 -0
- package/engine/rules/wcag/4.1.2-summary-name.js +31 -0
- package/engine/wcag22.js +4 -4
- package/package.json +1 -1
package/engine/COVERAGE.md
CHANGED
|
@@ -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 |
|
|
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 |
|
|
95
|
-
| 3.2.2 | [On Input](https://www.w3.org/WAI/WCAG22/Understanding/on-input.html) | A |
|
|
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 |
|
|
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:**
|
|
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
|
|
package/engine/lib/contrast.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
592
|
-
//
|
|
593
|
-
|
|
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) {
|
|
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
|
|
609
|
-
if (acc) return film > 0
|
|
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')
|
package/engine/rules/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|