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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.1.8',
9
+ version: '1.1.9',
10
10
  },
11
11
  };
@@ -1,17 +1,79 @@
1
1
  // WCAG contrast math (WCAG 2.x, relative luminance per sRGB).
2
2
 
3
- /** Parse a computed CSS colour (`rgb()`/`rgba()`) → {r,g,b,a} or null.
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 the regex was a top cost in contrast audits.
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 match = cssColor.match(/rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)/);
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 ${style.color} on this background).`,
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: style.color,
478
- background: `rgb(${Math.round(background.r)}, ${Math.round(background.g)}, ${Math.round(background.b)})`,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.1.8",
3
+ "version": "1.1.9",
4
4
  "description": "Accessibility (WCAG 2.2) audit engine written from the W3C specifications — runs entirely in the browser, no dependencies.",
5
5
  "type": "module",
6
6
  "main": "engine/index.js",