basalt-ui 1.25.0 → 1.27.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 (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  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 +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -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 +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -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 +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -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 +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -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/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `useNav`'s active-resolution pass, under a REAL TanStack memory router — the half that cannot be
3
+ * tested headlessly (`matchRoute`/`useRouterState` both need a live router). The fixture below
4
+ * mirrors every collision shape the playground's own `demo/nav-model.tsx` actually has: a root `/`
5
+ * item, a `/dashboard` parent with three children, flat siblings, a same-path duplicate
6
+ * (`reports`/`components`, mirroring the playground's disabled-placeholder shape), and a
7
+ * search-bearing link (mirroring `dashboardFilters.linkSearch`-style destinations).
8
+ *
9
+ * The harness is modeled on `controls/controls.router.test.tsx`'s `mountPage`, narrowed to a
10
+ * headless probe: `useNav` renders no JSX itself (`./router-tanstack` is a no-JSX barrel), so the
11
+ * probe component only reads its return value into a sink — no `MantineProvider` needed.
12
+ */
13
+ import { cleanup, render, waitFor } from '@testing-library/react'
14
+ import { describe, expect, test } from 'bun:test'
15
+ import type { ReactNode } from 'react'
16
+ import {
17
+ Outlet,
18
+ RouterProvider,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ } from '@tanstack/react-router'
24
+ import type { SidebarItem, SidebarSection } from '../nav/types'
25
+ import type { AnyNavItem } from './nav'
26
+ import { defineNav, navGroup } from './nav'
27
+ import { useNav } from './use-nav'
28
+
29
+ const NAV = defineNav({
30
+ groups: [
31
+ navGroup({ id: 'main', label: 'Main' }, [
32
+ { id: 'home', label: 'Home', link: { to: '/' } },
33
+ {
34
+ id: 'dashboard',
35
+ label: 'Dashboard',
36
+ // A store-backed destination (law C10's shape) — `search` rides the link like every
37
+ // other field; `matchOf` below never reads it, so it must not affect matching either.
38
+ link: { to: '/dashboard', search: { window: '30d' } },
39
+ children: [
40
+ { id: 'dashboard-sessions', label: 'Sessions', link: { to: '/dashboard/sessions' } },
41
+ { id: 'dashboard-traffic', label: 'Traffic', link: { to: '/dashboard/traffic' } },
42
+ { id: 'dashboard-revenue', label: 'Revenue', link: { to: '/dashboard/revenue' } },
43
+ ],
44
+ },
45
+ { id: 'mobile-nav-pill', label: 'Mobile nav pill', link: { to: '/mobile-nav-pill' } },
46
+ {
47
+ id: 'controls-mobile',
48
+ label: 'Controls (mobile)',
49
+ link: { to: '/controls-mobile' },
50
+ },
51
+ { id: 'components', label: 'Components', link: { to: '/components' } },
52
+ { id: 'charts-page', label: 'Charts', link: { to: '/charts' } },
53
+ // The playground's `reports` shape: a disabled placeholder that deliberately borrows an
54
+ // already-covered route rather than inventing one. Declared AFTER `components`, so the
55
+ // definition-order tie-break keeps `components` the winner at `/components`.
56
+ { id: 'reports', label: 'Reports', disabled: true, link: { to: '/components' } },
57
+ ]),
58
+ ],
59
+ })
60
+
61
+ const ROUTE_PATHS = [
62
+ '/',
63
+ '/dashboard',
64
+ '/dashboard/sessions',
65
+ '/dashboard/traffic',
66
+ '/dashboard/revenue',
67
+ '/mobile-nav-pill',
68
+ '/controls-mobile',
69
+ '/components',
70
+ '/charts',
71
+ ]
72
+
73
+ type NavProbeOpts = { isActive?: (item: AnyNavItem) => boolean }
74
+ type NavResult = { sections: SidebarSection[] }
75
+ type Sink = { current: NavResult | null }
76
+
77
+ /** Depth-first flatten of the rendered `SidebarSection[]` — the same shape every downstream reader
78
+ * (the sidebar, the mobile bar, the breadcrumb) walks. */
79
+ function flattenItems(sections: SidebarSection[]): SidebarItem[] {
80
+ const out: SidebarItem[] = []
81
+ const walk = (items: SidebarItem[]): void => {
82
+ for (const item of items) {
83
+ out.push(item)
84
+ if (item.children) walk(item.children)
85
+ }
86
+ }
87
+ for (const section of sections) walk(section.items)
88
+ return out
89
+ }
90
+
91
+ async function mountNav(entry: string, opts?: NavProbeOpts): Promise<Sink> {
92
+ const sink: Sink = { current: null }
93
+
94
+ function NavProbe(): ReactNode {
95
+ const nav = useNav(NAV, opts)
96
+ sink.current = { sections: nav.sections }
97
+ return null
98
+ }
99
+
100
+ const rootRoute = createRootRoute({
101
+ component: () => (
102
+ <>
103
+ <NavProbe />
104
+ <Outlet />
105
+ </>
106
+ ),
107
+ })
108
+ const children = ROUTE_PATHS.map((path) =>
109
+ createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
110
+ )
111
+ const router = createRouter({
112
+ routeTree: rootRoute.addChildren(children),
113
+ history: createMemoryHistory({ initialEntries: [entry] }),
114
+ })
115
+
116
+ render(<RouterProvider router={router} />)
117
+ await waitFor(() => {
118
+ expect(router.state.status).toBe('idle')
119
+ })
120
+ return sink
121
+ }
122
+
123
+ describe('useNav — exclusivity', () => {
124
+ test('exactly one row is active at every route in the definition', async () => {
125
+ for (const path of ROUTE_PATHS) {
126
+ const sink = await mountNav(path)
127
+ const items = flattenItems(sink.current!.sections)
128
+ const activeCount = items.filter((item) => item.active).length
129
+ expect(activeCount).toBe(1)
130
+ cleanup()
131
+ }
132
+ })
133
+
134
+ test('the deepest match wins — a child route does not also light its parent', async () => {
135
+ const sink = await mountNav('/dashboard/sessions')
136
+ const items = flattenItems(sink.current!.sections)
137
+ const dashboard = items.find((item) => item.key === 'dashboard')
138
+ const sessions = items.find((item) => item.key === 'dashboard-sessions')
139
+ expect(dashboard?.active).toBe(false)
140
+ expect(sessions?.active).toBe(true)
141
+ })
142
+
143
+ test('the parent of the active row is an ancestor, not active', async () => {
144
+ const sink = await mountNav('/dashboard/sessions')
145
+ const items = flattenItems(sink.current!.sections)
146
+ const dashboard = items.find((item) => item.key === 'dashboard')
147
+ expect(dashboard?.ancestor).toBe(true)
148
+ expect(dashboard?.active).toBe(false)
149
+ })
150
+
151
+ test('a same-path duplicate destination is neither active nor an ancestor', async () => {
152
+ const sink = await mountNav('/components')
153
+ const items = flattenItems(sink.current!.sections)
154
+ const components = items.find((item) => item.key === 'components')
155
+ const reports = items.find((item) => item.key === 'reports')
156
+ expect(components?.active).toBe(true)
157
+ expect(reports?.active).toBe(false)
158
+ expect(reports?.ancestor).toBeFalsy()
159
+ })
160
+
161
+ test("'/' stays exact — the root never lights on a nested route", async () => {
162
+ const sink = await mountNav('/dashboard')
163
+ const items = flattenItems(sink.current!.sections)
164
+ const home = items.find((item) => item.key === 'home')
165
+ expect(home?.active).toBe(false)
166
+ expect(home?.ancestor).toBeFalsy()
167
+ })
168
+
169
+ test('an item whose exact route is never visited still lights on a descendant route', async () => {
170
+ // `/dashboard` itself is never the visited entry in this test — only its descendant is —
171
+ // proving the fix kept `fuzzy: true` as the default rather than flipping every item to exact.
172
+ const sink = await mountNav('/dashboard/traffic')
173
+ const items = flattenItems(sink.current!.sections)
174
+ const dashboard = items.find((item) => item.key === 'dashboard')
175
+ expect(dashboard?.ancestor).toBe(true)
176
+ expect(dashboard?.active).toBe(false)
177
+ })
178
+
179
+ test('an isActive override cannot light two rows either', async () => {
180
+ const sink = await mountNav('/', {
181
+ isActive: (item) => item.id === 'components' || item.id === 'charts-page',
182
+ })
183
+ const items = flattenItems(sink.current!.sections)
184
+ const activeCount = items.filter((item) => item.active).length
185
+ expect(activeCount).toBe(1)
186
+ })
187
+ })
@@ -13,6 +13,7 @@ import { createElement, useMemo } from 'react'
13
13
  import type { ElementType, ReactNode } from 'react'
14
14
  import { Link, useMatchRoute, useRouterState } from '@tanstack/react-router'
15
15
  import type { MobileNavConfig, NavAnchor, SidebarItem, SidebarSection } from '../nav/types'
16
+ import { flattenNav } from './nav'
16
17
  import type { AnyNavGroup, AnyNavItem, NavConfig, NavItemId, NavTabId } from './nav'
17
18
 
18
19
  export type UseNavOptions<G extends ReadonlyArray<AnyNavGroup>> = {
@@ -24,8 +25,6 @@ export type UseNavOptions<G extends ReadonlyArray<AnyNavGroup>> = {
24
25
  badges?: Partial<Record<NavItemId<G>, number | ReactNode>>
25
26
  /** Override active detection entirely. */
26
27
  isActive?: (item: AnyNavItem) => boolean
27
- /** Passed straight through into the returned `mobileNav`. */
28
- moreExtra?: ReactNode
29
28
  getScrollElement?: () => HTMLElement | null
30
29
  }
31
30
 
@@ -57,40 +56,101 @@ export function useNav<
57
56
  const badgeOf = (id: string): number | ReactNode =>
58
57
  (badges as Record<string, number | ReactNode> | undefined)?.[id]
59
58
 
60
- const isActive = (item: AnyNavItem): boolean =>
59
+ // Per-item match, exactly as before: the override wins when supplied, otherwise a router
60
+ // prefix match (`'/'` stays exact). What changed is that this boolean no longer BECOMES
61
+ // `active` on its own — it only says "on the path", and the pass below picks ONE winner
62
+ // across the whole definition so a parent and its child can no longer both read `active`.
63
+ const matchOf = (item: AnyNavItem): boolean =>
61
64
  isActiveOverride?.(item) ??
62
65
  Boolean(
63
66
  matchRoute({
64
67
  to: item.link.to,
65
68
  ...(item.link.params !== undefined && { params: item.link.params }),
66
- // Prefix match by default so a parent stays lit on its children; `'/'` would then match
67
- // everything, so it is always exact.
68
69
  fuzzy: item.exact !== true && item.link.to !== '/',
69
70
  }),
70
71
  )
71
72
 
73
+ // One pass over the WHOLE flattened definition (`flattenNav` — depth-first, parent then
74
+ // children, the same order every other reader of a nav config walks it) rather than a
75
+ // per-item independent check, which is what let a parent prefix-match stay lit alongside an
76
+ // exact-matching child. The most SPECIFIC match (most non-empty `/`-segments in `link.to`)
77
+ // wins; a tie (the playground's `reports`/`components` shape, both pointing at `/components`)
78
+ // keeps the FIRST item in definition order, since only a strictly later, strictly longer match
79
+ // ever replaces the current winner below.
80
+ const allItems = flattenNav(config)
81
+ const specificity = (to: string): number => to.split('/').filter(Boolean).length
82
+ const matchedById = new Map<string, boolean>()
83
+ let winnerId: string | undefined
84
+ let winnerTo: string | undefined
85
+ for (const item of allItems) {
86
+ const matched = matchOf(item)
87
+ matchedById.set(item.id, matched)
88
+ if (
89
+ matched &&
90
+ (winnerTo === undefined || specificity(item.link.to) > specificity(winnerTo))
91
+ ) {
92
+ winnerId = item.id
93
+ winnerTo = item.link.to
94
+ }
95
+ }
96
+
97
+ // `active` is true for the winner only. `ancestor` is true for a matched item that sits
98
+ // strictly on the winner's PATH (`winnerTo` starts with `item.link.to + '/'`) — a same-length
99
+ // tie loser matches nothing here (its own `to` cannot be a strict prefix of an equally long
100
+ // `winnerTo`), so it reads as neither active nor an ancestor, which is the point: two
101
+ // destinations sharing one route are siblings, not a hierarchy.
102
+ const resolveState = (item: AnyNavItem): { active: boolean; ancestor: boolean } => {
103
+ if (item.id === winnerId) return { active: true, ancestor: false }
104
+ const matched = matchedById.get(item.id) ?? false
105
+ const ancestor = winnerTo !== undefined && matched && winnerTo.startsWith(`${item.link.to}/`)
106
+ return { active: false, ancestor }
107
+ }
108
+
72
109
  const toSidebarItem = (item: AnyNavItem): SidebarItem => {
73
110
  // One anchor per destination. Basalt renders every pixel of chrome around it (desktop row,
74
111
  // 56px slot, 44px sheet row) and only hosts this component, so `preload`, middle-click and
75
112
  // back/forward all keep working. `Link as ElementType` is the single cast in the whole
76
113
  // chain: the widened `AnyNavLink['search']` is `unknown`, which `LinkComponentProps` rejects.
77
114
  // The consumer gets zero casts, which is the point.
115
+ //
116
+ // `activeOptions: { exact: true }` is what makes `resolveState` above the ONLY authority on
117
+ // active, and it is not a preference — a `Link` computes its own `isActive` (default
118
+ // `activeOptions.exact: false`, i.e. prefix) and spreads `{ 'data-status': 'active',
119
+ // 'aria-current': 'page' }` LAST, after `activeProps` and after every caller prop
120
+ // (`@tanstack/react-router/dist/esm/link.js` — `...isActive && STATIC_ACTIVE_PROPS` is the
121
+ // final entry of the returned object). So `aria-current` cannot be overridden from outside;
122
+ // the only lever is `isActive` itself. Left at its default, the router re-derived the exact
123
+ // fuzzy match this resolver exists to replace, and at `/dashboard/sessions` BOTH the parent
124
+ // and the child anchor carried `aria-current="page"` even with basalt's own model marking one
125
+ // of them an ancestor — verified in Chrome, and the reason the first attempt at this fix
126
+ // measured as no fix at all.
127
+ //
128
+ // Exact here does NOT narrow basalt's own law. `resolveState` still matches fuzzily and still
129
+ // lights an item whose exact route is never visited; that item is simply named by the
130
+ // `aria-current` `app-sidebar.tsx` stamps itself, which survives because the router's spread
131
+ // is conditional on ITS `isActive` being true.
78
132
  const Anchor: NavAnchor = (props) =>
79
- createElement(Link as ElementType, { ...item.link, ...props })
133
+ createElement(Link as ElementType, {
134
+ ...item.link,
135
+ activeOptions: { exact: true },
136
+ ...props,
137
+ })
80
138
 
81
139
  const badge = badgeOf(item.id)
82
140
  const children = item.children?.map(toSidebarItem)
141
+ const { active, ancestor } = resolveState(item)
83
142
 
84
143
  return {
85
144
  key: item.id,
86
145
  label: item.label,
87
146
  icon: item.icon,
88
147
  Anchor,
89
- // Per-destination active only. Rolling a child's active state up into its parent is the
90
- // MOBILE SLOT's rule (§2.3.12) and belongs to `projectMobileNav`, which reads `children`
91
- // anyway — doing it here too would also light a desktop parent row whose own route does
92
- // not match.
93
- active: isActive(item),
148
+ // Exactly one destination in the WHOLE definition reads `active` (`resolveState` above).
149
+ // Rolling that up into a mobile SLOT (any descendant active → the slot reads active) is
150
+ // still `projectMobileNav`'s job, which reads `children` for that. `ancestor` is the
151
+ // desktop-only middle state: a matched item that sits on the winner's path but isn't it.
152
+ active,
153
+ ...(ancestor && { ancestor }),
94
154
  ...(item.short !== undefined && { short: item.short }),
95
155
  ...(item.mobile !== undefined && { mobile: item.mobile }),
96
156
  ...(item.disabled !== undefined && { disabled: item.disabled }),
@@ -119,7 +179,6 @@ export function useNav<
119
179
  }, [config, matchRoute, href, badges, isActiveOverride])
120
180
 
121
181
  const mobile = config.mobile
122
- const moreExtra = opts?.moreExtra
123
182
  const getScrollElement = opts?.getScrollElement
124
183
 
125
184
  const mobileNav = useMemo<MobileNavConfig>(() => {
@@ -137,10 +196,9 @@ export function useNav<
137
196
  ...(mobile?.maxTabs !== undefined && { maxTabs: mobile.maxTabs }),
138
197
  ...(mobile?.menuMax !== undefined && { menuMax: mobile.menuMax }),
139
198
  ...(mobile?.moreLabel !== undefined && { moreLabel: mobile.moreLabel }),
140
- ...(moreExtra !== undefined && { moreExtra }),
141
199
  ...(getScrollElement !== undefined && { getScrollElement }),
142
200
  }
143
- }, [config, mobile, moreExtra, getScrollElement])
201
+ }, [config, mobile, getScrollElement])
144
202
 
