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
@@ -1,11 +1,8 @@
1
1
  /*
2
- * StatCard's sparkline placement (docs/CONTROLS-SPEC.md §2.2). `bleed` (default) keeps the historic
3
- * full-width row beneath the hero value, bled past the card's own inset padding — same negative
4
- * margins the pre-WidgetHeader version drew inline. `right` sits the sparkline beside the
5
- * BREAKDOWN row instead, the reference-design look, and collapses back to `bleed`'s stacked layout
6
- * below `sm` — CSS only for the sparkline's own box, `.metricsRow` below for the row it sits in
7
- * (`47.99375em` is Mantine's own complement of `min-width: 48em`, same value
8
- * `content/prose.module.css` derives it from).
2
+ * StatCard's sparkline placement (docs/CONTROLS-SPEC.md §2.2). `bleed` (default) keeps the full-width
3
+ * row beneath the hero value, bled past the card's inset padding. `right` sits the sparkline beside
4
+ * the BREAKDOWN row and collapses back to `bleed`'s stacked layout when the card itself is narrower
5
+ * than 480px (`container: basalt-card`, set on the Card root in `stat-card.tsx`).
9
6
  */
10
7
 
11
8
  .body {
@@ -131,7 +128,7 @@
131
128
  }
132
129
 
133
130
  /* Asks for `StatCard`'s `SPARKLINE_RIGHT_WIDTH`/`_HEIGHT` (handed down as custom properties, so this
134
- * rule and the `< sm` reset below can both reach them). `align-self: flex-end` is the one alignment
131
+ * rule and the narrow-card reset below can both reach them). `align-self: flex-end` is the one alignment
135
132
  * this restructure keeps at the SPARKLINE's own level rather than the row's: `.metricsRow` does not
136
133
  * set `align-items: flex-end` itself, because a card with no breakdown would then need nothing to
137
134
  * anchor against — only the sparkline opts into sitting at the row's bottom edge, which matters once
@@ -146,7 +143,7 @@
146
143
  overflow: hidden;
147
144
  }
148
145
 
149
- @media (max-width: 47.99375em) {
146
+ @container basalt-card (max-width: 479.9px) {
150
147
  .metricsRow {
151
148
  flex-direction: column;
152
149
  align-items: stretch;
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * `sparklinePlacement` decides where the trend visual sits: `'bleed'` (default) keeps the historic
13
13
  * full-width row beneath the hero value, bled past the card's own inset padding; `'right'` sits it
14
- * beside the hero row, the reference-design look. Below `sm`, `'right'` collapses back to the
14
+ * beside the hero row, the reference-design look. In a card narrower than 480px, `'right'` collapses back to the
15
15
  * `'bleed'` layout — CSS only, no JS branch (`stat-card.module.css`).
16
16
  *
17
17
  * `info` and `subtitle` are forwarded to that same `WidgetHeader`, and a hero KPI usually wants
@@ -277,7 +277,7 @@ export type StatCardProps = BasaltProps &
277
277
  */
278
278
  sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
279
279
  /** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
280
- * `'right'` sits it beside the hero value row. Collapses to `'bleed'` below `sm`. */
280
+ * `'right'` sits it beside the hero value row. Collapses to `'bleed'` in a card narrower than 480px. */
281
281
  sparklinePlacement?: 'bleed' | 'right'
282
282
  /** Header-right slot (e.g. a ghost "..." menu trigger) — wrapped in `CtlSlot tier="widget"`, so a
283
283
  * raw `ActionIcon` with no `size` lands on the 24px step the 28px header row holds (C1/C5). */
@@ -357,7 +357,7 @@ export function StatCard({
357
357
  )}
