basalt-ui 1.14.0 → 1.15.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 (162) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/README.md +38 -2
  4. package/agent/rules/basalt-charts.md +260 -108
  5. package/agent/rules/basalt-mantine.md +4 -2
  6. package/agent/skills/basalt-charts/SKILL.md +114 -64
  7. package/agent/skills/basalt-design/SKILL.md +4 -3
  8. package/configs/oxlint-plugin.js +152 -0
  9. package/configs/oxlint-plugin.test.ts +107 -0
  10. package/configs/oxlint.json +2 -0
  11. package/dist/charts/cursor/resolve.d.ts +33 -0
  12. package/dist/charts/cursor/resolve.d.ts.map +1 -0
  13. package/dist/charts/cursor/resolve.js +61 -0
  14. package/dist/charts/cursor/resolve.test.d.ts +2 -0
  15. package/dist/charts/cursor/resolve.test.d.ts.map +1 -0
  16. package/dist/charts/cursor/scope.d.ts +17 -0
  17. package/dist/charts/cursor/scope.d.ts.map +1 -0
  18. package/dist/charts/cursor/scope.js +20 -0
  19. package/dist/charts/cursor/store.d.ts +31 -0
  20. package/dist/charts/cursor/store.d.ts.map +1 -0
  21. package/dist/charts/cursor/store.js +25 -0
  22. package/dist/charts/cursor/store.test.d.ts +2 -0
  23. package/dist/charts/cursor/store.test.d.ts.map +1 -0
  24. package/dist/charts/hooks/useChartCursor.d.ts +39 -0
  25. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -0
  26. package/dist/charts/hooks/useChartCursor.js +104 -0
  27. package/dist/charts/hooks/useChartSize.d.ts +8 -4
  28. package/dist/charts/hooks/useChartSize.d.ts.map +1 -1
  29. package/dist/charts/index.d.ts +16 -13
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/index.js +38 -19
  32. package/dist/charts/kinds/Bars.d.ts +25 -47
  33. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  34. package/dist/charts/kinds/Bars.js +230 -390
  35. package/dist/charts/kinds/Donut.d.ts +5 -4
  36. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  37. package/dist/charts/kinds/Donut.js +17 -14
  38. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  39. package/dist/charts/kinds/DualPanel.js +136 -90
  40. package/dist/charts/kinds/Heatmap.d.ts +15 -11
  41. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  42. package/dist/charts/kinds/Heatmap.js +38 -22
  43. package/dist/charts/kinds/MultiLine.d.ts +27 -35
  44. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  45. package/dist/charts/kinds/MultiLine.js +114 -237
  46. package/dist/charts/kinds/StackedArea.d.ts +21 -10
  47. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  48. package/dist/charts/kinds/StackedArea.js +74 -132
  49. package/dist/charts/kinds/ZonedLine.d.ts +24 -40
  50. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  51. package/dist/charts/kinds/ZonedLine.js +103 -184
  52. package/dist/charts/layout/auto-margin.d.ts +60 -0
  53. package/dist/charts/layout/auto-margin.d.ts.map +1 -0
  54. package/dist/charts/layout/auto-margin.js +37 -0
  55. package/dist/charts/layout/auto-margin.test.d.ts +2 -0
  56. package/dist/charts/layout/auto-margin.test.d.ts.map +1 -0
  57. package/dist/charts/primitives/CartesianChart.d.ts +143 -0
  58. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -0
  59. package/dist/charts/primitives/CartesianChart.js +349 -0
  60. package/dist/charts/primitives/CartesianChart.test.d.ts +2 -0
  61. package/dist/charts/primitives/CartesianChart.test.d.ts.map +1 -0
  62. package/dist/charts/primitives/ChartFrame.d.ts +16 -5
  63. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartFrame.js +17 -2
  65. package/dist/charts/primitives/ChartLegend.d.ts +12 -1
  66. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartLegend.js +21 -3
  68. package/dist/charts/primitives/ChartLegend.test.d.ts +2 -0
  69. package/dist/charts/primitives/ChartLegend.test.d.ts.map +1 -0
  70. package/dist/charts/primitives/ChartTooltip.d.ts +16 -16
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +45 -19
  73. package/dist/charts/primitives/HoverOverlay.d.ts +19 -4
  74. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  75. package/dist/charts/primitives/HoverOverlay.js +22 -1
  76. package/dist/charts/primitives/XZoneRects.d.ts +40 -0
  77. package/dist/charts/primitives/XZoneRects.d.ts.map +1 -0
  78. package/dist/charts/primitives/XZoneRects.js +28 -0
  79. package/dist/charts/primitives/XZoneRects.test.d.ts +2 -0
  80. package/dist/charts/primitives/XZoneRects.test.d.ts.map +1 -0
  81. package/dist/charts/series.d.ts +16 -1
  82. package/dist/charts/series.d.ts.map +1 -1
  83. package/dist/charts/series.js +3 -1
  84. package/dist/charts/series.test.d.ts +2 -0
  85. package/dist/charts/series.test.d.ts.map +1 -0
  86. package/dist/charts/utils/measure-text.d.ts +25 -0
  87. package/dist/charts/utils/measure-text.d.ts.map +1 -0
  88. package/dist/charts/utils/measure-text.js +64 -0
  89. package/dist/charts/utils/measure-text.test.d.ts +2 -0
  90. package/dist/charts/utils/measure-text.test.d.ts.map +1 -0
  91. package/dist/content/fence-block.d.ts.map +1 -1
  92. package/dist/surfaces.d.ts +1 -1
  93. package/dist/surfaces.d.ts.map +1 -1
  94. package/dist/surfaces.js +1 -1
  95. package/dist/tokens/chart-margin.test.d.ts +2 -0
  96. package/dist/tokens/chart-margin.test.d.ts.map +1 -0
  97. package/dist/tokens/index.d.ts +29 -0
  98. package/dist/tokens/index.d.ts.map +1 -1
  99. package/dist/tokens/index.js +10 -0
  100. package/dist/tokens/palette.d.ts +4 -0
  101. package/dist/tokens/palette.d.ts.map +1 -1
  102. package/dist/tokens/palette.js +3 -0
  103. package/llms.txt +3 -3
  104. package/package.json +1 -1
  105. package/src/charts/cursor/resolve.test.ts +61 -0
  106. package/src/charts/cursor/resolve.ts +102 -0
  107. package/src/charts/cursor/scope.tsx +32 -0
  108. package/src/charts/cursor/store.test.ts +46 -0
  109. package/src/charts/cursor/store.ts +53 -0
  110. package/src/charts/hooks/useChartCursor.ts +163 -0
  111. package/src/charts/hooks/useChartSize.ts +8 -4
  112. package/src/charts/index.ts +34 -14
  113. package/src/charts/kinds/Bars.tsx +327 -460
  114. package/src/charts/kinds/Donut.tsx +31 -23
  115. package/src/charts/kinds/DualPanel.tsx +228 -134
  116. package/src/charts/kinds/Heatmap.tsx +72 -47
  117. package/src/charts/kinds/MultiLine.tsx +158 -294
  118. package/src/charts/kinds/StackedArea.tsx +116 -175
  119. package/src/charts/kinds/ZonedLine.tsx +148 -255
  120. package/src/charts/layout/auto-margin.test.ts +42 -0
  121. package/src/charts/layout/auto-margin.ts +110 -0
  122. package/src/charts/primitives/CartesianChart.test.ts +99 -0
  123. package/src/charts/primitives/CartesianChart.tsx +566 -0
  124. package/src/charts/primitives/ChartFrame.test.tsx +33 -0
  125. package/src/charts/primitives/ChartFrame.tsx +40 -4
  126. package/src/charts/primitives/ChartLegend.test.tsx +48 -0
  127. package/src/charts/primitives/ChartLegend.tsx +45 -2
  128. package/src/charts/primitives/ChartTooltip.tsx +67 -37
  129. package/src/charts/primitives/HoverOverlay.tsx +39 -3
  130. package/src/charts/primitives/XZoneRects.test.tsx +63 -0
  131. package/src/charts/primitives/XZoneRects.tsx +67 -0
  132. package/src/charts/series.test.ts +61 -0
  133. package/src/charts/series.ts +18 -1
  134. package/src/charts/utils/measure-text.test.ts +36 -0
  135. package/src/charts/utils/measure-text.ts +109 -0
  136. package/src/content/fence-block.tsx +2 -3
  137. package/src/surfaces.ts +2 -1
  138. package/src/theme/spacing.test.ts +2 -0
  139. package/src/tokens/chart-margin.test.ts +39 -0
  140. package/src/tokens/density.test.ts +1 -0
  141. package/src/tokens/index.ts +36 -0
  142. package/src/tokens/palette.ts +3 -0
  143. package/dist/charts/hooks/useChartTooltip.d.ts +0 -13
  144. package/dist/charts/hooks/useChartTooltip.d.ts.map +0 -1
  145. package/dist/charts/hooks/useChartTooltip.js +0 -26
  146. package/dist/charts/hooks/useHoverSync.d.ts +0 -46
  147. package/dist/charts/hooks/useHoverSync.d.ts.map +0 -1
  148. package/dist/charts/hooks/useHoverSync.js +0 -75
  149. package/dist/charts/hover-context.d.ts +0 -21
  150. package/dist/charts/hover-context.d.ts.map +0 -1
  151. package/dist/charts/hover-context.js +0 -12
  152. package/dist/charts/hover-sync.d.ts +0 -14
  153. package/dist/charts/hover-sync.d.ts.map +0 -1
  154. package/dist/charts/hover-sync.js +0 -17
  155. package/dist/charts/primitives/ResponsiveChart.d.ts +0 -45
  156. package/dist/charts/primitives/ResponsiveChart.d.ts.map +0 -1
  157. package/dist/charts/primitives/ResponsiveChart.js +0 -24
  158. package/src/charts/hooks/useChartTooltip.ts +0 -31
  159. package/src/charts/hooks/useHoverSync.ts +0 -152
  160. package/src/charts/hover-context.ts +0 -28
  161. package/src/charts/hover-sync.tsx +0 -37
  162. package/src/charts/primitives/ResponsiveChart.tsx +0 -64
