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
@@ -21,27 +21,18 @@
21
21
  * <BasaltDataTable data={rows} columns={columns} />
22
22
  *
23
23
  * @example
24
- * // With the toolbar + pagination bar:
24
+ * // With the title, the toolbar + pagination bar:
25
25
  * <BasaltDataTable
26
+ * title="Top pages"
26
27
  * data={rows}
27
28
  * columns={columns}
28
29
  * enableGlobalFilter
29
30
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
30
- * toolbarActions={<Button size="xs">Export</Button>}
31
+ * actions={<Button>Export</Button>}
31
32
  * enablePagination
32
33
  * />
33
34
  */
34
- import {
35
- Box,
36
- Group,
37
- MultiSelect,
38
- Pagination,
39
- Select,
40
- Skeleton,
41
- Table,
42
- Text,
43
- TextInput,
44
- } from '@mantine/core'
35
+ import { Box, Group, Pagination, Select, Skeleton, Table, Text, TextInput } from '@mantine/core'
45
36
  import type { MantineSpacing } from '@mantine/core'
46
37
  import type {
47
38
  Column,
@@ -65,7 +56,17 @@ import {
65
56
  } from '@tanstack/react-table'
66
57
  import type { CSSProperties, ReactNode } from 'react'
67
58
  import { useCallback, useMemo, useState } from 'react'
59
+ import { FilterSet } from '../controls'
60
+ // `EnumFilter` is deliberately NOT on the `./controls` barrel (`docs/CONTROLS-SPEC.md` §3 —
61
+ // reaching for it directly is hand-rolling a filter, `basalt/hand-rolled-filter`); a facet column
62
+ // is the one place inside the framework itself that legitimately builds a `FieldHandle` over
63
+ // something that is not a store field, so it reaches past the barrel on purpose.
64
+ import { EnumFilter } from '../controls/enum-filter'
65
+ import { MultiSelectFilter } from '../controls/multi-select-filter'
66
+ import { CtlSlot } from '../theme'
68
67
  import { alpha, VX } from '../tokens'
68
+ import type { EnumField, FieldHandle, MultiField } from '../state'
69
+ import { WidgetHeader } from '../widget-header'
69
70
 
70
71
  // ── Column alignment ──────────────────────────────────────────────────────────
71
72
 
@@ -113,17 +114,17 @@ function resolveAlign<T>(column: Column<T, unknown>): DataTableAlign | undefined
113
114
 
114
115
  // ── Facets ────────────────────────────────────────────────────────────────────
115
116
 
116
- /** A single selectable option inside a {@link DataTableFacet}'s Select/MultiSelect. */
117
+ /** A single selectable option inside a {@link DataTableFacet}'s `EnumFilter`/`MultiSelectFilter` pill. */
117
118
  export type DataTableFacetOption = {
118
119
  /** The raw filter value, compared against the column's stringified cell value. */
119
120
  value: string
120
- /** Label shown in the Select/MultiSelect dropdown. */
121
+ /** Label shown in the pill's popover. */
121
122
  label: string
122
123
  }
123
124
 
124
125
  /**
125
- * Declares one faceted column filter, rendered as a Mantine Select (or MultiSelect when
126
- * `multiple` is set) inside the toolbar.
126
+ * Declares one faceted column filter, rendered as an `EnumFilter` pill (or `MultiSelectFilter`
127
+ * when `multiple` is set) inside a `FilterSet` in the toolbar (`docs/CONTROLS-SPEC.md` §3).
127
128
  *
128
129
  * @example
129
130
  * const facets: DataTableFacet[] = [
@@ -135,14 +136,89 @@ export type DataTableFacetOption = {
135
136
  export type DataTableFacet = {
136
137
  /** The TanStack column id — the column's manual `id`, or its `accessorKey` string. */
137
138
  columnId: string
138
- /** Label shown as the Select/MultiSelect placeholder. */
139
+ /** The pill's label — read at rest (single-select) and as the `Filters`/count fallback (multi). */
139
140
  label: string
140
141
  /** Selectable options for this facet. */
141
142
  options: DataTableFacetOption[]
142
- /** Render a MultiSelect (any-of match) instead of a single Select (exact match). @default false */
143
+ /** Render a `MultiSelectFilter` (any-of match) instead of an `EnumFilter` (exact match). @default false */
143
144
  multiple?: boolean
144
145
  }
145
146
 
