+ ) }
+/>` }
+/>
+
+## Theming
+
+The heatmap resolves its full-intensity color the same way the other HTML charts do: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values.
+
+
+
+// Or via the theme:
+const customTheme: ChartTheme = {
+ heatmapChart: { primaryColor: '#c0392b' },
+};
+
+
+
+` }
+/>
+
+Everything else — the cell gap, corner radius, in-cell value size, the empty-cell color and the selection ring — comes straight from WordPress design system tokens (`--wpds-*`) in CSS, so it tracks the active design system theme automatically. There are no per-instance props for these. The `heatmapChart` theme section exposes just the scale color (`primaryColor`, above) and the compact sizing (`compactCellSize`, `compactCellGap`):
+
+
+
+` }
+/>
+
+## Responsive Behavior
+
+By default the chart fills its parent container's dimensions. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width:
+
+
+
+
+
+// Derive height from width via aspectRatio (height = width × 0.4)
+
+
+// Fixed dimensions
+` }
+/>
+
+
+
+For more details on responsive behavior, see the [Responsive Design section](./?path=/docs/js-packages-charts-library-introduction--docs#responsive-design) in the introduction.
+
+## Accessibility
+
+The heatmap chart is designed for full keyboard and screen reader access:
+
+- The grid container is a `
` with a localised `aria-label`; each visual row is a `role="row"` and each cell a `role="gridcell"`.
+- Each cell has an `aria-label` containing the cell name and value (or "No data"). An `aria-label` is used rather than a native `title` so no duplicate browser tooltip appears. Axis labels are decorative (`aria-hidden`) because the cell label already carries that text.
+- **Keyboard navigation**: Tab focuses the chart; arrow keys navigate between cells; the focused cell receives a visible highlight ring, and (when `withTooltips` is set) its tooltip. Escape clears the selection.
+- Color alone is never the sole indicator of value — every cell exposes its value via its accessible label, and `showValues` can render numeric labels inside cells.
+- The component does not use animations by default, and no `prefers-reduced-motion` opt-out is required.
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
new file mode 100644
index 000000000000..1ce3eb1d37da
--- /dev/null
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
@@ -0,0 +1,89 @@
+import {
+ chartDecorator,
+ sharedChartArgTypes,
+ ChartStoryArgs,
+} from '../../../stories/chart-decorator';
+import { heatmapActivityMatrix, heatmapCalendarSeries } from '../../../stories/sample-data';
+import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config';
+import { HeatmapChart } from '../index';
+import { buildCalendarHeatmapData } from '../private';
+import type { Meta, StoryObj } from '@storybook/react';
+
+type StoryArgs = ChartStoryArgs< React.ComponentProps< typeof HeatmapChart > >;
+
+const meta: Meta< StoryArgs > = {
+ title: 'JS Packages/Charts Library/Charts/Heatmap Chart',
+ component: HeatmapChart,
+ parameters: { layout: 'centered' },
+ decorators: [ chartDecorator ],
+ argTypes: {
+ ...sharedChartArgTypes,
+ ...themeArgTypes,
+ compact: { control: 'boolean', table: { category: 'Visual Style' } },
+ showValues: { control: 'boolean', table: { category: 'Visual Style' } },
+ },
+} satisfies Meta< StoryArgs >;
+
+export default meta;
+type Story = StoryObj< StoryArgs >;
+
+export const Default: Story = {
+ args: {
+ ...sharedThemeArgs,
+ data: heatmapActivityMatrix,
+ rowLabels: [ 'Mon', '', 'Wed', '', 'Fri', '', '' ],
+ withTooltips: true,
+ },
+};
+
+export const Compact: Story = {
+ args: { ...Default.args, compact: true, containerHeight: '160px' },
+};
+
+export const Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = {
+ render: ( { weekStartsOn, ...args } ) => {
+ const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, {
+ weekStartsOn,
+ } );
+ return ;
+ },
+ args: { ...sharedThemeArgs, withTooltips: true, weekStartsOn: 1 },
+ argTypes: {
+ weekStartsOn: {
+ control: { type: 'inline-radio', labels: { 0: 'Sunday', 1: 'Monday' } },
+ options: [ 1, 0 ],
+ table: { category: 'Calendar' },
+ },
+ },
+};
+
+export const WithCompositionLegend: Story = {
+ render: args => (
+
+
+
+ ),
+ args: { ...Default.args },
+};
+
+export const FixedDimensions: Story = {
+ args: {
+ ...Default.args,
+ width: 720,
+ height: 220,
+ },
+};
+
+export const AspectRatio: Story = {
+ args: {
+ ...Default.args,
+ aspectRatio: 0.4,
+ },
+};
+
+export const ErrorStates: Story = {
+ args: {
+ ...Default.args,
+ data: [],
+ },
+};
diff --git a/projects/js-packages/charts/src/stories/index.docs.mdx b/projects/js-packages/charts/src/stories/index.docs.mdx
index ccac4d3111a8..212e3f133157 100644
--- a/projects/js-packages/charts/src/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/stories/index.docs.mdx
@@ -8,7 +8,7 @@ A comprehensive charting library for displaying interactive data visualizations
## Features
-- ✨ **Rich Chart Types** - Bar charts, bar list charts, conversion funnel charts, donut charts, geo charts, leaderboard visualizations, line charts, pie charts, and sparklines
+- ✨ **Rich Chart Types** - Bar charts, bar list charts, conversion funnel charts, donut charts, geo charts, heatmap charts, leaderboard visualizations, line charts, pie charts, and sparklines
- 🎨 **Themeable** - Built-in default theme with custom theme support
- 📱 **Responsive** - Automatically adapts to container size and viewport changes
- ♿ **Accessible** - Full keyboard navigation, screen reader support, and ARIA compliance
@@ -90,6 +90,10 @@ A variation of pie charts with a hollow center for additional content display.
Visualizes geographical data on an interactive world map, making it easy to understand the distribution of values across countries.
+### [Heatmap Chart](./?path=/docs/js-packages-charts-library-charts-heatmap-chart--docs)
+
+Visualize values across a two-dimensional matrix of cells on a sequential color scale. Supports a compact mode for short widgets and a calendar/contribution-style layout via `buildCalendarHeatmapData`.
+
### [Leaderboard Chart](./?path=/docs/js-packages-charts-library-charts-leaderboard-chart--docs)
Specialized for ranking data with progress bars and comparison values.
diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts
index b051cc667c19..4ba1c8e61f83 100644
--- a/projects/js-packages/charts/src/stories/sample-data/index.ts
+++ b/projects/js-packages/charts/src/stories/sample-data/index.ts
@@ -4,8 +4,9 @@
*/
import type { FunnelStep } from '../../charts/conversion-funnel-chart';
+import type { HeatmapColumn } from '../../charts/heatmap-chart';
import type { LeaderboardEntry } from '../../charts/leaderboard-chart';
-import type { DataPointPercentage, GeoData, SeriesData } from '../../types';
+import type { DataPointDate, DataPointPercentage, GeoData, SeriesData } from '../../types';
/**
* Olympic medals data for top countries (1896-2020)
@@ -1014,3 +1015,39 @@ export const viewsByEuropeanCountry: GeoData = [
[ 'Turkey', 200 ],
[ 'Russia', 100 ],
];
+
+/**
+ * Activity matrix for the heatmap chart (12 columns × 7 rows)
+ *
+ * Weekday-by-week grid with quarter labels and scattered empty cells.
+ * - Category: matrix
+ * - Data points: 84
+ * - Suitable for: HeatmapChart
+ */
+export const heatmapActivityMatrix: HeatmapColumn[] = Array.from(
+ { length: 12 },
+ ( _col, col ) => ( {
+ label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '',
+ data: Array.from( { length: 7 }, ( _row, row ) => ( {
+ label: `Col ${ col + 1 }, Row ${ row + 1 }`,
+ value: ( col * 7 + row ) % 5 === 0 ? null : ( ( col + row ) % 5 ) + 1,
+ } ) ),
+ } )
+);
+
+/**
+ * Daily activity series for the calendar heatmap (120 days from 2024-01-01)
+ *
+ * Date/value pairs for building a GitHub-style contribution calendar via
+ * `buildCalendarHeatmapData`.
+ * - Category: time-series
+ * - Data points: 120
+ * - Suitable for: HeatmapChart (calendar layout)
+ */
+export const heatmapCalendarSeries: DataPointDate[] = Array.from(
+ { length: 120 },
+ ( _, index ) => ( {
+ date: new Date( 2024, 0, 1 + index ),
+ value: Math.round( Math.abs( Math.sin( index ) ) * 4 ),
+ } )
+);
From 61d674d5bcdb521e559f4e08867e947ad9ff42d0 Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:43:33 +1200
Subject: [PATCH 05/17] style(charts): trim redundant comments and doc
cross-references
Drop comments that restate the code or the package's given conventions
(WPDS theming, cross-references to the other HTML charts), keeping only
the non-obvious why: the containerBounds-ref loop guard, the
repeat()/var() constraint, the 15% color-mix floor, and the
aria-activedescendant focus model. Docs keep WPDS theming notes but drop
the other-HTML-chart references.
---
.../heatmap-chart/heatmap-chart.module.scss | 21 +++++-----------
.../charts/heatmap-chart/heatmap-chart.tsx | 25 ++++++-------------
.../heatmap-chart/private/heatmap-legend.tsx | 2 --
.../private/use-heatmap-colors.ts | 4 +--
.../heatmap-chart/stories/index.api.mdx | 2 +-
.../heatmap-chart/stories/index.docs.mdx | 2 +-
.../charts/src/charts/heatmap-chart/types.ts | 4 +--
.../src/providers/chart-context/themes.ts | 7 ++----
projects/js-packages/charts/src/types.ts | 10 +++-----
9 files changed, 25 insertions(+), 52 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
index 4c42198baeb7..53a811676afc 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
@@ -8,15 +8,9 @@
color: var(--wpds-color-fg-content-neutral-weak, #707070);
}
-// A single CSS grid holds the cell matrix and its axis labels: track 1 of
-// each axis is the label gutter (`auto`), the rest are the data cells. The
-// track template is set inline (CSS `repeat()` won't take a `var()` count);
-// sizes, gap and colors stay here. Non-compact cells flex to fill; compact
-// cells are fixed squares and the grid shrinks to content. ARIA rows use
-// `display: contents` so their cells join this grid.
+// Track template is set inline because CSS `repeat()` won't take a `var()`
+// count. ARIA rows are `display: contents` so their cells join this grid.
.heatmap-chart__grid {
- // `--heatmap-cell-gap` is only set inline for compact mode's tighter
- // rhythm; otherwise the gap is the WPDS extra-small gap token.
display: grid;
gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
width: 100%;
@@ -68,14 +62,12 @@
min-width: 0;
min-height: 0;
border-radius: var(--wpds-border-radius-sm, 2px);
- // Default is the empty (no-data) color; cells with a value override it below.
+ // Empty (no-data) default; cells with a value override it below.
background: var(--wpds-color-bg-track-neutral-weak, #f0f0f0);
}
-// CSS-first shading for cells with a value: --intensity (0–1) maps to the
-// color-mix percentage, floored at 15% so the lowest value stays visibly
-// tinted (distinct from empty). Mixing toward `transparent` lets the tint
-// adapt to the chart background.
+// Floor the mix at 15% so the lowest value stays visibly tinted, distinct from
+// an empty cell.
.heatmap-chart__cell--filled {
background: color-mix(in sRGB, var(--heatmap-primary) calc((0.15 + 0.85 * var(--intensity)) * 100%), transparent);
}
@@ -92,8 +84,7 @@
font-size: var(--wpds-typography-font-size-md, 13px);
}
-// Strong (high-value) cells get a dark fill, so flip the in-cell value
-// to light for contrast.
+// Light text for contrast on the darker high-value fill.
.heatmap-chart__cell--strong .heatmap-chart__cell-value {
color: #fff;
}
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index 92f39c8d0a98..65078d87a782 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -64,14 +64,11 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
detectBounds: true,
scroll: true,
} );
- // `containerBounds` is a fresh object every render, so the keyboard-tooltip effect reads
- // it from a ref instead of depending on it — depending on it would loop (showTooltip →
- // render → new bounds → effect → showTooltip → …).
+ // Read from a ref so the keyboard-tooltip effect doesn't depend on containerBounds, which
+ // is a new object each render and would loop the effect via showTooltip.
const containerBoundsRef = useRef( containerBounds );
containerBoundsRef.current = containerBounds;
- // Same theme integration as the other HTML charts: prop > theme > palette colors[0].
- // The resolved color feeds CSS `color-mix` via the `--heatmap-primary` custom property.
const { color: primaryColorHex } = getElementStyles( {
index: 0,
overrideColor: primaryColor || heatmapChartSettings.primaryColor,
@@ -87,8 +84,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
const rows = Math.max( 0, ...data.map( column => column.data.length ) );
const { compactCellGap, compactCellSize } = heatmapChartSettings;
- // Cell gap and radius come from WPDS tokens in CSS. The only inline gap override is the
- // compact mode's tighter contribution-graph rhythm (not a WPDS dimension).
const drawValues = showValues ?? ! compact;
const buildTooltipData = useCallback(
@@ -177,10 +172,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
}
}, [ withTooltips, selectedIndex, hideTooltip ] );
- // Keyboard navigation drives the tooltip too: anchor it at the selected cell's center
- // (mirrors how bar/line charts surface the tooltip on keyboard focus). Cleared on
- // blur/Escape rather than here, so a mouse hover (selectedIndex === undefined) is left
- // alone.
+ // Anchor the tooltip at the selected cell's center on keyboard nav. Cleared on blur/Escape,
+ // not here, so a mouse hover (no selection) isn't affected.
useEffect( () => {
if ( ! withTooltips || selectedIndex === undefined ) {
return;
@@ -249,7 +242,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
= ( {
} ) }
style={ gridStyle as CSSProperties }
>
- { /* Header band: corner gutter + column labels. Decorative — each cell's
- aria-label already carries the column/row text for screen readers. */ }
+ { /* Corner gutter + column labels; aria-hidden, since each cell's label carries the text. */ }
{ data.map( ( column, columnIndex ) => (
= ( {
id={ `${ chartId }-cell-${ columnIndex }-${ rowIndex }` }
data-testid="heatmap-cell"
role="gridcell"
- // Focus stays on the grid container, which points here via
- // aria-activedescendant; cells are programmatically focusable but
- // out of the tab sequence.
+ // Focus stays on the grid (aria-activedescendant); cells are
+ // focusable but out of the tab order.
tabIndex={ -1 }
aria-colindex={ columnIndex + 1 }
aria-label={ accessibleLabel }
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx
index 2b347bf708b1..f634804794e6 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/private/heatmap-legend.tsx
@@ -29,8 +29,6 @@ export const HeatmapLegend: FC< HeatmapLegendProps > = ( { steps = 5, lessLabel,
{ Array.from( { length: steps }, ( _, index ) => {
- // Swatches share the cell fill rule: --intensity drives the same color-mix
- // percentage in CSS, so the legend matches the rendered cells exactly.
const intensity = steps <= 1 ? 1 : index / ( steps - 1 );
return (
{
};
/**
- * Normalize a value to 0–1 within the extent. Drives each cell's `--intensity` custom
- * property; CSS turns that into the cell's `color-mix` percentage (the value→shade scale
- * lives in CSS, not here). A flat extent (min === max) maps everything to the full color.
+ * Normalize a value to 0–1 within the extent. A flat extent (min === max) maps to 1.
* @param value - The value to normalize
* @param extent - Tuple of [min, max] values for the normalization range
* @return Normalized value between 0 and 1
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
index f8268b01d216..57160ee1f276 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
@@ -17,7 +17,7 @@ and shaded with CSS `color-mix`, so the scale adapts to the chart background.
| `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. |
| `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. |
| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell. |
-| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolved like the other HTML charts: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. |
+| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolution order: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. |
| `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. |
| `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. |
| `width` | `number` | - | Fixed width in pixels. When omitted, the chart fills its parent's width. |
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
index 3d98cdc94462..ebb694c02943 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
@@ -183,7 +183,7 @@ Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard na
## Theming
-The heatmap resolves its full-intensity color the same way the other HTML charts do: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values.
+The heatmap resolves its full-intensity color in this order: the `primaryColor` prop wins, then `heatmapChart.primaryColor` from the active theme, then the first palette color (`colors[0]`). The resolved color is fed to CSS `color-mix`, which derives the lighter shades for lower values.
theme `heatmapChart.primaryColor` > palette `colors[0]`).
+ * Color the cell scale interpolates toward at the highest value
+ * (this prop > theme `heatmapChart.primaryColor` > palette `colors[0]`).
*/
primaryColor?: string;
renderTooltip?: ( data: HeatmapTooltipData ) => ReactNode;
diff --git a/projects/js-packages/charts/src/providers/chart-context/themes.ts b/projects/js-packages/charts/src/providers/chart-context/themes.ts
index b319abe71ded..1200b3b2d24e 100644
--- a/projects/js-packages/charts/src/providers/chart-context/themes.ts
+++ b/projects/js-packages/charts/src/providers/chart-context/themes.ts
@@ -81,11 +81,8 @@ const defaultTheme: CompleteChartTheme = {
margin: { top: 2, right: 2, bottom: 2, left: 2 },
strokeWidth: 1.5,
},
- // Cell gap/radius/value-size and the selection ring come from WPDS tokens in CSS.
- // `primaryColor` is intentionally unset so it falls back to the palette's `colors[0]`,
- // matching how leaderboardChart resolves its primary (the prop and a theme override still
- // win). The compact 11px square / 2px gap is the contribution-graph rhythm, which doesn't
- // map to a WPDS dimension. Override via GlobalChartsProvider.
+ // `primaryColor` is left unset so it falls back to the palette's `colors[0]`. The compact
+ // 11px square / 2px gap is the contribution-graph rhythm, which has no WPDS dimension.
heatmapChart: {
compactCellGap: 2,
compactCellSize: 11,
diff --git a/projects/js-packages/charts/src/types.ts b/projects/js-packages/charts/src/types.ts
index 14fff2122523..0fc9d03e487b 100644
--- a/projects/js-packages/charts/src/types.ts
+++ b/projects/js-packages/charts/src/types.ts
@@ -414,15 +414,13 @@ export type ChartTheme = {
strokeWidth?: number;
};
/**
- * HeatmapChart specific settings. Cell gap, radius, value font size and the selection
- * ring come straight from WPDS tokens in CSS, so the only values here are the scale color
- * (for parity with the other HTML charts) and the compact sizing.
+ * HeatmapChart settings. Cell gap, radius, value size and the selection ring come from
+ * WPDS tokens in CSS, so only the scale color and the compact sizing live here.
*/
heatmapChart?: {
/**
- * Color the cell scale interpolates toward at the highest value. Resolved like the
- * other HTML charts (prop > this > palette `colors[0]`) and fed to CSS `color-mix`
- * as `--heatmap-primary`. Omit to use the palette color.
+ * Color the cell scale interpolates toward at the highest value (prop > this >
+ * palette `colors[0]`), fed to CSS `color-mix`. Omit to use the palette color.
*/
primaryColor?: string;
/** Gap in px between cells in compact mode */
From 7aff0f0d244650a7fabcb442a7cea7dd721f7afd Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:46:44 +1200
Subject: [PATCH 06/17] test(charts): drop vestigial SVG title assertion
The HTML heatmap has no SVG element, so the no-native-title check
can't fail; the aria-label assertion in the same test is the real check.
---
.../src/charts/heatmap-chart/test/heatmap-chart.test.tsx | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
index 1aa47b28251b..79825b210673 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
@@ -59,11 +59,9 @@ describe( 'HeatmapChart', () => {
} );
/* eslint-disable testing-library/no-node-access */
- test( 'gives each cell an accessible name for screen readers (no native title tooltip)', () => {
+ test( 'gives each cell an accessible name for screen readers', () => {
renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
- // aria-label (not ) provides the accessible name so no native browser tooltip shows.
- expect( grid.querySelectorAll( 'title' ) ).toHaveLength( 0 );
const labels = Array.from( grid.querySelectorAll( '[role="gridcell"]' ) ).map( c =>
c.getAttribute( 'aria-label' )
);
From e4407f0ccc5d82206f1aefefae169cf7541da1fb Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:49:17 +1200
Subject: [PATCH 07/17] test(charts): query by role instead of suppressing
no-node-access
Use getByRole('gridcell', { name }) for the accessible-name check and
within(row).getAllByRole('gridcell') for the ARIA-hierarchy check, so the
testing-library/no-node-access rule no longer needs disabling.
---
.../heatmap-chart/test/heatmap-chart.test.tsx | 16 ++++------------
1 file changed, 4 insertions(+), 12 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
index 79825b210673..8b3053324cc0 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
@@ -1,4 +1,4 @@
-import { render, screen } from '@testing-library/react';
+import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GlobalChartsProvider } from '../../../providers';
import HeatmapChart from '../heatmap-chart';
@@ -58,14 +58,10 @@ describe( 'HeatmapChart', () => {
expect( screen.queryByText( '3' ) ).not.toBeInTheDocument();
} );
- /* eslint-disable testing-library/no-node-access */
test( 'gives each cell an accessible name for screen readers', () => {
renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
- const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
- const labels = Array.from( grid.querySelectorAll( '[role="gridcell"]' ) ).map( c =>
- c.getAttribute( 'aria-label' )
- );
- expect( labels.some( l => l?.includes( 'W1' ) && l?.includes( 'Mon' ) ) ).toBe( true );
+ // The gridcell's accessible name is its aria-label (column + row + value).
+ expect( screen.getByRole( 'gridcell', { name: 'W1 Mon: 1' } ) ).toBeInTheDocument();
} );
test( 'shows a tooltip on cell hover when withTooltips is set', async () => {
@@ -74,7 +70,6 @@ describe( 'HeatmapChart', () => {
await userEvent.setup().hover( cell );
await expect( screen.findByRole( 'tooltip' ) ).resolves.toBeInTheDocument();
} );
- /* eslint-enable testing-library/no-node-access */
test( 'shows a tooltip on keyboard navigation when withTooltips is set', async () => {
renderChart( { withTooltips: true, rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
@@ -205,17 +200,14 @@ describe( 'HeatmapChart', () => {
expect( grid ).not.toHaveAttribute( 'aria-activedescendant' );
} );
- /* eslint-disable testing-library/no-node-access */
test( 'rows contain gridcell children in the ARIA hierarchy', () => {
renderChart();
const rows = screen.getAllByRole( 'row' );
expect( rows.length ).toBeGreaterThan( 0 );
rows.forEach( row => {
- const cells = Array.from( row.querySelectorAll( '[role="gridcell"]' ) );
- expect( cells.length ).toBeGreaterThan( 0 );
+ expect( within( row ).getAllByRole( 'gridcell' ).length ).toBeGreaterThan( 0 );
} );
} );
- /* eslint-enable testing-library/no-node-access */
test( 'leaves cell gap and radius to WPDS tokens (no inline overrides)', () => {
renderChart();
From dc0f351c9718298d596bd426542fbfb450dea87c Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 17:58:30 +1200
Subject: [PATCH 08/17] feat(charts): compact in-cell formatting for large
heatmap values
Large values (e.g. 748,500) overflowed the cell when rendered in full.
Use formatNumberCompact for the in-cell value (748.5K) so it fits; the
tooltip and aria-label keep the full, precise value. Adds a LargeValues
story and sample dataset.
---
.../charts/heatmap-chart/heatmap-chart.tsx | 5 ++--
.../heatmap-chart/stories/index.api.mdx | 2 +-
.../heatmap-chart/stories/index.docs.mdx | 8 ++++++-
.../heatmap-chart/stories/index.stories.tsx | 15 +++++++++++-
.../heatmap-chart/test/heatmap-chart.test.tsx | 13 ++++++++++
.../charts/src/stories/sample-data/index.ts | 24 +++++++++++++++++++
6 files changed, 62 insertions(+), 5 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index 65078d87a782..fd6b957c54ce 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -1,4 +1,4 @@
-import { formatNumber } from '@automattic/number-formatters';
+import { formatNumber, formatNumberCompact } from '@automattic/number-formatters';
import { useTooltip, useTooltipInPortal } from '@visx/tooltip';
import { __ } from '@wordpress/i18n';
import clsx from 'clsx';
@@ -333,7 +333,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
>
{ drawValues && present && (
- { formatNumber( value ) }
+ { /* Compact so large values fit the cell; tooltip + aria-label keep full precision. */ }
+ { formatNumberCompact( value ) }
) }
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
index 57160ee1f276..1d87b37d43c6 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.api.mdx
@@ -16,7 +16,7 @@ and shaded with CSS `color-mix`, so the scale adapts to the chart background.
| `data` | `HeatmapColumn[]` | - | **Required.** Array of columns; each column contains an optional label and an array of cells. |
| `rowLabels` | `string[]` | `[]` | y-axis labels by row index. Empty entries render blank. |
| `compact` | `boolean` | `false` | Compact mode: tighten cell gaps, suppress in-cell values, and thin axis labels. |
-| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell. |
+| `showValues` | `boolean` | `!compact` | Render the numeric value inside each cell (compact notation for large numbers; the tooltip and aria-label keep full precision). |
| `primaryColor` | `string` | theme / palette | Color the scale interpolates toward at the highest value. Resolution order: this prop > `heatmapChart.primaryColor` > palette `colors[0]`. |
| `withTooltips` | `boolean` | `false` | Show a tooltip on cell hover or keyboard navigation. |
| `renderTooltip` | `(data: HeatmapTooltipData) => ReactNode` | - | Custom tooltip render function. Receives `HeatmapTooltipData`. The default tooltip formats numeric values with `@automattic/number-formatters`. |
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
index ebb694c02943..4f8349e508bb 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
@@ -72,7 +72,7 @@ The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, ea
- **`rowLabels`**: y-axis labels by row index. Empty entries render blank.
- **`withTooltips`** (default: `false`): show a tooltip on cell hover
- **`compact`** (default: `false`): tighten spacing and suppress in-cell values for compact widgets
-- **`showValues`** (default: `!compact`): render the numeric value inside each cell
+- **`showValues`** (default: `!compact`): render the numeric value inside each cell (compact notation for large numbers; the tooltip keeps full precision)
- **`primaryColor`**: color the scale interpolates toward at the highest value (prop > `heatmapChart.primaryColor` > palette `colors[0]`)
- **`width`** / **`height`**: explicit dimensions in pixels. When omitted the chart fills its parent.
- **`gap`** (default: `'md'`): gap between chart layout regions (chart area, legend, children)
@@ -95,6 +95,12 @@ Use `compact` for narrow widgets or dashboards where vertical space is limited.
/>` }
/>
+## Large Values
+
+In-cell values use compact notation (e.g. `748.5K`, `1.2M`) so large numbers fit the cell. The tooltip and each cell's accessible label keep the full, precise value.
+
+
+
## Calendar Layout
Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into the column/row structure expected by the chart. This produces the GitHub-style contribution graph layout.
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
index 1ce3eb1d37da..e1f4f9414698 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
@@ -3,7 +3,11 @@ import {
sharedChartArgTypes,
ChartStoryArgs,
} from '../../../stories/chart-decorator';
-import { heatmapActivityMatrix, heatmapCalendarSeries } from '../../../stories/sample-data';
+import {
+ heatmapActivityMatrix,
+ heatmapCalendarSeries,
+ heatmapLargeValueMatrix,
+} from '../../../stories/sample-data';
import { sharedThemeArgs, themeArgTypes } from '../../../stories/theme-config';
import { HeatmapChart } from '../index';
import { buildCalendarHeatmapData } from '../private';
@@ -40,6 +44,15 @@ export const Compact: Story = {
args: { ...Default.args, compact: true, containerHeight: '160px' },
};
+export const LargeValues: Story = {
+ args: {
+ ...Default.args,
+ data: heatmapLargeValueMatrix,
+ containerWidth: '900px',
+ containerHeight: '320px',
+ },
+};
+
export const Calendar: StoryObj< StoryArgs & { weekStartsOn: 0 | 1 } > = {
render: ( { weekStartsOn, ...args } ) => {
const { data, rowLabels } = buildCalendarHeatmapData( heatmapCalendarSeries, {
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
index 8b3053324cc0..0ab22a71127a 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
@@ -58,6 +58,19 @@ describe( 'HeatmapChart', () => {
expect( screen.queryByText( '3' ) ).not.toBeInTheDocument();
} );
+ test( 'formats large in-cell values compactly', () => {
+ render(
+
+
+
+ );
+ expect( screen.getByText( /748\.5\s?K/i ) ).toBeInTheDocument();
+ } );
+
test( 'gives each cell an accessible name for screen readers', () => {
renderChart( { rowLabels: [ 'Mon', 'Tue', 'Wed' ] } );
// The gridcell's accessible name is its aria-label (column + row + value).
diff --git a/projects/js-packages/charts/src/stories/sample-data/index.ts b/projects/js-packages/charts/src/stories/sample-data/index.ts
index 4ba1c8e61f83..ce6223dbcdce 100644
--- a/projects/js-packages/charts/src/stories/sample-data/index.ts
+++ b/projects/js-packages/charts/src/stories/sample-data/index.ts
@@ -1035,6 +1035,30 @@ export const heatmapActivityMatrix: HeatmapColumn[] = Array.from(
} )
);
+/**
+ * Large-value matrix for the heatmap chart (12 columns × 7 rows)
+ *
+ * Same shape as the activity matrix but with values up to ~1,000,000, to exercise
+ * compact in-cell number formatting (e.g. `748.5K`).
+ * - Category: matrix
+ * - Data points: 84
+ * - Suitable for: HeatmapChart
+ */
+export const heatmapLargeValueMatrix: HeatmapColumn[] = Array.from(
+ { length: 12 },
+ ( _col, col ) => ( {
+ label: col % 4 === 0 ? `Q${ Math.floor( col / 4 ) + 1 }` : '',
+ data: Array.from( { length: 7 }, ( _row, row ) => {
+ const index = col * 7 + row;
+ return {
+ label: `Col ${ col + 1 }, Row ${ row + 1 }`,
+ value:
+ index % 9 === 0 ? null : Math.round( Math.abs( Math.sin( index ) ) * 990_000 ) + 1_000,
+ };
+ } ),
+ } )
+);
+
/**
* Daily activity series for the calendar heatmap (120 days from 2024-01-01)
*
From d5be04bbf85025b1c4b7b79b841df772185ef9da Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 18:10:48 +1200
Subject: [PATCH 09/17] fix(charts): pick in-cell text color by fill luminance
for WCAG contrast
The in-cell value used white text whenever the data value was above the
midpoint, but with a light palette (e.g. colors[0] #98C8DF) even the
fullest cell is light, so white numbers failed contrast. Decide the text
color from the cell's actual blended-fill luminance instead: light text
only where it out-contrasts dark text (W3C 0.179 threshold).
Adds reusable relativeLuminance and prefersLightText to color-utils
(composed with the existing lightenHexColor for the over-white blend)
rather than bespoke color math in the chart.
---
.../heatmap-chart/heatmap-chart.module.scss | 3 +-
.../charts/heatmap-chart/heatmap-chart.tsx | 23 +++++++++++-
.../heatmap-chart/stories/index.stories.tsx | 2 --
.../charts/src/utils/color-utils.ts | 36 +++++++++++++++++++
.../charts/src/utils/test/color-utils.test.ts | 33 +++++++++++++++++
5 files changed, 93 insertions(+), 4 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
index 53a811676afc..82a593844f24 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
@@ -84,7 +84,8 @@
font-size: var(--wpds-typography-font-size-md, 13px);
}
-// Light text for contrast on the darker high-value fill.
+// Light text on fills dark enough to out-contrast dark text (decided in JS from
+// the blended fill luminance).
.heatmap-chart__cell--strong .heatmap-chart__cell-value {
color: #fff;
}
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index fd6b957c54ce..b5045be4a657 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -18,6 +18,12 @@ import {
GlobalChartsContext,
} from '../../providers';
import { attachSubComponents } from '../../utils';
+import {
+ isValidHexColor,
+ lightenHexColor,
+ normalizeColorToHex,
+ prefersLightText,
+} from '../../utils/color-utils';
import { Center } from '../private/center';
import { useChartChildren } from '../private/chart-composition';
import { ChartLayout } from '../private/chart-layout';
@@ -37,6 +43,10 @@ export type HeatmapContextValue = {
export const HeatmapContext = createContext< HeatmapContextValue | null >( null );
+// Mirrors the color-mix floor in heatmap-chart.module.scss (.heatmap-chart__cell--filled):
+// the rendered fill is the primary mixed over the chart background at 0.15 + 0.85 * intensity.
+const CELL_MIX_FLOOR = 0.15;
+
const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
data,
chartId: providedChartId,
@@ -74,6 +84,16 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
overrideColor: primaryColor || heatmapChartSettings.primaryColor,
} );
+ // Pick the in-cell text color from the cell's actual blended fill luminance (not the data
+ // value), so light text is only used where it out-contrasts dark text. Falls back to dark
+ // text when the primary isn't a resolvable hex (e.g. a bare CSS token).
+ const primaryHex = normalizeColorToHex( primaryColorHex );
+ const cellHasLightText = ( intensity: number ): boolean =>
+ isValidHexColor( primaryHex ) &&
+ prefersLightText(
+ lightenHexColor( primaryHex, 1 - ( CELL_MIX_FLOOR + ( 1 - CELL_MIX_FLOOR ) * intensity ) )
+ );
+
const extent = useMemo( () => getValueExtent( data ), [ data ] );
const heatmapContext = useMemo< HeatmapContextValue >(
() => ( { extent, primaryColorHex } ),
@@ -321,7 +341,8 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
data-row={ rowIndex }
className={ clsx( styles[ 'heatmap-chart__cell' ], {
[ styles[ 'heatmap-chart__cell--filled' ] ]: present,
- [ styles[ 'heatmap-chart__cell--strong' ] ]: present && normalized > 0.5,
+ [ styles[ 'heatmap-chart__cell--strong' ] ]:
+ present && cellHasLightText( normalized ),
[ styles[ 'heatmap-chart__cell--selected' ] ]:
selectedIndex === flatIndex,
} ) }
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
index e1f4f9414698..efe26d5e9538 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.stories.tsx
@@ -48,8 +48,6 @@ export const LargeValues: Story = {
args: {
...Default.args,
data: heatmapLargeValueMatrix,
- containerWidth: '900px',
- containerHeight: '320px',
},
};
diff --git a/projects/js-packages/charts/src/utils/color-utils.ts b/projects/js-packages/charts/src/utils/color-utils.ts
index 5dfdd92b2128..4e75e407ee03 100644
--- a/projects/js-packages/charts/src/utils/color-utils.ts
+++ b/projects/js-packages/charts/src/utils/color-utils.ts
@@ -232,3 +232,39 @@ export const lightenHexColor = ( hex: string, blend: number ): string => {
.toString( 16 )
.padStart( 2, '0' ) }${ newB.toString( 16 ).padStart( 2, '0' ) }`;
};
+
+/**
+ * WCAG relative luminance of a hex color (0 = black, 1 = white).
+ *
+ * @param hex - Hex color string (e.g., '#98C8DF')
+ * @return Relative luminance in the range [0, 1]
+ * @throws {Error} if hex string is malformed
+ */
+export const relativeLuminance = ( hex: string ): number => {
+ validateHexColor( hex );
+
+ const toLinear = ( value: number ): number => {
+ const channel = value / 255;
+ return channel <= 0.03928 ? channel / 12.92 : Math.pow( ( channel + 0.055 ) / 1.055, 2.4 );
+ };
+
+ const r = toLinear( parseInt( hex.slice( 1, 3 ), 16 ) );
+ const g = toLinear( parseInt( hex.slice( 3, 5 ), 16 ) );
+ const b = toLinear( parseInt( hex.slice( 5, 7 ), 16 ) );
+
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
+};
+
+/**
+ * Whether light text reads better than dark text on the given background, using the W3C
+ * luminance threshold (0.179) that maximizes contrast against black vs white.
+ *
+ * @param backgroundHex - Hex background color
+ * @return true if light text should be used; false (dark text) for malformed colors
+ */
+export const prefersLightText = ( backgroundHex: string ): boolean => {
+ if ( ! isValidHexColor( backgroundHex ) ) {
+ return false;
+ }
+ return relativeLuminance( backgroundHex ) <= 0.179;
+};
diff --git a/projects/js-packages/charts/src/utils/test/color-utils.test.ts b/projects/js-packages/charts/src/utils/test/color-utils.test.ts
index 47e06211bc28..31d8511b4fe5 100644
--- a/projects/js-packages/charts/src/utils/test/color-utils.test.ts
+++ b/projects/js-packages/charts/src/utils/test/color-utils.test.ts
@@ -8,6 +8,8 @@ import {
parseHslString,
parseRgbString,
normalizeColorToHex,
+ relativeLuminance,
+ prefersLightText,
} from '../color-utils';
// Helper to convert hex to HSL tuple using d3-color
@@ -881,3 +883,34 @@ describe( 'normalizeColorToHex', () => {
} );
} );
} );
+
+describe( 'relativeLuminance', () => {
+ it( 'returns 0 for black and 1 for white', () => {
+ expect( relativeLuminance( '#000000' ) ).toBeCloseTo( 0, 5 );
+ expect( relativeLuminance( '#ffffff' ) ).toBeCloseTo( 1, 5 );
+ } );
+
+ it( 'returns a higher luminance for a lighter color', () => {
+ expect( relativeLuminance( '#98c8df' ) ).toBeGreaterThan( relativeLuminance( '#006dab' ) );
+ } );
+
+ it( 'throws on a malformed hex', () => {
+ expect( () => relativeLuminance( 'not-a-color' ) ).toThrow();
+ } );
+} );
+
+describe( 'prefersLightText', () => {
+ it( 'prefers dark text on light backgrounds', () => {
+ expect( prefersLightText( '#ffffff' ) ).toBe( false );
+ expect( prefersLightText( '#98c8df' ) ).toBe( false );
+ } );
+
+ it( 'prefers light text on dark backgrounds', () => {
+ expect( prefersLightText( '#000000' ) ).toBe( true );
+ expect( prefersLightText( '#006dab' ) ).toBe( true );
+ } );
+
+ it( 'falls back to dark text for malformed colors', () => {
+ expect( prefersLightText( 'var(--token)' ) ).toBe( false );
+ } );
+} );
From 8dc8968f4bb84a97f94066fd94cd215e016fe531 Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Tue, 30 Jun 2026 18:15:48 +1200
Subject: [PATCH 10/17] style(charts): use WPDS token for light in-cell text
Replace the hardcoded #fff with --wpds-color-fg-interactive-neutral-strong,
the WPDS light foreground for content on a strong/colored surface (content
tokens have no strong/inverse variant). Pairs with the neutral dark default.
---
.../charts/src/charts/heatmap-chart/heatmap-chart.module.scss | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
index 82a593844f24..5cc87dc355de 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
@@ -87,7 +87,7 @@
// Light text on fills dark enough to out-contrast dark text (decided in JS from
// the blended fill luminance).
.heatmap-chart__cell--strong .heatmap-chart__cell-value {
- color: #fff;
+ color: var(--wpds-color-fg-interactive-neutral-strong, #f0f0f0);
}
.heatmap-chart__legend-swatch {
From 5862d7b07edc1c26283f47196c4cc5aa2f8be419 Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Wed, 1 Jul 2026 11:48:08 +1200
Subject: [PATCH 11/17] fix(charts): reflow heatmap fluidly via CSS sizing
The heatmap pinned the responsive wrapper's debounced, measured pixel
width and height onto its container, so on resize the grid snapped to
stale dimensions 300ms behind the cursor instead of reflowing with its
parent. CSS Grid can do the reflow for free, but the inline pixel sizes
were overriding it.
Size the chart purely in CSS instead, matching the conversion-funnel
chart: width/height flow at 100%, the grid uses flex to fill remaining
space, and a min-height floor keeps it usable when the parent has no
explicit height. Aspect-ratio mode now expresses the ratio as a CSS
aspect-ratio on the responsive wrapper so its height tracks width
fluidly rather than snapping to a debounced measurement; this also
makes aspect-ratio sizing fluid for the other charts that share the
wrapper.
Both axes now reflow continuously in the same layout pass, with no
JavaScript in the sizing path.
---
.../src/charts/heatmap-chart/heatmap-chart.module.scss | 9 +++++++--
.../charts/src/charts/heatmap-chart/heatmap-chart.tsx | 4 ----
.../src/charts/heatmap-chart/stories/index.docs.mdx | 2 +-
.../charts/private/with-responsive/with-responsive.tsx | 5 +++++
4 files changed, 13 insertions(+), 7 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
index 5cc87dc355de..b9dd58c73756 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.module.scss
@@ -1,5 +1,7 @@
.heatmap-chart {
width: 100%;
+ height: 100%;
+ min-height: 0;
font-size: var(--wpds-typography-font-size-sm, 12px);
}
@@ -13,9 +15,10 @@
.heatmap-chart__grid {
display: grid;
gap: var(--heatmap-cell-gap, var(--wpds-dimension-gap-xs, 4px));
+ flex: 1 1 0;
width: 100%;
- height: 100%;
- min-height: 0;
+ // Floor so the grid stays usable when the parent has no explicit height.
+ min-height: 200px;
&:focus-visible {
outline:
@@ -27,8 +30,10 @@
}
.heatmap-chart__grid--compact {
+ flex: 0 0 auto;
width: max-content;
height: max-content;
+ min-height: 0;
}
.heatmap-chart__row {
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index b5045be4a657..caf2ea55ab66 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -50,8 +50,6 @@ const CELL_MIX_FLOOR = 0.15;
const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
data,
chartId: providedChartId,
- width = 0,
- height = 0,
className,
compact = false,
showValues,
@@ -231,7 +229,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
return (
@@ -267,7 +264,6 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
trailingContent={ nonLegendChildren }
gap={ gap }
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) }
- style={ { width: width || undefined, height: height || undefined } }
data-testid="heatmap-chart"
data-chart-id={ `heatmap-chart-${ chartId }` }
>
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
index 4f8349e508bb..d7923e6a1c8a 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
@@ -219,7 +219,7 @@ Everything else — the cell gap, corner radius, in-cell value size, the empty-c
## Responsive Behavior
-By default the chart fills its parent container's dimensions. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width:
+By default the chart fills its parent container's dimensions, reflowing fluidly as the container resizes. The parent must have an explicit height, or you can set `aspectRatio` to derive the height from the available width. When neither is provided, the chart falls back to a minimum height so it stays usable rather than collapsing:
, 'o
const effectiveHeight = measuredHeight || height || 0;
const defaultHeight = hasAspectRatio ? 'auto' : '100%';
+ // Express the aspect ratio in CSS so the container height tracks its width
+ // fluidly, rather than snapping to a debounced measured height.
+ const aspectRatioStyle =
+ hasAspectRatio && aspectRatio ? { aspectRatio: `${ 1 / aspectRatio }` } : null;
return (
, 'o
style={ {
width: width ?? '100%',
height: height ?? defaultHeight,
+ ...aspectRatioStyle,
} }
>
Date: Wed, 1 Jul 2026 11:55:26 +1200
Subject: [PATCH 12/17] docs(charts): note heatmap in-cell text contrast
handling
The Accessibility section listed value labels and color cues but
omitted that in-cell text picks light or dark from the blended fill
luminance, which is what keeps numbers legible across the scale and
under any primary color. Document it alongside the other a11y
guarantees.
---
.../charts/src/charts/heatmap-chart/stories/index.docs.mdx | 1 +
1 file changed, 1 insertion(+)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
index d7923e6a1c8a..78d1b8294a3e 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
@@ -247,4 +247,5 @@ The heatmap chart is designed for full keyboard and screen reader access:
- Each cell has an `aria-label` containing the cell name and value (or "No data"). An `aria-label` is used rather than a native `title` so no duplicate browser tooltip appears. Axis labels are decorative (`aria-hidden`) because the cell label already carries that text.
- **Keyboard navigation**: Tab focuses the chart; arrow keys navigate between cells; the focused cell receives a visible highlight ring, and (when `withTooltips` is set) its tooltip. Escape clears the selection.
- Color alone is never the sole indicator of value — every cell exposes its value via its accessible label, and `showValues` can render numeric labels inside cells.
+- **In-cell text contrast**: when values are shown, each cell chooses light or dark text from the luminance of its blended fill rather than the raw value, so the number stays legible across the whole scale and with any primary color.
- The component does not use animations by default, and no `prefers-reduced-motion` opt-out is required.
From 407ce32d6da06e8c9c1dfefcf3e02f7165147a7b Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Wed, 1 Jul 2026 12:34:28 +1200
Subject: [PATCH 13/17] fix(charts): cap aspect-ratio wrapper at maxWidth
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The CSS aspect-ratio was applied to a wrapper that stays width: 100%,
while the wrapped chart is sized from the maxWidth-capped measured
width. In a container wider than maxWidth (default 1200) with an
aspectRatio set, the wrapper reserved height for the full container
width while the chart only filled the capped width — e.g. a 2000px
parent with aspectRatio 0.4 gave a 2000x800 wrapper around 1200-wide
content, leaving a block of empty space below the chart.
Cap the wrapper's width at maxWidth in the aspect-ratio branch so the
CSS-derived height matches the capped content. Scoped to that branch,
so non-aspect-ratio charts keep their existing full-width wrapper.
Reported by a Codex review of the HeatmapChart PR.
---
.../private/with-responsive/with-responsive.tsx | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
index 84e67c84b301..bf8527c53851 100644
--- a/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
+++ b/projects/js-packages/charts/src/charts/private/with-responsive/with-responsive.tsx
@@ -94,9 +94,16 @@ export function withResponsive< T extends Exclude< BaseChartProps< unknown >, 'o
const defaultHeight = hasAspectRatio ? 'auto' : '100%';
// Express the aspect ratio in CSS so the container height tracks its width
- // fluidly, rather than snapping to a debounced measured height.
+ // fluidly, rather than snapping to a debounced measured height. Cap the width
+ // at maxWidth so the CSS-derived height matches the maxWidth-capped content
+ // (the wrapped chart is sized from the capped `measuredWidth`).
const aspectRatioStyle =
- hasAspectRatio && aspectRatio ? { aspectRatio: `${ 1 / aspectRatio }` } : null;
+ hasAspectRatio && aspectRatio
+ ? {
+ aspectRatio: `${ 1 / aspectRatio }`,
+ maxWidth: width === undefined ? maxWidth : undefined,
+ }
+ : null;
return (
Date: Wed, 1 Jul 2026 12:34:46 +1200
Subject: [PATCH 14/17] fix(charts): honor fixed dimensions in unresponsive
heatmap
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
HeatmapChartInternal stopped reading width/height when sizing moved to
CSS, so consumers of the exported HeatmapChartUnresponsive that pass
width or height got them silently ignored — the chart fell back to
100% plus the grid min-height and could not render the documented
fixed size without a wrapper of their own.
Apply width/height inline again, and have the responsive export drop
the wrapper's measured pixels (passing width/height undefined) so its
grid still fills the container via CSS and reflows fluidly. Net: the
unresponsive export pins explicit dimensions; the responsive export
stays unpinned. Cover both with tests.
Reported by a Codex review of the HeatmapChart PR.
---
.../charts/heatmap-chart/heatmap-chart.tsx | 17 +++++++++++++-
.../heatmap-chart/test/heatmap-chart.test.tsx | 23 ++++++++++++++++++-
2 files changed, 38 insertions(+), 2 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index caf2ea55ab66..bbfb588b2c85 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -50,6 +50,8 @@ const CELL_MIX_FLOOR = 0.15;
const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
data,
chartId: providedChartId,
+ width = 0,
+ height = 0,
className,
compact = false,
showValues,
@@ -229,6 +231,7 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
return (
@@ -264,6 +267,10 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
trailingContent={ nonLegendChildren }
gap={ gap }
className={ clsx( 'heatmap-chart', styles[ 'heatmap-chart' ], className ) }
+ // Explicit dimensions (the unresponsive export) pin the size; otherwise
+ // width/height are unset and the grid fills its container via CSS. The
+ // responsive export drops the measured pixels so reflow stays fluid.
+ style={ { width: width || undefined, height: height || undefined } }
data-testid="heatmap-chart"
data-chart-id={ `heatmap-chart-${ chartId }` }
>
@@ -396,8 +403,16 @@ const HeatmapChart = attachSubComponents( HeatmapChartWithProvider, {
Legend: HeatmapLegend,
} ) as FC< HeatmapChartProps > & HeatmapChartSubComponents;
+// The responsive wrapper already sizes the container; drop its measured pixel
+// width/height so the grid fills that container via CSS and reflows fluidly,
+// instead of pinning to a debounced measurement.
+const HeatmapChartResponsiveInner: FC< HeatmapChartProps > = props => (
+
+);
+HeatmapChartResponsiveInner.displayName = 'HeatmapChart';
+
const HeatmapChartResponsive = attachSubComponents(
- withResponsive< HeatmapChartProps >( HeatmapChartWithProvider ),
+ withResponsive< HeatmapChartProps >( HeatmapChartResponsiveInner ),
{ Legend: HeatmapLegend }
) as FC< HeatmapChartProps & ResponsiveConfig > & HeatmapChartSubComponents;
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
index 0ab22a71127a..aea7a16f5ce2 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/test/heatmap-chart.test.tsx
@@ -1,7 +1,7 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { GlobalChartsProvider } from '../../../providers';
-import HeatmapChart from '../heatmap-chart';
+import HeatmapChart, { HeatmapChartUnresponsive } from '../heatmap-chart';
import type { HeatmapColumn } from '../types';
const mockRefCallback = jest.fn();
@@ -277,4 +277,25 @@ describe( 'HeatmapChart', () => {
const grid = screen.getByRole( 'grid', { name: /heatmap/i } );
expect( grid.style.getPropertyValue( '--heatmap-primary' ) ).toBe( '#0a0b0c' );
} );
+
+ test( 'the unresponsive export pins explicit width and height', () => {
+ render(
+
+
+
+ );
+ const chart = screen.getByTestId( 'heatmap-chart' );
+ expect( chart ).toHaveStyle( { width: '480px', height: '240px' } );
+ } );
+
+ test( 'the responsive export leaves the chart unpinned so it fills its container', () => {
+ render(
+
+
+
+ );
+ const chart = screen.getByTestId( 'heatmap-chart' );
+ expect( chart ).not.toHaveStyle( { width: '500px' } );
+ expect( chart ).not.toHaveStyle( { height: '300px' } );
+ } );
} );
From d898ef871aa15d35b7b002aaef0381f2af18907f Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Wed, 1 Jul 2026 12:54:18 +1200
Subject: [PATCH 15/17] refactor(charts): stabilize heatmap handleCellMouseMove
callback
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
handleCellMouseMove closed over containerBounds, a fresh object from
useTooltipInPortal on every render, so the callback was rebuilt each
render and reattached to every cell. Read the bounds from the existing
containerBoundsRef — the same pattern the keyboard-tooltip effect uses
— so the callback memoizes and the two tooltip paths stay consistent.
Raised as a nit in the @claude review.
---
.../charts/src/charts/heatmap-chart/heatmap-chart.tsx | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
index bbfb588b2c85..651ddf1a8a36 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/heatmap-chart.tsx
@@ -175,14 +175,17 @@ const HeatmapChartInternal: FC< HeatmapChartProps > = ( {
const target = event.currentTarget;
const columnIndex = Number( target.dataset.column );
const rowIndex = Number( target.dataset.row );
+ // Read bounds from the ref (like the keyboard-tooltip effect) so this
+ // callback stays stable across renders.
+ const bounds = containerBoundsRef.current;
// TooltipInPortal re-adds containerBounds, so subtract it to land at the cursor.
showTooltip( {
- tooltipLeft: event.clientX - containerBounds.left,
- tooltipTop: event.clientY - containerBounds.top,
+ tooltipLeft: event.clientX - bounds.left,
+ tooltipTop: event.clientY - bounds.top,
tooltipData: buildTooltipData( columnIndex, rowIndex ),
} );
},
- [ withTooltips, showTooltip, buildTooltipData, containerBounds ]
+ [ withTooltips, showTooltip, buildTooltipData ]
);
const handleCellMouseLeave = useCallback( () => {
From c4c3e43708e329887dc6a17471402c0d7cfe4881 Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Wed, 1 Jul 2026 12:54:25 +1200
Subject: [PATCH 16/17] test(charts): cover with-responsive aspect-ratio
wrapper styles
The shared HOC now expresses aspectRatio as a CSS aspect-ratio and
caps the wrapper at maxWidth, but nothing asserted it. Add tests for
the CSS aspect-ratio value and the maxWidth cap, plus its omission
when an explicit width is passed, so this shared behavior is locked in.
Raised as a nit in the @claude review.
---
.../with-responsive/test/with-responsive.test.tsx | 14 ++++++++++++++
1 file changed, 14 insertions(+)
diff --git a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
index fedf762944f7..d8f3dbec039d 100644
--- a/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
+++ b/projects/js-packages/charts/src/charts/private/with-responsive/test/with-responsive.test.tsx
@@ -72,6 +72,20 @@ describe( 'withResponsive', () => {
const wrapper = screen.getByTestId( 'responsive-wrapper' );
expect( wrapper ).toHaveStyle( { width: '100%', height: 'auto' } );
} );
+
+ test( 'wrapper expresses aspectRatio in CSS and caps at maxWidth', () => {
+ render( );
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
+ // CSS aspect-ratio is width/height, so 1 / 0.5 = 2.
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', maxWidth: '800px' } );
+ } );
+
+ test( 'wrapper omits the maxWidth cap when an explicit width is set', () => {
+ render( );
+ const wrapper = screen.getByTestId( 'responsive-wrapper' );
+ expect( wrapper ).toHaveStyle( { aspectRatio: '2', width: '300px' } );
+ expect( wrapper ).not.toHaveStyle( { maxWidth: '1200px' } );
+ } );
} );
describe( 'configuration', () => {
From eeb8f353d0a6c2d4d3f227b9e6b29a6831b69235 Mon Sep 17 00:00:00 2001
From: Adam Wood <1017872+adamwoodnz@users.noreply.github.com>
Date: Wed, 1 Jul 2026 13:55:22 +1200
Subject: [PATCH 17/17] docs(charts): fix code-block indentation in heatmap
docs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The `` example blocks authored their JSX at the template's base
column, so Storybook's dedent stripped the props' indentation and the
examples rendered with every prop flush against the component tag.
Indent the JSX body one level (imports stay flush), matching the
convention the other chart docs already use, so the dedent preserves
the prop indentation. Kept to tabs throughout — no tab/space mix.
---
.../heatmap-chart/stories/index.docs.mdx | 124 +++++++++---------
1 file changed, 62 insertions(+), 62 deletions(-)
diff --git a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
index 78d1b8294a3e..0321c4932213 100644
--- a/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
+++ b/projects/js-packages/charts/src/charts/heatmap-chart/stories/index.docs.mdx
@@ -18,11 +18,11 @@ The Heatmap Chart renders a CSS Grid of colored cells where color intensity maps
code={ `import { HeatmapChart } from '@automattic/charts';
import '@automattic/charts/style.css';
-` }
+ ` }
/>
## API Reference
@@ -56,11 +56,11 @@ The simplest heatmap requires `data` — an array of `HeatmapColumn` objects, ea
},
];
-` }
+ ` }
/>
### Required Props
@@ -89,10 +89,10 @@ Use `compact` for narrow widgets or dashboards where vertical space is limited.
` }
+ data={ columns }
+ rowLabels={ rowLabels }
+ compact={ true }
+ />` }
/>
## Large Values
@@ -111,22 +111,22 @@ Use `buildCalendarHeatmapData` to convert a flat `DataPointDate[]` series into t
language="jsx"
code={ `import { HeatmapChart, buildCalendarHeatmapData } from '@automattic/charts';
-const series = [
- { date: new Date( '2024-01-01' ), value: 3 },
- { date: new Date( '2024-01-02' ), value: 0 },
- // ...
-];
-
-function CalendarHeatmap() {
- const { data, rowLabels } = buildCalendarHeatmapData( series );
- return (
-
- );
-}` }
+ const series = [
+ { date: new Date( '2024-01-01' ), value: 3 },
+ { date: new Date( '2024-01-02' ), value: 0 },
+ // ...
+ ];
+
+ function CalendarHeatmap() {
+ const { data, rowLabels } = buildCalendarHeatmapData( series );
+ return (
+
+ );
+ }` }
/>
`buildCalendarHeatmapData` accepts an optional `options` object:
@@ -142,11 +142,11 @@ Pass `width` and `height` to bypass the responsive wrapper and render at an exac
` }
+ data={ columns }
+ rowLabels={ rowLabels }
+ width={ 720 }
+ height={ 220 }
+ />` }
/>
## Composition API — Legend
@@ -158,8 +158,8 @@ Add a legend by placing `` as a child. The legend renders
-
-` }
+
+ ` }
/>
## Error / Empty State
@@ -175,16 +175,16 @@ Enable `withTooltips` to show a tooltip on cell hover **and** during keyboard na
(
-
+ ) }
+ />` }
/>
## Theming
@@ -195,17 +195,17 @@ The heatmap resolves its full-intensity color in this order: the `primaryColor`
language="tsx"
code={ `import { GlobalChartsProvider, HeatmapChart, type ChartTheme } from '@automattic/charts';
-// Per-instance:
-
+ // Per-instance:
+
-// Or via the theme:
-const customTheme: ChartTheme = {
- heatmapChart: { primaryColor: '#c0392b' },
-};
+ // Or via the theme:
+ const customTheme: ChartTheme = {
+ heatmapChart: { primaryColor: '#c0392b' },
+ };
-
-
-` }
+
+
+ ` }
/>
Everything else — the cell gap, corner radius, in-cell value size, the empty-cell color and the selection ring — comes straight from WordPress design system tokens (`--wpds-*`) in CSS, so it tracks the active design system theme automatically. There are no per-instance props for these. The `heatmapChart` theme section exposes just the scale color (`primaryColor`, above) and the compact sizing (`compactCellSize`, `compactCellGap`):
@@ -213,8 +213,8 @@ Everything else — the cell gap, corner radius, in-cell value size, the empty-c
-
-` }
+
+ ` }
/>
## Responsive Behavior
@@ -228,11 +228,11 @@ By default the chart fills its parent container's dimensions, reflowing fluidly
-// Derive height from width via aspectRatio (height = width × 0.4)
-
+ // Derive height from width via aspectRatio (height = width × 0.4)
+
-// Fixed dimensions
-` }
+ // Fixed dimensions
+ ` }
/>