pour-engine 1.16.0 → 1.18.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/lib/contrast.js +15 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +22 -1
- package/engine/rules/wcag/1.4.11-non-text-contrast.js +1 -3
- package/engine/rules/wcag/1.4.3-color-contrast.js +39 -11
- package/engine/rules/wcag/1.4.3-control-contrast.js +1 -3
- package/engine/rules/wcag/4.1.2-aria-field-name.js +7 -3
- package/package.json +1 -1
package/config/project.config.js
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -93,6 +93,21 @@ export function contrastRatio(foreground, background) {
|
|
|
93
93
|
return (lighter + 0.05) / (darker + 0.05);
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
/**
|
|
97
|
+
* Display a ratio truncated, never rounded up: 4.495 must read "4.49",
|
|
98
|
+
* because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction.
|
|
99
|
+
* Shared by the rules and the /contrast/ checker so the two surfaces can
|
|
100
|
+
* never show the same ratio differently. Above 10 one decimal is plenty
|
|
101
|
+
* (no text threshold lives up there), and "21:1" reads better than
|
|
102
|
+
* "21.0:1" — a trailing zero implies precision the eye can use.
|
|
103
|
+
*/
|
|
104
|
+
export function showRatio(ratio) {
|
|
105
|
+
const text = ratio >= 10
|
|
106
|
+
? (Math.floor(ratio * 10) / 10).toFixed(1)
|
|
107
|
+
: (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
108
|
+
return text.replace(/\.0+$/, '');
|
|
109
|
+
}
|
|
110
|
+
|
|
96
111
|
/** Alpha-composite `top` over `bottom` (both {r,g,b,a}) → opaque colour. */
|
|
97
112
|
export function composite(top, bottom) {
|
|
98
113
|
const a = top.a + bottom.a * (1 - top.a);
|
|
@@ -52,7 +52,28 @@ export default {
|
|
|
52
52
|
return !hasStrayText && effectiveChildren(child).every(isValidChild);
|
|
53
53
|
};
|
|
54
54
|
const invalid = effectiveChildren(element).filter((child) => !isValidChild(child));
|
|
55
|
-
if (!invalid.length)
|
|
55
|
+
if (!invalid.length) {
|
|
56
|
+
// Every rendered item neutralised: role="none" on each <li> strips
|
|
57
|
+
// its listitem semantics while the <ul> keeps announcing "list" —
|
|
58
|
+
// Chromium exposes the list role over the none children (measured
|
|
59
|
+
// 2026-08-09), so readers hear a list that owns zero items around
|
|
60
|
+
// content they can plainly see. That is the same broken announcement
|
|
61
|
+
// as <ul><div>, reached via a different door. A PARTIAL
|
|
62
|
+
// neutralisation stays valid: hiding a decorative separator item
|
|
63
|
+
// from the count is what role="none" on an <li> is for.
|
|
64
|
+
const renderedItems = effectiveChildren(element).filter((child) =>
|
|
65
|
+
child.tagName === 'LI' && (!isRendered || isRendered(child)));
|
|
66
|
+
const neutralised = renderedItems.filter((child) =>
|
|
67
|
+
['presentation', 'none'].includes(child.getAttribute('role') ?? ''));
|
|
68
|
+
if (renderedItems.length && neutralised.length === renderedItems.length) {
|
|
69
|
+
return {
|
|
70
|
+
status: 'fail',
|
|
71
|
+
message: `Every item in this list carries role="${neutralised[0].getAttribute('role')}", so the list announces itself with zero items while ${renderedItems.length} are visible — screen readers lose the count and the positions.`,
|
|
72
|
+
fix: 'Remove the role from the <li> elements, or neutralise the whole structure by putting role="presentation" (or the intended widget role) on the list element itself.',
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return { status: 'pass' };
|
|
76
|
+
}
|
|
56
77
|
const tags = [...new Set(invalid.map((child) => {
|
|
57
78
|
const role = child.getAttribute('role');
|
|
58
79
|
return `<${child.tagName.toLowerCase()}${role ? ` role="${role}"` : ''}>`;
|
|
@@ -6,9 +6,7 @@
|
|
|
6
6
|
// with placeholder text can conform with a faint border (#ccc borders are
|
|
7
7
|
// endemic, WebAIM's own search field included), and that judgment needs
|
|
8
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);
|
|
9
|
+
import { parseColor, contrastRatio, composite, effectiveBackground, showRatio } from '../../lib/contrast.js';
|
|
12
10
|
|
|
13
11
|
export default {
|
|
14
12
|
id: 'non-text-contrast',
|
|
@@ -6,11 +6,15 @@ import {
|
|
|
6
6
|
opacityAnimating, restingOpacity, mediaRects,
|
|
7
7
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
8
8
|
pseudoBackdropForText, filmedContrastBounds, backgroundColorSource, scrimPaint, applyOverlays,
|
|
9
|
+
showRatio,
|
|
9
10
|
} from '../../lib/contrast.js';
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
*
|
|
13
|
-
|
|
12
|
+
/** Serialise a judged colour for the report and the checker link. Composited
|
|
13
|
+
* channels keep up to two decimals: rounding them to integers changes the
|
|
14
|
+
* pair enough that the checker scores a different ratio than the audit
|
|
15
|
+
* reported (4.21 vs 4.19 on a 70%-opacity footer). */
|
|
16
|
+
const channelText = (value) => String(Math.round(value * 100) / 100);
|
|
17
|
+
const asRgb = (color) => `rgb(${channelText(color.r)}, ${channelText(color.g)}, ${channelText(color.b)})`;
|
|
14
18
|
|
|
15
19
|
/** The one case a full-page veil still can't be judged for the author: the
|
|
16
20
|
* resting page and the dimmed one land on opposite sides of the threshold,
|
|
@@ -699,8 +703,10 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
699
703
|
message: `Contrast is ${showRatio(haloRatio)}:1 against this text's own outline and ${showRatio(ratio)}:1 against the background — below the ${required}:1 WCAG minimum for this text size.`,
|
|
700
704
|
fix: `Increase the difference between the text colour and its text-shadow outline (or the background) until the ratio reaches ${required}:1.`,
|
|
701
705
|
data: {
|
|
702
|
-
|
|
703
|
-
|
|
706
|
+
// The judged (composited) colour, not style.color: the checker
|
|
707
|
+
// link must score the pair the viewer actually sees.
|
|
708
|
+
foreground: asRgb(foreground),
|
|
709
|
+
background: asRgb(halo),
|
|
704
710
|
ratio: Number(showRatio(haloRatio)),
|
|
705
711
|
required,
|
|
706
712
|
},
|
|
@@ -737,22 +743,44 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
737
743
|
// carried alongside, never instead: a page written in oklch() or
|
|
738
744
|
// color(srgb …) would otherwise be handed an rgb() value that appears
|
|
739
745
|
// nowhere in its stylesheet.
|
|
740
|
-
const asRgb = (color) => `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`;
|
|
741
746
|
const foregroundRgb = asRgb(foreground);
|
|
742
747
|
const backgroundRgb = asRgb(background);
|
|
743
|
-
|
|
748
|
+
// …but only when the authored string is the judged colour in another
|
|
749
|
+
// spelling. An authored colour that differs in VALUE (translucent text,
|
|
750
|
+
// an ancestor's opacity dimming the glyphs) still names what to search
|
|
751
|
+
// the stylesheet for, yet must never reach the checker link: the checker
|
|
752
|
+
// would score a pair the page never renders, showing a passing ratio for
|
|
753
|
+
// this very failure.
|
|
754
|
+
const sameValue = (css, judged) => {
|
|
755
|
+
const parsed = parseColor(css);
|
|
756
|
+
return parsed != null && parsed.a >= 1 && asRgb(parsed) === judged;
|
|
757
|
+
};
|
|
758
|
+
const authoredFg = style.color !== foregroundRgb ? style.color : null;
|
|
744
759
|
const backgroundSource = backgroundColorSource(element);
|
|
745
|
-
const
|
|
760
|
+
const authoredBg = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
|
|
761
|
+
const foregroundCss = authoredFg && sameValue(authoredFg, foregroundRgb) ? authoredFg : null;
|
|
762
|
+
const backgroundCss = authoredBg && sameValue(authoredBg, backgroundRgb) ? authoredBg : null;
|
|
746
763
|
const authored = [
|
|
747
|
-
|
|
748
|
-
|
|
764
|
+
authoredFg && `the text as ${authoredFg}`,
|
|
765
|
+
authoredBg && `the background as ${authoredBg}`,
|
|
749
766
|
].filter(Boolean).join(' and ');
|
|
767
|
+
// Say WHY the rendered colour differs when the mechanism is provable, so
|
|
768
|
+
// the author learns the cause along with the finding.
|
|
769
|
+
const parsedAuthoredFg = authoredFg && !foregroundCss ? parseColor(authoredFg) : null;
|
|
770
|
+
const dimmedNote = parsedAuthoredFg
|
|
771
|
+
? (opacity < 1
|
|
772
|
+
? ` The gap is opacity: this element or an ancestor renders at ${Math.round(opacity * 100)}% opacity, dimming the text to the sRGB value above.`
|
|
773
|
+
: (parsedAuthoredFg.a < 1
|
|
774
|
+
? ' The text colour is translucent, so it composites with the background into the sRGB value above.'
|
|
775
|
+
: ''))
|
|
776
|
+
: '';
|
|
750
777
|
|
|
751
778
|
return {
|
|
752
779
|
status: 'fail',
|
|
753
780
|
message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.`,
|
|
754
781
|
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${foregroundRgb} on ${backgroundRgb}).`
|
|
755
|
-
+ (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : '')
|
|
782
|
+
+ (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : '')
|
|
783
|
+
+ dimmedNote,
|
|
756
784
|
// The exact pair, for the UIs to link out to an interactive checker.
|
|
757
785
|
data: {
|
|
758
786
|
foreground: foregroundRgb,
|
|
@@ -4,11 +4,9 @@
|
|
|
4
4
|
// endemic ~2.8:1 gray-on-white) was previously never measured by any rule.
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
|
-
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity,
|
|
7
|
+
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity, showRatio,
|
|
8
8
|
} from '../../lib/contrast.js';
|
|
9
9
|
|
|
10
|
-
const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
11
|
-
|
|
12
10
|
export default {
|
|
13
11
|
id: 'control-contrast',
|
|
14
12
|
impact: 'serious',
|
|
@@ -7,7 +7,11 @@
|
|
|
7
7
|
// naming counts. Toggle roles (checkbox/radio/switch) DO name from content.
|
|
8
8
|
import { labelledByName } from '../../lib/accessible-name.js';
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
// progressbar and meter belong here too: ARIA 1.2 marks both roles
|
|
11
|
+
// "Accessible Name Required: True", they never name from content, and a
|
|
12
|
+
// nameless one announces a bare value — "25%, progress bar" — of nothing
|
|
13
|
+
// in particular (found live: unlabelled ant-progress widgets).
|
|
14
|
+
const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider', 'progressbar', 'meter'];
|
|
11
15
|
const FROM_CONTENT = ['checkbox', 'radio', 'switch'];
|
|
12
16
|
|
|
13
17
|
function authorName(element) {
|
|
@@ -21,7 +25,7 @@ export default {
|
|
|
21
25
|
id: 'aria-field-name',
|
|
22
26
|
impact: 'serious',
|
|
23
27
|
tags: ['wcag2a', 'wcag412'],
|
|
24
|
-
help: 'ARIA
|
|
28
|
+
help: 'ARIA fields and value widgets must have an accessible name',
|
|
25
29
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/name-role-value.html',
|
|
26
30
|
selector: [...AUTHOR_ONLY, ...FROM_CONTENT]
|
|
27
31
|
.map((role) => `[role="${role}"]:not(input):not(select):not(textarea)`)
|
|
@@ -42,7 +46,7 @@ export default {
|
|
|
42
46
|
}
|
|
43
47
|
return {
|
|
44
48
|
status: 'fail',
|
|
45
|
-
message: `This role="${role}"
|
|
49
|
+
message: `This role="${role}" control has no accessible name — screen readers announce the role and value with no idea what it's for.${
|
|
46
50
|
AUTHOR_ONLY.includes(role) ? ' (Content inside it does not count as a name for this role.)' : ''}`,
|
|
47
51
|
fix: 'Add aria-label="…" or aria-labelledby pointing at its visible label.',
|
|
48
52
|
};
|
package/package.json
CHANGED