147
+ /**
148
+ * The "no filter" member of a single-select facet's `FieldHandle`. A closed enum field always has
149
+ * a real member as its fallback (C4) — there is no "unset" — so a facet, whose underlying
150
+ * `column.getFilterValue()` genuinely can be `undefined`, needs one synthetic option to stand for
151
+ * that state. Rendered as the enum's first radio; picking it clears the column filter, same as every
152
+ * other value change.
153
+ *
154
+ * Its label is `Any <facet>`, and it used to be a bare `All`. The pill now reads the SELECTED
155
+ * option's label at every value including the fallback (`controls/enum-filter.tsx` — a bar reading
156
+ * `Compare` over a field holding `'previous'` is the bug that law fixes), and a toolbar with three
157
+ * facets would then have read `All` three times. `Any project` is the one label that works in both
158
+ * places at once: it is a legible popover row for "no constraint", and a legible pill readout of the
159
+ * state the filter is actually in. Derived from `facet.label`, so no facet has to declare it.
160
+ */
161
+ const FACET_ALL_VALUE = '__basalt_facet_all__'
162
+
163
+ /** The synthetic row's label — see {@link FACET_ALL_VALUE}. */
164
+ function facetAllLabel(facet: DataTableFacet): string {
165
+ return `Any ${facet.label.toLowerCase()}`
166
+ }
167
+
168
+ /**
169
+ * Presents a single-select facet column as a `FieldHandle<EnumField<string>>` so it can render
170
+ * through the shared `EnumFilter` pill instead of a hand-rolled `Select` (`basalt/hand-rolled-filter`).
171
+ * `use()` reads/writes `column.getFilterValue()`/`setFilterValue()` directly — no internal
172
+ * subscription needed, since the enclosing `BasaltDataTable` already re-renders on every
173
+ * `columnFilters` change and this handle is rebuilt fresh each render alongside its column.
174
+ */
175
+ function facetEnumHandle<T>(
176
+ column: Column<T, unknown>,
177
+ facet: DataTableFacet,
178
+ ): FieldHandle<EnumField<string>> {
179
+ return {
180
+ kind: 'enum',
181
+ fallback: FACET_ALL_VALUE,
182
+ options: [{ value: FACET_ALL_VALUE, label: facetAllLabel(facet) }, ...facet.options],
183
+ use: () => {
184
+ const current = (column.getFilterValue() as string | undefined) ?? FACET_ALL_VALUE
185
+ return [
186
+ current,
187
+ (next: string) => {
188
+ column.setFilterValue(next === FACET_ALL_VALUE ? undefined : next)
189
+ },
190
+ ] as const
191
+ },
192
+ isDefault: (value) => value === FACET_ALL_VALUE,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Presents a multi-select facet column as a `FieldHandle<MultiField<string>>`. Unlike the
198
+ * single-select case, `MultiField`'s own fallback — an empty selection — already means "no
199
+ * constraint", so no synthetic option is needed here.
200
+ */
201
+ function facetMultiHandle<T>(
202
+ column: Column<T, unknown>,
203
+ facet: DataTableFacet,
204
+ ): FieldHandle<MultiField<string>> {
205
+ return {
206
+ kind: 'multi',
207
+ fallback: [],
208
+ options: facet.options,
209
+ use: () => {
210
+ const current = (column.getFilterValue() as readonly string[] | undefined) ?? []
211
+ return [
212
+ current,
213
+ (next: readonly string[]) => {
214
+ column.setFilterValue(next.length > 0 ? [...next] : undefined)
215
+ },
216
+ ] as const
217
+ },
218
+ isDefault: (value) => value.length === 0,
219
+ }
220
+ }
221
+
146
222
  // ── Props ─────────────────────────────────────────────────────────────────────
147
223
 
148
224
  /**
@@ -159,6 +235,19 @@ export type DataTableFacet = {
159
235
  * }
160
236
  */
161
237
  export type BasaltDataTableProps<T> = {
238
+ // ── Header (WidgetHeader, docs/CONTROLS-SPEC.md §2.2) ──────────────────────────
239
+
240
+ /**
241
+ * Optional heading rendered above the toolbar via `WidgetHeader tier="widget"`. `count` always
242
+ * reads `table.getRowCount()` (C11) — never a raw `data.length` — so it tracks the row model
243
+ * (post-filter/-pagination) rather than the unfiltered input.
244
+ */
245
+ title?: string
246
+ /** Optional leading icon, forwarded to `WidgetHeader`. */
247
+ icon?: ReactNode
248
+ /** Optional muted line rendered below the title row. */
249
+ subtitle?: string
250
+
162
251
  /** Row data array. */
163
252
  data: T[]
164
253
  /**
@@ -171,15 +260,43 @@ export type BasaltDataTableProps<T> = {
171
260
  // oxlint-disable-next-line typescript/no-explicit-any -- TanStack-idiomatic heterogeneous column array
172
261
  columns: ColumnDef<T, any>[]
173
262
  /**
174
- * Enable column sorting via clickable headers.
263
+ * Enable column sorting via clickable headers. Client-side — the rows in `data` are what get
264
+ * reordered.
265
+ *
266
+ * Under `manualPagination` that is one page, so leaving this on without also passing
267
+ * `manualSorting` is a contract violation and throws in dev — see `manualPagination`.
175
268
  * @default true
176
269
  */
177
270
  enableSorting?: boolean
271
+ /**
272
+ * Hand sorting to the server: the table stops reordering `data` locally and renders it in the
273
+ * order given, while the headers keep toggling `SortingState` and reporting it through
274
+ * `onSortingChange` — which is where you re-request the sorted page.
275
+ *
276
+ * This is the required companion to `manualPagination` whenever sort headers are live; without
277
+ * it a server-paginated table sorts one page and the "of N" bar presents that as a sort of all N.
278
+ * @default false
279
+ * @example
280
+ * <BasaltDataTable
281
+ * data={page.rows}
282
+ * columns={columns}
283
+ * enablePagination
284
+ * manualPagination
285
+ * manualSorting
286
+ * rowCount={page.total}
287
+ * onSortingChange={(s) => refetch({ sort: s[0] })}
288
+ * />
289
+ */
290
+ manualSorting?: boolean
178
291
  /** Stripe alternate rows. Forwarded to Mantine `Table` — `'odd'`/`'even'` pick the phase. */
179
292
  striped?: boolean | 'odd' | 'even'
180
293
  /** Highlight hovered rows. Forwarded to Mantine `Table`. */
181
294
  highlightOnHover?: boolean
182
- /** Rendered when `data` is empty. Falls back to a simple message when omitted. */
295
+ /**
296
+ * Rendered when no rows are VISIBLE — `data` is empty, or a filter/page matches none of it.
297
+ * Falls back to a simple message when omitted. (Before 1.25.0 this tracked `data.length` alone,
298
+ * so a search matching nothing rendered a blank `<tbody>` with no message at all.)
299
+ */
183
300
  emptyState?: ReactNode
184
301
  /**
185
302
  * When true, renders skeleton placeholder rows instead of the empty-state branch.
@@ -234,18 +351,31 @@ export type BasaltDataTableProps<T> = {
234
351
  /** Called whenever the internal global filter value changes. */
235
352
  onGlobalFilterChange?: (value: string) => void
236
353
  /**
237
- * Faceted column filters, rendered in the toolbar as Mantine Select/MultiSelect controls wired
238
- * to TanStack's per-column `columnFilters` state. The toolbar renders whenever this array is
239
- * non-empty, `enableGlobalFilter` is set, or `toolbarActions` is passed.
354
+ * Faceted column filters, rendered in the toolbar as `EnumFilter`/`MultiSelectFilter` pills
355
+ * inside a `FilterSet`, wired to TanStack's per-column `columnFilters` state. The toolbar
356
+ * renders whenever this array is non-empty, `enableGlobalFilter` is set, or `actions` is passed.
240
357
  * @example
241
358
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
242
359
  */
243
360
  facets?: DataTableFacet[]
361
+ /** Called whenever the faceted column filters change — the seam for server-side faceting. */
362
+ onColumnFiltersChange?: (filters: ColumnFiltersState) => void
363
+ /**
364
+ * Hand filtering to the server: the table stops narrowing `data` locally and renders every row
365
+ * given, while the search input and facets keep reporting through `onGlobalFilterChange` /
366
+ * `onColumnFiltersChange` — which is where you re-request the filtered page.
367
+ *
368
+ * Required alongside `manualPagination` whenever `enableGlobalFilter` or `facets` is set;
369
+ * without it the controls narrow one page while "of N" keeps counting the whole set.
370
+ * @default false
371
+ */
372
+ manualFiltering?: boolean
244
373
  /**
245
374
  * Right-aligned toolbar slot (e.g. an "Export" button). Renders the toolbar row even when no
246
- * search input or facets are configured.
375
+ * search input or facets are configured. Renamed from `toolbarActions` — the toolbar (search +
376
+ * facets + this slot) is wrapped in `CtlSlot` (C1/C5), so its controls resolve to the `ctl` tier.
247
377
  */
248
- toolbarActions?: ReactNode
378
+ actions?: ReactNode
249
379
 
250
380
  // ── Pagination ────────────────────────────────────────────────────────────────
251
381
 
@@ -272,6 +402,21 @@ export type BasaltDataTableProps<T> = {
272
402
  * Disables local pagination slicing — pass the already-paginated page of `data` plus `rowCount`
273
403
  * (and optionally `pageCount`) for server-driven pagination. `onPaginationChange` is where you
274
404
  * fetch the next page.
405
+ *
406
+ * **It imposes a contract, enforced at render.** `data` is now one page, but the pagination bar
407
+ * says "Showing 1–25 of 412" — so every OTHER client-side control becomes a claim about 412 rows
408
+ * that it can only make about 25. Each has to be resolved explicitly, because only the call site
409
+ * knows whether the server does the work:
410
+ *
411
+ * - `rowCount` (or `pageCount`) must be given, or "of N" counts the page and the pager collapses
412
+ * to a single page nobody can leave;
413
+ * - sorting must be handed over with `manualSorting`, or switched off with `enableSorting={false}`;
414
+ * - `enableGlobalFilter` / `facets`, if used, must be handed over with `manualFiltering`;
415
+ * - `enablePagination` must be on, or `manualPagination` is inert and the page renders as if it
416
+ * were the whole table.
417
+ *
418
+ * Unresolved, each throws in dev and degrades to the honest render in production (no sort
419
+ * headers, no filter controls, no "of N") — never to the plausible wrong answer.
275
420
  * @default false
276
421
  * @example
277
422
  * <BasaltDataTable
@@ -334,9 +479,20 @@ export type BasaltDataTableProps<T> = {
334
479
  /** Row separators. Forwarded to Mantine `Table`. @default true (Mantine's own default) */
335
480
  withRowBorders?: boolean
336
481
  /**
337
- * Outer table border. Turn it OFF for a table that already sits inside a `Card`/`ChartCard`,
338
- * where the card owns the frame and a second one reads as a nested box.
339
- * @default true
482
+ * Outer table border — a full box around the table.
483
+ *
484
+ * **`false` by default since this minor** (it was `true`). Three separate faults, all visible on
485
+ * the playground's `Controls (mobile)` page at 390px: its TOP edge sat directly under the table's
486
+ * own `WidgetHeader`, separating a header from its own content; its BOTTOM edge landed on the last
487
+ * row's own hairline, two rules at zero distance; and its left/right edges carried no information
488
+ * at all on a table that fills the page (`docs/DESIGN-SPEC.md` §8 — a border is a layout divider,
489
+ * and there is nothing here to divide). The head-row rule and the between-row rules stay, so the
490
+ * table still reads as a table.
491
+ *
492
+ * Pass `true` for a table that genuinely needs a frame — a small table floating in whitespace
493
+ * beside other content, where the box IS the grouping.
494
+ *
495
+ * @default false
340
496
  */
341
497
  withTableBorder?: boolean
342
498
  }
@@ -373,6 +529,23 @@ const RANGE_LABEL_STYLE: CSSProperties = {
373
529
 
374
530
  const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]
375
531
 
532
+ /**
533
+ * The header row's two halves. The lead SHRINKS and the toolbar does not — a title can ellipsize, a
534
+ * 220px search field with a facet pill beside it cannot, and when both were shrinkable flex split
535
+ * the overflow between them and clipped the search's placeholder mid-word.
536
+ */
537
+ const TABLE_HEADER_LEAD_STYLE: CSSProperties = { flex: '1 1 auto', minWidth: 0 }
538
+ const TABLE_TOOLBAR_STYLE: CSSProperties = { flex: '0 0 auto' }
539
+ /**
540
+ * The toolbar search's width. Mantine's Input grows to its container, which in a `justify:
541
+ * space-between` row means "whatever is left" — so the field was 600px on a wide table and 180px on a
542
+ * narrow one, for the same one-word query. A stated width also makes the toolbar's own minimum width
543
+ * knowable, which is what lets it wrap under the title below `sm` with no media query.
544
+ */
545
+ const SEARCH_WIDTH = 220
546
+ /** The rows-per-page Select's width — see `SEARCH_WIDTH` for why a toolbar field states one. */
547
+ const PAGE_SIZE_WIDTH = 116
548
+
376
549
  // ── Column pinning helpers ────────────────────────────────────────────────────
377
550
 
378
551
  // Merges the left/center/right header-group triad TanStack exposes for pinning into the single
@@ -423,14 +596,110 @@ function getPinnedCellStyle<T>(
423
596
  }
424
597
  }
425
598
 
599
+ // ── The manual-pagination contract ────────────────────────────────────────────
600
+
601
+ /** The house dev gate (`src/provider`, `src/charts/kinds/BandStrip` use the same expression):
602
+ * `basaltViteConfig` defines `process.env.NODE_ENV`, so a production bundle constant-folds this to
603
+ * `false` and drops the throw. Read per call, never hoisted, so a test can flip it. */
604
+ function isDev(): boolean {
605
+ return process.env['NODE_ENV'] !== 'production'
606
+ }
607
+
608
+ /** One client-side control that `manualPagination` turns into a claim the table cannot support. */
609
+ type ManualPaginationBreach = 'inert' | 'total' | 'sorting' | 'filtering'
610
+
611
+ const BREACH_REMEDY: Record<ManualPaginationBreach, string> = {
612
+ inert:
613
+ '`enablePagination` is not set, so `manualPagination` never reaches the table and the server ' +
614
+ 'page renders as if it were the whole set, with no bar to say otherwise. Pass ' +
615
+ '`enablePagination`, or drop `manualPagination`.',
616
+ total:
617
+ 'neither `rowCount` nor `pageCount` was given, so "of N" counts only the rows on this page and ' +
618
+ 'the pager collapses to one page nobody can leave. Pass `rowCount={total}`.',
619
+ sorting:
620
+ 'client-side sorting is still armed. It reorders only the rows in `data` — one page — while the ' +
621
+ 'header chevron and "of N" together present it as a sort of all of them. Pass `manualSorting` ' +
622
+ 'and sort in the request you make from `onSortingChange`, or `enableSorting={false}`.',
623
+ filtering:
624
+ 'the search input / facets still filter client-side. They narrow only the rows in `data` — one ' +
625
+ 'page — while "of N" keeps counting the whole set. Pass `manualFiltering` and filter in the ' +
626
+ 'request you make from `onGlobalFilterChange` / `onColumnFiltersChange`, or drop ' +
627
+ '`enableGlobalFilter` / `facets`.',
628
+ }
629
+
630
+ /**
631
+ * Every way the props on hand contradict `manualPagination`. Computed from PROPS only — never from
632
+ * the table instance, whose `getCanSort()` would read back the very `enableSorting` this decides.
633
+ */
634
+ function manualPaginationBreaches(config: {
635
+ columns: readonly { enableSorting?: boolean }[]
636
+ enablePagination: boolean
637
+ manualPagination: boolean
638
+ rowCount: number | undefined
639
+ pageCount: number | undefined
640
+ enableSorting: boolean
641
+ manualSorting: boolean
642
+ enableGlobalFilter: boolean
643
+ hasFacets: boolean
644
+ manualFiltering: boolean
645
+ }): ManualPaginationBreach[] {
646
+ if (!config.manualPagination) return []
647
+ if (!config.enablePagination) return ['inert']
648
+ const breaches: ManualPaginationBreach[] = []
649
+ if (config.rowCount === undefined && config.pageCount === undefined) breaches.push('total')
650
+ const anyColumnMaySort = config.columns.some((column) => column.enableSorting !== false)
651
+ if (config.enableSorting && anyColumnMaySort && !config.manualSorting) breaches.push('sorting')
652
+ if ((config.enableGlobalFilter || config.hasFacets) && !config.manualFiltering)
653
+ breaches.push('filtering')
654
+ return breaches
655
+ }
656
+
657
+ function manualPaginationMessage(breaches: ManualPaginationBreach[]): string {
658
+ return (
659
+ 'BasaltDataTable: `manualPagination` is set, so `data` is one server page — but:\n · ' +
660
+ breaches.map((breach) => BREACH_REMEDY[breach]).join('\n · ') +
661
+ '\nEach has to be resolved explicitly: only the call site knows whether the server does the ' +
662
+ 'work, so there is no default that is not a guess about the data.'
663
+ )
664
+ }
665
+
666
+ /** Bounded by the sixteen possible breach sets, so it cannot grow with render count. */
667
+ const reportedBreaches = new Set<string>()
668
+
669
+ /**
670
+ * Dev throws; production reports once and lets the caller degrade.
671
+ *
672
+ * Split deliberately from BandStrip's datum/prop rule rather than copied. That rule throws on a bad
673
+ * PROP because a prop is authored, deterministic, and caught the first time the component renders —
674
+ * all true here. What differs is that a bad `meta.align` has no honest render (every alignment we
675
+ * could pick might be the wrong one, and would look right in review), whereas this one does: a
676
+ * table with no sort headers, no filter controls and no "of N" asserts nothing false. Throwing in
677
+ * production would add no correctness the degradation does not already give, and would convert a
678
+ * misreport on a rarely-visited page into a blank one. The report is what keeps the degradation
679
+ * from being its own silence.
680
+ */
681
+ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): void {
682
+ if (breaches.length === 0) return
683
+ const message = manualPaginationMessage(breaches)
684
+ if (isDev()) throw new Error(message)
685
+ if (reportedBreaches.has(message)) return
686
+ reportedBreaches.add(message)
687
+ console.error(message)
688
+ }
689
+
426
690
  // ── Component ─────────────────────────────────────────────────────────────────
427
691
 
428
692
  /**
429
693
  * A sortable data table backed by TanStack Table and rendered with Mantine primitives.
430
694
  * Generic over `T` — column types are inferred from `ColumnDef<T>`.
431
695
  *
432
- * Sorting is local/client-side (no `any`). When `enableSorting` is `false` the table
433
- * is read-only. An `emptyState` node is rendered when `data` is empty.
696
+ * Sorting is local/client-side by default (no `any`); `manualSorting` hands it to the server.
697
+ * When `enableSorting` is `false` the table is read-only. An `emptyState` node is rendered
698
+ * whenever no rows are visible — empty `data`, or a filter that matches none of it.
699
+ *
700
+ * `manualPagination` imposes a contract on the rest of the props (see its docblock): every
701
+ * client-side control it would turn into a false claim about the unseen rows must be handed to the
702
+ * server or switched off, and an unresolved one throws in dev rather than misreporting.
434
703
  *
435
704
  * Toolbar (search + facets), pagination, and column pinning are opt-in chrome layered on top —
436
705
  * see the corresponding props below. None of them change rendering unless explicitly enabled.
@@ -446,9 +715,13 @@ function getPinnedCellStyle<T>(
446
715
  * />
447
716
  */
448
717
  export function BasaltDataTable<T>({
718
+ title,
719
+ icon,
720
+ subtitle,
449
721
  data,
450
722
  columns,
451
723
  enableSorting = true,
724
+ manualSorting = false,
452
725
  striped,
453
726
  highlightOnHover,
454
727
  emptyState,
@@ -462,7 +735,9 @@ export function BasaltDataTable<T>({
462
735
  initialGlobalFilter,
463
736
  onGlobalFilterChange,
464
737
  facets,
465
- toolbarActions,
738
+ onColumnFiltersChange,
739
+ manualFiltering = false,
740
+ actions,
466
741
  enablePagination = false,
467
742
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
468
743
  initialPagination,
@@ -479,7 +754,7 @@ export function BasaltDataTable<T>({
479
754
  verticalSpacing,
480
755
  horizontalSpacing,
481
756
  withRowBorders,
482
- withTableBorder = true,
757
+ withTableBorder = false,
483
758
  }: BasaltDataTableProps<T>) {
484
759
  const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
485
760
  const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
@@ -511,6 +786,17 @@ export function BasaltDataTable<T>({
511
786
  [onGlobalFilterChange],
512
787
  )
513
788
 
789
+ const handleColumnFiltersChange = useCallback(
790
+ (updater: Updater<ColumnFiltersState>) => {
791
+ setColumnFilters((prev) => {
792
+ const next = typeof updater === 'function' ? updater(prev) : updater
793
+ onColumnFiltersChange?.(next)
794
+ return next
795
+ })
796
+ },
797
+ [onColumnFiltersChange],
798
+ )
799
+
514
800
  const handlePaginationChange = useCallback(
515
801
  (updater: Updater<PaginationState>) => {
516
802
  setPagination((prev) => {
@@ -550,6 +836,27 @@ export function BasaltDataTable<T>({
550
836
  })
551
837
  }, [columns, facets])
552
838
 
839
+ // Enforced before the table is built, so the degraded options below are the ones TanStack gets —
840
+ // a degraded table must not merely hide a control, it must not compute the answer either.
841
+ const hasFacets = Boolean(facets && facets.length > 0)
842
+ const breaches = manualPaginationBreaches({
843
+ columns,
844
+ enablePagination,
845
+ manualPagination,
846
+ rowCount,
847
+ pageCount,
848
+ enableSorting,
849
+ manualSorting,
850
+ enableGlobalFilter,
851
+ hasFacets,
852
+ manualFiltering,
853
+ })
854
+ enforceManualPaginationContract(breaches)
855
+ // Unreachable in dev (the line above threw); in production these are the honest fallbacks.
856
+ const sortingEnabled = enableSorting && !breaches.includes('sorting')
857
+ const filteringEnabled = !breaches.includes('filtering')
858
+ const totalIsAuthoritative = !breaches.includes('total')
859
+
553
860
  const table = useReactTable<T>({
554
861
  data,
555
862
  columns: tableColumns,
@@ -562,25 +869,36 @@ export function BasaltDataTable<T>({
562
869
  },
563
870
  onSortingChange: handleSortingChange,
564
871
  onGlobalFilterChange: handleGlobalFilterChange,
565
- onColumnFiltersChange: setColumnFilters,
872
+ onColumnFiltersChange: handleColumnFiltersChange,
566
873
  onColumnPinningChange: setColumnPinning,
567
874
  ...(enablePagination && { onPaginationChange: handlePaginationChange }),
568
875
  getCoreRowModel: getCoreRowModel(),
569
876
  getSortedRowModel: getSortedRowModel(),
570
877
  getFilteredRowModel: getFilteredRowModel(),
878
+ // Both row models stay installed and are BYPASSED by these flags, which is TanStack's own
879
+ // seam — so a degraded table renders `data` in the order and completeness the server sent it.
880
+ // Forced only by a BREACH, never by `enableSorting={false}` on its own: that pair still applies
881
+ // an `initialSorting` today (it disables the header control, not the row model) and a table
882
+ // that is not misreporting has no business losing it.
883
+ manualSorting: manualSorting || breaches.includes('sorting'),
884
+ manualFiltering: manualFiltering || breaches.includes('filtering'),
571
885
  ...(enablePagination && {
572
886
  getPaginationRowModel: getPaginationRowModel(),
573
887
  manualPagination,
574
888
  ...(manualPagination && rowCount !== undefined && { rowCount }),
575
889
  ...(manualPagination && pageCount !== undefined && { pageCount }),
576
890
  }),
577
- enableSorting,
891
+ enableSorting: sortingEnabled,
578
892
  enableColumnPinning: enablePinning,
579
893
  })
580
894
 
581
- const showToolbar =
582
- enableGlobalFilter || Boolean(facets && facets.length > 0) || Boolean(toolbarActions)
895
+ const showSearch = enableGlobalFilter && filteringEnabled
896
+ const showFacets = hasFacets && filteringEnabled
897
+ const showToolbar = showSearch || showFacets || Boolean(actions)
583
898
  const headerGroups = enablePinning ? getOrderedHeaderGroups(table) : table.getHeaderGroups()
899
+ // The rows actually rendered, not `data` — a filter or a page index that matches nothing leaves
900
+ // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
901
+ const rows = table.getRowModel().rows
584
902
 
585
903
  const tableNode = (
586
904
  <Table
@@ -597,7 +915,7 @@ export function BasaltDataTable<T>({
597
915
  {headerGroups.map((headerGroup) => (
598
916
  <Table.Tr key={headerGroup.id}>
599
917
  {headerGroup.headers.map((header) => {
600
- const canSort = enableSorting && header.column.getCanSort()
918
+ const canSort = sortingEnabled && header.column.getCanSort()
601
919
  const sorted = header.column.getIsSorted()
602
920
  const toggleSorting = header.column.getToggleSortingHandler()
603
921
  const pinnedStyle = enablePinning
@@ -654,7 +972,7 @@ export function BasaltDataTable<T>({
654
972
  ))}
655
973
  </Table.Tr>
656
974
  ))
657
- ) : data.length === 0 ? (
975
+ ) : rows.length === 0 ? (
658
976
  <Table.Tr>
659
977
  <Table.Td colSpan={columns.length}>
660
978
  {emptyState ?? (
@@ -665,7 +983,7 @@ export function BasaltDataTable<T>({
665
983
  </Table.Td>
666
984
  </Table.Tr>
667
985
  ) : (
668
- table.getRowModel().rows.map((row) => {
986
+ rows.map((row) => {
669
987
  const cells = enablePinning
670
988
  ? [
671
989
  ...row.getLeftVisibleCells(),
@@ -709,62 +1027,81 @@ export function BasaltDataTable<T>({
709
1027
  const scrolls = maxHeight !== undefined || minWidth !== undefined
710
1028
 
711
1029
  const paginationState = table.getState().pagination
1030
+ // The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
1031
+ // Select renders EMPTY when its `value` matches no row, so a table opened at
1032
+ // `initialPagination.pageSize: 5` against the default `[10, 25, 50, 100]` showed a blank
1033
+ // rows-per-page box — a control stating nothing, next to a range label stating `1–5`.
1034
+ const pageSizeRows = [...new Set([...pageSizeOptions, paginationState.pageSize])]
1035
+ .sort((a, b) => a - b)
1036
+ .map((size) => ({ value: String(size), label: `${size} / page` }))
712
1037
  const total = table.getRowCount()
713
1038
  const rangeStart = total === 0 ? 0 : paginationState.pageIndex * paginationState.pageSize + 1
714
1039
  const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
715
1040
 
716
1041
  return (
717
1042
  <>
718
- {showToolbar && (
719
- <Group justify="space-between" align="flex-end" wrap="wrap" gap="xs" mb="xs">
720
- <Group gap="xs" wrap="wrap" align="flex-end">
721
- {enableGlobalFilter && (
722
- <TextInput
723
- size="xs"
724
- radius="md"
725
- placeholder={globalFilterPlaceholder}
726
- leftSection={searchIcon}
727
- value={globalFilter}
728
- onChange={(event) => handleGlobalFilterChange(event.currentTarget.value)}
729
- w={220}
1043
+ {/*
1044
+ * ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
1045
+ * the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
1046
+ * spent title → 11px → search → 11px → thead before a single datum appeared, and the toolbar
1047
+ * read as page furniture rather than as this table's controls.
1048
+ *
1049
+ * `wrap="wrap"` is the below-`sm` behaviour and needs no media query: the toolbar's minimum
1050
+ * width (a 220px search plus its pills) exceeds what is left beside a title on a phone, so flex
1051
+ * wraps it under the title on its own. `align="center"` puts the 30px toolbar controls on the
1052
+ * title's optical line rather than on its baseline.
1053
+ */}
1054
+ {(title !== undefined || showToolbar) && (
1055
+ <Group justify="space-between" align="center" wrap="wrap" gap="xs" mb="xs">
1056
+ {title !== undefined ? (
1057
+ <Box style={TABLE_HEADER_LEAD_STYLE}>
1058
+ <WidgetHeader
1059
+ tier="widget"
1060
+ title={title}
1061
+ {...(icon !== undefined && { icon })}
1062
+ {...(subtitle !== undefined && { subtitle })}
1063
+ count={table.getRowCount()}
730
1064
  />
731
- )}
732
- {facets?.map((facet) => {
733
- const column = table.getColumn(facet.columnId)
734
- if (!column) return null
735
- if (facet.multiple) {
736
- const value = (column.getFilterValue() as string[] | undefined) ?? []
737
- return (
738
- <MultiSelect
739
- key={facet.columnId}
740
- size="xs"
1065
+ </Box>
1066
+ ) : null}
1067
+ {showToolbar && (
1068
+ <CtlSlot>
1069
+ <Group gap="xs" wrap="wrap" align="center" style={TABLE_TOOLBAR_STYLE}>
1070
+ {showSearch && (
1071
+ <TextInput
741
1072
  radius="md"
742
- placeholder={facet.label}
743
- data={facet.options}
744
- value={value}
745
- onChange={(next) => column.setFilterValue(next.length > 0 ? next : undefined)}
746
- clearable
747
- w={200}
1073
+ w={SEARCH_WIDTH}
1074
+ placeholder={globalFilterPlaceholder}
1075
+ leftSection={searchIcon}
1076
+ value={globalFilter}
1077
+ onChange={(event) => handleGlobalFilterChange(event.currentTarget.value)}
748
1078
  />
749
- )
750
- }
751
- const value = (column.getFilterValue() as string | undefined) ?? null
752
- return (
753
- <Select
754
- key={facet.columnId}
755
- size="xs"
756
- radius="md"
757
- placeholder={facet.label}
758
- data={facet.options}
759
- value={value}
760
- onChange={(next) => column.setFilterValue(next ?? undefined)}
761
- clearable
762
- w={180}
763
- />
764
- )
765
- })}
766
- </Group>
767
- {toolbarActions}
1079
+ )}
1080
+ {showFacets && (
1081
+ <FilterSet>
1082
+ {facets?.map((facet) => {
1083
+ const column = table.getColumn(facet.columnId)
1084
+ if (!column) return null
1085
+ return facet.multiple ? (
1086
+ <MultiSelectFilter
1087
+ key={facet.columnId}
1088
+ field={facetMultiHandle(column, facet)}
1089
+ label={facet.label}
1090
+ />
1091
+ ) : (
1092
+ <EnumFilter
1093
+ key={facet.columnId}
1094
+ field={facetEnumHandle(column, facet)}
1095
+ label={facet.label}
1096
+ />
1097
+ )
1098
+ })}
1099
+ </FilterSet>
1100
+ )}
1101
+ {actions}
1102
+ </Group>
1103
+ </CtlSlot>
1104
+ )}
768
1105
  </Group>
769
1106
  )}
770
1107
  {scrolls ? (
@@ -783,20 +1120,18 @@ export function BasaltDataTable<T>({
783
1120
  {enablePagination && (
784
1121
  <Group justify="space-between" mt="xs" wrap="wrap" gap="xs" align="center">
785
1122
  <Text style={RANGE_LABEL_STYLE}>
786
- Showing {rangeStart}–{rangeEnd} of {total}
1123
+ Showing {rangeStart}–{rangeEnd}
1124
+ {totalIsAuthoritative ? ` of ${total}` : ''}
787
1125
  </Text>
788
1126
  <Group gap="xs" align="center">
789
1127
  <Select
790
- size="xs"
1128
+ size="ctl"
791
1129
  radius="md"
792
- data={pageSizeOptions.map((size) => ({
793
- value: String(size),
794
- label: `${size} / page`,
795
- }))}
1130
+ w={PAGE_SIZE_WIDTH}
1131
+ data={pageSizeRows}
796
1132
  value={String(paginationState.pageSize)}
797
1133
  onChange={(value) => value && table.setPageSize(Number(value))}
798
1134
  allowDeselect={false}
799
- w={110}
800
1135
  />
801
1136
  <Pagination
802
1137
  size="sm"