basalt-ui 1.30.2 → 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 +31 -0
  3. package/MIGRATING.md +172 -12
  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 +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  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 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  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 +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -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 +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  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 +214 -177
  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 +25 -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
@@ -12,15 +12,43 @@
12
12
  * The header renders only when at least one of title/info/value/actions/icon/count is set — ending
13
13
  * the `''`-as-hidden-header sentinel a consumer used to reach for otherwise.
14
14
  */
15
+ import { useCallback, useMemo, useRef, useState } from 'react'
15
16
  import type { CSSProperties, ReactNode } from 'react'
16
17
  import { cx } from '../../common/props'
17
18
  import type { BasaltProps, SlotStylesProps } from '../../common/props'
18
19
  import { VX } from '../../tokens'
19
20
  import { WidgetHeader } from '../../dashboard/widget-header'
20
21
  import type { DeltaPolarity } from '../../dashboard/delta-badge'
22
+ import { useChartSize } from '../hooks/useChartSize'
23
+ import { useHysteresis } from '../hooks/use-hysteresis'
24
+ import { ChartCardContext } from './chart-card-context'
21
25
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
22
26
  import type { ChartState } from './ChartPending'
23
27
 
28
+ /**
29
+ * Below this rendered height the header folds its subtitle regardless of width (wave 8's
30
+ * header-economy law, `docs/CHARTS-SPEC.md` §5) — a short-but-wide card (a KPI-style
31
+ * chart card) still needs the vertical space back. A `@container basalt-card` SIZE query would be
32
+ * the natural expression, but `container-type: size` on a box with no explicit height (the common,
33
+ * non-grid-stretched case) collapses it to 0 — CSS containment requires a definite size in the
34
+ * contained axis, and a ChartCard's height is ordinarily derived from its content (VERIFIED: making
35
+ * the root a size container drops `card-chrome.layout.test.ts`'s unstretched short-card fixture to
36
+ * a 0px box). `useChartSize` (the same measuring primitive `ChartFrame` uses) reads the rendered
37
+ * height instead, and `data-basalt-card-short` is the CSS hook `widget-header.module.css` reacts to
38
+ * with the same subtitle-fold declarations as the width query.
39
+ */
40
+ const CARD_SHORT_HEIGHT = 280
41
+ /**
42
+ * Hysteresis band on the flag above (`docs/waves/PLAN.md` wave 3, P0-1). `short` folds the header,
43
+ * which changes the card's OWN measured height by the fold delta — a card whose unfolded height
44
+ * lands in `[280, 280 + Δ)` is bistable, and the async RO → rAF → debounce → setState → commit path
45
+ * can sit one reading behind, which turned the bistability into a perpetual flip-flop on the
46
+ * "Sales by channel" dashboard card (27-52 times per 2s, every viewport). Entering short still
47
+ * happens below `CARD_SHORT_HEIGHT`; LEAVING it needs `CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK`, sized
48
+ * to clear the largest fold delta (subtitle line + inset + value row) with headroom.
49
+ */
50
+ const SHORT_EXIT_SLACK = 24
51
+
24
52
  // Surfaces resolve per theme via CSS vars, so the styles are static (no useMemo/isDark).
25
53
  // Depth = `shadow-card` (a whisper shadow + a 1px ring baked into the same value), never a
26
54
  // `border` property (docs/DESIGN-SPEC.md §5) — so a ChartCard reads IDENTICALLY to a Mantine
@@ -31,7 +59,16 @@ import type { ChartState } from './ChartPending'
31
59
  // `WidgetHeader`'s info bubble (`widget-header.module.css`'s `.infoBubble`, which is where the
32
60
  // tooltip this file used to own now lives) opens downward past the header's own box, and must be
33
61
  // able to overhang the card edge without being invisibly cut off.
