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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.17.0',
9
+ version: '1.19.0',
10
10
  },
11
11
  };
@@ -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
- /** Display a ratio truncated (never rounded up): 4.495 must read "4.49",
12
- * because "4.50:1 — below the 4.5:1 minimum" reads as a contradiction. */
13
- const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
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
- foreground: style.color,
703
- background: `rgb(${Math.round(halo.r)}, ${Math.round(halo.g)}, ${Math.round(halo.b)})`,
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
- const foregroundCss = style.color !== foregroundRgb ? style.color : null;
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 backgroundCss = backgroundSource && backgroundSource !== backgroundRgb ? backgroundSource : null;
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
- foregroundCss && `the text as ${foregroundCss}`,
748
- backgroundCss && `the background as ${backgroundCss}`,
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): don't know, and don't-know must
112
- // not silence a finding, so the whole rect stands.
113
- if (!testable) return [rect];
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
- if (encloses(rects[j], rects[i]) || encloses(rects[i], rects[j])) return false;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.17.0",
3
+ "version": "1.19.0",
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",