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.
@@ -6,6 +6,6 @@
6
6
  export default {
7
7
  engine: {
8
8
  name: 'pour engine',
9
- version: '1.16.0',
9
+ version: '1.18.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);
@@ -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) return { status: 'pass' };
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
- /** 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',
@@ -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
- const AUTHOR_ONLY = ['textbox', 'searchbox', 'combobox', 'listbox', 'spinbutton', 'slider'];
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 form fields must have an accessible name',
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}" field has no accessible name — screen readers announce the role with no idea what it's for.${
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pour-engine",
3
- "version": "1.16.0",
3
+ "version": "1.18.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",