basalt-ui 1.25.0 → 1.27.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 (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -1,9 +1,23 @@
1
1
  /**
2
- * StatCard — the KPI atom (docs/DESIGN-SPEC.md §5): a panel + shadow-card + card-radius card
3
- * (spacing xs/sm inset, ~118px min-height). Header row = mono uppercase micro-label + optional
4
- * `menu` slot (e.g. a ghost "..." trigger, consumer-owned); value row = mono ~24px value +
5
- * optional `DeltaBadge` (via `delta`, with an optional `deltaPeriod` timeframe); bottom = optional
6
- * full-bleed `sparkline` slot.
2
+ * StatCard — the KPI atom (docs/DESIGN-SPEC.md §5, docs/CONTROLS-SPEC.md §2.2): a panel +
3
+ * shadow-card + card-radius card (spacing xs/sm inset, no min-height — the content states the
4
+ * height, ~88-96px for the title/hero/trend shape). The header composes
5
+ * `WidgetHeader tier="widget"` — title/icon/value/delta/deltaPeriod all render through it, on the
6
+ * shared hero-metric row under the title. `actions` (e.g. a ghost "..." menu trigger) is wrapped in
7
+ * `CtlSlot tier="widget"` — the 24px ActionIcon step the 28px widget header row can hold, not the
8
+ * 30px `ctl` tier, which used to grow the row and knock a card with a kebab 2px out of line with the
9
+ * card beside it. `StatCard` is Mantine-coupled (`src/dashboard/`), unlike `ChartCard`, so it can
10
+ * mount the slot at all; `ChartCard` writes the same `data-basalt-tier="widget"` marker by hand.
11
+ *
12
+ * `sparklinePlacement` decides where the trend visual sits: `'bleed'` (default) keeps the historic
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
15
+ * `'bleed'` layout — CSS only, no JS branch (`stat-card.module.css`).
16
+ *
17
+ * `info` and `subtitle` are forwarded to that same `WidgetHeader`, and a hero KPI usually wants
18
+ * both: `value` is typed `string`, so the unit and the basis have nowhere else to go — `subtitle`
19
+ * carries them as a muted line, and `info` puts "how this number is computed" behind the glyph
20
+ * beside the title instead of inside the heading's accessible name.
7
21
  *
8
22
  * `src/dashboard` stays @visx-free — `sparkline` is a plain `ReactNode` slot, never a chart import
9
23
  * here. Pass a `LineSparkline`/`BarSparkline` from `basalt-ui/charts` at the call site.
@@ -24,20 +38,44 @@
24
38
  * import { StatCard } from 'basalt-ui'
25
39
  * import { LineSparkline } from 'basalt-ui/charts'
26
40
  *
41
+ * // `sparkline` is a RENDER PROP over the slot's measured box, so full-bleed is genuinely the
42
+ * // card's width — no `useChartSize` wrapper component, no hardcoded pixel value.
43
+ * <StatCard
44
+ * title="Active Users"
45
+ * value="12,483"
46
+ * delta={4.2}
47
+ * sparkline={({ width, height }) => <LineSparkline data={history} width={width} height={height} />}
48
+ * />
49
+ *
50
+ * @example
51
+ * // The reference-design look — sparkline beside the hero value row instead of bled below it. The
52
+ * // slot is a fixed 72×26 there, so the render prop still receives the box it should draw into.
27
53
  * <StatCard
28
- * label="Active Users"
54
+ * title="Active Users"
29
55
  * value="12,483"
30
56
  * delta={4.2}
31
- * sparkline={<LineSparkline data={history} width={160} height={32} />}
57
+ * sparklinePlacement="right"
58
+ * sparkline={({ width, height }) => (
59
+ * <BarSparkline data={history} width={width} height={height} ariaLabel="Active users trend" />
60
+ * )}
61
+ * />
62
+ *
63
+ * @example
64
+ * // A hero card: the unit on its own muted line, the method behind the info glyph.
65
+ * <StatCard
66
+ * title="Training load"
67
+ * value="412"
68
+ * subtitle="TSS · 7-day rolling"
69
+ * info="Sum of per-session TSS over the last 7 days, Garmin-reported."
32
70
  * />
33
71
  *
34
72
  * @example
35
73
  * // Past a threshold — an accent rail down the leading edge, value untouched.
36
- * <StatCard label="Downtime" value="29 min" tone="bad" />
74
+ * <StatCard title="Downtime" value="29 min" tone="bad" />
37
75
  *
38
76
  * @example
39
77
  * // The earned zero — a measured value worth asserting, not a card that merely has no reading.
40
- * <StatCard label="Downtime · last 24h" value="0 min" tone="good" />
78
+ * <StatCard title="Downtime · last 24h" value="0 min" tone="good" />
41
79
  *
42
80
  * @example
43
81
  * // The honest gate. A zero can be synthesized as well as measured — a downtime figure derived
@@ -46,19 +84,100 @@
46
84
  * // about a reading, so the gate belongs on whether a reading exists, not on whether the number
47
85
  * // happens to be defined — those are separate facts. Gate on measured coverage instead:
48
86
  * <StatCard
49
- * label="Downtime · last 24h"
87
+ * title="Downtime · last 24h"
50
88
  * value={`${downtimeMinutes} min`}
51
89
  * tone={hasCoverage ? (downtimeMinutes === 0 ? 'good' : 'bad') : undefined}
52
90
  * />
53
91
  */
54
- import { Box, Card, Group, VisuallyHidden } from '@mantine/core'
55
- import type { ReactNode } from 'react'
56
- import { DeltaBadge } from './delta-badge'
92
+ import { Box, Card, VisuallyHidden } from '@mantine/core'
93
+ import { useEffect, useRef, useState } from 'react'
94
+ import type { CSSProperties, ReactNode } from 'react'
95
+ import { WidgetHeader } from '../widget-header'
96
+ import { CtlSlot } from '../theme'
57
97
  import { VX } from '../tokens'
98
+ import classes from './stat-card.module.css'
58
99
 
59
100
  /** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
60
101
  export type StatCardTone = 'good' | 'warn' | 'bad'
61
102
 
103
+ /**
104
+ * One row of a {@link StatCardProps.breakdown} — the two or three parts a hero number is made of.
105
+ *
106
+ * `value` is a pre-formatted `string` for the same reason the hero's is: the card cannot know the
107
+ * locale, the currency or the precision the number was measured at. `tone` marks ONE row as past a
108
+ * threshold and is the same vocabulary as the card's own `tone`, so a row saying `bad` and a rail
109
+ * saying `bad` are the same claim at two scales; omitting it is untinted, never `'good'`.
110
+ */
111
+ export type StatCardBreakdownRow = {
112
+ readonly label: string
113
+ readonly value: string
114
+ readonly tone?: StatCardTone
115
+ }
116
+
117
+ /**
118
+ * The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
119
+ * KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
120
+ * long formatted value plus a delta badge in a 4-up grid) the slot is the side that yields, because
121
+ * the number is the card's content and the trend qualifies it. The render prop is handed the
122
+ * MEASURED width either way, so the bars are drawn at the width they actually got — the previous
123
+ * fixed 72 painted over the value instead.
124
+ */
125
+ const SPARKLINE_RIGHT_WIDTH = 72
126
+ /** The `'right'` placement's slot height — sized to the hero row it sits beside, not to the card. */
127
+ const SPARKLINE_RIGHT_HEIGHT = 26
128
+
129
+ /**
130
+ * The measured box a `sparkline` render prop receives.
131
+ *
132
+ * A `LineSparkline`/`BarSparkline` takes `width`/`height` as NUMBERS (they are SVG attributes — an
133
+ * svg cannot size itself from CSS and then scale its scales), so "full bleed" was never expressible
134
+ * as a `ReactNode`: the caller had to guess a pixel width, or write its own `useChartSize` wrapper
135
+ * component. Four consumers wrote that wrapper; two of them hardcoded a width and drew a sparkline
136
+ * that stopped short of the card edge on every viewport but the one they built on.
137
+ */
138
+ export type StatCardSparklineSize = {
139
+ width: number
140
+ height: number
141
+ }
142
+
143
+ /**
144
+ * Measures a slot and re-measures on resize. Plain `ResizeObserver`, not `useChartSize`:
145
+ * `src/dashboard/**` is Mantine-coupled and @visx-FREE (see the module doc), and `useChartSize`
146
+ * wraps `@visx/responsive`.
147
+ *
148
+ * The `undefined` guard is for the test DOM — happy-dom ships no `ResizeObserver`, and the one-shot
149
+ * measure above it is what keeps a mounted card rendering its sparkline there.
150
+ */
151
+ function useSlotWidth(active: boolean): {
152
+ ref: (node: HTMLDivElement | null) => void
153
+ width: number
154
+ } {
155
+ const [width, setWidth] = useState(0)
156
+ const nodeRef = useRef<HTMLDivElement | null>(null)
157
+
158
+ useEffect(() => {
159
+ const node = nodeRef.current
160
+ if (!active || node === null) return
161
+ const measure = () => {
162
+ setWidth(node.getBoundingClientRect().width)
163
+ }
164
+ measure()
165
+ if (typeof ResizeObserver === 'undefined') return
166
+ const observer = new ResizeObserver(measure)
167
+ observer.observe(node)
168
+ return () => {
169
+ observer.disconnect()
170
+ }
171
+ }, [active])
172
+
173
+ return {
174
+ ref: (node) => {
175
+ nodeRef.current = node
176
+ },
177
+ width,
178
+ }
179
+ }
180
+
62
181
  /** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
63
182
  * that it cannot be mistaken for the card's own edge. */
64
183
  const TONE_RAIL_WIDTH = 3
@@ -70,19 +189,79 @@ const TONE_LABEL: Record<StatCardTone, string> = {
70
189
  }
71
190
 
72
191
  export type StatCardProps = {
73
- /** Mono uppercase micro-label (docs/DESIGN-SPEC.md §3: 12.5px, tracking 0.06em, faint). */
74
- label: string
75
- /** Pre-formatted KPI value string (mono ~24px, weight 600, ink). */
192
+ /** Head-font title, rendered via `WidgetHeader tier="widget"`. */
193
+ title: string
194
+ /** Optional leading icon, forwarded to `WidgetHeader`. */
195
+ icon?: ReactNode
196
+ /** Muted line under the hero row, forwarded to `WidgetHeader` — the unit or basis a
197
+ * pre-formatted `value` cannot carry (`per day`, `of 40 planned`). Not a second metric. */
198
+ subtitle?: string
199
+ /** Info tooltip beside the title, forwarded to `WidgetHeader` — how the number is computed. Never
200
+ * part of the heading's accessible name; see `WidgetHeaderProps.info`. */
201
+ info?: string
202
+ /** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
76
203
  value: string
204
+ /** The value's unit, muted and mono at `--vx-text-sm`, immediately after it on the hero row —
205
+ * forwarded to `WidgetHeader`. `412` + `TSS`, not `"412 TSS"`: a unit painted at the hero's 24px
206
+ * reads as a second numeral. The BASIS (`7-day rolling`) is still `subtitle`; see
207
+ * `WidgetHeaderProps.unit`. */
208
+ unit?: string
209
+ /**
210
+ * The parts the hero number is made of — compact rows under it, one line each, no hairlines.
211
+ *
212
+ * It exists because three consumers wanted a KPI card that also SPLITS its number (revenue by
213
+ * channel, uptime by probe) and the only shapes basalt offered were a `subtitle` (one muted line,
214
+ * no second column) and a table. Both hand-rolled a card instead — which is the fork
215
+ * `shadow-basalt-export` reports as a `HeroCard`.
216
+ *
217
+ * Deliberately not a table and deliberately unruled: the divider law puts a hairline between
218
+ * OPTION rows and nowhere else (`docs/CONTROLS-SPEC.md` §2.1), and a KPI card that draws three
219
+ * of them stops reading as one card. Keep it to two or three rows — past that the card is a
220
+ * table, and a table is `BasaltDataTable` in a `Section`.
221
+ */
222
+ breakdown?: readonly StatCardBreakdownRow[]
77
223
  /** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
78
224
  delta?: number
79
225
  /** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
80
226
  deltaPeriod?: string
81
- /** Optional trend visual — a slot; pass a `LineSparkline`/`BarSparkline` from `basalt-ui/charts`.
82
- * Rendered full-bleed to the card's left/right/bottom edges, clipped to the corner radius. */
83
- sparkline?: ReactNode
84
- /** Optional header-right slot (e.g. a ghost "..." menu trigger) — consumer-owned. */
85
- menu?: ReactNode
227
+ /**
228
+ * Formats `delta` into the chip's label — forwarded to `WidgetHeader` and on to `DeltaBadge`.
229
+ * Defaults to `${Math.abs(delta).toFixed(1)}%`.
230
+ *
231
+ * A delta is not always a percentage, and the default silently claimed it was: a pace card's trend
232
+ * (`0:12 /km`) and a speed card's (`0.3 km/h`) both rendered as `0.3%` — a wrong unit on a KPI is
233
+ * the one failure worse than no chip, and it is why the consumer that needed it kept the card
234
+ * hand-rolled instead (the `HeroCard` fork `shadow-basalt-export` reports).
235
+ *
236
+ * @example
237
+ * // An absolute delta that prints its own sign — no percent, no glyph saying it twice.
238
+ * <StatCard title="Pace" value="5:31" unit="/km" delta={-12}
239
+ * deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`}
240
+ * deltaGlyph={false} />
241
+ */
242
+ deltaFormat?: (delta: number) => string
243
+ /** Render the ▲/▼ glyph on the delta chip. Defaults to `true`; a zero delta never shows one. Pass
244
+ * `false` when `deltaFormat` prints the sign itself. */
245
+ deltaGlyph?: boolean
246
+ /**
247
+ * Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
248
+ *
249
+ * Prefer the render prop for anything from `basalt-ui/charts`: those take numeric `width`/`height`
250
+ * (SVG attributes), so a plain node has to hardcode a width, and a hardcoded width is not
251
+ * full-bleed on any viewport but the one it was typed on. With `sparklinePlacement="bleed"` the
252
+ * measured width is the card's own inner width including the bled inset; with `'right'` it is the
253
+ * fixed 72×26 slot (see {@link SPARKLINE_RIGHT_WIDTH} for why that one is not measured).
254
+ *
255
+ * @example
256
+ * sparkline={({ width, height }) => <BarSparkline data={history} width={width} height={height} />}
257
+ */
258
+ sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
259
+ /** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
260
+ * `'right'` sits it beside the hero value row. Collapses to `'bleed'` below `sm`. */
261
+ sparklinePlacement?: 'bleed' | 'right'
262
+ /** Header-right slot (e.g. a ghost "..." menu trigger) — wrapped in `CtlSlot tier="widget"`, so a
263
+ * raw `ActionIcon` with no `size` lands on the 24px step the 28px header row holds (C1/C5). */
264
+ actions?: ReactNode
86
265
  /** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
87
266
  * assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
88
267
  * is absent, and stays untinted so a card with nothing measured can never render green. Pass
@@ -91,14 +270,28 @@ export type StatCardProps = {
91
270
  }
92
271
 
93
272
  export function StatCard({
94
- label,
273
+ title,
274
+ icon,
275
+ subtitle,
276
+ info,
95
277
  value,
278
+ unit,
279
+ breakdown,
96
280
  delta,
97
281
  deltaPeriod,
282
+ deltaFormat,
283
+ deltaGlyph,
98
284
  sparkline,
99
- menu,
285
+ sparklinePlacement = 'bleed',
286
+ actions,
100
287
  tone,
101
288
  }: StatCardProps) {
289
+ const isRender = typeof sparkline === 'function'
290
+ const bleeds = sparklinePlacement === 'bleed'
291
+ // Only a render prop needs a measurement; a plain node sizes itself.
292
+ const slot = useSlotWidth(isRender)
293
+ const measured: StatCardSparklineSize = { width: slot.width, height: SPARKLINE_RIGHT_HEIGHT }
294
+
102
295
  return (
103
296
  <Card
104
297
  style={{
@@ -108,11 +301,13 @@ export function StatCard({
108
301
  // shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
109
302
  // descendant's shadow). Mantine's Card root already sets `overflow: hidden`; this is
110
303
  // explicit for intent.
304
+ //
305
+ // NO `minHeight`. It was 118px, which is ~25px of dead space under a card holding a title
306
+ // row, a hero row and a 26px trend — and it was dead space the card could not use, since the
307
+ // body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
308
+ // the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
309
+ // stretches its items.
111
310
  padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)',
112
- minHeight: 118,
113
- display: 'flex', // theme-allow inline-display — Card root as a flex column; Mantine Card takes no layout props
114
- flexDirection: 'column',
115
- gap: 'var(--vx-space-stat-card-gap)',
116
311
  overflow: 'hidden',
117
312
  // Anchors the tone rail. Set unconditionally so a card's stacking context does not change
118
313
  // depending on whether it happens to have crossed a threshold this render.
@@ -143,48 +338,75 @@ export function StatCard({
143
338
  </>
144
339
  )}
145
340
 
146
- <Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs">
147
- <span
148
- style={{
149
- fontFamily: 'var(--basalt-font-mono)',
150
- fontSize: VX.text.xs,
151
- fontWeight: 500,
152
- letterSpacing: '0.06em',
153
- textTransform: 'uppercase',
154
- color: VX.faint,
155
- }}
156
- >
157
- {label}
158
- </span>
159
- {menu}
160
- </Group>
161
-
162
- <Group align="center" gap={8} wrap="nowrap">
163
- <span
164
- style={{
165
- fontFamily: 'var(--basalt-font-mono)',
166
- fontSize: VX.text.kpi,
167
- fontWeight: 600,
168
- letterSpacing: '-0.02em',
169
- color: VX.ink,
170
- }}
171
- >
172
- {value}
173
- </span>
174
- {delta !== undefined && <DeltaBadge value={delta} period={deltaPeriod} />}
175
- </Group>
341
+ <div className={classes.body} data-placement={sparklinePlacement}>
342
+ <div className={classes.header}>
343
+ <WidgetHeader
344
+ tier="widget"
345
+ title={title}
346
+ {...(icon !== undefined && { icon })}
347
+ {...(subtitle !== undefined && { subtitle })}
348
+ {...(info !== undefined && { info })}
349
+ value={value}
350
+ {...(unit !== undefined && { unit })}
351
+ {...(delta !== undefined && { delta })}
352
+ {...(deltaPeriod !== undefined && { deltaPeriod })}
353
+ {...(deltaFormat !== undefined && { deltaFormat })}
354
+ {...(deltaGlyph !== undefined && { deltaGlyph })}
355
+ // `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
356
+ // (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
357
+ // with a kebab sat 2px below a card without one in the same grid row. See
358
+ // `theme/ctl-theme.tsx`'s `WIDGET_THEME`.
359
+ {...(actions !== undefined && {
360
+ actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
361
+ })}
362
+ />
176
363
 
177
- {sparkline && (
178
- <div
179
- style={{
180
- marginTop: 'auto',
181
- marginInline: 'calc(-1 * var(--mantine-spacing-sm))',
182
- marginBottom: 'calc(-1 * var(--mantine-spacing-xs))',
183
- }}
184
- >
185
- {sparkline}
364
+ {breakdown !== undefined &&
365
+ breakdown.length > 0 && (
366
+ // A `<dl>`, not a table and not a stack of divs: each row IS a term and its value, which
367
+ // is the one semantic that survives a reader meeting the card out of context.
368
+ //
369
+ // INSIDE `.header`, not beside it: with `sparklinePlacement="right"` the body is a flex
370
+ // ROW, so a third child here would sit next to the trend rather than under the number
371
+ // it splits. The header block is the card's text column in both placements.
372
+ <dl className={classes.breakdown}>
373
+ {breakdown.map((row) => (
374
+ <div
375
+ key={row.label}
376
+ className={classes.breakdownRow}
377
+ {...(row.tone !== undefined && { 'data-tone': row.tone })}
378
+ >
379
+ <dt className={classes.breakdownLabel}>{row.label}</dt>
380
+ <dd className={classes.breakdownValue}>{row.value}</dd>
381
+ </div>
382
+ ))}
383
+ </dl>
384
+ )}
186
385
  </div>
187
- )}
386
+
387
+ {sparkline !== undefined && (
388
+ <div
389
+ ref={slot.ref}
390
+ className={bleeds ? classes.sparklineBleed : classes.sparklineRight}
391
+ {...(!bleeds && {
392
+ // CUSTOM PROPERTIES, not `flexBasis`/`height` directly. A React inline style beats
393
+ // every stylesheet rule, so an inline `flexBasis: 72` also applied below `sm` — where
394
+ // `'right'` collapses to a COLUMN and flex-basis is therefore the main-axis HEIGHT.
395
+ // The mobile sparkline rendered in a 72px-tall box holding 26px of bars, which is the
396
+ // dead band under every KPI value on a phone. Handed to CSS as values instead, the
397
+ // media query can reset the box and the numbers still live in one place.
398
+ style: {
399
+ '--basalt-stat-sparkline-w': `${SPARKLINE_RIGHT_WIDTH}px`,
400
+ '--basalt-stat-sparkline-h': `${SPARKLINE_RIGHT_HEIGHT}px`,
401
+ } as CSSProperties,
402
+ })}
403
+ >
404
+ {/* A measured slot renders nothing on the first commit (width 0) — an SVG at width 0 is
405
+ a visible 0-width box that then jumps, which is worse than one frame of nothing. */}
406
+ {isRender ? measured.width > 0 && sparkline(measured) : sparkline}
407
+ </div>
408
+ )}
409
+ </div>
188
410
  </Card>
