basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,275 @@
1
+ /**
2
+ * `IconSlot` — the one icon box (`docs/CONTROLS-SPEC.md` §3).
3
+ *
4
+ * The bug this file exists to prevent: a consumer's inline `<svg width="24" height="24">` handed to
5
+ * a basalt control's `icon` prop landed in Mantine's `leftSection`, which is a bare `flex` box with
6
+ * no size of its own. The glyph painted at 24px inside a 30px `ctl` button, sat on the parent's text
7
+ * baseline (so it hung low of the optical centre by the line box's descender), and set the row's
8
+ * height. Measured in Chrome on the playground's header: the `Accounts` secondary read visibly low
9
+ * and left of centre.
10
+ *
11
+ * Two halves, because neither alone would hold. The CSS-TEXT half pins the mechanism (a fixed square
12
+ * box, `100%` on the glyph, `display: block`) — CSS-module class hashes are unavailable under
13
+ * `bun test`, so the declarations themselves are read from the file, the same idiom
14
+ * `controls/filter-set.test.tsx` and `theme/layout-rhythm-css.test.ts` use. The RENDER half pins that
15
+ * every home named by the spec actually routes its `icon` through the slot, found by the
16
+ * `data-basalt-icon` marker attribute rather than by a class.
17
+ */
18
+ import { MantineProvider } from '@mantine/core'
19
+ import { render } from '@testing-library/react'
20
+ import { describe, expect, test } from 'bun:test'
21
+ import { readFileSync } from 'node:fs'
22
+ import { join } from 'node:path'
23
+ import type { ReactNode } from 'react'
24
+ import { ActionGroup } from '../controls/actions'
25
+ import { FilterPill } from '../controls/filter-pill'
26
+ import { SyncButton } from '../controls/sync-button'
27
+ import { WidgetHeader } from '../widget-header/widget-header'
28
+ import { IconSlot } from './icon-slot'
29
+
30
+ const ICON_SLOT_CSS = readFileSync(join(import.meta.dir, 'icon-slot.module.css'), 'utf8')
31
+ const CONTROLS_CSS = readFileSync(join(import.meta.dir, '../controls/controls.module.css'), 'utf8')
32
+ const WIDGET_HEADER_CSS = readFileSync(
33
+ join(import.meta.dir, '../widget-header/widget-header.module.css'),
34
+ 'utf8',
35
+ )
36
+
37
+ /** A consumer glyph that declares its OWN geometry — the exact shape that used to leak through. */
38
+ function OversizedGlyph(): ReactNode {
39
+ return (
40
+ <svg width={24} height={24} viewBox="0 0 24 24" data-testid="glyph">
41
+ <circle cx={12} cy={12} r={10} />
42
+ </svg>
43
+ )
44
+ }
45
+
46
+ /** A consumer glyph with no `width`/`height` and no `viewBox` — paints 300×150 unstyled. */
47
+ function UnsizedGlyph(): ReactNode {
48
+ return (
49
+ <svg data-testid="glyph">
50
+ <circle cx={8} cy={8} r={8} />
51
+ </svg>
52
+ )
53
+ }
54
+
55
+ function slots(container: HTMLElement): HTMLElement[] {
56
+ return [...container.querySelectorAll<HTMLElement>('[data-basalt-icon]')]
57
+ }
58
+
59
+ /** The slot wrapping the marked glyph — asserts the glyph is a DIRECT child, which the `> svg`
60
+ * selector requires to bite. */
61
+ function slotAround(container: HTMLElement): HTMLElement | null {
62
+ const glyph = container.querySelector('[data-testid="glyph"]')
63
+ const parent = glyph?.parentElement ?? null
64
+ return parent !== null && parent.hasAttribute('data-basalt-icon') ? parent : null
65
+ }
66
+
67
+ describe('the box is declared once, in CSS, and cannot be set at a call site', () => {
68
+ test('the slot is a fixed square off --vx-space-icon-size with a 16px fallback', () => {
69
+ expect(ICON_SLOT_CSS).toContain('width: var(--vx-space-icon-size, 1rem)')
70
+ expect(ICON_SLOT_CSS).toContain('height: var(--vx-space-icon-size, 1rem)')
71
+ })
72
+
73
+ test('the slot never participates in its row’s flex sizing', () => {
74
+ expect(ICON_SLOT_CSS).toContain('flex: none')
75
+ expect(ICON_SLOT_CSS).toContain('display: inline-flex')
76
+ expect(ICON_SLOT_CSS).toContain('align-items: center')
77
+ expect(ICON_SLOT_CSS).toContain('justify-content: center')
78
+ })
79
+
80
+ test('the glyph is restated at 100% and taken off the baseline — the two halves of "centred"', () => {
81
+ const rule = ICON_SLOT_CSS.slice(ICON_SLOT_CSS.indexOf('.slot > svg'))
82
+ expect(rule).toContain('width: 100%')
83
+ expect(rule).toContain('height: 100%')
84
+ expect(rule).toContain('display: block')
85
+ // Presentation attributes lose to CSS, which is the whole mechanism — a 24px, a 14px and an
86
+ // attribute-less SVG all resolve to the box.
87
+ expect(rule).toContain('object-fit: contain')
88
+ })
89
+
90
+ test('`img` and `picture` icons get the same box — an icon set is as likely to be a sprite', () => {
91
+ expect(ICON_SLOT_CSS).toContain('.slot > img')
92
+ expect(ICON_SLOT_CSS).toContain('.slot > picture')
93
+ })
94
+
95
+ test('IconSlot has no size prop — a call site cannot pick a geometry', () => {
96
+ const source = readFileSync(join(import.meta.dir, 'icon-slot.tsx'), 'utf8')
97
+ const props = source.slice(
98
+ source.indexOf('export type IconSlotProps'),
99
+ source.indexOf('export function IconSlot'),
100
+ )
101
+ expect(props).not.toContain('size')
102
+ // `children` + `className` and nothing else.
103
+ expect(props.match(/readonly \w+\??:/g)).toEqual(['readonly children:', 'readonly className?:'])
104
+ })
105
+
106
+ test('a home sizes the slot by overriding the box VAR, never by sizing the glyph', () => {
107
+ // The trailing pill affordance is 14px and the widget-tier heading icon is 14px. Both get there
108
+ // through `--vx-space-icon-size`; neither may reintroduce a per-component `> svg` size rule,
109
+ // which is the duplication that let `BarAction` ship with no box at all.
110
+ expect(CONTROLS_CSS).toContain('--vx-space-icon-size: 0.875rem')
111
+ expect(WIDGET_HEADER_CSS).toContain('--vx-space-icon-size: 0.875rem')
112
+ expect(WIDGET_HEADER_CSS).toContain('--vx-space-icon-size: 1rem')
113
+ expect(CONTROLS_CSS).not.toMatch(/\.pillIcon\s*>\s*svg/)
114
+ expect(WIDGET_HEADER_CSS).not.toMatch(/\.icon\s*>\s*svg/)
115
+ })
116
+ })
117
+
118
+ describe('every home named by the spec routes `icon` through the slot', () => {
119
+ test('a BarAction secondary — an oversized consumer SVG lands inside the slot', () => {
120
+ const { container } = render(
121
+ <MantineProvider>
122
+ <ActionGroup
123
+ secondary={[{ key: 'accounts', label: 'Accounts', icon: <OversizedGlyph /> }]}
124
+ />
125
+ </MantineProvider>,
126
+ )
127
+ // Both viewport variants mount (law C9), so the glyph appears twice; every occurrence is slotted.
128
+ const glyphs = [...container.querySelectorAll('[data-testid="glyph"]')]
129
+ expect(glyphs.length).toBeGreaterThan(0)
130
+ for (const glyph of glyphs) {
131
+ expect(glyph.parentElement?.hasAttribute('data-basalt-icon')).toBe(true)
132
+ }
133
+ })
134
+
135
+ test('a BarAction PRIMARY too — the mobile icon-only form is the same box', () => {
136
+ const { container } = render(
137
+ <MantineProvider>
138
+ <ActionGroup primary={{ key: 'save', label: 'Save as report', icon: <UnsizedGlyph /> }} />
139
+ </MantineProvider>,
140
+ )
141
+ const glyphs = [...container.querySelectorAll('[data-testid="glyph"]')]
142
+ expect(glyphs.length).toBeGreaterThan(0)
143
+ for (const glyph of glyphs) {
144
+ expect(glyph.parentElement?.hasAttribute('data-basalt-icon')).toBe(true)
145
+ }
146
+ })
147
+
148
+ test('an OverflowMenu row — the fold is a home too', () => {
149
+ const { container } = render(
150
+ <MantineProvider>
151
+ <ActionGroup
152
+ secondary={[
153
+ { key: 'a', label: 'A' },
154
+ { key: 'b', label: 'B' },
155
+ { key: 'c', label: 'C' },
156
+ { key: 'd', label: 'D', icon: <OversizedGlyph /> },
157
+ ]}
158
+ />
159
+ </MantineProvider>,
160
+ )
161
+ // The `More` dropdown mounts lazily, so assert the fold exists and its trigger is present; the
162
+ // ROW's slotting is covered by the source guard below, which reads the same code path.
163
+ expect(container.textContent).toContain('More')
164
+ })
165
+
166
+ test('a FilterPill — the leading glyph is slotted (every enum filter renders through it)', () => {
167
+ const { container } = render(
168
+ <MantineProvider>
169
+ <FilterPill label="EUR" icon={<OversizedGlyph />} />
170
+ </MantineProvider>,
171
+ )
172
+ expect(slotAround(container)).not.toBeNull()
173
+ })
174
+
175
+ test('a FilterPill’s trailing affordance is slotted too, at the 14px box', () => {
176
+ const { container } = render(
177
+ <MantineProvider>
178
+ <FilterPill label="EUR" />
179
+ </MantineProvider>,
180
+ )
181
+ // The `⇅` affordance is basalt's own glyph and still goes through the slot — the box is the
182
+ // slot's on every icon in the tier, framework-drawn or consumer-passed.
183
+ expect(slots(container).length).toBe(1)
184
+ })
185
+
186
+ test('a WidgetHeader — tier="widget"', () => {
187
+ const { container } = render(
188
+ <WidgetHeader tier="widget" title="Active users" icon={<OversizedGlyph />} />,
189
+ )
190
+ expect(slotAround(container)).not.toBeNull()
191
+ })
192
+
193
+ test('a WidgetHeader — tier="section" (and StatCard, which forwards `icon` to it)', () => {
194
+ const { container } = render(
195
+ <WidgetHeader tier="section" title="Funnel & retention" icon={<UnsizedGlyph />} />,
196
+ )
197
+ expect(slotAround(container)).not.toBeNull()
198
+ })
199
+
200
+ test('a SyncButton — basalt’s own glyph obeys the same box', () => {
201
+ const { container } = render(
202
+ <MantineProvider>
203
+ <SyncButton scope="page" syncing={false} onSync={() => {}} />
204
+ </MantineProvider>,
205
+ )
206
+ expect(slots(container).length).toBeGreaterThan(0)
207
+ })
208
+ })
209
+
210
+ describe('the slot is decorative and queryable, by construction', () => {
211
+ test('aria-hidden is unconditional — the control carries the name, never the icon', () => {
212
+ const { container } = render(
213
+ <IconSlot>
214
+ <OversizedGlyph />
215
+ </IconSlot>,
216
+ )
217
+ expect(container.querySelector('[data-basalt-icon]')?.getAttribute('aria-hidden')).toBe('true')
218
+ })
219
+
220
+ test('a home’s className is composed ONTO the slot, never in place of it', () => {
221
+ const { container } = render(
222
+ <IconSlot className="home-colour">
223
+ <OversizedGlyph />
224
+ </IconSlot>,
225
+ )
226
+ // The module hash is '' under `bun test`, so the assertion is that the home's class survived
227
+ // alongside whatever the module resolves to — i.e. it is appended, not substituted.
228
+ expect(container.querySelector('[data-basalt-icon]')?.className).toContain('home-colour')
229
+ })
230
+ })
231
+
232
+ /**
233
+ * The recurrence guard. Every `leftSection`/`rightSection` in the control tier that carries a
234
+ * CALLER-supplied icon must route it through `IconSlot` — this is what makes a seventh control
235
+ * added next month fail here instead of shipping an un-boxed glyph, which is exactly how
236
+ * `BarAction` shipped.
237
+ *
238
+ * Scoped to the files that own an `icon` prop in the tier the spec governs. `src/shell/app-sidebar.tsx`
239
+ * and `src/shell/app-mobile-nav.tsx` are deliberately out of scope: a nav row's icon column is sized
240
+ * by the sidebar's own rhythm rules, not by the control tier, and folding them in is a separate
241
+ * change with its own visual budget.
242
+ */
243
+ describe('no control in the tier may hand a caller’s icon straight to a section slot', () => {
244
+ const TIER_FILES = [
245
+ '../controls/actions.tsx',
246
+ '../controls/filter-pill.tsx',
247
+ '../controls/sync-button.tsx',
248
+ '../shell/sidebar-blocks.tsx',
249
+ ] as const
250
+
251
+ for (const rel of TIER_FILES) {
252
+ test(`${rel} routes every icon-bearing section through IconSlot`, () => {
253
+ const source = readFileSync(join(import.meta.dir, rel), 'utf8')
254
+ const offenders: string[] = []
255
+ for (const match of source.matchAll(/(left|right)Section[:=]\s*([^,\n]*)/g)) {
256
+ const raw = match[2] ?? ''
257
+ // A bare identifier (`leftSection: lead`) is resolved one level to its `const` initialiser,
258
+ // so a slot built a line above still counts. One level only — anything deeper than that is
259
+ // indirection a reader could not follow either.
260
+ const bare = /^([A-Za-z_$][\w$]*)\s*\}?\)?,?$/.exec(raw.trim())
261
+ const declared =
262
+ bare === null
263
+ ? undefined
264
+ : new RegExp(`\\bconst ${bare[1]}\\b[^\\n]*(?:\\n[^\\n]*){0,3}`).exec(source)?.[0]
265
+ const value = declared ?? raw
266
+ // Only the icon-bearing sites are in scope — a `rightSection` holding a count badge or a
267
+ // `<Text>` meta string is not an icon and has no box to pin.
268
+ if (!/icon|glyph|lead/i.test(value)) continue
269
+ if (value.includes('IconSlot')) continue
270
+ offenders.push(match[0].trim())
271
+ }
272
+ expect(offenders).toEqual([])
273
+ })
274
+ }
275
+ })
@@ -0,0 +1,59 @@
1
+ /**
2
+ * `IconSlot` — the ONE box every `icon` prop in the public API renders through
3
+ * (`docs/CONTROLS-SPEC.md` §3).
4
+ *
5
+ * basalt ships no icon dependency, so every `icon` slot takes a `ReactNode` the consumer fills —
6
+ * and a consumer's glyph arrives at whatever geometry its own icon set chose: `width="24"` from
7
+ * Tabler's defaults, no `width` at all from a hand-written `<svg viewBox="0 0 20 20">`, a
8
+ * `stroke-width` tuned for a 24px box, or an `<img>` off a sprite sheet. Passing that node straight
9
+ * into Mantine's `leftSection` (which is a bare `flex` box with no size of its own) lets the CALLER
10
+ * decide the height of a basalt control's row. That is the class of bug this component closes: the
11
+ * icon's box is the framework's, and there is no prop on it.
12
+ *
13
+ * **Derived, not configured.** There is no `size` prop, on purpose — a size at a call site is the
14
+ * same C5 violation a `size="xs"` on a Button is. The box reads `--vx-space-icon-size` (default
15
+ * 16px, the control tier's mark), and the only thing entitled to set that var is a HOME's own CSS:
16
+ * `WidgetHeader` sets it per `data-tier`, so a `tier="widget"` heading draws 14px icons and a
17
+ * `tier="section"` heading draws 16px, with every call site unchanged.
18
+ *
19
+ * Mantine-free and dependency-free (one CSS module, one `<span>`), so `src/widget-header/**` — which
20
+ * `ChartCard` composes from inside `src/charts/**` — can deep-import it without pulling
21
+ * `@mantine/*` into the charts graph (`scripts/check-dist-layering.mjs`). Deep-import it as
22
+ * `../theme/icon-slot`, never through the `./theme` barrel, for exactly that reason. INTERNAL: it is
23
+ * not on any barrel. A consumer never needs it — passing `icon` IS the contract, and this is what
24
+ * makes that contract hold.
25
+ *
26
+ * `data-basalt-icon` is the queryable marker, the same idiom `CtlSlot` uses for `data-basalt-tier`:
27
+ * a CSS-module class name is a build-time hash, so it is not something a test — or a consumer
28
+ * debugging a misaligned row — can select on.
29
+ *
30
+ * @example
31
+ * // A control's icon slot — no size anywhere at the call site.
32
+ * <Button leftSection={<IconSlot>{icon}</IconSlot>}>{label}</Button>
33
+ */
34
+ import type { ReactNode } from 'react'
35
+ import classes from './icon-slot.module.css'
36
+
37
+ export type IconSlotProps = {
38
+ readonly children: ReactNode
39
+ /**
40
+ * Composed ONTO the slot class, never in place of it — a home adds colour/state rules (the pill's
41
+ * muted-then-ink icon, a header's faint one) and the geometry stays the slot's.
42
+ */
43
+ readonly className?: string
44
+ }
45
+
46
+ export function IconSlot({ children, className }: IconSlotProps): ReactNode {
47
+ return (
48
+ // `aria-hidden` unconditionally: an icon in a basalt control is DECORATIVE by construction —
49
+ // every control that takes one also carries a label or an `aria-label`, so a named icon would
50
+ // double the announced name. A caller wanting a meaningful graphic names the control, not this.
51
+ <span
52
+ className={className === undefined ? classes.slot : `${classes.slot} ${className}`}
53
+ data-basalt-icon
54
+ aria-hidden="true"
55
+ >
56
+ {children}
57
+ </span>
58
+ )
59
+ }
@@ -99,6 +99,10 @@ export type { BasaltFontsConfig } from '../tokens'
99
99
  // snapshot) — see its own doc comment for why it lives here rather than in `tokens/`.
