basalt-ui 1.30.1 → 1.31.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 (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +38 -0
  3. package/MIGRATING.md +218 -14
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +54 -41
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +145 -131
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +31 -17
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +149 -32
  68. package/dist/charts/primitives/ChartLegend.d.ts +20 -14
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -112
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +83 -31
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +22 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +20 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +272 -53
  252. package/src/charts/primitives/ChartLegend.tsx +258 -72
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +227 -156
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +39 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -1,33 +1,33 @@
1
1
  import type { CSSProperties, ReactNode } from 'react'
2
- import { useCallback, useState } from 'react'
2
+ import { useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'
3
+ import { createPortal } from 'react-dom'
3
4
  import type { BasaltProps } from '../../common/props'
5
+ import {
6
+ deprecatedProp,
7
+ ignoredProp,
8
+ legendSlotContention,
9
+ plotBelowFloor,
10
+ } from '../../common/errors'
11
+ import { reportOnce, useValidateProps } from '../../common/validate'
12
+ import { SizeClassHintContext, useSizeClass } from '../../shell/use-size-class'
4
13
  import { VX } from '../../tokens'
5
14
  import { useChartSize } from '../hooks/useChartSize'
6
15
  import { deriveLegend } from '../series'
7
16
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
8
- import {
9
- resolveChartTier,
10
- resolveFrameHeight,
11
- resolveLegendRollup,
12
- resolvePlotRect,
13
- } from './chart-frame-layout'
17
+ import { ChartCardContext } from './chart-card-context'
18
+ import { resolveChartLayout, resolveFrameClass } from './chart-layout'
19
+ import { resolveFrameHeight, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
14
20
  import type { ResponsiveChartHeight } from './chart-frame-layout'
15
- import { ChartTierProvider } from './chart-tier'
21
+ import { ChartTierProvider, useCoarsePointer, useViewportHeight } from './chart-tier'
16
22
  import { ChartLegend } from './ChartLegend'
17
23
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
18
24
  import type { ChartState } from './ChartPending'
19
25
 
20
26
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
21
27
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
22
- export {
23
- legendEntryCap,
24
- resolveLegendRollup,
25
- resolvePlotRect,
26
- resolveChartTier,
27
- } from './chart-frame-layout'
28
+ export { legendEntryCap, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
28
29
  export type { ResponsiveChartHeight } from './chart-frame-layout'
29
30
 
30
- const DEFAULT_HEIGHT = 240
31
31
  const DEFAULT_MIN_WIDTH = 200
32
32
 
33
33
  /** Legend configuration for {@link ChartFrame}. Omit entirely (or pass `{}`) for the default
@@ -36,16 +36,12 @@ export type ChartFrameLegend = {
36
36
  /** Default 'bottom'. */
37
37
  placement?: LegendPlacement
38
38
  /**
39
- * Wrap cap → "+N more" rollup at high cardinality.
39
+ * @deprecated Removed in 1.32.0 — the legend now fits by measured width every time and folds
40
+ * overflow into an `All N` disclosure (`docs/CHARTS-SPEC.md` §8); drop the prop.
40
41
  *
41
- * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
42
- * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
43
- * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
44
- * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
45
- * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
46
- * number a caller stated, so under `fill` the PLOT yields the height instead
47
- * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
48
- * gets what is left — visible, and yours.
42
+ * Until then an explicit value still WINS OUTRIGHT over the measured fit, and it counts ENTRIES,
43
+ * not rows: `maxRows: 3` on a 7-entry legend renders three entries. Under `fill` the PLOT yields
44
+ * the height ({@link resolvePlotRect}'s `legendWins`).
49
45
  */
50
46
  maxRows?: number
51
47
  /** Visually separate role: series | overlay | reference. */
@@ -60,22 +56,36 @@ export type ChartFrameLegend = {
60
56
  * plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
61
57
  */
62
58
  toggle?: boolean
59
+ /**
60
+ * Internal escape hatch for a kind that never wants its legend portalled into a `ChartCard`
61
+ * header, whatever the placement — `Donut` sets this (`docs/waves/PLAN.md` wave 3, P1-4): its
62
+ * side/bottom legend is the ring's only key, so it must stay under or beside the ring rather than
63
+ * follow every other frame's default of claiming an available header slot. `false` keeps the
64
+ * legend in its own band regardless of `placement`; omitted (default) behaves as today. Not
65
+ * something a consumer composing a kind's own `legend` prop can reach — kinds compose `ChartFrame`
66
+ * directly to set it.
67
+ */
68
+ headerSlot?: boolean
63
69
  }
64
70
 
65
71
  export type ChartFrameProps = BasaltProps & {
66
72
  /** Series identity — drives the derived legend. Pass the SAME array the kind draws + tooltips from. */
67
73
  series: readonly SeriesStyle[]
68
74
  /**
69
- * Height in pixels, or one per size step. Used when neither `aspectRatio` nor `fill` is set.
70
- * Default 240.
75
+ * Height in pixels, or one per container step. Used when neither `aspectRatio` nor `fill` is set.
76
+ * Default: derived from the measured width and container class (`resolveChartLayout`). Inside a
77
+ * `ChartCard` the resulting height is a MINIMUM — the frame grows into a taller card body. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
78
+ * this is never read, which now warns once in dev (`common/errors.ts`' `ignoredProp`).
71
79
  *
72
- * `{ base: 180, md: 260 }` is the declarative escape from a page of fixed-height charts: the
73
- * steps are compared against the frame's own MEASURED width, not the viewport, so a chart squeezed
74
- * by a `PageAside` gets the short height on a desktop too. A plain number is unchanged and stays
75
- * supported ({@link resolveFrameHeight}).
80
+ * `{ base: 180, wide: 260 }` is the declarative escape from a page of fixed-height charts: the
81
+ * `regular`/`wide` steps are compared against the frame's own MEASURED width, not the viewport,
82
+ * so a chart squeezed by a `PageAside` gets the short height on a desktop too. A plain number is
83
+ * an override and stays supported ({@link resolveFrameHeight}); one that leaves the plot under
84
+ * `VX.minPlotHeight` warns once in dev.
76
85
  */
77
86
  height?: number | ResponsiveChartHeight
78
- /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set. */
87
+ /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set — which warns
88
+ * once in dev, for the same reason `height` does. */
79
89
  aspectRatio?: number
80
90
  /** Fill the parent flex/grid cell's measured height instead of a fixed/derived one. */
81
91
  fill?: boolean
@@ -126,6 +136,10 @@ export type PlotRect = {
126
136
  width: number
127
137
  height: number
128
138
  hidden: ReadonlySet<string>
139
+ /** Whether THIS frame's legend is currently portalled into a `ChartCard` header slot — a chart
140
+ * composing `ChartFrame` (e.g. `CartesianChart`'s end labels, R2C-10) reads this to skip a
141
+ * band-legend-only affordance once the header already names every series for free. */
142
+ legendInHeader: boolean
129
143
  }
130
144
 
131
145
  const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
@@ -209,6 +223,104 @@ export function ChartFrame({
209
223
  }: ChartFrameProps): ReactNode {
210
224
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
211
225
  const { ref: legendRef, width: legendW, height: legendH } = useChartSize()
226
+ const { ref: slotRef, width: slotMeasuredW } = useChartSize()
227
+ const {
228
+ inCard,
229
+ legendSlot,
230
+ short: cardShort,
231
+ claimLegendSlot,
232
+ releaseLegendSlot,
233
+ legendSlotVersion,
234
+ } = useContext(ChartCardContext)
235
+ const frameId = useId()
236
+ const [ownsSlot, setOwnsSlot] = useState(false)
237
+ const [slotSeedW, setSlotSeedW] = useState(0)
238
+ // Mirrors `ownsSlot` synchronously so the retry effect below can read "do I currently own the
239
+ // slot" without waiting a render for the state to catch up — see the two effects' own doc.
240
+ const ownsSlotRef = useRef(false)
241
+ const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
242
+ const vertical = placement === 'left' || placement === 'right'
243
+ // `Donut` sets `headerSlot: false` (P1-4) — it never wants the slot, so it must never CLAIM it
244
+ // either, or it would silently deny a sibling frame in the same card that does want it. A
245
+ // vertical (left/right) legend never portals into the header either (`inHeader` below requires
246
+ // `!vertical`), so it must not claim the slot from a sibling that could actually use it.
247
+ const wantsHeaderSlot = legend !== false && legend.headerSlot !== false && !vertical
248
+ // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
249
+ // legend naming a series with nothing to point at is its own small lie. Computed here (ahead of
250
+ // the claim effect below) only because that effect's own dev warning needs it.
251
+ const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
252
+ const legendVisible = legend !== false && resolvedState === null
253
+ // The card's header slot is observed like the frame is: its width is what a header legend fits.
254
+ // Only the frame that CLAIMS the slot (first to mount, P2-9) observes it or seeds its width — a
255
+ // second frame in the same card never measures a slot it will not portal into. The contention
256
+ // warning is reported HERE, from the deterministic `owns` this effect just computed, rather than
257
+ // as a separate reactive check on `ownsSlot` state — that state takes one extra render to catch up
258
+ // with the claim, and a passive effect reacting to the interim (stale) value warned about frames
259
+ // that went on to win the claim one render later.
260
+ // One claim attempt, shared by the owner effect below (real identity changes) and the
261
+ // denied-frame retry effect further down (a sibling's release). Reads `legendVisible` at CALL
262
+ // time through the closure, not through a dependency array of its own effect, so the two callers
263
+ // stay free to key on whatever triggers each of them.
264
+ const tryClaim = useCallback((): boolean => {
265
+ if (legendSlot === null || !wantsHeaderSlot) return false
266
+ const owns = claimLegendSlot(frameId)
267
+ ownsSlotRef.current = owns
268
+ setOwnsSlot(owns)
269
+ if (!owns) {
270
+ setSlotSeedW(0)
271
+ if (legendVisible) reportOnce('ChartFrame', legendSlotContention('ChartFrame'))
272
+ return false
273
+ }
274
+ // Seeded synchronously (P2-8) so the very first commit already knows the slot's real width,
275
+ // instead of assuming 0 until the ResizeObserver's first (asynchronous) callback — which is
276
+ // what let a header legend paint once in the band before jumping to the header on every mount.
277
+ setSlotSeedW(legendSlot.getBoundingClientRect().width)
278
+ slotRef(legendSlot)
279
+ return true
280
+ }, [legendSlot, wantsHeaderSlot, legendVisible, frameId, claimLegendSlot, slotRef])
281
+
282
+ // Releases the slot only if THIS frame currently owns it, read live off `ownsSlotRef` at
283
+ // cleanup time rather than a snapshot closed over when the effect ran — so it correctly releases
284
+ // whichever effect (this one's own claim, or the retry effect's) most recently won it.
285
+ const releaseIfOwned = useCallback(() => {
286
+ if (!ownsSlotRef.current) return
287
+ ownsSlotRef.current = false
288
+ releaseLegendSlot(frameId)
289
+ slotRef(null)
290
+ }, [releaseLegendSlot, frameId, slotRef])
291
+
292
+ // The OWNER effect: claims/releases only on a real identity change (the slot itself, whether this
293
+ // frame even wants one, or a remount) — never on `legendSlotVersion`. A prior version keyed this
294
+ // effect on `legendSlotVersion` too, so the owner's own cleanup (which unconditionally released)
295
+ // bumped the version, which re-ran this very effect, which released again — an infinite
296
+ // release → bump → re-run loop that surfaced as "Maximum update depth exceeded" under StrictMode
297
+ // replay or a `legendVisible` flip. Only a DENIED frame reacts to the version now (below).
298
+ useLayoutEffect(() => {
299
+ if (legendSlot === null || !wantsHeaderSlot) {
300
+ setOwnsSlot(false)
301
+ setSlotSeedW(0)
302
+ return undefined
303
+ }
304
+ tryClaim()
305
+ return releaseIfOwned
306
+ }, [legendSlot, wantsHeaderSlot, tryClaim, releaseIfOwned])
307
+
308
+ // The DENIED-frame retry: fires only when the slot's ownership changes elsewhere
309
+ // (`legendSlotVersion` bumps on a release). A frame that already owns the slot short-circuits via
310
+ // `ownsSlotRef` — this effect never releases what it claims; the owner effect's `releaseIfOwned`
311
+ // cleanup (keyed on stable identity, not on this version) is the one place release happens, for
312
+ // whichever frame currently holds the slot.
313
+ useLayoutEffect(() => {
314
+ if (ownsSlotRef.current) return
315
+ tryClaim()
316
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
317
+ }, [legendSlotVersion])
318
+ const viewportClass = useSizeClass()
319
+ // No `BasaltProvider` (a charts-only consumer) means no viewport hint: the unmeasured first frame
320
+ // resolves to the regular (desktop) class, not phone chrome.
321
+ const sizeClass = useContext(SizeClassHintContext) === undefined ? 'medium' : viewportClass
322
+ const coarse = useCoarsePointer()
323
+ const viewportH = useViewportHeight()
212
324
  const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
213
325
 
214
326
  const toggleKey = useCallback((key: string) => {
@@ -220,24 +332,116 @@ export function ChartFrame({
220
332
  })
221
333
  }, [])
222
334
 
223
- const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
224
- const vertical = placement === 'left' || placement === 'right'
225
- // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
226
- // legend naming a series with nothing to point at is its own small lie.
227
- const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
228
- const legendVisible = legend !== false && resolvedState === null
229
- const tier = resolveChartTier(containerW)
335
+ // The three sizing props are mutually exclusive and `fill` wins, silently — the type cannot say
336
+ // so without a union prop, which is a bigger change than a patch carries. Warn instead: a
337
+ // consumer passed `height` alongside `fill` for the life of a file and kept eight height
338
+ // constants (and a whole `compact` branch) that never moved a pixel, with nothing from tsc,
339
+ // oxlint or check-theme saying a word.
340
+ useValidateProps(
341
+ 'ChartFrame',
342
+ () => [
343
+ fill && height !== undefined
344
+ ? ignoredProp(
345
+ 'ChartFrame',
346
+ 'height',
347
+ "`fill` is set — a fill frame takes its parent cell's measured height. Pass one or the other.",
348
+ )
349
+ : null,
350
+ fill && aspectRatio !== undefined
351
+ ? ignoredProp(
352
+ 'ChartFrame',
353
+ 'aspectRatio',
354
+ "`fill` is set — a fill frame takes its parent cell's measured height. Pass one or the other.",
355
+ )
356
+ : null,
357
+ ],
358
+ [fill, height, aspectRatio],
359
+ )
360
+ const legendItems = legend === false ? [] : deriveLegend(series)
361
+ useValidateProps(
362
+ 'ChartFrame',
363
+ () =>
364
+ // A cap covering every entry rolls nothing up — it is how `Donut` states "never roll up".
365
+ legend !== false && legend.maxRows !== undefined && legend.maxRows < legendItems.length
366
+ ? deprecatedProp(
367
+ 'ChartFrame',
368
+ 'legend.maxRows',
369
+ 'nothing (the legend fits by measured width)',
370
+ '1.32.0',
371
+ 'Overflow folds into an `All N` disclosure; an explicit value still wins this release.',
372
+ )
373
+ : null,
374
+ [legend, legendItems.length],
375
+ )
376
+ const statedHeight =
377
+ height === undefined
378
+ ? undefined
379
+ : resolveFrameHeight(height, resolveFrameClass({ frameW: containerW, sizeClass }))
380
+ // Memoized on scalars (the legend by its keys+labels) so `ChartTierProvider`'s value is stable
381
+ // across renders that change nothing. No slot, not the owner, or not measured yet → `slotW` 0 →
382
+ // a band. `slotSeedW` (P2-8) covers the gap before the ResizeObserver's own first callback.
383
+ const slotW = legendSlot === null || !ownsSlot ? 0 : Math.max(slotMeasuredW, slotSeedW)
384
+ const groups = legend !== false && legend.groups === true
385
+ // Role and note both feed the resolver (`orderEntries`'s grouping, `legendEntryWidth`'s measured
386
+ // fit), so a change to either — a series moving `role`, a note appearing/disappearing — has to
387
+ // invalidate this memo exactly like a key or label change does; keying on key+label alone missed
388
+ // both and served a stale layout until something else happened to change first.
389
+ const legendKey = legendItems
390
+ .map((item) => `${item.key}\u0000${item.label}\u0000${item.role ?? ''}\u0000${item.note ?? ''}`)
391
+ .join('\u0001')
392
+ const layout = useMemo(
393
+ () =>
394
+ resolveChartLayout({
395
+ frameW: containerW,
396
+ slotW,
397
+ viewportH,
398
+ legendItems,
399
+ sizeClass,
400
+ coarse,
401
+ cardShort,
402
+ groups,
403
+ ...(statedHeight !== undefined && { override: { height: statedHeight } }),
404
+ }),
405
+ // `legendItems` is a fresh array per render; `legendKey` is its identity.
406
+ // oxlint-disable-next-line react-hooks/exhaustive-deps
407
+ [containerW, slotW, viewportH, legendKey, sizeClass, coarse, cardShort, groups, statedHeight],
408
+ )
409
+
410
+ // The resolver gates every placement: none at micro, else the card's header slot when there is
411
+ // one AND this frame owns it (P2-9) — `slotW` is already 0 for a non-owner or a `headerSlot:
412
+ // false` kind (P1-4), so `fit.where` already reads 'band' for both; `ownsSlot` here is only the
413
+ // (harmless) belt to that suspenders.
414
+ const fit = layout.legend.mode === 'dots' || layout.legend.mode === 'chips' ? layout.legend : null
415
+ const legendMode = fit?.mode
416
+ const showLegend = legendVisible && fit !== null
417
+ const inHeader =
418
+ showLegend && !vertical && legendSlot !== null && ownsSlot && fit?.where === 'header'
419
+ const sideLegendWidth = showLegend && vertical ? legendW : 0
420
+ const topBottomLegendHeight = showLegend && !vertical && !inHeader ? legendH : 0
230
421
 
422
+ const computedHeight =
423
+ aspectRatio !== undefined ? Math.round(containerW / aspectRatio) : layout.height
424
+ // In a card the computed height is a floor: a stretched grid row hands the body more, and the
425
+ // frame (flex 1 in the body) takes it. Never below what the legend band plus the plot floor need.
426
+ const grows = inCard && !fill
231
427
  const resolvedHeight = fill
232
428
  ? containerH
233
- : aspectRatio !== undefined
234
- ? Math.round(containerW / aspectRatio)
235
- : resolveFrameHeight(height ?? DEFAULT_HEIGHT, containerW)
429
+ : grows
430
+ ? Math.max(computedHeight, containerH)
431
+ : computedHeight
432
+ const cardMinHeight = Math.max(computedHeight, VX.minPlotHeight + topBottomLegendHeight)
236
433
 
237
- const sideLegendWidth = legendVisible && vertical ? legendW : 0
238
- const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
239
-
240
- const legendItems = legend === false ? [] : deriveLegend(series)
434
+ useValidateProps(
435
+ 'ChartFrame',
436
+ () =>
437
+ typeof height === 'number' &&
438
+ !fill &&
439
+ aspectRatio === undefined &&
440
+ height - topBottomLegendHeight < VX.minPlotHeight
441
+ ? plotBelowFloor('ChartFrame', height, VX.minPlotHeight)
442
+ : null,
443
+ [height, fill, aspectRatio, topBottomLegendHeight],
444
+ )
241
445
  const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
242
446
 
243
447
  // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
@@ -247,8 +451,8 @@ export function ChartFrame({
247
451
  // plot pays for. Top/bottom only: a side legend costs width, not height.
248
452
  const { maxRows, legendWins } = resolveLegendRollup({
249
453
  ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
250
- tier,
251
- fillBand: legendVisible && fill && !vertical,
454
+ ...(!vertical && fit !== null && fit.overflow > 0 && { fittedMaxRows: fit.visible }),
455
+ fillBand: showLegend && fill && !inHeader && !vertical,
252
456
  items: legendItems,
253
457
  containerW,
254
458
  available: resolvedHeight - VX.minPlotHeight,
@@ -264,7 +468,7 @@ export function ChartFrame({
264
468
  })
265
469
 
266
470
  const legendNode =
267
- legend === false || resolvedState !== null ? null : (
471
+ legend === false || resolvedState !== null || !showLegend ? null : (
268
472
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
269
473
  <ChartLegend
270
474
  items={legendItems}
@@ -274,6 +478,7 @@ export function ChartFrame({
274
478
  {...(chartId !== undefined && { chartId })}
275
479
  {...(legend.groups !== undefined && { groups: legend.groups })}
276
480
  {...(maxRows !== undefined && { maxRows })}
481
+ {...(legendMode !== undefined && { mode: legendMode })}
277
482
  {...(legend.highlighted !== undefined && { highlighted: legend.highlighted })}
278
483
  {...(legend.onHighlight !== undefined && { onHighlight: legend.onHighlight })}
279
484
  />
@@ -281,15 +486,26 @@ export function ChartFrame({
281
486
  )
282
487
 
283
488
  return (
284
- <ChartTierProvider tier={tier}>
489
+ <ChartTierProvider containerClass={layout.containerClass}>
285
490
  <div
286
491
  ref={containerRef}
287
492
  {...(className !== undefined && { className })}
288
- style={{ ...outerStyle(fill, vertical), ...style }}
493
+ style={{
494
+ ...outerStyle(fill, vertical),
495
+ ...(grows && { flex: '1 1 0', minHeight: cardMinHeight }),
496
+ ...style,
497
+ }}
289
498
  {...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
290
499
  {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
291
500
  >
292
- {legendNode !== null && (placement === 'top' || placement === 'left') && legendNode}
501
+ {inHeader &&
502
+ legendNode !== null &&
503
+ legendSlot !== null &&
504
+ createPortal(legendNode, legendSlot)}
505
+ {!inHeader &&
506
+ legendNode !== null &&
507
+ (placement === 'top' || placement === 'left') &&
508
+ legendNode}
293
509
  {plot.width > 0 &&
294
510
  plot.height > 0 &&
295
511
  (resolvedState === 'pending' ? (
@@ -303,9 +519,12 @@ export function ChartFrame({
303
519
  {...(typeof state?.empty === 'string' && { label: state.empty })}
304
520
  />
305
521
  ) : (
306
- children({ ...plot, hidden })
522
+ children({ ...plot, hidden, legendInHeader: inHeader })
307
523
  ))}
308
- {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
524
+ {!inHeader &&
525
+ legendNode !== null &&
526
+ (placement === 'bottom' || placement === 'right') &&
527
+ legendNode}
309
528
  </div>
310
529
  </ChartTierProvider>
311
530
  )