pour-engine 1.37.0 → 1.38.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/COVERAGE.md +1 -1
- package/engine/README.md +1 -1
- package/engine/lib/contrast.js +10 -27
- package/engine/lib/dom.js +6 -0
- package/engine/lib/roles.js +6 -0
- package/engine/rules/best-practice/landmark-top-level.js +1 -5
- package/engine/rules/best-practice/landmark-unique.js +2 -5
- package/engine/rules/wcag/1.3.1-definition-list.js +7 -8
- package/engine/rules/wcag/1.3.1-list-structure.js +2 -5
- package/engine/rules/wcag/1.4.12-text-spacing.js +12 -6
- package/engine/rules/wcag/1.4.3-color-contrast.js +8 -7
- package/engine/rules/wcag/2.2.2-video-loop-motion.js +26 -1
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +1 -2
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/COVERAGE.md
CHANGED
|
@@ -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-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
|
|
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, invoker-target, 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
117
|
**Totals:** 39 partially automated · 47 manual — with 88 rules.
|
package/engine/README.md
CHANGED
|
@@ -112,7 +112,7 @@ Nothing in the spec is silently skipped — that claim is the product.
|
|
|
112
112
|
|
|
113
113
|
## Honest current state
|
|
114
114
|
|
|
115
|
-
|
|
115
|
+
88 rules, hardened on real sites and the `testbeds/` pages. Audits walk into
|
|
116
116
|
open shadow roots (targets come back as `host >>> inner` selector chains);
|
|
117
117
|
iframe traversal is the host's job, and the extension does it. Known gaps:
|
|
118
118
|
the full accname spec (ours is a close approximation) and the language
|
package/engine/lib/contrast.js
CHANGED
|
@@ -1040,7 +1040,7 @@ export function viewportVeil(doc) {
|
|
|
1040
1040
|
/** Parse a computed text-shadow list into layers. Computed style serializes
|
|
1041
1041
|
* the colour first ("rgb(0, 0, 0) 1px 0px 2px") with px lengths; author-ish
|
|
1042
1042
|
* orderings are tolerated. */
|
|
1043
|
-
|
|
1043
|
+
function parseTextShadows(cssText) {
|
|
1044
1044
|
if (!cssText || cssText === 'none') return [];
|
|
1045
1045
|
const layers = [];
|
|
1046
1046
|
for (const part of cssText.split(/,(?![^(]*\))/)) {
|
|
@@ -1116,7 +1116,7 @@ export function textShadowNegligible(cssText, fontSize) {
|
|
|
1116
1116
|
* uncertain path keeps the FULL box, erring toward review, never past a
|
|
1117
1117
|
* real overlap. Returns null when the clip leaves nothing paintable.
|
|
1118
1118
|
*/
|
|
1119
|
-
|
|
1119
|
+
function paintableRect(element, rect) {
|
|
1120
1120
|
let { left, top, right, bottom } = rect;
|
|
1121
1121
|
let mode = getComputedStyle(element).position;
|
|
1122
1122
|
for (let a = element.parentElement; a; a = a.parentElement) {
|
|
@@ -1333,7 +1333,7 @@ export function imageLuminanceRange(url, overlays = [], grid = null) {
|
|
|
1333
1333
|
* cover/contain hands sizing to an intrinsic this function does not have, or
|
|
1334
1334
|
* when its position is not a plain length or percentage.
|
|
1335
1335
|
*/
|
|
1336
|
-
|
|
1336
|
+
function backgroundImagePaintRects(element) {
|
|
1337
1337
|
const style = getComputedStyle(element);
|
|
1338
1338
|
const images = splitLayers(style.backgroundImage);
|
|
1339
1339
|
if (!images.length) return [];
|
|
@@ -1492,29 +1492,6 @@ export function rangeWithBackdrop(range, under, overlays = []) {
|
|
|
1492
1492
|
};
|
|
1493
1493
|
}
|
|
1494
1494
|
|
|
1495
|
-
/**
|
|
1496
|
-
* Luminance range of a CSS gradient, from its colour stops. Interpolated
|
|
1497
|
-
* colours stay close enough to their stops' luminances to bracket.
|
|
1498
|
-
*/
|
|
1499
|
-
export function gradientLuminanceRange(backgroundImageCss, overlays = []) {
|
|
1500
|
-
const stops = (backgroundImageCss.match(/rgba?\([^)]+\)/g) ?? []).map(parseColor).filter(Boolean);
|
|
1501
|
-
if (!stops.length || stops.some((c) => c.a < 1)) return null; // translucent stops reveal what's beneath
|
|
1502
|
-
const composited = stops.map((stop) => (overlays.length ? applyOverlays(stop, overlays) : stop));
|
|
1503
|
-
const lums = composited.map(luminance);
|
|
1504
|
-
const min = Math.min(...lums);
|
|
1505
|
-
const max = Math.max(...lums);
|
|
1506
|
-
// The COLOURS at the extremes, not just their luminances: translucent text
|
|
1507
|
-
// takes its presented colour from the pixels beneath, so bracketing that
|
|
1508
|
-
// blend needs the actual rgb to composite against, and luminance alone
|
|
1509
|
-
// cannot be un-gamma'd back into one.
|
|
1510
|
-
return {
|
|
1511
|
-
min,
|
|
1512
|
-
max,
|
|
1513
|
-
minColor: composited[lums.indexOf(min)],
|
|
1514
|
-
maxColor: composited[lums.indexOf(max)],
|
|
1515
|
-
};
|
|
1516
|
-
}
|
|
1517
|
-
|
|
1518
1495
|
/** The layers of a computed background-image list, split on top-level
|
|
1519
1496
|
* commas only: commas inside url() and gradient() belong to the layer. */
|
|
1520
1497
|
export function splitBackgroundLayers(backgroundImageCss) {
|
|
@@ -1713,10 +1690,16 @@ export function pseudoTextColors(element, style) {
|
|
|
1713
1690
|
return found;
|
|
1714
1691
|
}
|
|
1715
1692
|
|
|
1693
|
+
/** The font weight from which text counts as bold for the large-scale
|
|
1694
|
+
* test. WCAG's glossary says "bold" and no number; CSS's bold keyword is
|
|
1695
|
+
* 700, and the semibold 600 that most modern faces ship reads as bold at
|
|
1696
|
+
* 14pt on screen, so it counts here too (David, 2026-09-02; was 700). */
|
|
1697
|
+
export const BOLD_WEIGHT = 600;
|
|
1698
|
+
|
|
1716
1699
|
/** WCAG "large text": ≥24px, or ≥18.66px (14pt) bold. */
|
|
1717
1700
|
export function isLargeText(style) {
|
|
1718
1701
|
const size = parseFloat(style.fontSize);
|
|
1719
1702
|
const weight = parseInt(style.fontWeight, 10) || 400;
|
|
1720
1703
|
// 18pt and 14pt at CSS's 96dpi: 24px, and 56/3 = 18.666…px exactly.
|
|
1721
|
-
return size >= 24 || (size >= 56 / 3 && weight >=
|
|
1704
|
+
return size >= 24 || (size >= 56 / 3 && weight >= BOLD_WEIGHT);
|
|
1722
1705
|
}
|
package/engine/lib/dom.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
// DOM helpers shared by all rules.
|
|
2
2
|
|
|
3
|
+
/** Elements that never reach the accessibility tree, so they cannot break
|
|
4
|
+
* the structure of a list or a description list. STYLE belongs with them:
|
|
5
|
+
* CSS-in-JS libraries inject <style> right beside the markup, and it was
|
|
6
|
+
* being counted as a stray child that broke the list. */
|
|
7
|
+
export const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
8
|
+
|
|
3
9
|
/**
|
|
4
10
|
* The audit context plus every OPEN shadow root beneath it, hosts before
|
|
5
11
|
* their shadow trees. Collected once per audit so each rule can query every
|
package/engine/lib/roles.js
CHANGED
|
@@ -24,6 +24,12 @@ export const GLOBAL_ARIA = new Set([
|
|
|
24
24
|
'live', 'owns', 'relevant', 'roledescription', 'braillelabel', 'brailleroledescription',
|
|
25
25
|
]);
|
|
26
26
|
|
|
27
|
+
/** The eight landmark roles (ARIA 1.2, Landmark Roles). Shared by the
|
|
28
|
+
* landmark rules, the landmark lens and the screen reader's rotor. */
|
|
29
|
+
export const LANDMARK_ROLES = new Set([
|
|
30
|
+
'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
|
|
31
|
+
]);
|
|
32
|
+
|
|
27
33
|
/** Attributes each role supports IN ADDITION to the globals.
|
|
28
34
|
*
|
|
29
35
|
* Includes the four attributes ARIA 1.2 removed from the GLOBAL set
|
|
@@ -3,11 +3,7 @@
|
|
|
3
3
|
// page outline. Complementary joins via the aside demotion semantics in
|
|
4
4
|
// lib/roles.js: only an aside that actually reaches the accessibility tree
|
|
5
5
|
// as complementary owes top-levelness.
|
|
6
|
-
import { implicitRole } from '../../lib/roles.js';
|
|
7
|
-
|
|
8
|
-
const LANDMARK_ROLES = new Set([
|
|
9
|
-
'main', 'navigation', 'banner', 'contentinfo', 'complementary', 'region', 'search', 'form',
|
|
10
|
-
]);
|
|
6
|
+
import { implicitRole, LANDMARK_ROLES } from '../../lib/roles.js';
|
|
11
7
|
|
|
12
8
|
/** HTML-AAM (form, section): "If a form has no accessible name, do not
|
|
13
9
|
* expose the element as a landmark", and a section is a region only with
|
|
@@ -1,18 +1,15 @@
|
|
|
1
1
|
// ARIA APG landmark pattern: same-type landmarks need distinguishing
|
|
2
2
|
// labels, and banner / contentinfo / main must be unique per page outright
|
|
3
3
|
// (two "banners" are ambiguous however well they're labelled).
|
|
4
|
-
import { implicitRole } from '../../lib/roles.js';
|
|
4
|
+
import { implicitRole, LANDMARK_ROLES } from '../../lib/roles.js';
|
|
5
5
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
6
6
|
|
|
7
7
|
const SINGLETON = new Set(['banner', 'contentinfo', 'main']);
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// Only the eight ARIA landmark roles (lib/roles.js) count. An explicit
|
|
10
10
|
// non-landmark role on a landmark element (<nav role="menubar">) makes it a
|
|
11
11
|
// widget, not a landmark, so it has nothing to be distinguished from
|
|
12
12
|
// (2026-08-25 overnight audit).
|
|
13
|
-
const LANDMARK_ROLES = new Set([
|
|
14
|
-
'banner', 'complementary', 'contentinfo', 'form', 'main', 'navigation', 'region', 'search',
|
|
15
|
-
]);
|
|
16
13
|
|
|
17
14
|
/** Landmark role of the element in the accessibility tree — explicit role
|
|
18
15
|
* first, else the implicit role (which handles header/footer demotion
|
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
+
import { NEVER_RENDERED } from '../../lib/dom.js';
|
|
3
|
+
|
|
2
4
|
const ALLOWED = new Set(['DT', 'DD', 'DIV']);
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// determine, and every dt/dd pair stays intact around an element that has
|
|
9
|
-
// no tree presence (2026-08-25 overnight audit).
|
|
10
|
-
const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
5
|
+
// NEVER_RENDERED cannot corrupt the term/description pairing: HTML's content
|
|
6
|
+
// model does exclude style from dl, but validity is not the criterion;
|
|
7
|
+
// 1.3.1 is about relationships assistive technology can determine, and
|
|
8
|
+
// every dt/dd pair stays intact around an element that has no tree
|
|
9
|
+
// presence (2026-08-25 overnight audit).
|
|
11
10
|
|
|
12
11
|
export default {
|
|
13
12
|
id: 'definition-list',
|
|
@@ -1,9 +1,6 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
// <style> right where the markup sits, and it was being counted as a stray
|
|
5
|
-
// child that broke the list.
|
|
6
|
-
const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
2
|
+
import { NEVER_RENDERED } from '../../lib/dom.js';
|
|
3
|
+
|
|
7
4
|
const ITEM_ROLES = ['listitem', 'presentation', 'none'];
|
|
8
5
|
|
|
9
6
|
export default {
|
|
@@ -4,13 +4,19 @@
|
|
|
4
4
|
// The override is injected and removed inside one synchronous pass — the
|
|
5
5
|
// engine only yields to the renderer between rules, so the page never
|
|
6
6
|
// paints the probed state.
|
|
7
|
-
// The probe reflows the whole page for a moment.
|
|
7
|
+
// The probe reflows the whole page for a moment. Three guards keep that
|
|
8
8
|
// invisible to the user: scroll anchoring is disabled while the override
|
|
9
9
|
// is in (or Chrome re-anchors during the inflated layout and leaves the
|
|
10
10
|
// page scrolled somewhere else when the probe lifts — measured at 884px
|
|
11
|
-
// of drift on a long report page)
|
|
12
|
-
//
|
|
13
|
-
|
|
11
|
+
// of drift on a long report page); the document keeps at least its own
|
|
12
|
+
// height, because a page set with a line height above 1.5 gets SHORTER
|
|
13
|
+
// under the override, the browser clamps the scroll position to the new
|
|
14
|
+
// end, and that clamp paints (measured on rocketvalidator.com: 2500px to
|
|
15
|
+
// 1966px in one frame, David, 2026-09-02); and the window scroll is put
|
|
16
|
+
// back instantly after removal, instantly whatever the page's own
|
|
17
|
+
// scroll-behavior says, since a smooth restore is the visible half of the
|
|
18
|
+
// jump.
|
|
19
|
+
const override = (minHeight) => `html { overflow-anchor: none !important; min-height: ${minHeight}px !important; }
|
|
14
20
|
* {
|
|
15
21
|
line-height: 1.5 !important;
|
|
16
22
|
letter-spacing: 0.12em !important;
|
|
@@ -56,7 +62,7 @@ export default {
|
|
|
56
62
|
const scrollY = win?.scrollY ?? 0;
|
|
57
63
|
const probe = doc.createElement('style');
|
|
58
64
|
probe.dataset.pourAudit = 'probe';
|
|
59
|
-
probe.textContent =
|
|
65
|
+
probe.textContent = override(doc.documentElement.scrollHeight);
|
|
60
66
|
doc.documentElement.append(probe);
|
|
61
67
|
let after;
|
|
62
68
|
try {
|
|
@@ -68,7 +74,7 @@ export default {
|
|
|
68
74
|
// The audit must never move the page: put the scroll back exactly
|
|
69
75
|
// where the user had it, after the removal's own reflow settles.
|
|
70
76
|
void doc.documentElement.offsetHeight;
|
|
71
|
-
if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo(scrollX, scrollY);
|
|
77
|
+
if (win && (win.scrollX !== scrollX || win.scrollY !== scrollY)) win.scrollTo({ left: scrollX, top: scrollY, behavior: 'instant' });
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
return elements.map((element, i) => {
|
|
@@ -6,8 +6,7 @@ import {
|
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects, inZeroClipSubtree,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
|
-
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors,
|
|
10
|
-
} from '../../lib/contrast.js';
|
|
9
|
+
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT } from '../../lib/contrast.js';
|
|
11
10
|
|
|
12
11
|
/** The first url() among a background-image list's layers, or null. */
|
|
13
12
|
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
@@ -407,7 +406,8 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
407
406
|
*/
|
|
408
407
|
export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
409
408
|
// When the colour pair already clears the LARGE-scale minimum, the miss
|
|
410
|
-
// is about scale, not colour: name the boundary (14pt bold / 18pt
|
|
409
|
+
// is about scale, not colour: name the boundary (14pt bold / 18pt, bold
|
|
410
|
+
// meaning BOLD_WEIGHT and up), or
|
|
411
411
|
// a bold-but-just-under button reads as the checker ignoring its weight.
|
|
412
412
|
const largeScaleNoteFor = (style, required, ratio) => {
|
|
413
413
|
if (required !== thresholds.normal || ratio < thresholds.large) return '';
|
|
@@ -418,15 +418,16 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
418
418
|
// twentieth of a pixel of a cutoff the size is shown to two decimals,
|
|
419
419
|
// and when even that rounds onto the cutoff it is named as just under
|
|
420
420
|
// it. (2026-08-25 overnight audit, defect 9.)
|
|
421
|
-
const
|
|
422
|
-
const
|
|
421
|
+
const bold = weightNum >= BOLD_WEIGHT;
|
|
422
|
+
const cutoff = bold ? 56 / 3 : 24;
|
|
423
|
+
const cutoffText = bold ? '18.67px' : '24px';
|
|
423
424
|
const nearCutoff = Math.abs(sizePx - cutoff) < 0.05;
|
|
424
425
|
let shown = nearCutoff ? `${sizePx.toFixed(2)}px` : `${Math.round(sizePx * 10) / 10}px`;
|
|
425
426
|
if (nearCutoff && parseFloat(shown) >= parseFloat(cutoffText)) shown = `just under ${cutoffText}`;
|
|
426
|
-
if (
|
|
427
|
+
if (bold && sizePx < 56 / 3) {
|
|
427
428
|
return ` This text is bold at ${shown}; bold text counts as large scale from 18.67px (14pt), where the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
428
429
|
}
|
|
429
|
-
if (
|
|
430
|
+
if (!bold && sizePx >= 56 / 3 && sizePx < 24) {
|
|
430
431
|
return ` At ${shown} regular weight this is not large scale; from 24px (18pt), or bold at this size, the ${thresholds.large}:1 minimum would apply and these colours would pass.`;
|
|
431
432
|
}
|
|
432
433
|
return '';
|
|
@@ -8,11 +8,35 @@
|
|
|
8
8
|
// finding goes to a human with the evidence attached, never asserted: the
|
|
9
9
|
// same verdict discipline as pause-stop-hide's ticker branch.
|
|
10
10
|
//
|
|
11
|
+
// One shape of that mechanism is stated in the markup and needs no human:
|
|
12
|
+
// a visible button whose aria-controls names the video, or a container
|
|
13
|
+
// holding it. That is what aria-controls is for, the element whose
|
|
14
|
+
// presence or contents the control changes, and a page that wires its
|
|
15
|
+
// pause button that way has answered the question the review would ask
|
|
16
|
+
// (pour.dev's own hero and sense videos, David, 2026-09-02). A button that
|
|
17
|
+
// merely sits near the video, or one that is hidden, settles nothing.
|
|
18
|
+
//
|
|
11
19
|
// A <video controls> ships its own pause button and is out of the selector.
|
|
12
20
|
// Icon-scale playback (tiny inline previews) is left alone for the same
|
|
13
21
|
// reason pause-stop-hide skips spinners: flagging every small moving
|
|
14
22
|
// thumbnail would bury the full-bleed hero that matters. Silence there is
|
|
15
23
|
// abstaining, not clearing.
|
|
24
|
+
/** A visible, enabled button whose aria-controls names the video or an
|
|
25
|
+
* ancestor of it, looked up in the video's own document or shadow tree. */
|
|
26
|
+
function pauseControlFor(video, isVisible) {
|
|
27
|
+
const root = video.getRootNode();
|
|
28
|
+
if (!root.querySelectorAll) return null;
|
|
29
|
+
const byId = (id) => (root.getElementById ? root.getElementById(id) : root.querySelector(`#${CSS.escape(id)}`));
|
|
30
|
+
for (const control of root.querySelectorAll('button[aria-controls], [role="button"][aria-controls], input[type="button"][aria-controls]')) {
|
|
31
|
+
if (control.disabled || !isVisible(control)) continue;
|
|
32
|
+
for (const id of control.getAttribute('aria-controls').split(/\s+/)) {
|
|
33
|
+
const target = id && byId(id);
|
|
34
|
+
if (target && (target === video || target.contains(video))) return control;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
export default {
|
|
17
41
|
id: 'video-loop-motion',
|
|
18
42
|
name: 'Looping video control',
|
|
@@ -23,7 +47,7 @@ export default {
|
|
|
23
47
|
selector: 'video[autoplay][loop]:not([controls])',
|
|
24
48
|
// Movement is a visual matter: an aria-hidden hero still moves on screen.
|
|
25
49
|
visibility: 'visual',
|
|
26
|
-
evaluate(element) {
|
|
50
|
+
evaluate(element, { isVisible }) {
|
|
27
51
|
// Icon scale means SMALL, not thin: a 1200×60 animated banner strip is
|
|
28
52
|
// a ticker, and only a video small on both axes is spinner-class.
|
|
29
53
|
const rect = element.getBoundingClientRect();
|
|
@@ -32,6 +56,7 @@ export default {
|
|
|
32
56
|
// something on the page already paused it — in both cases nothing is
|
|
33
57
|
// moving, and a paused-by-a-control video is the mechanism working.
|
|
34
58
|
if (element.paused && element.readyState >= 2) return { status: 'pass' };
|
|
59
|
+
if (pauseControlFor(element, isVisible)) return { status: 'pass' };
|
|
35
60
|
return {
|
|
36
61
|
status: 'incomplete',
|
|
37
62
|
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.',
|
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
3
|
+
import { effectiveRole, LANDMARK_ROLES } from '../../lib/roles.js';
|
|
4
4
|
|
|
5
5
|
// ARIA11 lists the landmark roles: banner, complementary, contentinfo,
|
|
6
6
|
// form, main, navigation, region, search. Judged by EFFECTIVE role, not tag
|
|
@@ -10,7 +10,6 @@ import { effectiveRole } from '../../lib/roles.js';
|
|
|
10
10
|
// and missed the second. region and form are landmarks only when named
|
|
11
11
|
// (ARIA 1.2 requires an accessible name for region; HTML-AAM maps form to
|
|
12
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
13
|
const NAMED_ONLY = new Set(['form', 'region']);
|
|
15
14
|
const LANDMARK_CANDIDATES = 'main, header, footer, nav, aside, section, form, [role]';
|
|
16
15
|
const hasName = (el) => Boolean(
|
package/package.json
CHANGED