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
@@ -21,27 +21,18 @@
21
21
  * <BasaltDataTable data={rows} columns={columns} />
22
22
  *
23
23
  * @example
24
- * // With the toolbar + pagination bar:
24
+ * // With the title, the toolbar + pagination bar:
25
25
  * <BasaltDataTable
26
+ * title="Top pages"
26
27
  * data={rows}
27
28
  * columns={columns}
28
29
  * enableGlobalFilter
29
30
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
30
- * toolbarActions={<Button size="xs">Export</Button>}
31
+ * actions={<Button>Export</Button>}
31
32
  * enablePagination
32
33
  * />
33
34
  */
34
- import {
35
- Box,
36
- Group,
37
- MultiSelect,
38
- Pagination,
39
- Select,
40
- Skeleton,
41
- Table,
42
- Text,
43
- TextInput,
44
- } from '@mantine/core'
35
+ import { Box, Group, Pagination, Select, Skeleton, Table, Text, TextInput } from '@mantine/core'
45
36
  import type { MantineSpacing } from '@mantine/core'
46
37
  import type {
47
38
  Column,
@@ -65,7 +56,17 @@ import {
65
56
  } from '@tanstack/react-table'
66
57
  import type { CSSProperties, ReactNode } from 'react'
67
58
  import { useCallback, useMemo, useState } from 'react'
59
+ import { FilterSet } from '../controls'
60
+ // `EnumFilter` is deliberately NOT on the `./controls` barrel (`docs/CONTROLS-SPEC.md` §3 —
61
+ // reaching for it directly is hand-rolling a filter, `basalt/hand-rolled-filter`); a facet column
62
+ // is the one place inside the framework itself that legitimately builds a `FieldHandle` over
63
+ // something that is not a store field, so it reaches past the barrel on purpose.
64
+ import { EnumFilter } from '../controls/enum-filter'
65
+ import { MultiSelectFilter } from '../controls/multi-select-filter'
66
+ import { CtlSlot } from '../theme'
68
67
  import { alpha, VX } from '../tokens'
68
+ import type { EnumField, FieldHandle, MultiField } from '../state'
69
+ import { WidgetHeader } from '../widget-header'
69
70
 
70
71
  // ── Column alignment ──────────────────────────────────────────────────────────
71
72
 
@@ -113,17 +114,17 @@ function resolveAlign<T>(column: Column<T, unknown>): DataTableAlign | undefined
113
114
 
114
115
  // ── Facets ────────────────────────────────────────────────────────────────────
115
116
 
116
- /** A single selectable option inside a {@link DataTableFacet}'s Select/MultiSelect. */
117
+ /** A single selectable option inside a {@link DataTableFacet}'s `EnumFilter`/`MultiSelectFilter` pill. */
117
118
  export type DataTableFacetOption = {
118
119
  /** The raw filter value, compared against the column's stringified cell value. */
119
120
  value: string
120
- /** Label shown in the Select/MultiSelect dropdown. */
121
+ /** Label shown in the pill's popover. */
121
122
  label: string
122
123
  }
123
124
 
124
125
  /**
125
- * Declares one faceted column filter, rendered as a Mantine Select (or MultiSelect when
126
- * `multiple` is set) inside the toolbar.
126
+ * Declares one faceted column filter, rendered as an `EnumFilter` pill (or `MultiSelectFilter`
127
+ * when `multiple` is set) inside a `FilterSet` in the toolbar (`docs/CONTROLS-SPEC.md` §3).
127
128
  *
128
129
  * @example
129
130
  * const facets: DataTableFacet[] = [
@@ -135,14 +136,89 @@ export type DataTableFacetOption = {
135
136
  export type DataTableFacet = {
136
137
  /** The TanStack column id — the column's manual `id`, or its `accessorKey` string. */
137
138
  columnId: string
138
- /** Label shown as the Select/MultiSelect placeholder. */
139
+ /** The pill's label — read at rest (single-select) and as the `Filters`/count fallback (multi). */
139
140
  label: string
140
141
  /** Selectable options for this facet. */
141
142
  options: DataTableFacetOption[]
142
- /** Render a MultiSelect (any-of match) instead of a single Select (exact match). @default false */
143
+ /** Render a `MultiSelectFilter` (any-of match) instead of an `EnumFilter` (exact match). @default false */
143
144
  multiple?: boolean
144
145
  }
