@automattic/charts 3.1.0 → 3.2.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/TOKENS.md +51 -12
  3. package/dist/index.cjs +307 -213
  4. package/dist/index.cjs.map +1 -1
  5. package/dist/index.css +7 -1
  6. package/dist/index.d.cts +223 -127
  7. package/dist/index.d.ts +223 -127
  8. package/dist/index.js +306 -212
  9. package/dist/index.js.map +1 -1
  10. package/package.json +8 -8
  11. package/src/charts/area-chart/area-chart.tsx +2 -2
  12. package/src/charts/area-chart/test/area-chart.test.tsx +45 -0
  13. package/src/charts/bar-chart/bar-chart.tsx +36 -10
  14. package/src/charts/bar-chart/private/use-bar-chart-options.ts +19 -12
  15. package/src/charts/bar-chart/test/bar-chart-pointer-events.test.tsx +67 -0
  16. package/src/charts/bar-chart/test/bar-chart.test.tsx +101 -0
  17. package/src/charts/bar-list-chart/bar-list-chart.tsx +40 -2
  18. package/src/charts/bar-list-chart/test/bar-list-chart.test.tsx +38 -0
  19. package/src/charts/line-chart/line-chart.tsx +18 -3
  20. package/src/charts/line-chart/test/line-chart.test.tsx +98 -0
  21. package/src/charts/sparkline/sparkline.tsx +9 -3
  22. package/src/charts/sparkline/types.ts +1 -1
  23. package/src/components/tooltip/accessible-tooltip.tsx +32 -2
  24. package/src/components/tooltip/test/accessible-tooltip.test.tsx +68 -0
  25. package/src/hooks/test/use-xychart-theme-provider-override.test.tsx +4 -4
  26. package/src/hooks/test/use-xychart-theme-style-reads.test.tsx +17 -10
  27. package/src/hooks/test/use-xychart-theme.test.tsx +50 -5
  28. package/src/hooks/use-xychart-theme.ts +14 -20
  29. package/src/providers/chart-context/global-charts-provider.tsx +44 -33
  30. package/src/providers/chart-context/private/series-palette.ts +45 -0
  31. package/src/providers/chart-context/private/theme-override-vars.ts +46 -2
  32. package/src/providers/chart-context/private/with-catalog-pointers.ts +45 -2
  33. package/src/providers/chart-context/test/sparse-theme.test.ts +16 -2
  34. package/src/providers/chart-context/test/theme-override-vars.test.ts +65 -0
  35. package/src/providers/chart-context/test/use-global-charts-theme.test.tsx +8 -1
  36. package/src/providers/chart-context/test/with-catalog-pointers.test.ts +57 -0
  37. package/src/providers/chart-context/themes.ts +8 -3
  38. package/src/styles/chart-scope.scss +52 -24
  39. package/src/styles/test/chart-scope.test.ts +22 -9
  40. package/src/types.ts +148 -31
  41. package/src/utils/get-styles.ts +7 -1
@@ -0,0 +1,45 @@
1
+ /**
2
+ * The series palette's slot manifest.
3
+ *
4
+ * Which slots exist is a fact about the catalog `chart-scope.scss` emits, not a theme value, so it
5
+ * is declared here rather than in `themes.ts`, which now derives `defaultTheme.colors` from it.
6
+ *
7
+ * That is as far as the separation goes, and the limit is worth knowing before the field is
8
+ * deleted. `GlobalChartsProvider` still resolves `providerTheme.colors`, not this manifest,
9
+ * because `withCatalogPointers` parks the consumer's own color in each pointer's terminal position
10
+ * and that literal is the palette's only carrier where `getComputedStyle` resolves nothing — SSR
11
+ * and jsdom. Swapping the provider onto this manifest passes in a browser and collapses every
12
+ * consumer palette to the catalog seed under jsdom; 24 tests say so. Removing `colors` therefore
13
+ * needs a different home for that literal first, not just a change of source here.
14
+ */
15
+
16
+ /** How many series-palette slots the catalog emits. `theme.colors` entries past this are ignored. */
17
+ export const SERIES_SLOT_COUNT = 5;
18
+
19
+ /**
20
+ * The catalog role holding one series-palette slot.
21
+ *
22
+ * @param slot - The one-based slot number.
23
+ * @return The role name.
24
+ */
25
+ export const seriesRole = ( slot: number ): string => `--a8c-charts-color-series-${ slot }`;
26
+
27
+ /*
28
+ * Only slot 1 carries a terminal literal, and it is reached only where `getComputedStyle` resolves
29
+ * nothing — SSR and jsdom. The rest resolve to nothing until a consumer sets them, and the provider
30
+ * drops what resolves to nothing, so the palette compacts rather than repeating one color.
31
+ *
32
+ * The literal matches slot 1's own last-resort fallback in `chart-scope.scss`. It is deliberately
33
+ * the bare hex rather than that slot's full `var()` chain: this string is resolved against the DOM,
34
+ * where the chain has already been walked, and is only read at all when no DOM answered.
35
+ */
36
+ const SERIES_SLOT_1_FALLBACK = '#3858e9';
37
+
38
+ /** The catalog pointer for every slot, in slot order. */
39
+ export const SERIES_PALETTE_POINTERS: readonly string[] = Array.from(
40
+ { length: SERIES_SLOT_COUNT },
41
+ ( _, index ) =>
42
+ index === 0
43
+ ? `var(${ seriesRole( 1 ) }, ${ SERIES_SLOT_1_FALLBACK })`
44
+ : `var(${ seriesRole( index + 1 ) })`
45
+ );
@@ -1,6 +1,15 @@
1
+ import { SERIES_SLOT_COUNT, seriesRole } from './series-palette';
1
2
  import type { ChartTheme } from '../../../types';
