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
@@ -1,36 +1,29 @@
1
1
  /**
2
- * `navExtra` — the seam that lets a consumer put arbitrary content (a tree, a filter panel,
3
- * a project list) inside the sidebar's nav scroll region instead of rendering a second column
4
- * beside the shell. Four invariants:
2
+ * `blocks` — the declared-data seam that replaced `navExtra` (law C13, `docs/CONTROLS-SPEC.md`
3
+ * §2.3), plus the two other things the sidebar now owns: the `brand.menu` workspace switcher and
4
+ * the flat-vs-menu settings footer.
5
5
  *
6
- * 1. It renders INSIDE the nav `ScrollArea`'s content (Mantine's own `.mantine-ScrollArea-content`
7
- * class, stable across runs — CSS-module class hashes are not available under `bun test`, see
8
- * `stat-card.test.tsx`/`use-basalt-spacing.test.tsx` for the same constraint elsewhere), as the
9
- * LAST child, so a long tree scrolls with the nav instead of fighting it for height.
10
- * 2. It STAYS MOUNTED when `collapsed` — hiding it on the rail lives entirely in a `min-width: sm`
11
- * CSS media query, never in JS, so the DOM is identical either way. jsdom does not evaluate the
12
- * media query, so this asserts presence in the DOM, not computed visibility — the CSS half
13
- * (`.root[data-collapsed] .navExtra { display: none }`) is exercised by the guard rules it
14
- * shares with `.childList`/`.sectionBand`, not by this test.
15
- * 3. `sections={[]}` plus only `navExtra` renders cleanly: the nav Stack holds exactly the
16
- * extra content, no orphan section wrapper.
17
- * 4. Omitting the prop reproduces today's DOM exactly — the nav Stack's child count matches
18
- * `sections.length`, with no stray wrapper appended for the (absent) slot. This is the
19
- * regression guard for every existing consumer.
6
+ * What is asserted here and what deliberately is not: CSS-module class hashes are unavailable under
7
+ * `bun test` (see `stat-card.test.tsx` for the same constraint), and jsdom evaluates no media query,
8
+ * so the collapsed-rail RULES are not testable. What IS testable is the DOM the rules select
9
+ * against — `data-rail` on every block, a dot node when a count earns one, and the ring on the
10
+ * settings row (the one rail projection that is JS-gated, because it moves the mark to a different
11
+ * node). A regression that reintroduces a JS `collapsed` check for the CSS-only halves fails the
12
+ * "stays mounted when collapsed" assertions instead of only breaking at the rail width in a browser.
20
13
  */
21
14
  import { MantineProvider } from '@mantine/core'
22
- import { render, screen } from '@testing-library/react'
15
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
23
16
  import { describe, expect, test } from 'bun:test'
24
- import type { ReactNode } from 'react'
25
17
  import { AppSidebar } from './app-sidebar'
26
18
  import type { AppSidebarProps } from './app-sidebar'
19
+ import type { SidebarBlock } from '../nav/types'
27
20
 
28
21
  const BRAND = { name: 'Argo' }
