basalt-ui 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Presentational app sidebar — a collapsible icon-rail with grouped nav sections, a brand header
3
- * (logo + collapse/close toggle), and a footer settings menu (consumer-supplied entries + an
4
- * optional version label).
3
+ * (logo + collapse toggle), and a footer settings menu (consumer-supplied entries + an
4
+ * optional version label). Desktop only — below `sm` the sidebar doesn't render at all; mobile
5
+ * navigation is `MobileNav`'s tab bar / More menu, not this component.
5
6
  *
6
7
  * Router-agnostic by design: active detection, typed navigation and the collapse store stay in the
7
8
  * consumer (or in `BasaltShell`), which feeds resolved `sections` + `collapsed`/`onToggleCollapse`
@@ -12,7 +13,9 @@
12
13
  * DESKTOP ONLY. There is no mobile sidebar drawer any more: below the `sm` breakpoint the navbar
13
14
  * is permanently collapsed and the bottom bar (`app-mobile-nav.tsx`) is the whole nav. The rail
14
15
  * styling is still gated behind a `min-width: sm` media query, and the collapse chevron is
15
- * `visibleFrom="sm"`. Grounded in argo `apps/dashboard/src/components/app-shell/app-sidebar.tsx`.
16
+ * `visibleFrom="sm"`. Originally extracted from argo's
17
+ * `apps/dashboard/src/components/app-shell/app-sidebar.tsx`; argo has since deleted that file in
18
+ * favor of this package.
16
19
  */
