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
@@ -7,22 +7,37 @@
7
7
  import { MantineProvider } from '@mantine/core'
8
8
  import { fireEvent, render, screen, waitFor } from '@testing-library/react'
9
9
  import { describe, expect, test } from 'bun:test'
10
- import { BasaltShell } from './index'
11
- import type { BasaltAccountProps, SidebarSection } from './index'
10
+ import { ActionGroup } from '../controls/actions'
11
+ import { BasaltShell, PageBar } from './index'
12
+ import type { BasaltAccountProps, SidebarBlock, SidebarSection } from './index'
12
13
 
13
14
  const BRAND = { name: 'Argo' }
14
15
  const ONE_SECTION: SidebarSection[] = [
15
16
  { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
16
17
  ]
17
18
 
18
- describe('BasaltShell sidebarNavExtra', () => {
19
- test("reaches AppSidebar's nav scroll region through the BasaltShell -> AppSidebar hand-off", () => {
19
+ const AWAITING: SidebarBlock = {
20
+ kind: 'list',
21
+ key: 'awaiting',
22
+ label: 'Awaiting action',
23
+ count: 3,
24
+ items: [
25
+ { key: 'a', label: 'Review PR' },
26
+ { key: 'b', label: 'Sign contract' },
27
+ { key: 'c', label: 'Reply to Jo' },
28
+ ],
29
+ }
30
+
31
+ describe('BasaltShell sidebarBlocks', () => {
32
+ test("a 'custom' block reaches the sidebar's nav scroll region through the hand-off", () => {
20
33
  const { container } = render(
21
34
  <MantineProvider>
22
35
  <BasaltShell
23
36
  brand={BRAND}
24
37
  sections={ONE_SECTION}
25
- sidebarNavExtra={<div data-testid="nav-extra">Extra</div>}
38
+ sidebarBlocks={[
39
+ { kind: 'custom', key: 'tree', node: <div data-testid="nav-extra">Extra</div> },
40
+ ]}
26
41
  />
27
42
  </MantineProvider>,
28
43
  )
@@ -31,6 +46,77 @@ describe('BasaltShell sidebarNavExtra', () => {
31
46
  expect(stack).not.toBeNull()
32
47
  expect(stack?.contains(extra)).toBe(true)
33
48
  })
49
+
50
+ /**
51
+ * Both halves of the SAME prop, which is the point of C13: one declaration renders the desktop
52
+ * block and produces the mobile row. `sidebarNavExtra` + `mobileNav.moreExtra` needed two.
53
+ */
54
+ test('the same block projects to ONE More row that opens a nested sheet of its items', async () => {
55
+ render(
56
+ <MantineProvider>
57
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} sidebarBlocks={[AWAITING]} />
58
+ </MantineProvider>,
59
+ )
60
+
61
+ fireEvent.click(screen.getByLabelText('More'))
62
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
63
+ // One row, stating its own count — not three rows, one per item. `Home` took the bar slot, so
64
+ // the block row is the whole More surface here.
65
+ const row = screen.getByText('Awaiting action · 3')
66
+ expect(document.querySelectorAll('.mantine-Menu-item')).toHaveLength(1)
67
+
68
+ fireEvent.click(row)
69
+ await waitFor(() => expect(document.querySelector('.mantine-Drawer-content')).not.toBeNull())
70
+ // Scoped to the sheet: the desktop block renders the same three labels, and a bare `getByText`
71
+ // would match both copies rather than proving the sheet holds them.
72
+ const body = document.querySelector('.mantine-Drawer-body')?.textContent ?? ''
73
+ expect(body).toContain('Review PR')
74
+ expect(body).toContain('Reply to Jo')
75
+ })
76
+
77
+ test("mobile:'hidden' keeps the block off the More surface entirely", () => {
78
+ render(
79
+ <MantineProvider>
80
+ <BasaltShell
81
+ brand={BRAND}
82
+ sections={[
83
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null, mobile: 'tab' }] },
84
+ ]}
85
+ sidebarBlocks={[{ ...AWAITING, mobile: 'hidden' }]}
86
+ />
87
+ </MantineProvider>,
88
+ )
89
+ // Nothing else feeds More, so a hidden block must not conjure the slot at all.
90
+ expect(screen.queryByLabelText('More')).toBeNull()
91
+ })
92
+ })
93
+
94
+ /**
95
+ * Law C13's enforcement column is "tsc — `sidebarNavExtra` / `mobileNav.moreExtra` removed". These
96
+ * two `@ts-expect-error`s ARE that gate: they fail the build the day either prop comes back, which
97
+ * is the only way a type-level removal can be asserted from a test file.
98
+ */
99
+ describe('BasaltShell — the removed ReactNode slots', () => {
100
+ test('sidebarNavExtra and mobileNav.moreExtra no longer type-check', () => {
101
+ const removedNavExtra = (
102
+ <BasaltShell
103
+ brand={BRAND}
104
+ sections={ONE_SECTION}
105
+ // @ts-expect-error sidebarNavExtra was replaced by `sidebarBlocks` kind 'custom' (C13)
106
+ sidebarNavExtra={<div />}
107
+ />
108
+ )
109
+ const removedMoreExtra = (
110
+ <BasaltShell
111
+ brand={BRAND}
112
+ sections={ONE_SECTION}
113
+ // @ts-expect-error mobileNav.moreExtra was replaced by `sidebarBlocks` kind 'list' (C13)
114
+ mobileNav={{ moreExtra: <div /> }}
115
+ />
116
+ )
117
+ expect(removedNavExtra).toBeTruthy()
118
+ expect(removedMoreExtra).toBeTruthy()
119
+ })
34
120
  })
35
121
 
36
122
  /**
@@ -140,3 +226,203 @@ describe('BasaltShell collapse persistence', () => {
140
226
  expect(localStorage.getItem(`basalt:${key}`)).toBe(JSON.stringify({ v: 1, value: false }))
141
227
  })
142
228
  })
229
+
230
+ /**
231
+ * Law C14 — an empty home renders nothing, so no route pays for a reserved row. This is the
232
+ * assertion the spec names as the law's gate: through 1.25.0 the mobile header was a 97px SUM whose
233
+ * second row (`appHeaderMobileActionsHeight`, 52px) was reserved on every route whether or not the
234
+ * page portalled anything into it. Both tokens are deleted; the header is `appShellHeaderHeight` at
235
+ * every width, and an empty `PageBar` adds no node in either place.
236
+ */
237
+ describe('BasaltShell header height (law C14)', () => {
238
+ test('the AppShell header is ONE row — a single unconditional height declaration, no media override', () => {
239
+ render(
240
+ <MantineProvider>
241
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} />
242
+ </MantineProvider>,
243
+ )
244
+ // Mantine emits the AppShell dimensions as a `<style>` block: one `:root` rule plus one
245
+ // `@media` rule per RESPONSIVE prop. A `{ base, sm }` header height therefore shows up twice;
246
+ // a single number shows up once, which is the invariant.
247
+ const css = [...document.querySelectorAll('style')]
248
+ .map((tag) => tag.textContent ?? '')
249
+ .find((text) => text.includes('--app-shell-header-height'))
250
+ expect(css).toBeDefined()
251
+ const heights = [...(css ?? '').matchAll(/--app-shell-header-height:\s*([^;]+)/g)].map(
252
+ (m) => m[1],
253
+ )
254
+ expect(heights).toHaveLength(1)
255
+ // 48px, expressed the way Mantine's own `rem()` does.
256
+ expect(heights[0]).toContain('3rem')
257
+ // The navbar and footer ARE responsive, so their overrides prove the query blocks still exist —
258
+ // the header simply is not among them any more.
259
+ expect(css).toContain('--app-shell-footer-height:0rem')
260
+ })
261
+
262
+ test('an empty PageBar contributes no node — not in the header, not in the page flow', () => {
263
+ render(
264
+ <MantineProvider>
265
+ <BasaltShell brand={BRAND} sections={ONE_SECTION}>
266
+ <PageBar />
267
+ </BasaltShell>
268
+ </MantineProvider>,
269
+ )
270
+ expect(document.querySelector('[data-basalt-page-bar]')).toBeNull()
271
+ })
272
+ })
273
+
274
+ /**
275
+ * `globalActions` is DECLARED DATA since 1.26.0 (`GlobalAction[]`, was `ReactNode`), which is what
276
+ * lets basalt project it onto mobile at all: the first two ride the bar, the rest fold into the
277
+ * header's ONE kebab, and a `'hidden'` one is dropped below `sm`. A `ReactNode` slot could express
278
+ * none of that, which is why every consumer hand-rolled a responsive twin instead.
279
+ */
280
+ describe('BasaltShell globalActions mobile policy', () => {
281
+ const ACTIONS = [
282
+ { key: 'timer', node: <span data-testid="g-timer" /> },
283
+ { key: 'bell', node: <span data-testid="g-bell" /> },
284
+ { key: 'theme', node: <span data-testid="g-theme" /> },
285
+ { key: 'devtools', node: <span data-testid="g-devtools" />, mobile: 'hidden' as const },
286
+ ]
287
+
288
+ const renderWithGlobals = () =>
289
+ render(
290
+ <MantineProvider>
291
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS} />
292
+ </MantineProvider>,
293
+ )
294
+
295
+ test('the first two are mounted ONCE, unwrapped — visible at every width', () => {
296
+ renderWithGlobals()
297
+ for (const id of ['g-timer', 'g-bell']) {
298
+ const node = screen.getByTestId(id)
299
+ expect(node.closest('.mantine-visible-from-sm')).toBeNull()
300
+ expect(node.closest('.mantine-hidden-from-sm')).toBeNull()
301
+ }
302
+ })
303
+
304
+ test("the third defaults to 'more': desktop-only inline, plus the header's one kebab", () => {
305
+ renderWithGlobals()
306
+ expect(screen.getByTestId('g-theme').closest('.mantine-visible-from-sm')).not.toBeNull()
307
+ const kebabs = document.querySelectorAll('[aria-label="More actions"]')
308
+ expect(kebabs).toHaveLength(1)
309
+ expect(kebabs[0]?.closest('.mantine-hidden-from-sm')).not.toBeNull()
310
+ })
311
+
312
+ test("'hidden' drops it below sm and never reaches the kebab", () => {
313
+ renderWithGlobals()
314
+ expect(screen.getByTestId('g-devtools').closest('.mantine-visible-from-sm')).not.toBeNull()
315
+ })
316
+
317
+ test('a page ActionGroup takes over the kebab, so the header never shows two', () => {
318
+ render(
319
+ <MantineProvider>
320
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
321
+ <PageBar actions={{ secondary: [{ key: 'export', label: 'Export' }] }} />
322
+ </BasaltShell>
323
+ </MantineProvider>,
324
+ )
325
+ expect(document.querySelectorAll('[aria-label="More actions"]')).toHaveLength(1)
326
+ })
327
+
328
+ test('filtersEnd folds into the SAME kebab — never a second one in row 2', () => {
329
+ // The bug this pins: every `ActionGroup` used to read the shell's `mobile: 'more'` globals and
330
+ // claim a kebab, so `PageBar.filtersEnd` (projected through its own group in row 2) grew a
331
+ // SECOND kebab that re-mounted the global node. Now only row 1 is `host: 'page'`.
332
+ render(
333
+ <MantineProvider>
334
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
335
+ <PageBar
336
+ actions={{ secondary: [{ key: 'export', label: 'Export CSV' }] }}
337
+ filtersEnd={[{ key: 'metrics', label: 'Manage metrics' }]}
338
+ />
339
+ </BasaltShell>
340
+ </MantineProvider>,
341
+ )
342
+
343
+ const kebabs = document.querySelectorAll('[aria-label="More actions"]')
344
+ expect(kebabs).toHaveLength(1)
345
+ // And it is the HEADER's, not row 2's.
346
+ expect(kebabs[0]?.closest('[data-basalt-page-bar="shell"]')).toBeNull()
347
+ expect(document.querySelector('.mantine-AppShell-header')?.contains(kebabs[0] as Node)).toBe(
348
+ true,
349
+ )
350
+ })
351
+
352
+ test('the global node reaches ONE dropdown, not two', async () => {
353
+ render(
354
+ <MantineProvider>
355
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
356
+ <PageBar
357
+ actions={{ secondary: [{ key: 'export', label: 'Export CSV' }] }}
358
+ filtersEnd={[{ key: 'metrics', label: 'Manage metrics' }]}
359
+ />
360
+ </BasaltShell>
361
+ </MantineProvider>,
362
+ )
363
+
364
+ // One inline copy (the desktop-only `visibleFrom` box) before any dropdown opens.
365
+ expect(screen.getAllByTestId('g-theme')).toHaveLength(1)
366
+ fireEvent.click(screen.getByLabelText('More actions'))
367
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
368
+ // Exactly one more copy — the open dropdown's — so the node is never live in two menus at once.
369
+ expect(screen.getAllByTestId('g-theme')).toHaveLength(2)
370
+ expect(document.querySelectorAll('[role="menu"]')).toHaveLength(1)
371
+ })
372
+
373
+ test("filtersEnd alone still yields exactly one kebab, and it is the header's", () => {
374
+ render(
375
+ <MantineProvider>
376
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
377
+ <PageBar filtersEnd={[{ key: 'metrics', label: 'Manage metrics' }]} />
378
+ </BasaltShell>
379
+ </MantineProvider>,
380
+ )
381
+ const kebabs = document.querySelectorAll('[aria-label="More actions"]')
382
+ expect(kebabs).toHaveLength(1)
383
+ expect(document.querySelector('.mantine-AppShell-header')?.contains(kebabs[0] as Node)).toBe(
384
+ true,
385
+ )
386
+ })
387
+
388
+ test('a PageBar with no row-1 actions and no filtersEnd hands the kebab BACK to the shell', () => {
389
+ render(
390
+ <MantineProvider>
391
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
392
+ <PageBar tabs={<span data-testid="tabs" />} />
393
+ </BasaltShell>
394
+ </MantineProvider>,
395
+ )
396
+ const kebabs = document.querySelectorAll('[aria-label="More actions"]')
397
+ expect(kebabs).toHaveLength(1)
398
+ expect(kebabs[0]?.closest('.mantine-hidden-from-sm')).not.toBeNull()
399
+ })
400
+
401
+ test('an ActionGroup mounted in some OTHER home inherits no global rows and no claim', () => {
402
+ // A consumer's tier-2 kebab (a `Section`/`ChartCard` actions slot) must not swallow the shell's
403
+ // globals — nor steal the claim, which would leave the shell's own kebab unrendered.
404
+ render(
405
+ <MantineProvider>
406
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} globalActions={ACTIONS}>
407
+ <ActionGroup secondary={[{ key: 'a', label: 'Section action' }]} />
408
+ </BasaltShell>
409
+ </MantineProvider>,
410
+ )
411
+ // Two kebabs is CORRECT here: the shell's (globals) and the section's (its own rows). What must
412
+ // not happen is the global node appearing in the section's.
413
+ const shellKebab = document
414
+ .querySelector('.mantine-AppShell-header')
415
+ ?.querySelector('[aria-label="More actions"]')
416
+ expect(shellKebab).not.toBeNull()
417
+ expect(screen.getAllByTestId('g-theme')).toHaveLength(1)
418
+ })
419
+
420
+ test('no globalActions at all renders no kebab', () => {
421
+ render(
422
+ <MantineProvider>
423
+ <BasaltShell brand={BRAND} sections={ONE_SECTION} />
424
+ </MantineProvider>,
425
+ )
426
+ expect(document.querySelector('[aria-label="More actions"]')).toBeNull()
427
+ })
428
+ })
@@ -17,25 +17,37 @@
17
17
  * active item across `sections`, not from a router hook. Collapse is persisted via basalt's own
