basalt-ui 1.24.0 → 1.26.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 (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  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 +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -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 +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -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 +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -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 +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -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/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -11,7 +11,7 @@ describe('cursor store', () => {
11
11
 
12
12
  store.set('2026-08-18', 'chart-a')
13
13
  expect(calls).toBe(1)
14
- expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a' })
14
+ expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: null })
15
15
 
16
16
  unsubscribe()
17
17
  store.set('2026-08-19', 'chart-a')
@@ -43,4 +43,22 @@ describe('cursor store', () => {
43
43
  scoped.set('2026-08-18', 'chart-a')
44
44
  expect(globalCursorStore.get().key).toBeNull()
45
45
  })
46
+
47
+ test('a set carrying a different kind notifies — the partition is state, not a filter on the store', () => {
48
+ // The store is dumb: it broadcasts whatever kind it's handed and never compares it against
49
+ // anything. Policing "does this kind match mine" is `useChartCursor`'s job, on read.
50
+ const store = createCursorStore()
51
+ let calls = 0
52
+ store.subscribe(() => {
53
+ calls++
54
+ })
55
+
56
+ store.set('2026-08-18', 'chart-a', 'time')
57
+ expect(calls).toBe(1)
58
+ expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: 'time' })
59
+
60
+ store.set('2026-08-18', 'chart-a', 'band')
61
+ expect(calls).toBe(2)
62
+ expect(store.get()).toEqual({ key: '2026-08-18', source: 'chart-a', kind: 'band' })
63
+ })
46
64
  })
@@ -12,22 +12,27 @@
12
12
  * on each pointer frame.
13
13
  */
14
14
 
15
+ import type { DomainKind } from './resolve'
16
+
15
17
  /** Broadcast cursor position: an opaque domain key plus the chart that owns it. */
16
18
  export type CursorState = {
17
19
  /** The hovered x-domain key (a date/category string), or null when nothing is hovered. */
18
20
  key: string | null
19
21
  /** `chartId` of the chart the pointer is actually over — the one that owns the tooltip. */
20
22
  source: string | null
23
+ /** The broadcaster's own x-domain kind. `null` only in the empty state — the store itself does
24
+ * not police this, it just carries it; the partition is enforced by the reading hook. */
25
+ kind: DomainKind | null
21
26
  }
22
27
 
23
28
  export type CursorStore = {
24
29
  get: () => CursorState
25
- set: (key: string | null, source: string | null) => void
30
+ set: (key: string | null, source: string | null, kind?: DomainKind | null) => void
26
31
  subscribe: (onChange: () => void) => () => void
27
32
  }
28
33
 
29
34
  /** Stable identity for "nothing hovered" — also the SSR snapshot, so it must never be recreated. */
30
- export const EMPTY_CURSOR: CursorState = { key: null, source: null }
35
+ export const EMPTY_CURSOR: CursorState = { key: null, source: null, kind: null }
31
36
 