package/AGENTS.md CHANGED
@@ -9,7 +9,7 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas
9
9
  | Subpath | Layer | Purpose |
10
10
  | --------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
11
  | `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone) |
12
- | `basalt-ui/charts` | headless | visx chart primitives, sparklines, hooks, and token re-exports (Mantine-free) |
12
+ | `basalt-ui/charts` | headless | CartesianChart + visx chart primitives, kinds, sparklines, hooks, and token re-exports (Mantine-free) |
13
13
  | `basalt-ui/tokens` | headless | VX token refs, buildPaletteCss, defineSeries, seriesTokens, groupTokens, alpha (Mantine-free) |
14
14
  | `basalt-ui/theme-lab` | mantine-coupled | ThemeLabControls, applyOverrides, COLOR_GROUPS — a low-level inspector for the non-derived structural tokens; identity/color tuning lives in DeriveControls |
15
15
  | `basalt-ui/vite` | mantine-coupled | basaltViteConfig(opts) — Vite preset for basalt-ui consumer apps; basaltAppPlugin(opts) — PWA head, manifest, and icon metadata derived from the token palette |
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ # [1.15.0](https://github.com/jkrumm/basalt-ui/compare/v1.14.0...v1.15.0) (2026-08-18)
2
+
3
+
4
+ ### Features
5
+
6
+ * add x-range chart bands, a dual-axis margin, and legend notes ([4883c19](https://github.com/jkrumm/basalt-ui/commit/4883c19d06a43d6a908efcf9fe8098610c86627a))
7
+ * make one cartesian primitive the whole chart contract ([4f8a507](https://github.com/jkrumm/basalt-ui/commit/4f8a507b2ca01923c1ee9bdf8a911ac0e5b76710))
8
+
1
9
  # [1.14.0](https://github.com/jkrumm/basalt-ui/compare/v1.13.0...v1.14.0) (2026-08-10)
2
10
 
3
11
 
package/README.md CHANGED
@@ -222,7 +222,7 @@ deleted rather than silently surviving into the next real skew.
222
222
  | Subpath | Mantine? | Purpose |
223
223
  | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
224
224
  | `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` |
225
- | `./charts` | **free** | visx chart primitives, sparklines, hooks, and token re-exports |
225
+ | `./charts` | **free** | `CartesianChart` + visx primitives, kinds, sparklines, hooks, token re-exports |
226
226
  | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
227
227
  | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
228
228
  | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
@@ -331,10 +331,46 @@ bun add @visx/axis @visx/curve @visx/event @visx/grid @visx/group @visx/responsi
331
331
  dependency-free: it needs the nine `@visx/*` packages, all pinned exact at `4.0.0`. They ship as
332
332
  optional peers, not bundled dependencies, so a `./tokens`-only consumer never installs them.
333
333
 
334
+ `CartesianChart` is the entry point: it owns the measured margins, both y scales and their
335
+ domains, the axes, grid, the page-shared cursor, the crosshair and the derived tooltip, so a chart
336
+ supplies its `series` and draws only marks. Legends toggle series, margins size themselves from
337
+ the tick labels actually painted, and charts share a cursor with no provider.
338
+
334
339
  ```tsx
335
- import { Bars, VxThemeProvider } from 'basalt-ui/charts'
340
+ import { CartesianChart, LinePath, VX } from 'basalt-ui/charts'
341
+ ;<CartesianChart
342
+ data={rows}
343
+ chartId="sessions"
344
+ getX={(d) => d.date}
345
+ series={[
346
+ {
347
+ key: 'sessions',
348
+ label: 'Sessions',
349
+ color: VX.accent,
350
+ mark: 'line',
351
+ getValue: (d) => d.sessions,
352
+ },
353
+ ]}
354
+ y={{ format: (v) => v.toFixed(0) }}
355
+ height={260}
356
+ >
357
+ {({ visible, xScale, yScale }) =>
358
+ visible.map((s) => (
359
+ <LinePath
360
+ key={s.key}
361
+ data={rows}
362
+ x={(d) => xScale(d.date) ?? 0}
363
+ y={(d) => yScale(s.getValue(d) ?? 0)}
364
+ stroke={s.color}
365
+ />
366
+ ))
367
+ }
368
+ </CartesianChart>
336
369
  ```
337
370
 
371
+ Shipped kinds (`Bars`, `MultiLine`, `StackedArea`, `ZonedLine`, `Donut`, `DualPanel`, `Heatmap`)
372
+ are that same composition, pre-wired.
373
+
338
374
  ### `./query` — TanStack Query adapter
339
375
 
340
376
  ```bash
@@ -11,8 +11,11 @@ paths:
11
11
 
12
12
  basalt-ui owns the chart doctrine. Charts are built from low-level [visx](https://airbnb.io/visx)
13
13
  primitives so we can build exactly the chart we want; the trade-off is that every chart duplicates
14
- structure unless we compose shared building blocks. **The primitives + a small kind set are the
15
- contract, not optional polish.**
14
+ structure unless we compose shared building blocks. **`CartesianChart` plus a small kind set are
15
+ the contract, not optional polish** — composing `CartesianChart` (single-plot) or `ChartFrame`
16
+ directly (multi-pane/radial/matrix) is mandatory for every chart, mechanically enforced by
17
+ `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). `docs/CHARTS-SPEC.md` is the ground
18
+ truth this doc reflects.
16
19
 
17
20
  ## The boundary (lint-enforced)
18
21
 
@@ -25,7 +28,7 @@ downstream apps too:
25
28
  `AreaClosed`, `scaleLinear`, `curveMonotoneX`, …) — keep the dependency declared in one place, and
26
29
  keep your own bespoke chart file under a `charts/` directory so it stays on the right side of the
27
30
  boundary.
28
- - **`@visx/tooltip` is banned everywhere** (`basalt/visx-tooltip`). Use `ChartTooltip` +
31
+ - **`@visx/tooltip` is banned everywhere** (`basalt/visx-tooltip`). Use `ChartTooltipFloat` +
29
32
  `TooltipHeader`/`TooltipRow`/`TooltipBody` from `basalt-ui/charts`.
30
33
 
31
34
  `basalt-ui/charts` is itself Mantine-free (`basalt/token-layer-boundary`, enforced only inside
@@ -44,62 +47,164 @@ either — both remedies point at a Mantine layout primitive (`Flex`/`Grid`/`Gro
44
47
  Mantine-free boundary above already forbids there, so the finding would be unactionable. Reach for
45
48
  `ChartCenter` (below, under "Pending state") when a chart file needs to center something.
46
49
 
50
+ ## The contract is mandatory (lint-enforced)
51
+
52
+ Composing `CartesianChart` for a single-plot chart, or `ChartFrame` directly for a multi-pane/
53
+ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
54
+ (`packages/basalt-ui/configs/oxlint-plugin.js`) make it a lint failure to bypass:
55
+
56
+ - **`basalt/hand-rolled-plot`** — rendering a chart-assembly primitive (`AxisLeftNumeric`,
57
+ `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) in a file that does not
58
+ compose `CartesianChart` fails the build. Escape: a `theme-allow` comment on the first such
59
+ site — that is how a genuinely non-single-plot shape (multi-pane, radial, matrix) declares
60
+ itself; `DualPanel` carries the repo's one such comment. The file that DEFINES `CartesianChart`
61
+ is exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
62
+ cannot fire inside X.
63
+ - **`basalt/chart-legend-literal`** — passing a hand-written array literal to `ChartLegend`'s
64
+ `items` fails the build; the legend must be derived from the same `series` array the chart draws
65
+ (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale and keep
66
+ naming a series the plot no longer draws.
67
+
68
+ Both ship at `warn` in the consumer preset (`configs/oxlint.json`) for one minor and `error`
69
+ repo-local, per the "Shipping a stricter guard — the grace minor" doctrine in
70
+ `packages/basalt-ui/CLAUDE.md`. They promote to `error` in the next minor.
71
+
72
+ ## Maps are not charts
73
+
74
+ Neither `@visx/geo` nor any map library is among the 9 exact-pinned `@visx/*` peers, and basalt
75
+ ships no map kind.
76
+
77
+ - **Maps are consumer territory.** A map is a MapLibre/Leaflet/deck.gl component in the consumer's
78
+ own tree, not a visx chart — build it wherever the rest of that consumer's non-chart UI lives.
79
+ - **The `basalt/visx-boundary` rule doesn't apply.** It constrains `@visx/*` imports only. A map
80
+ library is not `@visx/*`, so a map component needs no `charts/` path segment and no exemption.
81
+ - **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
82
+ `charts/` segment like every other visx import, same as any bespoke chart.
83
+ - **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
84
+ - **The guard trap:** `check-theme`'s `inline-spacing` kind is a per-line regex over raw source, not
85
+ an AST pass — it flags any `padding:`/`gap:`/`margin:` followed by a number in _any_ object
86
+ literal, in any file. A map's pixel geometry (`fitBounds({ padding: 48 })`, marker offsets) trips
87
+ it even though it isn't spacing, and hoisting the value to a module-scope const doesn't help — the
88
+ regex sees the line, not the binding. Use a `theme-allow` line comment; that's the sanctioned
89
+ answer, not a bug to file.
90
+
47
91
  ## Every chart has
48
92
 
49
- 1. **ChartCard** wrapper — gives title + info-tooltip + extra slot, consistent margin.
50
- 2. **ChartLegend** — never hand-rolled legend markup. Supports `line | bar | split | splitLine`
51
- shapes and optional highlight state.
52
- 3. **ChartTooltip** + `TooltipHeader` + `TooltipRow` + `TooltipBody` — never `@visx/tooltip` directly.
53
- Portals to `document.body` (SSR-guarded: renders nothing when `document` is undefined), so it
54
- can be authored anywhere in the tree, including inside an `<svg>`. A plain `<div>` there used to
55
- mount in the SVG namespace — accepting every prop, throwing nothing, typechecking, passing lint
56
- — and never paint; one consumer shipped eight authored tooltip rows no one had ever seen. The
57
- portal also un-breaks `position: fixed` under a transformed ancestor.
58
- 4. **AxisLeftNumeric** / **AxisRightNumeric** + **AxisBottomDate** — never raw `<AxisLeft>`/`<AxisBottom>`/`<AxisRight>`
59
- (they miss theme tokens + smart ticks). Enforced by `basalt-ui check-theme` (`raw-visx-axis` guard
60
- fails the build on a raw axis in a `/charts/` file; escape via `theme-allow`), not just convention.
61
- `AxisBottomDate` takes an optional `tickFormat` (`TickFormatter<string>`, defaults to
62
- `fmtAxisDate` — `DD.MM`) — the only supported exit for a sub-day window, where the default
63
- collapses every tick to the same label and a raw `<AxisBottom>` isn't an option.
64
- 5. **HoverOverlay** for pointer capture (mouse + touch + pen — `touch-action: pan-y` lets vertical
65
- page scroll pass through while a horizontal drag scrubs the chart), **HoverContext** for
66
- cross-chart crosshair sync, **useChartTooltip** for tip state, **useHoverSync** for the shared
67
- cursor. Wrap a group of date-aligned charts in **ChartHoverSync** (from `basalt-ui/charts`) to
68
- activate cross-chart sync — hovering one chart casts a ghost crosshair on all siblings; without
69
- it `useHoverSync` runs per-chart only and logs a dev warning:
93
+ 1. **`CartesianChart`** — the primitive every single-plot cartesian chart composes, and composes
94
+ NOTHING else from this list by hand. `ZonedLine`, `Bars`, `StackedArea`, and `MultiLine` all
95
+ compose it; a bespoke single-plot chart should too. It owns measured margins, both y scales +
96
+ domains, the x scale + tick thinning, grid, zones, axes, the shared cursor, the crosshair + its
97
+ per-series dots, the hover/keyboard overlay, and the derived tooltip. The caller supplies
98
+ `series` (the single source of truth — see #3) and a `children` render prop that draws ONLY
99
+ marks:
70
100
 
71
101
  ```tsx
72
- <ChartHoverSync>
73
- <ChartCard>…</ChartCard>
74
- <ChartCard>…</ChartCard>
75
- </ChartHoverSync>
102
+ <CartesianChart
103
+ data={data} chartId="sessions" getX={(d) => d.date} series={SERIES}
104
+ y={{ domain: 'auto', format: fmtInt }}
105
+ height={260}
106
+ >
107
+ {({ xScale, yScale, visible }) => visible.map((s) => <LinePath key={s.key} … />)}
108
+ </CartesianChart>
76
109
  ```
77
110
 
78
- Sync is exact string match on the broadcast key by default, so two charts share a cursor only
79
- when they emit identical key strings. A chart that folds/downsamples its domain for a narrow
80
- viewport stops owning most of the keys its unfolded siblings broadcast — the shared crosshair
81
- then lands on roughly one hover in three, worse than no shared cursor at all. Pass
82
- `useHoverSync`'s `resolveKey` to map a foreign key onto whichever of this chart's own points
83
- swallowed it; it only affects reading a SIBLING's broadcast key, never this chart's own hover:
111
+ Reaching past it for an axis, a tooltip, or a margin means the chart has drifted from every
112
+ other chart. `DualPanel`, `Heatmap`, and `Donut` are the deliberate exception, by shape: they
113
+ compose `ChartFrame` + `useChartCursor` + `autoMargin` + `ChartTooltipFloat` directly instead —
114
+ same machinery, different assembly, because their contract isn't a single plot rect with one or
115
+ two numeric y axes.
116
+
117
+ 2. **ChartCard** wrapper — never a raw `<Card>`. Gives title + info-tooltip + extra slot, consistent
118
+ margin.
119
+ 3. **`series` is the single source of truth.** Legend entries and tooltip rows are DERIVED from it
120
+ (`deriveLegend` / `deriveTooltipRows`) — a chart cannot show a row or a legend key it doesn't
121
+ draw. Never hand-author a legend or a tooltip row in parallel. Corollary for mark renderers: draw
122
+ `ctx.visible` (the `PlotContext` field), never the `series` prop directly — `visible` is `series`
123
+ minus whatever the legend has toggled off, so drawing `series` would repaint a mark the reader
124
+ just hid.
125
+ 4. **`ChartTooltipFloat`** + `TooltipHeader` + `TooltipRow` + `TooltipBody` — never `@visx/tooltip`
126
+ directly. `CartesianChart` wires this internally for a single-plot chart; a non-cartesian shape
127
+ composes it directly (see #1). Portals to `document.body` (SSR-guarded: renders nothing when
128
+ `document` is undefined), so it can be authored anywhere in the tree, including inside an
129
+ `<svg>`. A plain `<div>` there mounts in the SVG namespace — accepting every prop,
130
+ throwing nothing, typechecking, passing lint — and never paint; one consumer shipped eight
131
+ authored tooltip rows no one had ever seen. The portal also un-breaks `position: fixed` under a
132
+ transformed ancestor.
133
+ 5. **AxisLeftNumeric** / **AxisRightNumeric** + **AxisBottomDate** — never raw
134
+ `<AxisLeft>`/`<AxisBottom>`/`<AxisRight>` (they miss theme tokens + smart ticks). Enforced by
135
+ `basalt-ui check-theme` (`raw-visx-axis` guard fails the build on a raw axis in a `/charts/`
136
+ file; escape via `theme-allow`), not just convention. `AxisBottomDate` takes an optional
137
+ `tickFormat` (`TickFormatter<string>`, defaults to `fmtAxisDate` — `DD.MM`) — the only supported
138
+ exit for a sub-day window, where the default collapses every tick to the same label and a raw
139
+ `<AxisBottom>` isn't an option. `CartesianChart`'s own `formatX` prop is the higher-level way to
140
+ set this without touching the axis component at all.
141
+ 6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
142
+ external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
143
+ the page shares one cursor out of the box. `ChartCursorScope` **isolates** a subtree onto a
144
+ private store instead: it opts a subtree OUT of sharing, never into it. Reach for it only when a
145
+ group must not follow the rest of the page.
84
146
 
85
147
  ```tsx
86
- useHoverSync({
87
- data: foldedData,
88
- chartId: 'load-mobile',
89
- getKey: (d) => d.bucketKey,
90
- xScale,
91
- marginLeft,
92
- // ~288 daily buckets folded into ~97 columns at this width — map an unfolded sibling's key
93
- // onto the folded bucket that swallowed it.
94
- resolveKey: (key) => pointByKey.get(foldKey(key)) ?? null,
95
- })
148
+ <ChartCursorScope>
149
+ <ChartCard>…</ChartCard>
150
+ <ChartCard>…</ChartCard>
151
+ </ChartCursorScope>
96
152
  ```
97
153
 
98
- 6. **Theme-aware colors** via `VX.*` tokens + `alpha()`. **Never** a raw hex literal in a chart file.
99
- **Never** `localStorage.getItem('theme')` — the scheme resolves via CSS vars (see Dark/light below).
100
-
101
- **Exemption:** sparklines (`LineSparkline`, `BarSparkline` — tiny inline charts with no legend/tooltip)
102
- don't have to compose ChartCard/ChartLegend/ChartTooltip — but still use `VX.*` tokens.
154
+ **Resolution is domain-aware, not string-equal**, which is what makes sharing safe as a default.
155
+ Exact-string matching would desync any chart that folds or downsamples its own domain: it stops
156
+ owning most of the keys its siblings broadcast, so the shared crosshair lands on some hovers and
157
+ not others, with no rule a reader can infer — worse than no shared cursor at all.
158
+ `useChartCursor` resolves a broadcast key against a chart's own points by, in order: exact
159
+ match → nearest parsed-numeric/date match within the chart's own step → `null`. A chart that folds its calendar
160
+ into weekly buckets still tracks a hover from a sibling plotting daily points, with no extra
161
+ wiring — `resolveKey` no longer exists because there is nothing left for it to patch.
162
+
163
+ 7. **Keyboard-operable by construction.** The hover overlay `CartesianChart` renders is focusable;
164
+ `←`/`→` scrub the shared cursor one point at a time, `Escape` clears it. The tooltip is
165
+ `aria-live="polite"`. This comes for free from composing `CartesianChart`/`ChartFrame` — nothing
166
+ to wire per chart.
167
+ 8. **Theme-aware colors** via `VX.*` tokens + `alpha()`. **Never** a raw hex literal in a chart file.
168
+ **Never** `localStorage.getItem('theme')` — the scheme resolves via CSS vars (see Dark/light
169
+ below).
170
+
171
+ **Exemption:** sparklines (`LineSparkline`, `BarSparkline` — tiny inline charts with no
172
+ legend/tooltip) don't have to compose ChartCard/`CartesianChart`'s legend/tooltip — but still use
173
+ `VX.*` tokens.
174
+
175
+ ## Legend interaction
176
+
177
+ At ≥2 entries, the derived legend is a toggle by default: clicking an entry hides that series from
178
+ the plot, the tooltip, AND the auto y-domain, together — a stacked chart's axis actually shrinks
179
+ when a band is hidden, it doesn't just leave a dead gap. `legend={{ toggle: false }}` opts a chart
180
+ out. A single-entry legend never toggles (hiding the only series a chart draws is never useful).
181
+ This is exactly why marks must draw `ctx.visible` and never `series` (see "Every chart has" #3) —
182
+ `visible` is what actually reflects a hidden key; the raw `series` prop never changes.
183
+
184
+ ## Tooltip config
185
+
186
+ `CartesianChart`'s `tooltip` prop (`CartesianTooltipConfig<T>`, or `false` to disable the tooltip
187
+ and its crosshair dots entirely):
188
+
189
+ - `label?: (d: T) => { text; color } | null` — a right-aligned badge in the header (a status label,
190
+ a zone name).
191
+ - `prependRows?: (d: T) => ReactNode` — rows rendered BEFORE the derived per-series rows (a total, a
192
+ context line).
193
+ - `extraRows?: (d: T) => ReactNode` — rows appended after the derived rows.
194
+ - `follow?: boolean` — default `true`, the tooltip tracks the pointer. `follow: false` anchors it to
195
+ the crosshair at the plot's top edge instead, which reads better across a column of charts sharing
196
+ one cursor (every tooltip lines up on the same x). Anchoring costs one `getBoundingClientRect` per
197
+ hovered frame, which is why following is the default.
198
+
199
+ Per-series, `SeriesStyle.tooltip: false` means "draw the mark and legend it, but never give it a
200
+ tooltip row" — it lives on the series, not on the
201
+ kind.
202
+
203
+ A **stacked** chart needs `cursorValue` too: `CartesianChart`'s crosshair dot defaults to
204
+ `series.getValue(point)`, which is correct for an unstacked line but puts a stacked band's dot at
205
+ its own raw value — somewhere inside the fill, not on the edge the reader is tracking. Pass
206
+ `cursorValue={(point, series, visible) => …}` to place it at the cumulative band top instead;
207
+ `StackedArea` is the reference implementation (`kinds/StackedArea.tsx`).
103
208
 
104
209
  ## Pending state (`isPending`)
105
210
 
@@ -116,15 +221,18 @@ import { Bars } from 'basalt-ui/charts'
116
221
  isPending={query.isPending}
117
222
  chartId="load"
118
223
  getX={(d) => d.date}
119
- getY={(d) => d.load}
224
+ getValue={(d, key) => d[key]}
225
+ positiveBars={[{ key: 'load', label: 'Load', color: VX.accent }]}
120
226
  />
121
227
  ```
122
228
 
123
- All seven kinds accept `isPending` and forward it to `ChartFrame` (`Heatmap` handles it locally —
124
- it composes no `ChartFrame`, taking `width`/`height` directly rather than measuring). `ChartFrame`
125
- with `isPending` renders `ChartPending` over the plot rect in place of `children`, suppresses the
126
- legend entirely (a legend naming a series with nothing yet to point at is its own small lie), and
127
- sets `aria-busy="true"` on the outer container.
229
+ All seven kinds accept `isPending` and forward it to `ChartFrame` — `ZonedLine`/`Bars`/
230
+ `StackedArea`/`MultiLine` forward it through `CartesianChart`, which forwards it on to `ChartFrame`
231
+ in turn; `DualPanel`/`Heatmap`/`Donut` compose `ChartFrame` directly and forward it there. Every
232
+ path lands on the same `ChartFrame`, so there's one pending renderer, not seven. `ChartFrame` with
233
+ `isPending` renders `ChartPending` over the plot rect in place of `children`, suppresses the legend
234
+ entirely (a legend naming a "not measured" series with nothing to point at is its own small lie),
235
+ and sets `aria-busy="true"` on the outer container.
128
236
 
129
237
  `ChartPending` reserves the plot's exact footprint and draws **nothing** that could be mistaken for
130
238
  a measurement — no axes, no gridlines, no hatching, no marks, no animation (the motion doctrine
@@ -138,31 +246,78 @@ directly in a bespoke chart file the same way; it's deliberately minimal (`width
138
246
 
139
247
  ## Kinds — the recurring shapes
140
248
 
141
- basalt-ui ships these kinds (declarative props, generic over your point type via `getX`/`getY`):
142
-
143
- - **`ZonedLine`** — a line with zone bands, thresholds, and reference lines.
144
- - **`Bars`** — bars with optional overlaid line, zones, ref lines, dual-axis config.
145
- - **`StackedArea`** — opaque stacked bands.
146
- - **`Donut`** — proportional donut.
147
- - **`MultiLine`** — N series on a shared y-axis: legend-hover dimming, per-series synced dots,
148
- dashed companion (MA) lines, per-point markers (PR stars / status dots), zones + refLines, fixed
149
- or auto domain (covers z-score/σ via a fixed symmetric domain + zero refLine).
249
+ basalt-ui ships these kinds (declarative props, generic over your point type via `getX` and
250
+ per-series `getValue` accessors on `ChartSeries<T>`):
251
+
252
+ - **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, and reference lines.
253
+ Composes `CartesianChart`.
254
+ - **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, zones, ref lines,
255
+ dual-axis config. Composes `CartesianChart`.
256
+ - **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain. Composes `CartesianChart`.
257
+ - **`MultiLine`** — N series on a shared y-axis (or two, via `y2`): legend-hover dimming, per-series
258
+ synced dots, dashed companion (MA) lines, per-point markers (PR stars / status dots), zones +
259
+ refLines, fixed or auto domain (covers z-score/σ via a fixed symmetric domain + zero refLine).
260
+ Composes `CartesianChart`.
150
261
  - **`DualPanel`** — top line-pane + bottom signed-histogram pane sharing one x-scale and one cursor;
151
- optional fill-between two top lines, zones, refLines.
262
+ optional fill-between two top lines, zones, refLines. Composes `ChartFrame` directly (two panes,
263
+ not one plot rect).
152
264
  - **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip, optional
153
- gradient legend strip.
265
+ gradient legend strip. Composes `ChartFrame` directly; self-measures, no separate responsive
266
+ wrapper.
267
+ - **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
268
+ directly (radial, not cartesian).
269
+
270
+ `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
271
+ kind's horizontal zone bands, for marking a time window rather than a value range. Each
272
+ `{ from?, to?, fill }` bound is a `getX` **domain key** (the label string the kind's
273
+ `scalePoint<string>` runs over), not a date or timestamp. An omitted bound is the plot edge; a key
274
+ absent from the scale's domain skips that band entirely rather than clamping to an edge.
154
275
 
155
276
  How to add a chart:
156
277
 
157
- 1. **Fits an existing kind?** Use it. Pass `data`, `width`, `height`, `chartId`, accessors, and the
158
- declarative zones/thresholds/refLines arrays.
278
+ 1. **Fits an existing kind?** Use it. Pass `data`, `chartId`, `getX`, `series` (or
279
+ `positiveBars`/`getValue` for `Bars`), an `AxisConfig` per axis (`y`/`y2`), and the declarative
280
+ zones/thresholds/refLines arrays.
159
281
  2. **Second instance of a new recurring shape?** Extract a kind and migrate both call sites
160
282
  (Rule of Three: don't extract on the first, don't wait past the third). Bespoke escape hatches
161
- (`renderExtraTooltipRows`, etc.) are fine but must not grow into god-object configs.
162
- 3. **Genuinely unique (e.g. a dual-panel MACD)?** Stay bespoke — compose the primitives + the raw
163
- visx re-exports directly. Keep it in the page's chart file, not in a shared kind.
283
+ (`tooltip.extraRows`, etc.) are fine but must not grow into god-object configs.
284
+ 3. **Genuinely unique (e.g. a dual-panel MACD, or a dual-axis line pair no kind's config surface
285
+ covers)?** Stay bespoke — compose `CartesianChart` (single-plot) or `ChartFrame` (multi-pane/
286
+ non-cartesian) directly, drawing only marks in the `children` render prop. Keep it in the page's
287
+ chart file, not in a shared kind. See `apps/playground/src/demo/ChartsPage.tsx`'s
288
+ `SessionsRevenueChart` (a dual-axis line pair via `CartesianChart` + `y`/`y2`) and
289
+ `ChannelVolumeChart` (a role-grouped, `maxRows`-capped legend with a threshold-as-legend-entry
290
+ trick) for two real bespoke compositions.
291
+
292
+ **Anti-pattern:** a single `<Chart type="..." config={...} />` that switches by kind. Prefer N small
293
+ kinds.
164
294
 
165
- **Anti-pattern:** a single `<Chart type="..." config={...} />` that switches by kind. Prefer N small kinds.
295
+ ## Axis config (`AxisConfig<T>`)
296
+
297
+ One object per axis on `CartesianChart` and every kind that composes it — this collapses the
298
+ `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` / `numTicksY` /
299
+ `formatYTick` prop soup:
300
+
301
+ ```tsx
302
+ y={{
303
+ domain: 'auto', // 'auto' (default) | [min, max] | (data, visible) => [min, max]
304
+ autoMaxFloor: 100, // when 'auto': upper bound is at least this
305
+ autoMinCeil: 0, // when 'auto': lower bound is at most this (Infinity = pad from raw min)
306
+ autoPad: 1.1, // when 'auto': padding multiplier away from the data
307
+ ticks: 5,
308
+ format: (v) => `${v}%`,
309
+ grid: true, // horizontal grid rules; default on for `y`, off for `y2`
310
+ }}
311
+ ```
312
+
313
+ **Passing `y2` is what makes a chart dual-axis** — it draws the right axis and widens the right
314
+ margin by measurement; nothing else flips it on. A series opts into that axis with `axis: 'right'`
315
+ on its `ChartSeries` entry (`SeriesStyle.axis`, default `'left'`).
316
+
317
+ `domain` as a function is the seam a stacked chart uses: it receives `(data, visible)`, so a
318
+ domain summed from the VISIBLE series shrinks correctly when the legend toggles a band off
319
+ (`StackedArea`'s `yConfig` is the reference — summing `props.series` there instead would leave a
320
+ permanent gap above the stack once a band is hidden).
166
321
 
167
322
  ## Series color
168
323
 
@@ -183,6 +338,10 @@ shifts shade across schemes — that's why each entry is a `{ light, dark }` pai
183
338
  **Adding a new color** means adding a pair to your series map and rebuilding the palette CSS — never
184
339
  inline a hex in a chart.
185
340
 
341
+ A series that's invisible in the plot (flat at the domain floor, filtered to zero) can still carry
342
+ an explanation: `SeriesStyle.note` (and so `LegendEntry.note`, via `deriveLegend`) renders a short
343
+ muted qualifier after the legend label — e.g. _"Low cloud — 0% all night"_. Keep it to a clause.
344
+
186
345
  ## Dark/light mode
187
346
 
188
347
  Theme reactivity is **pure CSS**: the `--vx-*` variables are redeclared under the light/dark color
@@ -198,51 +357,44 @@ clutter). Keep stacked-area bands opaque — fading them leaks lower bands.
198
357
 
199
358
  ## Responsive sizing
200
359
 
201
- Use `ResponsiveChart` (render-prop container) or `useChartSize` (hook) from `basalt-ui/charts` —
202
- both are backed by `@visx/responsive`'s `useParentSize` and keep the `@visx/*` import inside a
203
- `charts/` directory per `basalt/visx-boundary`. Never reach for `@visx/responsive` directly or
204
- `useElementSize` from `@mantine/hooks` in a chart file.
360
+ There is exactly one responsive path now — `ChartFrame` (composed either directly, or via
361
+ `CartesianChart`, which composes it internally). `Heatmap` self-measures
362
+ the same way every other kind does, instead of taking `width`/`height` from a separate wrapper.
363
+ Every kind and `CartesianChart` itself resolve their size the same three ways, tried in this order:
205
364
 
206
365
  ```tsx
207
- import { ResponsiveChart, Bars } from 'basalt-ui/charts'
208
-
209
- // Preferred: render prop, renders nothing until the first measurement
210
- <ResponsiveChart height={320}>
211
- {({ width, height }) => (
212
- <Bars width={width} height={height} data={data} chartId="load" getX={…} getY={…} />
213
- )}
214
- </ResponsiveChart>
215
-
216
- // aspectRatio variant: height = width / ratio
217
- <ResponsiveChart aspectRatio={16 / 9}>
218
- {({ width, height }) => <MyChart width={width} height={height} />}
219
- </ResponsiveChart>
366
+ <Bars height={260} … /> // fixed height (most common)
367
+ <Bars aspectRatio={16 / 9} … /> // height = round(measured width / ratio)
368
+ <Bars fill … /> // fills the parent flex/grid cell's measured height
220
369
  ```
221
370
 
222
- Use `useChartSize` directly when you need the `ref` on a custom container:
371
+ Reach for `useChartSize` directly only when composing `ChartFrame`/`CartesianChart` yourself in a
372
+ bespoke chart and you need the measured `{ width, height }` for something beyond what those
373
+ primitives already do with it — `ChartFrame` already calls it internally for you in the common case.
374
+ `UseChartSizeResult`: `{ ref, width, height }`; attach `ref` to the container you want measured.
223
375
 
224
- ```ts
225
- import { useChartSize } from 'basalt-ui/charts'
226
-
227
- const { ref, width, height } = useChartSize()
228
- // attach ref to your container div; width/height update via ResizeObserver
229
- ```
376
+ ## Margin
230
377
 
231
- `ResponsiveChartProps`: `height` (default 240), `aspectRatio`, `debounceMs` (default 0), `children`.
232
- `UseChartSizeResult`: `{ ref, width, height }`.
378
+ Margins are **measured**, not hand-picked. `CartesianChart` runs every configured axis's tick
379
+ labels through `autoMargin`/`measureText` (`basalt-ui/charts`) before laying out the plot, so
380
+ `VX.margin` is a **floor**, not the value — a wide tick label (a long right-axis dollar format, a
381
+ rotated x label) widens its own gutter automatically instead of clipping or needing a hand-tuned
382
+ margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nudge at all:
233
383
 
234
- ## Migrating from pre-1.0 (`@argo/charts` era)
384
+ ```tsx
385
+ // now: nothing — passing y2 is what widens the right margin, measured from what's actually painted
386
+ <CartesianChart data={data} chartId="x" getX={…} series={SERIES} y={{…}} y2={{…}} />
387
+ ```
235
388
 
236
- Three silent renames/behavior changes trip up a consumer migrating off the pre-1.0 in-house charts
237
- package — all typecheck-clean only in a strict setup, so they're easy to miss in a quick port:
389
+ The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
390
+ override applied LAST, after measurement, so it always wins.
238
391
 
239
- - **`HoverCtx.date` → `HoverCtx.key`.** The hover context's cross-chart cursor field is generic
240
- now (not date-only) — rename the field, not just the type.
241
- - **`useHoverSync({ getX })` → `useHoverSync({ getKey })`.** Same generalization — pass an accessor
242
- returning the series key, not an x/date value.
243
- - **`legend.maxRows` renders a `+N more` chip** instead of silently truncating. A consumer that
244
- previously hand-rolled `.slice(0, N)` on its legend items gets a visible UX diff (a real overflow
245
- indicator) — drop the manual slice and let `maxRows` own it.
392
+ `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
393
+ but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
394
+ needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
395
+ don't use it today (neither renders a measured dual numeric axis), so treat it as a fallback, not the
396
+ default path. It returns `VX.margin` widened for `{ rightAxis: true }`, or any side overridden
397
+ directly; returns a new object per call, so memoize it at the call site if you do reach for it.
246
398
 
247
399
  ## Rule of thumb
248
400
 
@@ -114,8 +114,10 @@ Route components import chart primitives directly from `basalt-ui/charts` — ne
114
114
  as dead code against the floor.
115
115
  - **DatePickerInput**: from `@mantine/dates` (if you separately install it). In v9 it uses string
116
116
  values (`YYYY-MM-DD`) for `value`/`onChange` — no `Date` conversion.
117
- - **Responsive chart sizing**: use `ResponsiveChart` or `useChartSize` from `basalt-ui/charts`
118
- (see basalt-charts.md). Never `useElementSize` from `@mantine/hooks` in a chart file (Mantine is
117
+ - **Responsive chart sizing**: charts measure themselves — `CartesianChart`/`ChartFrame` do it,
118
+ including reserving the legend band, so pass `height` / `aspectRatio` / `fill` and nothing else
119
+ (see basalt-charts.md). `useChartSize` from `basalt-ui/charts` is for a non-chart box that needs
120
+ measuring. Never `useElementSize` from `@mantine/hooks` in a chart file (Mantine is
119
121
  banned inside `charts/**`), and never raw `@visx/responsive` outside `charts/**`.
120
122
 
121
123
  ## Strict surfaces & layout primitives