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
@@ -8,7 +8,10 @@
8
8
  * @example
9
9
  * import { ConnectivityIndicator } from 'basalt-ui'
10
10
  *
11
- * <BasaltShell globalActions={<ConnectivityIndicator />} {...rest} />
11
+ * <BasaltShell
12
+ * globalActions={[{ key: 'connectivity', node: <ConnectivityIndicator />, mobile: 'bar' }]}
13
+ * {...rest}
14
+ * />
12
15
  */
13
16
  import { ActionIcon, Badge, Divider, Group, Popover, Stack, Text } from '@mantine/core'
14
17
  import { useDisclosure } from '@mantine/hooks'
@@ -172,7 +175,10 @@ const ICON_COLOR: Record<ConnectivityStatus, string> = {
172
175
  * ConnectivityIndicator — clickable wifi icon that opens a per-signal detail popover.
173
176
  *
174
177
  * @example
175
- * <BasaltShell globalActions={<ConnectivityIndicator />} {...rest} />
178
+ * <BasaltShell
179
+ * globalActions={[{ key: 'connectivity', node: <ConnectivityIndicator />, mobile: 'bar' }]}
180
+ * {...rest}
181
+ * />
176
182
  */
177
183
  export function ConnectivityIndicator() {
178
184
  const [opened, { open, toggle, close }] = useDisclosure(false)
@@ -195,13 +201,14 @@ export function ConnectivityIndicator() {
195
201
  <ActionIcon
196
202
  variant="transparent"
197
203
  color={ICON_COLOR[status]}
198
- // A numeric size, not the named `"md"` — the theme's `ActionIcon.extend` `vars` (see
199
- // `theme/index.ts`) now re-points a `size="md"` ActionIcon at the density-tracking control-
200
- // height anchor (for a `size="md"` ActionIcon meant to match a `size="md"` Input/Button), so
201
- // this plain toolbar icon must opt OUT with an explicit number to keep Mantine's own static
202
- // `--ai-size-md` (28px at the 16px root) — unaffected by that override (`props.size` is a
203
- // number here, never the literal string `'md'`) and by the density knob.
204
- size={28}
204
+ // `ctl`, the control tier (`docs/CONTROLS-SPEC.md` §5) — this indicator's home is a header
205
+ // toolbar, and that is the tier a toolbar control is. It used to pin a literal `28` to opt
206
+ // OUT of the `size="md"` height (42px, sized for a form field), which was the right escape
207
+ // before the `ctl` tier existed; the cost was that the shell's globals rendered 2px short
208
+ // of every other control in the same 48px row. `--ai-size-ctl` is declared on `:root` by
209
+ // `cssVariablesResolver`, so this resolves with or without an enclosing `CtlSlot`, and it
210
+ // tracks the density knob like the rest of the tier.
211
+ size="ctl"
205
212
  onClick={toggle}
206
213
  onMouseEnter={scheduleOpen}
207
214
  onMouseLeave={scheduleClose}
@@ -7,7 +7,9 @@
7
7
  * remark-gfm + rehype-sanitize + remend, all optional peers) with its fence-renderer registry,
8
8
  * `MermaidDiagram` (beautiful-mermaid, optional peer), the MDX
9
9
  * component map, the docs-page frame (`ArticleLayout`), overview cards (`ArticleCard`/
10
- * `ArticleGrid`), and the contextual-help drawer (`GuideLink`/`GuideDrawer`). See
10
+ * `ArticleGrid`), the contextual-help drawer (`GuideLink`/`GuideDrawer`), and a re-export of the
11
+ * three `basalt-ui/controls` filters an article list needs (`FilterSet`/`ViewTabs`/
12
+ * `MultiSelectFilter`, which replaced the controlled `ArticleFilterBar`). See
11
13
  * `agent/rules/basalt-content.md` for the full doctrine + the content-collections recipe.
12
14
  */
13
15
  export { Prose } from './prose'
@@ -69,8 +71,19 @@ export type { Article, ArticleFilterQuery, ArticleOrder } from './article-model'
69
71
  export { toArticleActions } from './article-actions'
70
72
  export type { ToArticleActionsOptions } from './article-actions'
71
73
 
72
- export { ArticleFilterBar } from './article-filter-bar'
73
- export type { ArticleFilterBarProps } from './article-filter-bar'
74
+ // The article filter UI is no longer content's own: `ArticleFilterBar` (controlled,
75
+ // `value`/`onChange`, its own responsive twin) was replaced by the store-bound controls of
76
+ // `basalt-ui/controls` (`docs/CONTROLS-SPEC.md` §3, C2/C9). Re-exported here — not merely
77
+ // documented as moved — so a content-only consumer that never imports `./controls` still gets the
78
+ // three it needs from the surface it was already using. Both spellings are the same module: one
79
+ // component, two import paths.
80
+ export { FilterSet, MultiSelectFilter, ViewTabs } from '../controls'
81
+ export type {
82
+ FilterSetProps,
83
+ MultiSelectFilterProps,
84
+ ViewTabsOption,
85
+ ViewTabsProps,
86
+ } from '../controls'
74
87
 
75
88
  export { GuideDrawer, GuideLink } from './guide'
76
89
  export type { GuideDrawerProps, GuideLinkProps } from './guide'
@@ -32,37 +32,16 @@
32
32
  margin-bottom: 0;
33
33
  }
