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
@@ -1,9 +1,23 @@
1
1
  /**
2
- * StatCard — the KPI atom (docs/DESIGN-SPEC.md §5): a panel + shadow-card + card-radius card
3
- * (spacing xs/sm inset, ~118px min-height). Header row = mono uppercase micro-label + optional
4
- * `menu` slot (e.g. a ghost "..." trigger, consumer-owned); value row = mono ~24px value +
5
- * optional `DeltaBadge` (via `delta`, with an optional `deltaPeriod` timeframe); bottom = optional
6
- * full-bleed `sparkline` slot.
2
+ * StatCard — the KPI atom (docs/DESIGN-SPEC.md §5, docs/CONTROLS-SPEC.md §2.2): a panel +
3
+ * shadow-card + card-radius card (spacing xs/sm inset, no min-height — the content states the
4
+ * height, ~88-96px for the title/hero/trend shape). The header composes
5
+ * `WidgetHeader tier="widget"` — title/icon/value/delta/deltaPeriod all render through it, on the
6
+ * shared hero-metric row under the title. `actions` (e.g. a ghost "..." menu trigger) is wrapped in
7
+ * `CtlSlot tier="widget"` — the 24px ActionIcon step the 28px widget header row can hold, not the
8
+ * 30px `ctl` tier, which used to grow the row and knock a card with a kebab 2px out of line with the
9
+ * card beside it. `StatCard` is Mantine-coupled (`src/dashboard/`), unlike `ChartCard`, so it can
10
+ * mount the slot at all; `ChartCard` writes the same `data-basalt-tier="widget"` marker by hand.
11
+ *
12
+ * `sparklinePlacement` decides where the trend visual sits: `'bleed'` (default) keeps the historic
13
+ * full-width row beneath the hero value, bled past the card's own inset padding; `'right'` sits it
14
+ * beside the hero row, the reference-design look. Below `sm`, `'right'` collapses back to the
15
+ * `'bleed'` layout — CSS only, no JS branch (`stat-card.module.css`).
16
+ *
17
+ * `info` and `subtitle` are forwarded to that same `WidgetHeader`, and a hero KPI usually wants
18
+ * both: `value` is typed `string`, so the unit and the basis have nowhere else to go — `subtitle`
19
+ * carries them as a muted line, and `info` puts "how this number is computed" behind the glyph
20
+ * beside the title instead of inside the heading's accessible name.
7
21
  *
8
22
  * `src/dashboard` stays @visx-free — `sparkline` is a plain `ReactNode` slot, never a chart import
9
23
  * here. Pass a `LineSparkline`/`BarSparkline` from `basalt-ui/charts` at the call site.
@@ -24,20 +38,44 @@
24
38
  * import { StatCard } from 'basalt-ui'
25
39
  * import { LineSparkline } from 'basalt-ui/charts'
26
40
  *
41
+ * // `sparkline` is a RENDER PROP over the slot's measured box, so full-bleed is genuinely the
42
+ * // card's width — no `useChartSize` wrapper component, no hardcoded pixel value.
27
43
  * <StatCard
28
- * label="Active Users"
44
+ * title="Active Users"
29
45
  * value="12,483"
30
46
  * delta={4.2}
31
- * sparkline={<LineSparkline data={history} width={160} height={32} />}
47
+ * sparkline={({ width, height }) => <LineSparkline data={history} width={width} height={height} />}
48
+ * />
49
+ *
50
+ * @example
51
+ * // The reference-design look — sparkline beside the hero value row instead of bled below it. The
52
+ * // slot is a fixed 72×26 there, so the render prop still receives the box it should draw into.
53
+ * <StatCard
54
+ * title="Active Users"
55
+ * value="12,483"
56
+ * delta={4.2}
57
+ * sparklinePlacement="right"
58
+ * sparkline={({ width, height }) => (
59
+ * <BarSparkline data={history} width={width} height={height} ariaLabel="Active users trend" />
60
+ * )}
61
+ * />
62
+ *
63
+ * @example
64
+ * // A hero card: the unit on its own muted line, the method behind the info glyph.
65
+ * <StatCard
66
+ * title="Training load"
67
+ * value="412"
68
+ * subtitle="TSS · 7-day rolling"
69
+ * info="Sum of per-session TSS over the last 7 days, Garmin-reported."
32
70
  * />
33
71
  *
34
72
  * @example
35
73
  * // Past a threshold — an accent rail down the leading edge, value untouched.
36
- * <StatCard label="Downtime" value="29 min" tone="bad" />
74
+ * <StatCard title="Downtime" value="29 min" tone="bad" />
37
75
  *
38
76
  * @example
39
77
  * // The earned zero — a measured value worth asserting, not a card that merely has no reading.
40
- * <StatCard label="Downtime · last 24h" value="0 min" tone="good" />
78
+ * <StatCard title="Downtime · last 24h" value="0 min" tone="good" />
41
79
  *
42
80
  * @example
43
81
  * // The honest gate. A zero can be synthesized as well as measured — a downtime figure derived
@@ -46,19 +84,86 @@
46
84
  * // about a reading, so the gate belongs on whether a reading exists, not on whether the number
47
85
  * // happens to be defined — those are separate facts. Gate on measured coverage instead:
48
86
  * <StatCard
49
- * label="Downtime · last 24h"
87
+ * title="Downtime · last 24h"
50
88
  * value={`${downtimeMinutes} min`}
51
89
  * tone={hasCoverage ? (downtimeMinutes === 0 ? 'good' : 'bad') : undefined}
52
90
  * />
53
91
  */