2
3
 
4
+ /*
5
+ * `process.env.NODE_ENV` is replaced by the bundler at build time. Declare a
6
+ * minimal `process` locally so this file type-checks as source under `jetpack:src`.
7
+ */
8
+ declare const process: { env: Record< string, string | undefined > };
9
+
3
10
  // The consumer theme fields that correspond to an emitted catalog role, and the role each one overrides. Each role must be read by exactly the elements its field controlled before the role existed: `svgLabelSmall.fill` maps to the narrow `--a8c-charts-color-label-axis` rather than `--a8c-charts-color-label`, because the broad role is also read by legend labels, heatmap cell values, funnel labels and the line-chart tooltip, which that field never moved.
11
+ //
12
+ // `theme.colors` is the one field mapped by position rather than by name: entry N publishes slot N+1, which is what makes it sugar over the five slots rather than a second palette with its own precedence.
4
13
  const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] > =
5
14
  [
6
15
  [ '--a8c-charts-color-background', theme => theme.backgroundColor ],
@@ -8,8 +17,37 @@ const ROLE_FOR_FIELD: Array< [ string, ( theme: Partial< ChartTheme > ) => strin
8
17
  [ '--a8c-charts-color-axis', theme => theme.xAxisLineStyles?.stroke ],
9
18
  [ '--a8c-charts-color-tick', theme => theme.xTickLineStyles?.stroke ],
10
19
  [ '--a8c-charts-color-label-axis', theme => theme.svgLabelSmall?.fill ],
20
+ [ '--a8c-charts-color-label-background', theme => theme.labelBackgroundColor ],
21
+ [ '--a8c-charts-color-label-on-fill', theme => theme.labelTextColor ],
22
+ ...Array.from(
23
+ { length: SERIES_SLOT_COUNT },
24
+ ( _, index ): [ string, ( theme: Partial< ChartTheme > ) => string | undefined ] => [
25
+ seriesRole( index + 1 ),
26
+ theme => theme.colors?.[ index ],
27
+ ]
28
+ ),
11
29
  ];
12
30
 
31
+ let warnedAboutExtraColors = false;
32
+
33
+ /**
34
+ * Warns once when a consumer passes more colors than there are slots to publish them into.
35
+ *
36
+ * @param count - How many entries `theme.colors` holds.
37
+ */
38
+ function warnOnceAboutExtraColors( count: number ): void {
39
+ if ( warnedAboutExtraColors || process.env.NODE_ENV === 'production' ) {
40
+ return;
41
+ }
42
+
43
+ warnedAboutExtraColors = true;
44
+ // eslint-disable-next-line no-console
45
+ console.warn(
46
+ `[Charts] theme.colors holds ${ count } colors and the palette has ${ SERIES_SLOT_COUNT } slots, so entries past the ${ SERIES_SLOT_COUNT }th are ignored. ` +
47
+ 'Set a per-series color with `options.stroke` instead.'
48
+ );
49
+ }
50
+
13
51
  /**
14
52
  * The variable a `theme` prop override is published as, one layer outside the role itself.
15
53
  *
@@ -26,7 +64,7 @@ export const THEME_LAYERED_ROLES: readonly string[] = ROLE_FOR_FIELD.map( ( [ ro
26
64
  /**
27
65
  * Whether a value reads the role it is being published for, which would make the catalog entry depend on itself.
28
66
  *
29
- * The dependency runs `<role>: var(<role>-theme, …)`, so a value naming `<role>` closes a cycle through the catalog entry. CSS marks every custom property in a cycle invalid at computed-value time — the role's own fallback is *not* used, so the token resolves to nothing and every chart loses that colour. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
67
+ * The dependency runs `<role>: var(<role>-theme, …)`, so a value naming `<role>` closes a cycle through the catalog entry. CSS marks every custom property in a cycle invalid at computed-value time — the role's own fallback is *not* used, so the token resolves to nothing and every chart loses that color. Verified in Chrome: `--role-theme: var(--role, blue); --role: var(--role-theme, green)` leaves `--role` empty, not `green`.
30
68
  *
31
69
  * Three things this has to get right, none of which a plain substring test gets:
32
70
  *
@@ -54,10 +92,12 @@ export type ThemeOverrides = {
54
92
  };
55
93
 
56
94
  /**
57
- * Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colours read one source.
95
+ * Maps a sparse consumer theme onto the theme-layer variables its overridden catalog roles read, so CSS-painted and JS-resolved colors read one source.
58
96
  *
59
97
  * A value that reads the role it would override is left unpublished — that is the default theme's own pointer surviving `mergeThemes`, and publishing it would invalidate the role. The role is still reported in `roles`: `withCatalogPointers` restores its theme field to the catalog pointer either way, so CSS and visx agree on the catalog default rather than visx painting a literal CSS never sees.
60
98
  *
99
+ * `theme.colors` publishes through the same mechanism, one slot per entry, so a CSS declaration of `--a8c-charts-color-series-N` still beats it and a short array leaves the later slots unset rather than blank.
100
+ *
61
101
  * @param theme - The consumer's `theme` prop, before merging with the default theme.
62
102
  * @return The wrapper's custom properties, and the roles the consumer overrode.
63
103
  */
@@ -66,6 +106,10 @@ export const themeOverrideVars = ( theme?: Partial< ChartTheme > ): ThemeOverrid
66
106
  return { vars: {}, roles: [] };
67
107
  }