34
34
 
35
- /* Fixed-header clearance so an anchor-jumped/TOC-navigated heading isn't hidden under a sticky
36
- * header. RESPONSIVE (docs/CONTENT-SPEC.md §5): BasaltShell's AppShell header itself switches
37
- * height at the `sm` breakpoint (`shell/index.tsx`'s `header={{ height: { base, sm } }}`), so the
38
- * clearance must too — the default below is the DESKTOP value (`>= sm`, the common path); the
39
- * `max-width: 47.99375em` override below swaps in the MOBILE value. `47.99375em` is Mantine's own
40
- * complement of a `min-width: 48em` breakpoint (`em(px - 0.1)` — verified against the installed
41
- * `@mantine/core`'s `MantineClasses.mjs`, the same formula behind `visibleFrom`/`hiddenFrom`), NOT
42
- * an independently hand-picked value — a plain `47.99em` leaves a viewport band (`47.99em` to just
43
- * under `48em`) matched by NEITHER this rule nor AppShell's own `(min-width: 48em)` override.
44
- * `48em` is a STATIC assumption, not a value this CSS module can read live: it is Mantine's default
45
- * `theme.breakpoints.sm` (unmodified by `baseTheme`, `theme/index.ts`), and `shell/index.tsx` reads
46
- * that theme value directly for its own `min-width` media query — but a media query condition
47
- * cannot resolve a CSS custom property (`@media (min-width: var(--x))` is not valid CSS), so this
48
- * static module has no way to follow a consumer's `createBasaltTheme(overrides)` that overrides
49
- * `breakpoints.sm`. A consumer doing so desyncs this file's breakpoint from AppShell's actual
50
- * header-switch point — the failure mode is a heading that scrolls to a clearance tuned for the
51
- * WRONG header on the mismatched viewport band, not a total breakage. Both values also assume
52
- * BasaltShell's own AppShell header heights — a `./content` consumer rendering Prose standalone (no
53
- * BasaltShell) overrides `--vx-space-sticky-header-clearance`/`-mobile` itself. */
35
+ /* Sticky-header clearance for in-page anchors. One value on every viewport: since the
36
+ * controls waves the AppShell header is `appShellHeaderHeight` (48px) on phones too — the two-row
37
+ * mobile header and its `-mobile` clearance token are gone (docs/CONTROLS-SPEC.md §2.1, law C14).
38
+ * `--basalt-page-bar-h` (a sticky PageBar row 2, published by the shell) is added on top so a
39
+ * heading never lands under the filter row. A `./content` consumer rendering Prose standalone (no
40
+ * BasaltShell) overrides `--vx-space-sticky-header-clearance` itself. */
54
41
  .root h2,
55
42
  .root h3,