54
- import { Box, Card, Group, VisuallyHidden } from '@mantine/core'
55
- import type { ReactNode } from 'react'
56
- import { DeltaBadge } from './delta-badge'
92
+ import { Box, Card, VisuallyHidden } from '@mantine/core'
93
+ import { useEffect, useRef, useState } from 'react'
94
+ import type { CSSProperties, ReactNode } from 'react'
95
+ import { WidgetHeader } from '../widget-header'
96
+ import { CtlSlot } from '../theme'
57
97
  import { VX } from '../tokens'
98
+ import classes from './stat-card.module.css'
58
99
 
59
100
  /** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
60
101
  export type StatCardTone = 'good' | 'warn' | 'bad'
61
102
 
103
+ /**
104
+ * The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
105
+ * KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
106
+ * long formatted value plus a delta badge in a 4-up grid) the slot is the side that yields, because
107
+ * the number is the card's content and the trend qualifies it. The render prop is handed the
108
+ * MEASURED width either way, so the bars are drawn at the width they actually got — the previous
109
+ * fixed 72 painted over the value instead.
110
+ */
111
+ const SPARKLINE_RIGHT_WIDTH = 72
112
+ /** The `'right'` placement's slot height — sized to the hero row it sits beside, not to the card. */
113
+ const SPARKLINE_RIGHT_HEIGHT = 26
114
+
115
+ /**
116
+ * The measured box a `sparkline` render prop receives.
117
+ *
118
+ * A `LineSparkline`/`BarSparkline` takes `width`/`height` as NUMBERS (they are SVG attributes — an
119
+ * svg cannot size itself from CSS and then scale its scales), so "full bleed" was never expressible
120
+ * as a `ReactNode`: the caller had to guess a pixel width, or write its own `useChartSize` wrapper
121
+ * component. Four consumers wrote that wrapper; two of them hardcoded a width and drew a sparkline
122
+ * that stopped short of the card edge on every viewport but the one they built on.
123
+ */
124
+ export type StatCardSparklineSize = {
125
+ width: number
126
+ height: number
127
+ }
128
+
129
+ /**
130
+ * Measures a slot and re-measures on resize. Plain `ResizeObserver`, not `useChartSize`:
131
+ * `src/dashboard/**` is Mantine-coupled and @visx-FREE (see the module doc), and `useChartSize`
132
+ * wraps `@visx/responsive`.
133
+ *
134
+ * The `undefined` guard is for the test DOM — happy-dom ships no `ResizeObserver`, and the one-shot
135
+ * measure above it is what keeps a mounted card rendering its sparkline there.
136
+ */
137
+ function useSlotWidth(active: boolean): {
138
+ ref: (node: HTMLDivElement | null) => void
139
+ width: number
140
+ } {
141
+ const [width, setWidth] = useState(0)
142
+ const nodeRef = useRef<HTMLDivElement | null>(null)
143
+
144
+ useEffect(() => {
145
+ const node = nodeRef.current
146
+ if (!active || node === null) return
147
+ const measure = () => {
148
+ setWidth(node.getBoundingClientRect().width)
149
+ }
150
+ measure()
151
+ if (typeof ResizeObserver === 'undefined') return
152
+ const observer = new ResizeObserver(measure)
153
+ observer.observe(node)
154
+ return () => {
155
+ observer.disconnect()
156
+ }
157
+ }, [active])
158
+
159
+ return {
160
+ ref: (node) => {
161
+ nodeRef.current = node
162
+ },
163
+ width,
164
+ }
165
+ }
166
+
62
167
  /** The rail's width, in px. Wide enough to read as a deliberate mark at a glance, narrow enough
63
168
  * that it cannot be mistaken for the card's own edge. */