145
203
  return { sections, mobileNav }
146
204
  }
@@ -0,0 +1 @@
1
+ export { Section, type SectionProps } from './section'
@@ -0,0 +1,104 @@
1
+ /*
2
+ * Section — the tier-2 heading composer (docs/CONTROLS-SPEC.md §2.2). No border/background of its
3
+ * own — one shaded container level per page belongs to the cards inside a Section
4
+ * (`ChartCard`/`StatCard`), not to the Section itself.
5
+ */
6
+
7
+ /*
8
+ * 12px from the header to the body, and the same between two body children — one rhythm, read from
9
+ * the stack scale (`--vx-space-stack-md`) instead of the card-inset scale
10
+ * (`--mantine-spacing-sm`, 13px). A Section is a page-level GROUPING, so its gaps belong to the
11
+ * vertical rhythm, not to the inset ladder its cards use.
12
+ */
13
+ .root {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: var(--vx-space-stack-md, 0.75rem);
17
+ }
18
+
19
+ .body {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: var(--vx-space-stack-md, 0.75rem);
23
+ }
24
+
25
+ /*
26
+ * `summary` — the always-visible row under the header. No border, no background (the Section owns
27
+ * neither); it is a plain flow row, so a consumer's `Group` of stats or one line of `Text` lands
28
+ * where the body would have started.
29
+ */
30
+ .summary {
31
+ display: flex;
32
+ flex-direction: column;
33
+ gap: var(--mantine-spacing-xs, 0.625rem);
34
+ }
35
+
36
+ /*
37
+ * The collapse chevron — a plain control, not a Mantine ActionIcon, so it sits directly in the header
38
+ * row (its own CtlSlot fragment would nest a theme provider around one glyph). It is sized to the
39
+ * `ctl` tier BY HAND for exactly that reason: a 12px glyph in a 20px box was a different height from
40
+ * every other control in the same header, which is what made the section header read as ragged. 30px
41
+ * square, glyph centred, no padding — the box IS the tier.
42
+ */
43
+ .chevron {
44
+ /* `relative` is the anchor for the C15 hit-area overlay in the `< sm` block at the bottom. */
45
+ position: relative;
46
+ display: inline-flex;
47
+ flex: none;
48
+ align-items: center;
49
+ justify-content: center;
50
+ /* `--vx-space-control-height-ctl` is a bare rem — the theme's `ctlSizeVars` (`theme/index.ts`)
51
+ * reconstructs every OTHER ctl-tier box as `calc(<rem> * var(--mantine-scale))` so it tracks
52
+ * Mantine's `theme.scale`; read raw here, this box used to stay fixed while a scaled Button/Input
53
+ * beside it grew or shrank, reintroducing the exact raggedness this control was sized to fix. */
54
+ width: calc(var(--vx-space-control-height-ctl) * var(--mantine-scale, 1));
55
+ height: calc(var(--vx-space-control-height-ctl) * var(--mantine-scale, 1));
56
+ appearance: none;
57
+ margin: 0;
58
+ padding: 0;
59
+ border: none;
60
+ background: none;
61
+ border-radius: var(--vx-radius-ctrl);
62
+ color: var(--vx-muted);
63
+ cursor: pointer;
64
+ }
65
+
66
+ .chevron:hover,
67
+ .chevron:focus-visible {
68
+ color: var(--vx-ink);
69
+ background: var(--vx-surface-subtle);
70
+ }
71
+
72
+ .chevron svg {
73
+ transition: transform 150ms ease;
74
+ }
75
+
76
+ .chevron[data-open='false'] svg {
77
+ transform: rotate(-90deg);
78
+ }
79
+
80
+ /*
81
+ * Law C15 — the chevron is a 12px glyph in a 20px box, which is a fine mouse target and an
82
+ * unusable thumb one. Below `sm` an `::after` overlay lifts the HIT area to
83
+ * `--vx-space-touch-control-height` (36px at level 0) while the visible glyph stays put — the same
84
+ * overlay `shell/page-bar.module.css` draws for the bar's own controls. `overflow: visible` is part
85
+ * of the fix, not tidying: an overlay that extends past the box is clipped by any `overflow:
86
+ * hidden` on the host (which is what made the identical overlay a no-op on Mantine's Button roots).
87
+ */
88
+ @media (max-width: 47.99375em) {
89
+ .chevron {
90
+ overflow: visible;
91
+ }
92
+
93
+ .chevron::after {
94
+ content: '';
95
+ position: absolute;
96
+ top: 50%;
97
+ left: 50%;
98
+ translate: -50% -50%;
99
+ width: 100%;
100
+ height: 100%;
101
+ min-width: var(--vx-space-touch-control-height, 2.25rem);
102
+ min-height: var(--vx-space-touch-control-height, 2.25rem);
103
+ }
104
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
+ * signature — so dot access stays verbatim under the package's strict flags.
4
+ */
5
+ declare const classes: {
6
+ readonly root: string
7
+ readonly body: string
8
+ readonly summary: string
9
+ readonly chevron: string
10
+ }
11
+ export default classes
@@ -0,0 +1,204 @@
1
+ /**
2
+ * `Section` — the tier-2 heading composer (docs/CONTROLS-SPEC.md §2.2): the collapse contract
3
+ * (persisted vs local, header stays drawn, body unmounts, tabs hide), the scroll-anchor style, and
4
+ * the ≤3-actions dev warning (C6).
5
+ */
6
+ import { MantineProvider } from '@mantine/core'
7
+ import { fireEvent, render, screen } from '@testing-library/react'
8
+ import { beforeEach, describe, expect, spyOn, test } from 'bun:test'
9
+ import type { ReactElement } from 'react'
10
+ import { Section } from './section'
11
+
12
+ function renderWith(node: ReactElement) {
13
+ return render(<MantineProvider>{node}</MantineProvider>)
14
+ }
15
+
16
+ beforeEach(() => {
17
+ window.localStorage.clear()
18
+ })
19
+
20
+ describe('Section composes WidgetHeader at the section tier', () => {
21
+ test('renders an h2 carrying the title', () => {
22
+ renderWith(
23
+ <Section title="Revenue">
24
+ <div>body</div>
25
+ </Section>,
26
+ )
27
+ expect(screen.getByRole('heading', { level: 2, name: 'Revenue' })).toBeDefined()
28
+ })
29
+ })
30
+
31
+ describe('collapsible', () => {
32
+ test('the header stays drawn when closed; only the body unmounts', () => {
33
+ renderWith(
34
+ <Section title="Usage" collapsible>
35
+ <div>body content</div>
36
+ </Section>,
37
+ )
38
+ expect(screen.getByRole('heading', { level: 2, name: 'Usage' })).toBeDefined()
39
+ expect(screen.getByText('body content')).toBeDefined()
40
+
41
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse section' }))
42
+
43
+ expect(screen.getByRole('heading', { level: 2, name: 'Usage' })).toBeDefined()
44
+ expect(screen.queryByText('body content')).toBeNull()
45
+ })
46
+
47
+ test('tabs hide while collapsed', () => {
48
+ renderWith(
49
+ <Section title="Usage" collapsible tabs={<div>the tabs</div>}>
50
+ <div>body</div>
51
+ </Section>,
52
+ )
53
+ expect(screen.getByText('the tabs')).toBeDefined()
54
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse section' }))
55
+ expect(screen.queryByText('the tabs')).toBeNull()
56
+ })
57
+
58
+ test('an unpersisted fold (no persistKey) does not touch localStorage', () => {
59
+ renderWith(
60
+ <Section title="Usage" collapsible>
61
+ <div>body</div>
62
+ </Section>,
63
+ )
64
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse section' }))
65
+ expect(window.localStorage.length).toBe(0)
66
+ })
67
+
68
+ test('persistKey persists the fold state under basalt:section:<key>', () => {
69
+ renderWith(
70
+ <Section title="Usage" collapsible persistKey="usage">
71
+ <div>body</div>
72
+ </Section>,
73
+ )
74
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse section' }))
75
+
76
+ const raw = window.localStorage.getItem('basalt:section:usage')
77
+ expect(raw).not.toBeNull()
78
+ expect(JSON.parse(raw ?? '{}')).toMatchObject({ value: false })
79
+ })
80
+ })
81
+
82
+ describe('id — the scroll-anchor contract', () => {
83
+ test('sets the id and a scrollMarginTop clearing both sticky bars', () => {
84
+ const { container } = renderWith(
85
+ <Section id="usage" title="Usage">
86
+ <div>body</div>
87
+ </Section>,
88
+ )
89
+ const root = container.querySelector('#usage')
90
+ expect(root).not.toBeNull()
91
+ const style = root?.getAttribute('style') ?? ''
92
+ expect(style).toContain('scroll-margin-top')
93
+ expect(style).toContain('--app-shell-header-height')
94
+ expect(style).toContain('--basalt-page-bar-h')
95
+ })
96
+
97
+ test('omitting id sets neither an id nor the anchor style', () => {
98
+ const { container } = renderWith(
99
+ <Section title="Usage">
100
+ <div>body</div>
101
+ </Section>,
102
+ )
103
+ const root = container.firstElementChild
104
+ expect(root?.id).toBe('')
105
+ expect(root?.getAttribute('style')).toBeNull()
106
+ })
107
+ })
108
+
109
+ // Relies on the default test-runner NODE_ENV being non-production (`isDev()` true), matching
110
+ // `data/data-table.test.tsx`'s manualPagination dev-throw tests — no explicit env override needed.
111
+ describe('≤3 actions — the dev-only budget warning (C6)', () => {
112
+ // `Children.count` (like every `Children.*` helper) does not recurse into a Fragment passed as
113
+ // a single node — an ARRAY of actions is what the count actually sees, same shape a consumer
114
+ // building the list programmatically would pass.
115
+ test('4 actions warns', () => {
116
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
117
+ renderWith(
118
+ <Section
119
+ title="Usage"
120
+ actions={[1, 2, 3, 4].map((n) => (
121
+ <button key={n} type="button">
122
+ {n}
123
+ </button>
124
+ ))}
125
+ >
126
+ <div>body</div>
127
+ </Section>,
128
+ )
129
+ expect(warn).toHaveBeenCalledTimes(1)
130
+ expect(String(warn.mock.calls[0]?.[0])).toContain('≤3 budget')
131
+ warn.mockRestore()
132
+ })
133
+
134
+ test('3 actions does not warn', () => {
135
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
136
+ renderWith(
137
+ <Section
138
+ title="Usage"
139
+ actions={[1, 2, 3].map((n) => (
140
+ <button key={n} type="button">
141
+ {n}
142
+ </button>
143
+ ))}
144
+ >
145
+ <div>body</div>
146
+ </Section>,
147
+ )
148
+ expect(warn).not.toHaveBeenCalled()
149
+ warn.mockRestore()
150
+ })
151
+ })
152
+
153
+ describe('defaultOpen', () => {
154
+ test('defaultOpen={false} opens collapsed, and the chevron still expands it', () => {
155
+ renderWith(
156
+ <Section title="Usage" collapsible defaultOpen={false}>
157
+ <div>body content</div>
158
+ </Section>,
159
+ )
160
+ expect(screen.queryByText('body content')).toBeNull()
161
+
162
+ fireEvent.click(screen.getByRole('button', { name: 'Expand section' }))
163
+ expect(screen.getByText('body content')).toBeDefined()
164
+ })
165
+
166
+ test('a persisted value outranks it — a section the reader opened stays open', () => {
167
+ window.localStorage.setItem('basalt:section:usage', JSON.stringify({ v: 1, value: true }))
168
+ renderWith(
169
+ <Section title="Usage" collapsible persistKey="usage" defaultOpen={false}>
170
+ <div>body content</div>
171
+ </Section>,
172
+ )
173
+ expect(screen.getByText('body content')).toBeDefined()
174
+ })
175
+ })
176
+
177
+ describe('summary — visible collapsed or not', () => {
178
+ test('a collapsed section still states its headline figures', () => {
179
+ renderWith(
180
+ <Section title="Usage" collapsible summary={<span>48,204 requests</span>}>
181
+ <div>body content</div>
182
+ </Section>,
183
+ )
184
+ expect(screen.getByText('48,204 requests')).toBeDefined()
185
+
186
+ fireEvent.click(screen.getByRole('button', { name: 'Collapse section' }))
187
+
188
+ expect(screen.queryByText('body content')).toBeNull()
189
+ expect(screen.getByText('48,204 requests')).toBeDefined()
190
+ })
191
+
192
+ test('it renders under the header, above where the body starts', () => {
193
+ renderWith(
194
+ <Section title="Usage" summary={<span>48,204 requests</span>}>
195
+ <div>body content</div>
196
+ </Section>,
197
+ )
198
+ const summary = screen.getByText('48,204 requests').parentElement
199
+ expect(
200
+ summary?.previousElementSibling?.contains(screen.getByRole('heading', { level: 2 })),
201
+ ).toBe(true)
202
+ expect(summary?.nextElementSibling?.contains(screen.getByText('body content'))).toBe(true)
203
+ })
204
+ })