pour-engine 1.38.0 → 1.41.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 +24 -690
- package/engine/COVERAGE.md +4 -2
- package/engine/README.md +9 -4
- package/engine/index.js +34 -8
- package/engine/lib/accessible-name.js +81 -64
- package/engine/lib/contrast.js +111 -10
- package/engine/lib/dom.js +78 -18
- package/engine/lib/roles.js +18 -3
- package/engine/rules/best-practice/fieldset-legend.js +11 -5
- package/engine/rules/best-practice/landmark-unique.js +9 -1
- package/engine/rules/best-practice/region.js +10 -2
- package/engine/rules/wcag/1.1.1-area-alt.js +6 -1
- package/engine/rules/wcag/1.1.1-image-alt.js +2 -1
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +14 -17
- package/engine/rules/wcag/1.3.1-aria-required-children.js +20 -8
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +7 -10
- package/engine/rules/wcag/1.3.1-definition-list.js +42 -3
- package/engine/rules/wcag/1.3.1-form-label.js +12 -29
- package/engine/rules/wcag/1.3.1-list-structure.js +4 -3
- package/engine/rules/wcag/1.3.1-table-headers.js +23 -2
- package/engine/rules/wcag/1.3.4-orientation.js +117 -48
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +21 -8
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +38 -8
- package/engine/rules/wcag/1.4.10-reflow.js +3 -3
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +9 -1
- package/engine/rules/wcag/1.4.12-text-spacing.js +15 -2
- package/engine/rules/wcag/1.4.3-color-contrast.js +79 -7
- package/engine/rules/wcag/1.4.3-control-contrast.js +35 -6
- package/engine/rules/wcag/1.4.4-meta-viewport.js +7 -2
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +5 -5
- package/engine/rules/wcag/2.2.1-meta-refresh.js +39 -3
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +5 -5
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +2 -2
- package/engine/rules/wcag/2.4.2-document-title.js +7 -8
- package/engine/rules/wcag/2.4.4-link-text-generic.js +6 -5
- package/engine/rules/wcag/2.4.9-link-text-generic-only.js +8 -23
- package/engine/rules/wcag/2.5.8-target-size.js +201 -47
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +6 -12
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +4 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +6 -5
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +8 -3
- package/engine/rules/wcag/4.1.2-button-name.js +1 -1
- package/engine/rules/wcag/4.1.2-composite-widget-name.js +1 -1
- package/engine/rules/wcag/4.1.2-invoker-target.js +2 -1
- package/engine/rules/wcag/4.1.2-label-for-valid.js +2 -2
- package/engine/rules/wcag/4.1.2-nested-interactive.js +20 -8
- package/engine/rules/wcag/4.1.2-valid-role.js +6 -1
- package/engine/wcag22.js +7 -7
- package/package.json +1 -1
|
@@ -6,7 +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, BOLD_WEIGHT } from '../../lib/contrast.js';
|
|
9
|
+
showRatio, splitBackgroundLayers, backgroundLayerUrl, sampleGridFor, opacityGroupPaint, pseudoTextColors, BOLD_WEIGHT, hasPaintEffects, isolatedBlendBackdrop } from '../../lib/contrast.js';
|
|
10
10
|
|
|
11
11
|
/** The first url() among a background-image list's layers, or null. */
|
|
12
12
|
const firstLayerUrl = (css) => splitBackgroundLayers(css ?? '').map(backgroundLayerUrl).find(Boolean) ?? null;
|
|
@@ -297,7 +297,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
297
297
|
let what = 'image';
|
|
298
298
|
const grid = sampleGridFor(extent);
|
|
299
299
|
if (source.tagName === 'IMG') {
|
|
300
|
-
range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid);
|
|
300
|
+
range = await imageLuminanceRange(source.currentSrc || source.src, overlays, grid, under);
|
|
301
301
|
} else {
|
|
302
302
|
const css = source.css ?? getComputedStyle(source).backgroundImage;
|
|
303
303
|
if (!css || css === 'none') return null;
|
|
@@ -314,7 +314,7 @@ async function sampledVerdict(source, foreground, required, doc, overlays = [],
|
|
|
314
314
|
if (url) {
|
|
315
315
|
let absolute;
|
|
316
316
|
try { absolute = new URL(url, doc.baseURI).href; } catch { return null; }
|
|
317
|
-
range = await imageLuminanceRange(absolute, overlays, grid);
|
|
317
|
+
range = await imageLuminanceRange(absolute, overlays, grid, under);
|
|
318
318
|
} else if (layer.includes('gradient(')) {
|
|
319
319
|
what = 'gradient';
|
|
320
320
|
const stops = gradientStops(layer);
|
|
@@ -539,6 +539,10 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
539
539
|
const fill = parseColor(style.webkitTextFillColor);
|
|
540
540
|
const clipsText = /\btext\b/.test(style.webkitBackgroundClip ?? '') || /\btext\b/.test(style.backgroundClip ?? '');
|
|
541
541
|
if (clipsText) {
|
|
542
|
+
if (hasPaintEffects(styleSource)) {
|
|
543
|
+
return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented by this text and its background. Check the resulting contrast by eye.' };
|
|
544
|
+
}
|
|
545
|
+
|
|
542
546
|
const behind = effectiveBackground(element.parentElement ?? element);
|
|
543
547
|
const fgRange = style.backgroundImage !== 'none'
|
|
544
548
|
? sampledGradientRange(splitBackgroundLayers(style.backgroundImage)[0] ?? '') : null;
|
|
@@ -559,6 +563,38 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
559
563
|
// Visually hidden text (sr-only, image replacement) has no visual
|
|
560
564
|
// presentation for contrast to apply to — screen readers still read it.
|
|
561
565
|
if (textVisuallyHidden(element, style, foreground)) return { status: 'pass' };
|
|
566
|
+
// A leaf's own multiply blend over flat paint is exact per Compositing
|
|
567
|
+
// Level 1: B(Cb, Cs) = Cb * Cs. Keep groups, filters and generated paint
|
|
568
|
+
// outside this narrow path; its backdrop is resolved below as usual.
|
|
569
|
+
const ownMultiply = style.mixBlendMode === 'multiply'
|
|
570
|
+
&& styleSource === element && !element.children.length && !element.shadowRoot
|
|
571
|
+
&& parseColor(style.backgroundColor)?.a === 0 && style.backgroundImage === 'none'
|
|
572
|
+
&& style.filter === 'none' && (!style.backdropFilter || style.backdropFilter === 'none')
|
|
573
|
+
&& (!style.textShadow || style.textShadow === 'none')
|
|
574
|
+
&& !hasPaintEffects(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host)
|
|
575
|
+
&& !isolatedBlendBackdrop(element.assignedSlot ?? element.parentElement ?? element.getRootNode()?.host)
|
|
576
|
+
&& !['::before', '::after'].some((pseudo) => {
|
|
577
|
+
const pseudoStyle = getComputedStyle(element, pseudo);
|
|
578
|
+
const content = pseudoStyle.content;
|
|
579
|
+
// U+2060 only controls line breaking and has no glyph. A plain
|
|
580
|
+
// inline joiner with no independent box paint does not add a
|
|
581
|
+
// painted source to this leaf's multiply group.
|
|
582
|
+
const bareJoiner = (content === '"\u2060"' || content === "'\u2060'")
|
|
583
|
+
&& pseudoStyle.display === 'inline' && pseudoStyle.position === 'static'
|
|
584
|
+
&& pseudoStyle.cssFloat === 'none'
|
|
585
|
+
&& parseColor(pseudoStyle.backgroundColor)?.a === 0
|
|
586
|
+
&& ['backgroundImage', 'boxShadow', 'textShadow', 'transform', 'filter', 'backdropFilter', 'maskImage', 'webkitMaskImage']
|
|
587
|
+
.every((key) => !pseudoStyle[key] || pseudoStyle[key] === 'none')
|
|
588
|
+
&& ['Top', 'Right', 'Bottom', 'Left'].every((side) =>
|
|
589
|
+
parseFloat(pseudoStyle[`padding${side}`]) === 0 && parseFloat(pseudoStyle[`border${side}Width`]) === 0)
|
|
590
|
+
&& pseudoStyle.outlineStyle === 'none'
|
|
591
|
+
&& (!pseudoStyle.mixBlendMode || pseudoStyle.mixBlendMode === 'normal');
|
|
592
|
+
if (bareJoiner) return false;
|
|
593
|
+
return content && content !== 'none' && content !== 'normal' && content !== '""';
|
|
594
|
+
});
|
|
595
|
+
if (hasPaintEffects(styleSource) && !ownMultiply) {
|
|
596
|
+
return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented by this text and its background. Check the resulting contrast by eye.' };
|
|
597
|
+
}
|
|
562
598
|
if (!foreground) {
|
|
563
599
|
return { status: 'incomplete', message: 'The text colour could not be parsed — check contrast by eye.' };
|
|
564
600
|
}
|
|
@@ -582,6 +618,26 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
582
618
|
.map(({ pseudo, color, style: pseudoStyle }) => ({
|
|
583
619
|
origin: pseudo, color, required: isLargeText(pseudoStyle) ? thresholds.large : thresholds.normal,
|
|
584
620
|
}));
|
|
621
|
+
// A first-line override can paint every glyph. With no element
|
|
622
|
+
// descendants and one actual line, the base colour paints nothing and
|
|
623
|
+
// must not manufacture a failure. Wrapped text retains its base colour.
|
|
624
|
+
let basePaints = true;
|
|
625
|
+
if (!element.childElementCount && alternates.some((a) => a.origin === '::first-line')) {
|
|
626
|
+
const range = doc.createRange();
|
|
627
|
+
range.selectNodeContents(element);
|
|
628
|
+
const lines = [...range.getClientRects()].filter((r) => r.width > 0 && r.height > 0);
|
|
629
|
+
if (lines.length && lines.every((r) => Math.abs(r.top - lines[0].top) < 1)) {
|
|
630
|
+
const generated = ['::before', '::after'].some((pseudo) => {
|
|
631
|
+
const generatedStyle = getComputedStyle(element, pseudo);
|
|
632
|
+
return generatedStyle.display !== 'none'
|
|
633
|
+
&& !['none', 'normal'].includes(generatedStyle.content);
|
|
634
|
+
});
|
|
635
|
+
// Range does not include generated content. A generated block can
|
|
636
|
+
// occupy the first formatted line while the own text sits below it.
|
|
637
|
+
if (generated) return { status: 'incomplete', message: 'Generated content may occupy this element’s first line, so the colours that actually paint its text cannot be identified from text ranges alone. Check contrast by eye.' };
|
|
638
|
+
basePaints = false;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
585
641
|
const worstCandidate = (candidates, backdrop) => candidates.reduce((worst, candidate) => {
|
|
586
642
|
const shown = candidate.color.a < 1 ? composite(candidate.color, backdrop) : candidate.color;
|
|
587
643
|
const margin = contrastRatio(shown, backdrop) / candidate.required;
|
|
@@ -589,7 +645,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
589
645
|
}, null);
|
|
590
646
|
if (alternates.length) {
|
|
591
647
|
const estimate = effectiveBackground(styleSource) ?? { r: 255, g: 255, b: 255, a: 1 };
|
|
592
|
-
const pick = worstCandidate([{ origin: null, color: foreground, required }, ...alternates], estimate);
|
|
648
|
+
const pick = worstCandidate([...(basePaints ? [{ origin: null, color: foreground, required }] : []), ...alternates], estimate);
|
|
593
649
|
foreground = pick.color;
|
|
594
650
|
required = pick.required;
|
|
595
651
|
foregroundOrigin = pick.origin;
|
|
@@ -604,6 +660,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
604
660
|
// Near-zero resting opacity means the text isn't visually presented.
|
|
605
661
|
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
606
662
|
if (opacity < 0.05) return { status: 'pass' };
|
|
663
|
+
if (ownMultiply && (opacity < 1 || alternates.length)) {
|
|
664
|
+
return { status: 'incomplete', message: 'This text blends as a group or uses additional text paint. Check its presented contrast by eye.' };
|
|
665
|
+
}
|
|
607
666
|
if (opacity < 1) {
|
|
608
667
|
// Group opacity with a background INSIDE the group: the browser blends
|
|
609
668
|
// text and background together over what lies behind the carrier, so
|
|
@@ -696,6 +755,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
696
755
|
// images (external-link arrows etc.) don't affect the text's contrast.
|
|
697
756
|
const imageSource = backgroundImageSource(element);
|
|
698
757
|
if (imageSource) {
|
|
758
|
+
if (ownMultiply) return { status: 'incomplete', message: 'This text multiplies against an image or gradient. Its contrast depends on the pixels behind each glyph; check it by eye.' };
|
|
699
759
|
const { relation, intrinsic, dimensionless, isGradient, sizedSmall, paint } = await imageVsText(imageSource, element, doc);
|
|
700
760
|
if (relation !== 'clear') {
|
|
701
761
|
// imageSource.overlays are the translucent layers painted between the
|
|
@@ -761,6 +821,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
761
821
|
// hero panels, overlay scrims) that the ancestor walk cannot — instead
|
|
762
822
|
// of merely flagging them for a human.
|
|
763
823
|
const painted = paintedBackdrop(element);
|
|
824
|
+
if (ownMultiply && painted?.image) {
|
|
825
|
+
return { status: 'incomplete', message: 'This text multiplies against image paint. Check its presented contrast by eye.' };
|
|
826
|
+
}
|
|
764
827
|
// A translucent viewport-scale veil is painted ON TOP of this text
|
|
765
828
|
// (modal scrim, loading overlay, consent dimmer): the user sees the text
|
|
766
829
|
// through it, so the resting colours are not the presented ones — and if
|
|
@@ -801,6 +864,9 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
801
864
|
// otherwise sent every element on the page to the review lane, and a
|
|
802
865
|
// genuine 4.1:1 failure drowned among 163 siblings that were fine.
|
|
803
866
|
const pseudoResolved = pseudoBackdropForText(element);
|
|
867
|
+
if (ownMultiply && pseudoResolved) {
|
|
868
|
+
return { status: 'incomplete', message: 'Pseudo-element paint affects the backdrop of this blended text. Check its presented contrast by eye.' };
|
|
869
|
+
}
|
|
804
870
|
const film = pseudoResolved?.film ?? 0;
|
|
805
871
|
let pseudoBack = pseudoResolved?.color || pseudoResolved?.image ? pseudoResolved : null;
|
|
806
872
|
// The pseudo's BOX covering the text says nothing about its IMAGE doing
|
|
@@ -959,12 +1025,18 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
959
1025
|
if (alternates.length) {
|
|
960
1026
|
const dim = (color) => (opacity < 1 ? { ...color, a: color.a * opacity } : color);
|
|
961
1027
|
const base = { origin: null, color: dim(baseForeground), required: baseRequired };
|
|
962
|
-
const pick = worstCandidate([base, ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
|
|
1028
|
+
const pick = worstCandidate([...(basePaints ? [base] : []), ...alternates.map((a) => ({ ...a, color: dim(a.color) }))], background);
|
|
963
1029
|
foreground = pick.color;
|
|
964
1030
|
required = pick.required;
|
|
965
1031
|
foregroundOrigin = pick.origin;
|
|
966
1032
|
}
|
|
967
1033
|
// Semi-transparent text is really text-colour blended into the background.
|
|
1034
|
+
if (ownMultiply) {
|
|
1035
|
+
foreground = { ...foreground,
|
|
1036
|
+
r: foreground.r * background.r / 255,
|
|
1037
|
+
g: foreground.g * background.g / 255,
|
|
1038
|
+
b: foreground.b * background.b / 255 };
|
|
1039
|
+
}
|
|
968
1040
|
if (foreground.a < 1) foreground = composite(foreground, background);
|
|
969
1041
|
|
|
970
1042
|
// The scroll-independent hazard bracket for UNVERIFIED backgrounds:
|
|
@@ -1008,7 +1080,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
1008
1080
|
(!blindOnly || hitTestBlind) && near(rect)
|
|
1009
1081
|
&& !panel.contains(element) && !element.contains(panel)
|
|
1010
1082
|
&& textIntersects(element, rect)
|
|
1011
|
-
&& (contrastRatio(foreground, color) >= required) !== (direction === 'pass'));
|
|
1083
|
+
&& (ownMultiply || (contrastRatio(foreground, color) >= required) !== (direction === 'pass')));
|
|
1012
1084
|
if (flipping) {
|
|
1013
1085
|
return {
|
|
1014
1086
|
status: 'incomplete',
|
|
@@ -1046,7 +1118,7 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
1046
1118
|
if (!paint || paint.a < 1) continue;
|
|
1047
1119
|
const siblingRect = sibling.getBoundingClientRect();
|
|
1048
1120
|
if (siblingRect.width < 24 || siblingRect.height < 12 || !near(siblingRect) || !textIntersects(element, siblingRect)) continue;
|
|
1049
|
-
if ((contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
|
|
1121
|
+
if (ownMultiply || (contrastRatio(foreground, paint) >= required) !== (direction === 'pass')) {
|
|
1050
1122
|
return {
|
|
1051
1123
|
status: 'incomplete',
|
|
1052
1124
|
message: 'This text is positioned over a coloured box that isn’t its DOM ancestor, so its real background is ambiguous — check contrast by eye.',
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
7
|
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
|
|
8
|
+
hasPaintEffects, opacityGroupPaint, splitBackgroundLayers, sampledGradientRange,
|
|
8
9
|
} from '../../lib/contrast.js';
|
|
9
10
|
|
|
10
11
|
export default {
|
|
@@ -56,10 +57,26 @@ export default {
|
|
|
56
57
|
// far less contrast than its declared colours suggest.
|
|
57
58
|
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
58
59
|
if (opacity < 0.05) return { status: 'pass' };
|
|
60
|
+
if (hasPaintEffects(element)) {
|
|
61
|
+
return { status: 'incomplete', message: 'A filter or blend mode changes the colours presented inside this field. Check the resulting text contrast by eye.' };
|
|
62
|
+
}
|
|
59
63
|
// The control's own background wins; else whatever shows through it.
|
|
60
64
|
const own = parseColor(style.backgroundColor);
|
|
61
65
|
let background;
|
|
62
|
-
if (
|
|
66
|
+
if (style.backgroundImage !== 'none') {
|
|
67
|
+
const layers = splitBackgroundLayers(style.backgroundImage);
|
|
68
|
+
const range = layers.length === 1 && layers[0].includes('gradient(')
|
|
69
|
+
? sampledGradientRange(layers[0]) : null;
|
|
70
|
+
// A constant opaque gradient has exactly one painted background.
|
|
71
|
+
// Other image paint needs spatial sampling, not its fallback colour.
|
|
72
|
+
const fullBox = /^(auto|auto auto)$/.test(style.backgroundSize)
|
|
73
|
+
&& style.backgroundPosition === '0% 0%'
|
|
74
|
+
&& ['repeat', 'no-repeat'].includes(style.backgroundRepeat);
|
|
75
|
+
if (!range || range.min !== range.max || opacity < 1 || !fullBox) {
|
|
76
|
+
return { status: 'incomplete', message: 'An image or gradient paints this field background. Check its text against the pixels behind it.' };
|
|
77
|
+
}
|
|
78
|
+
background = range.minColor;
|
|
79
|
+
} else if (own && own.a >= 1) {
|
|
63
80
|
background = own;
|
|
64
81
|
} else {
|
|
65
82
|
// An image or gradient painted under a see-through control is a
|
|
@@ -73,18 +90,27 @@ export default {
|
|
|
73
90
|
};
|
|
74
91
|
}
|
|
75
92
|
const behind = effectiveBackground(element);
|
|
76
|
-
background = behind
|
|
93
|
+
background = behind; // effectiveBackground already composites the field fill
|
|
77
94
|
}
|
|
78
95
|
if (!background) {
|
|
79
96
|
return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
|
|
80
97
|
}
|
|
81
98
|
const required = isLargeText(style) ? 3 : 4.5;
|
|
82
99
|
|
|
100
|
+
let unresolvedGroup = false;
|
|
83
101
|
const judge = (color, what, ownOpacity = 1) => {
|
|
84
102
|
const parsed = parseColor(color);
|
|
85
103
|
if (!parsed || parsed.a === 0) return null;
|
|
86
104
|
// Same treatment the main rule gives faded text: what reaches the eye
|
|
87
105
|
// is the declared colour thinned by the opacity it is painted at.
|
|
106
|
+
if (opacity < 1) {
|
|
107
|
+
const group = opacityGroupPaint(element, { ...parsed, a: parsed.a * ownOpacity });
|
|
108
|
+
if (group?.unresolved) { unresolvedGroup = true; return null; }
|
|
109
|
+
if (group) {
|
|
110
|
+
const ratio = contrastRatio(group.foreground, group.background);
|
|
111
|
+
return ratio >= required ? null : { what, ratio };
|
|
112
|
+
}
|
|
113
|
+
}
|
|
88
114
|
const painted = opacity * ownOpacity;
|
|
89
115
|
const faded = painted < 1 ? { ...parsed, a: parsed.a * painted } : parsed;
|
|
90
116
|
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
@@ -94,16 +120,18 @@ export default {
|
|
|
94
120
|
};
|
|
95
121
|
|
|
96
122
|
const failures = [];
|
|
97
|
-
const valueVerdict = judge(style.color, 'value text');
|
|
123
|
+
const valueVerdict = judge(style.webkitTextFillColor || style.color, 'value text');
|
|
98
124
|
if (valueVerdict) failures.push(valueVerdict);
|
|
99
125
|
// Placeholder text is real text users must read; browsers expose its
|
|
100
|
-
// computed colour via the ::placeholder pseudo-element.
|
|
101
|
-
|
|
126
|
+
// computed colour via the ::placeholder pseudo-element. Selectors 4
|
|
127
|
+
// defines :placeholder-shown for controls actually showing that text;
|
|
128
|
+
// a populated field's unpainted hint must not create a violation.
|
|
129
|
+
if (element.getAttribute('placeholder')?.trim() && element.matches(':placeholder-shown')) {
|
|
102
130
|
let placeholderColor = null;
|
|
103
131
|
let placeholderOpacity = 1;
|
|
104
132
|
try {
|
|
105
133
|
const placeholderStyle = getComputedStyle(element, '::placeholder');
|
|
106
|
-
placeholderColor = placeholderStyle.color;
|
|
134
|
+
placeholderColor = placeholderStyle.webkitTextFillColor || placeholderStyle.color;
|
|
107
135
|
// ::placeholder { opacity: .3 } is a widespread idiom (framework
|
|
108
136
|
// resets, Firefox's own UA default of 0.54), and the criterion is on
|
|
109
137
|
// the visual presentation of the text, which is the faded colour.
|
|
@@ -117,6 +145,7 @@ export default {
|
|
|
117
145
|
if (verdict) failures.push(verdict);
|
|
118
146
|
}
|
|
119
147
|
}
|
|
148
|
+
if (unresolvedGroup) return { status: 'incomplete', message: 'The field and its text fade together over an unresolved background. Check the presented text contrast by eye.' };
|
|
120
149
|
if (!failures.length) return { status: 'pass' };
|
|
121
150
|
const worst = failures.sort((a, b) => a.ratio - b.ratio)[0];
|
|
122
151
|
return {
|
|
@@ -20,10 +20,15 @@ export default {
|
|
|
20
20
|
const maxScale = content.match(/maximum-scale\s*=\s*([\d.]+)/i);
|
|
21
21
|
const cappedZoom = maxScale && parseFloat(maxScale[1]) < 2;
|
|
22
22
|
if (!disablesZoom && !cappedZoom) return { status: 'pass' };
|
|
23
|
+
// Asserted the way ACT rule b4f0c3 asserts it, under its assumption that
|
|
24
|
+
// the page provides no other way to enlarge the text (technique G178).
|
|
25
|
+
// The message names that assumption so a reviewer who knows the page
|
|
26
|
+
// has its own text-size control can dismiss the finding.
|
|
27
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/b4f0c3/
|
|
23
28
|
return {
|
|
24
29
|
status: 'fail',
|
|
25
|
-
message: 'This viewport meta tag stops
|
|
26
|
-
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag.',
|
|
30
|
+
message: 'This viewport meta tag stops users zooming the page, so text cannot be enlarged with the browser. 1.4.4 is met only if the page provides its own control that enlarges all text to 200% (technique G178); this finding assumes it does not, since the tag cannot show it.',
|
|
31
|
+
fix: 'Remove user-scalable=no and any maximum-scale below 2 from the viewport meta tag. If the page has its own text-size control that reaches 200%, record that and dismiss this finding.',
|
|
27
32
|
};
|
|
28
33
|
},
|
|
29
34
|
};
|
|
@@ -92,13 +92,13 @@ export default {
|
|
|
92
92
|
// An explicit negative tabindex is the author OPTING OUT of the tab
|
|
93
93
|
// order, and the browser honours it: measured in Chrome 151, a scroller
|
|
94
94
|
// with tabindex="-1" is skipped by Tab even though the same scroller
|
|
95
|
-
// without the attribute would be auto-focused.
|
|
96
|
-
//
|
|
97
|
-
//
|
|
95
|
+
// without the attribute would be auto-focused. That does not prove
|
|
96
|
+
// keyboard inoperability: external controls may scroll the region or
|
|
97
|
+
// move focus into it programmatically.
|
|
98
98
|
if (element.hasAttribute('tabindex')) {
|
|
99
99
|
return {
|
|
100
|
-
status: '
|
|
101
|
-
message: 'This region scrolls,
|
|
100
|
+
status: 'incomplete',
|
|
101
|
+
message: 'This region scrolls, has no detected tabbable content and is removed from sequential focus by a negative tabindex. Check whether keyboard-operable controls elsewhere scroll it or move focus into it; without such a mechanism, the overflowed content may be unreachable.',
|
|
102
102
|
fix: 'Change tabindex="-1" to tabindex="0" (plus role="region" and an aria-label describing it), or make something inside it tabbable.',
|
|
103
103
|
};
|
|
104
104
|
}
|
|
@@ -23,6 +23,33 @@ function refreshDestination(content) {
|
|
|
23
23
|
return rest.trim();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
/**
|
|
27
|
+
* The delay the browser would actually use, and null when the HTML "shared
|
|
28
|
+
* declarative refresh steps" bail out and schedule nothing at all. Those
|
|
29
|
+
* steps collect ASCII digits and stop: a leading "+" ends the parse before a
|
|
30
|
+
* single digit is read (content="+5; url=…" refreshes nothing), and anything
|
|
31
|
+
* other than ";", "," or whitespace directly after the digits does the same
|
|
32
|
+
* (content="1x; url=…"). parseInt accepts both and returned 5 and 1, so the
|
|
33
|
+
* rule asserted a time limit on pages the browser never moves. Verified in
|
|
34
|
+
* Chromium: both of those pages stay put while content="1; url=…" navigates.
|
|
35
|
+
*/
|
|
36
|
+
function refreshDelay(content) {
|
|
37
|
+
const isAsciiSpace = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
|
|
38
|
+
const isDigit = (c) => c >= '0' && c <= '9';
|
|
39
|
+
let i = 0;
|
|
40
|
+
while (i < content.length && isAsciiSpace(content[i])) i++;
|
|
41
|
+
let digits = '';
|
|
42
|
+
while (i < content.length && isDigit(content[i])) digits += content[i++];
|
|
43
|
+
// No digits is only allowed when a decimal point follows, which the steps
|
|
44
|
+
// read as a fractional delay of zero seconds.
|
|
45
|
+
if (!digits && content[i] !== '.') return null;
|
|
46
|
+
const delay = digits ? Number(digits) : 0;
|
|
47
|
+
// Trailing digits and full stops are collected and thrown away.
|
|
48
|
+
while (i < content.length && (isDigit(content[i]) || content[i] === '.')) i++;
|
|
49
|
+
if (i < content.length && content[i] !== ';' && content[i] !== ',' && !isAsciiSpace(content[i])) return null;
|
|
50
|
+
return delay;
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
export default {
|
|
27
54
|
id: 'meta-refresh',
|
|
28
55
|
name: 'Timed page refresh',
|
|
@@ -34,7 +61,10 @@ export default {
|
|
|
34
61
|
visibleOnly: false,
|
|
35
62
|
evaluate(element) {
|
|
36
63
|
const content = element.getAttribute('content') ?? '';
|
|
37
|
-
const delay =
|
|
64
|
+
const delay = refreshDelay(content);
|
|
65
|
+
// The browser discards this directive, so no time limit is set by the
|
|
66
|
+
// content and 2.2.1 has nothing to judge.
|
|
67
|
+
if (delay === null) return { status: 'pass' };
|
|
38
68
|
// A refresh with no destination reloads THIS page. At zero seconds that
|
|
39
69
|
// is not the allowed instant redirect — it is a loop that throws the
|
|
40
70
|
// user back to the top of the page over and over (failure F41).
|
|
@@ -50,10 +80,16 @@ export default {
|
|
|
50
80
|
// 2.2.1 exempts a time limit "longer than 20 hours" — 20 hours exactly
|
|
51
81
|
// is still inside the criterion.
|
|
52
82
|
if (delay > 72000) return { status: 'pass' };
|
|
83
|
+
// Asserted the way ACT rule bc659a asserts it: a meta refresh gives the
|
|
84
|
+
// user no way to turn the limit off, adjust it or extend it, so the only
|
|
85
|
+
// escapes are the real-time and essential exceptions, which the tag
|
|
86
|
+
// cannot show either way. The message names that assumption so a
|
|
87
|
+
// reviewer who knows the page offers a control can dismiss the finding.
|
|
88
|
+
// https://www.w3.org/WAI/standards-guidelines/act/rules/bc659a/
|
|
53
89
|
return {
|
|
54
90
|
status: 'fail',
|
|
55
|
-
message: `The page refreshes
|
|
56
|
-
fix: 'Remove the timed refresh; let users act in their own time.',
|
|
91
|
+
message: `The page refreshes or redirects itself after ${delay}s. Slow readers lose their place, or the whole page, and a meta refresh offers no way to turn that off, adjust it or extend it. 2.2.1 excuses a time limit only where it is part of a real-time event or essential; this finding assumes neither applies, since the tag cannot show it.`,
|
|
92
|
+
fix: 'Remove the timed refresh; let users act in their own time. If the page offers a control that turns the refresh off, or the refresh is essential, record that and dismiss this finding.',
|
|
57
93
|
};
|
|
58
94
|
},
|
|
59
95
|
};
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Two shapes are checkable, and they need different verdicts.
|
|
8
8
|
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Legacy markup is not proof of motion: blink has no native animation in
|
|
10
|
+
// modern browsers, and a marquee may be stopped or controlled by script.
|
|
11
11
|
//
|
|
12
12
|
// A CSS animation set to repeat for ever is the modern ticker. Clauses 1
|
|
13
13
|
// and 2 are settled by the CSS itself — it starts with the page and never
|
|
@@ -117,15 +117,15 @@ export default {
|
|
|
117
117
|
if (!elements.length) return [];
|
|
118
118
|
const moving = endlesslyMoving(elements[0].ownerDocument);
|
|
119
119
|
return elements.map((element) => {
|
|
120
|
-
if (element.tagName === 'MARQUEE'
|
|
120
|
+
if (element.tagName === 'MARQUEE') {
|
|
121
121
|
// scrollamount="0" is the documented way to ship a <marquee> that
|
|
122
122
|
// doesn't actually move.
|
|
123
123
|
if (element.tagName === 'MARQUEE' && element.getAttribute('scrollamount') === '0') {
|
|
124
124
|
return { status: 'pass' };
|
|
125
125
|
}
|
|
126
126
|
return {
|
|
127
|
-
status: '
|
|
128
|
-
message:
|
|
127
|
+
status: 'incomplete',
|
|
128
|
+
message: 'This marquee can scroll content. Check whether it actually starts automatically, continues for more than five seconds alongside other content, and has a working pause, stop or hide mechanism. The element alone does not establish those conditions.',
|
|
129
129
|
fix: 'Replace it with static content, or a CSS animation with a pause control that honours prefers-reduced-motion.',
|
|
130
130
|
};
|
|
131
131
|
}
|
|
@@ -80,8 +80,8 @@ export default {
|
|
|
80
80
|
const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
|
|
81
81
|
if (linkCount < 4) return { status: 'pass' };
|
|
82
82
|
return {
|
|
83
|
-
status: '
|
|
84
|
-
message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link
|
|
83
|
+
status: 'incomplete',
|
|
84
|
+
message: `This page has ${linkCount} links and buttons but no detected landmark, heading, skip link or titled frame. Check whether it contains blocks repeated on other pages and, if so, whether a mechanism lets users bypass them. A single page cannot establish repetition.`,
|
|
85
85
|
fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
|
|
86
86
|
};
|
|
87
87
|
},
|
|
@@ -3,18 +3,17 @@
|
|
|
3
3
|
// merely PRESENT satisfies the markup, not the criterion, so the two
|
|
4
4
|
// well-known undescriptive shapes are checked as well.
|
|
5
5
|
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
6
|
+
// Familiar template strings are review candidates, not proof of a missing
|
|
7
|
+
// topic: a document can genuinely describe the HTML document title or an
|
|
8
|
+
// artwork named Untitled 1.
|
|
9
9
|
// Tier two, sent to a human: single generic words and framework scaffold
|
|
10
10
|
// defaults. Almost always a leftover, but a gallery page really can be about
|
|
11
11
|
// an artwork called "Untitled", and "Example Domain" really is the subject
|
|
12
12
|
// of example.com. Those are somebody's call to make, not the engine's.
|
|
13
13
|
// "New document", "New page" and "New tab" sit in tier two as well: a
|
|
14
14
|
// document editor's creation screen, a wiki's new-page form or a
|
|
15
|
-
// browser-style app's new-tab page is about exactly that
|
|
16
|
-
//
|
|
17
|
-
// placeholder (2026-08-25 overnight audit).
|
|
15
|
+
// browser-style app's new-tab page is about exactly that. Even numbered
|
|
16
|
+
// editor-style titles remain a question about the actual subject.
|
|
18
17
|
//
|
|
19
18
|
// Deliberately NOT judged: whether a real-looking title matches the page it
|
|
20
19
|
// sits on. That needs someone who can read the page, and guessing at it
|
|
@@ -63,8 +62,8 @@ export default {
|
|
|
63
62
|
const normalized = title.replace(/\s+/g, ' ').toLowerCase();
|
|
64
63
|
if (PLACEHOLDER.has(normalized) || /^(?:untitled|new (?:document|page|tab))[\s-]*\d+$/.test(normalized)) {
|
|
65
64
|
return {
|
|
66
|
-
status: '
|
|
67
|
-
message: `“${title}”
|
|
65
|
+
status: 'incomplete',
|
|
66
|
+
message: `“${title}” resembles a template title. Check whether it describes this page's actual topic or purpose; the same words can also be a legitimate subject or name.`,
|
|
68
67
|
fix: 'Put this page\'s own subject in the title first, then the site name.',
|
|
69
68
|
};
|
|
70
69
|
}
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
// it sits in. A "Read more" under a headline naming the
|
|
14
14
|
// article is a judgment call about whether that context really
|
|
15
15
|
// reaches the user, so this REVIEWS. It never asserts.
|
|
16
|
-
// 2.4.9 (AAA)
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// ambiguous to everyone, which is the exception failing to
|
|
20
|
-
// apply rather than saving it, so this FAILS.
|
|
16
|
+
// 2.4.9 (AAA) requires a mechanism for purpose to be available from link
|
|
17
|
+
// text alone. A page may let users expand its link names,
|
|
18
|
+
// so a generic name in this snapshot still needs review.
|
|
21
19
|
//
|
|
22
20
|
// Judged on the ACCESSIBLE NAME, not the text content: a link written as
|
|
23
21
|
// <a href="…" aria-label="Read more about the budget">Read more</a> is
|
|
@@ -31,6 +29,8 @@
|
|
|
31
29
|
// normalizeName below): apostrophes become spaces, so "plus d informations"
|
|
32
30
|
// is how "plus d'informations" — straight or typographic — must be listed.
|
|
33
31
|
|
|
32
|
+
import { effectiveRole, implicitRole } from '../../lib/roles.js';
|
|
33
|
+
|
|
34
34
|
const GENERIC = new Set([
|
|
35
35
|
// English
|
|
36
36
|
'read more', 'read more about this', 'more', 'much more', 'learn more',
|
|
@@ -83,6 +83,7 @@ export function createLinkPurposeRule({ id, impact, tags, help, helpUrl, verdict
|
|
|
83
83
|
// link-name's finding, not this one.
|
|
84
84
|
selector: 'a[href], [role="link"]',
|
|
85
85
|
evaluate(element, { accessibleName }) {
|
|
86
|
+
if ((effectiveRole(element) ?? implicitRole(element)) !== 'link') return { status: 'pass' };
|
|
86
87
|
const name = accessibleName(element);
|
|
87
88
|
if (!name) return { status: 'pass' }; // nameless: link-name reports it
|
|
88
89
|
const normalized = normalizeName(name);
|
|
@@ -4,28 +4,13 @@
|
|
|
4
4
|
// threshold: the link text ALONE has to identify the purpose, and context
|
|
5
5
|
// is not allowed to make up the difference.
|
|
6
6
|
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
// everyone — which is the exception failing to apply rather than rescuing
|
|
11
|
-
// it. Nothing about the surrounding page can change that reading, so there
|
|
12
|
-
// is nothing here for a human to settle.
|
|
7
|
+
// The criterion allows a mechanism that expands link names. F84 requires
|
|
8
|
+
// checking that no such mechanism exists, which a name-only snapshot cannot
|
|
9
|
+
// prove. Semantic ambiguity and alternative presentation remain review.
|
|
13
10
|
//
|
|
14
|
-
//
|
|
15
|
-
// (
|
|
16
|
-
//
|
|
17
|
-
// term that names its destination exactly (one-sided decoration like
|
|
18
|
-
// python.org's CSS-generated ">>>More" prompt still asserts);
|
|
19
|
-
// · a name that is exactly "go" — every corpus hit was a brand logo
|
|
20
|
-
// ("Go" is the language's name), and a brand name identifies the link.
|
|
21
|
-
//
|
|
22
|
-
// A third, beside "go" (2026-08-25 overnight audit): the French entries
|
|
23
|
-
// "plus" and "suite" are ordinary English words and, more to the point,
|
|
24
|
-
// common English plan and product names (a "Plus" tier, a "Suite"). A
|
|
25
|
-
// pricing link named "Plus" identifies its destination the way the Go logo
|
|
26
|
-
// does, so outside a page declared French they review rather than assert.
|
|
27
|
-
// On a link whose closest lang is French the generic reading ("more",
|
|
28
|
-
// "continued") is the only one and the assertion stands.
|
|
11
|
+
// The review wording distinguishes recognizable proper-name ambiguities
|
|
12
|
+
// (Go, Plus, Suite and quoted code terms) from ordinary generic phrases.
|
|
13
|
+
// Neither branch treats a phrase-list match as semantic proof.
|
|
29
14
|
import { createLinkPurposeRule } from './2.4.4-link-text-generic.js';
|
|
30
15
|
|
|
31
16
|
const FRENCH_ONLY_ENGLISH_NAMES = new Set(['plus', 'suite']);
|
|
@@ -50,8 +35,8 @@ export default createLinkPurposeRule({
|
|
|
50
35
|
fix: 'If the name really is generic here, put the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
51
36
|
};
|
|
52
37
|
return {
|
|
53
|
-
status: '
|
|
54
|
-
message: `“${name}”
|
|
38
|
+
status: 'incomplete',
|
|
39
|
+
message: `“${name}” appears generic when read alone. Check whether it identifies the destination in this context or whether a mechanism makes the link text descriptive. 2.4.9 permits such a mechanism; the initial link name alone cannot establish a failure.`,
|
|
55
40
|
fix: 'Name the destination in the link text, e.g. "Read more about the 2026 budget".',
|
|
56
41
|
};
|
|
57
42
|
},
|