pour-engine 1.17.0 → 1.19.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.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/2.5.8-target-size.js +28 -4
- 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);
|
|
@@ -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',
|
|
@@ -108,9 +108,14 @@ function reachableRects(element, rect) {
|
|
|
108
108
|
}
|
|
109
109
|
}
|
|
110
110
|
// Nothing could be probed at all (entirely out of viewport,
|
|
111
|
-
// pointer-events: none, nothing laid out):
|
|
112
|
-
// not silence a finding
|
|
113
|
-
|
|
111
|
+
// pointer-events: none, nothing laid out): the OCCLUSION question is
|
|
112
|
+
// unanswerable, and don't-know must not silence a finding — but the
|
|
113
|
+
// fragment GEOMETRY is measurable regardless, so the fragments stand,
|
|
114
|
+
// never the bounding box. A wrapped link's box is mostly leading and
|
|
115
|
+
// line-gap no pointer can activate; counting that empty space as
|
|
116
|
+
// reachable made an offscreen two-line neighbour crowd a small target
|
|
117
|
+
// its painted lines never came near (ja.wikipedia's word list).
|
|
118
|
+
if (!testable) return probes;
|
|
114
119
|
return reachable;
|
|
115
120
|
}
|
|
116
121
|
|
|
@@ -212,6 +217,17 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
212
217
|
// link cards, overlay + inner button) — not two targets crowding.
|
|
213
218
|
const encloses = (a, b) =>
|
|
214
219
|
a.left <= b.left && a.right >= b.right && a.top <= b.top && a.bottom >= b.bottom;
|
|
220
|
+
// Enclosure proved on the element's PAINT: true only when a single
|
|
221
|
+
// painted fragment contains the other rect. For unwrapped elements the
|
|
222
|
+
// one fragment is the box, so this is `encloses` exactly; a wrapped
|
|
223
|
+
// element's fragments are each line it actually paints, and a box that
|
|
224
|
+
// merely spans them encloses nothing. The box test runs first as a
|
|
225
|
+
// cheap superset — no fragment can enclose what the box doesn't.
|
|
226
|
+
const paintedEncloses = (target, targetBox, innerBox) => {
|
|
227
|
+
if (!encloses(targetBox, innerBox)) return false;
|
|
228
|
+
const fragments = [...target.getClientRects()].filter((f) => f.width > 0 && f.height > 0);
|
|
229
|
+
return fragments.length <= 1 || fragments.some((f) => encloses(f, innerBox));
|
|
230
|
+
};
|
|
215
231
|
|
|
216
232
|
// ── Obscured targets ──────────────────────────────────────────────
|
|
217
233
|
// The criterion measures the region that ACCEPTS the pointer action.
|
|
@@ -399,7 +415,15 @@ export function createTargetSizeRule({ id, tags, help, helpUrl, min, spacingExce
|
|
|
399
415
|
const crowds = (other, j) => {
|
|
400
416
|
if (j === i || !laidOut[j]) return false;
|
|
401
417
|
if (other.contains(element) || element.contains(other)) return false; // same control, nested markup
|
|
402
|
-
|
|
418
|
+
// "One control drawn twice" (stretched-link overlays) is a claim
|
|
419
|
+
// about PAINT, so it must be proved on a painted fragment, not on
|
|
420
|
+
// the bounding box: a two-line wrapped link's box swallows a small
|
|
421
|
+
// same-line neighbour while its painted lines sit nowhere near it
|
|
422
|
+
// — a different control entirely, and dismissing it here silenced
|
|
423
|
+
// the honest fragment test below. A single-fragment element's one
|
|
424
|
+
// fragment IS its box, so genuine overlays are dismissed exactly
|
|
425
|
+
// as before.
|
|
426
|
+
if (paintedEncloses(other, rects[j], rects[i]) || paintedEncloses(element, rects[i], rects[j])) return false;
|
|
403
427
|
const within = (box) => {
|
|
404
428
|
if (undersized[j]) {
|
|
405
429
|
const c = { x: box.left + box.width / 2, y: box.top + box.height / 2 };
|
package/package.json
CHANGED