18
18
  * `createPersistedState` (`../state`) keyed by `storageKey` — see `collapseStore`.
19
19
  */
20
- import { AppShell } from '@mantine/core'
21
- import { useMemo } from 'react'
20
+ import { AppShell, Box } from '@mantine/core'
21
+ import { Fragment, useMemo } from 'react'
22
22
  import type { MouseEvent, ReactNode } from 'react'
23
23
  import { AppSidebar } from './app-sidebar'
24
24
  import { MobileNav, accountRowCount } from './app-mobile-nav'
25
- import { projectMobileNav } from './mobile-nav-model'
25
+ import { blockRowCount, projectMobileNav } from './mobile-nav-model'
26
26
  import { AppBreadcrumbs } from './app-breadcrumbs'
27
- import { PageActionsOutlet, PageHeaderProvider } from './page-header'
28
- import type { BasaltAccountProps } from './account-types'
29
- import type { SidebarSearchConfig } from './sidebar-search'
30
- import type { MobileNavConfig, NavAnchor, SidebarItem, SidebarSection } from '../nav/types'
31
- import { useBasaltSpacing } from '../theme'
27
+ import { PageBarOutlet, PageBarProvider, usePageKebabClaimed } from './page-bar'
28
+ import { OverflowMenu, globalActionAsBarAction, globalActionMobile } from '../controls/actions'
29
+ import type { GlobalAction } from '../controls/actions'
30
+ import type { AccountMenuItem, BasaltAccountProps } from './account-types'
31
+ import type { SidebarSearchActions, SidebarSearchConfig } from './sidebar-search'
32
+ import type {
33
+ MobileNavConfig,
34
+ NavAnchor,
35
+ SidebarBlock,
36
+ SidebarItem,
37
+ SidebarSection,
38
+ } from '../nav/types'
39
+ import { CtlSlot, useBasaltSpacing } from '../theme'
32
40
  import { createPersistedState } from '../state'