64
169
  const TONE_RAIL_WIDTH = 3
@@ -70,19 +175,41 @@ const TONE_LABEL: Record<StatCardTone, string> = {
70
175
  }
71
176
 
72
177
  export type StatCardProps = {
73
- /** Mono uppercase micro-label (docs/DESIGN-SPEC.md §3: 12.5px, tracking 0.06em, faint). */
74
- label: string
75
- /** Pre-formatted KPI value string (mono ~24px, weight 600, ink). */
178
+ /** Head-font title, rendered via `WidgetHeader tier="widget"`. */
179
+ title: string
180
+ /** Optional leading icon, forwarded to `WidgetHeader`. */
181
+ icon?: ReactNode
182
+ /** Muted line under the hero row, forwarded to `WidgetHeader` — the unit or basis a
183
+ * pre-formatted `value` cannot carry (`per day`, `of 40 planned`). Not a second metric. */
184
+ subtitle?: string
185
+ /** Info tooltip beside the title, forwarded to `WidgetHeader` — how the number is computed. Never
186
+ * part of the heading's accessible name; see `WidgetHeaderProps.info`. */
187
+ info?: string
188
+ /** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
76
189
  value: string
77
190
  /** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
78
191
  delta?: number
79
192
  /** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
80
193
  deltaPeriod?: string
81
- /** Optional trend visual — a slot; pass a `LineSparkline`/`BarSparkline` from `basalt-ui/charts`.
82
- * Rendered full-bleed to the card's left/right/bottom edges, clipped to the corner radius. */
83
- sparkline?: ReactNode
84
- /** Optional header-right slot (e.g. a ghost "..." menu trigger) — consumer-owned. */
85
- menu?: ReactNode
194
+ /**
195
+ * Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
196
+ *
197
+ * Prefer the render prop for anything from `basalt-ui/charts`: those take numeric `width`/`height`
198
+ * (SVG attributes), so a plain node has to hardcode a width, and a hardcoded width is not
199
+ * full-bleed on any viewport but the one it was typed on. With `sparklinePlacement="bleed"` the
200
+ * measured width is the card's own inner width including the bled inset; with `'right'` it is the
201
+ * fixed 72×26 slot (see {@link SPARKLINE_RIGHT_WIDTH} for why that one is not measured).
202
+ *
203
+ * @example
204
+ * sparkline={({ width, height }) => <BarSparkline data={history} width={width} height={height} />}
205
+ */
206
+ sparkline?: ReactNode | ((size: StatCardSparklineSize) => ReactNode)
207
+ /** Where `sparkline` sits. `'bleed'` (default) is today's full-width row bled to the card edges;
208
+ * `'right'` sits it beside the hero value row. Collapses to `'bleed'` below `sm`. */
209
+ sparklinePlacement?: 'bleed' | 'right'
210
+ /** Header-right slot (e.g. a ghost "..." menu trigger) — wrapped in `CtlSlot tier="widget"`, so a
211
+ * raw `ActionIcon` with no `size` lands on the 24px step the 28px header row holds (C1/C5). */
212
+ actions?: ReactNode
86
213
  /** Threshold verdict — draws an accent rail down the card's leading edge and announces itself to
87
214
  * assistive tech. Omitting it is NOT `'good'`: omitted covers a reading that is fine AND one that
88
215
  * is absent, and stays untinted so a card with nothing measured can never render green. Pass
@@ -91,14 +218,24 @@ export type StatCardProps = {
91
218
  }
92
219
 
93
220
  export function StatCard({
94
- label,
221
+ title,
222
+ icon,
223
+ subtitle,
224
+ info,
95
225
  value,
96
226
  delta,
97
227
  deltaPeriod,
98
228
  sparkline,
99
- menu,
229
+ sparklinePlacement = 'bleed',
230
+ actions,
100
231
  tone,
101
232
  }: StatCardProps) {
233
+ const isRender = typeof sparkline === 'function'
234
+ const bleeds = sparklinePlacement === 'bleed'
235
+ // Only a render prop needs a measurement; a plain node sizes itself.
236
+ const slot = useSlotWidth(isRender)
237
+ const measured: StatCardSparklineSize = { width: slot.width, height: SPARKLINE_RIGHT_HEIGHT }
238
+
102
239
  return (
103
240
  <Card
104
241
  style={{
@@ -108,11 +245,13 @@ export function StatCard({
108
245
  // shadow-card ring is unaffected (verified — only an ANCESTOR's overflow clips a
109
246
  // descendant's shadow). Mantine's Card root already sets `overflow: hidden`; this is
110
247
  // explicit for intent.
248
+ //
249
+ // NO `minHeight`. It was 118px, which is ~25px of dead space under a card holding a title
250
+ // row, a hero row and a 26px trend — and it was dead space the card could not use, since the
251
+ // body is `height: 100%` and the sparkline is `margin-top: auto`. The content is what states
252
+ // the height now (~88-96px), and four cards in a `SimpleGrid` still match because a grid row
253
+ // stretches its items.
111
254
  padding: 'var(--mantine-spacing-xs) var(--mantine-spacing-sm)',
112
- minHeight: 118,
113
- display: 'flex', // theme-allow inline-display — Card root as a flex column; Mantine Card takes no layout props
114
- flexDirection: 'column',
115
- gap: 'var(--vx-space-stat-card-gap)',
116
255
  overflow: 'hidden',
117
256
  // Anchors the tone rail. Set unconditionally so a card's stacking context does not change
118
257
  // depending on whether it happens to have crossed a threshold this render.
@@ -143,48 +282,50 @@ export function StatCard({
143
282
  </>
144
283
  )}
145
284
 
146
- <Group justify="space-between" align="flex-start" wrap="nowrap" gap="xs">
147
- <span
148
- style={{
149
- fontFamily: 'var(--basalt-font-mono)',
150
- fontSize: VX.text.xs,
151
- fontWeight: 500,
152
- letterSpacing: '0.06em',
153
- textTransform: 'uppercase',
154
- color: VX.faint,
155
- }}
156
- >
157
- {label}
158
- </span>
159
- {menu}
160
- </Group>
161
-
162
- <Group align="center" gap={8} wrap="nowrap">
163
- <span
164
- style={{
165
- fontFamily: 'var(--basalt-font-mono)',
166
- fontSize: VX.text.kpi,
167
- fontWeight: 600,
168
- letterSpacing: '-0.02em',
169
- color: VX.ink,
170
- }}
171
- >
172
- {value}
173
- </span>
174
- {delta !== undefined && <DeltaBadge value={delta} period={deltaPeriod} />}
175
- </Group>
176
-
177
- {sparkline && (
178
- <div
179
- style={{
180
- marginTop: 'auto',
181
- marginInline: 'calc(-1 * var(--mantine-spacing-sm))',
182
- marginBottom: 'calc(-1 * var(--mantine-spacing-xs))',
183
- }}
184
- >
185
- {sparkline}
285
+ <div className={classes.body} data-placement={sparklinePlacement}>
286
+ <div className={classes.header}>
287
+ <WidgetHeader
288
+ tier="widget"
289
+ title={title}
290
+ {...(icon !== undefined && { icon })}
291
+ {...(subtitle !== undefined && { subtitle })}
292
+ {...(info !== undefined && { info })}
293
+ value={value}
294
+ {...(delta !== undefined && { delta })}
295
+ {...(deltaPeriod !== undefined && { deltaPeriod })}
296
+ // `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
297
+ // (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
298
+ // with a kebab sat 2px below a card without one in the same grid row. See
299
+ // `theme/ctl-theme.tsx`'s `WIDGET_THEME`.
300
+ {...(actions !== undefined && {
301
+ actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
302
+ })}
303
+ />
186
304
  </div>
187
- )}
305
+
306
+ {sparkline !== undefined && (
307
+ <div
308
+ ref={slot.ref}
309
+ className={bleeds ? classes.sparklineBleed : classes.sparklineRight}
310
+ {...(!bleeds && {
311
+ // CUSTOM PROPERTIES, not `flexBasis`/`height` directly. A React inline style beats
312
+ // every stylesheet rule, so an inline `flexBasis: 72` also applied below `sm` — where
313
+ // `'right'` collapses to a COLUMN and flex-basis is therefore the main-axis HEIGHT.
314
+ // The mobile sparkline rendered in a 72px-tall box holding 26px of bars, which is the
315
+ // dead band under every KPI value on a phone. Handed to CSS as values instead, the
316
+ // media query can reset the box and the numbers still live in one place.
317
+ style: {
318
+ '--basalt-stat-sparkline-w': `${SPARKLINE_RIGHT_WIDTH}px`,
319
+ '--basalt-stat-sparkline-h': `${SPARKLINE_RIGHT_HEIGHT}px`,
320
+ } as CSSProperties,
321
+ })}
322
+ >
323
+ {/* A measured slot renders nothing on the first commit (width 0) — an SVG at width 0 is
324
+ a visible 0-width box that then jumps, which is worse than one frame of nothing. */}
325
+ {isRender ? measured.width > 0 && sparkline(measured) : sparkline}
326
+ </div>
327
+ )}
328
+ </div>
188
329
  </Card>