17
20
  import {
18
21
  ActionIcon,
@@ -29,60 +32,95 @@ import {
29
32
  UnstyledButton,
30
33
  } from '@mantine/core'
31
34
  import { useDisclosure } from '@mantine/hooks'
32
- import { useEffect, useRef, useState } from 'react'
35
+ import { useEffect, useRef } from 'react'
33
36
  import type { ReactNode } from 'react'
34
37
  import type { BrandConfig, SettingsMenuItem } from './index'
35
- import type { SidebarItem, SidebarSection } from '../nav/types'
38
+ import type { SidebarBlock, SidebarItem, SidebarSection } from '../nav/types'
36
39
  import { NavCountBadge } from './nav-count-badge'
37
40
  import { SidebarAccount } from './app-sidebar-account'
38
- import type { BasaltAccountProps } from './account-types'
41
+ import type { AccountMenuItem, BasaltAccountProps } from './account-types'
39
42
  import { SidebarSearch } from './sidebar-search'
40
- import type { SidebarSearchConfig } from './sidebar-search'
43
+ import type { SidebarSearchActions, SidebarSearchConfig } from './sidebar-search'
44
+ import {
45
+ IconChevron,
46
+ SidebarBlockView,
47
+ SidebarProgressRing,
48
+ usePersistedFold,
49
+ } from './sidebar-blocks'
50
+ import {
51
+ sidebarBlockPlacement,
52
+ sidebarBlockRail,
53
+ sidebarSectionFoldKey,
54
+ } from './sidebar-block-model'
41
55
  import { VX } from '../tokens'
42
56
  import { useBasaltSpacing } from '../theme'
43
57
  import classes from './app-sidebar.module.css'
44
58
 
45
59
  export type AppSidebarProps = {
46
- brand: BrandConfig
60
+ /**
61
+ * Brand identity. Supplying `menu` turns the brand row into a `Name ▾` workspace switcher —
62
+ * the entries are the existing `AccountMenuItem` shape, so a consumer already mapping account
63
+ * actions needs no second vocabulary.
64
+ */
65
+ brand: BrandConfig & { menu?: AccountMenuItem[] }
47
66
  sections: SidebarSection[]
48
67
  collapsed: boolean
49
68
  onToggleCollapse: () => void
50
69
  /**
51
70
  * Footer settings-menu entries (theme switcher, devtools, …) — supplied by the consumer.
52
- * The pinned footer "Settings" row renders ONLY when this is a non-empty list — apps that put
53
- * Settings in a nav section (the common case) omit this and get no duplicate footer row. The
54
- * `brand.version` label renders inside this menu, so it also only shows when the row does.
71
+ * The pinned footer row renders ONLY when this is a non-empty list — apps that put Settings in a
72
+ * nav section (the common case) omit this and get no duplicate footer row.
73
+ *
74
+ * **Three entries or fewer render FLAT**, as one link row each (Settings · Integrations · Invite
75
+ * teammates), and four or more collapse into the single gear "Settings" menu. A menu that opens
76
+ * to show two rows costs a click for nothing; a footer of eight rows costs the nav its height.
77
+ * That threshold is basalt's — `docs/CONTROLS-SPEC.md` §2.3 — and `settingsMenu` is the override
78
+ * for the cases the count cannot see. `brand.version` renders as a faint label under the flat
79
+ * rows, and inside the dropdown in the menu form.
55
80
  */
56
81
  settingsMenuItems?: SettingsMenuItem[]
82
+ /**
83
+ * Which footer form `settingsMenuItems` takes. `'auto'` (the default) is the count rule above;
84
+ * `'flat'` and `'menu'` force one regardless of how many entries there are.
85
+ *
86
+ * The override exists because the COUNT is not the whole question. Three rows that are each a
87
+ * theme radio group, a devtools switch and a density slider are three CONTROLS, not three
88
+ * destinations — flat they fill the footer with widgets and push the account row off the fold,
89
+ * and the count rule cannot tell them from three links. Equally, four short destinations may be
90
+ * worth their height on a tall sidebar. `'auto'` stays the default so nothing moves for a
91
+ * consumer that never had an opinion.
92
+ *
93
+ * DESKTOP FOOTER ONLY. Below `sm` there is no sidebar at all and the same entries are flat rows
94
+ * in the bottom bar's More surface either way — a dropdown inside that sheet would be a menu
95
+ * inside a menu.
96
+ *
97
+ * @default 'auto'
98
+ */
99
+ settingsMenu?: 'auto' | 'flat' | 'menu'
57
100
  /**
58
101
  * Optional account row rendered below the settings menu in the sidebar footer (see
59
- * `SidebarAccount` / `BasaltAccountProps`), separated by its own top hairline. Omitting it
60
- * reproduces today's footer unchanged.
102
+ * `SidebarAccount` / `BasaltAccountProps`) — no separating hairline, the row's own top padding
103
+ * supplies the separation. Omitting it reproduces today's footer unchanged.
61
104
  */
62
105
  account?: BasaltAccountProps
63
106
  /**
64
107
  * Optional search field rendered directly below the brand and ABOVE the nav scroll region — a
65
- * fixed, non-scrolling row. Pair with basalt-ui/commands' openSpotlight.
108
+ * fixed, non-scrolling row. Pair with basalt-ui/commands' openSpotlight. `actions` adds up to two
109
+ * icon-only buttons to the right of that row.
66
110
  */
67
- search?: SidebarSearchConfig
111
+ search?: SidebarSearchConfig & { actions?: SidebarSearchActions }
68
112
  /**
69
- * Arbitrary content appended after `sections` inside the nav `ScrollArea` — a tree, a filter
70
- * panel, a project list, anything a set of `SidebarItem`s can't express. Renders as the last
71
- * child of the scrolling nav column, so a long list scrolls with the rest of the nav instead of
72
- * fighting it for height. Pass `sections={[]}` to use this slot exclusively; the section-spacing
73
- * rule only fires between adjacent children, so an empty `sections` produces no orphan divider or
74
- * dead padding above it.
113
+ * Declared blocks (`docs/CONTROLS-SPEC.md` §2.3, law C13) — an "Awaiting action" list, a
114
+ * "Recents" list, a "Getting started" progress row, or a `kind: 'custom'` node for a tree or
115
+ * filter panel no set of rows can express (`kind: 'custom'` is what replaced `navExtra`).
75
116
  *
76
- * Hidden on the collapsed desktop rail; still present in the mobile drawer, which opens at full
77
- * width. The rail is ~56px of icon buttons with no sensible representation for arbitrary
78
- * consumer content, so the CSS media query that drives the rail hides this slot the same way it
79
- * hides `.childList` — never a JS check on `collapsed`, since that one value is shared by the
80
- * rail AND the drawer (only the media query tells them apart). `SidebarSearch` gets to adapt
81
- * itself to the rail (it takes `collapsed` and renders its own icon-only form) because the shell
82
- * owns that control; it cannot adapt content it knows nothing about, so this slot hides instead
83
- * of squashing it.
117
+ * `placement: 'nav'` appends after `sections` INSIDE the nav scroll region, so a long list
118
+ * scrolls with the nav instead of fighting it for height; `'bottom'` pins above the settings
119
+ * footer. Because these are data and not a `ReactNode` slot, basalt owns both projections a slot
120
+ * could never express: the collapsed rail (a count dot on the icon, a ring on the settings row)
121
+ * and the mobile More sheet — see `sidebarBlockRail` / `sidebarBlockMobile`.
84
122
  */
85
- navExtra?: ReactNode
123
+ blocks?: SidebarBlock[]
86
124
  }
87
125
 
88
126
  /** Inline collapse/expand chevrons — keeps the shell icon-dependency-free. */
@@ -106,24 +144,6 @@ function IconCollapse({ collapsed }: { collapsed: boolean }) {
106
144
  )
107
145
  }