33
41
  import headerClasses from './app-header.module.css'
34
42
  import mobileNavClasses from './app-mobile-nav.module.css'
35
43
 
36
44
  export { AppSidebar, type AppSidebarProps } from './app-sidebar'
37
45
  export { NavCountBadge } from './nav-count-badge'
38
- export { SidebarSearch, type SidebarSearchConfig } from './sidebar-search'
46
+ export {
47
+ SidebarSearch,
48
+ type SidebarSearchConfig,
49
+ type SidebarSearchActions,
50
+ } from './sidebar-search'
39
51
  export { SidebarAccount } from './app-sidebar-account'
40
52
  export type {
41
53
  AccountBadgeTone,
@@ -56,7 +68,7 @@ export {
56
68
  MOBILE_MORE_KEY,
57
69
  } from './mobile-nav-model'
58
70
  export { AppBreadcrumbs } from './app-breadcrumbs'
59
- export { PageHeaderProvider, PageActions, PageActionsOutlet } from './page-header'
71
+ export { PageBar, type PageBarProps } from './page-bar'
60
72
 
61
73
  /**
62
74
  * The shared nav vocabulary lives in `src/nav/types.ts` so the headless router bridge can `import
@@ -70,6 +82,12 @@ export type {
70
82
  NavSectionMobile,
71
83
  SidebarItem,
72
84
  SidebarSection,
85
+ SidebarBlock,
86
+ SidebarBlockItem,
87
+ SidebarBlockTone,
88
+ SidebarListBlock,
89
+ SidebarProgressBlock,
90
+ SidebarCustomBlock,
73
91
  MobileNavConfig,
74
92
  MobileNavGroup,
75
93
  MobileNavModel,
@@ -98,8 +116,11 @@ export type SettingsMenuItem = {
98
116
  }
99
117
 
100
118
  export type BasaltShellProps = {
101
- /** Brand identity for the sidebar header. */
102
- brand: BrandConfig
119
+ /**
120
+ * Brand identity for the sidebar header. Supplying `menu` turns the brand row into a `Name ▾`
121
+ * workspace switcher — the rows are `AccountMenuItem`s, the shape the account menu already uses.
122
+ */
123
+ brand: BrandConfig & { menu?: AccountMenuItem[] }
103
124
  /** Grouped nav sections — the ONE definition, rendered as the sidebar and projected to the bar. */
104
125
  sections: SidebarSection[]
105
126
  /**
@@ -107,28 +128,54 @@ export type BasaltShellProps = {
107
128
  * configured still produces a working bar (see `projectMobileNav`'s zero-config fallback).
108
129
  */
109
130
  mobileNav?: MobileNavConfig
110
- /** Persistent, shell-owned top-bar slot (timer, refresh, notifications). */
111
- globalActions?: ReactNode
112
131
  /**
113
- * DESKTOP ONLY. Arbitrary content appended after `sections` inside the sidebar's nav scroll
114
- * region (a tree, a filter panel, a project list, …), for anything a set of `SidebarItem`s can't
115
- * express. Hidden on the collapsed desktop rail; the sidebar itself no longer exists below the
116
- * `sm` breakpoint, so this slot has no mobile representation — put mobile-reachable extras in
117
- * `mobileNav.moreExtra` instead.
132
+ * Persistent, shell-owned header actions (timer, notifications, a global `SyncButton`) — DECLARED
133
+ * DATA, not a `ReactNode` slot, so basalt owns the mobile projection: the first two ride the bar,
134
+ * the rest fold into the header's single kebab (`mobile: 'bar' | 'more' | 'hidden'`, see
135
+ * `GlobalAction`). That kebab is the SAME one `PageBar`'s ROW 1 opens — a route whose bar has
136
+ * row-1 actions (or `filtersEnd`) lends it, any other route gets it from the shell. An
137
+ * `ActionGroup` a consumer mounts in some other home never inherits these rows.
138
+ */
139
+ globalActions?: GlobalAction[]
140
+ /**
141
+ * Sidebar blocks — DECLARED DATA (law C13, `docs/CONTROLS-SPEC.md` §2.3), which replaced both
142
+ * `sidebarNavExtra` and `mobileNav.moreExtra`. `placement: 'nav'` (the default for `list` and
143
+ * `custom`) appends after the nav sections inside the scroll region; `'bottom'` pins above the
144
+ * settings footer.
145
+ *
146
+ * Because they are data, basalt owns the projections the two `ReactNode` slots could not express:
147
+ * a `list` with a `count` becomes a dot on its icon in the collapsed rail, a `progress` block
148
+ * becomes a ring on the settings row, and a block with `mobile: 'more'` becomes one More-sheet
149
+ * row opening a nested sheet of its items. A `kind: 'custom'` block is desktop-only, exactly as
150
+ * `sidebarNavExtra` was.
151
+ */
152
+ sidebarBlocks?: SidebarBlock[]
153
+ /**
154
+ * Entries appended to the sidebar settings footer — flat link rows at three or fewer, one gear
155
+ * menu at four or more (`AppSidebarProps.settingsMenuItems` documents the threshold).
118
156
  */
119
- sidebarNavExtra?: ReactNode
120
- /** Entries appended to the sidebar settings menu. */
121
157
  settingsMenuItems?: SettingsMenuItem[]
122
158
  /**
123
- * Optional account row rendered above the settings menu in the sidebar footer (see
159
+ * Forces the footer form instead of taking the count rule above: `'flat'` always renders link
160
+ * rows, `'menu'` always renders the gear dropdown. `'auto'` (the default) is today's ≤3 → flat
161
+ * behaviour. Reach for it when the rows are CONTROLS rather than destinations — three of those
162
+ * (a theme radio group, a devtools switch) read as a widget pile in the footer even though the
163
+ * count says flat. Full rationale on `AppSidebarProps.settingsMenu`.
164
+ *
165
+ * @default 'auto'
166
+ */
167
+ settingsMenu?: 'auto' | 'flat' | 'menu'
168
+ /**
169
+ * Optional account row rendered below the settings menu in the sidebar footer (see
124
170
  * `SidebarAccount` / `BasaltAccountProps`). Omitting it reproduces today's footer unchanged.
125
171
  */
126
172
  account?: BasaltAccountProps
127
173
  /**
128
174
  * Optional search field below the brand in the sidebar (fixed, above the nav scroll). Supply
129
- * `onOpen`, e.g. `() => openSpotlight()` from basalt-ui/commands.
175
+ * `onOpen`, e.g. `() => openSpotlight()` from basalt-ui/commands. `actions` adds one or two
176
+ * icon-only buttons to the right of that row (a tuple by type — see `SidebarSearchActions`).
130
177
  */
131
- search?: SidebarSearchConfig
178
+ search?: SidebarSearchConfig & { actions?: SidebarSearchActions }
132
179
  /**
133
180
  * localStorage key for the persisted sidebar-collapsed flag. Ignored when `collapsed` is set.
134
181
  *
@@ -254,8 +301,9 @@ export function BasaltShell({
254
301
  sections,
255
302
  mobileNav,
256
303
  globalActions,
257
- sidebarNavExtra,
304
+ sidebarBlocks,
258
305
  settingsMenuItems,
306
+ settingsMenu,
259
307
  storageKey = 'basalt-sidebar-collapsed',
260
308
  collapsed: collapsedProp,
261
309
  onCollapsedChange,
@@ -280,26 +328,29 @@ export function BasaltShell({
280
328
  }
281
329
 
282
330
  const activeCrumb = findActiveCrumb(sections)
283
- // The account row and every settings entry render as flat rows in the More surface (there is no
284
- // mobile sidebar to reach them through any more), so they count toward BOTH `needsMore` and the
285
- // menu-vs-sheet threshold. `accountRowCount` is the SAME function that renders those rows, not a
286
- // second estimate of them: an account is worth 0 rows while `loading` and up to seven once
287
- // authenticated, and §2.2's whole guarantee (`menuMax` rows fit the headroom above the bar, and
288
- // the menu runs `flip: false` so it cannot escape upward) is arithmetic over this number.
331
+ // The account row, every settings entry and every mobile-reachable sidebar block render as flat
332
+ // rows in the More surface (there is no mobile sidebar to reach them through any more), so they
333
+ // count toward BOTH `needsMore` and the menu-vs-sheet threshold. `accountRowCount` and
334
+ // `blockRowCount` are the SAME functions the renderer agrees with, not second estimates of them:
335
+ // an account is worth 0 rows while `loading` and up to seven once authenticated, a block is worth
336
+ // exactly one row however many items it holds, and §2.2's whole guarantee (`menuMax` rows fit the
337
+ // headroom above the bar, and the menu runs `flip: false` so it cannot escape upward) is
338
+ // arithmetic over this number.
289
339
  const extraMoreRows =
290
- accountRowCount(account) + (settingsMenuItems?.length ?? 0) + (mobileNav?.moreExtra ? 1 : 0)
340
+ accountRowCount(account) + (settingsMenuItems?.length ?? 0) + blockRowCount(sidebarBlocks)
291
341
  const model = useMemo(
292
342
  () => projectMobileNav(sections, { config: mobileNav, extraMoreRows }),
293
343
  [sections, mobileNav, extraMoreRows],
294
344
  )
295
345
 
296
346
  return (
297
- <PageHeaderProvider>
347
+ <PageBarProvider globalActions={globalActions ?? []}>
298
348
  <AppShell
299
349
  h="100dvh"
300
350
  layout="alt"
301
351
  header={{
302
- height: { base: step.appShellHeaderMobileHeight, sm: step.appShellHeaderHeight },
352
+ // ONE height at every width (law C14): nothing reserves a second mobile row any more.
353
+ height: step.appShellHeaderHeight,
303
354
  }}
304
355
  navbar={{
305
356
  width: {
@@ -320,8 +371,8 @@ export function BasaltShell({
320
371
  <div className={headerClasses.lead}>
321
372
  <AppBreadcrumbs {...activeCrumb} />
322
373
  </div>
323
- <PageActionsOutlet className={headerClasses.pageActions} />
324
- {globalActions && <div className={headerClasses.global}>{globalActions}</div>}
374
+ <PageBarOutlet className={headerClasses.pageBar} />
375
+ <HeaderGlobalActions actions={globalActions ?? []} />
325
376
  </div>
326
377
  </AppShell.Header>
327
378
 
@@ -331,8 +382,9 @@ export function BasaltShell({
331
382
  sections={sections}
332
383
  collapsed={collapsed}
333
384
  onToggleCollapse={toggleCollapse}
334
- navExtra={sidebarNavExtra}
385
+ {...(sidebarBlocks !== undefined && { blocks: sidebarBlocks })}
335
386
  {...(settingsMenuItems !== undefined && { settingsMenuItems })}
387
+ {...(settingsMenu !== undefined && { settingsMenu })}
336
388
  {...(account !== undefined && { account })}
337
389
  {...(search !== undefined && { search })}
338
390
  />
@@ -349,9 +401,56 @@ export function BasaltShell({
349
401
  config={mobileNav}
350
402
  {...(account !== undefined && { account })}
351
403
  {...(settingsMenuItems !== undefined && { settingsMenuItems })}
404
+ {...(sidebarBlocks !== undefined && { blocks: sidebarBlocks })}
352
405
  />
353
406
  </AppShell.Footer>
354
407
  </AppShell>
355
- </PageHeaderProvider>
408
+ </PageBarProvider>
409
+ )
410
+ }
411
+
412
+ /**
413
+ * The shell's own `globalActions`, projected for both viewports.
414
+ *
415
+ * Each action is mounted EXACTLY ONCE inline, in declaration order — a `mobile: 'bar'` action with
416
+ * no wrapper (visible at every width), a `'more'`/`'hidden'` one inside a `visibleFrom="sm"` box.
417
+ * The `'more'` nodes appear a second time only inside the kebab dropdown, which Mantine mounts
418
+ * lazily on open. That asymmetry is why `GlobalAction`'s JSDoc points anything stateful at
419
+ * `mobile: 'bar'`.
420
+ *
421
+ * The kebab renders here only while NO page bar owns one (`usePageKebabClaimed`), so a header never
422
+ * shows two — the claim is what makes "one kebab per header" a fact rather than a convention.
423
+ */
424
+ function HeaderGlobalActions({ actions }: { actions: readonly GlobalAction[] }): ReactNode {
425
+ const kebabClaimed = usePageKebabClaimed()
426
+ if (actions.length === 0) return null
427
+
428
+ const more = actions
429
+ .filter((action, index) => globalActionMobile(action, index) === 'more')
430
+ .map(globalActionAsBarAction)
431
+
432
+ return (
433
+ // `CtlSlot` here for the same reason a home wraps its own slot (law C5): `globalActions` are
434
+ // consumer NODES, so basalt cannot pass them a size — it can only provide the tier they resolve
435
+ // against. Without it a `<NotificationBell/>`'s internal `ActionIcon` fell back to Mantine's
436
+ // `md` (28px) beside a 30px page group.
437
+ <CtlSlot>
438
+ <div className={headerClasses.global}>
439
+ {actions.map((action, index) =>
440
+ globalActionMobile(action, index) === 'bar' ? (
441
+ <Fragment key={action.key}>{action.node}</Fragment>
442
+ ) : (
443
+ <Box key={action.key} visibleFrom="sm">
444
+ {action.node}
445
+ </Box>
446
+ ),
447
+ )}
448
+ {!kebabClaimed && more.length > 0 && (
449
+ <Box hiddenFrom="sm">
450
+ <OverflowMenu actions={more} trigger="kebab" label="More actions" />
451
+ </Box>
452
+ )}
453
+ </div>
454
+ </CtlSlot>
356
455
  )
357
456
  }