189
330
  )
190
331
  }
@@ -13,12 +13,13 @@
13
13
  * (`apps/playground/src/data-table-align.type-guard.ts`); the value can only be caught here,
14
14
  * and a silent fallback to left-aligned would look correct in review.
15
15
  */
16
- import { MantineProvider } from '@mantine/core'
17
- import { render, screen } from '@testing-library/react'
16
+ import { MantineProvider, Text } from '@mantine/core'
17
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
18
18
  import { describe, expect, test } from 'bun:test'
19
19
  import { BasaltDataTable } from './data-table'
20
20
  import { createColumnHelper } from './table'
21
21
  import type { BasaltDataTableProps } from './data-table'
22
+ import type { ColumnFiltersState } from './table'
22
23
 
23
24
  type Row = { project: string; cost: number }
24
25
  const col = createColumnHelper<Row>()
@@ -73,15 +74,19 @@ describe('stickyHeader and table chrome', () => {
73
74
  expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
74
75
  })
75
76
 
76
- test('withTableBorder defaults on and can be turned off for a table inside a card', () => {
77
+ test('withTableBorder defaults OFF and can be turned on for a table that needs a frame', () => {
78
+ // It defaulted ON, and the box was three faults at once — its top edge separated the table's own
79
+ // `WidgetHeader` from its content, its bottom edge landed on the last row's hairline (two rules
80
+ // at zero distance), and its side edges divided nothing. See the prop's JSDoc; the head rule and
81
+ // the between-row rules are untouched, so the table still reads as a table.
77
82
  expect(
78
83
  renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
79
- ).toBe('true')
84
+ ).toBeNull()
80
85
  expect(
81
- renderTable({ withTableBorder: false })
86
+ renderTable({ withTableBorder: true })
82
87
  .container.querySelector('table')
83
88
  ?.getAttribute('data-with-table-border'),
84
- ).toBeNull()
89
+ ).toBe('true')
85
90
  })