29
22
  const ONE_SECTION: AppSidebarProps['sections'] = [
30
23
  { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
31
24
  ]
32
25
 
33
- function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode }) {
26
+ function renderSidebar(props: Partial<AppSidebarProps>) {
34
27
  return render(
35
28
  <MantineProvider>
36
29
  <AppSidebar
@@ -44,17 +37,68 @@ function renderSidebar(props: Partial<AppSidebarProps> & { navExtra?: ReactNode
44
37
  )
45
38
  }
46
39
 
47
- /** The nav `ScrollArea`'s content Stack — the direct parent of `sections` and `navExtra`. */
40
+ /** The nav `ScrollArea`'s content Stack — the direct parent of `sections` and the `'nav'` blocks. */
48
41
  function navStack(container: HTMLElement): Element {
49
42
  const stack = container.querySelector('.mantine-ScrollArea-content > .mantine-Stack-root')
50
43
  if (!stack) throw new Error('expected the nav ScrollArea content Stack to render')
51
44
  return stack
52
45
  }
53
46
 
54
- describe('navExtra', () => {
55
- test('renders inside the nav scroll region, as the last child, when expanded', () => {
47
+ const AWAITING: SidebarBlock = {
48
+ kind: 'list',
49
+ key: 'awaiting',
50
+ label: 'Awaiting action',
51
+ count: 3,
52
+ items: [
53
+ { key: 'a', label: 'Review PR', tone: 'warn' },
54
+ { key: 'b', label: 'Sign contract', tone: 'bad', href: '/contract' },
55
+ { key: 'c', label: 'Reply to Jo', meta: '2h' },
56
+ ],
57
+ }
58
+
59
+ describe('sidebar blocks — rendering per kind', () => {
60
+ test("a 'list' block renders its label, count badge and rows", () => {
61
+ renderSidebar({ blocks: [AWAITING] })
62
+ expect(screen.getByText('Awaiting action')).toBeTruthy()
63
+ expect(screen.getByText('3')).toBeTruthy()
64
+ expect(screen.getByText('Review PR')).toBeTruthy()
65
+ expect(screen.getByText('Reply to Jo')).toBeTruthy()
66
+ })
67
+
68
+ test('a row with nowhere to go is NOT a link — the Recents shape stays plain text', () => {
69
+ renderSidebar({
70
+ blocks: [
71
+ {
72
+ kind: 'list',
73
+ key: 'recents',
74
+ label: 'Recents',
75
+ items: [
76
+ { key: 'r1', label: 'Q3 plan' },
77
+ { key: 'r2', label: 'Q4 plan', href: '/q4' },
78
+ ],
79
+ },
80
+ ],
81
+ })
82
+ // A keyboard walk must not stop on a dead `<a>`; the sibling WITH an href proves the same
83
+ // renderer still produces one when there is somewhere to go.
84
+ expect(screen.getByText('Q3 plan').closest('a')).toBeNull()
85
+ expect(screen.getByText('Q4 plan').closest('a')).not.toBeNull()
86
+ })
87
+
88
+ test("a 'progress' block renders label, `n of m` and a Progress track", () => {
89
+ const { container } = renderSidebar({
90
+ blocks: [
91
+ { kind: 'progress', key: 'onboarding', label: 'Getting started', value: 1, total: 5 },
92
+ ],
93
+ })
94
+ expect(screen.getByText('Getting started')).toBeTruthy()
95
+ expect(screen.getByText('1 of 5')).toBeTruthy()
96
+ expect(container.querySelector('.mantine-Progress-root')).not.toBeNull()
97
+ })
98
+
99
+ test("a 'custom' block mounts the node verbatim, inside the nav scroll region", () => {
56
100
  const { container } = renderSidebar({
57
- navExtra: <div data-testid="nav-extra">Extra</div>,
101
+ blocks: [{ kind: 'custom', key: 'tree', node: <div data-testid="nav-extra">Extra</div> }],
58
102
  })
59
103
  const extra = screen.getByTestId('nav-extra')
60
104
  const stack = navStack(container)
@@ -62,33 +106,404 @@ describe('navExtra', () => {
62
106
  expect(stack.lastElementChild?.contains(extra)).toBe(true)
63
107
  })
64
108
 
65
- test('stays mounted in the DOM when collapsed — hiding it is CSS-only, not a JS gate', () => {
109
+ test('a custom block stays mounted when collapsed — hiding it is CSS-only, not a JS gate', () => {
66
110
  const { container } = renderSidebar({
67
111
  collapsed: true,
68
- navExtra: <div data-testid="nav-extra">Extra</div>,
112
+ blocks: [{ kind: 'custom', key: 'tree', node: <div data-testid="nav-extra">Extra</div> }],
69
113
  })
70
- const extra = screen.getByTestId('nav-extra')
71
- const stack = navStack(container)
72
- // Presence, not visibility — jsdom never evaluates the `min-width: sm` media query that hides
73
- // this in the rail. A regression here (reintroducing `!collapsed &&` in app-sidebar.tsx) would
74
- // fail this assertion instead of only breaking silently in a real browser at the rail width.
75
- expect(stack.contains(extra)).toBe(true)
114
+ expect(navStack(container).contains(screen.getByTestId('nav-extra'))).toBe(true)
76
115
  })
77
116
 
78
- test('sections={[]} plus only navExtra renders cleanly — no orphan section wrapper', () => {
117
+ test('sections={[]} plus one block renders cleanly — no orphan section wrapper', () => {
79
118
  const { container } = renderSidebar({
80
119
  sections: [],
81
- navExtra: <div data-testid="nav-extra">Extra</div>,
120
+ blocks: [{ kind: 'custom', key: 'tree', node: <div data-testid="nav-extra">Extra</div> }],
82
121
  })
83
- const stack = navStack(container)
84
- expect(screen.getByTestId('nav-extra')).toBeTruthy()
85
- expect(stack.children.length).toBe(1)
122
+ expect(navStack(container).children.length).toBe(1)
86
123
  })
87
124
 
88
- test("omitting the prop reproduces today's DOM — no stray wrapper for the unused slot", () => {
125
+ test("omitting blocks reproduces today's DOM — no stray wrapper for the unused slot", () => {
89
126
  const { container } = renderSidebar({})
90
- expect(screen.queryByTestId('nav-extra')).toBeNull()
91
- const stack = navStack(container)
92
- expect(stack.children.length).toBe(ONE_SECTION.length)
127
+ expect(navStack(container).children.length).toBe(ONE_SECTION.length)
128
+ })
129
+ })
130
+
131
+ describe('sidebar blocks — placement', () => {
132
+ test("'nav' is the default for list and custom: both land in the scroll region", () => {
133
+ const { container } = renderSidebar({
134
+ blocks: [AWAITING, { kind: 'custom', key: 'tree', node: <span>tree</span> }],
135
+ })
136
+ expect(navStack(container).children.length).toBe(ONE_SECTION.length + 2)
137
+ })
138
+
139
+ test("'bottom' pins above the settings footer, OUTSIDE the nav scroll region", () => {
140
+ const { container } = renderSidebar({
141
+ blocks: [{ ...AWAITING, placement: 'bottom' }],
142
+ settingsMenuItems: [{ key: 'theme', label: 'Theme', onClick: () => {} }],
143
+ })
144
+ expect(navStack(container).children.length).toBe(ONE_SECTION.length)
145
+ const label = screen.getByText('Awaiting action')
146
+ expect(label.closest('.mantine-ScrollArea-root')).toBeNull()
147
+ // Above the settings row, not below it.
148
+ const footer = label.closest('.mantine-Stack-root')
149
+ const settings = screen.getByLabelText('Theme')
150
+ expect(footer?.contains(settings)).toBe(true)
151
+ })
152
+
153
+ test('a progress block is bottom-placed with no placement stated', () => {
154
+ const { container } = renderSidebar({
155
+ blocks: [{ kind: 'progress', key: 'p', label: 'Getting started', value: 1, total: 5 }],
156
+ })
157
+ expect(navStack(container).children.length).toBe(ONE_SECTION.length)
158
+ expect(screen.getByText('Getting started').closest('.mantine-ScrollArea-root')).toBeNull()
159
+ })
160
+ })
161
+
162
+ describe('sidebar blocks — Show more', () => {
163
+ const LONG: SidebarBlock = {
164
+ kind: 'list',
165
+ key: 'long',
166
+ label: 'Awaiting action',
167
+ max: 3,
168
+ items: Array.from({ length: 6 }, (_, i) => ({ key: `i${i}`, label: `Item ${i}` })),
169
+ }
170
+
171
+ test('max shows the first N and a Show more toggle; the toggle reveals the rest', () => {
172
+ renderSidebar({ blocks: [LONG] })
173
+ expect(screen.getByText('Item 2')).toBeTruthy()
174
+ expect(screen.queryByText('Item 3')).toBeNull()
175
+
176
+ fireEvent.click(screen.getByText('Show more'))
177
+ expect(screen.getByText('Item 5')).toBeTruthy()
178
+ expect(screen.getByText('Show less')).toBeTruthy()
179
+ })
180
+
181
+ test('no max, or a max past the item count, renders no toggle at all', () => {
182
+ renderSidebar({ blocks: [AWAITING, { ...LONG, key: 'long2', max: 99 }] })
183
+ expect(screen.queryByText('Show more')).toBeNull()
184
+ })
185
+
186
+ test('Show more is EPHEMERAL — it persists nothing (unlike the fold)', () => {
187
+ localStorage.clear()
188
+ renderSidebar({ blocks: [LONG] })
189
+ fireEvent.click(screen.getByText('Show more'))
190
+ expect(localStorage.getItem('basalt:sidebar-block:long')).toBeNull()
191
+ })
192
+ })
193
+
194
+ /**
195
+ * The fold keys are a CONTRACT, not an implementation detail: a consumer mirroring the state reads
196
+ * them with `readPersistedValue` from `basalt-ui/state`, and renaming one silently resets every
197
+ * user's sidebar. Section folds were a `useState` keyed by label through 1.25.0 — every reload
198
+ * re-opened a section the user had closed.
199
+ */
200
+ describe('sidebar blocks — persisted folds', () => {
201
+ test('a block fold writes the versioned envelope at basalt:sidebar-block:<key>', () => {
202
+ localStorage.clear()
203
+ renderSidebar({ blocks: [{ ...AWAITING, key: 'awaiting-fold', collapsible: true }] })
204
+
205
+ fireEvent.click(screen.getByText('Awaiting action'))
206
+
207
+ expect(localStorage.getItem('basalt:sidebar-block:awaiting-fold')).toBe(
208
+ JSON.stringify({ v: 1, value: true }),
209
+ )
210
+ })
211
+
212
+ test('a nav-section fold writes at basalt:sidebar-section:<label-slug>', () => {
213
+ localStorage.clear()
214
+ renderSidebar({
215
+ sections: [
216
+ { label: 'Tools & More', collapsible: true, items: [{ key: 't', label: 'T', icon: null }] },
217
+ ],
218
+ })
219
+
220
+ fireEvent.click(screen.getByText('Tools & More'))
221
+
222
+ expect(localStorage.getItem('basalt:sidebar-section:tools-more')).toBe(
223
+ JSON.stringify({ v: 1, value: true }),
224
+ )
225
+ })
226
+
227
+ test('a persisted fold is restored on the next mount', () => {
228
+ localStorage.clear()
229
+ localStorage.setItem('basalt:sidebar-section:restored', JSON.stringify({ v: 1, value: true }))
230
+ renderSidebar({
231
+ sections: [
232
+ {
233
+ label: 'Restored',
234
+ collapsible: true,
235
+ items: [{ key: 't', label: 'Folded', icon: null }],
236
+ },
237
+ ],
238
+ })
239
+ expect(screen.getByText('Restored').closest('button')?.getAttribute('aria-expanded')).toBe(
240
+ 'false',
241
+ )
242
+ })
243
+ })
244
+
245
+ /** The `data-rail` attribute of the nth block — the whole contract with the rail stylesheet. */
246
+ const railOf = (container: HTMLElement, index = 0): string | null | undefined =>
247
+ container.querySelectorAll('[data-rail]')[index]?.getAttribute('data-rail')
248
+
249
+ /**
250
+ * Rail projection. `data-rail` is the whole contract between the resolver and the stylesheet — the
251
+ * CSS selects on it (`.root[data-collapsed] .block[data-rail='hidden']`), so an attribute that
252
+ * stops matching the resolver is a rail that silently stops projecting.
253
+ */
254
+ describe('sidebar blocks — rail projection', () => {
255
+ test("a list with a count is data-rail='dot' and renders the dot node", () => {
256
+ const { container } = renderSidebar({
257
+ blocks: [{ ...AWAITING, icon: <span data-testid="block-icon" /> }],
258
+ })
259
+ expect(railOf(container)).toBe('dot')
260
+ // The dot lives inside the icon wrapper — that is what the rail shows instead of the badge.
261
+ expect(screen.getByTestId('block-icon').parentElement?.children).toHaveLength(2)
262
+ })
263
+
264
+ test("a list with NO count is data-rail='hidden' — a dot with no number behind it means nothing", () => {
265
+ const { container } = renderSidebar({
266
+ blocks: [
267
+ { kind: 'list', key: 'recents', label: 'Recents', items: [{ key: 'a', label: 'A' }] },
268
+ ],
269
+ })
270
+ expect(railOf(container)).toBe('hidden')
271
+ })
272
+
273
+ test("rail:'hidden' overrides the count default", () => {
274
+ const { container } = renderSidebar({
275
+ blocks: [{ ...AWAITING, icon: <span />, rail: 'hidden' }],
276
+ })
277
+ expect(railOf(container)).toBe('hidden')
278
+ })
279
+
280
+ test('a count with NO icon leaves the rail — a dot has to sit on something', () => {
281
+ const { container } = renderSidebar({ blocks: [AWAITING] })
282
+ expect(railOf(container)).toBe('hidden')
283
+ })
284
+
285
+ test('a custom block is always hidden in the rail, as navExtra was', () => {
286
+ const { container } = renderSidebar({
287
+ blocks: [{ kind: 'custom', key: 'tree', node: <span>tree</span> }],
288
+ })
289
+ expect(railOf(container)).toBe('hidden')
290
+ })
291
+
292
+ test("a progress block is data-rail='ring', and the ring renders on the SETTINGS row when collapsed", () => {
293
+ const { container } = renderSidebar({
294
+ collapsed: true,
295
+ blocks: [{ kind: 'progress', key: 'p', label: 'Getting started', value: 1, total: 5 }],
296
+ settingsMenuItems: [{ key: 'theme', label: 'Theme', onClick: () => {} }],
297
+ })
298
+ expect(railOf(container)).toBe('ring')
299
+ const ring = container.querySelector('[data-basalt-rail-ring]')
300
+ expect(ring).not.toBeNull()
301
+ expect(screen.getByLabelText('Theme').contains(ring as Node)).toBe(true)
302
+ })
303
+
304
+ test('expanded, there is no ring — the block paints its own track instead', () => {
305
+ const { container } = renderSidebar({
306
+ blocks: [{ kind: 'progress', key: 'p', label: 'Getting started', value: 1, total: 5 }],
307
+ settingsMenuItems: [{ key: 'theme', label: 'Theme', onClick: () => {} }],
308
+ })
309
+ expect(container.querySelector('[data-basalt-rail-ring]')).toBeNull()
310
+ })
311
+
312
+ test("rail:'hidden' on a progress block puts no ring on the settings row", () => {
313
+ const { container } = renderSidebar({
314
+ collapsed: true,
315
+ blocks: [
316
+ {
317
+ kind: 'progress',
318
+ key: 'p',
319
+ label: 'Getting started',
320
+ value: 1,
321
+ total: 5,
322
+ rail: 'hidden',
323
+ },
324
+ ],
325
+ settingsMenuItems: [{ key: 'theme', label: 'Theme', onClick: () => {} }],
326
+ })
327
+ expect(container.querySelector('[data-basalt-rail-ring]')).toBeNull()
328
+ })
329
+ })
330
+
331
+ describe('brand.menu — the workspace switcher', () => {
332
+ const MENU = [
333
+ { key: 'switch', label: 'Switch workspace', onClick: () => {} },
334
+ { key: 'new', label: 'New workspace', onClick: () => {} },
335
+ ]
336
+
337
+ test('no menu: the brand name is plain text, not a control', () => {
338
+ renderSidebar({})
339
+ expect(screen.queryByLabelText('Argo workspace')).toBeNull()
340
+ })
341
+
342
+ test('with a menu the brand row becomes a `Name ▾` trigger opening the entries', async () => {
343
+ let picked = 0
344
+ renderSidebar({
345
+ brand: { name: 'Argo', menu: [{ ...MENU[0]!, onClick: () => (picked += 1) }, MENU[1]!] },
346
+ })
347
+
348
+ fireEvent.click(screen.getByLabelText('Argo workspace'))
349
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
350
+ expect(screen.getByText('New workspace')).toBeTruthy()
351
+
352
+ fireEvent.click(screen.getByText('Switch workspace'))
353
+ expect(picked).toBe(1)
354
+ })
355
+
356
+ test('an empty menu array is no menu — never a trigger that opens nothing', () => {
357
+ renderSidebar({ brand: { name: 'Argo', menu: [] } })
358
+ expect(screen.queryByLabelText('Argo workspace')).toBeNull()
359
+ })
360
+ })
361
+
362
+ /**
363
+ * The footer threshold. A menu that opens to show two rows costs a click for nothing; eight flat
364
+ * rows cost the nav its height. Three is the line (`docs/CONTROLS-SPEC.md` §2.3), and it is
365
+ * basalt's — there is no prop.
366
+ */
367
+ describe('settingsMenuItems — flat at three or fewer', () => {
368
+ const three = [
369
+ { key: 'settings', label: 'Settings', onClick: () => {} },
370
+ { key: 'integrations', label: 'Integrations', onClick: () => {} },
371
+ { key: 'invite', label: 'Invite teammates', onClick: () => {} },
372
+ ]
373
+
374
+ test('three entries render as three link rows, each firing its own handler', () => {
375
+ let fired = ''
376
+ renderSidebar({
377
+ settingsMenuItems: three.map((entry) => ({ ...entry, onClick: () => (fired = entry.key) })),
378
+ })
379
+ expect(screen.getByText('Integrations')).toBeTruthy()
380
+ expect(screen.getByText('Invite teammates')).toBeTruthy()
381
+ fireEvent.click(screen.getByLabelText('Invite teammates'))
382
+ expect(fired).toBe('invite')
383
+ })
384
+
385
+ test('four entries collapse into the single gear menu', async () => {
386
+ renderSidebar({
387
+ settingsMenuItems: [...three, { key: 'devtools', label: 'Devtools', onClick: () => {} }],
388
+ })
389
+ // Only the gear trigger is present until it opens.
390
+ expect(screen.queryByText('Integrations')).toBeNull()
391
+ fireEvent.click(screen.getByLabelText('Settings'))
392
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
393
+ expect(screen.getByText('Integrations')).toBeTruthy()
394
+ })
395
+
396
+ test('the version rides the flat rows as a faint label, and the menu as a Menu.Label', async () => {
397
+ renderSidebar({ brand: { name: 'Argo', version: '2.1.0' }, settingsMenuItems: three })
398
+ expect(screen.getByText('Argo v2.1.0')).toBeTruthy()
399
+ })
400
+
401
+ test('no entries at all renders no footer row', () => {
402
+ renderSidebar({})
403
+ expect(screen.queryByLabelText('Settings')).toBeNull()
404
+ })
405
+
406
+ /**
407
+ * The count is not the whole question — three rows that are each a control (a theme radio group,
408
+ * a devtools switch) read as a widget pile flat, and the count rule cannot see the difference.
409
+ * `settingsMenu` is that override and nothing more: it moves the form, never the entries.
410
+ */
411
+ describe('settingsMenu forces the form', () => {
412
+ test("'menu' collapses two entries into the gear dropdown", async () => {
413
+ renderSidebar({ settingsMenuItems: three.slice(0, 2), settingsMenu: 'menu' })
414
+ expect(screen.queryByText('Integrations')).toBeNull()
415
+ fireEvent.click(screen.getByLabelText('Settings'))
416
+ await waitFor(() => expect(document.querySelector('[role="menu"]')).not.toBeNull())
417
+ expect(screen.getByText('Integrations')).toBeTruthy()
418
+ })
419
+
420
+ test("'flat' keeps four entries as four link rows", () => {
421
+ renderSidebar({
422
+ settingsMenuItems: [...three, { key: 'devtools', label: 'Devtools', onClick: () => {} }],
423
+ settingsMenu: 'flat',
424
+ })
425
+ // No dropdown to open — every entry is its own row, and the gear trigger is not one of them.
426
+ expect(document.querySelector('[role="menu"]')).toBeNull()
427
+ expect(screen.getByText('Devtools')).toBeTruthy()
428
+ expect(screen.getByText('Integrations')).toBeTruthy()
429
+ })
430
+
431
+ test("'auto' is the default — the count rule, unchanged", () => {
432
+ renderSidebar({ settingsMenuItems: three, settingsMenu: 'auto' })
433
+ expect(screen.getByText('Integrations')).toBeTruthy()
434
+ })
435
+ })
436
+ })
437
+
438
+ describe('search.actions', () => {
439
+ test('no actions: the trigger is the row, with no wrapper and no extra buttons', () => {
440
+ const { container } = renderSidebar({ search: { onOpen: () => {} } })
441
+ expect(container.querySelectorAll('button')).toHaveLength(2) // search trigger + collapse toggle
442
+ })
443
+
444
+ test('two actions render as icon-only buttons at the icon tier, right of the ⌘K row', () => {
445
+ let created = 0
446
+ renderSidebar({
447
+ search: {
448
+ onOpen: () => {},
449
+ actions: [
450
+ { key: 'new', label: 'New note', icon: <span />, onClick: () => (created += 1) },
451
+ { key: 'filter', label: 'Filter', icon: <span /> },
452
+ ],
453
+ },
454
+ })
455
+ const newButton = screen.getByLabelText('New note')
456
+ expect(newButton.getAttribute('data-size')).toBe('icon')
457
+ expect(screen.getByLabelText('Filter')).toBeTruthy()
458
+ fireEvent.click(newButton)
459
+ expect(created).toBe(1)
460
+ })
461
+
462
+ test('an action with no icon falls back to its first grapheme, label as the accessible name', () => {
463
+ renderSidebar({
464
+ search: { onOpen: () => {}, actions: [{ key: 'new', label: 'New note' }] },
465
+ })
466
+ expect(screen.getByLabelText('New note').textContent).toBe('N')
467
+ })
468
+ })
469
+
470
+ describe('nav item active/ancestor state (useNav exclusivity — router-tanstack/use-nav.test.tsx)', () => {
471
+ test('only the active row carries aria-current="page"', () => {
472
+ renderSidebar({
473
+ sections: [
474
+ {
475
+ label: 'Main',
476
+ items: [
477
+ { key: 'home', label: 'Home', icon: null, active: true },
478
+ { key: 'charts', label: 'Charts', icon: null, active: false },
479
+ ],
480
+ },
481
+ ],
482
+ })
483
+ expect(screen.getByText('Home').closest('a')?.getAttribute('aria-current')).toBe('page')
484
+ expect(screen.getByText('Charts').closest('a')?.hasAttribute('aria-current')).toBe(false)
485
+ })
486
+
487
+ test('an ancestor row carries data-ancestor and no aria-current', () => {
488
+ renderSidebar({
489
+ sections: [
490
+ {
491
+ label: 'Main',
492
+ items: [
493
+ {
494
+ key: 'dashboard',
495
+ label: 'Dashboard',
496
+ icon: null,
497
+ active: false,
498
+ ancestor: true,
499
+ children: [{ key: 'sessions', label: 'Sessions', icon: null, active: true }],
500
+ },
501
+ ],
502
+ },
503
+ ],
504
+ })
505
+ const dashboardAnchor = screen.getByText('Dashboard').closest('a')
506
+ expect(dashboardAnchor?.hasAttribute('data-ancestor')).toBe(true)
507
+ expect(dashboardAnchor?.hasAttribute('aria-current')).toBe(false)
93
508
  })
94
509
  })