pour-engine 1.1.8 → 1.1.9
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
CHANGED
package/engine/lib/contrast.js
CHANGED
|
@@ -1,17 +1,79 @@
|
|
|
1
1
|
// WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Computed colours do not all come back as `rgb()` any more. A colour the
|
|
4
|
+
// author wrote in a modern syntax is returned in THAT syntax: Chrome and
|
|
5
|
+
// Safari hand back `color(srgb 0.98 0.95 0.91)` verbatim, and Tailwind 4
|
|
6
|
+
// emits `oklch()` for its whole palette. Matching only the legacy comma form
|
|
7
|
+
// made every such colour parse as null, which resolveBackground reads as
|
|
8
|
+
// "nothing painted here" — so contrast got measured against whatever sat
|
|
9
|
+
// BEHIND the element instead of the element's own background, and a real
|
|
10
|
+
// failure could be reported as a pass or waved through as unreadable.
|
|
11
|
+
//
|
|
12
|
+
// Two tiers. The syntaxes we can convert exactly are converted here; anything
|
|
13
|
+
// else (display-p3, oklch, lab, color-mix output…) is handed to the browser,
|
|
14
|
+
// which paints one pixel and tells us the sRGB it produced. sRGB is the space
|
|
15
|
+
// WCAG's luminance maths is defined in, so that conversion is the right one.
|
|
16
|
+
const LEGACY_RGB = /^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)\s*(?:,\s*([\d.]+%?)\s*)?\)$/;
|
|
17
|
+
const MODERN_RGB = /^rgba?\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/;
|
|
18
|
+
const SRGB_COLOR = /^color\(\s*srgb\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s+(-?[\d.]+%?)\s*(?:\/\s*([\d.]+%?)\s*)?\)$/;
|
|
19
|
+
|
|
20
|
+
const clamp255 = (value) => Math.min(255, Math.max(0, value));
|
|
21
|
+
/** A channel token: `128`, `50%`, or (in color()) `0.5` on a 0-1 scale. */
|
|
22
|
+
const channel = (token, unitScale) =>
|
|
23
|
+
clamp255(token.endsWith('%') ? (parseFloat(token) / 100) * 255 : parseFloat(token) * unitScale);
|
|
24
|
+
const alphaOf = (token) => {
|
|
25
|
+
if (token === undefined) return 1;
|
|
26
|
+
const value = parseFloat(token);
|
|
27
|
+
return Math.min(1, Math.max(0, token.endsWith('%') ? value / 100 : value));
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Colour spaces we convert ourselves, exactly and with no DOM. */
|
|
31
|
+
function parseKnownSyntax(cssColor) {
|
|
32
|
+
const rgb = LEGACY_RGB.exec(cssColor) ?? MODERN_RGB.exec(cssColor);
|
|
33
|
+
if (rgb) return { r: channel(rgb[1], 1), g: channel(rgb[2], 1), b: channel(rgb[3], 1), a: alphaOf(rgb[4]) };
|
|
34
|
+
const srgb = SRGB_COLOR.exec(cssColor);
|
|
35
|
+
if (srgb) return { r: channel(srgb[1], 255), g: channel(srgb[2], 255), b: channel(srgb[3], 255), a: alphaOf(srgb[4]) };
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// One reused 1×1 canvas. Only unknown syntaxes reach it, and the result is
|
|
40
|
+
// cached by string, so a page pays for it once per distinct colour.
|
|
41
|
+
let probe;
|
|
42
|
+
let probeUnavailable = false;
|
|
43
|
+
function paintToSrgb(cssColor) {
|
|
44
|
+
if (probeUnavailable) return null;
|
|
45
|
+
try {
|
|
46
|
+
probe ??= document.createElement('canvas').getContext('2d', { willReadFrequently: true });
|
|
47
|
+
if (!probe) { probeUnavailable = true; return null; }
|
|
48
|
+
// An invalid value leaves fillStyle untouched, so two different sentinels
|
|
49
|
+
// tell "the browser rejected it" apart from "it really is that colour".
|
|
50
|
+
probe.fillStyle = '#000000';
|
|
51
|
+
probe.fillStyle = cssColor;
|
|
52
|
+
const asBlack = probe.fillStyle;
|
|
53
|
+
probe.fillStyle = '#ffffff';
|
|
54
|
+
probe.fillStyle = cssColor;
|
|
55
|
+
if (asBlack === '#000000' && probe.fillStyle === '#ffffff') return null; // rejected by the browser
|
|
56
|
+
probe.clearRect(0, 0, 1, 1);
|
|
57
|
+
probe.fillRect(0, 0, 1, 1);
|
|
58
|
+
const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;
|
|
59
|
+
// getImageData un-premultiplies, which loses precision as alpha falls;
|
|
60
|
+
// recover the colour from the alpha the browser reported.
|
|
61
|
+
return { r, g, b, a: a / 255 };
|
|
62
|
+
} catch {
|
|
63
|
+
probeUnavailable = true; // no canvas (or readback blocked): stop trying
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Parse a computed CSS colour → {r,g,b,a}, or null when it cannot be read.
|
|
4
69
|
* Cached by string: pages use a handful of distinct colours across
|
|
5
|
-
* thousands of elements, and
|
|
70
|
+
* thousands of elements, and parsing was a top cost in contrast audits.
|
|
6
71
|
* Callers must not mutate the returned object. */
|
|
7
72
|
const colorCache = new Map();
|
|
8
73
|
export function parseColor(cssColor) {
|
|
9
74
|
if (cssColor == null) return null;
|
|
10
75
|
if (colorCache.has(cssColor)) return colorCache.get(cssColor);
|
|
11
|
-
const
|
|
12
|
-
const color = match
|
|
13
|
-
? { r: +match[1], g: +match[2], b: +match[3], a: match[4] === undefined ? 1 : +match[4] }
|
|
14
|
-
: null;
|
|
76
|
+
const color = parseKnownSyntax(cssColor) ?? paintToSrgb(cssColor);
|
|
15
77
|
if (colorCache.size < 10_000) colorCache.set(cssColor, color); // bounded: computed colors are few
|
|
16
78
|
return color;
|
|
17
79
|
}
|
|
@@ -64,6 +126,28 @@ export function resetAuditCaches() {
|
|
|
64
126
|
pseudoCache = new WeakMap();
|
|
65
127
|
}
|
|
66
128
|
|
|
129
|
+
/**
|
|
130
|
+
* The computed `background-color` STRING that ended the background walk, as
|
|
131
|
+
* the author's stylesheet produced it. Only when one opaque colour settled
|
|
132
|
+
* it: translucent layers composite into something no single declaration in
|
|
133
|
+
* the CSS holds, and there is nothing honest to point at.
|
|
134
|
+
*
|
|
135
|
+
* Reported so a failure names a colour the developer can actually find. A
|
|
136
|
+
* page written in oklch() or color(srgb …) gets told its ratio in sRGB, and
|
|
137
|
+
* searching the stylesheet for that rgb() value finds nothing at all.
|
|
138
|
+
*/
|
|
139
|
+
export function backgroundColorSource(element) {
|
|
140
|
+
for (let node = element; node && node.nodeType === 1;
|
|
141
|
+
node = node.parentElement ?? node.getRootNode()?.host) {
|
|
142
|
+
const style = getComputedStyle(node);
|
|
143
|
+
if (style.backgroundImage !== 'none') return null;
|
|
144
|
+
const color = parseColor(style.backgroundColor);
|
|
145
|
+
if (!color || color.a === 0) continue; // transparent: keep walking, as the resolver does
|
|
146
|
+
return color.a >= 1 ? style.backgroundColor : null; // translucent: composited, no single source
|
|
147
|
+
}
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
|
|
67
151
|
function canvasColor(doc) {
|
|
68
152
|
// Pages using color-scheme: dark get a dark canvas from the browser even
|
|
69
153
|
// with no author background — assuming white there inverts every result.
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
backgroundImageSource, backgroundImagePaintRect, imageLuminanceRange, gradientLuminanceRange,
|
|
5
5
|
rangeVerdict, isLargeText, cumulativeOpacity, opacityAnimating, restingOpacity, mediaRects,
|
|
6
6
|
paintedBackdrop, opaquePanelRects, viewportVeil, textShadowHalo, textShadowNegligible,
|
|
7
|
-
pseudoBackdropForText,
|
|
7
|
+
pseudoBackdropForText, backgroundColorSource,
|
|
8
8
|
} from '../../lib/contrast.js';
|
|
9
9
|
|
|
10
10
|
/** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
|
|
@@ -468,14 +468,33 @@ export function createContrastRule({ id, tags, help, helpUrl, thresholds }) {
|
|
|
468
468
|
const hazard = backdropHazard('fail', backgroundVerified);
|
|
469
469
|
if (hazard) return hazard;
|
|
470
470
|
}
|
|
471
|
+
// Both colours in sRGB, which is the space the ratio was computed in and
|
|
472
|
+
// the one an interactive checker accepts. The author's own syntax is
|
|
473
|
+
// carried alongside, never instead: a page written in oklch() or
|
|
474
|
+
// color(srgb …) would otherwise be handed an rgb() value that appears
|
|
475
|
+
// nowhere in its stylesheet.
|
|
476
|
+
const asRgb = (color) => `rgb(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)})`;
|
|
477
|
+
const foregroundRgb = asRgb(foreground);
|
|
478
|
+
const backgroundRgb = asRgb(background);
|
|
479
|
+
const foregroundCss = style.color !== foregroundRgb ? style.color : null;
|
|
480
|
+
const backgroundSource = backgroundColorSource(element);
|
|
481
|
+
const backgroundCss = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
|
|
482
|
+
const authored = [
|
|
483
|
+
foregroundCss && `the text as ${foregroundCss}`,
|
|
484
|
+
backgroundCss && `the background as ${backgroundCss}`,
|
|
485
|
+
].filter(Boolean).join(' and ');
|
|
486
|
+
|
|
471
487
|
return {
|
|
472
488
|
status: 'fail',
|
|
473
489
|
message: `Contrast is ${showRatio(ratio)}:1 — below the ${required}:1 WCAG minimum for this text size.`,
|
|
474
|
-
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${
|
|
490
|
+
fix: `Darken the text or lighten the background until the ratio reaches ${required}:1 (currently ${foregroundRgb} on ${backgroundRgb}).`
|
|
491
|
+
+ (authored ? ` Your CSS writes ${authored}, so searching it for the sRGB values above won't find them.` : ''),
|
|
475
492
|
// The exact pair, for the UIs to link out to an interactive checker.
|
|
476
493
|
data: {
|
|
477
|
-
foreground:
|
|
478
|
-
background:
|
|
494
|
+
foreground: foregroundRgb,
|
|
495
|
+
background: backgroundRgb,
|
|
496
|
+
...(foregroundCss ? { foregroundCss } : {}),
|
|
497
|
+
...(backgroundCss ? { backgroundCss } : {}),
|
|
479
498
|
ratio: Number(showRatio(ratio)),
|
|
480
499
|
required,
|
|
481
500
|
},
|
package/package.json
CHANGED