100
100
  export { useBasaltSpacing } from './use-basalt-spacing'
101
101
 
102
+ // The `size="ctl"` slot theme (`docs/CONTROLS-SPEC.md` §5) — see `./ctl-theme`'s own doc for the
103
+ // merge/precedence contract.
104
+ export { CTL_THEME, CtlSlot, type CtlSlotProps } from './ctl-theme'
105
+
102
106
  // Typed `theme.other.basaltDerive` / `theme.other.basaltFonts` / `theme.other.basaltRadius` /
103
107
  // `theme.other.basaltDensity` reads (Mantine's `MantineThemeOther` ships an index signature, so
104
108
  // this merge is additive — no widening of the existing `[key: string]: any`). Set by
@@ -745,7 +749,12 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
745
749
  SegmentedControl: SegmentedControl.extend({
746
750
  defaultProps: { radius: radius.card },
747
751
  classNames: { label: segmentedControlClasses.label },
748
- styles: {
752
+ // `label` gets a `ctl`-only min-height (function-form `styles`, same `props.size === …` gate
753
+ // as Button/ActionIcon/Input's `vars` overrides above) so a `size="ctl"` SegmentedControl
754
+ // matches the ctl Button/Input box exactly instead of following its own fixed padding — see
755
+ // `CTL_SEGMENT_PADDING_Y`'s doc below for why the label's own padding alone can't track
756
+ // density. `sm`/`md` SegmentedControls (forms) are untouched, keeping Mantine's own sizing.
757
+ styles: (_theme, props) => ({
749
758
  root: {
750
759
  backgroundColor: 'color-mix(in srgb, var(--vx-ink) 6%, transparent)',
751
760
  padding: `${SPACE_FIXED.segmentedTrackInset}px`,
@@ -755,7 +764,19 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
755
764
  boxShadow: 'var(--vx-shadow-ctrl)',
756
765
  borderRadius: 'var(--vx-radius-tight)',
757
766
  },
758
- },
767
+ // Conditionally spread the WHOLE key (never a present `label: undefined`) — the package
768
+ // builds under `exactOptionalPropertyTypes`, which treats a key present-but-`undefined` as
769
+ // a type error distinct from the key being absent.
770
+ ...(props.size === 'ctl'
771
+ ? {
772
+ label: {
773
+ display: 'inline-flex',
774
+ alignItems: 'center',
775
+ minHeight: `calc(var(--vx-space-control-height-ctl) * var(--mantine-scale) - ${2 * SPACE_FIXED.segmentedTrackInset}px)`,
776
+ },
777
+ }
778
+ : {}),
779
+ }),
759
780
  }),