62
+ //
63
+ // The root is a flex column and a `basalt-card` inline-size container (the card's own width drives
64
+ // its header chrome, `docs/CHARTS-SPEC.md` §8): the body takes the row's spare height when a grid stretches
65
+ // the card.
34
66
  const cardStyle: CSSProperties = {
67
+ display: 'flex',
68
+ flexDirection: 'column',
69
+ minHeight: 0,
70
+ containerType: 'inline-size',
71
+ containerName: 'basalt-card',
35
72
  borderRadius: VX.radiusCard,
36
73
  boxShadow: VX.shadowCard,
37
74
  backgroundColor: VX.surface.panel,
@@ -45,6 +82,11 @@ const bodyClipStyle: CSSProperties = {
45
82
  borderBottomLeftRadius: VX.radiusCard,
46
83
  borderBottomRightRadius: VX.radiusCard,
47
84
  overflow: 'hidden',
85
+ flex: '1 1 auto',
86
+ minHeight: 0,
87
+ // A column, so a `ChartFrame` inside can grow into the body a stretched grid row hands the card.
88
+ display: 'flex',
89
+ flexDirection: 'column',
48
90
  padding: '2px var(--vx-space-card-inset-x, 0.8125rem) var(--vx-space-card-inset-y, 0.6875rem)',
49
91
  }
50
92
  // Card inset = the `cardInsetY`/`cardInsetX` role tokens (vertical / horizontal), the SAME two
@@ -60,6 +102,13 @@ const bodyClipStyle: CSSProperties = {
60
102
  const headerWrapStyle: CSSProperties = {
61
103
  padding: 'var(--vx-space-card-inset-y, 0.6875rem) var(--vx-space-card-inset-x, 0.8125rem) 4px',
62
104
  }
105
+ // A short card (wave 8) trims the header's own top inset — the subtitle line and the wide-tier
106
+ // value/delta row are both already gone by the time `short` is true, so the full card inset reads
107
+ // as dead air rather than rhythm. Bottom stays 4px (unchanged) so the header-to-body seam matches
108
+ // every other ChartCard.
109
+ const headerWrapShortStyle: CSSProperties = {
110
+ padding: '4px var(--vx-space-card-inset-x, 0.8125rem) 4px',
111
+ }
63
112
 
64
113
  /**
65
114
  * The three boxes a `ChartCard` paints, and therefore the slot set a consumer may class
@@ -143,6 +192,8 @@ export function ChartCard({
143
192
  style,
144
193
  children,
145
194
  }: ChartCardProps) {
195
+ const [legendSlot, setLegendSlot] = useState<HTMLDivElement | null>(null)
196
+ const { ref: sizeRef, height: measuredHeight } = useChartSize()
146
197
  const resolvedState = resolveChartState({ ...(state !== undefined && { state }) })
147
198
  const hasHeader =
148
199
  title !== undefined ||
@@ -151,59 +202,101 @@ export function ChartCard({
151
202
  actions !== undefined ||
152
203
  icon !== undefined ||
153
204
  count !== undefined
205
+ // Hysteresis (P0-1, `SHORT_EXIT_SLACK`'s own doc): which threshold applies depends on the
206
+ // PREVIOUS committed flag, so a card whose unfolded height sits in the bistable band settles
207
+ // instead of flip-flopping forever.
208
+ const short = useHysteresis(
209
+ (wasShort) =>
210
+ measuredHeight > 0 &&
211
+ measuredHeight < (wasShort ? CARD_SHORT_HEIGHT + SHORT_EXIT_SLACK : CARD_SHORT_HEIGHT),
212
+ false,
213
+ )
214
+
215
+ const legendOwnerRef = useRef<string | null>(null)
216
+ // Bumped on every release so a previously-denied sibling's claim effect (keyed on the context
217
+ // value it reads, see `chart-card-context.ts`) re-runs and gets a chance to re-claim the slot the
218
+ // owner just gave up — without it a frame told "no" once stayed denied even after the owner
219
+ // unmounted.
220
+ const [legendSlotVersion, setLegendSlotVersion] = useState(0)
221
+ const claimLegendSlot = useCallback((id: string) => {
222
+ if (legendOwnerRef.current === null) legendOwnerRef.current = id
223
+ return legendOwnerRef.current === id
224
+ }, [])
225
+ const releaseLegendSlot = useCallback((id: string) => {
226
+ if (legendOwnerRef.current === id) {
227
+ legendOwnerRef.current = null
228
+ setLegendSlotVersion((v) => v + 1)
229
+ }
230
+ }, [])
231
+ const context = useMemo(
232
+ () => ({
233
+ legendSlot,
234
+ inCard: true,
235
+ short,
236
+ claimLegendSlot,
237
+ releaseLegendSlot,
238
+ legendSlotVersion,
239
+ }),
240
+ [legendSlot, short, claimLegendSlot, releaseLegendSlot, legendSlotVersion],
241
+ )
154
242
 
155
243
  return (
156
- <div
157
- className={cx(classNames?.root, className)}
158
- style={{ ...cardStyle, ...style }}
159
- {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
160
- >
161
- {hasHeader && (
162
- <div
163
- {...(classNames?.header !== undefined && { className: classNames.header })}
164
- style={headerWrapStyle}
165
- >
166
- <WidgetHeader
167
- tier="widget"
168
- title={title ?? ''}
169
- {...(icon !== undefined && { icon })}
170
- {...(subtitle !== undefined && { subtitle })}
171
- {...(info !== undefined && { info })}
172
- {...(value !== undefined && { value })}
173
- {...(delta !== undefined && { delta })}
174
- {...(deltaPeriod !== undefined && { deltaPeriod })}
175
- {...(deltaPolarity !== undefined && { deltaPolarity })}
176
- {...(count !== undefined && { count })}
177
- {...(actions !== undefined && {
178
- actions: <span data-basalt-tier="widget">{actions}</span>,
179
- })}
180
- />
181
- </div>
182
- )}
244
+ <ChartCardContext.Provider value={context}>
183
245
  <div
184
- {...(classNames?.body !== undefined && { className: classNames.body })}
185
- style={bodyClipStyle}
246
+ ref={sizeRef}
247
+ className={cx(classNames?.root, className)}
248
+ style={{ ...cardStyle, ...style }}
249
+ {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
250
+ {...(short && { 'data-basalt-card-short': '' })}
186
251
  >
187
- {resolvedState === 'pending' ? (
188
- <ChartPending width="100%" height={placeholderHeight} />
189
- ) : resolvedState === 'error' ? (
190
- <ChartError
191
- width="100%"
192
- height={placeholderHeight}
193
- error={state?.error}
194
- action={stateAction}
195
- />
196
- ) : resolvedState === 'empty' ? (
197
- <ChartEmpty
198
- width="100%"
199
- height={placeholderHeight}
200
- {...(typeof state?.empty === 'string' && { label: state.empty })}
201
- action={stateAction}
202
- />
203
- ) : (
204
- children
252
+ {hasHeader && (
253
+ <div
254
+ {...(classNames?.header !== undefined && { className: classNames.header })}
255
+ style={short ? headerWrapShortStyle : headerWrapStyle}
256
+ >
257
+ <WidgetHeader
258
+ tier="widget"
259
+ title={title ?? ''}
260
+ {...(icon !== undefined && { icon })}
261
+ {...(subtitle !== undefined && { subtitle })}
262
+ {...(info !== undefined && { info })}
263
+ {...(value !== undefined && { value })}
264
+ {...(delta !== undefined && { delta })}
265
+ {...(deltaPeriod !== undefined && { deltaPeriod })}
266
+ {...(deltaPolarity !== undefined && { deltaPolarity })}
267
+ {...(count !== undefined && { count })}
268
+ {...(actions !== undefined && {
269
+ actions: <span data-basalt-tier="widget">{actions}</span>,
270
+ })}
271
+ />
272
+ <div ref={setLegendSlot} data-basalt-legend-slot="" />
273
+ </div>
205
274
  )}
275
+ <div
276
+ {...(classNames?.body !== undefined && { className: classNames.body })}
277
+ style={bodyClipStyle}
278
+ >
279
+ {resolvedState === 'pending' ? (
280
+ <ChartPending width="100%" height={placeholderHeight} />
281
+ ) : resolvedState === 'error' ? (
282
+ <ChartError
283
+ width="100%"
284
+ height={placeholderHeight}
285
+ error={state?.error}
286
+ action={stateAction}
287
+ />
288
+ ) : resolvedState === 'empty' ? (
289
+ <ChartEmpty
290
+ width="100%"
291
+ height={placeholderHeight}
292
+ {...(typeof state?.empty === 'string' && { label: state.empty })}
293
+ action={stateAction}
294
+ />
295
+ ) : (
296
+ children
297
+ )}
298
+ </div>
206
299
  </div>
207
- </div>
300
+ </ChartCardContext.Provider>
208
301
  )
209
302
  }
@@ -1,35 +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'
4
- import { ignoredProp } from '../../common/errors'
5
- import { useValidateProps } from '../../common/validate'
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'
6
13
  import { VX } from '../../tokens'
7
14
  import { useChartSize } from '../hooks/useChartSize'
8
15
  import { deriveLegend } from '../series'
9
16
  import type { ChartLegendConfig, LegendPlacement, SeriesStyle } from '../series'
10
- import {
11
- resolveChartTier,
12
- resolveFrameHeight,
13
- resolveLegendRollup,
14
- resolvePlotRect,
15
- } 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'
16
20
  import type { ResponsiveChartHeight } from './chart-frame-layout'
17
- import { ChartTierProvider } from './chart-tier'
21
+ import { ChartTierProvider, useCoarsePointer, useViewportHeight } from './chart-tier'
18
22
  import { ChartLegend } from './ChartLegend'
19
23
  import { ChartEmpty, ChartError, ChartPending, resolveChartState } from './ChartPending'
20
24
  import type { ChartState } from './ChartPending'
21
25
 
22
26
  /** Re-exported for `ChartFrame.test.tsx` and any other consumer that previously reached these
23
27
  * through `ChartFrame` — the layout math itself lives in `./chart-frame-layout` (pure, DOM-free). */
24
- export {
25
- legendEntryCap,
26
- resolveLegendRollup,
27
- resolvePlotRect,
28
- resolveChartTier,
29
- } from './chart-frame-layout'
28
+ export { legendEntryCap, resolveLegendRollup, resolvePlotRect } from './chart-frame-layout'
30
29
  export type { ResponsiveChartHeight } from './chart-frame-layout'
31
30
 
32
- const DEFAULT_HEIGHT = 240
33
31
  const DEFAULT_MIN_WIDTH = 200
34
32
 
35
33
  /** Legend configuration for {@link ChartFrame}. Omit entirely (or pass `{}`) for the default
@@ -38,22 +36,12 @@ export type ChartFrameLegend = {
38
36
  /** Default 'bottom'. */
39
37
  placement?: LegendPlacement
40
38
  /**
41
- * Rollup cap at high cardinality: the legend renders this many ENTRIES and folds the remainder
42
- * into a `+N more` disclosure chip.
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.
43
41
  *
44
- * **It counts entries, not rows** — the name is inherited and now load-bearing across
45
- * `ChartTierMetrics.legendMaxRows`, so it is not renamed in a patch. `maxRows: 3` on a 7-entry
46
- * legend renders three entries, which is ONE row at any width they fit on. `ChartLegend` slices
47
- * (`entries.slice(0, cap)`); nothing here measures rows.
48
- *
49
- * An explicit value WINS OUTRIGHT — over the phone tier's two-row default (`chartTierMetrics`)
50
- * AND over a `fill` frame's measured fit (`legendEntryCap`). 1.30.0 fixed only the first half:
51
- * the fit still ran `Math.min(fitted, caller)` on top, so meteo's 7-entry meteogram legend in a
52
- * 150px docked row rendered 2 entries at `maxRows` 3, 6 AND 99 — five series drawn in colours
53
- * nothing named. Both losers are DEFAULTS (the tier's, and `VX.minPlotHeight`); this is the one
54
- * number a caller stated, so under `fill` the PLOT yields the height instead
55
- * ({@link resolvePlotRect}'s `legendWins`). State a number too large for the box and the plot
56
- * 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`).
57
45
  */
58
46
  maxRows?: number
59
47
  /** Visually separate role: series | overlay | reference. */
@@ -68,20 +56,32 @@ export type ChartFrameLegend = {
68
56
  * plot, the tooltip, and the auto domain together (`docs/CHARTS-SPEC.md` §5).
69
57
  */
70
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
71
69
  }
72
70
 
73
71
  export type ChartFrameProps = BasaltProps & {
74
72
  /** Series identity — drives the derived legend. Pass the SAME array the kind draws + tooltips from. */
75
73
  series: readonly SeriesStyle[]
76
74
  /**
77
- * Height in pixels, or one per size step. Used when neither `aspectRatio` nor `fill` is set.
78
- * Default 240. Passing it WITH `fill` is a wiring mistake, not a fallback — `fill` wins and
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
79
78
  * this is never read, which now warns once in dev (`common/errors.ts`' `ignoredProp`).
80
79
  *
81
- * `{ base: 180, md: 260 }` is the declarative escape from a page of fixed-height charts: the
82
- * steps are compared against the frame's own MEASURED width, not the viewport, so a chart squeezed
83
- * by a `PageAside` gets the short height on a desktop too. A plain number is unchanged and stays
84
- * 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.
85
85
  */
86
86
  height?: number | ResponsiveChartHeight
87
87
  /** height = Math.round(containerWidth / aspectRatio). Ignored when `fill` is set — which warns
@@ -136,6 +136,10 @@ export type PlotRect = {
136
136
  width: number
137
137
  height: number
138
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
139
143
  }
140
144
 
141
145
  const outerStyle = (fill: boolean, vertical: boolean): CSSProperties => ({
@@ -219,6 +223,104 @@ export function ChartFrame({
219
223
  }: ChartFrameProps): ReactNode {
220
224
  const { ref: containerRef, width: containerW, height: containerH } = useChartSize()
221
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()
222
324
  const [hidden, setHidden] = useState<ReadonlySet<string>>(() => new Set())
223
325
 
224
326
  const toggleKey = useCallback((key: string) => {
@@ -255,25 +357,91 @@ export function ChartFrame({
255
357
  ],
256
358
  [fill, height, aspectRatio],
257
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
+ )
258
409
 
259
- const placement = legend === false ? 'bottom' : (legend.placement ?? 'bottom')
260
- const vertical = placement === 'left' || placement === 'right'
261
- // Every non-null state replaces the plot, and all three suppress the legend for one reason: a
262
- // legend naming a series with nothing to point at is its own small lie.
263
- const resolvedState = resolveChartState({ ...(state !== undefined && { state }), isPending })
264
- const legendVisible = legend !== false && resolvedState === null
265
- const tier = resolveChartTier(containerW)
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
266
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
267
427
  const resolvedHeight = fill
268
428
  ? containerH
269
- : aspectRatio !== undefined
270
- ? Math.round(containerW / aspectRatio)
271
- : resolveFrameHeight(height ?? DEFAULT_HEIGHT, containerW)
429
+ : grows
430
+ ? Math.max(computedHeight, containerH)
431
+ : computedHeight
432
+ const cardMinHeight = Math.max(computedHeight, VX.minPlotHeight + topBottomLegendHeight)
272
433
 
273
- const sideLegendWidth = legendVisible && vertical ? legendW : 0
274
- const topBottomLegendHeight = legendVisible && !vertical ? legendH : 0
275
-
276
- 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
+ )
277
445
  const togglable = legend !== false && (legend.toggle ?? legendItems.length > 1)
278
446
 
279
447
  // The cap, and who pays for it — one pure decision ({@link resolveLegendRollup} states the three
@@ -283,8 +451,8 @@ export function ChartFrame({
283
451
  // plot pays for. Top/bottom only: a side legend costs width, not height.
284
452
  const { maxRows, legendWins } = resolveLegendRollup({
285
453
  ...(legend !== false && legend.maxRows !== undefined && { statedMaxRows: legend.maxRows }),
286
- tier,
287
- fillBand: legendVisible && fill && !vertical,
454
+ ...(!vertical && fit !== null && fit.overflow > 0 && { fittedMaxRows: fit.visible }),
455
+ fillBand: showLegend && fill && !inHeader && !vertical,
288
456
  items: legendItems,
289
457
  containerW,
290
458
  available: resolvedHeight - VX.minPlotHeight,
@@ -300,7 +468,7 @@ export function ChartFrame({
300
468
  })
301
469
 
302
470
  const legendNode =
303
- legend === false || resolvedState !== null ? null : (
471
+ legend === false || resolvedState !== null || !showLegend ? null : (
304
472
  <div ref={legendRef} style={legendWrapperStyle(vertical)}>
305
473
  <ChartLegend
306
474
  items={legendItems}
@@ -310,6 +478,7 @@ export function ChartFrame({
310
478
  {...(chartId !== undefined && { chartId })}
311
479
  {...(legend.groups !== undefined && { groups: legend.groups })}
312
480
  {...(maxRows !== undefined && { maxRows })}
481
+ {...(legendMode !== undefined && { mode: legendMode })}
313
482
  {...(legend.highlighted !== undefined && { highlighted: legend.highlighted })}
314
483
  {...(legend.onHighlight !== undefined && { onHighlight: legend.onHighlight })}
315
484
  />
@@ -317,15 +486,26 @@ export function ChartFrame({
317
486
  )
318
487
 
319
488
  return (
320
- <ChartTierProvider tier={tier}>
489
+ <ChartTierProvider containerClass={layout.containerClass}>
321
490
  <div
322
491
  ref={containerRef}
323
492
  {...(className !== undefined && { className })}
324
- style={{ ...outerStyle(fill, vertical), ...style }}
493
+ style={{
494
+ ...outerStyle(fill, vertical),
495
+ ...(grows && { flex: '1 1 0', minHeight: cardMinHeight }),
496
+ ...style,
497
+ }}
325
498
  {...(ariaLabel !== undefined && { role: 'group', 'aria-label': ariaLabel })}
326
499
  {...(resolvedState === 'pending' && { 'aria-busy': 'true' })}
327
500
  >
328
- {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}
329
509
  {plot.width > 0 &&
330
510
  plot.height > 0 &&
331
511
  (resolvedState === 'pending' ? (
@@ -339,9 +519,12 @@ export function ChartFrame({
339
519
  {...(typeof state?.empty === 'string' && { label: state.empty })}
340
520
  />
341
521
  ) : (
342
- children({ ...plot, hidden })
522
+ children({ ...plot, hidden, legendInHeader: inHeader })
343
523
  ))}
344
- {legendNode !== null && (placement === 'bottom' || placement === 'right') && legendNode}
524
+ {!inHeader &&
525
+ legendNode !== null &&
526
+ (placement === 'bottom' || placement === 'right') &&
527
+ legendNode}
345
528
  </div>
346
529
  </ChartTierProvider>
347
530
  )