basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,655 @@
1
+ /**
2
+ * `BarAction` — the one typed vocabulary for an action in a home (`docs/CONTROLS-SPEC.md` §2.1),
3
+ * plus the two components that project it: `ActionGroup` (a home's `actions` slot) and
4
+ * `OverflowMenu` (the `More` / kebab fold).
5
+ *
6
+ * The point of typing actions as DATA rather than taking a `ReactNode` row is law C7: a home never
7
+ * scrolls horizontally and never wraps, so the overflow fold has to be COMPUTED by basalt. A
8
+ * `ReactNode` row can only be clipped or scrolled; a `BarAction[]` can be split into "inline" and
9
+ * "in the More menu" without the caller knowing the breakpoint.
10
+ *
11
+ * The desktop/mobile swap belongs to this control, never to the caller (law C9): both variants are
12
+ * rendered, once each, and Mantine's `visibleFrom`/`hiddenFrom` decides which one paints. No JS
13
+ * media query, so there is no first-paint flash and no hook that re-renders on resize.
14
+ */
15
+ import { ActionIcon, Button, Group, Menu } from '@mantine/core'
16
+ import { createContext, useContext, useEffect } from 'react'
17
+ import type { ReactNode } from 'react'
18
+ import type { NavAnchor } from '../nav/types'
19
+ import type { AnyNavLink } from '../router-tanstack/nav'
20
+ import { IconSlot } from '../theme/icon-slot'
21
+ import { ControlGroup } from './control-group'
22
+ import classes from './actions.module.css'
23
+
24
+ /**
25
+ * A button-shaped action. `Anchor` is the router seam basalt HOSTS (the same seam `SidebarItem`
26
+ * uses — pass the one `useNav` builds, or your router's `Link`); `link` is the no-router fallback
27
+ * and only its `to` is honoured, since resolving `search`/`params` needs a router. Supply `Anchor`
28
+ * whenever the destination carries either.
29
+ */
30
+ export type BarActionItem = {
31
+ key: string
32
+ label: string
33
+ kind?: undefined
34
+ icon?: ReactNode
35
+ onClick?: () => void
36
+ link?: AnyNavLink
37
+ Anchor?: NavAnchor
38
+ disabled?: boolean
39
+ loading?: boolean
40
+ danger?: boolean
41
+ /**
42
+ * Joins this action to its adjacent `group: true` siblings into ONE `ControlGroup` — shared
43
+ * borders, radius on the outer ends only, no gap. Declare it on every member of the run: a set of
44
+ * three where the middle one omits it is two runs of one, which is the same row it was before.
45
+ *
46
+ * It is an opt-in because "these act on the same thing" is a fact only the caller has. A `‹ Today ›`
47
+ * stepper is the shape; three independent actions that happen to sit beside each other are not, and
48
+ * joining those would claim a relationship the reader would then have to un-learn.
49
+ */
50
+ group?: true
51
+ /** Mobile placement. @default 'bar' for `primary`, `'more'` for everything else */
52
+ mobile?: 'bar' | 'more' | 'hidden'
53
+ }
54
+
55
+ /** A nested group. Always folds into the `More` menu on both viewports — it never takes bar width. */
56
+ export type BarActionMenu = {
57
+ key: string
58
+ kind: 'menu'
59
+ label: string
60
+ icon?: ReactNode
61
+ items: BarAction[]
62
+ }
63
+
64
+ /** An escape hatch for a control basalt does not model (a live chip, a consumer's own widget). */
65
+ export type BarActionCustom = {
66
+ key: string
67
+ kind: 'custom'
68
+ node: ReactNode
69
+ mobile?: 'bar' | 'more' | 'hidden'
70
+ }
71
+
72
+ export type BarAction = BarActionItem | BarActionMenu | BarActionCustom
73
+
74
+ /**
75
+ * A home's `actions` slot. `primary` is SINGULAR by type — that is the whole enforcement of law
76
+ * C6's "exactly one primary"; there is no runtime check because a second one cannot be written.
77
+ */
78
+ export type ActionGroupProps = {
79
+ primary?: BarAction
80
+ secondary?: BarAction[]
81
+ }
82
+
83
+ /**
84
+ * A shell-owned, persistent header action (timer, notifications, a global `SyncButton`).
85
+ * `node` is rendered as-is — basalt owns only the placement.
86
+ *
87
+ * Below `sm` a `'more'` action renders inside the header's single kebab, which means its `node` is
88
+ * mounted a second time there (lazily — a Mantine `Menu.Dropdown` mounts its children on open).
89
+ * Keep `'more'` for self-contained nodes and give anything holding live state `mobile: 'bar'`,
90
+ * which renders exactly once.
91
+ */
92
+ export type GlobalAction = {
93
+ key: string
94
+ node: ReactNode
95
+ /** @default 'bar' for the first two, `'more'` for the rest */
96
+ mobile?: 'bar' | 'more' | 'hidden'
97
+ }
98
+
99
+ /** Secondaries rendered inline on desktop; the rest fold into `More` (`docs/CONTROLS-SPEC.md` §2.1). */
100
+ export const DESKTOP_SECONDARY_MAX = 3
101
+
102
+ /** `GlobalAction`s without an explicit `mobile` that still land on the mobile bar. */
103
+ export const MOBILE_GLOBAL_BAR_MAX = 2
104
+
105
+ /**
106
+ * What the shell hands the page's `ActionGroup` so the header ends up with ONE kebab instead of
107
+ * two: the shell's `mobile: 'more'` global actions (already mapped to `kind: 'custom'` rows), and
108
+ * a claim the mobile kebab takes on mount. While a page's kebab holds the claim, the shell renders
109
+ * no kebab of its own.
110
+ *
111
+ * Lives here, in the lowest layer, so `src/shell/**` imports downward into `src/controls/**` and
112
+ * never the reverse — a context declared in the shell would make `./controls` pull the shell in.
113
+ */
114
+ export type BarExtras = {
115
+ mobileMoreActions: readonly BarAction[]
116
+ /** Claims the single mobile kebab. Returns the release, so it works straight out of an effect. */
117
+ claimKebab: () => () => void
118
+ }
119
+
120
+ const NO_EXTRAS: BarExtras = {
121
+ mobileMoreActions: [],
122
+ claimKebab: () => () => {},
123
+ }
124
+
125
+ const BarExtrasContext = createContext<BarExtras>(NO_EXTRAS)
126
+
127
+ /** Internal — mounted by `BasaltShell` (via `PageBarProvider`), never by a consumer. */
128
+ export function BarExtrasProvider({
129
+ value,
130
+ children,
131
+ }: {
132
+ value: BarExtras
133
+ children: ReactNode
134
+ }): ReactNode {
135
+ return <BarExtrasContext.Provider value={value}>{children}</BarExtrasContext.Provider>
136
+ }
137
+
138
+ /** Resolved mobile placement of one action. A `kind: 'menu'` group is always a `More` row. */
139
+ export function barActionMobile(action: BarAction, isPrimary: boolean): 'bar' | 'more' | 'hidden' {
140
+ if (action.kind === 'menu') return 'more'
141
+ return action.mobile ?? (isPrimary ? 'bar' : 'more')
142
+ }
143
+
144
+ /** `GlobalAction[]` → the same placement law, with "the first two ride the bar" as the default. */
145
+ export function globalActionMobile(action: GlobalAction, index: number): 'bar' | 'more' | 'hidden' {
146
+ return action.mobile ?? (index < MOBILE_GLOBAL_BAR_MAX ? 'bar' : 'more')
147
+ }
148
+
149
+ /** A `GlobalAction` reaching a `BarAction` slot — the kebab renders one row shape, not two. */
150
+ export function globalActionAsBarAction(action: GlobalAction): BarActionCustom {
151
+ return { key: action.key, kind: 'custom', node: action.node, mobile: 'more' }
152
+ }
153
+
154
+ function DotsGlyph() {
155
+ return (
156
+ <svg width={18} height={18} viewBox="0 0 24 24" fill="currentColor" aria-hidden>
157
+ <circle cx={12} cy={5} r={1.75} />
158
+ <circle cx={12} cy={12} r={1.75} />
159
+ <circle cx={12} cy={19} r={1.75} />
160
+ </svg>
161
+ )
162
+ }
163
+
164
+ function ChevronGlyph() {
165
+ return (
166
+ <svg width={14} height={14} viewBox="0 0 24 24" fill="none" aria-hidden>
167
+ <path
168
+ d="M6 9l6 6 6-6"
169
+ stroke="currentColor"
170
+ strokeWidth={2}
171
+ strokeLinecap="round"
172
+ strokeLinejoin="round"
173
+ />
174
+ </svg>
175
+ )
176
+ }
177
+
178
+ /** The compact labelled mobile bar entry's horizontal inset — see {@link MobileBarEntry}. */
179
+ const MOBILE_PRIMARY_PADDING_X = 10
180
+
181
+ /**
182
+ * The gap between two adjacent controls in a bar row — `SPACE_STEP.controlGap`'s level-0 value, the
183
+ * same 6px `--vx-space-control-gap` gives the pill row. A NUMBER and not `gap="xs"`: Mantine's `xs`
184
+ * is 11px, the BODY tier's gap, which made the header's own row looser than the filter row directly
185
+ * under it. `Group`'s `gap` takes a raw px number, so there is no spacing key that resolves to 6.
186
+ */
187
+ const BAR_GAP = 6
188
+
189
+ /**
190
+ * One action as a button. No `size` prop anywhere: the enclosing `CtlSlot` (mounted by the home,
191
+ * never by the action) resolves the `ctl` tier — law C5.
192
+ */
193
+ function BarButton({
194
+ action,
195
+ variant,
196
+ px,
197
+ }: {
198
+ action: BarActionItem
199
+ variant: 'filled' | 'default'
200
+ /** Overrides the tier's horizontal inset — only the compact mobile primary passes it. */
201
+ px?: number
202
+ }): ReactNode {
203
+ const shared = {
204
+ variant,
205
+ ...(px !== undefined && { px }),
206
+ disabled: action.disabled === true,
207
+ loading: action.loading === true,
208
+ // EVERY icon in this file goes through `IconSlot` — the box is the framework's, never the
209
+ // caller's glyph (see `theme/icon-slot.tsx`). Mantine's `leftSection` is a bare flex box with no
210
+ // size of its own, so a consumer's `width="24"` SVG used to set this row's height.
211
+ ...(action.icon !== undefined && { leftSection: <IconSlot>{action.icon}</IconSlot> }),
212
+ ...(action.danger === true && { color: 'red' }),
213
+ ...(action.onClick !== undefined && { onClick: action.onClick }),
214
+ }
215
+ if (action.Anchor !== undefined) {
216
+ return (
217
+ <Button component={action.Anchor} {...shared}>
218
+ {action.label}
219
+ </Button>
220
+ )
221
+ }
222
+ if (action.link !== undefined) {
223
+ return (
224
+ <Button component="a" href={action.link.to} {...shared}>
225
+ {action.label}
226
+ </Button>
227
+ )
228
+ }
229
+ return <Button {...shared}>{action.label}</Button>
230
+ }
231
+
232
+ /** The mobile form of a bar action: icon only, the label demoted to the accessible name. */
233
+ function BarIconButton({
234
+ action,
235
+ variant,
236
+ }: {
237
+ action: BarActionItem
238
+ /** `default` is the bordered desktop form — a joined `ControlGroup` member (see {@link BarEntry}). */
239
+ variant: 'filled' | 'subtle' | 'default'
240
+ }): ReactNode {
241
+ const shared = {
242
+ variant,
243
+ 'aria-label': action.label,
244
+ disabled: action.disabled === true,
245
+ loading: action.loading === true,
246
+ ...(action.danger === true && { color: 'red' }),
247
+ ...(action.onClick !== undefined && { onClick: action.onClick }),
248
+ }
249
+ const body = <IconSlot>{action.icon}</IconSlot>
250
+ if (action.Anchor !== undefined) {
251
+ return (
252
+ <ActionIcon component={action.Anchor} {...shared}>
253
+ {body}
254
+ </ActionIcon>
255
+ )
256
+ }
257
+ if (action.link !== undefined) {
258
+ return (
259
+ <ActionIcon component="a" href={action.link.to} {...shared}>
260
+ {body}
261
+ </ActionIcon>
262
+ )
263
+ }
264
+ return <ActionIcon {...shared}>{body}</ActionIcon>
265
+ }
266
+
267
+ function MenuRow({ action }: { action: BarActionItem }): ReactNode {
268
+ const shared = {
269
+ className:
270
+ action.danger === true ? `${classes.menuItem} ${classes.menuItemDanger}` : classes.menuItem,
271
+ disabled: action.disabled === true,
272
+ ...(action.icon !== undefined && { leftSection: <IconSlot>{action.icon}</IconSlot> }),
273
+ ...(action.onClick !== undefined && { onClick: action.onClick }),
274
+ }
275
+ if (action.Anchor !== undefined && action.disabled !== true) {
276
+ return (
277
+ <Menu.Item component={action.Anchor} {...shared}>
278
+ {action.label}
279
+ </Menu.Item>
280
+ )
281
+ }
282
+ if (action.link !== undefined && action.disabled !== true) {
283
+ return (
284
+ <Menu.Item component="a" href={action.link.to} {...shared}>
285
+ {action.label}
286
+ </Menu.Item>
287
+ )
288
+ }
289
+ return <Menu.Item {...shared}>{action.label}</Menu.Item>
290
+ }
291
+
292
+ /** Depth-first: a `kind: 'menu'` group becomes a label plus its items, never a nested dropdown. */
293
+ function menuRows(actions: readonly BarAction[]): ReactNode[] {
294
+ return actions.flatMap((action): ReactNode[] => {
295
+ if (action.kind === 'menu') {
296
+ return [
297
+ <Menu.Label key={`${action.key}-label`}>{action.label}</Menu.Label>,
298
+ ...menuRows(action.items),
299
+ ]
300
+ }
301
+ if (action.kind === 'custom') {
302
+ return [
303
+ <div key={action.key} className={classes.customRow}>
304
+ {action.node}
305
+ </div>,
306
+ ]
307
+ }
308
+ return [<MenuRow key={action.key} action={action} />]
309
+ })
310
+ }
311
+
312
+ export type OverflowMenuProps = {
313
+ actions: readonly BarAction[]
314
+ /**
315
+ * `more` — the labelled desktop `More` button. `kebab` — the mobile ⋯ icon button.
316
+ * @default 'more'
317
+ */
318
+ trigger?: 'more' | 'kebab'
319
+ /** Trigger label (and the kebab's accessible name). @default 'More' */
320
+ label?: string
321
+ }
322
+
323
+ /**
324
+ * The overflow fold — the only place an action that doesn't fit goes (law C7). Rows are 44px below
325
+ * `sm` (`--vx-space-sheet-row-height`), so the mobile kebab is already a touch surface without a
326
+ * second, sheet-shaped component.
327
+ */
328
+ export function OverflowMenu({
329
+ actions,
330
+ trigger = 'more',
331
+ label = 'More',
332
+ }: OverflowMenuProps): ReactNode {
333
+ if (actions.length === 0) return null
334
+ return (
335
+ <Menu
336
+ position="bottom-end"
337
+ offset={6}
338
+ withinPortal
339
+ // AppShell itself sits at z-index 100 (`getDefaultZIndex('app')`), so the menu must clear it.
340
+ zIndex={400}
341
+ closeOnItemClick
342
+ closeOnClickOutside
343
+ closeOnEscape
344
+ trapFocus
345
+ returnFocus
346
+ // Required for a menu following the WAI-ARIA disclosure pattern — Mantine's own note on the
347
+ // prop. Without it every row is `tabindex="-1"`.
348
+ menuItemTabIndex={0}
349
+ >
350
+ <Menu.Target>
351
+ {trigger === 'kebab' ? (
352
+ // `size="ctl"` explicitly, not inherited: the kebab is rendered by `HeaderGlobalActions`
353
+ // too, which is shell chrome rather than a home's slot, so there is no `CtlSlot` above it
354
+ // there. 28px beside 30px buttons is the mismatch the header read as.
355
+ <ActionIcon variant="subtle" size="ctl" aria-label={label}>
356
+ <IconSlot className={classes.kebabIcon}>
357
+ <DotsGlyph />
358
+ </IconSlot>
359
+ </ActionIcon>
360
+ ) : (
361
+ <Button
362
+ variant="default"
363
+ rightSection={
364
+ <IconSlot className={classes.chevronIcon}>
365
+ <ChevronGlyph />
366
+ </IconSlot>
367
+ }
368
+ >
369
+ {label}
370
+ </Button>
371
+ )}
372
+ </Menu.Target>
373
+ <Menu.Dropdown>{menuRows(actions)}</Menu.Dropdown>
374
+ </Menu>
375
+ )
376
+ }
377
+
378
+ /**
379
+ * Internal — the shared projection behind `ActionGroup` (and `PageBar`'s two rows).
380
+ *
381
+ * `host` is the whole reason this is not just `ActionGroup`. The shell's `mobile: 'more'` global
382
+ * actions must land in EXACTLY ONE kebab, and the only instance entitled to them is `PageBar`'s
383
+ * row 1. When every `ActionGroup` read `BarExtrasContext`, a page with `filtersEnd` (or any
384
+ * consumer composing an `ActionGroup` into a `Section`/`ChartCard` `actions` slot) grew a SECOND
385
+ * kebab that re-mounted the shell's global nodes and stole the claim from the shell's own fallback.
386
+ * `host: 'slot'` — the public `ActionGroup`'s value — gets `NO_EXTRAS`: no global rows, no claim.
387
+ */
388
+ type BarActionRowProps = ActionGroupProps & {
389
+ /** `page` = `PageBar` row 1, the one instance that reads the shell extras and takes the claim. */
390
+ host: 'page' | 'slot'
391
+ /**
392
+ * `desktop` renders the desktop variant only — `PageBar` row 2, whose items live in the row-1
393
+ * kebab below `sm` (spec §2.1: mobile row 2 is tabs + the first pill + `Filters (n)`, nothing
394
+ * else). @default 'both'
395
+ */
396
+ viewport?: 'both' | 'desktop'
397
+ /**
398
+ * Actions projected onto the MOBILE variant only, never the desktop row — `PageBar.filtersEnd`,
399
+ * whose desktop home is row 2. Each still obeys its own `mobile` placement, so a `'bar'` one
400
+ * becomes an inline icon button and a `'hidden'` one stays desktop-only.
401
+ */
402
+ mobileOnly?: readonly BarAction[]
403
+ /**
404
+ * The home's `SyncButton`, mounted ONCE between the secondaries and the primary — see the ordering
405
+ * note in the body. Only `PageBar` row 1 passes it; a home with no sync passes nothing and the
406
+ * desktop row stays a single group.
407
+ */
408
+ syncNode?: ReactNode
409
+ }
410
+
411
+ /**
412
+ * Splits a bar row into runs, where a run of length > 1 is rendered as one `ControlGroup`.
413
+ *
414
+ * Two triggers, and they are the same rule read on two viewports (`docs/CONTROLS-SPEC.md` §3):
415
+ *
416
+ * - **`group: true`** — declared adjacency. The caller states that these act on one thing, so they
417
+ * are joined on both viewports.
418
+ * - **Adjacent ICON-ONLY entries on the mobile bar** (`viewport: 'mobile'`). Below `sm` a
419
+ * `BarActionItem` carrying an icon renders as an `ActionIcon` and nothing else (`MobileBarEntry`),
420
+ * so two of them in a row are two 30px squares with a 6px gap — three boxes' worth of border for
421
+ * two actions, in the one place width is scarce. This half is DERIVED from what is actually
422
+ * rendered, not declared, which is why it cannot be a prop: the caller does not know which of its
423
+ * actions the phone form draws icon-only.
424
+ *
425
+ * `excludeKey` is the primary's, and it always breaks the run: a `filled` primary joined to a
426
+ * `default`/`subtle` sibling puts a fill edge against a border edge, which paints a rim in neither
427
+ * colour. A `kind: 'menu'`/`kind: 'custom'` entry breaks it for its own reason — the first is a
428
+ * dropdown trigger with its own overlay, the second is a node basalt does not draw, and neither can
429
+ * be promised the shared-border geometry.
430
+ *
431
+ * **A joined member that ships an icon renders ICON-ONLY, on desktop too**, with its `label` demoted
432
+ * to the accessible name. That is not a saving, it is what a joined set MEANS: `‹ Today ›` is one
433
+ * control whose middle segment names it, and the arrows' content is their direction. Rendered
434
+ * labelled, the same three actions measured 304px against 118px — nearly a quarter of the bar for a
435
+ * word already implied by the arrow. A joined member with no icon (`Absolute | Rate`) keeps its
436
+ * label, so a labelled joined set is unaffected; the rule reads what the caller actually supplied
437
+ * rather than adding a prop to ask.
438
+ */
439
+ function joinRuns(
440
+ actions: readonly BarAction[],
441
+ opts: { viewport: 'desktop' | 'mobile'; excludeKey?: string | undefined },
442
+ ): BarAction[][] {
443
+ const joinable = (action: BarAction): boolean =>
444
+ action.kind === undefined &&
445
+ action.key !== opts.excludeKey &&
446
+ (action.group === true || (opts.viewport === 'mobile' && action.icon !== undefined))
447
+
448
+ const runs: BarAction[][] = []
449
+ for (const action of actions) {
450
+ const tail = runs.at(-1)
451
+ if (joinable(action) && tail !== undefined && tail.every(joinable)) tail.push(action)
452
+ else runs.push([action])
453
+ }
454
+ return runs
455
+ }
456
+
457
+ export function BarActionRow({
458
+ primary,
459
+ secondary,
460
+ host,
461
+ viewport = 'both',
462
+ mobileOnly = [],
463
+ syncNode,
464
+ }: BarActionRowProps): ReactNode {
465
+ const context = useContext(BarExtrasContext)
466
+ // Read unconditionally (hook order), scoped afterwards — see this type's doc.
467
+ const extras = host === 'page' ? context : NO_EXTRAS
468
+ const list = secondary ?? []
469
+
470
+ const flat = list.filter((a): a is BarActionItem | BarActionCustom => a.kind !== 'menu')
471
+ const menus = list.filter((a): a is BarActionMenu => a.kind === 'menu')
472
+ const inline = flat.slice(0, DESKTOP_SECONDARY_MAX)
473
+ const desktopOverflow = [...flat.slice(DESKTOP_SECONDARY_MAX), ...menus]
474
+
475
+ const wantsMobile = viewport === 'both'
476
+ const mobileList = [...list, ...mobileOnly]
477
+ const mobileBar = wantsMobile
478
+ ? [
479
+ ...(primary !== undefined && barActionMobile(primary, true) === 'bar' ? [primary] : []),
480
+ ...mobileList.filter((a) => barActionMobile(a, false) === 'bar'),
481
+ ]
482
+ : []
483
+ const mobileMore = wantsMobile
484
+ ? [
485
+ ...(primary !== undefined && barActionMobile(primary, true) === 'more' ? [primary] : []),
486
+ ...mobileList.filter((a) => barActionMobile(a, false) === 'more'),
487
+ ...extras.mobileMoreActions,
488
+ ]
489
+ : []
490
+
491
+ // The claim is what makes "one kebab per header" true: while this group owns a kebab, the shell
492
+ // renders none, so its `mobile: 'more'` global actions have exactly one home. Only a `page` host
493
+ // can claim — `NO_EXTRAS.claimKebab` is a no-op, and its `mobileMoreActions` is empty.
494
+ const claimsKebab = mobileMore.length > 0
495
+ const claimKebab = extras.claimKebab
496
+ useEffect(() => {
497
+ if (!claimsKebab) return
498
+ return claimKebab()
499
+ }, [claimsKebab, claimKebab])
500
+
501
+ // The PRIMARY is the row's LAST desktop entry, not its first (`docs/CONTROLS-SPEC.md` §2.1):
502
+ // custom chips · secondaries · `More` · sync · primary. Reading order runs weakest-to-strongest and
503
+ // ends at the header's right edge, where the commit action belongs; leading with it put the one
504
+ // filled button furthest from the edge and left `More` and `Sync` reading as the row's conclusion.
505
+ //
506
+ // `syncNode` is what splits the desktop row in two, and it splits it rather than joining it
507
+ // because a `SyncButton` must be mounted EXACTLY ONCE (law C9 — it holds a live relative age and
508
+ // its own interval). Rendering it inside both the `visibleFrom` and the `hiddenFrom` group would
509
+ // double that. So it sits BETWEEN them, unwrapped, and the primary follows it in a second
510
+ // desktop-only group. With no `syncNode` there is nothing to sit between and the desktop row stays
511
+ // one group — which is also what keeps a plain `ActionGroup` a single element to query.
512
+ const desktopPrimary =
513
+ primary !== undefined ? <BarEntry action={primary} emphasis="primary" /> : null
514
+ const hasDesktop = primary !== undefined || list.length > 0
515
+ const hasMobile = mobileBar.length > 0 || claimsKebab
516
+ if (!hasDesktop && !hasMobile && syncNode === undefined) return null
517
+
518
+ const desktopLead = inline.length > 0 || desktopOverflow.length > 0 || syncNode === undefined
519
+
520
+ return (
521
+ <>
522
+ {hasDesktop && desktopLead && (
523
+ <Group gap={BAR_GAP} wrap="nowrap" visibleFrom="sm">
524
+ {joinRuns(inline, { viewport: 'desktop' }).map((run) =>
525
+ run.length === 1 ? (
526
+ <BarEntry key={run[0]!.key} action={run[0]!} emphasis="secondary" />
527
+ ) : (
528
+ <ControlGroup key={`join-${run[0]!.key}`}>
529
+ {run.map((action) => (
530
+ <BarEntry key={action.key} action={action} emphasis="secondary" iconOnly />
531
+ ))}
532
+ </ControlGroup>
533
+ ),
534
+ )}
535
+ <OverflowMenu actions={desktopOverflow} />
536
+ {syncNode === undefined && desktopPrimary}
537
+ </Group>
538
+ )}
539
+ {syncNode}
540
+ {syncNode !== undefined && desktopPrimary !== null && (
541
+ <Group gap={BAR_GAP} wrap="nowrap" visibleFrom="sm">
542
+ {desktopPrimary}
543
+ </Group>
544
+ )}
545
+ {hasMobile && (
546
+ <Group gap={BAR_GAP} wrap="nowrap" hiddenFrom="sm">
547
+ {joinRuns(mobileBar, { viewport: 'mobile', excludeKey: primary?.key }).map((run) =>
548
+ run.length === 1 ? (
549
+ <BarEntry
550
+ key={run[0]!.key}
551
+ action={run[0]!}
552
+ emphasis={run[0]!.key === primary?.key ? 'mobile-primary' : 'mobile-secondary'}
553
+ />
554
+ ) : (
555
+ <ControlGroup key={`join-${run[0]!.key}`}>
556
+ {run.map((action) => (
557
+ <BarEntry
558
+ key={action.key}
559
+ action={action}
560
+ emphasis={action.key === primary?.key ? 'mobile-primary' : 'mobile-secondary'}
561
+ />
562
+ ))}
563
+ </ControlGroup>
564
+ ),
565
+ )}
566
+ {claimsKebab && (
567
+ <OverflowMenu actions={mobileMore} trigger="kebab" label="More actions" />
568
+ )}
569
+ </Group>
570
+ )}
571
+ </>
572
+ )
573
+ }
574
+
575
+ /**
576
+ * A home's `actions` slot, projected for both viewports.
577
+ *
578
+ * Desktop: `primary` as a `filled` button · up to `DESKTOP_SECONDARY_MAX` secondaries as `default`
579
+ * buttons · everything past that, plus every `kind: 'menu'`, in one `More` menu.
580
+ * Mobile: `primary` as an icon button when it ships an icon and a compact labelled button when it
581
+ * does not · one kebab holding every `mobile: 'more'` action · `mobile: 'hidden'` drops the action
582
+ * entirely.
583
+ *
584
+ * The shell's `mobile: 'more'` global actions are folded in by `PageBar`'s ROW-1 group only, not by
585
+ * this component — see `BarActionRowProps.host` for why every other home has to stay out of them.
586
+ *
587
+ * @example
588
+ * <PageBar actions={{ primary: { key: 'new', label: 'New run', onClick: start },
589
+ * secondary: [{ key: 'export', label: 'Export', onClick: exportCsv }] }} />
590
+ */
591
+ export function ActionGroup(props: ActionGroupProps): ReactNode {
592
+ return <BarActionRow {...props} host="slot" />
593
+ }
594
+
595
+ /**
596
+ * One bar entry, resolved by kind: a custom node renders itself, a menu group folds to `More`.
597
+ *
598
+ * A `kind: 'custom'` node goes in a `ctl`-height slot rather than straight into the row. It is the
599
+ * one entry basalt does not draw, so it is also the one entry that can arrive at any height — a
600
+ * `Badge` is 20px, a bare `<span>` is its line box — and a 20px chip beside three 30px buttons is
601
+ * exactly the "buttons and badges have different sizes" the header read as. The slot pins the tier's
602
+ * height and centres whatever it holds; it does not restyle the node.
603
+ */
604
+ function BarEntry({
605
+ action,
606
+ emphasis,
607
+ iconOnly,
608
+ }: {
609
+ action: BarAction
610
+ emphasis: 'primary' | 'secondary' | 'mobile-primary' | 'mobile-secondary'
611
+ /** Set by a JOINED run — see {@link joinRuns}. The label survives as the accessible name. */
612
+ iconOnly?: boolean
613
+ }): ReactNode {
614
+ if (action.kind === 'custom') return <span className={classes.customSlot}>{action.node}</span>
615
+ if (action.kind === 'menu') return <OverflowMenu actions={[action]} label={action.label} />
616
+ if (emphasis === 'mobile-primary') return <MobileBarEntry action={action} variant="filled" />
617
+ if (emphasis === 'mobile-secondary') return <MobileBarEntry action={action} variant="subtle" />
618
+ if (iconOnly === true && action.icon !== undefined) {
619
+ return <BarIconButton action={action} variant="default" />
620
+ }
621
+ return <BarButton action={action} variant={emphasis === 'primary' ? 'filled' : 'default'} />
622
+ }
623
+
624
+ /**
625
+ * One action on the mobile bar. With an icon it is an `ActionIcon`; WITHOUT one it is a compact
626
+ * `Button` carrying the label. Both emphases go through it, so the rule is the same for the primary
627
+ * and for a `mobile: 'bar'` secondary.
628
+ *
629
+ * The first-grapheme fallback this replaced (`initial(label)`) drew `S` for `Save as report` and `E`
630
+ * for `Export`, which is an avatar — a glyph a reader has to already know the meaning of. A label is
631
+ * longer and says what the button does, and the breadcrumb beside it now truncates
632
+ * (`shell/app-header.module.css`), so the width is available. Dropping the fallback WITHOUT this
633
+ * branch would have been worse than either: `BarIconButton` renders `action.icon`, so an icon-less
634
+ * action would have become an empty 30px box.
635
+ *
636
+ * `px` is the phone form's own inset — the `ctl` default is sized for a labelled desktop button.
637
+ */
638
+ function MobileBarEntry({
639
+ action,
640
+ variant,
641
+ }: {
642
+ action: BarActionItem
643
+ variant: 'filled' | 'subtle'
644
+ }): ReactNode {
645
+ if (action.icon !== undefined) return <BarIconButton action={action} variant={variant} />
646
+ return (
647
+ <BarButton
648
+ action={action}
649
+ // `subtle` is an ActionIcon variant, not a Button one — the labelled form of a non-primary bar
650
+ // action is a `default` button, which is what a secondary reads as on desktop too.
651
+ variant={variant === 'filled' ? 'filled' : 'default'}
652
+ px={MOBILE_PRIMARY_PADDING_X}
653
+ />
654
+ )
655
+ }