108
146
 
109
- function IconChevron({ open }: { open: boolean }) {
110
- return (
111
- <svg
112
- width={12}
113
- height={12}
114
- viewBox="0 0 24 24"
115
- fill="none"
116
- stroke="currentColor"
117
- strokeWidth={2}
118
- strokeLinecap="round"
119
- strokeLinejoin="round"
120
- aria-hidden
121
- >
122
- {open ? <path d="M6 9l6 6l6 -6" /> : <path d="M9 6l6 6l-6 6" />}
123
- </svg>
124
- )
125
- }
126
-
127
147
  function IconGear() {
128
148
  return (
129
149
  <svg
@@ -146,6 +166,57 @@ function IconGear() {
146
166
  const HOVER_OPEN_DELAY = 150
147
167
  const HOVER_CLOSE_DELAY = 200
148
168
 
169
+ /** `settingsMenuItems` at or under this count render as flat rows — see the prop's JSDoc. */
170
+ const FLAT_SETTINGS_MAX = 3
171
+
172
+ /**
173
+ * The brand name, and — when `brand.menu` is supplied — the workspace switcher it becomes.
174
+ *
175
+ * The entries are `AccountMenuItem`s: the shell already had exactly this row shape (label, icon,
176
+ * onClick, danger) for the account menu, and a second vocabulary for the same dropdown would be
177
+ * two things to keep in step for no gain. The chevron is inline text, not an icon dependency.
178
+ */
179
+ function BrandName({
180
+ brand,
181
+ menuWidth,
182
+ }: {
183
+ brand: BrandConfig & { menu?: AccountMenuItem[] }
184
+ menuWidth: number
185
+ }) {
186
+ const label = (
187
+ <Text className={classes.brandName} fz={VX.text.xl} fw={550}>
188
+ {brand.name}
189
+ </Text>
190
+ )
191
+ const menu = brand.menu
192
+ if (menu === undefined || menu.length === 0) return label
193
+
194
+ return (
195
+ <Menu position="bottom-start" withArrow width={menuWidth} zIndex={500}>
196
+ <Menu.Target>
197
+ <UnstyledButton className={classes.brandButton} aria-label={`${brand.name} workspace`}>
198
+ {label}
199
+ {/* `open` is the DOWN glyph — a switcher's affordance points at its dropdown, and this is
200
+ the same chevron every fold in the sidebar uses rather than a second one. */}
201
+ <IconChevron open />
202
+ </UnstyledButton>
203
+ </Menu.Target>
204
+ <Menu.Dropdown>
205
+ {menu.map((entry) => (
206
+ <Menu.Item
207
+ key={entry.key}
208
+ leftSection={entry.icon}
209
+ {...(entry.danger === true && { color: 'red' })}
210
+ onClick={entry.onClick}
211
+ >
212
+ {entry.label}
213
+ </Menu.Item>
214
+ ))}
215
+ </Menu.Dropdown>
216
+ </Menu>
217
+ )
218
+ }
219
+
149
220
  /** True when the item or any descendant is active — drives inline child expansion. */
150
221
  function hasActiveDescendant(item: SidebarItem): boolean {
151
222
  if (item.active) return true
@@ -168,6 +239,15 @@ function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
168
239
  leftSection: item.icon,
169
240
  rightSection: item.badge ?? (item.count ? <NavCountBadge count={item.count} /> : undefined),
170
241
  active,
242
+ // Basalt is the one that stamps `aria-current`, not the theme and not a router `<Link>`'s own
243
+ // default (which a consumer's `SidebarItem.Anchor` may compute independently) — mirrors
244
+ // `app-mobile-nav.tsx`'s identical pattern so both surfaces agree on what "active" means.
245
+ 'aria-current': active ? ('page' as const) : undefined,
246
+ // `item.ancestor` (a parent on the winner's path — `useNav`'s exclusivity pass) is NEVER
247
+ // active and never carries `aria-current`; it only earns a `data-ancestor` hook for CSS. A
248
+ // leaf item never has `ancestor` set, so this is a no-op there — shared here rather than
249
+ // duplicated per render path.
250
+ ...(item.ancestor === true && { 'data-ancestor': true }),
171
251
  ...(item.onClick !== undefined && { onClick: item.onClick }),
172
252
  }
173
253
  if (Anchor) {
@@ -179,18 +259,63 @@ function NavLinkBody({ item, active }: { item: SidebarItem; active: boolean }) {
179
259
  /**
180
260
  * Group label — a micro-label (docs/DESIGN-SPEC.md §3: mono, uppercase, tracked, faint). Typography
181
261
  * lives entirely in `.sectionLabel` (app-sidebar.module.css), not Mantine `Text` props, since the
182
- * treatment is a shell-specific micro-label rather than a themed primitive. `flush` drops the
183
- * intrinsic inset/margin so the collapsible `sectionHeader` can own the padding instead (otherwise
184
- * the button's hover box double-insets and hugs the text).
262
+ * treatment is a shell-specific micro-label rather than a themed primitive. Flush (no intrinsic
263
+ * inset/margin) at every call site, so the collapsible `sectionHeader` owns the padding instead
264
+ * (otherwise the button's hover box would double-inset and hug the text).
185
265
  */
186
- function SectionLabel({ children, flush }: { children: ReactNode; flush?: boolean }) {
266
+ function SectionLabel({ children }: { children: ReactNode }) {
187
267
  return (
188
- <Text component="div" px={flush ? 0 : 'xs'} mb={flush ? 0 : 4} className={classes.sectionLabel}>
268
+ <Text component="div" px={0} mb={0} className={classes.sectionLabel}>
189
269
  {children}
190
270
  </Text>
191
271
  )
192
272
  }
193
273
 
274
+ /**
275
+ * One nav section, owning its own PERSISTED fold.
276
+ *
277
+ * A component per section rather than one state object in `AppSidebar`, because the fold moved from
278
+ * a `useState` keyed by label to `createPersistedState` at `basalt:sidebar-section:<label-slug>` —
279
+ * one hook per section, which can only have a stable hook count inside a component of its own.
280
+ * `defaultCollapsed` is the SEED for that key, so it stops overriding what the user last chose.
281
+ */
282
+ function NavSection({ section, collapsed }: { section: SidebarSection; collapsed: boolean }) {
283
+ const [folded, setFolded] = usePersistedFold(
284
+ sidebarSectionFoldKey(section.label),
285
+ Boolean(section.defaultCollapsed),
286
+ )
287
+ const items = section.items.map((item) => (
288
+ <NavItemRow key={item.key} item={item} collapsed={collapsed} />
289
+ ))
290
+
291
+ if (!section.collapsible) {
292
+ return (
293
+ <div>
294
+ <div className={classes.sectionBand}>
295
+ <SectionLabel>{section.label}</SectionLabel>
296
+ </div>
297
+ <Stack gap={1}>{items}</Stack>
298
+ </div>
299
+ )
300
+ }
301
+
302
+ return (
303
+ <div>
304
+ <UnstyledButton
305
+ className={`${classes.sectionBand} ${classes.sectionHeader}`}
306
+ onClick={() => setFolded(!folded)}
307
+ aria-expanded={!folded}
308
+ >
309
+ <SectionLabel>{section.label}</SectionLabel>
310
+ <IconChevron open={!folded} />
311
+ </UnstyledButton>
312
+ <Collapse expanded={!folded}>
313
+ <Stack gap={1}>{items}</Stack>
314
+ </Collapse>
315
+ </div>
316
+ )
317
+ }
318
+
194
319
  function NavItemRow({ item, collapsed }: { item: SidebarItem; collapsed: boolean }) {
195
320
  const [opened, { open, close }] = useDisclosure(false)
196
321
  const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
@@ -350,58 +475,99 @@ export function AppSidebar({
350
475
  collapsed,
351
476
  onToggleCollapse,
352
477
  settingsMenuItems,
478
+ settingsMenu = 'auto',
353
479
  account,
354
480
  search,
355
- navExtra,
481
+ blocks,
356
482
  }: AppSidebarProps) {
357
483
  // Density-tracking Menu dropdown width (`SPACE_STEP.sidebarSettingsMenuWidth`) — read the ACTIVE
358
484
  // resolved level, not the frozen level-0 constant (see that constant's own doc in
359
485
  // `tokens/palette.ts`).
360
486
  const { step } = useBasaltSpacing()
361
487
 
362
- // Desktop collapsible-section state, keyed by section label. Seeded once from each section's
363
- // `defaultCollapsed`; non-collapsible sections are simply never read here.
364
- const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>(() =>
365
- Object.fromEntries(sections.map((s) => [s.label, Boolean(s.defaultCollapsed)])),
488
+ const navBlocks = (blocks ?? []).filter((block) => sidebarBlockPlacement(block) === 'nav')
489
+ const bottomBlocks = (blocks ?? []).filter((block) => sidebarBlockPlacement(block) === 'bottom')
490
+ // The rail's stand-in for a progress block: the first one asking for a ring gets one, on the
491
+ // settings row. JS-gated on `collapsed` rather than CSS (unlike every other rail rule) because
492
+ // the mark moves to a DIFFERENT node — nothing in the block's own subtree can express it. With no
493
+ // settings row there is nowhere to put it, and the block simply has no rail form.
494
+ const railRing = bottomBlocks.find(
495
+ (block) => block.kind === 'progress' && sidebarBlockRail(block) === 'ring',
366
496
  )
367
497
 
368
- const renderSectionItems = (section: SidebarSection) =>
369
- section.items.map((item) => <NavItemRow key={item.key} item={item} collapsed={collapsed} />)
370
-
371
- // The pinned footer "Settings" row is OPT-IN: it renders only when the consumer supplies
372
- // settings-menu entries. Most apps put Settings in a nav section instead — rendering the row
373
- // unconditionally produced a duplicate "Settings" the consumer couldn't remove. On mobile the
374
- // same entries are reachable as flat rows in the bottom bar's More surface.
375
- const hasSettingsMenu = (settingsMenuItems?.length ?? 0) > 0
376
- const settingsRow = hasSettingsMenu ? (
377
- <Group {...(account ? {} : { className: classes.footer })} gap="xs" wrap="nowrap">
378
- <Menu position="right-start" withArrow width={step.sidebarSettingsMenuWidth} zIndex={500}>
379
- <Menu.Target>
380
- <UnstyledButton className={classes.footerBtn} aria-label="Settings">
381
- <IconGear />
498
+ // The pinned footer row is OPT-IN: it renders only when the consumer supplies settings-menu
499
+ // entries. Most apps put Settings in a nav section instead — rendering the row unconditionally
500
+ // produced a duplicate "Settings" the consumer couldn't remove. On mobile the same entries are
501
+ // reachable as flat rows in the bottom bar's More surface.
502
+ const settingsItems = settingsMenuItems ?? []
503
+ const versionLabel = brand.version !== undefined ? `${brand.name} v${brand.version}` : undefined
504
+ const ringMark =
505
+ collapsed && railRing !== undefined && railRing.kind === 'progress' ? (
506
+ <SidebarProgressRing value={railRing.value} total={railRing.total} />
507
+ ) : null
508
+
509
+ // Three or fewer render as flat link rows; four or more keep the gear menu — unless the consumer
510
+ // forced one form, which is the only thing `settingsMenu` does. See both props' docs.
511
+ const flatSettings =
512
+ settingsMenu === 'flat' ||
513
+ (settingsMenu === 'auto' && settingsItems.length <= FLAT_SETTINGS_MAX)
514
+ const settingsRow =
515
+ settingsItems.length === 0 ? null : flatSettings ? (
516
+ <div className={classes.footerLinks}>
517
+ {settingsItems.map((entry, index) => (
518
+ <UnstyledButton
519
+ key={entry.key}
520
+ className={classes.footerBtn}
521
+ onClick={entry.onClick}
522
+ aria-label={entry.label}
523
+ >
524
+ {/* A FIXED slot, so rows align on one icon column whether or not each ships an icon —
525
+ and the gear fallback is functional, not decorative: in the collapsed rail the label
526
+ is hidden and the glyph IS the row, so an icon-less row would be an empty hover
527
+ target. `SettingsMenuItem.icon` stays optional; supply one per row in the flat form
528
+ and the gear never appears. */}
529
+ <span className={classes.footerIconSlot}>{entry.icon ?? <IconGear />}</span>
382
530
  <Text className={classes.footerText} fz={VX.text.md}>
383
- Settings
531
+ {entry.label}
384
532
  </Text>
533
+ {/* The rail ring rides the FIRST row only — one progress block is one mark. */}
534
+ {index === 0 ? ringMark : null}
385
535
  </UnstyledButton>
386
- </Menu.Target>
387
- <Menu.Dropdown>
388
- {settingsMenuItems?.map((entry) => (
389
- <Menu.Item key={entry.key} leftSection={entry.icon} onClick={entry.onClick}>
390
- {entry.label}
391
- </Menu.Item>
392
- ))}
393
- {brand.version && (
394
- <>
395
- <Menu.Divider />
396
- <Menu.Label>
397
- {brand.name} v{brand.version}
398
- </Menu.Label>
399
- </>
400
- )}
401
- </Menu.Dropdown>
402
- </Menu>
403
- </Group>
404
- ) : null
536
+ ))}
537
+ {versionLabel !== undefined && (
538
+ <Text component="div" className={classes.footerVersion}>
539
+ {versionLabel}
540
+ </Text>
541
+ )}
542
+ </div>
543
+ ) : (
544
+ <Group gap="xs" wrap="nowrap">
545
+ <Menu position="right-start" withArrow width={step.sidebarSettingsMenuWidth} zIndex={500}>
546
+ <Menu.Target>
547
+ <UnstyledButton className={classes.footerBtn} aria-label="Settings">
548
+ <IconGear />
549
+ <Text className={classes.footerText} fz={VX.text.md}>
550
+ Settings
551
+ </Text>
552
+ {ringMark}
553
+ </UnstyledButton>
554
+ </Menu.Target>
555
+ <Menu.Dropdown>
556
+ {settingsItems.map((entry) => (
557
+ <Menu.Item key={entry.key} leftSection={entry.icon} onClick={entry.onClick}>
558
+ {entry.label}
559
+ </Menu.Item>
560
+ ))}
561
+ {versionLabel !== undefined && (
562
+ <>
563
+ <Menu.Divider />
564
+ <Menu.Label>{versionLabel}</Menu.Label>
565
+ </>
566
+ )}
567
+ </Menu.Dropdown>
568
+ </Menu>
569
+ </Group>
570
+ )
405
571
 
406
572
  return (
407
573
  <Stack gap={0} h="100%" className={classes.root} data-collapsed={collapsed || undefined}>
@@ -416,9 +582,7 @@ export function AppSidebar({
416
582
  style={{ display: 'block' }}
417
583
  />
418
584
  )}
419
- <Text className={classes.brandName} fz={VX.text.xl} fw={550}>
420
- {brand.name}
421
- </Text>
585
+ <BrandName brand={brand} menuWidth={step.sidebarAccountMenuWidth} />
422
586
  </Group>
423
587
  <ActionIcon
424
588
  variant="subtle"
@@ -450,57 +614,29 @@ export function AppSidebar({
450
614
  classNames={{ viewport: classes.navViewport }}
451
615
  >
452
616
  <Stack gap={0} className={classes.nav}>
453
- {sections.map((section) => {
454
- if (!section.collapsible) {
455
- return (
456
- <div key={section.label}>
457
- <div className={classes.sectionBand}>
458
- <SectionLabel flush>{section.label}</SectionLabel>
459
- </div>
460
- <Stack gap={1}>{renderSectionItems(section)}</Stack>
461
- </div>
462
- )
463
- }
464
-
465
- const isOpen = !collapsedSections[section.label]
466
- return (
467
- <div key={section.label}>
468
- <UnstyledButton
469
- className={`${classes.sectionBand} ${classes.sectionHeader}`}
470
- onClick={() =>
471
- setCollapsedSections((prev) => ({
472
- ...prev,
473
- [section.label]: !prev[section.label],
474
- }))
475
- }
476
- aria-expanded={isOpen}
477
- >
478
- <SectionLabel flush>{section.label}</SectionLabel>
479
- <IconChevron open={isOpen} />
480
- </UnstyledButton>
481
- <Collapse expanded={isOpen}>
482
- <Stack gap={1}>{renderSectionItems(section)}</Stack>
483
- </Collapse>
484
- </div>
485
- )
486
- })}
487
- {navExtra !== undefined && navExtra !== null && (
488
- <div className={classes.navExtra}>{navExtra}</div>
489
- )}
617
+ {sections.map((section) => (
618
+ <NavSection key={section.label} section={section} collapsed={collapsed} />
619
+ ))}
620
+ {navBlocks.map((block) => (
621
+ <SidebarBlockView key={block.key} block={block} />
622
+ ))}
490
623
  </Stack>
491
624
  </ScrollArea>
492
625
 
493
- {account ? (
626
+ {(bottomBlocks.length > 0 || settingsRow !== null || account !== undefined) && (
494
627
  <Stack gap={0} className={classes.footer}>
628
+ {bottomBlocks.map((block) => (
629
+ <SidebarBlockView key={block.key} block={block} />
630
+ ))}
495
631
  {settingsRow}
496
- <SidebarAccount
497
- state={account.state}
498
- {...(account.actions !== undefined && { actions: account.actions })}
499
- {...(account.showEmail !== undefined && { showEmail: account.showEmail })}
500
- />
632
+ {account !== undefined && (
633
+ <SidebarAccount
634
+ state={account.state}
635
+ {...(account.actions !== undefined && { actions: account.actions })}
636
+ {...(account.showEmail !== undefined && { showEmail: account.showEmail })}
637
+ />
638
+ )}
501
639
  </Stack>
502
- ) : (
503
- settingsRow
504
640
  )}
505
641
  </Stack>
506
642
  )