760
781
  // Track = ink-8%; leader/section fill colors are a per-usage `color` prop (left to consumers).
761
782
  Progress: Progress.extend({
@@ -935,10 +956,16 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
935
956
  // body cells is a per-table call-site concern, left to consumers. `--table-border-color`
936
957
  // drives row separators AND the opt-in `withTableBorder` outer border — both drop to the
937
958
  // hairline (Mantine's default reads gray-3/dark-4 = the STRONG line, too heavy in a card).
959
+ // Row heights are declared here rather than through `verticalSpacing`, because Mantine derives
960
+ // BOTH `th` and `td` padding from that one prop — a 36/40 pair (label band over roomier data
961
+ // rows) is not expressible by it at all. `height` on a table cell behaves as a MINIMUM, so a
962
+ // wrapping cell still grows; `verticalSpacing` keeps working on top of it for a consumer that
963
+ // wants a denser or looser table than the tier.
938
964
  Table: Table.extend({
939
965
  classNames: { table: controlsClasses.tableRoot },
940
966
  styles: {
941
967
  th: {
968
+ height: spacing.step.tableHeaderHeight,
942
969
  fontFamily: 'var(--basalt-font-mono)',
943
970
  fontSize: VX.text.micro,
944
971
  fontWeight: 500,
@@ -946,6 +973,7 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
946
973
  textTransform: 'uppercase',
947
974
  color: 'var(--vx-faint)',
948
975
  },
976
+ td: { height: spacing.step.tableRowHeight },
949
977
  },
950
978
  }),
951
979
  // The v9 thumb indicator is a center dot painted in the TRACK color — over the remapped
@@ -1105,6 +1133,138 @@ function familyBridge(data: PaletteData): Record<string, string> {
1105
1133
  return vars
1106
1134
  }
1107
1135
 
1136
+ /**
1137
+ * The `ctl` tier's horizontal inset — one number for a Button, a `FilterPill`, a SegmentedControl
1138
+ * segment and a `kind: 'custom'` Badge, so a home's row reads as one control family
1139
+ * (`docs/CONTROLS-SPEC.md` §3: "the pill: 30px, padding 0 10px"). Not in `SPACE_STEP`: it is a
1140
+ * Mantine size-var value, consumed only by {@link ctlSizeVars}, and it happens to equal
1141
+ * `SPACE.rowInsetX` without being the same concept (that one is a nav/menu ROW's inset).
1142
+ */
1143
+ const CTL_PADDING_X = 10
1144
+ /** The `ctl` tier's Input vertical inset — 30px box, ~20px line, so 4px a side leaves the border. */
1145
+ const CTL_INPUT_PADDING_Y = 4
1146
+ /**
1147
+ * A `ctl` SegmentedControl segment's vertical inset, in px. Fractional on purpose, and derived
1148
+ * rather than picked: the label's line box is `VX.text.sm` × `--mantine-line-height-sm` ≈ 21px and
1149
+ * the track adds `SPACE_FIXED.segmentedTrackInset` on each side (2px), so landing the CONTROL on the
1150
+ * 30px `ctl` tier needs (30 − 4 − 21) / 2. Mantine's own `--input-padding-y-xs` is 4.5px for the
1151
+ * same reason — a control tier is a total height, and the halves that make it up need not be whole.
1152
+ */
1153
+ const CTL_SEGMENT_PADDING_Y = 2.5
1154
+ /** A `ctl` Combobox chevron. Mantine's own `xs` value; restated because it cannot be referenced. */
1155
+ const CTL_CHEVRON_SIZE = 14
1156
+ /**
1157
+ * The `ctl` tier's Radio/Checkbox indicator box, in px — 16, the SAME square
1158
+ * `theme/icon-slot.module.css` gives every icon in the tier, and for the same reason: a 13.5px
1159
+ * option label needs a mark it reads as part of the row rather than as a control beside it. Mantine's
1160
+ * default is `sm` (20px) and its `md` is 24px, both of which made the popover a column of controls
1161
+ * (`controls/filter-sheet.tsx`'s `SheetOptionList` doc records the sheet's version of the same
1162
+ * finding — there the fix was to remove the indicator entirely).
1163
+ */
1164
+ const CTL_TOGGLE_SIZE = 16
1165
+ /**
1166
+ * A `ctl` Radio's inner dot. Mantine's own ratio is `size * 0.4` — the literal fallback its
1167
+ * `varsResolver` applies to a NUMERIC size (`Radio/RadioIndicator/RadioIndicator.mjs`), and what its
1168
+ * shipped pairs already encode (10px dot in a 24px `md` box). Derived rather than picked so the dot
1169
+ * stays proportional if the box moves.
1170
+ */
1171
+ const CTL_RADIO_DOT_SIZE = CTL_TOGGLE_SIZE * 0.4
1172
+ /**
1173
+ * The `ctl` Switch: an 18×30 track with a 12px thumb. Mantine ships no tier between `xs` (16×32,
1174
+ * 12px thumb) and `sm` (20×38, 14px), and neither fits — `xs` is SHORTER than the tier's indicator
1175
+ * box while being twice as wide as it, which reads as a slider rather than a toggle. 18 is one step
1176
+ * over the 16px indicator (a switch must read as the taller control, it carries a moving part), and
1177
+ * 30 is the narrowest track that still shows travel: 30 − 12 thumb − 2×3 padding leaves 12px of it.
1178
+ */
1179
+ const CTL_SWITCH_HEIGHT = 18
1180
+ const CTL_SWITCH_WIDTH = 30
1181
+ const CTL_SWITCH_THUMB_SIZE = 12
1182
+ /** The `on`/`off` labels INSIDE a `ctl` switch track, and their inset. Mantine's `xs` pair, which is
1183
+ * the last tier whose label still fits an 18px track. */
1184
+ const CTL_SWITCH_TRACK_LABEL_SIZE = 5
1185
+ const CTL_SWITCH_TRACK_LABEL_PADDING = 2
1186
+
1187
+ /**
1188
+ * The `size="ctl"` / `size="icon"` Mantine size-tier var sets (`docs/CONTROLS-SPEC.md` §5, C5) —
1189
+ * scheme-independent, so they live in `cssVariablesResolver`'s `variables` block (`:root`, not
1190
+ * `light`/`dark`), declared exactly once regardless of color scheme. Mechanism, verified against
1191
+ * the installed `@mantine/core` 9.3.0 source (`core/utils/get-size/get-size.mjs`): a component's own
1192
+ * `varsResolver` calls `getSize(props.size, '<prefix>')`, which for a non-numeric `size` returns the
1193
+ * STRING `var(--<prefix>-<size>)` verbatim — so `size="ctl"` on `Button` resolves
1194
+ * `--button-height: var(--button-height-ctl)` with ZERO code in this framework's own `Button.extend`
1195
+ * `vars` callback; only the var these names point AT has to exist. `getFontSize(size)` is
1196
+ * `getSize(size, 'mantine-font-size')`, so every `-fz`/`-font-size` component var (Button's
1197
+ * `--button-fz`, Input's `--input-fz`, SegmentedControl's `--sc-font-size`, Combobox's
1198
+ * `--combobox-option-fz`) resolves through the ONE `--mantine-font-size-ctl` declared below — no
1199
+ * per-component `-ctl` font-size var exists to declare (verified against
1200
+ * `SegmentedControl.mjs`/`Combobox.mjs`; a per-component name would be dead weight no `getSize`
1201
+ * call ever reads).
1202
+ *
1203
+ * Heights read the density-tracking `--vx-space-control-height-ctl`/`-widget` anchors
1204
+ * (`tokens/index.ts`'s `spaceDecls`, `docs/CONTROLS-SPEC.md` §5) through the SAME rem-plus-
1205
+ * `--mantine-scale` reconstruction `--vx-space-input-height`/`--vx-space-control-height` already
1206
+ * use for `size="md"` (see `Input.extend`'s doc above) — so the ctl/icon tier tracks the density
1207
+ * knob exactly like every other control height. Paddings, the combobox chevron, and the
1208
+ * SegmentedControl/Combobox option padding have no density-tracking token of their own
1209
+ * (`docs/CONTROLS-SPEC.md` §5 specifies only the height ladder), so they are declared as literal
1210
+ * values here.
1211
+ *
1212
+ * **They may NOT be declared as `var(--<prefix>-xs)`, and that mistake shipped.** Mantine declares
1213
+ * its `-xs`/`-sm`/… size vars on the COMPONENT ROOT class (`.m_77c9d27d { --button-padding-x-xs:
1214
+ * 14px }` in `Button.css`, `.m_cf365364 { --sc-padding-xs: 2px 6px }` in `SegmentedControl.css`,
1215
+ * and likewise for Input/Combobox/Badge) — never on `:root`. A `-ctl` var declared HERE lives on
1216
+ * `:root`, where `var(--sc-padding-xs)` resolves to nothing, so the whole custom property became
1217
+ * guaranteed-invalid and every property reading it fell back to its INITIAL value: a pill with
1218
+ * `padding: 0`, a SegmentedControl whose segments touched
1219
+ * (`allnewreturningchurnedflagged`), a chevron-less Select. Literal values are the only form that
1220
+ * can resolve at `:root`. Verified live with `getComputedStyle(document.documentElement)
1221
+ * .getPropertyValue('--sc-padding-ctl')` returning `''`.
1222
+ *
1223
+ * `size="icon"` (24px) has no vertical-padding/chevron consumer (it is ActionIcon-only), so only
1224
+ * `--ai-size-icon` is declared for it. The Badge tier exists so a `kind: 'custom'` chip in a home
1225
+ * (`docs/CONTROLS-SPEC.md` §2.1) is the same 30px as the buttons beside it — Mantine's tallest
1226
+ * shipped Badge is 32px `xl`, and none of its tiers is 30.
1227
+ */
1228
+ function ctlSizeVars(): Record<string, string> {
1229
+ const ctlHeight = 'calc(var(--vx-space-control-height-ctl) * var(--mantine-scale))'
1230
+ return {
1231
+ '--button-height-ctl': ctlHeight,
1232
+ '--button-padding-x-ctl': `calc(${pxRem(CTL_PADDING_X)} * var(--mantine-scale))`,
1233
+ '--input-height-ctl': ctlHeight,
1234
+ '--input-padding-y-ctl': `calc(${pxRem(CTL_INPUT_PADDING_Y)} * var(--mantine-scale))`,
1235
+ '--ai-size-ctl': ctlHeight,
1236
+ '--ai-size-icon': 'calc(var(--vx-space-control-height-widget) * var(--mantine-scale))',
1237
+ // A shorthand pair (`<y> <x>`), the shape SegmentedControl's own `--sc-padding-*` vars take.
1238
+ // `y` stays a bare literal (the label's own box height is now enforced by the `size === 'ctl'`
1239
+ // `minHeight` override in `SegmentedControl.extend` above, not by this padding), `x` scales like
1240
+ // every other ctl-tier horizontal inset (`--button-padding-x-ctl`, `--badge-padding-x-ctl`).
1241
+ '--sc-padding-ctl': `${pxRem(CTL_SEGMENT_PADDING_Y)} calc(${pxRem(CTL_PADDING_X)} * var(--mantine-scale))`,
1242
+ '--combobox-option-padding-ctl': `${pxRem(CTL_SEGMENT_PADDING_Y)} ${pxRem(CTL_PADDING_X)}`,
1243
+ '--combobox-chevron-size-ctl': pxRem(CTL_CHEVRON_SIZE),
1244
+ '--badge-height-ctl': ctlHeight,
1245
+ '--badge-padding-x-ctl': `calc(${pxRem(CTL_PADDING_X)} * var(--mantine-scale))`,
1246
+ '--badge-fz-ctl': `calc(${pxRem(VX.text.xs)} * var(--mantine-scale))`,
1247
+ // `= VX.text.sm` per `docs/CONTROLS-SPEC.md` §5 — same `calc(<rem> * var(--mantine-scale))`
1248
+ // shape Mantine's own `--mantine-font-size-*` vars use.
1249
+ '--mantine-font-size-ctl': `calc(${pxRem(VX.text.sm)} * var(--mantine-scale))`,
1250
+ '--mantine-line-height-ctl': 'var(--mantine-line-height-sm)',
1251
+ // Radio / Checkbox / Switch — the three components whose DEFAULT (`sm`, a 20px indicator) sat
1252
+ // beside a 13.5px option label in every popover and sheet basalt draws. Every prefix here was
1253
+ // read out of the installed `@mantine/core` source, and `ctl-tier-coverage.test.ts` now scans
1254
+ // those three components too, so a future minor that adds a `getSize` call fails the build
1255
+ // instead of resolving the new var to its initial value. Note there is NO
1256
+ // `--checkbox-icon-size`: Checkbox reads only `checkbox-size` and derives its tick in CSS.
1257
+ '--radio-size-ctl': `calc(${pxRem(CTL_TOGGLE_SIZE)} * var(--mantine-scale))`,
1258
+ '--radio-icon-size-ctl': `calc(${pxRem(CTL_RADIO_DOT_SIZE)} * var(--mantine-scale))`,
1259
+ '--checkbox-size-ctl': `calc(${pxRem(CTL_TOGGLE_SIZE)} * var(--mantine-scale))`,
1260
+ '--switch-height-ctl': `calc(${pxRem(CTL_SWITCH_HEIGHT)} * var(--mantine-scale))`,
1261
+ '--switch-width-ctl': `calc(${pxRem(CTL_SWITCH_WIDTH)} * var(--mantine-scale))`,
1262
+ '--switch-thumb-size-ctl': `calc(${pxRem(CTL_SWITCH_THUMB_SIZE)} * var(--mantine-scale))`,
1263
+ '--switch-label-font-size-ctl': pxRem(CTL_SWITCH_TRACK_LABEL_SIZE),
1264
+ '--switch-track-label-padding-ctl': pxRem(CTL_SWITCH_TRACK_LABEL_PADDING),
1265
+ }
1266
+ }
1267
+
1108
1268
  /**
1109
1269
  * Build the `cssVariablesResolver` bound to a given palette `data` — the derive-config-dependent
1110
1270
  * half of the fallback hexes below (see `buildTheme` above for the same split on `theme.colors`).
@@ -1123,7 +1283,9 @@ function familyBridge(data: PaletteData): Record<string, string> {
1123
1283
  function buildCssVariablesResolver(data: PaletteData): CSSVariablesResolver {
1124
1284
  const { INK, NEUTRAL, SURFACE } = data
1125
1285
  return (theme) => ({
1126
- variables: {},
1286
+ variables: {
1287
+ ...ctlSizeVars(),
1288
+ },
1127
1289
  light: {
1128
1290
  ...onColorVars(theme, 'light', data),
1129
1291
  ...familyBridge(data),
@@ -0,0 +1,72 @@
1
+ /**
2
+ * CSS-text regression tests for two shell rules that broke silently because neither is exercised
3
+ * by a render test: `.footerVersion`'s collapsed-rail hide (a corrupted selector left it unscoped,
4
+ * hiding the sidebar version label at every desktop viewport) and the sticky page-bar rows' z-index
5
+ * (a bare `1` let ordinary in-flow content — Mantine's Input section, Indicator, sticky table
6
+ * headers — paint over them while scrolling).
7
+ *
8
+ * Both are asserted against the raw `.module.css` source text rather than through jsdom: jsdom does
9
+ * not implement CSS cascade/specificity resolution or `getComputedStyle` for stacking, so a
10
+ * render-based test cannot see either regression. Reading the source text directly is what actually
11
+ * pins the rule shape.
12
+ */
13
+ import { readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
15
+ import { describe, expect, it } from 'bun:test'
16
+
17
+ const APP_SIDEBAR_CSS = readFileSync(
18
+ join(import.meta.dir, '../shell/app-sidebar.module.css'),
19
+ 'utf8',
20
+ )
21
+ const PAGE_BAR_CSS = readFileSync(join(import.meta.dir, '../shell/page-bar.module.css'), 'utf8')
22
+
23
+ /** The `@media (min-width: 48em)` rail block — where `[data-collapsed]` rules live. */
24
+ function railBlock(css: string): string {
25
+ const start = css.indexOf('@media (min-width: 48em)')
26
+ expect(start).toBeGreaterThanOrEqual(0)
27
+ return css.slice(start)
28
+ }
29
+
30
+ describe('app-sidebar.module.css — collapsed-rail `.footerVersion` hide', () => {
31
+ const rail = railBlock(APP_SIDEBAR_CSS)
32
+
33
+ it('scopes the version-label hide to `.root[data-collapsed]`, not a bare `.footerVersion`', () => {
34
+ expect(rail).toContain('.root[data-collapsed] .footerVersion {\n display: none;\n }')
35
+ })
36
+
37
+ it('never declares an unscoped `.footerVersion` rule inside the rail media query', () => {
38
+ // A bare `.footerVersion {` (no `[data-collapsed]` ancestor on the same rule) would hide the
39
+ // version label at every desktop viewport, expanded or collapsed — the exact regression.
40
+ for (const m of rail.matchAll(/([^{}\n]+)\{\s*display:\s*none;\s*\}/g)) {
41
+ const selector = m[1]!.trim()
42
+ if (!selector.endsWith('.footerVersion')) continue
43
+ expect(selector).toContain('[data-collapsed]')
44
+ }
45
+ })
46
+
47
+ it('does not duplicate the base `.footerIconSlot` rule inside the rail media query', () => {
48
+ // The real `.footerIconSlot` rule is declared once, outside the media query — a stray copy
49
+ // inside `@media (min-width: 48em)` is the corruption artifact this test guards against.
50
+ expect([...rail.matchAll(/\.footerIconSlot\s*\{/g)]).toHaveLength(0)
51
+ })
52
+ })
53
+
54
+ describe('page-bar.module.css — sticky rows sit under the AppShell header, above page content', () => {
55
+ it('`.bar` sits one layer below the AppShell header, not at the bare `z-index: 1` every sibling can equal', () => {
56
+ const bar = PAGE_BAR_CSS.slice(
57
+ PAGE_BAR_CSS.indexOf('.bar {'),
58
+ PAGE_BAR_CSS.indexOf('\n}', PAGE_BAR_CSS.indexOf('.bar {')),
59
+ )
60
+ expect(bar).toContain('z-index: calc(var(--app-shell-header-z-index, 100) - 1);')
61
+ expect(bar).not.toMatch(/z-index:\s*1\s*;/)
62
+ })
63
+
64
+ it('`.row2Sticky` uses the same header-relative z-index as `.bar`, so the two never fall out of sync', () => {
65
+ const row2 = PAGE_BAR_CSS.slice(
66
+ PAGE_BAR_CSS.indexOf('.row2Sticky {'),
67
+ PAGE_BAR_CSS.indexOf('\n}', PAGE_BAR_CSS.indexOf('.row2Sticky {')),
68
+ )
69
+ expect(row2).toContain('z-index: calc(var(--app-shell-header-z-index, 100) - 1);')
70
+ expect(row2).not.toMatch(/z-index:\s*1\s*;/)
71
+ })
72
+ })
@@ -70,7 +70,17 @@ describe('every re-pointed component defaultProps/styles radius matches its ship
70
70
  test('SegmentedControl root = 7, indicator = 5', () => {
71
71
  const sc = baseTheme.components?.['SegmentedControl']
72
72
  expect(sc?.defaultProps?.['radius']).toBe(7)
73
- expect(sc?.styles?.['indicator']?.['borderRadius']).toBe('var(--vx-radius-tight)')
73
+ // `styles` is function-form (size='ctl' gates a label override — see `theme/index.ts`), not a
74
+ // flat object, so it has to be called rather than indexed directly.
75
+ const stylesFn = sc?.styles as
76
+ | ((
77
+ theme: unknown,
78
+ props: { size?: string },
79
+ ctx: unknown,
80
+ ) => Record<string, Record<string, unknown>>)
81
+ | undefined
82
+ const resolved = stylesFn?.({}, {}, {})
83
+ expect(resolved?.['indicator']?.['borderRadius']).toBe('var(--vx-radius-tight)')
74
84
  })
75
85
 
76
86
  test('Progress = 4', () => {