pour-engine 1.1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/config/project.config.js +11 -0
- package/engine/COVERAGE.md +115 -0
- package/engine/README.md +115 -0
- package/engine/index.js +258 -0
- package/engine/lib/accessible-name.js +126 -0
- package/engine/lib/contrast.js +628 -0
- package/engine/lib/dom.js +116 -0
- package/engine/lib/roles.js +125 -0
- package/engine/rules/best-practice/accesskeys.js +29 -0
- package/engine/rules/best-practice/aria-label-misuse.js +22 -0
- package/engine/rules/best-practice/button-type.js +15 -0
- package/engine/rules/best-practice/empty-heading.js +19 -0
- package/engine/rules/best-practice/fieldset-legend.js +35 -0
- package/engine/rules/best-practice/heading-order.js +30 -0
- package/engine/rules/best-practice/landmark-one-main.js +22 -0
- package/engine/rules/best-practice/landmark-top-level.js +46 -0
- package/engine/rules/best-practice/landmark-unique.js +84 -0
- package/engine/rules/best-practice/multiple-labels.js +23 -0
- package/engine/rules/best-practice/new-window-link.js +21 -0
- package/engine/rules/best-practice/no-autofocus.js +16 -0
- package/engine/rules/best-practice/page-heading-one.js +17 -0
- package/engine/rules/best-practice/positive-tabindex.js +17 -0
- package/engine/rules/best-practice/redundant-alt-phrase.js +22 -0
- package/engine/rules/best-practice/redundant-aria-label.js +18 -0
- package/engine/rules/best-practice/redundant-aria.js +32 -0
- package/engine/rules/best-practice/redundant-image-alt.js +20 -0
- package/engine/rules/best-practice/redundant-role.js +62 -0
- package/engine/rules/best-practice/redundant-tabindex.js +15 -0
- package/engine/rules/best-practice/region.js +73 -0
- package/engine/rules/index.js +179 -0
- package/engine/rules/wcag/1.1.1-area-alt.js +18 -0
- package/engine/rules/wcag/1.1.1-image-alt.js +48 -0
- package/engine/rules/wcag/1.1.1-input-image-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-object-alt.js +17 -0
- package/engine/rules/wcag/1.1.1-svg-img-alt.js +34 -0
- package/engine/rules/wcag/1.2.2-media-captions.js +19 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +66 -0
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +49 -0
- package/engine/rules/wcag/1.3.1-definition-list.js +21 -0
- package/engine/rules/wcag/1.3.1-dlitem-parent.js +17 -0
- package/engine/rules/wcag/1.3.1-form-label.js +81 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +59 -0
- package/engine/rules/wcag/1.3.1-listitem-parent.js +33 -0
- package/engine/rules/wcag/1.3.1-p-as-heading.js +25 -0
- package/engine/rules/wcag/1.3.1-table-headers.js +61 -0
- package/engine/rules/wcag/1.3.5-autocomplete-valid.js +38 -0
- package/engine/rules/wcag/1.4.1-link-in-text-block.js +74 -0
- package/engine/rules/wcag/1.4.10-reflow.js +25 -0
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +109 -0
- package/engine/rules/wcag/1.4.12-text-spacing.js +71 -0
- package/engine/rules/wcag/1.4.2-audio-control.js +40 -0
- package/engine/rules/wcag/1.4.3-color-contrast.js +462 -0
- package/engine/rules/wcag/1.4.3-control-contrast.js +72 -0
- package/engine/rules/wcag/1.4.4-meta-viewport.js +22 -0
- package/engine/rules/wcag/1.4.6-contrast-enhanced.js +11 -0
- package/engine/rules/wcag/2.1.1-scrollable-region-focusable.js +48 -0
- package/engine/rules/wcag/2.2.1-meta-refresh.js +20 -0
- package/engine/rules/wcag/2.2.2-pause-stop-hide.js +25 -0
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +36 -0
- package/engine/rules/wcag/2.4.11-focus-not-obscured.js +80 -0
- package/engine/rules/wcag/2.4.2-document-title.js +18 -0
- package/engine/rules/wcag/2.4.4-link-name.js +19 -0
- package/engine/rules/wcag/2.4.7-focus-visible.js +46 -0
- package/engine/rules/wcag/2.5.3-label-in-name.js +88 -0
- package/engine/rules/wcag/2.5.5-target-size-enhanced.js +14 -0
- package/engine/rules/wcag/2.5.8-target-size.js +159 -0
- package/engine/rules/wcag/3.1.1-html-lang.js +31 -0
- package/engine/rules/wcag/3.1.2-valid-lang-parts.js +20 -0
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +37 -0
- package/engine/rules/wcag/4.1.2-aria-allowed-attr.js +44 -0
- package/engine/rules/wcag/4.1.2-aria-attr-valid.js +50 -0
- package/engine/rules/wcag/4.1.2-aria-field-name.js +54 -0
- package/engine/rules/wcag/4.1.2-aria-hidden-focus.js +48 -0
- package/engine/rules/wcag/4.1.2-aria-valid-refs.js +61 -0
- package/engine/rules/wcag/4.1.2-button-name.js +17 -0
- package/engine/rules/wcag/4.1.2-dialog-name.js +35 -0
- package/engine/rules/wcag/4.1.2-frame-title.js +17 -0
- package/engine/rules/wcag/4.1.2-label-for-valid.js +25 -0
- package/engine/rules/wcag/4.1.2-nested-interactive.js +30 -0
- package/engine/rules/wcag/4.1.2-role-required-aria.js +36 -0
- package/engine/rules/wcag/4.1.2-valid-role.js +52 -0
- package/engine/wcag22.js +116 -0
- package/engine/wcag30-draft.js +149 -0
- package/package.json +34 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// WCAG SC 1.4.11 Non-text Contrast (Level AA)
|
|
2
|
+
// The automatable slice: whether a form field's fill or border reaches 3:1
|
|
3
|
+
// against its surroundings. Verdicts are pass or NEEDS-REVIEW, never fail:
|
|
4
|
+
// per the Understanding document a boundary is only required when it is
|
|
5
|
+
// the visual information needed to IDENTIFY the field — a labelled field
|
|
6
|
+
// with placeholder text can conform with a faint border (#ccc borders are
|
|
7
|
+
// endemic, WebAIM's own search field included), and that judgment needs
|
|
8
|
+
// eyes. Focus-indicator contrast is not statically computable — manual.
|
|
9
|
+
import { parseColor, contrastRatio, composite, effectiveBackground } from '../../lib/contrast.js';
|
|
10
|
+
|
|
11
|
+
const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
id: 'non-text-contrast',
|
|
15
|
+
impact: 'serious',
|
|
16
|
+
tags: ['wcag21aa', 'wcag1411'],
|
|
17
|
+
help: 'Form fields and icons need a 3:1 visible boundary',
|
|
18
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html',
|
|
19
|
+
selector:
|
|
20
|
+
'input:not([type="hidden"]):not([type="checkbox"]):not([type="radio"]):not([type="submit"])'
|
|
21
|
+
+ ':not([type="button"]):not([type="reset"]):not([type="image"]), select, textarea',
|
|
22
|
+
visibility: 'visual',
|
|
23
|
+
evaluate(element) {
|
|
24
|
+
// Inactive controls are exempt (same carve-out 1.4.3 makes).
|
|
25
|
+
if (element.disabled || element.closest('[aria-disabled="true"]')) return { status: 'pass' };
|
|
26
|
+
// Zero-area controls (JS-widget hidden duplicates) paint no boundary
|
|
27
|
+
// to judge.
|
|
28
|
+
const ownRect = element.getBoundingClientRect();
|
|
29
|
+
if (ownRect.width <= 1 || ownRect.height <= 1) return { status: 'pass' };
|
|
30
|
+
const style = getComputedStyle(element);
|
|
31
|
+
// A native default-styled control's boundary comes from the UA.
|
|
32
|
+
if (style.appearance !== 'none' && element.tagName === 'SELECT') return { status: 'pass' };
|
|
33
|
+
const surrounding = effectiveBackground(element.parentElement ?? element);
|
|
34
|
+
if (!surrounding) {
|
|
35
|
+
return { status: 'incomplete', message: 'The background around this field could not be determined — check the field has a visible boundary by eye.' };
|
|
36
|
+
}
|
|
37
|
+
// Every candidate boundary colour, translucents composited over the
|
|
38
|
+
// surroundings so a 30%-white border on blue is judged as the lighter
|
|
39
|
+
// blue the user actually sees.
|
|
40
|
+
const boundaries = [];
|
|
41
|
+
let translucentSeen = false;
|
|
42
|
+
const fill = parseColor(style.backgroundColor);
|
|
43
|
+
if (fill && fill.a > 0) {
|
|
44
|
+
boundaries.push(fill.a >= 1 ? fill : composite(fill, surrounding));
|
|
45
|
+
if (fill.a < 1) translucentSeen = true;
|
|
46
|
+
}
|
|
47
|
+
let hasBorder = false;
|
|
48
|
+
for (const side of ['Top', 'Right', 'Bottom', 'Left']) {
|
|
49
|
+
if (parseFloat(style[`border${side}Width`]) <= 0) continue;
|
|
50
|
+
if (style[`border${side}Style`] === 'none') continue;
|
|
51
|
+
hasBorder = true;
|
|
52
|
+
const color = parseColor(style[`border${side}Color`]);
|
|
53
|
+
if (!color || color.a === 0) continue;
|
|
54
|
+
boundaries.push(color.a >= 1 ? color : composite(color, surrounding));
|
|
55
|
+
if (color.a < 1) translucentSeen = true;
|
|
56
|
+
}
|
|
57
|
+
const best = Math.max(1, ...boundaries.map((color) => contrastRatio(color, surrounding)));
|
|
58
|
+
if (best >= 3) return { status: 'pass' };
|
|
59
|
+
// Box-shadow outlines and background images can also draw the boundary;
|
|
60
|
+
// those aren't statically comparable — abstain rather than assert.
|
|
61
|
+
if (style.boxShadow !== 'none' || style.backgroundImage !== 'none') {
|
|
62
|
+
return {
|
|
63
|
+
status: 'incomplete',
|
|
64
|
+
message: 'This field’s boundary may come from a box-shadow or background image — check it reaches 3:1 against the page by eye.',
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
// Translucent layers compound (a tinted fill under a tinted border):
|
|
68
|
+
// the compositing above is per-layer, so a near-miss stays a human call.
|
|
69
|
+
if (translucentSeen) {
|
|
70
|
+
return {
|
|
71
|
+
status: 'incomplete',
|
|
72
|
+
message: `This field's boundary uses translucent colours and reaches about ${showRatio(best)}:1 against the page — near the 3:1 minimum; check by eye.`,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
// Input-group pattern: a borderless input inside a wrapper that carries
|
|
76
|
+
// the visible boundary (border, or a fill contrasting with the page
|
|
77
|
+
// around it). The FIELD the user perceives is the wrapper — but only a
|
|
78
|
+
// SNUG wrapper counts: a page section's border is not a field boundary.
|
|
79
|
+
for (let wrapper = element.parentElement, depth = 0; wrapper && depth < 2; wrapper = wrapper.parentElement, depth++) {
|
|
80
|
+
const wrapperRect = wrapper.getBoundingClientRect();
|
|
81
|
+
if (wrapperRect.height > ownRect.height + 24 || wrapperRect.width > ownRect.width + 160) break;
|
|
82
|
+
const wrapperStyle = getComputedStyle(wrapper);
|
|
83
|
+
const wrapperHasBorder = ['Top', 'Right', 'Bottom', 'Left'].some((side) =>
|
|
84
|
+
parseFloat(wrapperStyle[`border${side}Width`]) > 0 && wrapperStyle[`border${side}Style`] !== 'none'
|
|
85
|
+
&& (parseColor(wrapperStyle[`border${side}Color`])?.a ?? 0) > 0);
|
|
86
|
+
if (wrapperHasBorder) return { status: 'pass' };
|
|
87
|
+
const wrapperFill = parseColor(wrapperStyle.backgroundColor);
|
|
88
|
+
if (wrapperFill && wrapperFill.a >= 1) {
|
|
89
|
+
const around = effectiveBackground(wrapper.parentElement ?? wrapper);
|
|
90
|
+
if (around && contrastRatio(wrapperFill, around) >= 3) return { status: 'pass' };
|
|
91
|
+
break; // opaque wrapper IS the visual field and it doesn't contrast — judge as found
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// Per the Understanding document, a boundary is only REQUIRED when it
|
|
95
|
+
// is the visual information needed to identify the field — a clearly
|
|
96
|
+
// labelled field with placeholder text inside it can conform without
|
|
97
|
+
// one. That judgment needs human eyes, so this rule never asserts.
|
|
98
|
+
if (!hasBorder && (!fill || fill.a === 0)) {
|
|
99
|
+
return {
|
|
100
|
+
status: 'incomplete',
|
|
101
|
+
message: 'This field has no border and no distinct fill — if its label and layout don’t already make it findable, low-vision users can’t locate it. Check by eye (3:1 boundary or clear identification needed).',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
status: 'incomplete',
|
|
106
|
+
message: `This field's boundary reaches only ${showRatio(best)}:1 against the page — below the 3:1 minimum. That’s conformant ONLY if the field is identifiable without the boundary (visible label, placeholder position); check by eye.`,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// WCAG SC 1.4.12 Text Spacing (Level AA)
|
|
2
|
+
// Active probe, in the spirit of the criterion's own test procedure: apply
|
|
3
|
+
// the SC's exact spacing overrides and check that no text gets cut off.
|
|
4
|
+
// The override is injected and removed inside one synchronous pass — the
|
|
5
|
+
// engine only yields to the renderer between rules, so the page never
|
|
6
|
+
// paints the probed state.
|
|
7
|
+
const OVERRIDE = `* {
|
|
8
|
+
line-height: 1.5 !important;
|
|
9
|
+
letter-spacing: 0.12em !important;
|
|
10
|
+
word-spacing: 0.16em !important;
|
|
11
|
+
}
|
|
12
|
+
p { margin-bottom: 2em !important; }`;
|
|
13
|
+
|
|
14
|
+
const clipped = (element) =>
|
|
15
|
+
element.scrollWidth > element.clientWidth + 2 || element.scrollHeight > element.clientHeight + 2;
|
|
16
|
+
|
|
17
|
+
export default {
|
|
18
|
+
id: 'text-spacing',
|
|
19
|
+
impact: 'serious',
|
|
20
|
+
tags: ['wcag21aa', 'wcag1412'],
|
|
21
|
+
help: 'Text must survive user spacing overrides without being cut off',
|
|
22
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html',
|
|
23
|
+
// Candidates: containers that CAN clip. Everything else reflows freely
|
|
24
|
+
// and passes by construction.
|
|
25
|
+
selector: 'p, h1, h2, h3, h4, h5, h6, a, button, li, span, div, td, th, label, dt, dd, figcaption',
|
|
26
|
+
visibility: 'visual',
|
|
27
|
+
evaluateAll(elements) {
|
|
28
|
+
const doc = elements[0]?.ownerDocument ?? document;
|
|
29
|
+
// The candidate scan computes styles per element — fine on real pages,
|
|
30
|
+
// ruinous on 200k-node single-page specs, whose plain flowing text is
|
|
31
|
+
// also the least likely content on the web to clip. Past a scale where
|
|
32
|
+
// the probe's cost dwarfs its yield, abstain wholesale.
|
|
33
|
+
if (elements.length > 20_000) return elements.map(() => ({ status: 'pass' }));
|
|
34
|
+
// Only containers that hide overflow and hold text can lose content.
|
|
35
|
+
// Collapsed containers (accordion panels at height 0) show nothing —
|
|
36
|
+
// there is no visible text for the override to clip.
|
|
37
|
+
const candidates = elements.map((element) => {
|
|
38
|
+
const style = getComputedStyle(element);
|
|
39
|
+
const hides = /(hidden|clip)/.test(`${style.overflowX} ${style.overflowY}`);
|
|
40
|
+
if (!hides || !element.textContent.trim()) return null;
|
|
41
|
+
return element.clientWidth > 4 && element.clientHeight > 4 ? element : null;
|
|
42
|
+
});
|
|
43
|
+
if (!candidates.some(Boolean)) return elements.map(() => ({ status: 'pass' }));
|
|
44
|
+
|
|
45
|
+
const before = candidates.map((element) => element && clipped(element));
|
|
46
|
+
const probe = doc.createElement('style');
|
|
47
|
+
probe.dataset.pourAudit = 'probe';
|
|
48
|
+
probe.textContent = OVERRIDE;
|
|
49
|
+
doc.documentElement.append(probe);
|
|
50
|
+
let after;
|
|
51
|
+
try {
|
|
52
|
+
// Force the reflow the measurements depend on.
|
|
53
|
+
void doc.documentElement.offsetHeight;
|
|
54
|
+
after = candidates.map((element) => element && clipped(element));
|
|
55
|
+
} finally {
|
|
56
|
+
probe.remove();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return elements.map((element, i) => {
|
|
60
|
+
if (!candidates[i]) return { status: 'pass' };
|
|
61
|
+
// Only NEW clipping counts: content already truncated by design
|
|
62
|
+
// (ellipsis patterns) is judged as-authored, not under the probe.
|
|
63
|
+
if (before[i] || !after[i]) return { status: 'pass' };
|
|
64
|
+
return {
|
|
65
|
+
status: 'fail',
|
|
66
|
+
message: 'With the WCAG text-spacing overrides applied (line height 1.5, letter/word spacing bumps), text in this element gets cut off — users who need wider spacing lose content.',
|
|
67
|
+
fix: 'Let the container grow (min-height instead of height, avoid overflow:hidden on text), so spacing changes reflow instead of clip.',
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// WCAG SC 1.4.2 Audio Control (Level A)
|
|
2
|
+
export default {
|
|
3
|
+
id: 'audio-control',
|
|
4
|
+
impact: 'critical',
|
|
5
|
+
tags: ['wcag2a', 'wcag142'],
|
|
6
|
+
help: 'Auto-playing audio must be stoppable or muted',
|
|
7
|
+
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/audio-control.html',
|
|
8
|
+
selector: 'audio[autoplay], video[autoplay]',
|
|
9
|
+
visibleOnly: false,
|
|
10
|
+
evaluate(element) {
|
|
11
|
+
// Muted is checked as a PROPERTY first: frameworks (React) set it on
|
|
12
|
+
// the DOM object without reflecting the attribute, and browsers only
|
|
13
|
+
// allow autoplay when muted — attribute-only checking failed every
|
|
14
|
+
// silent background video on the modern web.
|
|
15
|
+
if (element.muted || element.volume === 0) return { status: 'pass' };
|
|
16
|
+
if (element.hasAttribute('muted') || element.hasAttribute('controls')) return { status: 'pass' };
|
|
17
|
+
// The criterion applies to audio playing for MORE than 3 seconds; a
|
|
18
|
+
// short non-looping chime is exempt — but a LOOPING clip exceeds any
|
|
19
|
+
// duration by definition, so it fails without needing metadata.
|
|
20
|
+
if (element.loop) {
|
|
21
|
+
return {
|
|
22
|
+
status: 'fail',
|
|
23
|
+
message: 'This media auto-plays on a loop with no controls and no muting — its audio talks over screen readers indefinitely.',
|
|
24
|
+
fix: 'Add controls (or muted), or don’t autoplay.',
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
if (Number.isFinite(element.duration) && element.duration <= 3) return { status: 'pass' };
|
|
28
|
+
if (!Number.isFinite(element.duration)) {
|
|
29
|
+
return {
|
|
30
|
+
status: 'incomplete',
|
|
31
|
+
message: 'This media auto-plays without controls or muting; its duration isn’t loaded yet, and only audio playing longer than 3 seconds fails 1.4.2 — check whether it has sound and how long it plays.',
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
status: 'fail',
|
|
36
|
+
message: 'This media auto-plays with no controls and no muting — its audio talks over screen readers.',
|
|
37
|
+
fix: 'Add controls (or muted), or don’t autoplay.',
|
|
38
|
+
};
|
|
39
|
+
},
|
|
40
|
+
};
|