189
411
  )
190
412
  }
@@ -14,7 +14,7 @@
14
14
  * and a silent fallback to left-aligned would look correct in review.
15
15
  */
16
16
  import { MantineProvider, Text } from '@mantine/core'
17
- import { fireEvent, render, screen, waitFor } from '@testing-library/react'
17
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
18
18
  import { describe, expect, test } from 'bun:test'
19
19
  import { BasaltDataTable } from './data-table'
20
20
  import { createColumnHelper } from './table'
@@ -74,15 +74,19 @@ describe('stickyHeader and table chrome', () => {
74
74
  expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
75
75
  })
76
76
 
77
- test('withTableBorder defaults on and can be turned off for a table inside a card', () => {
77
+ test('withTableBorder defaults OFF and can be turned on for a table that needs a frame', () => {
78
+ // It defaulted ON, and the box was three faults at once — its top edge separated the table's own
79
+ // `WidgetHeader` from its content, its bottom edge landed on the last row's hairline (two rules
80
+ // at zero distance), and its side edges divided nothing. See the prop's JSDoc; the head rule and
81
+ // the between-row rules are untouched, so the table still reads as a table.
78
82
  expect(
79
83
  renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
80
- ).toBe('true')
84
+ ).toBeNull()
81
85
  expect(
82
- renderTable({ withTableBorder: false })
86
+ renderTable({ withTableBorder: true })
83
87
  .container.querySelector('table')
84
88
  ?.getAttribute('data-with-table-border'),
85
- ).toBeNull()
89
+ ).toBe('true')
86
90
  })