145
146
 
147
+ /**
148
+ * The "no filter" member of a single-select facet's `FieldHandle`. A closed enum field always has
149
+ * a real member as its fallback (C4) — there is no "unset" — so a facet, whose underlying
150
+ * `column.getFilterValue()` genuinely can be `undefined`, needs one synthetic option to stand for
151
+ * that state. Rendered as the enum's first radio; picking it clears the column filter, same as every
152
+ * other value change.
153
+ *
154
+ * Its label is `Any <facet>`, and it used to be a bare `All`. The pill now reads the SELECTED
155
+ * option's label at every value including the fallback (`controls/enum-filter.tsx` — a bar reading
156
+ * `Compare` over a field holding `'previous'` is the bug that law fixes), and a toolbar with three
157
+ * facets would then have read `All` three times. `Any project` is the one label that works in both
158
+ * places at once: it is a legible popover row for "no constraint", and a legible pill readout of the
159
+ * state the filter is actually in. Derived from `facet.label`, so no facet has to declare it.
160
+ */
161
+ const FACET_ALL_VALUE = '__basalt_facet_all__'
162
+
163
+ /** The synthetic row's label — see {@link FACET_ALL_VALUE}. */
164
+ function facetAllLabel(facet: DataTableFacet): string {
165
+ return `Any ${facet.label.toLowerCase()}`
166
+ }
167
+
168
+ /**
169
+ * Presents a single-select facet column as a `FieldHandle<EnumField<string>>` so it can render
170
+ * through the shared `EnumFilter` pill instead of a hand-rolled `Select` (`basalt/hand-rolled-filter`).
171
+ * `use()` reads/writes `column.getFilterValue()`/`setFilterValue()` directly — no internal
172
+ * subscription needed, since the enclosing `BasaltDataTable` already re-renders on every
173
+ * `columnFilters` change and this handle is rebuilt fresh each render alongside its column.
174
+ */
175
+ function facetEnumHandle<T>(
176
+ column: Column<T, unknown>,
177
+ facet: DataTableFacet,
178
+ ): FieldHandle<EnumField<string>> {
179
+ return {
180
+ kind: 'enum',
181
+ fallback: FACET_ALL_VALUE,
182
+ options: [{ value: FACET_ALL_VALUE, label: facetAllLabel(facet) }, ...facet.options],
183
+ use: () => {
184
+ const current = (column.getFilterValue() as string | undefined) ?? FACET_ALL_VALUE
185
+ return [
186
+ current,
187
+ (next: string) => {
188
+ column.setFilterValue(next === FACET_ALL_VALUE ? undefined : next)
189
+ },
190
+ ] as const
191
+ },
192
+ isDefault: (value) => value === FACET_ALL_VALUE,
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Presents a multi-select facet column as a `FieldHandle<MultiField<string>>`. Unlike the
198
+ * single-select case, `MultiField`'s own fallback — an empty selection — already means "no
199
+ * constraint", so no synthetic option is needed here.
200
+ */
201
+ function facetMultiHandle<T>(
202
+ column: Column<T, unknown>,
203
+ facet: DataTableFacet,
204
+ ): FieldHandle<MultiField<string>> {
205
+ return {
206
+ kind: 'multi',
207
+ fallback: [],
208
+ options: facet.options,
209
+ use: () => {
210
+ const current = (column.getFilterValue() as readonly string[] | undefined) ?? []
211
+ return [
212
+ current,
213
+ (next: readonly string[]) => {
214
+ column.setFilterValue(next.length > 0 ? [...next] : undefined)
215
+ },
216
+ ] as const
217
+ },
218
+ isDefault: (value) => value.length === 0,
219
+ }
220
+ }
221
+
146
222
  // ── Props ─────────────────────────────────────────────────────────────────────
147
223
 
148
224
  /**
@@ -159,6 +235,19 @@ export type DataTableFacet = {
159
235
  * }
160
236
  */
161
237
  export type BasaltDataTableProps<T> = {
238
+ // ── Header (WidgetHeader, docs/CONTROLS-SPEC.md §2.2) ──────────────────────────
239
+
240
+ /**
241
+ * Optional heading rendered above the toolbar via `WidgetHeader tier="widget"`. `count` always
242
+ * reads `table.getRowCount()` (C11) — never a raw `data.length` — so it tracks the row model
243
+ * (post-filter/-pagination) rather than the unfiltered input.
244
+ */
245
+ title?: string
246
+ /** Optional leading icon, forwarded to `WidgetHeader`. */
247
+ icon?: ReactNode
248
+ /** Optional muted line rendered below the title row. */
249
+ subtitle?: string
250
+
162
251
  /** Row data array. */
163
252
  data: T[]
164
253
  /**
@@ -262,9 +351,9 @@ export type BasaltDataTableProps<T> = {
262
351
  /** Called whenever the internal global filter value changes. */
263
352
  onGlobalFilterChange?: (value: string) => void
264
353
  /**
265
- * Faceted column filters, rendered in the toolbar as Mantine Select/MultiSelect controls wired
266
- * to TanStack's per-column `columnFilters` state. The toolbar renders whenever this array is
267
- * non-empty, `enableGlobalFilter` is set, or `toolbarActions` is passed.
354
+ * Faceted column filters, rendered in the toolbar as `EnumFilter`/`MultiSelectFilter` pills
355
+ * inside a `FilterSet`, wired to TanStack's per-column `columnFilters` state. The toolbar
356
+ * renders whenever this array is non-empty, `enableGlobalFilter` is set, or `actions` is passed.
268
357
  * @example
269
358
  * facets={[{ columnId: 'department', label: 'Department', options: departmentOptions }]}
270
359
  */
@@ -283,9 +372,10 @@ export type BasaltDataTableProps<T> = {
283
372
  manualFiltering?: boolean
284
373
  /**
285
374
  * Right-aligned toolbar slot (e.g. an "Export" button). Renders the toolbar row even when no
286
- * search input or facets are configured.
375
+ * search input or facets are configured. Renamed from `toolbarActions` — the toolbar (search +
376
+ * facets + this slot) is wrapped in `CtlSlot` (C1/C5), so its controls resolve to the `ctl` tier.
287
377
  */
288
- toolbarActions?: ReactNode
378
+ actions?: ReactNode
289
379
 
290
380
  // ── Pagination ────────────────────────────────────────────────────────────────
291
381
 
@@ -389,9 +479,20 @@ export type BasaltDataTableProps<T> = {
389
479
  /** Row separators. Forwarded to Mantine `Table`. @default true (Mantine's own default) */
390
480
  withRowBorders?: boolean
391
481
  /**
392
- * Outer table border. Turn it OFF for a table that already sits inside a `Card`/`ChartCard`,
393
- * where the card owns the frame and a second one reads as a nested box.
394
- * @default true
482
+ * Outer table border — a full box around the table.
483
+ *
484
+ * **`false` by default since this minor** (it was `true`). Three separate faults, all visible on
485
+ * the playground's `Controls (mobile)` page at 390px: its TOP edge sat directly under the table's
486
+ * own `WidgetHeader`, separating a header from its own content; its BOTTOM edge landed on the last
487
+ * row's own hairline, two rules at zero distance; and its left/right edges carried no information
488
+ * at all on a table that fills the page (`docs/DESIGN-SPEC.md` §8 — a border is a layout divider,
489
+ * and there is nothing here to divide). The head-row rule and the between-row rules stay, so the
490
+ * table still reads as a table.
491
+ *
492
+ * Pass `true` for a table that genuinely needs a frame — a small table floating in whitespace
493
+ * beside other content, where the box IS the grouping.
494
+ *
495
+ * @default false
395
496
  */
396
497
  withTableBorder?: boolean
397
498
  }
@@ -428,6 +529,23 @@ const RANGE_LABEL_STYLE: CSSProperties = {
428
529
 
429
530
  const DEFAULT_PAGE_SIZE_OPTIONS = [10, 25, 50, 100]
430
531
 
532
+ /**
533
+ * The header row's two halves. The lead SHRINKS and the toolbar does not — a title can ellipsize, a
534
+ * 220px search field with a facet pill beside it cannot, and when both were shrinkable flex split
535
+ * the overflow between them and clipped the search's placeholder mid-word.
536
+ */
537
+ const TABLE_HEADER_LEAD_STYLE: CSSProperties = { flex: '1 1 auto', minWidth: 0 }
538
+ const TABLE_TOOLBAR_STYLE: CSSProperties = { flex: '0 0 auto' }
539
+ /**
540
+ * The toolbar search's width. Mantine's Input grows to its container, which in a `justify:
541
+ * space-between` row means "whatever is left" — so the field was 600px on a wide table and 180px on a
542
+ * narrow one, for the same one-word query. A stated width also makes the toolbar's own minimum width
543
+ * knowable, which is what lets it wrap under the title below `sm` with no media query.
544
+ */
545
+ const SEARCH_WIDTH = 220
546
+ /** The rows-per-page Select's width — see `SEARCH_WIDTH` for why a toolbar field states one. */
547
+ const PAGE_SIZE_WIDTH = 116
548
+
431
549
  // ── Column pinning helpers ────────────────────────────────────────────────────
432
550
 
433
551
  // Merges the left/center/right header-group triad TanStack exposes for pinning into the single
@@ -597,6 +715,9 @@ function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): vo
597
715
  * />
598
716
  */
599
717
  export function BasaltDataTable<T>({
718
+ title,
719
+ icon,
720
+ subtitle,
600
721
  data,
601
722
  columns,
602
723
  enableSorting = true,
@@ -616,7 +737,7 @@ export function BasaltDataTable<T>({
616
737
  facets,
617
738
  onColumnFiltersChange,
618
739
  manualFiltering = false,
619
- toolbarActions,
740
+ actions,
620
741
  enablePagination = false,
621
742
  pageSizeOptions = DEFAULT_PAGE_SIZE_OPTIONS,
622
743
  initialPagination,
@@ -633,7 +754,7 @@ export function BasaltDataTable<T>({
633
754
  verticalSpacing,
634
755
  horizontalSpacing,
635
756
  withRowBorders,
636
- withTableBorder = true,
757
+ withTableBorder = false,
637
758
  }: BasaltDataTableProps<T>) {
638
759
  const [sorting, setSorting] = useState<SortingState>(initialSorting ?? [])
639
760
  const [globalFilter, setGlobalFilter] = useState(initialGlobalFilter ?? '')
@@ -773,7 +894,7 @@ export function BasaltDataTable<T>({
773
894
 
774
895
  const showSearch = enableGlobalFilter && filteringEnabled
775
896
  const showFacets = hasFacets && filteringEnabled
776
- const showToolbar = showSearch || showFacets || Boolean(toolbarActions)
897
+ const showToolbar = showSearch || showFacets || Boolean(actions)
777
898
  const headerGroups = enablePinning ? getOrderedHeaderGroups(table) : table.getHeaderGroups()
778
899
  // The rows actually rendered, not `data` — a filter or a page index that matches nothing leaves
779
900
  // `data` non-empty while the body has nothing in it, and a blank body reads as a broken table.
@@ -906,63 +1027,81 @@ export function BasaltDataTable<T>({
906
1027
  const scrolls = maxHeight !== undefined || minWidth !== undefined
907
1028
 
908
1029
  const paginationState = table.getState().pagination
1030
+ // The CURRENT page size is always an option, even when it is not in `pageSizeOptions`. Mantine's
1031
+ // Select renders EMPTY when its `value` matches no row, so a table opened at
1032
+ // `initialPagination.pageSize: 5` against the default `[10, 25, 50, 100]` showed a blank
1033
+ // rows-per-page box — a control stating nothing, next to a range label stating `1–5`.
1034
+ const pageSizeRows = [...new Set([...pageSizeOptions, paginationState.pageSize])]
1035
+ .sort((a, b) => a - b)
1036
+ .map((size) => ({ value: String(size), label: `${size} / page` }))
909
1037
  const total = table.getRowCount()
910
1038
  const rangeStart = total === 0 ? 0 : paginationState.pageIndex * paginationState.pageSize + 1
911
1039
  const rangeEnd = Math.min((paginationState.pageIndex + 1) * paginationState.pageSize, total)
912
1040
 
913
1041
  return (
914
1042
  <>
915
- {showToolbar && (
916
- <Group justify="space-between" align="flex-end" wrap="wrap" gap="xs" mb="xs">
917
- <Group gap="xs" wrap="wrap" align="flex-end">
918
- {showSearch && (
919
- <TextInput
920
- size="xs"
921
- radius="md"
922
- placeholder={globalFilterPlaceholder}
923
- leftSection={searchIcon}
924
- value={globalFilter}
925
- onChange={(event) => handleGlobalFilterChange(event.currentTarget.value)}
926
- w={220}
1043
+ {/*
1044
+ * ONE header row: the `WidgetHeader` (title · count) on the left, the toolbar right-aligned in
1045
+ * the SAME row. It was two stacked rows with `mb="xs"` on each, so a titled table with search
1046
+ * spent title → 11px → search → 11px → thead before a single datum appeared, and the toolbar
1047
+ * read as page furniture rather than as this table's controls.
1048
+ *
1049
+ * `wrap="wrap"` is the below-`sm` behaviour and needs no media query: the toolbar's minimum
1050
+ * width (a 220px search plus its pills) exceeds what is left beside a title on a phone, so flex
1051
+ * wraps it under the title on its own. `align="center"` puts the 30px toolbar controls on the
1052
+ * title's optical line rather than on its baseline.
1053
+ */}
1054
+ {(title !== undefined || showToolbar) && (
1055
+ <Group justify="space-between" align="center" wrap="wrap" gap="xs" mb="xs">
1056
+ {title !== undefined ? (
1057
+ <Box style={TABLE_HEADER_LEAD_STYLE}>
1058
+ <WidgetHeader
1059
+ tier="widget"
1060
+ title={title}
1061
+ {...(icon !== undefined && { icon })}
1062
+ {...(subtitle !== undefined && { subtitle })}
1063
+ count={table.getRowCount()}
927
1064
  />
928
- )}
929
- {showFacets &&
930
- facets?.map((facet) => {
931
- const column = table.getColumn(facet.columnId)
932
- if (!column) return null
933
- if (facet.multiple) {
934
- const value = (column.getFilterValue() as string[] | undefined) ?? []
935
- return (
936
- <MultiSelect
937
- key={facet.columnId}
938
- size="xs"
939
- radius="md"
940
- placeholder={facet.label}
941
- data={facet.options}
942
- value={value}
943
- onChange={(next) => column.setFilterValue(next.length > 0 ? next : undefined)}
944
- clearable
945
- w={200}
946
- />
947
- )
948
- }
949
- const value = (column.getFilterValue() as string | undefined) ?? null
950
- return (
951
- <Select
952
- key={facet.columnId}
953
- size="xs"
1065
+ </Box>
1066
+ ) : null}
1067
+ {showToolbar && (
1068
+ <CtlSlot>
1069
+ <Group gap="xs" wrap="wrap" align="center" style={TABLE_TOOLBAR_STYLE}>
1070
+ {showSearch && (
1071
+ <TextInput
954
1072
  radius="md"
955
- placeholder={facet.label}
956
- data={facet.options}
957
- value={value}
958
- onChange={(next) => column.setFilterValue(next ?? undefined)}
959
- clearable
960
- w={180}
1073
+ w={SEARCH_WIDTH}
1074
+ placeholder={globalFilterPlaceholder}
1075
+ leftSection={searchIcon}
1076
+ value={globalFilter}
1077
+ onChange={(event) => handleGlobalFilterChange(event.currentTarget.value)}
961
1078
  />
962
- )
963
- })}
964
- </Group>
965
- {toolbarActions}
1079
+ )}
1080
+ {showFacets && (
1081
+ <FilterSet>
1082
+ {facets?.map((facet) => {
1083
+ const column = table.getColumn(facet.columnId)
1084
+ if (!column) return null
1085
+ return facet.multiple ? (
1086
+ <MultiSelectFilter
1087
+ key={facet.columnId}
1088
+ field={facetMultiHandle(column, facet)}
1089
+ label={facet.label}
1090
+ />
1091
+ ) : (
1092
+ <EnumFilter
1093
+ key={facet.columnId}
1094
+ field={facetEnumHandle(column, facet)}
1095
+ label={facet.label}
1096
+ />
1097
+ )
1098
+ })}
1099
+ </FilterSet>
1100
+ )}
1101
+ {actions}
1102
+ </Group>
1103
+ </CtlSlot>
1104
+ )}
966
1105
  </Group>
967
1106
  )}
968
1107
  {scrolls ? (
@@ -986,16 +1125,13 @@ export function BasaltDataTable<T>({
986
1125
  </Text>
987
1126
  <Group gap="xs" align="center">
988
1127
  <Select
989
- size="xs"
1128
+ size="ctl"
990
1129
  radius="md"
991
- data={pageSizeOptions.map((size) => ({
992
- value: String(size),
993
- label: `${size} / page`,
994
- }))}
1130
+ w={PAGE_SIZE_WIDTH}
1131
+ data={pageSizeRows}
995
1132
  value={String(paginationState.pageSize)}
996
1133
  onChange={(value) => value && table.setPageSize(Number(value))}
997
1134
  allowDeselect={false}
998
- w={110}
999
1135
  />
1000
1136
  <Pagination
1001
1137
  size="sm"
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unit tests for checkSource — the pure (text, relPath, cfg) → Finding[] core.
3
3
  *
4
- * Covers all 25 guard kinds. Co-located with the guard, excluded from tsc
4
+ * Covers all 27 guard kinds. Co-located with the guard, excluded from tsc
5
5
  * (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
6
6
  *
7
7
  * The walker/reporter half is covered by the integration test in
@@ -1916,15 +1916,16 @@ describe('severity', () => {
1916
1916
  expect(f.find((v) => v.kind === 'raw-spacing')?.severity).toBe('error')
1917
1917
  })
1918
1918
 
1919
- // The grace ledger. Five kinds landed warn-only in the round-4 guard minor; deleting an entry
1920
- // from GRACE_PERIOD_KINDS is the promotion, and it shows up here as an explicit diff.
1919
+ // The grace ledger. These five kinds landed warn-only in the round-4 guard minor and sat there
1920
+ // for five minors with nothing tracking the promise (D4) — GRACE_PERIOD_KINDS is now empty and
1921
+ // the C16 version gate in grace.test.ts is what stops a future entry sitting unpromoted again.
1921
1922
  it.each([
1922
1923
  'theme-allow-unscoped',
1923
1924
  'surface-shadow-override',
1924
1925
  'css-raw-surface',
1925
1926
  'inline-font-size',
1926
1927
  'hidden-inline-style',
1927
- ])('%s is still in its grace minor (warn)', (kind) => {
1928
+ ])('%s is promoted past its grace minor (error)', (kind) => {
1928
1929
  const cases: Record<string, [string, string]> = {
1929
1930
  'theme-allow-unscoped': ['const x = 1 // theme-allow', PATH],
1930
1931
  'surface-shadow-override': ['const s = { boxShadow: `0 0 0 2px ${VX.accent}` }', PATH],
@@ -1937,7 +1938,7 @@ describe('severity', () => {
1937
1938
  }
1938
1939
  const [src, path] = cases[kind] as [string, string]
1939
1940
  const hit = find(src, path).find((v) => v.kind === kind)
1940
- expect(hit?.severity).toBe('warn')
1941
+ expect(hit?.severity).toBe('error')
1941
1942
  })
1942
1943
 
1943
1944
  it('ships every OTHER kind past its grace period', () => {
@@ -2139,9 +2140,9 @@ describe('theme-allow scoping', () => {
2139
2140
  expect(f).toHaveLength(0)
2140
2141
  })
2141
2142
 
2142
- it('ships theme-allow-unscoped as a warning, not a build failure', () => {
2143
+ it('ships theme-allow-unscoped as an error (promoted, C16)', () => {
2143
2144
  const f = find(`const x = 1 // theme-allow`)
2144
- expect(f.every((v) => v.severity === 'warn')).toBe(true)
2145
+ expect(f.every((v) => v.severity === 'error')).toBe(true)
2145
2146
  })
2146
2147
 
2147
2148
  // The annotation itself lives in a comment; a `theme-allow` in real code must not waive anything.
@@ -2370,9 +2371,9 @@ describe('surface-shadow-override', () => {
2370
2371
  expect(kinds(f)).not.toContain('surface-shadow-override')
2371
2372
  })
2372
2373
 
2373
- it('ships as a warning for its grace minor', () => {
2374
+ it('is an error now that its grace minor has ended (promoted, C16)', () => {
2374
2375
  const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
2375
- expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('warn')
2376
+ expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('error')
2376
2377
  })
2377
2378
  })
2378
2379
 
@@ -2435,6 +2436,114 @@ describe('inline-font-size', () => {
2435
2436
  })
2436
2437
  })
2437
2438
 
2439
+ // ── 26. in-body-page-title (the text lane of law C8) ─────────────────────────
2440
+
2441
+ describe('in-body-page-title', () => {
2442
+ it('flags <Title order={1}> in the body', () => {
2443
+ expect(kinds(find(`<Title order={1}>Analytics</Title>`))).toContain('in-body-page-title')
2444
+ })
2445
+
2446
+ it('flags order={2} too', () => {
2447
+ expect(kinds(find(`<Title order={2}>Analytics</Title>`))).toContain('in-body-page-title')
2448
+ })
2449
+
2450
+ // The formatted default — the reason this kind is a bounded TAG scan and not a line regex.
2451
+ it('flags a Title formatted across lines', () => {
2452
+ const f = find(`<Title\n order={1}\n mb="sm"\n>\n Analytics\n</Title>`)
2453
+ expect(kinds(f)).toContain('in-body-page-title')
2454
+ })
2455
+
2456
+ it('does NOT flag order={3} — a section heading', () => {
2457
+ expect(kinds(find(`<Title order={3}>Sessions</Title>`))).not.toContain('in-body-page-title')
2458
+ })
2459
+
2460
+ it('does NOT flag a Title with no order', () => {
2461
+ expect(kinds(find(`<Title>Sessions</Title>`))).not.toContain('in-body-page-title')
2462
+ })
2463
+
2464
+ it('does NOT flag anything under a content/ path segment', () => {
2465
+ const f = find(`<Title order={1}>Doc</Title>`, 'src/content/Guide.tsx')
2466
+ expect(kinds(f)).not.toContain('in-body-page-title')
2467
+ })
2468
+
2469
+ // File-scoped and coarser than the plugin's node-level ancestry, on purpose: a document heading
2470
+ // told to become a breadcrumb is the WRONG advice, so the text lane errs silent.
2471
+ it('does NOT flag a file that renders Prose', () => {
2472
+ const f = find(`<Prose>\n <Title order={1}>Doc</Title>\n</Prose>`)
2473
+ expect(kinds(f)).not.toContain('in-body-page-title')
2474
+ })
2475
+
2476
+ it('honours a theme-allow', () => {
2477
+ const f = find(
2478
+ `// theme-allow in-body-page-title — a shell-less print view\n<Title order={1}>P</Title>`,
2479
+ )
2480
+ expect(kinds(f)).not.toContain('in-body-page-title')
2481
+ })
2482
+
2483
+ it('lands warn while the grace entry stands (C16)', () => {
2484
+ const f = find(`<Title order={1}>Analytics</Title>`)
2485
+ expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('warn')
2486
+ })
2487
+ })
2488
+
2489
+ // ── 27. raw-selection-control (the text lane of law C1) ──────────────────────
2490
+
2491
+ describe('raw-selection-control', () => {
2492
+ it.each(['SegmentedControl', 'Select', 'MultiSelect', 'NativeSelect', 'TagsInput', 'Chip.Group'])(
2493
+ 'flags a raw <%s>',
2494
+ (tag) => {
2495
+ expect(kinds(find(`<${tag} data={[]} />`))).toContain('raw-selection-control')
2496
+ },
2497
+ )
2498
+
2499
+ it('reports the tag it found, not the whole line', () => {
2500
+ const f = find(`<Select data={[]} />`)
2501
+ expect(f.find((x) => x.kind === 'raw-selection-control')?.token).toBe('<Select')
2502
+ })
2503
+
2504
+ it.each(['SettingsRow', 'Modal', 'Drawer', 'Menu.Dropdown'])(
2505
+ 'does NOT flag one inside a %s window',
2506
+ (host) => {
2507
+ const f = find(`<${host}>\n <Select data={[]} />\n</${host}>`)
2508
+ expect(kinds(f)).not.toContain('raw-selection-control')
2509
+ },
2510
+ )
2511
+
2512
+ // The window is 12 lines; past it the approximation would start swallowing the NEXT row's control.
2513
+ it('DOES flag one 20 lines below the host tag — the window is bounded', () => {
2514
+ const f = find(`<Modal>\n${' <Text>x</Text>\n'.repeat(20)} <Select data={[]} />\n</Modal>`)
2515
+ expect(kinds(f)).toContain('raw-selection-control')
2516
+ })
2517
+
2518
+ it('does NOT flag anything in a file importing @mantine/form', () => {
2519
+ const f = find(`import { useForm } from '@mantine/form'\n<Select data={[]} />`)
2520
+ expect(kinds(f)).not.toContain('raw-selection-control')
2521
+ })
2522
+
2523
+ it('does NOT flag the file that DEFINES a basalt control', () => {
2524
+ const f = find(`export function ViewTabs() {\n return <SegmentedControl data={[]} />\n}`)
2525
+ expect(kinds(f)).not.toContain('raw-selection-control')
2526
+ })
2527
+
2528
+ it('does NOT flag a bound basalt control', () => {
2529
+ expect(kinds(find(`<SelectFilter field={f} label="Channel" />`))).not.toContain(
2530
+ 'raw-selection-control',
2531
+ )
2532
+ })
2533
+
2534
+ it('honours a theme-allow', () => {
2535
+ const f = find(
2536
+ `{/* theme-allow raw-selection-control — a one-off admin picker */}\n<Select data={[]} />`,
2537
+ )
2538
+ expect(kinds(f)).not.toContain('raw-selection-control')
2539
+ })
2540
+
2541
+ it('lands warn while the grace entry stands (C16)', () => {
2542
+ const f = find(`<Select data={[]} />`)
2543
+ expect(f.find((x) => x.kind === 'raw-selection-control')?.severity).toBe('warn')
2544
+ })
2545
+ })
2546
+
2438
2547
  // ── 26. hidden-inline-style ──────────────────────────────────────────────────
2439
2548
 
2440
2549
  describe('hidden-inline-style', () => {
@@ -2475,12 +2584,12 @@ export const C = () => <div style={labelStyle} />`,
2475
2584
  expect(kinds(f)).not.toContain('hidden-inline-style')
2476
2585
  })
2477
2586
 
2478
- it('ships as a warning for its grace minor', () => {
2587
+ it('is an error now that its grace minor has ended (promoted, C16)', () => {
2479
2588
  const f = find(
2480
2589
  `const wrapperStyle = { position: 'relative', width: '100%' }
2481
2590
  export const C = () => <div style={wrapperStyle} />`,
2482
2591
  )
2483
- expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('warn')
2592
+ expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('error')
2484
2593
  })
2485
2594
  })
2486
2595
 
@@ -2638,7 +2747,7 @@ describe('markup files (index.html / *.webmanifest)', () => {
2638
2747
  * Round 9: `.astro` and `.vue` became scannable and fell through to the `ts` dialect, so `<!-- … -->`
2639
2748
  * was never stripped — a `theme-allow` written in an HTML comment waived nothing there, and a color
2640
2749
  * inside a commented-out block still reported. They resolve as `sfc` now: BOTH comment dialects,
2641
- * and the full 25-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2750
+ * and the full 27-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2642
2751
  * `<script setup>` is real TS).
2643
2752
  */
2644
2753
  describe('single-file components (.astro / .vue)', () => {
@@ -2770,11 +2879,15 @@ describe("profile: 'tokens-only'", () => {
2770
2879
  'css-raw-surface': false,
2771
2880
  'inline-font-size': false,
2772
2881
  'hidden-inline-style': true,
2882
+ // The two wave-6 control kinds: `<Title>` is a Mantine component, and the remedy for either is
2883
+ // a Mantine-rendered home (PageBar / WidgetHeader) or a basalt control over @mantine/core.
2884
+ 'in-body-page-title': true,
2885
+ 'raw-selection-control': true,
2773
2886
  }
2774
2887
 
2775
2888
  it('classifies every kind in the registry — the table is exhaustive', () => {
2776
2889
  expect(Object.keys(MANTINE_COUPLED).toSorted()).toEqual(Object.keys(GUARD_RULES).toSorted())
2777
- expect(Object.keys(MANTINE_COUPLED)).toHaveLength(25)
2890
+ expect(Object.keys(MANTINE_COUPLED)).toHaveLength(27)
2778
2891
  })
2779
2892
 
2780
2893
  it('the disabled set is exactly the Mantine-coupled half — a complete partition', () => {