32
37
  export function createCursorStore(): CursorStore {
33
38
  let state: CursorState = EMPTY_CURSOR
@@ -35,9 +40,9 @@ export function createCursorStore(): CursorStore {
35
40
 
36
41
  return {
37
42
  get: () => state,
38
- set: (key, source) => {
39
- if (state.key === key && state.source === source) return
40
- state = key === null && source === null ? EMPTY_CURSOR : { key, source }
43
+ set: (key, source, kind = null) => {
44
+ if (state.key === key && state.source === source && state.kind === kind) return
45
+ state = key === null && source === null ? EMPTY_CURSOR : { key, source, kind }
41
46
  for (const listener of listeners) listener()
42
47
  },
43
48
  subscribe: (onChange) => {
@@ -2,7 +2,7 @@ import { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
3
  import type { KeyboardEvent, PointerEvent } from 'react'
4
4
  import type { CursorResolution } from '../cursor/resolve'
5
- import { buildDomainIndex, resolveCursorPoint } from '../cursor/resolve'
5
+ import { buildDomainIndex, classifyDomain, resolveCursorPoint } from '../cursor/resolve'
6
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
7
7
 
8
8
  /** Viewport-space pointer anchor the tooltip positions against. */
@@ -70,6 +70,9 @@ export function useChartCursor<T>({
70
70
  () => buildDomainIndex(data, getKeyRef.current, resolution),
71
71
  [data, resolution],
72
72
  )
73
+ // Mirrors `index`'s memo shape (deps built off `data`, reading the accessor through a ref) —
74
+ // `resolution` is left out because `kind` never reads it, and `exhaustive-deps` enforces that.
75
+ const kind = useMemo(() => classifyDomain(data.map((d) => getKeyRef.current(d))), [data])
73
76
 
74
77
  const frameRef = useRef<number | null>(null)
75
78
  const pendingRef = useRef<CursorAnchor | null>(null)
@@ -80,7 +83,7 @@ export function useChartCursor<T>({
80
83
  // drops the chart, a tab switches) fires no leave/blur, so the store would keep pointing at
81
84
  // a dead chartId — and any sibling whose domain resolves that stale key would paint a ghost
82
85
  // crosshair with no way to clear it. Same ownership guard as `clear()`.
83
- if (store.get().source === chartId) store.set(null, null)
86
+ if (store.get().source === chartId) store.set(null, null, null)
84
87
  },
85
88
  [store, chartId],
86
89
  )
@@ -117,9 +120,9 @@ export function useChartCursor<T>({
117
120
  }
118
121
 
119
122
  scheduleAnchor({ x: event.clientX, y: event.clientY })
120
- store.set(getKeyRef.current(closest), chartId)
123
+ store.set(getKeyRef.current(closest), chartId, kind)
121
124
  },
122
- [data, marginLeft, chartId, store, scheduleAnchor],
125
+ [data, marginLeft, chartId, store, scheduleAnchor, kind],
123
126
  )
124
127
 
125
128
  const clear = useCallback(() => {
@@ -131,7 +134,7 @@ export function useChartCursor<T>({
131
134
  setAnchor(null)
132
135
  // Only clear the SHARED cursor if this chart still owns it: moving fast from chart A to B lets
133
136
  // A's leave fire after B's move, and an unconditional clear would wipe B's cursor.
134
- if (store.get().source === chartId) store.set(null, null)
137
+ if (store.get().source === chartId) store.set(null, null, null)
135
138
  }, [store, chartId])
136
139
 
137
140
  const onKeyDown = useCallback(
@@ -155,12 +158,17 @@ export function useChartCursor<T>({
155
158
  const rect = event.currentTarget.getBoundingClientRect()
156
159
  const x = xScaleRef.current(getKeyRef.current(next)) ?? 0
157
160
  scheduleAnchor({ x: rect.left + x, y: rect.top })
158
- store.set(getKeyRef.current(next), chartId)
161
+ store.set(getKeyRef.current(next), chartId, kind)
159
162
  },
160
- [data, store, chartId, clear, scheduleAnchor],
163
+ [data, store, chartId, clear, scheduleAnchor, kind],
161
164
  )
162
165
 
163
- const point = cursor.key === null ? null : resolveCursorPoint(index, cursor.key)
166
+ // A broadcast from a domain of a DIFFERENT kind is filtered out here, before resolution is even
167
+ // attempted — a category chart never resolves a date chart's key just because both happen to
168
+ // parse. `isSource` is unaffected: it compares `chartId`, not `kind`, and a chart's own broadcast
169
+ // always carries its own kind by construction.
170
+ const point =
171
+ cursor.key === null || cursor.kind !== kind ? null : resolveCursorPoint(index, cursor.key)
164
172
 
165
173
  return {
166
174
  point,
@@ -76,7 +76,7 @@ export {
76
76
  } from './primitives/ChartPending'
77
77
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
78
78
  export { HatchPattern, hatchFill, hatchSizeFor } from './primitives/HatchPattern'
79
- export { ChartCard } from './primitives/ChartCard'
79
+ export { ChartCard, type ChartCardProps } from './primitives/ChartCard'
80
80
  export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
81
81
  export {
82
82
  ChartTooltipFloat,
@@ -98,7 +98,7 @@ export {
98
98
  type BandTooltipConfig,
99
99
  type BandTooltipRowContext,
100
100
  } from './hooks/useBandPlot'
101
- export type { CursorResolution } from './cursor/resolve'
101
+ export type { CursorResolution, DomainKind } from './cursor/resolve'
102
102
  export { useChartSize, type UseChartSizeResult, type ChartSize } from './hooks/useChartSize'
103
103
 
104
104
  // ── Utils ────────────────────────────────────────────────────────────────
@@ -448,6 +448,130 @@ describe('tooltip.onFollow — a follower whose domain never resolves the broadc
448
448
  })
449
449
  })
450
450
 
451
+ describe('cursor partitions by x-domain kind — a band chart never follows a time chart', () => {
452
+ const categoryRows: Row[] = [
453
+ { date: 'Visited', a: 100, b: 100 },
454
+ { date: 'Added to cart', a: 40, b: 40 },
455
+ { date: 'Purchased', a: 10, b: 10 },
456
+ ]
457
+
458
+ const numericRows: Row[] = [
459
+ { date: '0', a: 5, b: 5 },
460
+ { date: '1', a: 6, b: 6 },
461
+ { date: '2', a: 7, b: 7 },
462
+ ]
463
+
464
+ test('a category-keyed chart renders no crosshair while a date-keyed sibling owns the cursor', async () => {
465
+ render(
466
+ <ChartCursorScope>
467
+ <CartesianChart<Row>
468
+ data={rows}
469
+ chartId="kind-time-source"
470
+ getX={(d) => d.date}
471
+ series={[seriesFor('a')]}
472
+ ariaLabel="Source"
473
+ legend={false}
474
+ >
475
+ {() => null}
476
+ </CartesianChart>
477
+ <CartesianChart<Row>
478
+ data={categoryRows}
479
+ chartId="kind-band-follower"
480
+ getX={(d) => d.date}
481
+ series={[seriesFor('b')]}
482
+ ariaLabel="Follower"
483
+ legend={false}
484
+ tooltip={{ follow: false, onFollow: true }}
485
+ >
486
+ {() => null}
487
+ </CartesianChart>
488
+ </ChartCursorScope>,
489
+ )
490
+
491
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
492
+
493
+ // The SOURCE (time) still renders its own tooltip — the follower (band) never resolves the
494
+ // broadcast at all, because its kind differs, so it stays silent rather than mounting an empty
495
+ // tooltip shell.
496
+ expect(await screen.findByText('a')).toBeTruthy()
497
+ expect(screen.queryByText('b')).toBeNull()
498
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
499
+ })
500
+
501
+ test('a second date-keyed chart DOES render the crosshair at the same key — time charts on a page still share', async () => {
502
+ render(
503
+ <ChartCursorScope>
504
+ <CartesianChart<Row>
505
+ data={rows}
506
+ chartId="kind-time-source-2"
507
+ getX={(d) => d.date}
508
+ series={[seriesFor('a')]}
509
+ ariaLabel="Source"
510
+ legend={false}
511
+ >
512
+ {() => null}
513
+ </CartesianChart>
514
+ <CartesianChart<Row>
515
+ data={rows}
516
+ chartId="kind-time-follower"
517
+ getX={(d) => d.date}
518
+ series={[seriesFor('b')]}
519
+ ariaLabel="Follower"
520
+ legend={false}
521
+ tooltip={{ follow: false, onFollow: true }}
522
+ >
523
+ {() => null}
524
+ </CartesianChart>
525
+ </ChartCursorScope>,
526
+ )
527
+
528
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
529
+
530
+ // Both charts are `'time'`-kinded, so the partition never fires and the pair keeps sharing.
531
+ expect(await screen.findByText('a')).toBeTruthy()
532
+ expect(await screen.findByText('b')).toBeTruthy()
533
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(2)
534
+ })
535
+
536
+ // This is the case the OLD `parseKey`-only heuristic got wrong: a numeric domain and a date
537
+ // domain both parse to a number, so `resolveCursorPoint` used to attempt (and by chance often
538
+ // succeed at) resolving one against the other. Without `classifyDomain` gating the read, this
539
+ // test fails — the follower would resolve the source's broadcast key and render 'b'.
540
+ test('a numeric-keyed chart does not follow a date-keyed sibling either', async () => {
541
+ render(
542
+ <ChartCursorScope>
543
+ <CartesianChart<Row>
544
+ data={rows}
545
+ chartId="kind-time-source-3"
546
+ getX={(d) => d.date}
547
+ series={[seriesFor('a')]}
548
+ ariaLabel="Source"
549
+ legend={false}
550
+ >
551
+ {() => null}
552
+ </CartesianChart>
553
+ <CartesianChart<Row>
554
+ data={numericRows}
555
+ chartId="kind-linear-follower"
556
+ getX={(d) => d.date}
557
+ series={[seriesFor('b')]}
558
+ ariaLabel="Follower"
559
+ legend={false}
560
+ tooltip={{ follow: false, onFollow: true }}
561
+ >
562
+ {() => null}
563
+ </CartesianChart>
564
+ </ChartCursorScope>,
565
+ )
566
+
567
+ fireEvent.keyDown(screen.getByRole('slider', { name: 'Source' }), { key: 'ArrowRight' })
568
+
569
+ expect(await screen.findByText('a')).toBeTruthy()
570
+ expect(screen.queryByText('b')).toBeNull()
571
+ expect(screen.queryAllByRole('tooltip')).toHaveLength(1)
572
+ })
573
+ })
574
+
451
575
  /**
452
576
  * `xTickValues` — the seam a tick COUNT cannot express.
453
577
  *
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `ChartCard`'s header — the `''`-as-hidden-header sentinel is gone (docs/CONTROLS-SPEC.md §2.2):
3
+ * the header now renders only when at least one of title/info/value/actions/icon/count is set.
4
+ * Mantine-free (`src/charts/**`), so no `MantineProvider` wrapper is needed — mirrors
5
+ * `ChartFrame.test.tsx`'s rationale.
6
+ */
7
+ import { render, screen } from '@testing-library/react'
8
+ import { describe, expect, test } from 'bun:test'
9
+ import { ChartCard } from './ChartCard'
10
+
11
+ describe('the header renders only when it has something to show', () => {
12
+ test('nothing set — no heading at all', () => {
13
+ render(
14
+ <ChartCard>
15
+ <svg />
16
+ </ChartCard>,
17
+ )
18
+ expect(screen.queryByRole('heading')).toBeNull()
19
+ })
20
+
21
+ test('title alone renders the h3', () => {
22
+ render(
23
+ <ChartCard title="Revenue over time">
24
+ <svg />
25
+ </ChartCard>,
26
+ )
27
+ expect(screen.getByRole('heading', { level: 3, name: 'Revenue over time' })).toBeDefined()
28
+ })
29
+
30
+ test('count alone (no title) still renders the header, with an empty title', () => {
31
+ render(
32
+ <ChartCard count={4}>
33
+ <svg />
34
+ </ChartCard>,
35
+ )
36
+ expect(screen.getByText('4')).toBeDefined()
37
+ })
38
+
39
+ test('value alone renders the header', () => {
40
+ render(
41
+ <ChartCard value="$12,483">
42
+ <svg />
43
+ </ChartCard>,
44
+ )
45
+ expect(screen.getByText('$12,483')).toBeDefined()
46
+ })
47
+
48
+ test('actions alone renders the header, carrying data-basalt-tier="widget"', () => {
49
+ const { container } = render(
50
+ <ChartCard actions={<button type="button">Export</button>}>
51
+ <svg />
52
+ </ChartCard>,
53
+ )
54
+ expect(screen.getByRole('button', { name: 'Export' })).toBeDefined()
55
+ expect(container.querySelector('[data-basalt-tier="widget"]')).not.toBeNull()
56
+ })
57
+
58
+ test('subtitle alone does NOT render the header', () => {
59
+ render(
60
+ <ChartCard subtitle="Net revenue per day">
61
+ <svg />
62
+ </ChartCard>,
63
+ )
64
+ expect(screen.queryByText('Net revenue per day')).toBeNull()
65
+ })
66
+ })
@@ -1,92 +1,20 @@
1
+ /**
2
+ * Standard wrapper for every visx chart (docs/CONTROLS-SPEC.md §2.2) — card with a `WidgetHeader
3
+ * tier="widget"` title row (icon/info/value/delta/count/actions) plus an optional subtitle, over a
4
+ * clipped chart body. Do not wrap visx charts in bare divs.
5
+ *
6
+ * `WidgetHeader` lives in `src/widget-header/` and is itself Mantine-free, so composing it here
7
+ * keeps `./charts` resolving with no `@mantine/*` installed (`check-dist-layering.mjs`,
8
+ * `basalt/token-layer-boundary`). The `actions` slot cannot mount `CtlSlot` (Mantine-coupled) for
9
+ * the same reason — it carries only `data-basalt-tier="widget"`; a basalt control placed there
10
+ * sizes itself (`size="ctl"` internally), and a raw Mantine element is not auto-tiered.
11
+ *
12
+ * The header renders only when at least one of title/info/value/actions/icon/count is set — ending
13
+ * the `''`-as-hidden-header sentinel a consumer used to reach for otherwise.
14
+ */
1
15
  import type { CSSProperties, ReactNode } from 'react'
2
- import { useEffect, useId, useRef, useState } from 'react'
3
16
  import { VX } from '../../tokens'
4
-
5
- const infoWrapStyle: CSSProperties = {
6
- position: 'relative',
7
- display: 'inline-flex',
8
- alignItems: 'center',
9
- marginLeft: 5,
10
- }
11
- const infoTriggerStyle: CSSProperties = {
12
- appearance: 'none',
13
- background: 'none',
14
- border: 'none',
15
- padding: 0,
16
- margin: 0,
17
- cursor: 'help',
18
- display: 'inline-flex',
19
- alignItems: 'center',
20
- }
21
- // The tooltip bubble reads like every floating surface — panel bg + shadow-card + card radius
22
- // (docs/DESIGN-SPEC.md section 5). It opens DOWNWARD (top: 100%) by convention, into/over the
23
- // chart body. The header is intentionally outside the body's clip box (see `cardStyle` /
24
- // `bodyClipStyle` below), so the bubble can overhang the card edge without ever being clipped.
25
- // Mantine-free by design (the charts boundary), so this is a plain hover/focus-state bubble, not
26
- // a Mantine Tooltip.
27
- const bubbleStyle: CSSProperties = {
28
- position: 'absolute',
29
- top: 'calc(100% + 7px)',
30
- left: 0,
31
- zIndex: 10,
32
- maxWidth: 260,
33
- width: 'max-content',
34
- padding: '7px 10px',
35
- borderRadius: VX.radiusCard,
36
- backgroundColor: VX.surface.panel,
37
- boxShadow: VX.shadowCard,
38
- color: VX.muted,
39
- fontSize: VX.text.sm,
40
- fontWeight: 400,
41
- lineHeight: 1.4,
42
- whiteSpace: 'normal',
43
- pointerEvents: 'none',
44
- }
45
-
46
- function InfoIcon({ title }: { title: string }) {
47
- const [open, setOpen] = useState(false)
48
- const tipId = useId()
49
- const triggerRef = useRef<HTMLButtonElement>(null)
50
-
51
- useEffect(() => {
52
- if (!open) return
53
- const handlePointerDown = (event: PointerEvent) => {
54
- if (triggerRef.current?.contains(event.target as Node)) return
55
- setOpen(false)
56
- }
57
- document.addEventListener('pointerdown', handlePointerDown)
58
- return () => document.removeEventListener('pointerdown', handlePointerDown)
59
- }, [open])
60
-
61
- return (
62
- <span style={infoWrapStyle}>
63
- <button
64
- ref={triggerRef}
65
- type="button"
66
- aria-label="More information"
67
- aria-describedby={open ? tipId : undefined}
68
- onMouseEnter={() => setOpen(true)}
69
- onMouseLeave={() => setOpen(false)}
70
- onFocus={() => setOpen(true)}
71
- onBlur={() => setOpen(false)}
72
- onClick={() => setOpen(true)}
73
- onKeyDown={(event) => {
74
- if (event.key === 'Escape') setOpen(false)
75
- }}
76
- style={{ ...infoTriggerStyle, color: open ? VX.muted : VX.faint }}
77
- >
78
- <svg width={11} height={11} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
79
- <path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 5a1 1 0 0 1 1 1v7a1 1 0 1 1-2 0v-7a1 1 0 0 1 1-1z" />
80
- </svg>
81
- </button>
82
- {open && (
83
- <span id={tipId} role="tooltip" style={bubbleStyle}>
84
- {title}
85
- </span>
86
- )}
87
- </span>
88
- )
89
- }
17
+ import { WidgetHeader } from '../../widget-header'
90
18
 
91
19
  // Surfaces resolve per theme via CSS vars, so the styles are static (no useMemo/isDark).
92
20
  // Depth = `shadow-card` (a whisper shadow + a 1px ring baked into the same value), never a
@@ -94,14 +22,16 @@ function InfoIcon({ title }: { title: string }) {
94
22
  // Card/Paper (same shadow token, same radius token, no intrinsic margin; outer spacing comes
95
23
  // from the parent Stack/SimpleGrid gap). Shadow + panel background + card radius all live on
96
24
  // this OUTER box, which is never clipped — only the chart body below clips its own bottom
97
- // corners (`bodyClipStyle`). The header is a direct, unclipped child of this box on purpose: its
98
- // info-tooltip bubble must be able to overflow the card bounds without being invisibly cut off.
99
- const cardStyle = {
25
+ // corners (`bodyClipStyle`). The header is a direct, unclipped child of this box on purpose:
26
+ // `WidgetHeader`'s info bubble (`widget-header.module.css`'s `.infoBubble`, which is where the
27
+ // tooltip this file used to own now lives) opens downward past the header's own box, and must be
28
+ // able to overhang the card edge without being invisibly cut off.
29
+ const cardStyle: CSSProperties = {
100
30
  borderRadius: VX.radiusCard,
101
31
  boxShadow: VX.shadowCard,
102
32
  backgroundColor: VX.surface.panel,
103
33
  }
104
- const bodyClipStyle = {
34
+ const bodyClipStyle: CSSProperties = {
105
35
  // Clip the chart body to the card's bottom corners — a chart with its own square edges would
106
36
  // otherwise poke past the card's border-radius. Only the bottom corners: the body's top edge is
107
37
  // interior, sitting under the (unclipped) header. Trade-off: a chart tooltip that overflows the
@@ -109,71 +39,81 @@ const bodyClipStyle = {
109
39
  // practice — a consumer rendering an oversized/edge tooltip should portal it out of the card.
110
40
  borderBottomLeftRadius: VX.radiusCard,
111
41
  borderBottomRightRadius: VX.radiusCard,
112
- overflow: 'hidden' as const,
113
- padding: '2px var(--mantine-spacing-sm, 0.75rem) var(--mantine-spacing-xs, 0.625rem)',
114
- }
115
- const headerStyle = {
116
- display: 'flex',
117
- justifyContent: 'space-between',
118
- alignItems: 'center',
119
- // Card inset = spacing xs (vertical) / sm (horizontal). The `--mantine-spacing-*` vars carry
120
- // basalt's OWNED scale (theme.spacing.xs 0.625rem / sm 0.75rem); the rem fallbacks keep a
121
- // Mantine-free `./charts`-only consumer (no MantineProvider vars) padded identically. No
122
- // header/body divider — the title block and the plot read as one continuous surface; a rule here
123
- // only doubled the card's shadow-embedded ring and made the header feel heavier.
124
- padding: 'var(--mantine-spacing-xs, 0.625rem) var(--mantine-spacing-sm, 0.75rem) 4px',
125
- }
126
- const titleColumnStyle = {
127
- display: 'inline-flex',
128
- flexDirection: 'column' as const,
129
- lineHeight: 1.15,
42
+ overflow: 'hidden',
43
+ padding: '2px var(--mantine-spacing-sm, 0.8125rem) var(--mantine-spacing-xs, 0.6875rem)',
130
44
  }
131
- const titleStyle = {
132
- display: 'inline-flex',
133
- alignItems: 'center',
134
- fontFamily: 'var(--basalt-font-head)',
135
- fontStretch: '88%',
136
- fontWeight: 550,
137
- fontSize: VX.text.md,
138
- color: VX.ink,
45
+ // Card inset = spacing xs (vertical) / sm (horizontal). The `--mantine-spacing-*` vars carry
46
+ // basalt's OWNED scale (theme.spacing.xs 0.6875rem / sm 0.8125rem); the rem fallbacks keep a
47
+ // Mantine-free `./charts`-only consumer (no MantineProvider vars) padded identically. No
48
+ // header/body divider — the title block and the plot read as one continuous surface; a rule here
49
+ // only doubled the card's shadow-embedded ring and made the header feel heavier.
50
+ const headerWrapStyle: CSSProperties = {
51
+ padding: 'var(--mantine-spacing-xs, 0.6875rem) var(--mantine-spacing-sm, 0.8125rem) 4px',
139
52
  }
140
- const subtitleStyle = {
141
- fontSize: VX.text.sm,
142
- fontWeight: 400,
143
- color: VX.muted,
144
- marginTop: 1,
53
+
54
+ export type ChartCardProps = {
55
+ /** Optional — the header renders only when this or one of info/value/actions/icon/count is set. */
56
+ title?: string
57
+ /** Optional leading icon, forwarded to `WidgetHeader`. */
58
+ icon?: ReactNode
59
+ /** Optional muted line rendered below the title row — does NOT by itself trigger the header. */
60
+ subtitle?: string
61
+ /** Renders `WidgetHeader`'s info glyph beside the title — a `More information` button whose
62
+ * bubble opens on hover, focus and click. Never part of the heading's accessible name. */
63
+ info?: string
64
+ /** Pre-formatted metric value, on the hero-metric row with `delta`. */
65
+ value?: string
66
+ /** Signed delta rendered via `DeltaBadge`, alongside `value`. */
67
+ delta?: number
68
+ /** Comparison timeframe forwarded to `DeltaBadge` (e.g. `MoM`). */
69
+ deltaPeriod?: string
70
+ /** Mono count tag after the title. */
71
+ count?: number
72
+ /** Right-aligned slot — carries `data-basalt-tier="widget"` (no `CtlSlot`; see module doc). */
73
+ actions?: ReactNode
74
+ children: ReactNode
145
75
  }
146
76
 
147
- /**
148
- * Standard wrapper for every visx chart — card with info-tooltip title + optional
149
- * subtitle (the question the chart answers) + optional extra slot (current value
150
- * badge etc.). Do not wrap visx charts in bare divs.
151
- */
152
77
  export function ChartCard({
153
78
  title,
79
+ icon,
154
80
  subtitle,
155
- tooltip,
156
- extra,
81
+ info,
82
+ value,
83
+ delta,
84
+ deltaPeriod,
85
+ count,
86
+ actions,
157
87
  children,
158
- }: {
159
- title: string
160
- subtitle?: string
161
- tooltip: string
162
- extra?: ReactNode
163
- children: ReactNode
164
- }) {
88
+ }: ChartCardProps) {
89
+ const hasHeader =
90
+ title !== undefined ||
91
+ info !== undefined ||
92
+ value !== undefined ||
93
+ actions !== undefined ||
94
+ icon !== undefined ||
95
+ count !== undefined
96
+
165
97
  return (
166
98
  <div style={cardStyle}>
167
- <div style={headerStyle}>
168
- <span style={titleColumnStyle}>
169
- <span style={titleStyle}>
170
- {title}
171
- <InfoIcon title={tooltip} />
172
- </span>
173
- {subtitle !== undefined && <span style={subtitleStyle}>{subtitle}</span>}
174
- </span>
175
- {extra !== undefined && <span>{extra}</span>}
176
- </div>
99
+ {hasHeader && (
100
+ <div style={headerWrapStyle}>
101
+ <WidgetHeader
102
+ tier="widget"
103
+ title={title ?? ''}
104
+ {...(icon !== undefined && { icon })}
105
+ {...(subtitle !== undefined && { subtitle })}
106
+ {...(info !== undefined && { info })}
107
+ {...(value !== undefined && { value })}
108
+ {...(delta !== undefined && { delta })}
109
+ {...(deltaPeriod !== undefined && { deltaPeriod })}
110
+ {...(count !== undefined && { count })}
111
+ {...(actions !== undefined && {
112
+ actions: <span data-basalt-tier="widget">{actions}</span>,
113
+ })}
114
+ />
115
+ </div>
116
+ )}
177
117
  <div style={bodyClipStyle}>{children}</div>
178
118
  </div>
179
119
  )