basalt-ui 1.25.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 (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  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 +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/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -1,560 +1,130 @@
1
1
  ---
2
2
  source: basalt-ui
3
- description: visx chart conventions — compose basalt-ui primitives/kinds and keep all color in `--vx-*` tokens. Enforced by the oxlint `@visx` ban.
3
+ description: visx chart doctrine — compose CartesianChart (or ChartFrame for a non-single-plot shape), derive legends and tooltip rows from `series`, keep every color in `--vx-*`. Enforced by basalt/hand-rolled-plot, basalt/chart-legend-literal, basalt/chart-in-raw-surface and the two @visx boundary rules.
4
4
  paths:
5
- - 'src/**/charts/**'
6
- - 'apps/**/charts/**'
7
- - 'packages/**/charts/**'
5
+ - '**/charts/**'
8
6
  ---
9
7
 
10
- # Basalt Charts — visx Conventions
11
-
12
- basalt-ui owns the chart doctrine. Charts are built from low-level [visx](https://airbnb.io/visx)
13
- primitives so we can build exactly the chart we want; the trade-off is that every chart duplicates
14
- structure unless we compose shared building blocks. **`CartesianChart` plus a small kind set are
15
- the contract, not optional polish** — composing `CartesianChart` (single-plot) or `ChartFrame`
16
- directly (multi-pane/radial/matrix) is mandatory for every chart, mechanically enforced by
17
- `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). **This file is the contract you
18
- receive** — the basalt-ui repo's `docs/CHARTS-SPEC.md` is the same argument at length, and it does
19
- not ship in the package. Read it at
20
- <https://github.com/jkrumm/basalt-ui/blob/master/docs/CHARTS-SPEC.md> or not at all.
21
-
22
- ## The boundary (lint-enforced)
23
-
24
- The shipped oxlint preset (`basalt-ui/configs/oxlint.json`) enforces two hard rules — they hold in
25
- downstream apps too:
26
-
27
- - **`@visx/*` may only be imported inside a `charts/` directory** (`basalt/visx-boundary`).
28
- Everywhere else, compose basalt-ui chart primitives/kinds. If you need a raw visx primitive for a
29
- bespoke chart, import it from `basalt-ui/charts` (it re-exports `Group`, `LinePath`, `Bar`,
30
- `AreaClosed`, `scaleLinear`, `curveMonotoneX`, …) — keep the dependency declared in one place, and
31
- keep your own bespoke chart file under a `charts/` directory so it stays on the right side of the
32
- boundary.
33
- - **`@visx/tooltip` is banned everywhere** (`basalt/visx-tooltip`). Use `ChartTooltipFloat` +
34
- `TooltipHeader`/`TooltipRow`/`TooltipBody` from `basalt-ui/charts`.
35
-
36
- `basalt-ui/charts` is itself Mantine-free (`basalt/token-layer-boundary`, enforced only inside
37
- basalt-ui's own repo) — that keeps the token layer it reads from upstream of Mantine
38
- (`cssVariablesResolver` reads `--vx-*` tokens to bind Mantine's surfaces to them, so a chart
39
- importing `@mantine/*` directly would fork chrome and charts apart instead of sharing one source),
40
- AND it means `basalt-ui/charts`/`basalt-ui/tokens` resolve and render with no `@mantine/*`
41
- installed (real, CI-tested — `scripts/pack-test.sh`'s "charts/tokens-only (no-Mantine) resolution +
42
- render" step). Neither is something a consumer app maintains: there is no local chart-primitives
43
- tree to keep Mantine-free and no bridge file to own — `BasaltProvider` already bridges the Mantine
44
- color scheme to the `--vx-*` CSS variables charts read internally. Just compose the shipped
45
- primitives/kinds and pull color from `VX.*`/`alpha()` (`basalt-ui/tokens`).
46
-
47
- `check-theme`'s `inline-display` and `raw-html-layout` guards don't fire inside a chart file
48
- either — both remedies point at a Mantine layout primitive (`Flex`/`Grid`/`Group`/`Box`), which the
49
- Mantine-free boundary above already forbids there, so the finding would be unactionable. Reach for
50
- `ChartCenter` (below, under "Pending state") when a chart file needs to center something.
51
-
52
- ## The contract is mandatory (lint-enforced)
53
-
54
- Composing `CartesianChart` for a single-plot chart, or `ChartFrame` directly for a multi-pane/
55
- radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
56
- (`packages/basalt-ui/configs/oxlint-plugin.js`) make it a lint failure to bypass:
57
-
58
- - **`basalt/hand-rolled-plot`** — rendering a chart-assembly primitive (`AxisLeftNumeric`,
59
- `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) in a file that does not
60
- compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
61
- — one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
62
- file went unpoliced. To waive ONE node, put `theme-allow hand-rolled-plot — <why>` on it (or on a
63
- comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix, or a
64
- strip with no y dimension) declares the whole file with `theme-allow-file hand-rolled-plot —
65
- <why>`, anywhere in it; basalt's own tree carries three, on `DualPanel`, `BandStrip` and
66
- `MirroredBars`. `Donut` and `Heatmap` are non-single-plot too and carry none — they render no
67
- assembly primitive, so the rule never fires and there is nothing to waive.
68
- **`theme-allow-file` is the 1.21.0 spelling** — at 1.20.0 a node-scoped annotation was silently
69
- promoted to a file declaration, so per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
70
- exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
71
- cannot fire inside X.
72
- - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
73
- `items`; **since 1.20.0 also a `.map()` over a non-`series` array**, because deriving from AN
74
- array is not deriving from THE series. The legend must come from the same `series` the chart
75
- draws (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale
76
- naming a series the plot no longer draws.
77
-
78
- Both ship `warn` in the consumer preset (`configs/oxlint.json`) and `error` repo-local, per the
79
- "Shipping a stricter guard — the grace minor" doctrine in `packages/basalt-ui/CLAUDE.md`. 1.20.0
80
- WIDENS both, so both restart their grace rather than promoting — the live ledger with the promotion
81
- note per rule is `PLUGIN_RULE_GRACE`, exported beside the plugin in `configs/oxlint-plugin.js`, and
82
- a test asserts it against the shipped preset in both directions.
83
-
84
- **A documented limit:** `hand-rolled-plot` keys on those visx primitives, so a chart drawn with DOM
85
- nodes (divs sized in percent, an SVG assembled by hand) is structurally invisible to it. That is a
86
- deliberate trade — every alternative detector tried either flagged basalt's own `Donut`/`Heatmap` or
87
- an icon in a card header, and a noisy shipped rule gets switched off. Compose `CartesianChart`
88
- because it is the contract, not because lint will catch you.
89
-
90
- ## Maps are not charts
91
-
92
- Neither `@visx/geo` nor any map library is among the 9 exact-pinned `@visx/*` peers, and basalt
93
- ships no map kind.
94
-
95
- - **Maps are consumer territory.** A map is a MapLibre/Leaflet/deck.gl component in the consumer's
96
- own tree, not a visx chart — build it wherever the rest of that consumer's non-chart UI lives.
97
- - **The `basalt/visx-boundary` rule doesn't apply.** It constrains `@visx/*` imports only. A map
98
- library is not `@visx/*`, so a map component needs no `charts/` path segment and no exemption.
99
- - **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
100
- `charts/` segment like every other visx import, same as any bespoke chart.
101
- - **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
102
- - **The guard trap, narrowed at 1.21.0:** `check-theme`'s `inline-spacing` kind is a per-line regex
103
- over raw source, not an AST pass. A **unitless** number now only reports inside a style-object
104
- context (a `style=`/`sx=`/`css=` attribute, or a const whose name or type says styles), so a map's
105
- pixel geometry — `fitBounds({ padding: 48 })`, marker offsets — no longer trips it: an options bag
106
- is not CSS. A value carrying a unit is CSS wherever it was written and still reports. Where it
107
- does fire on genuine map chrome, scope a `theme-allow inline-spacing — map geometry` comment to
108
- it; that's the sanctioned answer, not a bug to file.
109
-
110
- ## Every chart has
111
-
112
- 1. **`CartesianChart`** — the primitive every single-plot cartesian chart composes, and composes
113
- NOTHING else from this list by hand. `ZonedLine`, `Bars`, `StackedArea`, and `MultiLine` all
114
- compose it; a bespoke single-plot chart should too. It owns measured margins, both y scales +
115
- domains, the x scale + tick thinning, grid, zones, axes, the shared cursor, the crosshair + its
116
- per-series dots, the hover/keyboard overlay, and the derived tooltip. The caller supplies
117
- `series` (the single source of truth — see #3) and a `children` render prop that draws ONLY
118
- marks:
119
-
120
- ```tsx
121
- <CartesianChart
122
- data={data} chartId="sessions" getX={(d) => d.date} series={SERIES}
123
- y={{ domain: 'auto', format: fmtInt }}
124
- height={260}
125
- >
126
- {({ xScale, yScale, visible }) => visible.map((s) => <LinePath key={s.key} … />)}
127
- </CartesianChart>
128
- ```
129
-
130
- Reaching past it for an axis, a tooltip, or a margin means the chart has drifted from every
131
- other chart. `DualPanel`, `Heatmap`, and `Donut` are the deliberate exception, by shape: they
132
- compose `ChartFrame` + `useChartCursor` + `autoMargin` + `ChartTooltipFloat` directly instead —
133
- same machinery, different assembly, because their contract isn't a single plot rect with one or
134
- two numeric y axes.
135
-
136
- 2. **ChartCard** wrapper — never a raw `<Card>`. Gives title + info-tooltip + extra slot, consistent
137
- margin.
138
- 3. **`series` is the single source of truth.** Legend entries and tooltip rows are DERIVED from it
139
- (`deriveLegend` / `deriveTooltipRows`) — a chart cannot show a row or a legend key it doesn't
140
- draw. Never hand-author a legend or a tooltip row in parallel. Corollary for mark renderers: draw
141
- `ctx.visible` (the `PlotContext` field), never the `series` prop directly — `visible` is `series`
142
- minus whatever the legend has toggled off, so drawing `series` would repaint a mark the reader
143
- just hid.
144
- 4. **`ChartTooltipFloat`** + `TooltipHeader` + `TooltipRow` + `TooltipBody` — never `@visx/tooltip`
145
- directly. `CartesianChart` wires this internally for a single-plot chart; a non-cartesian shape
146
- composes it directly (see #1). Portals to `document.body` (SSR-guarded: renders nothing when
147
- `document` is undefined), so it can be authored anywhere in the tree, including inside an
148
- `<svg>`. A plain `<div>` there mounts in the SVG namespace — accepting every prop,
149
- throwing nothing, typechecking, passing lint — and never paint; one consumer shipped eight
150
- authored tooltip rows no one had ever seen. The portal also un-breaks `position: fixed` under a
151
- transformed ancestor.
152
- 5. **AxisLeftNumeric** / **AxisRightNumeric** + **AxisBottomDate** — never raw
153
- `<AxisLeft>`/`<AxisBottom>`/`<AxisRight>` (they miss theme tokens + smart ticks). Enforced by
154
- `basalt-ui check-theme` (`raw-visx-axis` guard fails the build on a raw axis in a `/charts/`
155
- file; escape via `theme-allow`), not just convention. `AxisBottomDate` takes an optional
156
- `tickFormat` (`TickFormatter<string>`, defaults to `fmtAxisDate` — `DD.MM`) — the only supported
157
- exit for a sub-day window, where the default collapses every tick to the same label and a raw
158
- `<AxisBottom>` isn't an option. `CartesianChart`'s own `formatX` prop is the higher-level way to
159
- set this without touching the axis component at all — and every cartesian kind (`Bars`/
160
- `MultiLine`/`StackedArea`/`ZonedLine`/`DualPanel`) forwards its own `formatX?: (key) => string`
161
- now too, not just `CartesianChart` itself. Without it, the only route to a custom x label was
162
- pre-formatting it into the domain key, which makes one string serve as display value, scale
163
- identity, AND cursor key simultaneously — and a truncating formatter then collapses two points
164
- onto one domain value, silently dropping one from the plot (see #6 below for why that's the
165
- cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
166
- already are that seam, and a second prop over one concern would fork them.
167
- **Which x ticks get painted is a separate seam**, `xTickValues?: (keys, xMax) => readonly
168
- string[]`, on `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine`
169
- plus both band kinds. Resolution order is explicit VALUES → explicit COUNT (`xTicks`, unchanged)
170
- → as many as fit (`smartTicks`); omit both and nothing moves. Reach for it on a dense time axis,
171
- because **a count cannot express one**: `smartTicks`/`smartTicksEvery` append the final key
172
- unconditionally, so any count that does not land exactly on the last index paints two labels on
173
- top of each other at the right edge — at every count, not at an unlucky one.
174
- **The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
175
- its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
176
- values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
177
- that is already a regular grid (five-minute buckets ARE evenly spaced); wrong for an event-shaped
178
- series (speed-test runs, deploys, sessions). Two silent consequences: on a page sharing one
179
- cursor, event points draw at equal spacing, so the crosshair marks the right point at a different
180
- screen x than a regularly-sampled sibling — the correlation stays legible in the numbers and not
181
- in the geometry; and because the domain value must be unique, two events at the same instant
182
- collapse onto one position and one stops being drawn. `getX` returning a date string reads like a
183
- time axis and is not one. Tracked as issue #52; a chart whose x is a measured quantity currently
184
- needs a `basalt/hand-rolled-plot` file declaration.
185
-
186
- 6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
187
- external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
188
- the page shares one cursor out of the box. `ChartCursorScope` **isolates** a subtree onto a
189
- private store instead: it opts a subtree OUT of sharing, never into it. Reach for it only when a
190
- group must not follow the rest of the page.
191
-
192
- ```tsx
193
- <ChartCursorScope>
194
- <ChartCard>…</ChartCard>
195
- <ChartCard>…</ChartCard>
196
- </ChartCursorScope>
197
- ```
198
-
199
- **Resolution is domain-aware, not string-equal**, which is what makes sharing safe as a default.
200
- Exact-string matching would desync any chart that folds or downsamples its own domain: it stops
201
- owning most of the keys its siblings broadcast, so the shared crosshair lands on some hovers and
202
- not others, with no rule a reader can infer — worse than no shared cursor at all.
203
- `useChartCursor` resolves a broadcast key against a chart's own points by, in order: exact
204
- match → nearest parsed-numeric/date match within the chart's own step → `null`. A chart that folds its calendar
205
- into weekly buckets still tracks a hover from a sibling plotting daily points, with no extra
206
- wiring — `resolveKey` no longer exists because there is nothing left for it to patch.
207
-
208
- `CursorResolution = 'nearest' | 'leading'` (exported from `basalt-ui/charts`) picks WHICH own
209
- point wins that resolution, reachable as `cursorResolution` on `CartesianChart`/every cartesian
210
- kind/`DualPanel` and as `useChartCursor`'s `resolution` option — default `'nearest'`, unchanged.
211
- Reach for `'leading'` when `getX` returns a bucket's LEADING EDGE (a weekly series keyed by its
212
- Monday, a monthly series keyed by its 1st): under `'nearest'`, a hover landing in the back half
213
- of a bucket resolves to the FOLLOWING bucket instead of the one it's actually inside, so the
214
- shared crosshair sits one column right of the data being pointed at — reproducibly, for every
215
- back-half hover.
216
-
217
- 7. **Keyboard-operable by construction.** The hover overlay `CartesianChart` renders is focusable;
218
- `←`/`→` scrub the shared cursor one point at a time, `Escape` clears it. The tooltip is
219
- `aria-live="polite"`. This comes for free from composing `CartesianChart`/`ChartFrame` — nothing
220
- to wire per chart. That's exactly why `ChartFrame`'s outer container uses `role="group"` on its
221
- `ariaLabel`, **never** `role="img"`: per the ARIA spec every descendant of a `role="img"`
222
- element is presentational, which would erase the hover overlay's `role="slider"` from the
223
- accessibility tree entirely — the label still announces, but the keyboard-scrubbable control
224
- underneath it becomes unreachable, silently, with no error anywhere. jsdom does not implement
225
- that pruning, so no `getByRole` test can ever catch this regression; it is a structural rule, not
226
- a test-covered one. Do not "simplify" `ChartFrame`'s container role back to `img` — it looks like
227
- a no-op refactor and it is not.
228
- 8. **Theme-aware colors** via `VX.*` tokens + `alpha()`. **Never** a raw hex literal in a chart file.
229
- **Never** `localStorage.getItem('theme')` — the scheme resolves via CSS vars (see Dark/light
230
- below).
231
-
232
- **Exemption:** sparklines (`LineSparkline`, `BarSparkline` — tiny inline charts with no
233
- legend/tooltip) don't have to compose ChartCard/`CartesianChart`'s legend/tooltip — but still use
234
- `VX.*` tokens.
235
-
236
- ## Legend interaction
237
-
238
- At ≥2 entries, the derived legend is a toggle by default: clicking an entry hides that series from
239
- the plot, the tooltip, AND the auto y-domain, together — a stacked chart's axis actually shrinks
240
- when a band is hidden, it doesn't just leave a dead gap. `legend={{ toggle: false }}` opts a chart
241
- out. A single-entry legend never toggles (hiding the only series a chart draws is never useful).
242
- This is exactly why marks must draw `ctx.visible` and never `series` (see "Every chart has" #3) —
243
- `visible` is what actually reflects a hidden key; the raw `series` prop never changes.
244
-
245
- ## Tooltip config
246
-
247
- `CartesianChart`'s `tooltip` prop (`CartesianTooltipConfig<T>`, or `false` to disable the tooltip
248
- and its crosshair dots entirely):
249
-
250
- - `label?: (d: T) => { text; color } | null` — a right-aligned badge in the header (a status label,
251
- a zone name).
252
- - `prependRows?: (d: T, ctx: { visible; hidden }) => ReactNode` — rows rendered BEFORE the derived
253
- per-series rows (a total, a context line). `ctx` is the same `visible`/`hidden` the plot itself
254
- draws from, so a hand-authored row tracks legend toggling instead of desyncing from it.
255
- - `extraRows?: (d: T, ctx: { visible; hidden }) => ReactNode` — rows appended after the derived
256
- rows. Same `ctx` as `prependRows`.
257
- - `follow?: boolean` — default `true`, the tooltip tracks the pointer. `follow: false` anchors it to
258
- the crosshair at the plot's top edge instead, which reads better across a column of charts sharing
259
- one cursor (every tooltip lines up on the same x). Anchoring costs one `getBoundingClientRect` per
260
- hovered frame, which is why following is the default.
261
- - `formatHeader?: (key, d) => string` — overrides the tooltip header's date text (also
262
- `TooltipHeader`'s own `format` prop directly, and the identical seam on `DualPanel`'s
263
- `formatHeader`, so the two never diverge). Default: today's `fmtTooltipDate` behavior, unchanged.
264
- The seam exists because `fmtTooltipDate` regexes `YYYY-MM-DD` out of the domain key and builds a
265
- LOCAL `Date` from it — a UTC ISO key then names a different day than `formatX`, the tooltip badge,
266
- and every sibling chart, all of which resolve locally; the only prior workaround was carrying a
267
- local-offset ISO key. Receives the raw `getX` key alongside the hovered datum.
268
-
269
- Per-series, `SeriesStyle.tooltip: false` means "draw the mark and legend it, but never give it a
270
- tooltip row" — it lives on the series, not on the kind. `ChartSeries.formatValue` is
271
- `(v: number, d: T) => string` — a row can cite the hovered datum, not just the plotted number (e.g.
272
- `97.5 kg (92.5 × 3)`). `Bars` has its own per-key version of the opt-out: `BarsBar.tooltip: false` /
273
- `BarsLine.tooltip: false` draws and legends a bar series without ever listing it as a tooltip row.
274
-
275
- A **stacked** chart needs `cursorValue` too: `CartesianChart`'s crosshair dot defaults to
276
- `series.getValue(point)`, which is correct for an unstacked line but puts a stacked band's dot at
277
- its own raw value — somewhere inside the fill, not on the edge the reader is tracking. Pass
278
- `cursorValue={(point, series, visible) => …}` to place it at the cumulative band top instead;
279
- `StackedArea` is the reference implementation (`kinds/StackedArea.tsx`).
280
-
281
- ## Pending state (`isPending`)
282
-
283
- "Nothing to draw" is three states, not two: **measured-and-empty**, **measured-and-absent** (a
284
- genuine coverage gap), and **not-asked-yet** (the query hasn't resolved). The `data ?? []` idiom
285
- collapses the third into the second — an in-flight query renders as a fully-hatched "not measured"
286
- window, a positive claim the series was watched and carried nothing. Pass `isPending` instead of
287
- faking an empty array:
288
-
289
- ```tsx
290
- import { Bars } from 'basalt-ui/charts'
291
- ;<Bars
292
- data={data ?? []}
293
- isPending={query.isPending}
294
- chartId="load"
295
- getX={(d) => d.date}
296
- getValue={(d, key) => d[key]}
297
- positiveBars={[{ key: 'load', label: 'Load', color: VX.accent }]}
298
- />
299
- ```
300
-
301
- All seven kinds accept `isPending` and forward it to `ChartFrame` — `ZonedLine`/`Bars`/
302
- `StackedArea`/`MultiLine` forward it through `CartesianChart`, which forwards it on to `ChartFrame`
303
- in turn; `DualPanel`/`Heatmap`/`Donut` compose `ChartFrame` directly and forward it there. Every
304
- path lands on the same `ChartFrame`, so there's one pending renderer, not seven. `ChartFrame` with
305
- `isPending` renders `ChartPending` over the plot rect in place of `children`, suppresses the legend
306
- entirely (a legend naming a "not measured" series with nothing to point at is its own small lie),
307
- and sets `aria-busy="true"` on the outer container.
308
-
309
- `ChartPending` reserves the plot's exact footprint and draws **nothing** that could be mistaken for
310
- a measurement — no axes, no gridlines, no hatching, no marks, no animation (the motion doctrine
311
- bans idle pulsing) — just a faint, static, centered label (`ChartPendingProps.label`, default
312
- `'Loading…'`).
313
-
314
- `ChartCenter` (also exported from `./charts`) is the centering primitive `ChartPending` is built
315
- on — it exists only because chart files can't import Mantine's `Center`/`Flex`. Reach for it
316
- directly in a bespoke chart file the same way; it's deliberately minimal (`width`, `height`,
317
- `children`, nothing else), not a general layout system.
318
-
319
- ## Kinds — the recurring shapes
320
-
321
- basalt-ui ships these kinds (declarative props, generic over your point type via `getX` and
322
- per-series `getValue` accessors on `ChartSeries<T>`):
323
-
324
- - **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, and reference lines.
325
- Composes `CartesianChart`.
326
- - **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, zones, ref lines,
327
- dual-axis config. Composes `CartesianChart`.
328
- - **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain. Composes `CartesianChart`.
329
- - **`MultiLine`** — N series on a shared y-axis (or two, via `y2`): legend-hover dimming, per-series
330
- synced dots, dashed companion (MA) lines, per-point markers (PR stars / status dots), zones +
331
- refLines, fixed or auto domain (covers z-score/σ via a fixed symmetric domain + zero refLine).
332
- Composes `CartesianChart`. `ChartSeries.getMarker` returns `{ color?, r?, fillOpacity?, ring? }`
333
- — `ring` defaults `true` (today's punched-out stroke, unchanged), `ring: false` omits the stroke
334
- entirely, `fillOpacity` defaults `1`. Existed because a consumer's plain `fillOpacity: 0.7` dots
335
- were unreproducible after moving their chart onto the kind — the old shape had no seam for either.
336
- - **`DualPanel`** — top line-pane + bottom signed-histogram pane sharing one x-scale and one cursor;
337
- optional fill-between two top lines, zones, refLines, per-point markers on the top pane
338
- (`ChartSeries.getMarker`). Composes `ChartFrame` directly (two panes, not one plot rect). The
339
- bottom pane's tooltip row is `formatBar` (separate from `formatBottom`'s tick labels); its domain
340
- is configurable via `bottomYDomain`/`bottomMaxAbsFloor`.
341
- - **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip, optional
342
- gradient legend strip. Composes `ChartFrame` directly; self-measures, no separate responsive
343
- wrapper.
344
- - **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
345
- directly (radial, not cartesian).
346
- - **`BandStrip`** — 1-D categorical bands over a shared x axis, **no y dimension at all**: one rect
347
- per slot, `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` IS the state set —
348
- it drives the legend, each band's fill and the one derived tooltip row, so a strip cannot name a
349
- state it does not draw. `absentFraction` paints the share of a folded slot nothing measured as a
350
- hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
351
- defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
352
- `CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
353
- `BandStripSeries.formatValue` is `(d) => string | null` — `null` renders an em dash, an absent
354
- READING, which `''` cannot say (`''` is a state whose label is the whole row).
355
- - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
356
- its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
357
- (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
358
- `getAbsentFraction` hatches an unmeasured slot and `getBarOpacity` dims one. A sibling of
359
- `DualPanel`, not an extension: `DualPanel`'s top pane is a LINE pane and its bottom takes one
360
- SIGNED `getBar` over a symmetric domain. Two independent magnitudes are not one signed quantity.
361
- Composes `ChartFrame` directly.
362
-
363
- Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and `foldBands` is
364
- exported so a consumer can test their merge against the grouping that will actually run.
365
- `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
366
-
367
- **`merge` does not run on a sub-cap dataset.** `foldBands` returns `[...data]` unchanged when
368
- `data.length <= cap`, which on a wide viewport is the common case — so any bookkeeping the merge
369
- adds (how many source buckets a slot folded, how many were unmeasured) is absent exactly where you
370
- are least likely to look for it. Put that bookkeeping on the datum at CONSTRUCTION and have `merge`
371
- sum it; do not compute it from `group.length` inside the merge. Summing is also the composable
372
- form, and it keeps "every source bucket accounted for exactly once" true by construction.
373
-
374
- **A band state naming no `series` entry throws in dev.** `series` IS the state set, so an
375
- unresolvable `state` has no legend entry, no colour and no tooltip row — and the old behaviour,
376
- skipping the band, drew a coverage GAP, which on a measured/not-measured strip is a false claim
377
- about the data rather than an "unknown". Where the key comes from decides the treatment:
378
-
379
- | Key comes off | Dev | Production |
380
- | ------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
381
- | the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
382
- | a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
383
-
384
- A feed that grows a state basalt has never seen must degrade rather than take a dashboard down; a
385
- typo, which is the same input, still fails loudly wherever it is written. The dashed neutral outline
386
- is used by no legend entry and no state fill, so it can never be mistaken for data. Pane keys are
387
- props, never data-driven — an unresolvable `up.key` used to hide the pane AND its axis, which reads
388
- as "that half measured zero".
389
-
390
- `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
391
- kind's horizontal zone bands, for marking a time window rather than a value range. Each
392
- `{ from?, to?, fill, align? }` bound is a `getX` **domain key** (the label string the kind's
393
- `scalePoint<string>` runs over), not a date or timestamp. An omitted bound is the plot edge; a key
394
- absent from the scale's domain skips that band entirely rather than clamping to an edge. `align`
395
- defaults `'center'` (a present bound resolves to the point's own center, today's behaviour);
396
- `align: 'edge'` widens by half a step at each present bound instead — a two-key band covers both
397
- terminal slots in full, and `from === to` renders one step wide instead of being skipped as
398
- degenerate. An edge-aligned bound is clamped into the plot range at the first/last sample.
399
-
400
- How to add a chart:
401
-
402
- 1. **Fits an existing kind?** Use it. Pass `data`, `chartId`, `getX`, `series` (or
403
- `positiveBars`/`getValue` for `Bars`), an `AxisConfig` per axis (`y`/`y2`), and the declarative
404
- zones/thresholds/refLines arrays.
405
- 2. **Second instance of a new recurring shape?** Extract a kind and migrate both call sites
406
- (Rule of Three: don't extract on the first, don't wait past the third). Bespoke escape hatches
407
- (`tooltip.extraRows`, etc.) are fine but must not grow into god-object configs.
408
- 3. **Genuinely unique (e.g. a dual-panel MACD, or a dual-axis line pair no kind's config surface
409
- covers)?** Stay bespoke — compose `CartesianChart` (single-plot) or `ChartFrame` (multi-pane/
410
- non-cartesian) directly, drawing only marks in the `children` render prop. Keep it in the page's
411
- chart file, not in a shared kind. See `apps/playground/src/demo/ChartsPage.tsx`'s
412
- `SessionsRevenueChart` (a dual-axis line pair via `CartesianChart` + `y`/`y2`) and
413
- `ChannelVolumeChart` (a role-grouped, `maxRows`-capped legend with a threshold-as-legend-entry
414
- trick) for two real bespoke compositions.
415
-
416
- **Anti-pattern:** a single `<Chart type="..." config={...} />` that switches by kind. Prefer N small
417
- kinds.
418
-
419
- ## Axis config (`AxisConfig<T>`)
420
-
421
- One object per axis on `CartesianChart` and every kind that composes it — this collapses the
422
- `yDomain` / `yAutoMaxFloor` / `yAutoMinCeil` / `yAutoPad` / `numTicksY` /
423
- `formatYTick` prop soup:
424
-
425
- ```tsx
426
- y={{
427
- domain: 'auto', // 'auto' (default) | [min, max] | (data, visible) => [min, max]
428
- autoMaxFloor: 100, // when 'auto': raw upper bound is at least this, clamped BEFORE padding
429
- autoMinCeil: 0, // when 'auto': lower bound is at most this (Infinity = pad from raw min)
430
- autoPad: 1.1, // when 'auto': padding multiplier away from the data
431
- ticks: 5,
432
- format: (v) => `${v}%`,
433
- grid: true, // horizontal grid rules; default on for `y`, off for `y2`
434
- nice: false, // default false — opt in to d3's scale.nice() domain rounding
435
- }}
436
- ```
437
-
438
- **Behavior change (2026-08-19) — the one item in this doc that can move an existing chart's
439
- rendering:** `autoMaxFloor` now clamps the raw upper bound BEFORE padding, mirroring `autoMinCeil`,
440
- which has always clamped first and padded second — two different laws used to live in one function.
441
- The old order padded the raw max, then applied the floor last, so when the floor won it landed
442
- exactly on the axis top with zero headroom: a target line sitting at precisely the floor value was
443
- glued to the plot edge. Measured case: dataMax 3.2, pad 1.1, floor 6 → axis top was 6.0, is now
444
- 6.6. A consumer relying on the old ordering will see their axis top move — lower the floor or pin
445
- `domain` explicitly to opt back out.
446
-
447
- `nice` defaults `false` deliberately: flipping the default would move the domain of every
448
- already-migrated chart, so it's opt-in per axis.
449
-
450
- **Passing `y2` is what makes a chart dual-axis** — it draws the right axis and widens the right
451
- margin by measurement; nothing else flips it on. A series opts into that axis with `axis: 'right'`
452
- on its `ChartSeries` entry (`SeriesStyle.axis`, default `'left'`).
453
-
454
- `domain` as a function is the seam a stacked chart uses: it receives `(data, visible)`, so a
455
- domain summed from the VISIBLE series shrinks correctly when the legend toggles a band off
456
- (`StackedArea`'s `yConfig` is the reference — summing `props.series` there instead would leave a
457
- permanent gap above the stack once a band is hidden).
458
-
459
- ## Series color
460
-
461
- App-specific series colors are _your_ domain data, not framework data. Declare them once:
462
-
463
- ```ts
464
- import { defineSeries, groupTokens } from 'basalt-ui/tokens'
465
-
466
- const SERIES = defineSeries({
467
- load: { light: '#…', dark: '#…' },
468
- recovery: { light: '#…', dark: '#…' },
469
- })
470
- const tokens = groupTokens('series', SERIES) // → { load: 'var(--vx-series-load)', ... }
471
- ```
472
-
473
- Emit the CSS via `buildPaletteCss({ groups: { 'series-': SERIES } })`. A hue keeps its identity but
474
- shifts shade across schemes — that's why each entry is a `{ light, dark }` pair, not a single value.
475
- **Adding a new color** means adding a pair to your series map and rebuilding the palette CSS — never
476
- inline a hex in a chart.
477
-
478
- A series that's invisible in the plot (flat at the domain floor, filtered to zero) can still carry
479
- an explanation: `SeriesStyle.note` (and so `LegendEntry.note`, via `deriveLegend`) renders a short
480
- muted qualifier after the legend label — e.g. _"Low cloud — 0% all night"_. Keep it to a clause.
481
-
482
- `SeriesStyle.strokeOpacity` dims the plotted stroke (e.g. a faint moving-average companion) — the
483
- legend swatch honors it too, parity with `fillOpacity`. The tooltip-row swatch and the crosshair
484
- dot deliberately do NOT honor it: a sub-1 opacity on a 12px value-readout chip reads as a rendering
485
- bug, not as data.
486
-
487
- ## Dark/light mode
488
-
489
- Theme reactivity is **pure CSS**: the `--vx-*` variables are redeclared under the light/dark color
490
- scheme, so toggling the scheme restyles every chart with no React re-render. Charts read `VX.*` (var
491
- refs) directly. Don't branch on color scheme in JS; never read `localStorage.getItem('theme')`.
492
-
493
- ## Area gradients
494
-
495
- Use the `AreaGradient` primitive (a vertical `<linearGradient>` of `color-mix` stops over a `--vx-*`
496
- color) with the global strength knobs (`--vx-area-top` / `--vx-area-bottom`). Default the fill **on**
497
- for plain metric lines, **off** when the chart already carries zone/threshold fills (avoid double-fill
498
- clutter). Keep stacked-area bands opaque — fading them leaks lower bands.
499
-
500
- ## Responsive sizing
501
-
502
- There is exactly one responsive path now — `ChartFrame` (composed either directly, or via
503
- `CartesianChart`, which composes it internally). `Heatmap` self-measures
504
- the same way every other kind does, instead of taking `width`/`height` from a separate wrapper.
505
- Every kind and `CartesianChart` itself resolve their size the same three ways, tried in this order:
506
-
507
- ```tsx
508
- <Bars height={260} … /> // fixed height (most common)
509
- <Bars aspectRatio={16 / 9} … /> // height = round(measured width / ratio)
510
- <Bars fill … /> // fills the parent flex/grid cell's measured height
511
- ```
512
-
513
- Reach for `useChartSize` directly only when composing `ChartFrame`/`CartesianChart` yourself in a
514
- bespoke chart and you need the measured `{ width, height }` for something beyond what those
515
- primitives already do with it — `ChartFrame` already calls it internally for you in the common case.
516
- `UseChartSizeResult`: `{ ref, width, height }`; attach `ref` to the container you want measured.
517
-
518
- ## Margin
519
-
520
- Margins are **measured**, not hand-picked. `CartesianChart` runs every configured axis's tick
521
- labels through `autoMargin`/`measureText` (`basalt-ui/charts`) before laying out the plot, so
522
- `VX.margin` is a **floor**, not the value — a wide tick label (a long right-axis dollar format, a
523
- rotated x label) widens its own gutter automatically instead of clipping or needing a hand-tuned
524
- margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nudge at all:
525
-
526
- ```tsx
527
- // now: nothing — passing y2 is what widens the right margin, measured from what's actually painted
528
- <CartesianChart data={data} chartId="x" getX={…} series={SERIES} y={{…}} y2={{…}} />
529
- ```
530
-
531
- The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
532
- override applied LAST, after measurement, so it always wins.
533
-
534
- **A band plot has one extra law, because a band axis puts its FIRST tick on the plot's left edge.**
535
- With no left axis to measure, `autoMargin` stops at `VX.margin.left` and half the first x label
536
- hangs off the canvas — so `BandStrip` floors its left gutter at half the widest x label, mirroring
537
- what `autoMargin` already does on the right. Both terminal gutters are then capped (14% of width
538
- left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart. `MirroredBars`
539
- probes real tick labels from both panes, so it takes the plain measured margin and neither the
540
- floor nor the cap applies.
541
-
542
- **The height law is the number you actually have to compute.** A band row is
543
- `height − legend − margin.top − margin.bottom`: `ChartFrame` reserves the measured legend band out
544
- of the plot rect first, then the kind takes the margins out of what is left. There is no
545
- `bandHeight` prop — to get a 44px band, hand the frame `44 + margins + legend`. `margin={{ top: 0 }}`
546
- reclaims the whole top gutter, which a 1-D strip draws nothing in; the bottom is not reclaimable,
547
- `autoMargin` floors it at `VX.margin.bottom`. Derive the constants from `VX.margin` rather than
548
- restating them — a consumer that copies `30` into three chart files has three silent-drift sites the
549
- moment the density scale is retuned.
550
-
551
- `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
552
- but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
553
- needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
554
- don't use it today (neither renders a measured dual numeric axis), so treat it as a fallback, not the
555
- default path. It returns `VX.margin` widened for `{ rightAxis: true }`, or any side overridden
556
- directly; returns a new object per call, so memoize it at the call site if you do reach for it.
557
-
558
- ## Rule of thumb
559
-
560
- > If the new chart doesn't fit the primitives, add a kind — don't loosen the primitives.
8
+ <!-- basalt:coverage -->
9
+ <!-- GENERATED from src/surfaces.ts — `basalt-ui check-coverage --write`. Do not hand-edit. -->
10
+ <!-- backed by: guard kinds — chart-missing-aria-label, raw-color-fn, raw-hex, raw-visx-axis, unframed-chart · oxlint rules — basalt/chart-in-raw-surface, basalt/chart-legend-literal, basalt/hand-rolled-plot, basalt/visx-boundary, basalt/visx-tooltip -->
11
+ <!-- not guarded: — -->
12
+ <!-- /basalt:coverage -->
13
+
14
+ # Basalt Charts — the primitive contract
15
+
16
+ basalt-ui owns the chart doctrine: charts are assembled from low-level visx primitives, so without a
17
+ shared assembly every chart drifts from every other one. **This file is the contract; the API is not
18
+ here** — read the shipped types and JSDoc, and `llms.txt` at the install directory, for props.
19
+
20
+ ## The one mandatory primitive
21
+
22
+ **Every single-plot cartesian chart composes `CartesianChart`, and composes nothing else from its job
23
+ list by hand.** It owns the measured margins, both y scales and their domains, the x scale and tick
24
+ thinning, the grid, zones, the axes, the page-shared cursor, the crosshair and its per-series dots,
25
+ the hover/keyboard overlay and the derived tooltip. The caller supplies `series` and a `children`
26
+ render prop that draws **only marks**.
27
+
28
+ Reaching past it for an axis, a tooltip or a margin means the chart has drifted. `basalt/hand-rolled-plot`
29
+ enforces this per NODE: a chart-assembly primitive rendered in a file that does not compose
30
+ `CartesianChart` is a finding, waived one node at a time with `theme-allow hand-rolled-plot — <why>`.
31
+
32
+ **Five shipped shapes are the declared exceptions**, by shape, not by preference: `DualPanel` (two
33
+ panes), `MirroredBars` (two bar panes on one baseline), `BandStrip` (no y dimension at all), `Donut`
34
+ (radial) and `Heatmap` (a matrix). They compose `ChartFrame` + `useChartCursor` + `autoMargin` +
35
+ `ChartTooltipFloat` directly. The first three carry a `theme-allow-file hand-rolled-plot — <why>`
36
+ declaration; `Donut` and `Heatmap` render no assembly primitive, so nothing fires and there is
37
+ nothing to waive. A genuinely non-single-plot bespoke shape declares itself the same way — file
38
+ scope, spelled, with a reason. The module that DEFINES `CartesianChart` is exempt definitionally: a
39
+ rule saying "compose X" cannot fire inside X.
40
+
41
+ **A documented limit:** the rule keys on those visx primitives, so a chart drawn with DOM nodes or a
42
+ hand-assembled `<svg>` is structurally invisible to it. Compose the primitive because it is the
43
+ contract, not because lint will catch you.
44
+
45
+ ## `series` is the single source of truth
46
+
47
+ Legend entries and tooltip rows are **DERIVED** from `series` (`deriveLegend` / `deriveTooltipRows`,
48
+ or just let `ChartFrame`/`CartesianChart` do it), so a chart cannot show a row or a key it does not
49
+ draw. A hand-written array literal passed to `ChartLegend`'s `items` — or a `.map()` over some OTHER
50
+ array, because deriving from AN array is not deriving from THE series — is
51
+ `basalt/chart-legend-literal`.
52
+
53
+ **Corollary for mark renderers: draw `ctx.visible`, never the `series` prop.** `visible` is `series`
54
+ minus whatever the legend has toggled off, and the legend toggles by default at two or more entries
55
+ — hiding a series drops it from the plot, the tooltip AND the auto y-domain together. Drawing
56
+ `series` repaints the mark the reader just hid.
57
+
58
+ ## Boundaries (three independent rules, no escape hatch)
59
+
60
+ - **`@visx/*` only inside a `charts/` path segment** (`basalt/visx-boundary`, shipped AND repo-local).
61
+ Need a raw visx primitive? Import it from `basalt-ui/charts`, which re-exports the curated set, and
62
+ keep your bespoke chart file under a `charts/` directory.
63
+ - **`@visx/tooltip` is banned everywhere**, charts included (`basalt/visx-tooltip`) — use
64
+ `ChartTooltipFloat` + `TooltipHeader`/`TooltipRow`/`TooltipBody`. It portals to `document.body`, so
65
+ it can be authored anywhere including inside an `<svg>`; a plain `<div>` there mounts in the SVG
66
+ namespace, typechecks, lints, throws nothing, and never paints.
67
+ - **basalt's own `charts/` and `tokens/` import zero `@mantine/*`** (`basalt/token-layer-boundary`,
68
+ repo-local by design). That is a framework invariant, not an obligation on your app's own
69
+ `charts/`-named directory: it keeps the token layer upstream of Mantine and lets
70
+ `basalt-ui/charts` + `basalt-ui/tokens` resolve with no Mantine installed.
71
+ - Because Mantine layout primitives are unreachable inside a chart file, `inline-display` and
72
+ `raw-html-layout` deliberately do NOT fire there — the finding would be unactionable. Raw `<div>`
73
+ is fine in a chart file; `VX.*` tokens are still mandatory. Reach for `ChartCenter` to center.
74
+ - **Maps are not charts.** No map library is a `@visx/*` package, basalt ships no map kind, and the
75
+ boundary says nothing about one — build it wherever the rest of the app's non-chart UI lives, and
76
+ style its chrome with `--vx-*` so it matches.
77
+
78
+ ## Color, scheme and margins
79
+
80
+ - **Never a raw hex in a chart file**, and never `localStorage.getItem('theme')`. Theme reactivity is
81
+ **pure CSS**: the `--vx-*` variables are redeclared per scheme, so toggling restyles every chart
82
+ with no React re-render. Don't branch on the color scheme in JS.
83
+ - **Series color is consumer domain data** — one guard-exempt file per app, `defineSeries` →
84
+ `seriesTokens`/`groupTokens` → `buildPaletteCss` (basalt-tokens.md). A hue keeps its identity and
85
+ shifts shade across schemes, which is why every entry is a `{ light, dark }` pair.
86
+ - **Margins are MEASURED, not hand-picked.** Every configured axis's tick labels run through
87
+ `autoMargin`/`measureText` before layout, so `VX.margin` is a floor and a wide label widens its own
88
+ gutter. Passing `y2` is what makes a chart dual-axis — the right margin widens by measurement, with
89
+ no manual nudge. `margin={{ side: n }}` is the per-side override, applied last.
90
+ - **Derive constants from `VX.margin`** rather than copying a number into three chart files — those
91
+ are three silent-drift sites the moment the density scale is retuned.
92
+ - **Never fake an empty dataset for a pending query.** "Nothing to draw" is three states, and
93
+ `data ?? []` collapses "not asked yet" into "measured and absent", which is a positive claim about
94
+ data nobody fetched. Pass `isPending`; every kind forwards it to one pending renderer that reserves
95
+ the plot's footprint and draws nothing that could be mistaken for a measurement.
96
+ - **`ChartFrame`'s container is `role="group"`, never `role="img"`.** Every descendant of a
97
+ `role="img"` element is presentational, which erases the hover overlay's `role="slider"` from the
98
+ accessibility tree — silently, and jsdom does not implement the pruning, so no `getByRole` test can
99
+ catch the regression. Do not "simplify" it back.
100
+
101
+ ## Adding a chart
102
+
103
+ 1. **Fits a shipped kind?** Use it. The kinds are declarative — `data`, `chartId`, `getX`, `series`,
104
+ one `AxisConfig` per axis, zones/thresholds/refLines as plain arrays.
105
+ 2. **Second instance of a shape with no kind?** Extract a kind and migrate both call sites (Rule of
106
+ Three: not on the first, not past the third). **Prove a new kind by porting real call sites** and
107
+ shipping the "still cannot express" list with it — a demo page is written against the API that
108
+ already exists.
109
+ 3. **Genuinely unique?** Stay bespoke, composing `CartesianChart` (or `ChartFrame`) directly in the
110
+ page's chart file, not in a shared kinds directory.
111
+
112
+ Sparklines are the one exemption from the composition contract (no legend, no tooltip, no
113
+ `CartesianChart`) — they still read `VX.*`.
114
+
115
+ **Anti-pattern:** a single `<Chart type="…" config={…} />` that switches by kind. Prefer N small
116
+ kinds, and if a chart doesn't fit the primitives, **add a kind — don't loosen the primitives.**
117
+
118
+ ## The x axis is categorical, and the API does not say so
119
+
120
+ `CartesianChart` builds x as a point scale over domain KEYS: N points are N evenly spaced positions
121
+ whatever the values behind them. Correct and invisible for a regular grid; wrong for an event-shaped
122
+ series, where two consequences are silent — equal spacing means a shared crosshair marks the right
123
+ point at a different screen x than a regularly-sampled sibling, and two events at the same instant
124
+ collapse onto one position with one of them dropped. `getX` returning a date string reads like a time
125
+ axis and is not one. A chart whose x is a measured quantity needs a file declaration today.
126
+
127
+ The cursor is likewise **shared page-wide by default** with no provider, resolved domain-aware
128
+ (nearest parsed date/number within a chart's own step, not string equality) so a chart that folds its
129
+ domain still tracks a sibling's hover. `ChartCursorScope` ISOLATES a subtree — it opts out of
130
+ sharing, never into it.