68
108
 
109
+ if ( Array.isArray( theme.colors ) && theme.colors.length > SERIES_SLOT_COUNT ) {
110
+ warnOnceAboutExtraColors( theme.colors.length );
111
+ }
112
+
69
113
  const vars: Record< string, string > = {};
70
114
  const roles: string[] = [];
71
115
 
@@ -1,11 +1,50 @@
1
1
  import { defaultTheme } from '../themes';
2
+ import { SERIES_PALETTE_POINTERS, SERIES_SLOT_COUNT, seriesRole } from './series-palette';
2
3
  import type { CompleteChartTheme } from '../../../types';
3
4
 
4
- // Restores each overridden role's mapped theme field to the catalog pointer `defaultTheme` already carries for it, so the value comes from `themes.ts` itself rather than a second, hand-copied literal.
5
+ /**
6
+ * The pointer one palette slot is restored to.
7
+ *
8
+ * Unlike the other roles, this keeps the consumer's own color as the pointer's terminal literal instead of the catalog's. The literal is only ever reached where `getComputedStyle` resolves nothing — SSR and jsdom — and there the palette is the one field where falling back to the catalog default would be visible: every series would paint the same seeded blue. In a browser the slot resolves first, so a CSS declaration of it still outranks the consumer's `theme.colors`.
9
+ *
10
+ * @param index - The slot's zero-based index in `colors`.
11
+ * @param value - The merged theme's entry for it.
12
+ * @return The catalog pointer for that slot.
13
+ */
14
+ const seriesPointer = ( index: number, value: string | undefined ): string => {
15
+ const role = seriesRole( index + 1 );
16
+
17
+ // Idempotent: `withCatalogPointers` runs this once per overridden slot, so from the second call on the array already holds pointers.
18
+ if ( typeof value === 'string' && value.startsWith( `var(${ role }` ) ) {
19
+ return value;
20
+ }
21
+
22
+ // A slot the consumer left empty keeps the catalog's own pointer, so a CSS declaration of it still reaches the palette.
23
+ if ( typeof value !== 'string' || value === '' ) {
24
+ return SERIES_PALETTE_POINTERS[ index ];
25
+ }
26
+
27
+ return `var(${ role }, ${ value })`;
28
+ };
29
+
30
+ // Restores each overridden role's mapped theme field to the catalog pointer already written for it — `defaultTheme` for the ordinary roles, `series-palette.ts` for the palette slots — so the value comes from the one place that declares it rather than a second, hand-copied literal.
31
+ //
32
+ // Every series slot restores the whole `colors` array, not just its own entry. `mergeThemes` replaces arrays outright, so a consumer passing two colors would otherwise leave a two-entry palette and put slots 3 to 5 out of reach of a CSS declaration that sets them.
5
33
  const CATALOG_RESTORE_FOR_ROLE: Record<
6
34
  string,
7
35
  ( theme: CompleteChartTheme ) => Partial< CompleteChartTheme >
