@automattic/charts 1.8.1 → 1.9.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/dist/index.cjs +530 -52
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +122 -34
  5. package/dist/index.d.cts +97 -7
  6. package/dist/index.d.ts +97 -7
  7. package/dist/index.js +527 -54
  8. package/dist/index.js.map +1 -1
  9. package/package.json +7 -7
  10. package/src/charts/area-chart/area-chart.module.scss +3 -1
  11. package/src/charts/area-chart/area-chart.tsx +5 -2
  12. package/src/charts/bar-chart/bar-chart.module.scss +6 -2
  13. package/src/charts/bar-chart/private/comparison-bars.tsx +6 -0
  14. package/src/charts/conversion-funnel-chart/conversion-funnel-chart.module.scss +15 -12
  15. package/src/charts/geo-chart/geo-chart.tsx +6 -1
  16. package/src/charts/geo-chart/test/geo-chart.test.tsx +11 -1
  17. package/src/charts/heatmap-chart/heatmap-chart.module.scss +103 -0
  18. package/src/charts/heatmap-chart/heatmap-chart.tsx +422 -0
  19. package/src/charts/heatmap-chart/index.ts +4 -0
  20. package/src/charts/heatmap-chart/private/build-calendar-data.ts +81 -0
  21. package/src/charts/heatmap-chart/private/heatmap-legend.tsx +53 -0
  22. package/src/charts/heatmap-chart/private/index.ts +5 -0
  23. package/src/charts/heatmap-chart/private/use-heatmap-colors.ts +45 -0
  24. package/src/charts/heatmap-chart/test/build-calendar-data.test.ts +88 -0
  25. package/src/charts/heatmap-chart/test/heatmap-chart.test.tsx +301 -0
  26. package/src/charts/heatmap-chart/test/use-heatmap-colors.test.ts +34 -0
  27. package/src/charts/heatmap-chart/types.ts +42 -0
  28. package/src/charts/index.ts +1 -0
  29. package/src/charts/leaderboard-chart/leaderboard-chart.module.scss +18 -6
  30. package/src/charts/line-chart/line-chart.module.scss +6 -4
  31. package/src/charts/line-chart/line-chart.tsx +6 -2
  32. package/src/charts/line-chart/private/line-chart-annotation.tsx +16 -3
  33. package/src/charts/private/grid-control/grid-control.module.scss +1 -4
  34. package/src/charts/private/svg-empty-state/svg-empty-state.module.scss +1 -1
  35. package/src/charts/private/with-responsive/test/with-responsive.test.tsx +14 -0
  36. package/src/charts/private/with-responsive/with-responsive.tsx +12 -0
  37. package/src/charts/private/x-zoom.module.scss +6 -3
  38. package/src/components/legend/private/base-legend.module.scss +3 -1
  39. package/src/components/tooltip/base-tooltip.module.scss +4 -1
  40. package/src/components/trend-indicator/trend-indicator.module.scss +5 -3
  41. package/src/hooks/use-xychart-theme.ts +24 -0
  42. package/src/index.ts +12 -0
  43. package/src/providers/chart-context/themes.ts +29 -16
  44. package/src/types.ts +19 -2
  45. package/src/utils/color-utils.ts +36 -0
  46. package/src/utils/test/color-utils.test.ts +33 -0
@@ -72,6 +72,20 @@ describe( 'withResponsive', () => {
72
72
  const wrapper = screen.getByTestId( 'responsive-wrapper' );
73
73
  expect( wrapper ).toHaveStyle( { width: '100%', height: 'auto' } );
74
74
  } );
75
+
76
+ test( 'wrapper expresses aspectRatio in CSS and caps at maxWidth', () => {
77
+ render( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } maxWidth={ 800 } /> );
78
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
79
+ // CSS aspect-ratio is width/height, so 1 / 0.5 = 2.
80
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } );
81
+ } );
82
+
83
+ test( 'wrapper omits the maxWidth cap when an explicit width is set', () => {
84
+ render( <ResponsiveComponent data={ [] } aspectRatio={ 0.5 } width={ 300 } /> );
85
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
86
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } );
87
+ expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } );
88
+ } );
75
89
  } );