358
358
  {...(!bleeds && {
359
359
  // CUSTOM PROPERTIES, not `flexBasis`/`height` directly. A React inline style beats every
360
- // stylesheet rule, so an inline `flexBasis: 72` also applied below `sm` — where `'right'`
360
+ // stylesheet rule, so an inline `flexBasis: 72` also applied in a narrow card — where `'right'`
361
361
  // collapses `.metricsRow` to a COLUMN and flex-basis is therefore the main-axis HEIGHT.
362
362
  // The mobile sparkline rendered in a 72px-tall box holding 26px of bars, which is the dead
363
363
  // band under every KPI value on a phone. Handed to CSS as values instead, the media query
@@ -400,6 +400,9 @@ export function StatCard({
400
400
  // Anchors the tone rail. Set unconditionally so a card's stacking context does not change
401
401
  // depending on whether it happens to have crossed a threshold this render.
402
402
  position: 'relative',
403
+ // The card's own width drives its chrome (`widget-header.module.css`, `.metricsRow`).
404
+ containerType: 'inline-size',
405
+ containerName: 'basalt-card',
403
406
  ...style,
404
407
  }}
405
408
  data-tone={tone}
@@ -2,8 +2,8 @@
2
2
  * StatGroup's column law and its optional rail, in CSS (law C9).
3
3
  *
4
4
  * **The law keys on the CONTAINER, not the viewport.** A viewport breakpoint answers the wrong
5
- * question — the same one `resolveChartTier` refuses to ask (`charts/primitives/chart-frame-layout.
6
- * ts`): the shell has regions that change the AVAILABLE width with the viewport held still (a
5
+ * question — the same one `resolveContainerClass` refuses to ask (`tokens/size-classes.ts`): the
6
+ * shell has regions that change the AVAILABLE width with the viewport held still (a
7
7
  * `PageAside` claiming its 300px, the sidebar collapsing 256px → its 48px rail, a consumer's own
8
8
  * split layout), and `@media` cannot see any of them. Measured defect: a `PageAside` mounted on a
9
9
  * 1512px desktop left the KPI row ~956px wide, `@media` still resolved `lg`, and four cells of
@@ -26,8 +26,7 @@
26
26
  * boundary at the 16px initial font size — 768px and 1200px — but `em` inside a `@container`
27
27
  * condition resolves against the CONTAINER's font-size rather than the root's, so a consumer panel
28
28
  * with smaller text would silently move the breakpoint. px is the only spelling that cannot drift.
29
- * (`47.99375em` → `767.9px`, `74.99375em` → `1199.9px`; the same complements `stat-card.module.css`
30
- * uses.) A condition still cannot read a custom property either way, so the numbers are written;
29
+ * (`47.99375em` → `767.9px`, `74.99375em` → `1199.9px`.) A condition still cannot read a custom property either way, so the numbers are written;
31
30
  * the COUNTS are custom properties the component sets per instance.
32
31
  */
33
32
 
@@ -44,6 +43,19 @@
44
43
  gap: var(--vx-space-stack-md);
45
44
  }
46
45
 
46
+ /* 260px is StatGroup's own literal (docs/DESIGN-CORE.md § Layout, elevation, shapes), not a container class: one column below it,
47
+ * so every cell leads a row and the rail draws no leading hairline at all. */
48
+ @container basalt-stat-group (max-width: 259.9px) {
49
+ .root {
50
+ grid-template-columns: minmax(0, 1fr);
51
+ }
52
+
53
+ .root[data-divided] > *:nth-child(n) {
54
+ border-inline-start: 0;
55
+ padding-inline-start: 0;
56
+ }
57
+ }
58
+
47
59
  @container basalt-stat-group (min-width: 768px) {
48
60
  .root {
49
61
  grid-template-columns: repeat(var(--basalt-stat-group-cols-sm), minmax(0, 1fr));
@@ -10,7 +10,7 @@
10
10
  * **`cols` is the DESKTOP count, and it is the only number a consumer states.** The law is
11
11
  * `base 1 → sm min(cols, 2) → lg cols`: a phone gets one column because a dashboard card holds a
12
12
  * chart; a tablet gets at most two because a third makes every chart narrower than
13
- * `VX.phoneChartWidth`; the full count lands at `lg`. Mantine's `cols={{ … }}` object is
13
+ * `CONTAINER_CLASSES.regular`; the full count lands at `lg`. Mantine's `cols={{ … }}` object is
14
14
  * deliberately NOT forwarded — a responsive object on the public prop is exactly the seam that let
15
15
  * five call sites disagree.
16
16
  *
@@ -19,12 +19,13 @@
19
19
  * ACTUALLY has. The shell moves that width without moving the viewport — a `PageAside` claiming its
20
20
  * 300px, the sidebar collapsing 256px → its 48px rail, a consumer's split pane — and a `@media`
21
21
  * query sees none of it: it kept three chart columns in a 956px content box and every chart in the
22
- * row went narrower than `VX.phoneChartWidth`, which is the exact condition the `lg` rung exists to
23
- * avoid. See `widget-grid.module.css`'s header for the fallback.
22
+ * row went narrower than `CONTAINER_CLASSES.regular`, which is the exact condition the `lg` rung
23
+ * exists to avoid. See `widget-grid.module.css`'s header for the fallback.
24
24
  *
25
- * **This is the sanctioned place `lg` enters the package.** `sm` is still the only breakpoint a
26
- * consumer writes (`docs/CONTROLS-SPEC.md` §2); `WidgetGrid` and `StatGroup` own `lg` internally so
27
- * nobody else has to.
25
+ * **This is the sanctioned place `lg` enters the package.** A consumer never writes a
26
+ * breakpoint — component layout keys on the container's own width
27
+ * (`docs/DESIGN-CORE.md` § Layout, elevation, shapes); `WidgetGrid` and `StatGroup` own their `lg`
28
+ * container rung internally so nobody else has to.
28
29
  *
29
30
  * `WidgetGrid.Item` is for the one card that is wider than the rest — a hero chart beside two
30
31
  * stacked panels. `span` is likewise the DESKTOP span and is CLAMPED to the live column count at
@@ -9,8 +9,13 @@
9
9
  * by name with rem fallbacks at their level-0 px value (36/28/6), same idiom as ChartCard's
10
10
  * `--mantine-spacing-*` fallbacks.
11
11
  *
12
- * Layout: the title row holds title/icon/info/count/actions only. `value` + `delta` render on
13
- * their own hero-metric row directly beneath it — for every tier, never inline with the title.
12
+ * Layout keys on the enclosing card's OWN width (`container: basalt-card`, declared by ChartCard /
13
+ * StatCard / Section — docs/DESIGN-CORE.md § Layout, elevation, shapes), never the viewport: below 480px the subtitle folds
14
+ * into the info glyph; from 480px (wave 8) `value` + `delta` move onto the title row. Wrapping and
15
+ * shrinking are unconditional. The subtitle ALSO folds (regardless of width) whenever the card's own
16
+ * height is under 280px — see the `[data-basalt-card-short]` block below (wave 8's header-economy
17
+ * law). No enclosing card, no container: the standard layout. Container conditions are px literals
18
+ * of CONTAINER_CLASSES.
14
19
  */
15
20
 
16
21
  .root {
@@ -18,73 +23,15 @@
18
23
  flex-direction: column;
19
24
  }
20
25
 
26
+ /* The row WRAPS only when it does not fit (a lone kebab stays beside the title; a wide `actions` slot
27
+ * takes its own right-aligned line instead of squeezing the heading) — unconditional, so composers
28
+ * with no `basalt-card` container (`SettingsSection`, `BasaltDataTable`) keep the safety net. */
21
29
  .titleRow {
22
30
  display: flex;
31
+ flex-wrap: wrap;
23
32
  align-items: center;
24
33
  gap: var(--vx-space-control-gap, 0.375rem);
25
- }
26
-
27
- /*
28
- * Below `sm` the title row WRAPS, which is the safety net for an `actions` slot too wide to sit
29
- * beside the title. `flex-wrap` is the right tool precisely because it is conditional: a 30px kebab
30
- * fits and stays on the row, while a control whose hypothetical size does not fit takes its own line
31
- * instead of squeezing the heading to an ellipsis. No breakpoint arithmetic, no max-width fraction,
32
- * and no prop — the row measures the actual content.
33
- *
34
- * `row-gap` is the 8px rhythm step, so a wrapped slot reads as the line under the title rather than
35
- * as a second header. `.actions` keeps its `margin-inline-start: auto`, which on a wrapped line makes
36
- * it right-aligned — the same edge it holds when it is on the row.
37
- */
38
- @media (max-width: 47.99375em) {
39
- .titleRow {
40
- flex-wrap: wrap;
41
- row-gap: var(--vx-space-stack-sm);
42
- }
43
-
44
- /*
45
- * WRAP ONLY WHEN IT DOES NOT FIT (M4 follow-up) — `flex: 1 1 100%` forced EVERY actions slot onto
46
- * its own row, even a single 24px icon that fit beside the title fine (a KPI card's lone `···`
47
- * menu dropped under the title and pushed the value down with it). `flex: 0 1 auto` restores
48
- * ordinary flex sizing: no grow, so a short slot stays put beside the title on the shared row;
49
- * `min-width: 0` plus `flex-shrink: 1` (part of the same shorthand, overriding the base rule's
50
- * `flex-shrink: 0`) let it shrink and, once `.titleRow`'s own `flex-wrap: wrap` decides it truly
51
- * does not fit, drop to its own line. `max-width: 100%` is the cap once it IS alone on that line —
52
- * without it a `.actions` still carrying unshrinkable content (the `visibleFrom="sm"` control plus
53
- * a nowrap switch `Group`, see below) can overflow the card rather than shrink into it. The base
54
- * rule's `margin-inline-start: auto` still applies in both states — right-aligned beside the title
55
- * when it fits, right-aligned as the lone item on its own wrapped line when it does not.
56
- *
57
- * `.actions` keeps its OWN `flex-wrap: wrap` from the prior fix: `Section` can hand it a `tabs`
58
- * control (`CtlSlot`-wrapped) AND an `actions` control (another `CtlSlot`) in the same slot, and
59
- * without this a nowrap `Group` of switches beside a view-switch control gets squeezed into one
60
- * shared line and overflows below its own min-content width — the exact regression this rule
61
- * once fixed. It is orthogonal to the row-level "wrap only when it does not fit" law above: with
62
- * one child, wrapping has nothing to do, so a lone `···` menu still stays on the title line.
63
- *
64
- * The real flex items get `flex: 1 1 auto; min-width: 0` so they can shrink or (via the `> * > *`
65
- * twin) stack within whatever width `.actions` itself resolves to — a full-width phone `Select`
66
- * still reads as its own full-width line because the CONTROL sets that itself (`ViewTabs`'
67
- * `fullWidth`/`.phoneSelect`), not because `.actions` forces it.
68
- *
69
- * The `> * > *` twin exists because `CtlSlot` (`theme/ctl-theme.tsx`) wraps a home's actions in a
70
- * `display: contents` box — invisible to the flex box tree, so its OWN children (not it) are the
71
- * real flex items. `display: contents` elements still match `.actions > *` by DOM structure but
72
- * carry no box to apply `flex`/`min-width` to, hence styling one level deeper as well; a call site
73
- * that skips `CtlSlot` and hands `actions` a real element directly gets the same rule from the
74
- * first selector.
75
- */
76
- .actions {
77
- flex: 0 1 auto;
78
- flex-wrap: wrap;
79
- min-width: 0;
80
- max-width: 100%;
81
- }
82
-
83
- .actions > *,
84
- .actions > * > * {
85
- flex: 1 1 auto;
86
- min-width: 0;
87
- }
34
+ row-gap: var(--vx-space-stack-sm);
88
35
  }
89
36
 
90
37
  .root[data-tier='section'] .titleRow {
@@ -203,59 +150,28 @@
203
150
  }
204
151
 
205
152
  /*
206
- * The hero-metric row — value + delta, directly under the title row, for every tier. The delta rides
207
- * 4px off the value, not 8: it is a suffix ON the number (`$168,920 +47%`), and at 8px it read as a
208
- * second, unrelated chip in the row. `baseline` alignment is what keeps the mono numeral and the
209
- * badge's own smaller mono text sitting on one line.
210
- */
211
- /*
212
- * The hero-metric row: the value, then its `DeltaBadge`.
213
- *
214
- * The gap is the 8px vertical-rhythm step (`--vx-space-stack-sm`), not the 4px it shipped as. 4px is
215
- * the rhythm's SMALLEST step — the distance between a label and the thing it labels — and at that
216
- * distance the badge read as a superscript hanging off the numeral rather than as a second fact
217
- * beside it. It is a token and not a literal so it tracks the density knob with every other spacing
218
- * in the header (`deriveSpacing`, `tokens/palette.ts`); `theme/layout-rhythm-css.test.ts`'s sibling
219
- * assertion in `widget-header.test.tsx` pins it against exactly the literal it used to be.
220
- *
221
- * `align-items: center`, not `baseline`. The badge is a BOX with its own padding and a mono 11.5px
222
- * label, so aligning its text baseline to a 24px KPI numeral's baseline put the box itself low —
223
- * its top edge sat below the numeral's x-height and its bottom edge hung under the numeral's
224
- * baseline. A chip beside a numeral is centred on the numeral, the same law the `count` tag in the
225
- * title row above already follows.
153
+ * The hero-metric row: the value, then its `DeltaBadge`, 8px apart (`--vx-space-stack-sm`, tracks
154
+ * density). It always WRAPS — the badge drops under the value when they do not fit, the value never
155
+ * ellipsizes. `center`, not `baseline`: the badge is a padded box, so a chip beside a numeral is
156
+ * centred on it (same law as the `count` tag above).
226
157
  */
227
158
  .metrics {
228
159
  display: flex;
229
- flex-wrap: nowrap;
160
+ flex-wrap: wrap;
230
161
  align-items: center;
231
162
  gap: var(--vx-space-stack-sm);
163
+ /* Tight when the badge wraps under the value. */
164
+ row-gap: 2px;
232
165
  margin-top: 2px;
233
166
  min-width: 0;
234
167
  }
235
168
 
236
- /*
237
- * Below `sm` the metric row WRAPS, so the badge drops under the value instead of ellipsizing it.
238
- * `nowrap` plus the `.value` clamp below is right on desktop — a `+18.9%` cannot ellipsize and read,
239
- * a numeral can — but a KPI's numeral IS the content, and in a 2-up card grid at 390px
240
- * `$1294.9k ▲18.9%` rendered as `$129… ▲18.9%`. Truncating the number to protect the delta about the
241
- * number is the wrong way round. Conditional like the title row above it: a short value keeps the
242
- * badge beside it, and only a value that would otherwise clip gives up the line.
243
- */
244
- @media (max-width: 47.99375em) {
245
- .metrics {
246
- flex-wrap: wrap;
247
- row-gap: 2px;
248
- }
249
- }
250
-
251
- /* The value is the row's shrinkable half and the delta badge is not: a pre-formatted numeral can
252
- * ellipsize and still read, a `+18.9%` cannot. Without the clamp the badge grew past the header box
253
- * and painted over `StatCard`'s 72px sparkline slot beside it. */
169
+ /* NEVER truncated: the numeral IS the content. It shrinks with the card's own width instead —
170
+ * `cqi` against `basalt-card` (the clamp, at the end of this file), from the `kpi` step down to the
171
+ * `lg` step (16px), and `overflow-wrap` breaks a pathological string rather than clipping it. */
254
172
  .value {
255
173
  min-width: 0;
256
- overflow: hidden;
257
- text-overflow: ellipsis;
258
- white-space: nowrap;
174
+ overflow-wrap: anywhere;
259
175
  font-family: var(--basalt-font-mono);
260
176
  font-size: var(--vx-text-kpi);
261
177
  font-weight: 600;
@@ -266,8 +182,8 @@
266
182
  /*
267
183
  * The unit beside the hero value. Mono like the number it qualifies (so `412 TSS` sits on one
268
184
  * baseline grid), `--vx-text-sm` and `--vx-muted` so it never competes with the numeral — the whole
269
- * point is that a unit is the SMALL half of the pair. `flex-shrink: 0`: the value is the row's
270
- * shrinkable half (see `.value`), and a unit that ellipsized to `TS…` would state nothing.
185
+ * point is that a unit is the SMALL half of the pair. `flex-shrink: 0`: a unit clipped to `TS…`
186
+ * would state nothing.
271
187
  */
272
188
  .unit {
273
189
  flex-shrink: 0;
@@ -278,14 +194,28 @@
278
194
  color: var(--vx-muted);
279
195
  }
280
196
 
281
- /* The C1/C5 home for header actions — always pinned right, regardless of what else is present. */
197
+ /* The C1/C5 home for header actions — always pinned right, regardless of what else is present.
198
+ * Law C15: adopts the shared hit overlay (`data-basalt-hit-scope`, `styles.css`); `--vx-hit-gap`
199
+ * caps the expansion at the control gap so neighbouring 24px icons never overlap. */
282
200
  .actions {
201
+ --vx-hit-gap: var(--vx-space-control-gap);
283
202
  display: inline-flex;
284
- flex-shrink: 0;
203
+ /* No grow, but it may shrink and wrap once `.titleRow` decides it does not fit, capped at 100% once
204
+ * alone on its line. Its flex items shrink too; the `> * > *` twin reaches through `CtlSlot`'s
205
+ * `display: contents` wrapper, whose own children are the real items. */
206
+ flex: 0 1 auto;
207
+ flex-wrap: wrap;
285
208
  align-items: center;
286
209
  gap: var(--vx-space-control-gap, 0.375rem);
287
210
  margin-inline-start: auto;
288
211
  min-width: 0;
212
+ max-width: 100%;
213
+ }
214
+
215
+ .actions > *,
216
+ .actions > * > * {
217
+ flex: 1 1 auto;
218
+ min-width: 0;
289
219
  }
290
220
 
291
221
  /* Directly under the title row, 4px off it — 1px put it inside the title's own line box, so a
@@ -306,6 +236,7 @@
306
236
  /* ── info glyph ─────────────────────────────────────────────────────────────────────────────── */
307
237
 
308
238
  .info {
239
+ --vx-hit-gap: var(--vx-space-control-gap);
309
240
  position: relative;
310
241
  display: inline-flex;
311
242
  flex-shrink: 0;
@@ -330,6 +261,12 @@
330
261
  color: var(--vx-muted);
331
262
  }
332
263
 
264
+ /* Folded-subtitle pieces: hidden until a `basalt-card` narrower than 480px shows them (below). */
265
+ .foldGlyph,
266
+ .foldNote {
267
+ display: none;
268
+ }
269
+
333
270
  /*
334
271
  * The tooltip bubble reads like every other floating surface — panel bg + `shadow-card` + card
335
272
  * radius (docs/DESIGN-SPEC.md §5). It opens DOWNWARD by convention, into whatever sits below the
@@ -383,33 +320,108 @@
383
320
  opacity: 0.6;
384
321
  }
385
322
 
323
+ /* Card narrower than 480px: the subtitle folds into the info glyph (`InfoGlyph`'s `note`, or
324
+ * `.foldGlyph` when there is no `info`) — exactly one of the two shows. */
325
+ @container basalt-card (max-width: 479.9px) {
326
+ .subtitle {
327
+ display: none;
328
+ }
329
+
330
+ .foldGlyph {
331
+ display: inline-flex;
332
+ }
333
+
334
+ .foldNote {
335
+ display: block;
336
+ }
337
+ }
338
+
386
339
  /*
387
- * Law C15 — the `actions` slot is the tier-2/tier-3 home, and its controls are 30px (`ctl`) or 24px
388
- * (`icon`). Below `sm` an `::after` overlay lifts the HIT area to
389
- * `--vx-space-touch-control-height` (36px at level 0) without moving the visible box, the same
390
- * overlay `shell/page-bar.module.css` draws for the bar. `overflow: visible` is load-bearing:
391
- * Mantine's Button/ActionIcon roots are `overflow: hidden`, which clips an overlay that by
392
- * definition extends past the box — the clipping only exists for their loading overlay, painted
393
- * under `[data-loading]`, which is excluded here.
340
+ * The same fold, triggered by the CARD'S OWN HEIGHT instead of its width (wave 8's header-economy
341
+ * law) — a short-but-wide chart card (a KPI-style plot with little vertical room) still needs the
342
+ * subtitle line back. Not a `@container basalt-card` SIZE query: `container-type: size` on an
343
+ * auto-height card collapses it to 0 (see `ChartCard`'s `CARD_SHORT_HEIGHT` doc), so `ChartCard`
344
+ * measures itself with a ResizeObserver instead and sets `data-basalt-card-short` under 280px.
394
345
  */
395
- @media (max-width: 47.99375em) {
396
- .actions :is(button, a) {
397
- position: relative;
346
+ [data-basalt-card-short] .subtitle {
347
+ display: none;
348
+ }
349
+
350
+ [data-basalt-card-short] .foldGlyph {
351
+ display: inline-flex;
352
+ }
353
+
354
+ [data-basalt-card-short] .foldNote {
355
+ display: block;
356
+ }
357
+
358
+ /* A short card's KPI numeral is the header's own tallest line (the merged title row has no other
359
+ * source of height once the subtitle folds) — the same floor the width `cqi` clamp already uses,
360
+ * so a short card never introduces a THIRD value size. */
361
+ [data-basalt-card-short] .value {
362
+ font-size: var(--vx-text-lg);
363
+ }
364
+
365
+ /* The KPI numeral shrinks with its card. `(width >= 0px)` is always true but only inside a
366
+ * `basalt-card` container, so `cqi` never resolves against an unrelated one (or the viewport) —
367
+ * with no card the numeral stays at the plain `kpi` step above. 8cqi reaches the 24px `kpi` step at
368
+ * a 300px card. */
369
+ @container basalt-card (width >= 0px) {
370
+ .value {
371
+ font-size: clamp(var(--vx-text-lg), 8cqi, var(--vx-text-kpi));
372
+ }
373
+ }
374
+
375
+ /*
376
+ * From 480px (`CONTAINER_CLASSES.regular`, wave 8's header-economy law) `value` + `delta` ride the
377
+ * title row: the title row dissolves (`display: contents`) so heading, info, count, metrics and
378
+ * actions share the root's one flex line, and `order` puts the metrics before the actions. The
379
+ * metrics take the free space (`margin-inline-start: auto`), which pushes metrics AND actions
380
+ * right; with no metrics, `.actions` keeps its own auto margin. The heading keeps the tier's row
381
+ * height (`min-height` was on `.titleRow`), so a title-less header does not collapse.
382
+ *
383
+ * This threshold moved from `wide` (800px) in wave 5/7 — most cards sit in the 480-799px `regular`
384
+ * band, which never reached the merged row before, keeping the taller stacked layout on the
385
+ * majority of dashboard cards (wave 8, `docs/waves/PLAN.md`).
386
+ */
387
+ @container basalt-card (min-width: 480px) {
388
+ .root {
389
+ flex-direction: row;
390
+ flex-wrap: wrap;
391
+ align-items: center;
392
+ column-gap: var(--vx-space-control-gap, 0.375rem);
393
+ }
394
+
395
+ .titleRow {
396
+ display: contents;
397
+ }
398
+
399
+ .root[data-tier='section'] .heading {
400
+ min-height: var(--vx-space-section-header-height, 2.25rem);
401
+ }
402
+
403
+ .root[data-tier='widget'] .heading,
404
+ .root[data-tier='group'] .heading {
405
+ min-height: var(--vx-space-widget-header-height, 1.75rem);
406
+ }
407
+
408
+ .metrics {
409
+ order: 1;
410
+ margin-block: 0;
411
+ margin-inline-start: auto;
412
+ }
413
+
414
+ .actions {
415
+ order: 2;
398
416
  }
399
417
 
400
- .actions :is(button, a):not([data-loading]) {
401
- overflow: visible;
418
+ .root:has(.metrics) .actions {
419
+ margin-inline-start: 0;
402
420
  }
403
421
 
404
- .actions :is(button, a)::after {
405
- content: '';
406
- position: absolute;
407
- top: 50%;
408
- left: 50%;
409
- translate: -50% -50%;
410
- width: 100%;
411
- height: 100%;
412
- min-width: var(--vx-space-touch-control-height, 2.25rem);
413
- min-height: var(--vx-space-touch-control-height, 2.25rem);
422
+ .subtitle,
423
+ .sparkline {
424
+ order: 3;
425
+ flex: 0 0 100%;
414
426
  }
415
427
  }
@@ -17,6 +17,8 @@ declare const classes: {
17
17
  readonly sparkline: string
18
18
  readonly info: string
19
19
  readonly infoTrigger: string
20
+ readonly foldGlyph: string
21
+ readonly foldNote: string
20
22
  readonly infoBubble: string
21
23
  readonly deltaBadge: string
22
24
  readonly deltaGlyph: string
@@ -9,9 +9,11 @@
9
9
  * 88%/550 title treatment. `tier: 'group'` also renders an `<h3>`, but at the quietest rank on the
10
10
  * page: a mono, uppercase, faint micro-label — an inspector/aside group heading, one step below a
11
11
  * `widget` tier and resolved automatically by `Section` when it renders on the `PageAside` panel
12
- * surface (never a call-site prop). The title row holds title/icon/info/count/actions only; `value`
13
- * + `delta` render on their own hero-metric row directly beneath it, for every tier — never inline
14
- * with the title. `StatCard`, `ChartCard`, `Section`, `SettingsSection`/`DangerZone` and
12
+ * surface (never a call-site prop). The title row holds title/icon/info/count/actions; `value` +
13
+ * `delta` render on their own hero-metric row beneath it, and move onto the title row when the
14
+ * enclosing card (`container: basalt-card`) is 480px or wider — CSS only (wave 8: was 800px). The
15
+ * subtitle also folds into the info glyph whenever the card's own height is under 280px, regardless
16
+ * of width. `StatCard`, `ChartCard`, `Section`, `SettingsSection`/`DangerZone` and
15
17
  * `BasaltDataTable` each compose this and render nothing else above their body (wave 3,
16
18
  * docs/CONTROLS-SPEC.md §2.2's composer table).
17
19
  *
@@ -62,7 +64,9 @@ export type WidgetHeaderProps = BasaltProps &
62
64
  title: string
63
65
  /** Optional leading icon, rendered before the title. Decorative — hidden from assistive tech. */
64
66
  icon?: ReactNode
65
- /** Optional muted line rendered below the title row. */
67
+ /** Optional muted line rendered below the title row. In a card narrower than 480px, or shorter
68
+ * than 280px regardless of width, it folds into the info glyph's bubble instead (CSS only —
69
+ * exactly one of the two shows). */
66
70
  subtitle?: string
67
71
  /**
68
72
  * Renders an info glyph BESIDE the heading (never inside it — this text would otherwise become
@@ -72,8 +76,8 @@ export type WidgetHeaderProps = BasaltProps &
72
76
  * click — so a keyboard user gets it too. Mantine-free: a plain state bubble, not a Tooltip.
73
77
  */
74
78
  info?: string
75
- /** Pre-formatted metric value (mono, hero size). Renders on its own row with `delta`, directly
76
- * under the title row — never inline with the title. */
79
+ /** Pre-formatted metric value (mono, hero size, never truncated). Renders on its own row with
80
+ * `delta` under the title row, or on the title row in a card 480px or wider. */
77
81
  value?: string
78
82
  /**
79
83
  * The value's unit, rendered immediately after it on the hero row: muted, mono, `--vx-text-sm`.
@@ -170,7 +174,22 @@ export type WidgetHeaderDeltaProps = Pick<
170
174
  * composes it so an inspector row's `hint` is the same glyph, bubble and keyboard behaviour a
171
175
  * `WidgetHeader`'s `info` is — not a second one that drifts.
172
176
  */
173
- export function InfoGlyph({ text }: { text: string }) {
177
+ export function InfoGlyph({
178
+ text,
179
+ note,
180
+ ariaLabel = 'More information',
181
+ className,
182
+ }: {
183
+ text: string
184
+ /** A second paragraph in the bubble, shown only where `.foldNote` is (the folded subtitle). */
185
+ note?: string
186
+ /** The GLYPH's own accessible name. Default `'More information'`; `WidgetHeader` overrides it to
187
+ * include the subtitle text for its fold-glyph mount (`info === undefined`), where this trigger
188
+ * IS the subtitle's only accessible surface — the bubble it opens is otherwise unreachable
189
+ * without first knowing there is something to open. */
190
+ ariaLabel?: string
191
+ className?: string
192
+ }) {
174
193
  const [open, setOpen] = useState(false)
175
194
  const tipId = useId()
176
195
  const triggerRef = useRef<HTMLButtonElement>(null)
@@ -186,14 +205,15 @@ export function InfoGlyph({ text }: { text: string }) {
186
205
  }, [open])
187
206
 
188
207
  return (
189
- <span className={classes.info}>
208
+ <span className={cx(classes.info, className)}>
190
209
  <button
191
210
  ref={triggerRef}
192
211
  type="button"
212
+ data-basalt-hit
193
213
  className={classes.infoTrigger}
194
214
  // The GLYPH is named, not the text — `text` reaches AT through `aria-describedby` below, so
195
215
  // it never lands in the heading's or the button's accessible name.
196
- aria-label="More information"
216
+ aria-label={ariaLabel}
197
217
  aria-describedby={open ? tipId : undefined}
198
218
  data-open={open}
199
219
  onMouseEnter={() => setOpen(true)}
@@ -212,6 +232,7 @@ export function InfoGlyph({ text }: { text: string }) {
212
232
  {open && (
213
233
  <span id={tipId} role="tooltip" className={classes.infoBubble}>
214
234
  {text}
235
+ {note !== undefined && <span className={classes.foldNote}>{note}</span>}
215
236
  </span>
216
237
  )}
217
238
  </span>
@@ -258,9 +279,22 @@ export function WidgetHeader({
258
279
  <span className={classes.titleText}>{title}</span>
259
280
  </Heading>
260
281
  {/* OUTSIDE the heading on purpose — see `info`'s own doc. */}
261
- {info !== undefined && <InfoGlyph text={info} />}
282
+ {info !== undefined && (
283
+ <InfoGlyph text={info} {...(subtitle !== undefined && { note: subtitle })} />
284
+ )}
285
+ {info === undefined && subtitle !== undefined && (
286
+ <InfoGlyph
287
+ text={subtitle}
288
+ className={classes.foldGlyph}
289
+ ariaLabel={`More information: ${subtitle}`}
290
+ />
291
+ )}
262
292
  {count !== undefined && <span className={classes.count}>{count}</span>}
263
- {actions !== undefined && <span className={classes.actions}>{actions}</span>}
293
+ {actions !== undefined && (
294
+ <span className={classes.actions} data-basalt-hit-scope>
295
+ {actions}
296
+ </span>
297
+ )}
264
298
  </div>
265
299
  {(value !== undefined || delta !== undefined) && (
266
300
  <div className={cx(classes.metrics, classNames?.metric)}>