8
36
  > = {
37
+ // Each of the five entries rebuilds the whole array, so a consumer overriding every slot runs this five times. That is deliberate rather than merely tolerated: `seriesPointer` is idempotent, so every pass after the first is a no-op, and five allocations of a five-element array is not worth a special case in a map that is otherwise one entry per role.
38
+ ...Object.fromEntries(
39
+ Array.from( { length: SERIES_SLOT_COUNT }, ( _, index ) => [
40
+ seriesRole( index + 1 ),
41
+ ( theme: CompleteChartTheme ) => ( {
42
+ colors: SERIES_PALETTE_POINTERS.map( ( _pointer, slot ) =>
43
+ seriesPointer( slot, theme.colors?.[ slot ] )
44
+ ),
45
+ } ),
46
+ ] )
47
+ ),
9
48
  '--a8c-charts-color-background': () => ( { backgroundColor: defaultTheme.backgroundColor } ),
10
49
  '--a8c-charts-color-grid': theme => ( {
11
50
  gridStyles: { ...theme.gridStyles, stroke: defaultTheme.gridStyles.stroke },
@@ -19,6 +58,10 @@ const CATALOG_RESTORE_FOR_ROLE: Record<
19
58
  '--a8c-charts-color-label-axis': theme => ( {
20
59
  svgLabelSmall: { ...theme.svgLabelSmall, fill: defaultTheme.svgLabelSmall.fill },
21
60
  } ),
61
+ '--a8c-charts-color-label-background': () => ( {
62
+ labelBackgroundColor: defaultTheme.labelBackgroundColor,
63
+ } ),
64
+ '--a8c-charts-color-label-on-fill': () => ( { labelTextColor: defaultTheme.labelTextColor } ),
22
65
  };
23
66
 
24
67
  /**
@@ -26,7 +69,7 @@ const CATALOG_RESTORE_FOR_ROLE: Record<
26
69
  *
27
70
  * This covers roles `themeOverrideVars` deliberately left unpublished as well as the ones it published. A value that reads its own role is not publishable, but leaving visx the consumer's literal is what the whole mechanism exists to prevent: `theme={ { gridStyles: { stroke: 'var(--brand, var(--a8c-charts-color-grid, red))' } } }` would have CSS paint the catalog default while visx painted `--brand`.
28
71
  *
29
- * Every field outside the five mapped roles is left exactly as `merged` provided it: those aren't published as theme-layer vars, so rewriting them would erase a consumer's override with no replacement carrier.
72
+ * Every field outside the mapped roles is left exactly as `merged` provided it: those aren't published as theme-layer vars, so rewriting them would erase a consumer's override with no replacement carrier.
30
73
  *
31
74
  * @param merged - The consumer theme merged onto `defaultTheme` (`mergeThemes` output).
32
75
  * @param overriddenRoles - The catalog roles the consumer overrode, from `themeOverrideVars`.
@@ -20,16 +20,30 @@ describe( 'defaultTheme', () => {
20
20
  expect( strings.filter( value => value.includes( '--wpds-' ) ) ).toEqual( [] );
21
21
  } );
22
22
 
23
- it( 'points every themed colour at an --a8c-charts-* catalog role', () => {
23
+ it( 'points every themed color at an --a8c-charts-* catalog role', () => {
24
24
  const varStrings = strings.filter( value => value.startsWith( 'var(' ) );
25
25
 
26
26
  expect( varStrings.length ).toBeGreaterThan( 0 );
27
27
  expect( varStrings.every( value => value.startsWith( 'var(--a8c-charts-' ) ) ).toBe( true );
28
28
  } );
29
29
 
30
+ // The palette slots past the first are the exception, below: a terminal literal there would be a color nobody chose.
30
31
  it( 'gives every catalog pointer a terminal literal for the SSR and jsdom paths', () => {
31
- const varStrings = strings.filter( value => value.startsWith( 'var(--a8c-charts-' ) );
32
+ const varStrings = strings
33
+ .filter( value => value.startsWith( 'var(--a8c-charts-' ) )
34
+ .filter( value => ! /^var\( *--a8c-charts-color-series-[2-9] *\)$/.test( value ) );
32
35
 
33
36
  expect( varStrings.every( value => value.includes( ',' ) ) ).toBe( true );
34
37
  } );
38
+
39
+ // Only slot 1 seeds a palette. The rest resolve to nothing until a consumer sets them, and the provider drops what resolves to nothing, so the palette compacts instead of repeating one color.
40
+ it( 'leaves the palette slots past the first with no default of their own', () => {
41
+ expect( defaultTheme.colors ).toEqual( [
42
+ 'var(--a8c-charts-color-series-1, #3858e9)',
43
+ 'var(--a8c-charts-color-series-2)',
44
+ 'var(--a8c-charts-color-series-3)',
45
+ 'var(--a8c-charts-color-series-4)',
46
+ 'var(--a8c-charts-color-series-5)',
47
+ ] );
48
+ } );
35
49
  } );
@@ -29,6 +29,9 @@ describe( 'themeOverrideVars', () => {
29
29
  xAxisLineStyles: { stroke: '#333' },
30
30
  xTickLineStyles: { stroke: '#444' },
31
31
  svgLabelSmall: { fill: '#555' },
32
+ labelBackgroundColor: '#556',
33
+ labelTextColor: '#557',
34
+ colors: [ '#661', '#662', '#663', '#664', '#665' ],
32
35
  } )
33
36
  ).toEqual( {
34
37
  vars: {
@@ -37,6 +40,13 @@ describe( 'themeOverrideVars', () => {
37
40
  '--a8c-charts-color-axis-theme': '#333',
38
41
  '--a8c-charts-color-tick-theme': '#444',
39
42
  '--a8c-charts-color-label-axis-theme': '#555',
43
+ '--a8c-charts-color-label-background-theme': '#556',
44
+ '--a8c-charts-color-label-on-fill-theme': '#557',
45
+ '--a8c-charts-color-series-1-theme': '#661',
46
+ '--a8c-charts-color-series-2-theme': '#662',
47
+ '--a8c-charts-color-series-3-theme': '#663',
48
+ '--a8c-charts-color-series-4-theme': '#664',
49
+ '--a8c-charts-color-series-5-theme': '#665',
40
50
  },
41
51
  roles: [
42
52
  '--a8c-charts-color-background',
@@ -44,6 +54,13 @@ describe( 'themeOverrideVars', () => {
44
54
  '--a8c-charts-color-axis',
45
55
  '--a8c-charts-color-tick',
46
56
  '--a8c-charts-color-label-axis',
57
+ '--a8c-charts-color-label-background',
58
+ '--a8c-charts-color-label-on-fill',
59
+ '--a8c-charts-color-series-1',
60
+ '--a8c-charts-color-series-2',
61
+ '--a8c-charts-color-series-3',
62
+ '--a8c-charts-color-series-4',
63
+ '--a8c-charts-color-series-5',
47
64
  ],
48
65
  } );
49
66
  } );
@@ -55,6 +72,9 @@ describe( 'themeOverrideVars', () => {
55
72
  xAxisLineStyles: { stroke: '#333' },
56
73
  xTickLineStyles: { stroke: '#444' },
57
74
  svgLabelSmall: { fill: '#555' },
75
+ labelBackgroundColor: '#556',
76
+ labelTextColor: '#557',
77
+ colors: [ '#661', '#662', '#663', '#664', '#665' ],
58
78
  } );
59
79
 
60
80
  expect( Object.keys( vars ) ).toEqual( THEME_LAYERED_ROLES.map( themeLayerVar ) );
@@ -68,6 +88,51 @@ describe( 'themeOverrideVars', () => {
68
88
  } );
69
89
  } );
70
90
 
91
+ // A short array is not padded: the later slots stay unset, so the palette compacts rather than repeating a color the consumer never chose.
92
+ it( 'publishes one palette slot per color and leaves the rest unset', () => {
93
+ expect( themeOverrideVars( { colors: [ 'red', 'blue' ] } ) ).toEqual( {
94
+ vars: {
95
+ '--a8c-charts-color-series-1-theme': 'red',
96
+ '--a8c-charts-color-series-2-theme': 'blue',
97
+ },
98
+ roles: [ '--a8c-charts-color-series-1', '--a8c-charts-color-series-2' ],
99
+ } );
100
+ } );
101
+
102
+ it( 'drops palette entries past the last slot', () => {
103
+ // Spied rather than asserted on: an over-long palette warns, and `@wordpress/jest-console` fails any test that leaves a console call unclaimed. The warning itself is covered below.
104
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
105
+ const { vars } = themeOverrideVars( {
106
+ colors: [ 'a', 'b', 'c', 'd', 'e', 'f', 'g' ],
107
+ } );
108
+
109
+ expect( vars[ '--a8c-charts-color-series-5-theme' ] ).toBe( 'e' );
110
+ expect( vars[ '--a8c-charts-color-series-6-theme' ] ).toBeUndefined();
111
+
112
+ warn.mockRestore();
113
+ } );
114
+
115
+ // Isolated so the warning's module-level latch starts unset — the assertion is that it warns once per module instance, not once per call.
116
+ it( 'warns once about the dropped entries, not once per render', () => {
117
+ jest.isolateModules( () => {
118
+ const warn = jest.spyOn( console, 'warn' ).mockImplementation( () => {} );
119
+ const {
120
+ themeOverrideVars: freshThemeOverrideVars,
121
+ // eslint-disable-next-line @typescript-eslint/no-require-imports -- isolateModules needs a synchronous require to get a module instance whose latch is unset.
122
+ } = require( '../private/theme-override-vars' ) as {
123
+ themeOverrideVars: typeof themeOverrideVars;
124
+ };
125
+
126
+ freshThemeOverrideVars( { colors: [ 'a', 'b', 'c', 'd', 'e', 'f' ] } );
127
+ freshThemeOverrideVars( { colors: [ 'a', 'b', 'c', 'd', 'e', 'f' ] } );
128
+
129
+ expect( warn ).toHaveBeenCalledTimes( 1 );
130
+ expect( warn ).toHaveBeenCalledWith( expect.stringContaining( 'holds 6 colors' ) );
131
+
132
+ warn.mockRestore();
133
+ } );
134
+ } );
135
+
71
136
  it( 'ignores a value that is itself a pointer at the role it would override', () => {
72
137
  expect(
73
138
  themeOverrideVars( { gridStyles: { stroke: 'var(--a8c-charts-color-grid, #dbdbdb)' } } ).vars
@@ -27,7 +27,14 @@ describe( 'useGlobalChartsTheme', () => {
27
27
 
28
28
  const { result } = renderHook( () => useGlobalChartsTheme(), { wrapper } );
29
29
 
30
- expect( result.current.colors ).toEqual( [ '#FF0000', '#00FF00', '#0000FF' ] );
30
+ // `theme.colors` publishes the palette's theme layers, so the merged theme holds catalog pointers rather than the consumer's literals. Each keeps its consumer color as the pointer's terminal literal, for SSR and jsdom.
31
+ expect( result.current.colors ).toEqual( [
32
+ 'var(--a8c-charts-color-series-1, #FF0000)',
33
+ 'var(--a8c-charts-color-series-2, #00FF00)',
34
+ 'var(--a8c-charts-color-series-3, #0000FF)',
35
+ 'var(--a8c-charts-color-series-4)',
36
+ 'var(--a8c-charts-color-series-5)',
37
+ ] );
31
38
  // Other properties should still come from default theme
32
39
  expect( result.current.backgroundColor ).toBe( defaultTheme.backgroundColor );
33
40
  expect( result.current.gridStyles ).toEqual( defaultTheme.gridStyles );
@@ -11,6 +11,8 @@ describe( 'withCatalogPointers', () => {
11
11
  xAxisLineStyles: { stroke: '#333' },
12
12
  xTickLineStyles: { stroke: '#444' },
13
13
  svgLabelSmall: { fill: '#555' },
14
+ labelBackgroundColor: '#556',
15
+ labelTextColor: '#557',
14
16
  } as Partial< ChartTheme > );
15
17
 
16
18
  const result = withCatalogPointers( merged, [
@@ -19,6 +21,8 @@ describe( 'withCatalogPointers', () => {
19
21
  '--a8c-charts-color-axis',
20
22
  '--a8c-charts-color-tick',
21
23
  '--a8c-charts-color-label-axis',
24
+ '--a8c-charts-color-label-background',
25
+ '--a8c-charts-color-label-on-fill',
22
26
  ] );
23
27
 
24
28
  expect( result.backgroundColor ).toBe( defaultTheme.backgroundColor );
@@ -26,6 +30,8 @@ describe( 'withCatalogPointers', () => {
26
30
  expect( result.xAxisLineStyles.stroke ).toBe( defaultTheme.xAxisLineStyles.stroke );
27
31
  expect( result.xTickLineStyles.stroke ).toBe( defaultTheme.xTickLineStyles.stroke );
28
32
  expect( result.svgLabelSmall.fill ).toBe( defaultTheme.svgLabelSmall.fill );
33
+ expect( result.labelBackgroundColor ).toBe( defaultTheme.labelBackgroundColor );
34
+ expect( result.labelTextColor ).toBe( defaultTheme.labelTextColor );
29
35
 
30
36
  expect( merged.backgroundColor ).toBe( '#111' );
31
37
  expect( merged.gridStyles.stroke ).toBe( '#222' );
@@ -44,4 +50,55 @@ describe( 'withCatalogPointers', () => {
44
50
  // Not a mapped role, so its literal survives even when other roles are being restored.
45
51
  expect( result.leaderboardChart.deltaColors ).toEqual( [ 'red', 'grey', 'green' ] );
46
52
  } );
53
+
54
+ // The palette is restored as a whole, so a consumer's short array cannot shorten it. `mergeThemes` replaces arrays outright, and a two-entry palette would leave slots 3 to 5 unreadable — a CSS declaration of one of them would have no entry to resolve through.
55
+ it( 'restores the full five-slot palette from a shorter consumer array', () => {
56
+ const merged = mergeThemes( defaultTheme, {
57
+ colors: [ '#111', '#222' ],
58
+ } as Partial< ChartTheme > );
59
+
60
+ const result = withCatalogPointers( merged, [
61
+ '--a8c-charts-color-series-1',
62
+ '--a8c-charts-color-series-2',
63
+ ] );
64
+
65
+ expect( result.colors ).toEqual( [
66
+ 'var(--a8c-charts-color-series-1, #111)',
67
+ 'var(--a8c-charts-color-series-2, #222)',
68
+ 'var(--a8c-charts-color-series-3)',
69
+ 'var(--a8c-charts-color-series-4)',
70
+ 'var(--a8c-charts-color-series-5)',
71
+ ] );
72
+ } );
73
+
74
+ // Each slot's restore rewrites the whole array, so restoring five of them must not wrap the pointers five times over.
75
+ it( 'restores the palette idempotently across every overridden slot', () => {
76
+ const merged = mergeThemes( defaultTheme, {
77
+ colors: [ '#111', '#222', '#333', '#444', '#555' ],
78
+ } as Partial< ChartTheme > );
79
+
80
+ const result = withCatalogPointers( merged, [
81
+ '--a8c-charts-color-series-1',
82
+ '--a8c-charts-color-series-2',
83
+ '--a8c-charts-color-series-3',
84
+ '--a8c-charts-color-series-4',
85
+ '--a8c-charts-color-series-5',
86
+ ] );
87
+
88
+ expect( result.colors ).toEqual( [
89
+ 'var(--a8c-charts-color-series-1, #111)',
90
+ 'var(--a8c-charts-color-series-2, #222)',
91
+ 'var(--a8c-charts-color-series-3, #333)',
92
+ 'var(--a8c-charts-color-series-4, #444)',
93
+ 'var(--a8c-charts-color-series-5, #555)',
94
+ ] );
95
+ } );
96
+
97
+ // The literal is only reached where getComputedStyle resolves nothing, and the palette is the one field where falling back to the catalog default would be visible: every series would paint the same seeded blue.
98
+ it( 'keeps the consumer color as each pointer’s terminal literal, for SSR and jsdom', () => {
99
+ const merged = mergeThemes( defaultTheme, { colors: [ 'red' ] } as Partial< ChartTheme > );
100
+ const result = withCatalogPointers( merged, [ '--a8c-charts-color-series-1' ] );
101
+
102
+ expect( result.colors[ 0 ] ).toBe( 'var(--a8c-charts-color-series-1, red)' );
103
+ } );
47
104
  } );
@@ -1,3 +1,4 @@
1
+ import { SERIES_PALETTE_POINTERS } from './private/series-palette';
1
2
  import type { CompleteChartTheme } from '../../types';
2
3
 
3
4
  /**
@@ -5,10 +6,14 @@ import type { CompleteChartTheme } from '../../types';
5
6
  */
6
7
  const defaultTheme: CompleteChartTheme = {
7
8
  backgroundColor: 'var(--a8c-charts-color-background, #fff)',
8
- labelBackgroundColor: 'transparent', // label background color (transparent by default)
9
- // White label text sits on top of arbitrary series colors, so it has no WPDS content-foreground equivalent. Every other colour here is a bare pointer at the catalog emitted by `chart-scope.scss`; the terminal literal is the last resort for the SSR and jsdom paths, where getComputedStyle resolves nothing.
9
+ labelBackgroundColor: 'var(--a8c-charts-color-label-background, transparent)',
10
+ // White label text sits on top of arbitrary series colors, so it has no WPDS content-foreground equivalent. Every other color here is a bare pointer at the catalog emitted by `chart-scope.scss`; the terminal literal is the last resort for the SSR and jsdom paths, where getComputedStyle resolves nothing.
10
11
  labelTextColor: 'var(--a8c-charts-color-label-on-fill, #FFFFFF)',
11
- colors: [ '#98C8DF', '#006DAB', '#A6DC80', '#1F9828', '#FF8C8F' ],
12
+ // Derived, not authored: the slot manifest lives in `private/series-palette.ts` because which
13
+ // slots exist describes the catalog, not the theme. The field itself is still load-bearing —
14
+ // see that file for why the provider reads the palette through the theme and what has to move
15
+ // before this can be deleted.
16
+ colors: [ ...SERIES_PALETTE_POINTERS ],
12
17
  gridStyles: {
13
18
  stroke: 'var(--a8c-charts-color-grid, #dbdbdb)',
14
19
  strokeWidth: 1,
@@ -1,41 +1,69 @@
1
1
  // The single mapping point between the --a8c-charts-* catalog and WordPress
2
- // design-system tokens.
2
+ // design-system tokens. Declares custom properties only — this stylesheet must
3
+ // never change layout.
3
4
 
4
- // Declared on the GlobalChartsProvider wrapper — not at :root — because
5
+ // Declared on the GlobalChartsProvider wrapper, not at :root, because
5
6
  // @wordpress/theme's ThemeProvider emits its generated --wpds-* ramp as inline
6
- // styles on its own wrapper div, not on the document root. Substituting at the
7
- // provider means a ThemeProvider above it is picked up; substituting at :root
8
- // would miss it entirely.
7
+ // styles on its own wrapper div. Substituting at :root would miss it entirely.
9
8
 
10
- // :where() keeps the rule at zero specificity so any consumer rule
11
- // targeting the same element wins. Chart roots deliberately do NOT
12
- // re-declare these: an element's own declaration beats an inherited one,
13
- // so re-declaring lower down would shadow a consumer override set inside
14
- // the provider tree.
9
+ // :where() keeps the rule at zero specificity so any consumer rule targeting
10
+ // the same element wins. Chart roots deliberately do NOT re-declare these: an
11
+ // element's own declaration beats an inherited one, so re-declaring lower down
12
+ // would shadow a consumer override set inside the provider tree.
15
13
 
16
- // The five roles a `theme` prop field can override read a `*-theme` variable
17
- // first, which GlobalChartsProvider writes inline from that field. Publishing
18
- // the consumer's value one layer out, rather than as the role itself, is what
19
- // keeps the catalog default reachable: a value that is invalid at
20
- // computed-value time (a fallback-less `var()` naming a token nothing set,
21
- // which is how premium-analytics passes its grid stroke) invalidates only the
22
- // `*-theme` variable, and the role's own fallback still resolves the mapped
23
- // --wpds-* token. Written as the role directly, that same value made the role
24
- // guaranteed-invalid and took every bare `var(--a8c-charts-color-grid)` read
25
- // site with it — gridlines painted `unset` instead of the spec grey.
14
+ // The roles a `theme` prop field can override read a `*-theme` variable first,
15
+ // which GlobalChartsProvider writes inline from that field. Publishing the
16
+ // consumer's value one layer out keeps the catalog default reachable: a value
17
+ // that is invalid at computed-value time (a fallback-less `var()` naming a
18
+ // token nothing set, which is how premium-analytics passes its grid stroke)
19
+ // invalidates only the `*-theme` variable, leaving the role's own fallback to
20
+ // resolve. Written as the role directly, it made the role guaranteed-invalid
21
+ // and took every bare read site with it.
26
22
  // `src/styles/test/chart-scope.test.ts` pins which roles carry the layer.
27
-
28
- // Declares custom properties only. This stylesheet must never change layout.
29
23
  :where(.a8c-charts-scope) {
24
+ // The series palette. GlobalChartsProvider resolves these five slots at its
25
+ // wrapper and seeds ColorCache with whatever resolves; a slot that resolves
26
+ // to nothing is skipped, so setting only slots 1 and 3 gives a two-color
27
+ // palette, and charts generate accessible colors beyond the seeds. Only
28
+ // slot 1 carries a default.
29
+
30
+ // Slot 1 names --wp-admin-theme-color first, because that is the only leg
31
+ // that does not depend on someone else's runtime. The design system's brand
32
+ // token tracks the admin color scheme only where a WPDS root provider
33
+ // boots and emits its generated ramp inline; without one the token falls to
34
+ // the plain stylesheet rule, a static #3858e9 with no reference to the
35
+ // admin color. Leading with the admin color costs nothing:
36
+ // @wordpress/theme writes --wp-admin-theme-color from a provider's own
37
+ // accent as a legacy wp-admin override, so a provider accent still wins.
38
+
39
+ // DO NOT SIMPLIFY the chain below. The innermost
40
+ // var(--wp-admin-theme-color, #3858e9) is unreachable — the outer leg
41
+ // already tried that variable — and reads like a copy-paste mistake. It is
42
+ // the design system's own spec value for the WPDS token, which
43
+ // `src/styles/test/wpds-fallbacks.test.ts` compares against literally, so
44
+ // collapsing it to a bare hex fails that test. Reordering to put the WPDS
45
+ // token first passes every test and breaks only on a host with no root
46
+ // provider.
47
+
48
+ // That test scans this file as text, comments included, so do not write an
49
+ // example --wpds-* reference in a comment here — it is read as a real one.
50
+ --a8c-charts-color-series-1: var(--a8c-charts-color-series-1-theme, var(--wp-admin-theme-color, var(--wpds-color-foreground-interactive-brand, var(--wp-admin-theme-color, #3858e9))));
51
+ --a8c-charts-color-series-2: var(--a8c-charts-color-series-2-theme);
52
+ --a8c-charts-color-series-3: var(--a8c-charts-color-series-3-theme);
53
+ --a8c-charts-color-series-4: var(--a8c-charts-color-series-4-theme);
54
+ --a8c-charts-color-series-5: var(--a8c-charts-color-series-5-theme);
30
55
  --a8c-charts-color-grid: var(--a8c-charts-color-grid-theme, var(--wpds-color-stroke-surface-neutral, #dbdbdb));
31
56
  --a8c-charts-color-axis: var(--a8c-charts-color-axis-theme, var(--wpds-color-stroke-surface-neutral, #dbdbdb));
32
57
  --a8c-charts-color-tick: var(--a8c-charts-color-tick-theme, var(--wpds-color-stroke-surface-neutral, #dbdbdb));
33
58
  --a8c-charts-color-label: var(--wpds-color-foreground-content-neutral, #1e1e1e);
34
59
  --a8c-charts-color-label-secondary: var(--wpds-color-foreground-content-neutral-weak, #707070);
35
60
  --a8c-charts-color-label-inverse: var(--wpds-color-foreground-interactive-neutral-strong, #f0f0f0);
36
- // White label text sits on arbitrary series colours, so it has no WPDS
61
+ // White label text sits on arbitrary series colors, so it has no WPDS
37
62
  // content-foreground equivalent.
38
- --a8c-charts-color-label-on-fill: #fff;
63
+ --a8c-charts-color-label-on-fill: var(--a8c-charts-color-label-on-fill-theme, #fff);
64
+ // The plate behind a pie-slice label. Transparent by default, so a label
65
+ // reads straight off the slice; there is no WPDS token for "no surface".
66
+ --a8c-charts-color-label-background: var(--a8c-charts-color-label-background-theme, transparent);
39
67
  // The SVG axis and tick labels. `svgLabelSmall.fill` publishes THIS role, so
40
68
  // deriving from --a8c-charts-color-label is safe and wanted: the theme field
41
69
  // moves only these labels, while setting the broad role in CSS still moves
@@ -20,16 +20,19 @@ const normalize = ( value: string ): string => value.replace( /\s+/g, ' ' ).trim
20
20
  /**
21
21
  * Unwraps the theme layer a `theme`-prop-overridable role carries, so the tables in `TOKENS.md` keep documenting what a role ultimately resolves to — its `--wpds-*` token and spec fallback — rather than restating the override plumbing in every row. Which roles carry the layer is pinned separately below.
22
22
  *
23
+ * A role with no catalog default reads its layer with no fallback at all — the empty series-palette slots — and unwraps to nothing, which is what it resolves to until a consumer sets it.
24
+ *
23
25
  * @param name - The declared property name.
24
26
  * @param value - Its normalized value.
25
- * @return The value with a leading `var(<name>-theme, … )` wrapper removed.
27
+ * @return The value with a leading `var(<name>-theme, … )` wrapper removed, or the empty string where that wrapper carried no fallback.
26
28
  */
27
29
  function stripThemeLayer( name: string, value: string ): string {
28
- const layered = new RegExp( `^var\\(\\s*${ themeLayerVar( name ) }\\s*,\\s*(.*)\\)$`, 's' ).exec(
29
- value
30
- );
30
+ const layered = new RegExp(
31
+ `^var\\(\\s*${ themeLayerVar( name ) }\\s*(?:,\\s*(.*))?\\)$`,
32
+ 's'
33
+ ).exec( value );
31
34
 
32
- return layered ? layered[ 1 ].trim() : value;
35
+ return layered ? ( layered[ 1 ] ?? '' ).trim() : value;
33
36
  }
34
37
 
35
38
  /**
@@ -55,6 +58,13 @@ function parseStylesheet(): Map< string, Entry > {
55
58
 
56
59
  const name = declaration.slice( 0, separator ).trim();
57
60
  const value = stripThemeLayer( name, normalize( declaration.slice( separator + 1 ) ) );
61
+
62
+ // A role that unwrapped to nothing maps to nothing and falls back to nothing.
63
+ if ( value === '' ) {
64
+ entries.set( name, { reads: null, fallback: null } );
65
+ continue;
66
+ }
67
+
58
68
  const wrapped = /^var\(\s*(.*)\)$/s.exec( value );
59
69
 
60
70
  if ( ! wrapped ) {
@@ -112,10 +122,13 @@ function parseTokensDoc(): Map< string, Entry > {
112
122
  // "no mapping" before looking for a token name inside it.
113
123
  const unmapped = mapping.startsWith( '_(none' );
114
124
  const derives = /derives from (--[\w-]+)/.exec( mapping );
115
- const wpds = /(--wpds-[\w-]+)/.exec( mapping );
125
+ // `--wp-*` rather than `--wpds-*`: the series palette maps to WordPress's own
126
+ // `--wp-admin-theme-color`, since the design system's brand token is a static
127
+ // hex on every admin screen and cannot carry the admin color scheme.
128
+ const token = /(--wp[\w-]+)/.exec( mapping );
116
129
 
117
130
  entries.set( role, {
118
- reads: unmapped ? null : derives?.[ 1 ] ?? wpds?.[ 1 ] ?? null,
131
+ reads: unmapped ? null : derives?.[ 1 ] ?? token?.[ 1 ] ?? null,
119
132
  fallback: fallback === '—' ? null : normalize( fallback ),
120
133
  } );
121
134
  }
@@ -140,13 +153,13 @@ describe( 'chart scope catalog', () => {
140
153
  // The layer is what keeps a `theme` prop override from being able to take its role down with it: an override that is invalid at computed-value time only invalidates `<role>-theme`, and the role's own fallback still resolves the mapped token. Drop the layer from a role and that role's overrides go back to blanking every read site.
141
154
  it.each( THEME_LAYERED_ROLES )( 'declares %s reading its theme layer first', role => {
142
155
  expect( stylesheet ).toMatch(
143
- new RegExp( `${ role }:\\s*var\\(\\s*${ themeLayerVar( role ) }\\s*,` )
156
+ new RegExp( `${ role }:\\s*var\\(\\s*${ themeLayerVar( role ) }\\s*[,)]` )
144
157
  );
145
158
  } );
146
159
 
147
160
  it( 'gives a theme layer to the overridable roles and to nothing else', () => {
148
161
  const layered = [ ...declared.keys() ].filter( role =>
149
- new RegExp( `${ role }:\\s*var\\(\\s*${ themeLayerVar( role ) }\\s*,` ).test( stylesheet )
162
+ new RegExp( `${ role }:\\s*var\\(\\s*${ themeLayerVar( role ) }\\s*[,)]` ).test( stylesheet )
150
163
  );
151
164
 
152
165
  expect( layered.sort() ).toEqual( [ ...THEME_LAYERED_ROLES ].sort() );