76
90
 
77
91
  describe( 'configuration', () => {
@@ -93,6 +93,17 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
93
93
  const effectiveHeight = measuredHeight || height || 0;
94
94
 
95
95
  const defaultHeight = hasAspectRatio ? 'auto' : '100%';
96
+ // Express the aspect ratio in CSS so the container height tracks its width
97
+ // fluidly, rather than snapping to a debounced measured height. Cap the width
98
+ // at maxWidth so the CSS-derived height matches the maxWidth-capped content
99
+ // (the wrapped chart is sized from the capped `measuredWidth`).
100
+ const aspectRatioStyle =
101
+ hasAspectRatio && aspectRatio
102
+ ? {
103
+ aspectRatio: `${ 1 / aspectRatio }`,
104
+ maxWidth: width === undefined ? maxWidth : undefined,
105
+ }
106
+ : null;
96
107
 
97
108
  return (
98
109
  <div
@@ -102,6 +113,7 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
102
113
  style={ {
103
114
  width: width ?? '100%',
104
115
  height: height ?? defaultHeight,
116
+ ...aspectRatioStyle,
105
117
  } }
106
118
  >
107
119
  <WrappedComponent
@@ -1,5 +1,8 @@
1
1
  .x-zoom {
2
2
 
3
+ // The translucent rgba overlay colors (selection fill/stroke, reset
4
+ // background/border) have no WPDS equivalent and stay as-is; only the
5
+ // opaque foreground and focus colors are tokenized.
3
6
  &__selection {
4
7
  fill: var(--charts-zoom-selection-fill, rgba(56, 88, 233, 0.16));
5
8
  stroke: var(--charts-zoom-selection-stroke, rgba(56, 88, 233, 0.65));
@@ -19,20 +22,20 @@
19
22
  height: 28px;
20
23
  padding: 0;
21
24
  background: var(--charts-zoom-reset-bg, rgba(255, 255, 255, 0.92));
22
- color: var(--charts-zoom-reset-fg, #1e1e1e);
25
+ color: var(--charts-zoom-reset-fg, var(--wpds-color-fg-content-neutral, #1e1e1e));
23
26
  border:
24
27
  var(--wpds-border-width-xs, 1px) solid
25
28
  var(--charts-zoom-reset-border, rgba(0, 0, 0, 0.16));
26
29
  border-radius: var(--wpds-border-radius-md, 4px);
27
30
  cursor: pointer;
28
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
31
+ box-shadow: var(--wpds-elevation-xs, 0 1px 1px 0 #00000008, 0 1px 2px 0 #00000005, 0 3px 3px 0 #00000005, 0 4px 4px 0 #00000003);
29
32
 
30
33
  &:hover {
31
34
  background: var(--charts-zoom-reset-bg-hover, rgba(255, 255, 255, 1));
32
35
  }
33
36
 
34
37
  &:focus-visible {
35
- outline: 2px solid var(--charts-zoom-reset-focus, #3858e9);
38
+ outline: 2px solid var(--charts-zoom-reset-focus, var(--wpds-color-stroke-focus-brand, #3858e9));
36
39
  outline-offset: 1px;
37
40
  }
38
41
  }
@@ -8,7 +8,9 @@
8
8
  &--interactive {
9
9
  cursor: pointer;
10
10
  user-select: none;
11
- transition: opacity 0.2s ease;
11
+ transition:
12
+ opacity var(--wpds-motion-duration-md, 200ms)
13
+ var(--wpds-motion-easing-subtle, cubic-bezier(0.15, 0, 0.15, 1));
12
14
 
13
15
  &:hover {
14
16
  opacity: 0.8;
@@ -1,10 +1,13 @@
1
1
  .tooltip {
2
2
  padding: var(--wpds-dimension-padding-sm, 8px);
3
+ // No WPDS token fits a translucent dark tooltip surface:
4
+ // bg-interactive-neutral-strong is opaque, and there is no
5
+ // white-on-dark content foreground token.
3
6
  background-color: rgba(0, 0, 0, 0.85);
4
7
  color: #fff;
5
8
  border-radius: var(--wpds-border-radius-md, 4px);
6
9
  font-size: var(--wpds-typography-font-size-md, 13px);
7
- box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
10
+ box-shadow: var(--wpds-elevation-sm, 0 1px 2px 0 #0000000d, 0 2px 3px 0 #0000000a, 0 6px 6px 0 #00000008, 0 8px 8px 0 #00000005);
8
11
  position: absolute;
9
12
  pointer-events: none;
10
13
  transform: translate(-50%, -100%);
@@ -6,16 +6,18 @@
6
6
  font-weight: var(--wpds-typography-font-weight-medium, 499);
7
7
  line-height: 1;
8
8
 
9
+ // Trend colors use the documented --charts-trend-* override API, falling
10
+ // back to the accessible WPDS semantic tokens.
9
11
  &--up {
10
- color: var(--charts-trend-up-color, #1a8917);
12
+ color: var(--charts-trend-up-color, var(--wpds-color-fg-content-success-weak, #008030));
11
13
  }
12
14
 
13
15
  &--down {
14
- color: var(--charts-trend-down-color, #d63638);
16
+ color: var(--charts-trend-down-color, var(--wpds-color-fg-content-error-weak, #cc1818));
15
17
  }
16
18
 
17
19
  &--neutral {
18
- color: var(--charts-trend-neutral-color, #646970);
20
+ color: var(--charts-trend-neutral-color, var(--wpds-color-fg-content-neutral-weak, #707070));
19
21
  }
20
22
 
21
23
  &__icon {
@@ -1,8 +1,15 @@
1
1
  import { buildChartTheme } from '@visx/xychart';
2
2
  import { useMemo } from 'react';
3
3
  import { useGlobalChartsTheme } from '../providers';
4
+ import { resolveCssVariable } from '../utils';
4
5
  import type { SeriesData } from '../types';
5
6
 
7
+ // visx applies grid, axis, and tick-label colors as SVG presentation attributes,
8
+ // where CSS var() cannot resolve. Resolve WPDS tokens to concrete values (or their
9
+ // fallbacks) before handing the theme to buildChartTheme.
10
+ const resolveColor = ( value?: string ): string | undefined =>
11
+ value ? resolveCssVariable( value ) ?? value : value;
12
+
6
13
  export const useXYChartTheme = ( data: SeriesData[] ) => {
7
14
  const theme = useGlobalChartsTheme();
8
15
 
@@ -14,6 +21,23 @@ export const useXYChartTheme = ( data: SeriesData[] ) => {
14
21
  return buildChartTheme( {
15
22
  ...theme,
16
23
  colors: [ ...seriesColors, ...( theme.colors ?? [] ) ],
24
+ backgroundColor: resolveColor( theme.backgroundColor ),
25
+ gridStyles: theme.gridStyles && {
26
+ ...theme.gridStyles,
27
+ stroke: resolveColor( theme.gridStyles.stroke ),
28
+ },
29
+ xAxisLineStyles: theme.xAxisLineStyles && {
30
+ ...theme.xAxisLineStyles,
31
+ stroke: resolveColor( theme.xAxisLineStyles.stroke ),
32
+ },
33
+ xTickLineStyles: theme.xTickLineStyles && {
34
+ ...theme.xTickLineStyles,
35
+ stroke: resolveColor( theme.xTickLineStyles.stroke ),
36
+ },
37
+ svgLabelSmall: theme.svgLabelSmall && {
38
+ ...theme.svgLabelSmall,
39
+ fill: resolveColor( theme.svgLabelSmall.fill ),
40
+ },
17
41
  } );
18
42
  }, [ theme, data ] );
19
43
  };
package/src/index.ts CHANGED
@@ -4,6 +4,11 @@ export { BarChart, BarChartUnresponsive } from './charts/bar-chart';
4
4
  export { BarListChart, BarListChartUnresponsive } from './charts/bar-list-chart';
5
5
  export { ConversionFunnelChart } from './charts/conversion-funnel-chart';
6
6
  export { GeoChart, GeoChartUnresponsive } from './charts/geo-chart';
7
+ export {
8
+ HeatmapChart,
9
+ HeatmapChartUnresponsive,
10
+ buildCalendarHeatmapData,
11
+ } from './charts/heatmap-chart';
7
12
  export { LeaderboardChart, LeaderboardChartUnresponsive } from './charts/leaderboard-chart';
8
13
  export { LineChart, LineChartUnresponsive } from './charts/line-chart';
9
14
  export { PieChart, PieChartUnresponsive } from './charts/pie-chart';
@@ -96,6 +101,13 @@ export type {
96
101
  MainMetricRenderProps,
97
102
  TooltipRenderProps,
98
103
  } from './charts/conversion-funnel-chart';
104
+ export type {
105
+ HeatmapChartProps,
106
+ HeatmapColumn,
107
+ HeatmapCell,
108
+ HeatmapTooltipData,
109
+ CalendarHeatmapResult,
110
+ } from './charts/heatmap-chart';
99
111
  export type { LeaderboardChartProps } from './charts/leaderboard-chart';
100
112
  export type {
101
113
  LineChartProps,
@@ -4,22 +4,24 @@ import type { CompleteChartTheme } from '../../types';
4
4
  * Default theme configuration
5
5
  */
6
6
  const defaultTheme: CompleteChartTheme = {
7
- backgroundColor: '#FFFFFF', // chart background color
7
+ backgroundColor: 'var(--wpds-color-bg-surface-neutral-strong, #fff)',
8
8
  labelBackgroundColor: 'transparent', // label background color (transparent by default)
9
- labelTextColor: '#FFFFFF', // label text color (white to match original behavior)
9
+ // White label text sits on top of arbitrary series colors, so it has no WPDS
10
+ // content-foreground equivalent and stays hardcoded (tokenization outlier).
11
+ labelTextColor: '#FFFFFF',
10
12
  colors: [ '#98C8DF', '#006DAB', '#A6DC80', '#1F9828', '#FF8C8F' ],
11
13
  gridStyles: {
12
- stroke: '#DCDCDE',
14
+ stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)',
13
15
  strokeWidth: 1,
14
16
  },
15
17
  tickLength: 4,
16
18
  gridColor: '',
17
19
  gridColorDark: '',
18
- xTickLineStyles: { stroke: 'black' },
19
- xAxisLineStyles: { stroke: '#DCDCDE', strokeWidth: 1 },
20
+ xTickLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
21
+ xAxisLineStyles: { stroke: 'var(--wpds-color-stroke-surface-neutral, #dbdbdb)', strokeWidth: 1 },
20
22
  legend: {
21
23
  labelStyles: {
22
- color: 'var(--jp-gray-80, #2c3338)',
24
+ color: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
23
25
  },
24
26
  containerStyles: {},
25
27
  shapeStyles: [],
@@ -31,35 +33,40 @@ const defaultTheme: CompleteChartTheme = {
31
33
  // that `buildChartTheme` injects as an inline style on SVG `<text>`
32
34
  // elements for axis labels and ticks. Setting `inherit` lets SVG text
33
35
  // pick up the host application's font-family via normal CSS inheritance.
34
- svgLabelSmall: { fill: 'var(--jp-gray-80, #2c3338)', fontFamily: 'inherit' },
36
+ svgLabelSmall: { fill: 'var(--wpds-color-fg-content-neutral, #1e1e1e)', fontFamily: 'inherit' },
35
37
  svgLabelBig: { fontFamily: 'inherit' },
36
38
  annotationStyles: {
37
39
  label: {
38
- anchorLineStroke: 'var(--jp-gray-80, #2c3338)',
39
- backgroundFill: '#fff',
40
+ anchorLineStroke: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
41
+ backgroundFill: 'var(--wpds-color-bg-surface-neutral-strong, #fff)',
40
42
  },
41
43
  connector: {
42
- stroke: 'var(--jp-gray-80, #2c3338)',
44
+ stroke: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
43
45
  },
44
46
  circleSubject: {
45
47
  stroke: 'transparent',
46
- fill: 'var(--jp-gray-80, #2c3338)',
48
+ fill: 'var(--wpds-color-fg-content-neutral, #1e1e1e)',
47
49
  radius: 5,
48
50
  },
49
51
  },
50
52
  geoChart: {
51
- featureFillColor: 'var(--jp-gray-0, #f6f7f7)',
53
+ featureFillColor: 'var(--wpds-color-bg-surface-neutral-weak, #f4f4f4)',
52
54
  },
53
55
  leaderboardChart: {
54
56
  rowGap: 12,
55
57
  columnGap: 4,
56
58
  labelSpacing: 'xs',
57
- deltaColors: [ '#FF8C8F', '#757575', '#1F9828' ], // [negative, neutral, positive]
59
+ // [negative, neutral, positive]
60
+ deltaColors: [
61
+ 'var(--wpds-color-fg-content-error-weak, #cc1818)',
62
+ 'var(--wpds-color-fg-content-neutral-weak, #707070)',
63
+ 'var(--wpds-color-fg-content-success-weak, #008030)',
64
+ ],
58
65
  },
59
66
  conversionFunnelChart: {
60
- backgroundColor: '#F3F4F6',
61
- positiveChangeColor: '#1F9828',
62
- negativeChangeColor: '#FF8C8F',
67
+ backgroundColor: 'var(--wpds-color-bg-surface-neutral-weak, #f4f4f4)',
68
+ positiveChangeColor: 'var(--wpds-color-fg-content-success-weak, #008030)',
69
+ negativeChangeColor: 'var(--wpds-color-fg-content-error-weak, #cc1818)',
63
70
  },
64
71
  lineChart: {
65
72
  lineStyles: {
@@ -81,6 +88,12 @@ const defaultTheme: CompleteChartTheme = {
81
88
  margin: { top: 2, right: 2, bottom: 2, left: 2 },
82
89
  strokeWidth: 1.5,
83
90
  },
91
+ // `primaryColor` is left unset so it falls back to the palette's `colors[0]`. The compact
92
+ // 11px square / 2px gap is the contribution-graph rhythm, which has no WPDS dimension.
93
+ heatmapChart: {
94
+ compactCellGap: 2,
95
+ compactCellSize: 11,
96
+ },
84
97
  };
85
98
 
86
99
  export { defaultTheme };
package/src/types.ts CHANGED
@@ -249,8 +249,8 @@ export type GradientStop = {
249
249
 
250
250
  export type SeriesDataOptions = {
251
251
  gradient?: {
252
- from: string;
253
- to: string;
252
+ from?: string;
253
+ to?: string;
254
254
  fromOpacity?: number;
255
255
  toOpacity?: number;
256
256
  stops?: GradientStop[];
@@ -413,6 +413,21 @@ export type ChartTheme = {
413
413
  /** Stroke width for the sparkline line */
414
414
  strokeWidth?: number;
415
415
  };
416
+ /**
417
+ * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
418
+ * WPDS tokens in CSS, so only the scale color and the compact sizing live here.
419
+ */
420
+ heatmapChart?: {
421
+ /**
422
+ * Color the cell scale interpolates toward at the highest value (prop > this >
423
+ * palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color.
424
+ */
425
+ primaryColor?: string;
426
+ /** Gap in px between cells in compact mode */
427
+ compactCellGap?: number;
428
+ /** Fixed square cell size in px for compact mode */
429
+ compactCellSize?: number;
430
+ };
416
431
  };
417
432
 
418
433
  /**
@@ -440,6 +455,8 @@ export type CompleteChartTheme = Required< ChartTheme > & {
440
455
  sparkline: Required< NonNullable< ChartTheme[ 'sparkline' ] > > & {
441
456
  margin: Required< NonNullable< ChartTheme[ 'sparkline' ] >[ 'margin' ] >;
442
457
  };
458
+ heatmapChart: Omit< Required< NonNullable< ChartTheme[ 'heatmapChart' ] > >, 'primaryColor' > &
459
+ Pick< NonNullable< ChartTheme[ 'heatmapChart' ] >, 'primaryColor' >;
443
460
  };
444
461
 
445
462
  export type AxisOptions = {
@@ -232,3 +232,39 @@ export const lightenHexColor = ( hex: string, blend: number ): string => {
232
232
  .toString( 16 )
233
233
  .padStart( 2, '0' ) }${ newB.toString( 16 ).padStart( 2, '0' ) }`;
234
234
  };
235
+
236
+ /**
237
+ * WCAG relative luminance of a hex color (0 = black, 1 = white).
238
+ *
239
+ * @param hex - Hex color string (e.g., '#98C8DF')
240
+ * @return Relative luminance in the range [0, 1]
241
+ * @throws {Error} if hex string is malformed
242
+ */
243
+ export const relativeLuminance = ( hex: string ): number => {
244
+ validateHexColor( hex );
245
+
246
+ const toLinear = ( value: number ): number => {
247
+ const channel = value / 255;
248
+ return channel <= 0.03928 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
249
+ };
250
+
251
+ const r = toLinear( parseInt( hex.slice( 1, 3 ), 16 ) );
252
+ const g = toLinear( parseInt( hex.slice( 3, 5 ), 16 ) );
253
+ const b = toLinear( parseInt( hex.slice( 5, 7 ), 16 ) );
254
+
255
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
256
+ };
257
+
258
+ /**
259
+ * Whether light text reads better than dark text on the given background, using the W3C
260
+ * luminance threshold (0.179) that maximizes contrast against black vs white.
261
+ *
262
+ * @param backgroundHex - Hex background color
263
+ * @return true if light text should be used; false (dark text) for malformed colors
264
+ */
265
+ export const prefersLightText = ( backgroundHex: string ): boolean => {
266
+ if ( ! isValidHexColor( backgroundHex ) ) {
267
+ return false;
268
+ }
269
+ return relativeLuminance( backgroundHex ) <= 0.179;
270
+ };
@@ -8,6 +8,8 @@ import {
8
8
  parseHslString,
9
9
  parseRgbString,
10
10
  normalizeColorToHex,
11
+ relativeLuminance,
12
+ prefersLightText,
11
13
  } from '../color-utils';
12
14
 
13
15
  // Helper to convert hex to HSL tuple using d3-color
@@ -881,3 +883,34 @@ describe( 'normalizeColorToHex', () => {
881
883
  } );
882
884
  } );
883
885
  } );
886
+
887
+ describe( 'relativeLuminance', () => {
888
+ it( 'returns 0 for black and 1 for white', () => {
889
+ expect( relativeLuminance( '#000000' ) ).toBeCloseTo( 0, 5 );
890
+ expect( relativeLuminance( '#ffffff' ) ).toBeCloseTo( 1, 5 );
891
+ } );
892
+
893
+ it( 'returns a higher luminance for a lighter color', () => {
894
+ expect( relativeLuminance( '#98c8df' ) ).toBeGreaterThan( relativeLuminance( '#006dab' ) );
895
+ } );
896
+
897
+ it( 'throws on a malformed hex', () => {
898
+ expect( () => relativeLuminance( 'not-a-color' ) ).toThrow();
899
+ } );
900
+ } );
901
+
902
+ describe( 'prefersLightText', () => {
903
+ it( 'prefers dark text on light backgrounds', () => {
904
+ expect( prefersLightText( '#ffffff' ) ).toBe( false );
905
+ expect( prefersLightText( '#98c8df' ) ).toBe( false );
906
+ } );
907
+
908
+ it( 'prefers light text on dark backgrounds', () => {
909
+ expect( prefersLightText( '#000000' ) ).toBe( true );
910
+ expect( prefersLightText( '#006dab' ) ).toBe( true );
911
+ } );
912
+
913
+ it( 'falls back to dark text for malformed colors', () => {
914
+ expect( prefersLightText( 'var(--token)' ) ).toBe( false );
915
+ } );
916
+ } );