87
91
  })
88
92
 
@@ -158,6 +162,37 @@ describe('regression — the bare table is unchanged', () => {
158
162
  })
159
163
  })
160
164
 
165
+ describe('title renders a WidgetHeader with the row-model count (C11)', () => {
166
+ test('omitting title renders no heading', () => {
167
+ renderTable()
168
+ expect(screen.queryByRole('heading')).toBeNull()
169
+ })
170
+
171
+ test('title renders an h3 carrying the row count from getRowCount()', () => {
172
+ renderTable({ title: 'Top pages' })
173
+ expect(screen.getByRole('heading', { level: 3, name: 'Top pages' })).toBeDefined()
174
+ expect(screen.getByText('2')).toBeDefined() // ROWS has 2 entries
175
+ })
176
+ })
177
+
178
+ describe('the toolbar has no fixed-width literals and resolves controls to the ctl tier', () => {
179
+ test('search input carries no inline width', () => {
180
+ const { container } = renderTable({ enableGlobalFilter: true })
181
+ const input = container.querySelector('input[placeholder="Search…"]')
182
+ expect(input?.getAttribute('style') ?? '').not.toContain('width')
183
+ })
184
+
185
+ test('the toolbar renders inside a data-basalt-tier="ctl" slot', () => {
186
+ const { container } = renderTable({
187
+ enableGlobalFilter: true,
188
+ actions: <button type="button">Export</button>,
189
+ })
190
+ const slot = container.querySelector('[data-basalt-tier="ctl"]')
191
+ expect(slot).not.toBeNull()
192
+ expect(slot?.querySelector('button')?.textContent).toBe('Export')
193
+ })
194
+ })
195
+
161
196
  /**
162
197
  * The manual-pagination contract (argo round 10, P1).
163
198
  *
@@ -331,19 +366,69 @@ describe('the empty state tracks the rendered rows, not the raw array', () => {
331
366
  })
332
367
  })
333
368
 
369
+ // Facets now render as FilterSet pills (EnumFilter/MultiSelectFilter, docs/CONTROLS-SPEC.md §3)
370
+ // instead of a raw Mantine Select. Mirrors `controls/controls.router.test.tsx`'s `openPill`
371
+ // harness fact: Mantine's Popover mounts its dropdown one flushed effect cycle after the click
372
+ // (needs `act`), and the dropdown's own contents read as `{ hidden: true }` to Testing Library
373
+ // while it still carries transition styles.
374
+ async function openPill(name: string | RegExp): Promise<void> {
375
+ await act(async () => {
376
+ fireEvent.click(screen.getByRole('button', { name }))
377
+ })
378
+ }
379
+
334
380
  describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
335
- test('a facet selection reports the new columnFilters', async () => {
381
+ test('a single-select facet reports the new columnFilters via its pill popover', async () => {
336
382
  const seen: ColumnFiltersState[] = []
337
- const { container } = renderTable({
383
+ renderTable({
338
384
  facets: [
339
385
  { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
340
386
  ],
341
387
  onColumnFiltersChange: (filters) => seen.push(filters),
342
388
  })
343
- const select = container.querySelector('input[placeholder="Project"]')
344
- if (!select) throw new Error('expected the facet Select')
345
- fireEvent.click(select)
346
- fireEvent.click(await screen.findByRole('option', { name: 'argo' }))
389
+ await openPill('Any project')
390
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
347
391
  await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
348
392
  })
393
+
394
+ test('picking the synthetic "Any <facet>" row clears the column filter again', async () => {
395
+ const seen: ColumnFiltersState[] = []
396
+ renderTable({
397
+ facets: [
398
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
399
+ ],
400
+ onColumnFiltersChange: (filters) => seen.push(filters),
401
+ })
402
+ await openPill('Any project')
403
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
404
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
405
+
406
+ await openPill('argo')
407
+ fireEvent.click(screen.getByRole('radio', { name: 'Any project', hidden: true }))
408
+ await waitFor(() => expect(seen[seen.length - 1]).toEqual([]))
409
+ })
410
+
411
+ test('a multi-select facet reports every checked value', async () => {
412
+ const seen: ColumnFiltersState[] = []
413
+ renderTable({
414
+ facets: [
415
+ {
416
+ columnId: 'project',
417
+ label: 'Project',
418
+ multiple: true,
419
+ options: [
420
+ { value: 'argo', label: 'argo' },
421
+ { value: 'linewatch', label: 'linewatch' },
422
+ ],
423
+ },
424
+ ],
425
+ onColumnFiltersChange: (filters) => seen.push(filters),
426
+ })
427
+ // A MULTI facet keeps the group label while its selection carries no information — that is
428
+ // `MultiSelectFilter`'s own law, and the reason only the single-select facet needed a readable
429
+ // synthetic row (`facetAllLabel`).
430
+ await openPill('Project')
431
+ fireEvent.click(screen.getByRole('checkbox', { name: 'argo', hidden: true }))
432
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: ['argo'] }]]))
433
+ })
349
434
  })