86
91
  })
87
92
 
@@ -156,3 +161,274 @@ describe('regression — the bare table is unchanged', () => {
156
161
  expect(screen.getByText('linewatch')).toBeTruthy()
157
162
  })
158
163
  })
164
+
165
+ describe('title renders a WidgetHeader with the row-model count (C11)', () => {
166
+ test('omitting title renders no heading', () => {
167
+ renderTable()
168
+ expect(screen.queryByRole('heading')).toBeNull()
169
+ })
170
+
171
+ test('title renders an h3 carrying the row count from getRowCount()', () => {
172
+ renderTable({ title: 'Top pages' })
173
+ expect(screen.getByRole('heading', { level: 3, name: 'Top pages' })).toBeDefined()
174
+ expect(screen.getByText('2')).toBeDefined() // ROWS has 2 entries
175
+ })
176
+ })
177
+
178
+ describe('the toolbar has no fixed-width literals and resolves controls to the ctl tier', () => {
179
+ test('search input carries no inline width', () => {
180
+ const { container } = renderTable({ enableGlobalFilter: true })
181
+ const input = container.querySelector('input[placeholder="Search…"]')
182
+ expect(input?.getAttribute('style') ?? '').not.toContain('width')
183
+ })
184
+
185
+ test('the toolbar renders inside a data-basalt-tier="ctl" slot', () => {
186
+ const { container } = renderTable({
187
+ enableGlobalFilter: true,
188
+ actions: <button type="button">Export</button>,
189
+ })
190
+ const slot = container.querySelector('[data-basalt-tier="ctl"]')
191
+ expect(slot).not.toBeNull()
192
+ expect(slot?.querySelector('button')?.textContent).toBe('Export')
193
+ })
194
+ })
195
+
196
+ /**
197
+ * The manual-pagination contract (argo round 10, P1).
198
+ *
199
+ * `manualPagination` makes `data` one server page while the bar reads "Showing 1–25 of 412", so
200
+ * every remaining client-side control becomes a claim about 412 rows it can only make about 25.
201
+ * These pin the replacement for the old silence: loud where the props contradict each other, and
202
+ * honest — never plausible-but-wrong — where a production bundle has dropped the throw.
203
+ */
204
+
205
+ /** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
206
+ function inProd(fn: () => void): void {
207
+ const previous = process.env['NODE_ENV']
208
+ process.env['NODE_ENV'] = 'production'
209
+ try {
210
+ fn()
211
+ } finally {
212
+ if (previous === undefined) delete process.env['NODE_ENV']
213
+ else process.env['NODE_ENV'] = previous
214
+ }
215
+ }
216
+
217
+ const SERVER_PAGE: Partial<BasaltDataTableProps<Row>> = {
218
+ enablePagination: true,
219
+ manualPagination: true,
220
+ rowCount: 412,
221
+ }
222
+
223
+ describe('manualPagination — the props that would misreport must be resolved', () => {
224
+ test('the bare adoption throws, naming the page-local sort and both ways out', () => {
225
+ expect(() => renderTable(SERVER_PAGE)).toThrow(
226
+ /client-side sorting is still armed[\s\S]*`manualSorting`[\s\S]*`enableSorting=\{false\}`/,
227
+ )
228
+ })
229
+
230
+ test('no rowCount and no pageCount throws — "of N" would count the page', () => {
231
+ expect(() =>
232
+ renderTable({ enablePagination: true, manualPagination: true, manualSorting: true }),
233
+ ).toThrow(/neither `rowCount` nor `pageCount`/)
234
+ })
235
+
236
+ test('manualPagination without enablePagination throws — the prop is inert, not harmless', () => {
237
+ expect(() => renderTable({ manualPagination: true })).toThrow(
238
+ /`enablePagination` is not set, so `manualPagination` never reaches the table/,
239
+ )
240
+ })
241
+
242
+ test('client-side search over one server page throws too — the same defect, one control over', () => {
243
+ expect(() =>
244
+ renderTable({ ...SERVER_PAGE, manualSorting: true, enableGlobalFilter: true }),
245
+ ).toThrow(/the search input \/ facets still filter client-side/)
246
+ })
247
+
248
+ test('facets trip the filtering breach even with no search input', () => {
249
+ expect(() =>
250
+ renderTable({
251
+ ...SERVER_PAGE,
252
+ manualSorting: true,
253
+ facets: [{ columnId: 'project', label: 'Project', options: [] }],
254
+ }),
255
+ ).toThrow(/still filter client-side/)
256
+ })
257
+
258
+ test('every unresolved breach is reported at once, not one per fix-and-rerun', () => {
259
+ expect(() => renderTable({ manualPagination: true, enablePagination: true })).toThrow(
260
+ /neither `rowCount`[\s\S]*client-side sorting is still armed/,
261
+ )
262
+ })
263
+ })
264
+
265
+ describe('manualPagination — the two sanctioned resolutions', () => {
266
+ test("enableSorting={false} renders, and no header claims to sort — argo's workaround", () => {
267
+ const { container } = renderTable({ ...SERVER_PAGE, enableSorting: false })
268
+ for (const th of container.querySelectorAll('th')) {
269
+ expect(th.getAttribute('aria-sort')).toBeNull()
270
+ expect(th.getAttribute('tabindex')).toBeNull()
271
+ }
272
+ expect(container.textContent).toContain('of 412')
273
+ })
274
+
275
+ test('manualSorting keeps the headers live but stops reordering the page locally', () => {
276
+ // asc by cost would put linewatch (3) above argo (12) — under manualSorting the server's
277
+ // order is what renders, because the server is the one that sorted.
278
+ const sorted = renderTable({
279
+ ...SERVER_PAGE,
280
+ manualSorting: true,
281
+ initialSorting: [{ id: 'cost', desc: false }],
282
+ })
283
+ expect(sorted.container.querySelector('tbody tr')?.textContent).toContain('argo')
284
+ expect(sorted.container.querySelector('th')?.getAttribute('aria-sort')).toBe('none')
285
+
286
+ // The control: the same initialSorting on a client-side table DOES reorder.
287
+ const local = renderTable({ initialSorting: [{ id: 'cost', desc: false }] })
288
+ expect(local.container.querySelector('tbody tr')?.textContent).toContain('linewatch')
289
+ })
290
+
291
+ test('manualFiltering hands the search to the server — the input filters nothing locally', () => {
292
+ const { container } = renderTable({
293
+ ...SERVER_PAGE,
294
+ manualSorting: true,
295
+ manualFiltering: true,
296
+ enableGlobalFilter: true,
297
+ initialGlobalFilter: 'zzzz',
298
+ })
299
+ // Client-side that term matches no row; the server page renders whole.
300
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2)
301
+ expect(container.querySelector('input')).toBeTruthy()
302
+ })
303
+ })
304
+
305
+ describe('manualPagination — production degrades to the honest table, never the plausible one', () => {
306
+ test('no sort headers, no search input, and no "of N" it cannot stand behind', () => {
307
+ const errors: unknown[][] = []
308
+ const previous = console.error
309
+ console.error = (...args: unknown[]) => errors.push(args)
310
+ try {
311
+ inProd(() => {
312
+ const { container } = renderTable({
313
+ manualPagination: true,
314
+ enablePagination: true,
315
+ enableGlobalFilter: true,
316
+ })
317
+ // Sorting: the control is gone, so the table asserts nothing about the unseen rows.
318
+ for (const th of container.querySelectorAll('th')) {
319
+ expect(th.getAttribute('aria-sort')).toBeNull()
320
+ }
321
+ // Filtering: same — a control that can only narrow one page is not rendered.
322
+ expect(container.querySelector('input[placeholder="Search…"]')).toBeNull()
323
+ // The count: no rowCount was given, so the bar states the range and claims no total.
324
+ expect(container.textContent).toContain('Showing 1–2')
325
+ expect(container.textContent).not.toContain('of 2')
326
+ })
327
+ } finally {
328
+ console.error = previous
329
+ }
330
+ // The degradation is not its own silence.
331
+ expect(errors).toHaveLength(1)
332
+ expect(String(errors[0]?.[0])).toContain('BasaltDataTable: `manualPagination` is set')
333
+ })
334
+ })
335
+
336
+ /**
337
+ * Sibling defect found by reading, not by a consumer: the empty branch keyed off `data.length`,
338
+ * so any narrowing that left `data` non-empty but the row model empty rendered a `<tbody>` with
339
+ * nothing in it — no rows, no message. A blank body reads as a broken table, not as no matches.
340
+ */
341
+ describe('the empty state tracks the rendered rows, not the raw array', () => {
342
+ test('a search matching nothing shows the empty state instead of a blank tbody', () => {
343
+ const { container } = renderTable({
344
+ enableGlobalFilter: true,
345
+ initialGlobalFilter: 'no-such-project',
346
+ })
347
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(1)
348
+ expect(container.textContent).toContain('No data to display.')
349
+ })
350
+
351
+ test('a custom emptyState is honoured on a filtered-to-nothing table too', () => {
352
+ const { container } = renderTable({
353
+ enableGlobalFilter: true,
354
+ initialGlobalFilter: 'no-such-project',
355
+ emptyState: <Text>Nothing matches that search.</Text>,
356
+ })
357
+ expect(container.textContent).toContain('Nothing matches that search.')
358
+ })
359
+
360
+ test('a page index past the end shows the empty state rather than an empty body', () => {
361
+ const { container } = renderTable({
362
+ enablePagination: true,
363
+ initialPagination: { pageIndex: 9, pageSize: 10 },
364
+ })
365
+ expect(container.textContent).toContain('No data to display.')
366
+ })
367
+ })
368
+
369
+ // Facets now render as FilterSet pills (EnumFilter/MultiSelectFilter, docs/CONTROLS-SPEC.md §3)
370
+ // instead of a raw Mantine Select. Mirrors `controls/controls.router.test.tsx`'s `openPill`
371
+ // harness fact: Mantine's Popover mounts its dropdown one flushed effect cycle after the click
372
+ // (needs `act`), and the dropdown's own contents read as `{ hidden: true }` to Testing Library
373
+ // while it still carries transition styles.
374
+ async function openPill(name: string | RegExp): Promise<void> {
375
+ await act(async () => {
376
+ fireEvent.click(screen.getByRole('button', { name }))
377
+ })
378
+ }
379
+
380
+ describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
381
+ test('a single-select facet reports the new columnFilters via its pill popover', async () => {
382
+ const seen: ColumnFiltersState[] = []
383
+ renderTable({
384
+ facets: [
385
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
386
+ ],
387
+ onColumnFiltersChange: (filters) => seen.push(filters),
388
+ })
389
+ await openPill('Any project')
390
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
391
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
392
+ })
393
+
394
+ test('picking the synthetic "Any <facet>" row clears the column filter again', async () => {
395
+ const seen: ColumnFiltersState[] = []
396
+ renderTable({
397
+ facets: [
398
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
399
+ ],
400
+ onColumnFiltersChange: (filters) => seen.push(filters),
401
+ })
402
+ await openPill('Any project')
403
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
404
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
405
+
406
+ await openPill('argo')
407
+ fireEvent.click(screen.getByRole('radio', { name: 'Any project', hidden: true }))
408
+ await waitFor(() => expect(seen[seen.length - 1]).toEqual([]))
409
+ })
410
+
411
+ test('a multi-select facet reports every checked value', async () => {
412
+ const seen: ColumnFiltersState[] = []
413
+ renderTable({
414
+ facets: [
415
+ {
416
+ columnId: 'project',
417
+ label: 'Project',
418
+ multiple: true,
419
+ options: [
420
+ { value: 'argo', label: 'argo' },
421
+ { value: 'linewatch', label: 'linewatch' },
422
+ ],
423
+ },
424
+ ],
425
+ onColumnFiltersChange: (filters) => seen.push(filters),
426
+ })
427
+ // A MULTI facet keeps the group label while its selection carries no information — that is
428
+ // `MultiSelectFilter`'s own law, and the reason only the single-select facet needed a readable
429
+ // synthetic row (`facetAllLabel`).
430
+ await openPill('Project')
431
+ fireEvent.click(screen.getByRole('checkbox', { name: 'argo', hidden: true }))
432
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: ['argo'] }]]))
433
+ })
434
+ })