56
43
  .root h4 {
57
- scroll-margin-top: var(--vx-space-sticky-header-clearance);
58
- }
59
-
60
- @media (max-width: 47.99375em) {
61
- .root h2,
62
- .root h3,
63
- .root h4 {
64
- scroll-margin-top: var(--vx-space-sticky-header-clearance-mobile);
65
- }
44
+ scroll-margin-top: calc(var(--vx-space-sticky-header-clearance) + var(--basalt-page-bar-h, 0px));
66
45
  }
67
46
 
68
47
  /* Headings share family/weight/color across densities; size + margin differ below. */
@@ -15,7 +15,7 @@ import { describe, expect, it } from 'bun:test'
15
15
 
16
16
  const PROSE_CSS_PATH = resolve(dirname(fileURLToPath(import.meta.url)), 'prose.module.css')
17
17
 
18
- describe('prose.module.css — responsive sticky-header clearance split (Decision 3)', () => {
18
+ describe('prose.module.css — sticky-header clearance (one header height, plus the PageBar row)', () => {
19
19
  const css = readFileSync(PROSE_CSS_PATH, 'utf8')
20
20
 
21
21
  // The first (unconditional) `.root h2, .root h3, .root h4 { scroll-margin-top: … }` rule in the
@@ -25,31 +25,15 @@ describe('prose.module.css — responsive sticky-header clearance split (Decisio
25
25
  /\.root h2,\s*\.root h3,\s*\.root h4\s*\{\s*scroll-margin-top:\s*([^;]+);\s*\}/,
26
26
  )
27
27
 
28
- it('the base (non-media) rule clears the DESKTOP header', () => {
28
+ it('clears the header plus the sticky PageBar row on every viewport (one header height since C14)', () => {
29
29
  expect(baseRuleMatch).not.toBeNull()
30
- expect(baseRuleMatch?.[1]).toBe('var(--vx-space-sticky-header-clearance)')
31
- })
32
-
33
- // Requires the literal `max-width: 47.99375em` query wrapping the rule — fails if the query is
34
- // inverted to `min-width`, retuned to a different value, or the whole `@media` block is deleted.
35
- const mediaBlockMatch = css.match(
36
- /@media\s*\(max-width:\s*47\.99375em\)\s*\{[\s\S]*?\.root h2,\s*\.root h3,\s*\.root h4\s*\{\s*scroll-margin-top:\s*([^;]+);\s*\}\s*\}/,
37
- )
38
-
39
- it("the mobile override exists at Mantine's exact 48em min-width complement (47.99375em)", () => {
40
- expect(mediaBlockMatch).not.toBeNull()
41
- })
42
-
43
- it('the media override clears the MOBILE header, not the desktop one (catches a var swap)', () => {
44
- expect(mediaBlockMatch?.[1]).toBe('var(--vx-space-sticky-header-clearance-mobile)')
30
+ expect(baseRuleMatch?.[1]).toBe(
31
+ 'calc(var(--vx-space-sticky-header-clearance) + var(--basalt-page-bar-h, 0px))',
32
+ )
45
33
  })
46
34
 
47
- it('the base rule appears before the override in source order — both share (0,2,1) specificity, so a same-specificity tie resolves by source order regardless of the @media nesting; an override placed BEFORE the base rule would lose to it even while the media query matches', () => {
48
- const baseIndex = css.indexOf('scroll-margin-top: var(--vx-space-sticky-header-clearance);')
49
- const mobileIndex = css.indexOf(
50
- 'scroll-margin-top: var(--vx-space-sticky-header-clearance-mobile);',
51
- )
52
- expect(baseIndex).toBeGreaterThan(-1)
53
- expect(mobileIndex).toBeGreaterThan(baseIndex)
35
+ it('has no mobile override — the -mobile clearance token no longer exists', () => {
36
+ expect(css).not.toContain('clearance-mobile')
37
+ expect(css).not.toContain('@media (max-width: 47.99375em)')
54
38
  })
55
39
  })
@@ -0,0 +1,62 @@
1
+ /*
2
+ * `OverflowMenu` row chrome. Rows are `ctl`-tier (30px) on desktop and 44px below `sm`
3
+ * (`--vx-space-sheet-row-height`, law C15) — the mobile kebab IS the sheet, so there is no second
4
+ * component for the touch case.
5
+ */
6
+
7
+ .menuItem {
8
+ min-height: calc(var(--vx-space-control-height-ctl) * var(--mantine-scale, 1));
9
+ }
10
+
11
+ /*
12
+ * Danger rows read the status token, not a Mantine shade — `--vx-bad-solid` resolves per color
13
+ * scheme, a pinned `red.6` does not (see the `mantine-shade-index` guard).
14
+ */
15
+ .menuItemDanger {
16
+ color: var(--vx-bad-solid);
17
+ }
18
+
19
+ /* A `kind: 'custom'` row hosts the consumer's own node, so it only supplies the row box. */
20
+ .customRow {
21
+ display: flex;
22
+ align-items: center;
23
+ min-height: calc(var(--vx-space-control-height-ctl) * var(--mantine-scale, 1));
24
+ padding: 0 var(--vx-space-row-inset-x);
25
+ }
26
+
27
+ @media (max-width: 47.99375em) {
28
+ .menuItem,
29
+ .customRow {
30
+ min-height: var(--vx-space-sheet-row-height);
31
+ }
32
+ }
33
+
34
+ /*
35
+ * A `kind: 'custom'` node's slot — see `BarEntry`'s doc. The whole point is the HEIGHT: basalt does
36
+ * not draw the node, so the only thing it can guarantee is the box it sits in. `inline-flex` +
37
+ * `align-items: center` centres a 20px Badge inside the 30px tier instead of letting it sit on the
38
+ * row's baseline, and `flex: none` keeps it out of the row's own shrink arithmetic.
39
+ */
40
+ .customSlot {
41
+ display: inline-flex;
42
+ flex: none;
43
+ align-items: center;
44
+ height: calc(var(--vx-space-control-height-ctl) * var(--mantine-scale, 1));
45
+ }
46
+
47
+ /*
48
+ * The fold's own two glyphs, sized by overriding the icon slot's box var — never by the `width`
49
+ * attribute on the SVG, which is a presentation attribute the slot's CSS outranks anyway. The kebab
50
+ * reads at 18px because it is the ENTIRE content of an icon button (there is no label beside it to
51
+ * balance against), while `More`'s trailing chevron is the same 14px every pill's trailing
52
+ * affordance takes (`controls.module.css`'s `.glyph`).
53
+ */
54
+ .kebabIcon {
55
+ --vx-space-icon-size: 1.125rem;
56
+ }
57
+
58
+ .chevronIcon {
59
+ --vx-space-icon-size: 0.875rem;
60
+
61
+ color: var(--vx-faint);
62
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
+ * signature — so dot access stays verbatim under the package's strict flags.
4
+ */
5
+ declare const classes: {
6
+ readonly menuItem: string
7
+ readonly menuItemDanger: string
8
+ readonly customRow: string
9
+ readonly customSlot: string
10
+ readonly kebabIcon: string
11
+ readonly chevronIcon: string
12
+ }
13
+ export default classes
@@ -0,0 +1,252 @@
1
+ /**
2
+ * `ActionGroup`'s fold and its mobile projection — the two behaviours that make `BarAction[]` data
3
+ * rather than a `ReactNode` row (`docs/CONTROLS-SPEC.md` §2.1, laws C6/C7/C9).
4
+ *
5
+ * Both variants of the group are mounted at once (the swap is CSS, law C9), so every assertion here
6
+ * scopes itself to one variant. A CSS module resolves to `''` under `bun test`, so the variants are
7
+ * told apart by Mantine's own `visibleFrom`/`hiddenFrom` utility classes, which are stable public
8
+ * API (`mantine-visible-from-sm` / `mantine-hidden-from-sm`).
9
+ */
10
+ import { MantineProvider } from '@mantine/core'
11
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
12
+ import { describe, expect, test } from 'bun:test'
13
+ import type { ReactNode } from 'react'
14
+ import {
15
+ ActionGroup,
16
+ BarActionRow,
17
+ BarExtrasProvider,
18
+ OverflowMenu,
19
+ barActionMobile,
20
+ globalActionMobile,
21
+ } from './actions'
22
+ import type { BarAction, BarExtras } from './actions'
23
+
24
+ function renderGroup(props: Parameters<typeof ActionGroup>[0]) {
25
+ return render(
26
+ <MantineProvider>
27
+ <ActionGroup {...props} />
28
+ </MantineProvider>,
29
+ )
30
+ }
31
+
32
+ const desktop = () => document.querySelector('.mantine-visible-from-sm')
33
+ const mobile = () => document.querySelector('.mantine-hidden-from-sm')
34
+
35
+ const secondary = (n: number): BarAction[] =>
36
+ Array.from({ length: n }, (_, i) => ({ key: `s${i}`, label: `Second ${i}` }))
37
+
38
+ describe('ActionGroup — desktop', () => {
39
+ test('the primary renders filled, the secondaries default', () => {
40
+ renderGroup({ primary: { key: 'new', label: 'New run' }, secondary: secondary(1) })
41
+ const row = desktop()
42
+ expect(row).not.toBeNull()
43
+ const primary = row?.querySelector('[data-variant="filled"]')
44
+ expect(primary?.textContent).toBe('New run')
45
+ expect(row?.querySelector('[data-variant="default"]')?.textContent).toBe('Second 0')
46
+ })
47
+
48
+ test('three secondaries render inline with no More menu', () => {
49
+ renderGroup({ secondary: secondary(3) })
50
+ const row = desktop()
51
+ expect(row?.querySelectorAll('[data-variant="default"]').length).toBe(3)
52
+ expect(row?.textContent).not.toContain('More')
53
+ })
54
+
55
+ test('a fourth secondary folds into ONE More menu instead of widening the row (law C7)', () => {
56
+ renderGroup({ secondary: secondary(5) })
57
+ const row = desktop()
58
+ // Three inline + the `More` trigger, which is itself a `default` button.
59
+ expect(row?.querySelectorAll('[data-variant="default"]').length).toBe(4)
60
+ expect(row?.textContent).toContain('More')
61
+ expect(screen.getAllByRole('button', { name: 'More' }).length).toBe(1)
62
+ })
63
+
64
+ test("a kind: 'menu' action never takes bar width — it folds even as the only secondary", () => {
65
+ renderGroup({
66
+ secondary: [{ key: 'more', kind: 'menu', label: 'Export as', items: secondary(2) }],
67
+ })
68
+ expect(desktop()?.textContent).toContain('More')
69
+ })
70
+ })
71
+
72
+ describe('ActionGroup — mobile', () => {
73
+ test('the primary rides the bar and the rest fold into one kebab', () => {
74
+ renderGroup({ primary: { key: 'new', label: 'New run' }, secondary: secondary(4) })
75
+ const row = mobile()
76
+ expect(row).not.toBeNull()
77
+ expect(row?.textContent).toContain('New run')
78
+ const kebabs = row?.querySelectorAll('[aria-label="More actions"]')
79
+ expect(kebabs?.length).toBe(1)
80
+ })
81
+
82
+ test('a primary WITH an icon becomes an icon button, named by its label', () => {
83
+ renderGroup({ primary: { key: 'new', label: 'New run', icon: <span>+</span> } })
84
+ const button = mobile()?.querySelector('[aria-label="New run"]')
85
+ expect(button).not.toBeNull()
86
+ // The label is the accessible name only — it is never painted beside the glyph.
87
+ expect(button?.textContent).toBe('+')
88
+ })
89
+
90
+ test('an icon-LESS primary keeps its label instead of drawing a first-letter avatar', () => {
91
+ // `N` for `New run` is an avatar: a glyph whose meaning has to be known in advance. The label is
92
+ // wider and says what the button does, and the breadcrumb beside it truncates to make room.
93
+ renderGroup({ primary: { key: 'new', label: 'New run' } })
94
+ const row = mobile()
95
+ expect(row?.textContent).toContain('New run')
96
+ expect(row?.textContent).not.toBe('N')
97
+ })
98
+
99
+ test("mobile: 'hidden' drops the action from the bar AND from the kebab", () => {
100
+ renderGroup({ secondary: [{ key: 'x', label: 'Nowhere', mobile: 'hidden' }] })
101
+ const row = mobile()
102
+ expect(row).toBeNull()
103
+ // Still on desktop, though — `mobile` only decides the small-viewport placement.
104
+ expect(desktop()?.textContent).toContain('Nowhere')
105
+ })
106
+
107
+ test("mobile: 'bar' promotes a secondary out of the kebab and onto the bar", () => {
108
+ renderGroup({ secondary: [{ key: 'live', label: 'Live', mobile: 'bar' }] })
109
+ // Icon-less, so it takes the labelled form — an ActionIcon with no icon would be an empty box.
110
+ expect(mobile()?.textContent).toContain('Live')
111
+ expect(mobile()?.querySelector('[aria-label="More actions"]')).toBeNull()
112
+ })
113
+
114
+ test("an icon-bearing mobile: 'bar' secondary is the icon form, named by its label", () => {
115
+ renderGroup({
116
+ secondary: [{ key: 'live', label: 'Live', mobile: 'bar', icon: <span>●</span> }],
117
+ })
118
+ expect(mobile()?.querySelector('[aria-label="Live"]')).not.toBeNull()
119
+ })
120
+ })
121
+
122
+ describe('the mobile placement law', () => {
123
+ test('a primary rides the bar, everything else folds', () => {
124
+ const action: BarAction = { key: 'a', label: 'A' }
125
+ expect(barActionMobile(action, true)).toBe('bar')
126
+ expect(barActionMobile(action, false)).toBe('more')
127
+ })
128
+
129
+ test('an explicit `mobile` wins over both defaults', () => {
130
+ expect(barActionMobile({ key: 'a', label: 'A', mobile: 'more' }, true)).toBe('more')
131
+ expect(barActionMobile({ key: 'a', label: 'A', mobile: 'bar' }, false)).toBe('bar')
132
+ })
133
+
134
+ test("a kind: 'menu' group is always a More row, never a bar slot", () => {
135
+ expect(barActionMobile({ key: 'm', kind: 'menu', label: 'M', items: [] }, true)).toBe('more')
136
+ })
137
+
138
+ test('the first two global actions ride the bar, the rest fold', () => {
139
+ const global = { key: 'g', node: null }
140
+ expect(globalActionMobile(global, 0)).toBe('bar')
141
+ expect(globalActionMobile(global, 1)).toBe('bar')
142
+ expect(globalActionMobile(global, 2)).toBe('more')
143
+ expect(globalActionMobile({ ...global, mobile: 'hidden' }, 0)).toBe('hidden')
144
+ })
145
+ })
146
+
147
+ describe('OverflowMenu', () => {
148
+ test('renders nothing for an empty action list — an empty home renders nothing (law C14)', () => {
149
+ render(
150
+ <MantineProvider>
151
+ <OverflowMenu actions={[]} />
152
+ </MantineProvider>,
153
+ )
154
+ expect(document.querySelector('button')).toBeNull()
155
+ })
156
+
157
+ test('the trigger is a labelled button on desktop and a named icon button as a kebab', () => {
158
+ const { unmount } = render(
159
+ <MantineProvider>
160
+ <OverflowMenu actions={secondary(1)} />
161
+ </MantineProvider>,
162
+ )
163
+ expect(screen.getByRole('button', { name: 'More' })).not.toBeNull()
164
+ unmount()
165
+
166
+ render(
167
+ <MantineProvider>
168
+ <OverflowMenu actions={secondary(1)} trigger="kebab" label="More actions" />
169
+ </MantineProvider>,
170
+ )
171
+ expect(screen.getByRole('button', { name: 'More actions' })).not.toBeNull()
172
+ })
173
+ })
174
+
175
+ /**
176
+ * The `host` scoping (`BarActionRowProps.host`). The shell's `mobile: 'more'` global actions belong
177
+ * to ONE kebab, and the leak that made this a law was that every `ActionGroup` read the context:
178
+ * `PageBar.filtersEnd` and any consumer-mounted tier-2 group each grew a second kebab holding a
179
+ * duplicate of the global node, and each took the claim that decides whether the shell renders its
180
+ * own.
181
+ */
182
+ describe('shell kebab extras are scoped to the page bar', () => {
183
+ const GLOBAL_ROW: BarAction = { key: 'g', kind: 'custom', node: <span data-testid="global" /> }
184
+
185
+ function renderWithExtras(node: ReactNode) {
186
+ const claims: number[] = []
187
+ const extras: BarExtras = {
188
+ mobileMoreActions: [GLOBAL_ROW],
189
+ claimKebab: () => {
190
+ claims.push(1)
191
+ return () => claims.push(-1)
192
+ },
193
+ }
194
+ const result = render(
195
+ <MantineProvider>
196
+ <BarExtrasProvider value={extras}>{node}</BarExtrasProvider>
197
+ </MantineProvider>,
198
+ )
199
+ return { ...result, claims }
200
+ }
201
+
202
+ test('a public ActionGroup takes neither the global rows nor the claim', async () => {
203
+ const { claims } = renderWithExtras(
204
+ <ActionGroup secondary={[{ key: 'a', label: 'Own action' }]} />,
205
+ )
206
+ expect(claims).toHaveLength(0)
207
+ // Its own kebab exists (its secondary defaults to `more`) but holds only its own row.
208
+ fireEvent.click(screen.getByLabelText('More actions'))
209
+ await waitFor(() => expect(screen.getByRole('menuitem', { name: 'Own action' })).toBeDefined())
210
+ expect(screen.queryByTestId('global')).toBeNull()
211
+ })
212
+
213
+ test('the page-bar row-1 group DOES take them — it is the one instance entitled to', async () => {
214
+ const { claims } = renderWithExtras(
215
+ <BarActionRow host="page" secondary={[{ key: 'a', label: 'Own action' }]} />,
216
+ )
217
+ expect(claims).toEqual([1])
218
+ fireEvent.click(screen.getByLabelText('More actions'))
219
+ await waitFor(() => expect(screen.getByTestId('global')).toBeDefined())
220
+ })
221
+
222
+ test("viewport: 'desktop' renders no mobile half at all — no kebab, no claim", () => {
223
+ const { claims } = renderWithExtras(
224
+ <BarActionRow host="page" viewport="desktop" secondary={[{ key: 'a', label: 'Row 2' }]} />,
225
+ )
226
+ expect(claims).toHaveLength(0)
227
+ expect(document.querySelector('.mantine-hidden-from-sm')).toBeNull()
228
+ expect(desktop()?.textContent).toContain('Row 2')
229
+ })
230
+
231
+ test('mobileOnly actions join the mobile kebab without widening the desktop row', async () => {
232
+ renderWithExtras(
233
+ <BarActionRow
234
+ host="page"
235
+ secondary={[{ key: 'a', label: 'Own action' }]}
236
+ mobileOnly={[{ key: 'metrics', label: 'Manage metrics' }]}
237
+ />,
238
+ )
239
+ expect(desktop()?.textContent).not.toContain('Manage metrics')
240
+ fireEvent.click(screen.getByLabelText('More actions'))
241
+ await waitFor(() =>
242
+ expect(screen.getByRole('menuitem', { name: 'Manage metrics' })).toBeDefined(),
243
+ )
244
+ })
245
+
246
+ test("a mobileOnly action marked 'bar' renders inline on the mobile bar", () => {
247
+ renderWithExtras(
248
+ <BarActionRow host="page" mobileOnly={[{ key: 'm', label: 'Metrics', mobile: 'bar' }]} />,
249
+ )
250
+ expect(mobile()?.textContent).toContain('Metrics')
251
+ })
252
+ })