basalt-ui 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -1,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
  }
@@ -14,7 +14,7 @@
14
14
  * and a silent fallback to left-aligned would look correct in review.
15
15
  */
16
16
  import { MantineProvider, Text } from '@mantine/core'
17
- import { fireEvent, render, screen, waitFor } from '@testing-library/react'
17
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
18
18
  import { describe, expect, test } from 'bun:test'
19
19
  import { BasaltDataTable } from './data-table'
20
20
  import { createColumnHelper } from './table'
@@ -74,15 +74,19 @@ describe('stickyHeader and table chrome', () => {
74
74
  expect(renderTable().container.querySelector('thead')?.getAttribute('data-sticky')).toBeNull()
75
75
  })
76
76
 
77
- test('withTableBorder defaults on and can be turned off for a table inside a card', () => {
77
+ test('withTableBorder defaults OFF and can be turned on for a table that needs a frame', () => {
78
+ // It defaulted ON, and the box was three faults at once — its top edge separated the table's own
79
+ // `WidgetHeader` from its content, its bottom edge landed on the last row's hairline (two rules
80
+ // at zero distance), and its side edges divided nothing. See the prop's JSDoc; the head rule and
81
+ // the between-row rules are untouched, so the table still reads as a table.
78
82
  expect(
79
83
  renderTable().container.querySelector('table')?.getAttribute('data-with-table-border'),
80
- ).toBe('true')
84
+ ).toBeNull()
81
85
  expect(
82
- renderTable({ withTableBorder: false })
86
+ renderTable({ withTableBorder: true })
83
87
  .container.querySelector('table')
84
88
  ?.getAttribute('data-with-table-border'),
85
- ).toBeNull()
89
+ ).toBe('true')
86
90
  })
87
91
  })
88
92
 
@@ -158,6 +162,37 @@ describe('regression — the bare table is unchanged', () => {
158
162
  })
159
163
  })
160
164
 
165
+ describe('title renders a WidgetHeader with the row-model count (C11)', () => {
166
+ test('omitting title renders no heading', () => {
167
+ renderTable()
168
+ expect(screen.queryByRole('heading')).toBeNull()
169
+ })
170
+
171
+ test('title renders an h3 carrying the row count from getRowCount()', () => {
172
+ renderTable({ title: 'Top pages' })
173
+ expect(screen.getByRole('heading', { level: 3, name: 'Top pages' })).toBeDefined()
174
+ expect(screen.getByText('2')).toBeDefined() // ROWS has 2 entries
175
+ })
176
+ })
177
+
178
+ describe('the toolbar has no fixed-width literals and resolves controls to the ctl tier', () => {
179
+ test('search input carries no inline width', () => {
180
+ const { container } = renderTable({ enableGlobalFilter: true })
181
+ const input = container.querySelector('input[placeholder="Search…"]')
182
+ expect(input?.getAttribute('style') ?? '').not.toContain('width')
183
+ })
184
+
185
+ test('the toolbar renders inside a data-basalt-tier="ctl" slot', () => {
186
+ const { container } = renderTable({
187
+ enableGlobalFilter: true,
188
+ actions: <button type="button">Export</button>,
189
+ })
190
+ const slot = container.querySelector('[data-basalt-tier="ctl"]')
191
+ expect(slot).not.toBeNull()
192
+ expect(slot?.querySelector('button')?.textContent).toBe('Export')
193
+ })
194
+ })
195
+
161
196
  /**
162
197
  * The manual-pagination contract (argo round 10, P1).
163
198
  *
@@ -331,19 +366,69 @@ describe('the empty state tracks the rendered rows, not the raw array', () => {
331
366
  })
332
367
  })
333
368
 
369
+ // Facets now render as FilterSet pills (EnumFilter/MultiSelectFilter, docs/CONTROLS-SPEC.md §3)
370
+ // instead of a raw Mantine Select. Mirrors `controls/controls.router.test.tsx`'s `openPill`
371
+ // harness fact: Mantine's Popover mounts its dropdown one flushed effect cycle after the click
372
+ // (needs `act`), and the dropdown's own contents read as `{ hidden: true }` to Testing Library
373
+ // while it still carries transition styles.
374
+ async function openPill(name: string | RegExp): Promise<void> {
375
+ await act(async () => {
376
+ fireEvent.click(screen.getByRole('button', { name }))
377
+ })
378
+ }
379
+
334
380
  describe('onColumnFiltersChange — the seam server-side faceting needs', () => {
335
- test('a facet selection reports the new columnFilters', async () => {
381
+ test('a single-select facet reports the new columnFilters via its pill popover', async () => {
336
382
  const seen: ColumnFiltersState[] = []
337
- const { container } = renderTable({
383
+ renderTable({
338
384
  facets: [
339
385
  { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
340
386
  ],
341
387
  onColumnFiltersChange: (filters) => seen.push(filters),
342
388
  })
343
- const select = container.querySelector('input[placeholder="Project"]')
344
- if (!select) throw new Error('expected the facet Select')
345
- fireEvent.click(select)
346
- fireEvent.click(await screen.findByRole('option', { name: 'argo' }))
389
+ await openPill('Any project')
390
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
347
391
  await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
348
392
  })
393
+
394
+ test('picking the synthetic "Any <facet>" row clears the column filter again', async () => {
395
+ const seen: ColumnFiltersState[] = []
396
+ renderTable({
397
+ facets: [
398
+ { columnId: 'project', label: 'Project', options: [{ value: 'argo', label: 'argo' }] },
399
+ ],
400
+ onColumnFiltersChange: (filters) => seen.push(filters),
401
+ })
402
+ await openPill('Any project')
403
+ fireEvent.click(screen.getByRole('radio', { name: 'argo', hidden: true }))
404
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: 'argo' }]]))
405
+
406
+ await openPill('argo')
407
+ fireEvent.click(screen.getByRole('radio', { name: 'Any project', hidden: true }))
408
+ await waitFor(() => expect(seen[seen.length - 1]).toEqual([]))
409
+ })
410
+
411
+ test('a multi-select facet reports every checked value', async () => {
412
+ const seen: ColumnFiltersState[] = []
413
+ renderTable({
414
+ facets: [
415
+ {
416
+ columnId: 'project',
417
+ label: 'Project',
418
+ multiple: true,
419
+ options: [
420
+ { value: 'argo', label: 'argo' },
421
+ { value: 'linewatch', label: 'linewatch' },
422
+ ],
423
+ },
424
+ ],
425
+ onColumnFiltersChange: (filters) => seen.push(filters),
426
+ })
427
+ // A MULTI facet keeps the group label while its selection carries no information — that is
428
+ // `MultiSelectFilter`'s own law, and the reason only the single-select facet needed a readable
429
+ // synthetic row (`facetAllLabel`).
430
+ await openPill('Project')
431
+ fireEvent.click(screen.getByRole('checkbox', { name: 'argo', hidden: true }))
432
+ await waitFor(() => expect(seen).toEqual([[{ id: 'project', value: ['argo'] }]]))
433
+ })
349
434
  })