basalt-ui 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,316 @@
1
+ /**
2
+ * `PageBar`'s two invariants that only a rendered tree can prove (`docs/CONTROLS-SPEC.md` §2.1):
3
+ * row 1 leaves the page and lands in the app-shell header, row 2 does NOT — it stays in the page
4
+ * flow, sticks under the header, and publishes its measured height. Both are decided by context
5
+ * (is there a `BasaltShell` above me?), so a unit test of the component alone can't see either.
6
+ *
7
+ * The `--basalt-page-bar-h` publish is the one piece needing a real ResizeObserver callback. The
8
+ * harness shim (`tests/setup/dom.ts`) is a deliberate no-op, so this file installs a controllable
9
+ * fake for the duration of the suite and fires the callback by hand against a stubbed rect —
10
+ * happy-dom evaluates no layout, so a genuine observer would only ever report 0 (which the
11
+ * `height > 0` guard correctly refuses to publish).
12
+ */
13
+ import { MantineProvider } from '@mantine/core'
14
+ import { render, screen } from '@testing-library/react'
15
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
16
+ import { readFileSync } from 'node:fs'
17
+ import { dirname, resolve } from 'node:path'
18
+ import { fileURLToPath } from 'node:url'
19
+ import { useEffect } from 'react'
20
+ import type { ReactNode } from 'react'
21
+ import { BasaltShell, PageBar } from './index'
22
+ import type { SidebarSection } from './index'
23
+
24
+ const BRAND = { name: 'Argo' }
25
+ const ONE_SECTION: SidebarSection[] = [
26
+ { label: 'Main', items: [{ key: 'home', label: 'Home', icon: null }] },
27
+ ]
28
+
29
+ const HEIGHT_VAR = '--basalt-page-bar-h'
30
+
31
+ type Observed = { node: Element; trigger: () => void }
32
+
33
+ let observed: Observed[] = []
34
+ let nativeResizeObserver: typeof ResizeObserver
35
+
36
+ /** A ResizeObserver whose callback the test fires — see this file's doc for why the shim can't. */
37
+ function installFakeResizeObserver(): void {
38
+ observed = []
39
+ class FakeResizeObserver {
40
+ constructor(private readonly callback: ResizeObserverCallback) {}
41
+ observe(node: Element): void {
42
+ observed.push({
43
+ node,
44
+ trigger: () => this.callback([], this as unknown as ResizeObserver),
45
+ })
46
+ }
47
+ unobserve(): void {}
48
+ disconnect(): void {}
49
+ }
50
+ window.ResizeObserver = FakeResizeObserver as unknown as typeof ResizeObserver
51
+ }
52
+
53
+ /** Every element measures `height` — the layout happy-dom never evaluates, for the whole tree. */
54
+ function stubEveryRect(height: number): void {
55
+ Element.prototype.getBoundingClientRect = function rect() {
56
+ return { height, width: 0, top: 0, left: 0, right: 0, bottom: height, x: 0, y: 0 } as DOMRect
57
+ }
58
+ }
59
+
60
+ function stubHeight(node: HTMLElement, height: number): void {
61
+ Object.defineProperty(node, 'getBoundingClientRect', {
62
+ configurable: true,
63
+ value: () => ({ height, width: 0, top: 0, left: 0, right: 0, bottom: height, x: 0, y: 0 }),
64
+ })
65
+ }
66
+
67
+ beforeEach(() => {
68
+ nativeResizeObserver = window.ResizeObserver
69
+ installFakeResizeObserver()
70
+ })
71
+
72
+ afterEach(() => {
73
+ window.ResizeObserver = nativeResizeObserver
74
+ document.documentElement.style.removeProperty(HEIGHT_VAR)
75
+ })
76
+
77
+ const shellRow = () => document.querySelector<HTMLElement>('[data-basalt-page-bar="shell"]')
78
+
79
+ describe('PageBar inside a BasaltShell', () => {
80
+ const renderInShell = () =>
81
+ render(
82
+ <MantineProvider>
83
+ <BasaltShell brand={BRAND} sections={ONE_SECTION}>
84
+ <PageBar
85
+ actions={{
86
+ primary: { key: 'row1', kind: 'custom', node: <span data-testid="row1" /> },
87
+ }}
88
+ tabs={<span data-testid="row2" />}
89
+ />
90
+ </BasaltShell>
91
+ </MantineProvider>,
92
+ )
93
+
94
+ test('row 1 portals into the app-shell header — every copy of it', () => {
95
+ renderInShell()
96
+ const header = document.querySelector('.mantine-AppShell-header')
97
+ expect(header).not.toBeNull()
98
+ const copies = screen.getAllByTestId('row1')
99
+ expect(copies.length).toBeGreaterThan(0)
100
+ for (const copy of copies) expect(header?.contains(copy)).toBe(true)
101
+ })
102
+
103
+ test('row 2 stays in the page flow — NOT in the header', () => {
104
+ renderInShell()
105
+ const header = document.querySelector('.mantine-AppShell-header')
106
+ const row2 = screen.getByTestId('row2')
107
+ expect(header?.contains(row2)).toBe(false)
108
+ expect(shellRow()?.contains(row2)).toBe(true)
109
+ })
110
+
111
+ test('a ResizeObserver reading publishes --basalt-page-bar-h on documentElement', () => {
112
+ renderInShell()
113
+ const row = shellRow()
114
+ expect(row).not.toBeNull()
115
+ stubHeight(row as HTMLElement, 44)
116
+ const entry = observed.find((o) => o.node === row)
117
+ expect(entry).toBeDefined()
118
+ entry?.trigger()
119
+ expect(document.documentElement.style.getPropertyValue(HEIGHT_VAR)).toBe('44px')
120
+ })
121
+
122
+ test('a zero reading is refused — the guard is what kept a consumer sticky offset from collapsing', () => {
123
+ renderInShell()
124
+ const row = shellRow()
125
+ stubHeight(row as HTMLElement, 0)
126
+ observed.find((o) => o.node === row)?.trigger()
127
+ expect(document.documentElement.style.getPropertyValue(HEIGHT_VAR)).toBe('')
128
+ })
129
+
130
+ test('unmounting removes the property, so the next route does not inherit this height', () => {
131
+ const { unmount } = renderInShell()
132
+ const row = shellRow()
133
+ stubHeight(row as HTMLElement, 44)
134
+ observed.find((o) => o.node === row)?.trigger()
135
+ unmount()
136
+ expect(document.documentElement.style.getPropertyValue(HEIGHT_VAR)).toBe('')
137
+ })
138
+
139
+ test('title is ignored inside a shell — the breadcrumb names the page (law C8)', () => {
140
+ render(
141
+ <MantineProvider>
142
+ <BasaltShell brand={BRAND} sections={ONE_SECTION}>
143
+ <PageBar title="Ignored" tabs={<span data-testid="row2" />} />
144
+ </BasaltShell>
145
+ </MantineProvider>,
146
+ )
147
+ expect(screen.queryByText('Ignored')).toBeNull()
148
+ })
149
+ })
150
+
151
+ /**
152
+ * A consumer's own test DOM has no `ResizeObserver` unless it installs a shim — happy-dom and jsdom
153
+ * both lack it. An unguarded `new ResizeObserver` in the effect turned that into a hard throw from
154
+ * every page test that mounted a bar, which is a framework bug, not a consumer one.
155
+ */
156
+ describe('PageBar with no ResizeObserver at all', () => {
157
+ test('a shell-less bar still mounts, and publishes nothing rather than throwing', () => {
158
+ // @ts-expect-error deliberately removing a DOM global the guard exists to survive
159
+ window.ResizeObserver = undefined
160
+ expect(() =>
161
+ render(
162
+ <MantineProvider>
163
+ <PageBar title="linewatch" tabs={<span data-testid="row2" />} />
164
+ </MantineProvider>,
165
+ ),
166
+ ).not.toThrow()
167
+ expect(screen.getByRole('heading', { level: 1 }).textContent).toBe('linewatch')
168
+ // happy-dom evaluates no layout, so the one-shot measure reads 0 and the guard refuses it.
169
+ expect(document.documentElement.style.getPropertyValue(HEIGHT_VAR)).toBe('')
170
+ })
171
+
172
+ test('a filters row mounts too, and unmounting still cleans up', () => {
173
+ // Only the SHELL-LESS form is exercised: `BasaltShell` mounts a Mantine `AppShell` whose own
174
+ // internals construct a `ResizeObserver`, so that half can never run without one — which is
175
+ // also why the guard matters for the shell-less consumer (linewatch) specifically.
176
+ // @ts-expect-error see above
177
+ window.ResizeObserver = undefined
178
+ const { unmount } = render(
179
+ <MantineProvider>
180
+ <PageBar title="linewatch" filters={<span data-testid="filters" />} />
181
+ </MantineProvider>,
182
+ )
183
+ expect(screen.getByTestId('filters')).toBeDefined()
184
+ expect(() => unmount()).not.toThrow()
185
+ expect(document.documentElement.style.getPropertyValue(HEIGHT_VAR)).toBe('')
186
+ })
187
+ })
188
+
189
+ describe('PageBar without a shell', () => {
190
+ test('renders the title as the page heading and sticks both rows at the top', () => {
191
+ render(
192
+ <MantineProvider>
193
+ <PageBar title="linewatch" tabs={<span data-testid="row2" />} />
194
+ </MantineProvider>,
195
+ )
196
+ const heading = screen.getByRole('heading', { level: 1 })
197
+ expect(heading.textContent).toBe('linewatch')
198
+ const bar = document.querySelector<HTMLElement>('[data-basalt-page-bar="standalone"]')
199
+ expect(bar).not.toBeNull()
200
+ expect(bar?.contains(heading)).toBe(true)
201
+ expect(bar?.contains(screen.getByTestId('row2'))).toBe(true)
202
+ })
203
+
204
+ test('renders nothing at all when every slot is empty (law C14)', () => {
205
+ render(
206
+ <MantineProvider>
207
+ <PageBar />
208
+ </MantineProvider>,
209
+ )
210
+ expect(document.querySelector('[data-basalt-page-bar]')).toBeNull()
211
+ })
212
+ })
213
+
214
+ /**
215
+ * The sticky offsets and law C7's no-horizontal-scroll rule live entirely in CSS, and a CSS module
216
+ * resolves to `''` under `bun test` (no bundler in the loop) — so they are asserted against the
217
+ * shipped CSS TEXT, the same pattern `prose.module.css.test.ts` and `styles.floor.test.ts` use.
218
+ */
219
+ describe('page-bar.module.css', () => {
220
+ const css = readFileSync(
221
+ resolve(dirname(fileURLToPath(import.meta.url)), 'page-bar.module.css'),
222
+ 'utf8',
223
+ )
224
+ /** Declarations only — the block comments above them discuss `::before` and `overflow` by name. */
225
+ const decls = css.replace(/\/\*[\s\S]*?\*\//g, '')
226
+
227
+ test('row 2 inside a shell sticks at the AppShell header-height VAR, never a measured number', () => {
228
+ const rule = decls.match(/\.row2Sticky\s*\{([^}]+)\}/)
229
+ expect(rule).not.toBeNull()
230
+ expect(rule?.[1]).toContain('position: sticky')
231
+ expect(rule?.[1]).toContain('top: var(--app-shell-header-height, 0px)')
232
+ })
233
+
234
+ test('the shell-less bar sticks at the top of the document', () => {
235
+ const rule = decls.match(/\.bar\s*\{([^}]+)\}/)
236
+ expect(rule?.[1]).toContain('position: sticky')
237
+ expect(rule?.[1]).toContain('top: 0')
238
+ })
239
+
240
+ test('no row ever scrolls sideways or wraps (law C7)', () => {
241
+ expect(decls).not.toContain('overflow-x')
242
+ expect(decls).not.toMatch(/overflow(-y)?:\s*(auto|scroll)/)
243
+ expect(decls.match(/flex-wrap: nowrap/g)?.length).toBeGreaterThanOrEqual(5)
244
+ })
245
+
246
+ test('the mobile hit area rides ::after — Mantine owns Button::before for its loading overlay', () => {
247
+ expect(decls).toContain('::after')
248
+ expect(decls).not.toContain('::before')
249
+ expect(decls).toContain('min-height: var(--vx-space-touch-control-height)')
250
+ })
251
+
252
+ test('the hit area un-clips its own host — Mantine roots are overflow: hidden', () => {
253
+ // Without this the overlay is clipped back to the 30px box and law C15's 36px target does not
254
+ // exist at all, which no token assertion can catch.
255
+ expect(decls).toMatch(/:not\(\[data-loading\]\)[\s\S]{0,80}overflow: visible/)
256
+ })
257
+ })
258
+
259
+ /**
260
+ * The height has to exist at the FIRST paint, not one frame later: `Section`'s scroll anchor reads
261
+ * `var(--basalt-page-bar-h, 0px)`, so a cold load of `/page#anchor` that scrolls before the property
262
+ * lands leaves the anchored heading under the sticky bar.
263
+ *
264
+ * The probe is what makes the timing observable. Passive effects run child-first, so a probe INSIDE
265
+ * the bar reads the property before the bar's own passive effect would have written it — it can only
266
+ * see `44px` if the publish happened in the layout phase.
267
+ */
268
+ describe('the height var is published before first paint', () => {
269
+ const nativeRect = Element.prototype.getBoundingClientRect
270
+
271
+ afterEach(() => {
272
+ Element.prototype.getBoundingClientRect = nativeRect
273
+ })
274
+
275
+ test('a passive effect inside the bar already sees --basalt-page-bar-h', () => {
276
+ const seen: { current: string | null } = { current: null }
277
+ function Probe(): ReactNode {
278
+ useEffect(() => {
279
+ seen.current = document.documentElement.style.getPropertyValue(HEIGHT_VAR)
280
+ }, [])
281
+ return <span data-testid="row2" />
282
+ }
283
+
284
+ stubEveryRect(44)
285
+ render(
286
+ <MantineProvider>
287
+ <PageBar title="linewatch" tabs={<Probe />} />
288
+ </MantineProvider>,
289
+ )
290
+
291
+ expect(seen.current).toBe('44px')
292
+ })
293
+ })
294
+
295
+ describe('className reaches the bar root in both forms', () => {
296
+ test('shell-less: it joins the standalone root, keeping the module class', () => {
297
+ render(
298
+ <MantineProvider>
299
+ <PageBar title="linewatch" tabs={<span data-testid="row2" />} className="lw-bleed" />
300
+ </MantineProvider>,
301
+ )
302
+ const bar = document.querySelector<HTMLElement>('[data-basalt-page-bar="standalone"]')
303
+ expect(bar?.classList.contains('lw-bleed')).toBe(true)
304
+ })
305
+
306
+ test('in a shell: it joins row 2s sticky wrapper, not the portalled row 1', () => {
307
+ render(
308
+ <MantineProvider>
309
+ <BasaltShell brand={BRAND} sections={ONE_SECTION}>
310
+ <PageBar tabs={<span data-testid="row2" />} className="argo-bleed" />
311
+ </BasaltShell>
312
+ </MantineProvider>,
313
+ )
314
+ expect(shellRow()?.classList.contains('argo-bleed')).toBe(true)
315
+ })
316
+ })
@@ -0,0 +1,315 @@
1
+ /**
2
+ * `PageBar` — the tier-1 home (`docs/CONTROLS-SPEC.md` §2.1). One per page; every page-level
3
+ * control enters through one of its slots (law C1) and is sized by it (law C5).
4
+ *
5
+ * Two rows, and WHERE they render is decided by context, never by a prop:
6
+ *
7
+ * - **Inside `BasaltShell`** row 1 (`actions`, `sync`) portals into the existing 48px app-shell
8
+ * header — the same slot/portal mechanism `PageActions` used through 1.25.0, which this replaces.
9
+ * The breadcrumb stays the header's lead, so `title` is ignored. Row 2 (`tabs`, `filters`,
10
+ * `filtersEnd`) renders IN-FLOW at the top of the page content, sticky under the header, and
11
+ * publishes its measured height as `--basalt-page-bar-h` on `documentElement` so a page can
12
+ * compute a viewport-filling body (`calc(100dvh - var(--app-shell-header-height) -
13
+ * var(--basalt-page-bar-h, 0px))`) without measuring anything itself.
14
+ * - **Without a shell** both rows render in-flow, sticky at the top of the document, with
15
+ * `title` + `icon` leading row 1 — the page title is the bar's job when there is no breadcrumb
16
+ * (law C8). The published height then covers the whole bar, since that is what content clears.
17
+ *
18
+ * An empty home renders NOTHING (law C14): a page with no tabs and no filters pays for no row 2,
19
+ * and the header never reserves a second mobile row the way the pre-1.26.0 two-row layout did.
20
+ *
21
+ * Originally extracted from argo's `apps/dashboard/src/components/app-shell/page-header.tsx`;
22
+ * linewatch's `page-header.tsx` measure-and-publish effect became the framework behaviour below.
23
+ */
24
+ import {
25
+ createContext,
26
+ useCallback,
27
+ useContext,
28
+ useEffect,
29
+ useLayoutEffect,
30
+ useRef,
31
+ useState,
32
+ } from 'react'
33
+ import type { ReactNode, RefObject } from 'react'
34
+ import { createPortal } from 'react-dom'
35
+ import { CtlSlot } from '../theme'
36
+ import { BarActionRow, BarExtrasProvider, globalActionAsBarAction } from '../controls/actions'
37
+ import { SyncButton } from '../controls/sync-button'
38
+ import type { ActionGroupProps, BarAction, GlobalAction } from '../controls/actions'
39
+ import type { SyncButtonProps } from '../controls/sync-button'
40
+ import classes from './page-bar.module.css'
41
+
42
+ /** The custom property row 2's measured height is published on. */
43
+ export const PAGE_BAR_HEIGHT_VAR = '--basalt-page-bar-h'
44
+
45
+ type PageBarSlots = {
46
+ /** The header node row 1 portals into. `null` outside a shell, and until the outlet mounts. */
47
+ target: HTMLElement | null
48
+ setTarget: (el: HTMLElement | null) => void
49
+ /** True only under `BasaltShell` — the provider is shell-internal, so the context IS the probe. */
50
+ inShell: boolean
51
+ /** Shell `globalActions` marked `mobile: 'more'`, already shaped as kebab rows. */
52
+ mobileMoreActions: readonly BarAction[]
53
+ /** How many page kebabs are mounted. `> 0` means the shell must not render one of its own. */
54
+ kebabClaims: number
55
+ claimKebab: () => () => void
56
+ }
57
+
58
+ const NO_SLOTS: PageBarSlots = {
59
+ target: null,
60
+ setTarget: () => {},
61
+ inShell: false,
62
+ mobileMoreActions: [],
63
+ kebabClaims: 0,
64
+ claimKebab: () => () => {},
65
+ }
66
+
67
+ const PageBarContext = createContext<PageBarSlots>(NO_SLOTS)
68
+
69
+ /**
70
+ * Internal — mounted by `BasaltShell`. Owns the portal target, and the single-kebab claim that
71
+ * lets the shell fall back to its OWN kebab on a route that renders no `PageBar`.
72
+ */
73
+ export function PageBarProvider({
74
+ globalActions,
75
+ children,
76
+ }: {
77
+ globalActions: readonly GlobalAction[]
78
+ children: ReactNode
79
+ }): ReactNode {
80
+ const [target, setTarget] = useState<HTMLElement | null>(null)
81
+ const [kebabClaims, setKebabClaims] = useState(0)
82
+
83
+ const claimKebab = useCallback(() => {
84
+ setKebabClaims((n) => n + 1)
85
+ return () => setKebabClaims((n) => n - 1)
86
+ }, [])
87
+
88
+ const mobileMoreActions = globalActions
89
+ .filter((action, index) => (action.mobile ?? (index < 2 ? 'bar' : 'more')) === 'more')
90
+ .map(globalActionAsBarAction)
91
+
92
+ const value: PageBarSlots = {
93
+ target,
94
+ setTarget,
95
+ inShell: true,
96
+ mobileMoreActions,
97
+ kebabClaims,
98
+ claimKebab,
99
+ }
100
+
101
+ return (
102
+ <PageBarContext.Provider value={value}>
103
+ <BarExtrasProvider value={{ mobileMoreActions, claimKebab }}>{children}</BarExtrasProvider>
104
+ </PageBarContext.Provider>
105
+ )
106
+ }
107
+
108
+ /** Internal — the app-shell header node the active page's row 1 portals into. */
109
+ export function PageBarOutlet({ className }: { className?: string }) {
110
+ const { setTarget } = useContext(PageBarContext)
111
+ return <div ref={setTarget} className={className} />
112
+ }
113
+
114
+ /** Internal — `true` while a page's `ActionGroup` owns the header's one mobile kebab. */
115
+ export function usePageKebabClaimed(): boolean {
116
+ return useContext(PageBarContext).kebabClaims > 0
117
+ }
118
+
119
+ export type PageBarProps = {
120
+ /**
121
+ * Read only when there is no `BasaltShell` outlet (a shell-less app such as linewatch). Inside a
122
+ * shell the breadcrumb names the page and this is ignored.
123
+ */
124
+ title?: string
125
+ icon?: ReactNode
126
+ actions?: ActionGroupProps
127
+ /** `scope` is fixed to `'page'` — a global sync belongs in the shell's `globalActions` (law C12). */
128
+ sync?: Omit<SyncButtonProps, 'scope'>
129
+ /** `FilterSet` descendants only (law C1). */
130
+ filters?: ReactNode
131
+ /** Right-aligned row-2 actions ("Manage metrics"). */
132
+ filtersEnd?: BarAction[]
133
+ /** One `ViewTabs`. */
134
+ tabs?: ReactNode
135
+ /**
136
+ * Added to the bar's ROOT — the shell-less `<div data-basalt-page-bar="standalone">`, or row 2's
137
+ * sticky wrapper (`data-basalt-page-bar="shell"`) inside a `BasaltShell`. The seam for the two
138
+ * things only the consumer's own layout knows: bleeding the sticky bar across its container's
139
+ * gutters (`margin-inline: calc(var(--gutter) * -1); padding-inline: var(--gutter)`) and drawing
140
+ * a hairline under it (`border-bottom: 1px solid var(--vx-border)`).
141
+ *
142
+ * Scope that CSS through this class, not through a global `[data-basalt-page-bar]` selector: the
143
+ * data attributes are stable enough to READ (a shell-less consumer can style
144
+ * `[data-basalt-page-bar="standalone"]` in a pinch, and this doc is the promise that the value
145
+ * stays), but a global rule reaches every page in the app including the ones that want neither.
146
+ */
147
+ className?: string
148
+ }
149
+
150
+ /**
151
+ * `useLayoutEffect` in the browser, `useEffect` on the server — the standard isomorphic pattern, so
152
+ * an SSR render never trips React's "useLayoutEffect does nothing on the server" warning. The
153
+ * branch reads a global that cannot change between renders, so the hook identity is stable.
154
+ */
155
+ const useIsomorphicLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect
156
+
157
+ /**
158
+ * Publishes an element's measured height as a custom property on `documentElement`.
159
+ *
160
+ * **A LAYOUT effect, and a plain ref rather than ref-state.** Both halves buy the same thing: the
161
+ * property exists at the FIRST paint. A passive effect publishes it one frame later, which is a
162
+ * frame in which a cold load of `/page#anchor` has already scrolled — `scroll-margin-top` reads
163
+ * `var(--basalt-page-bar-h, 0px)`, so the anchored heading lands under the sticky bar and stays
164
+ * there. A `useState` node would also have cost a second commit before the measure could run.
165
+ *
166
+ * The `height > 0` guard is the whole point: a ResizeObserver fires once with a zero box while the
167
+ * element is still being laid out (and again whenever it is hidden), and publishing that zero is
168
+ * what made a consumer's sticky offset collapse mid-navigation. The property is removed on unmount
169
+ * so a route without a bar does not inherit the last one's height.
170
+ */
171
+ function useMeasuredHeightVar(active: boolean): RefObject<HTMLDivElement | null> {
172
+ const ref = useRef<HTMLDivElement | null>(null)
173
+
174
+ useIsomorphicLayoutEffect(() => {
175
+ const node = ref.current
176
+ if (!active || node === null) return
177
+ const publish = () => {
178
+ const { height } = node.getBoundingClientRect()
179
+ if (height > 0) {
180
+ document.documentElement.style.setProperty(PAGE_BAR_HEIGHT_VAR, `${height}px`)
181
+ }
182
+ }
183
+ publish()
184
+ // Guarded like `FilterSet`'s identical call: happy-dom and jsdom ship no `ResizeObserver`, and a
185
+ // consumer's page test mounting a bar without the shim would otherwise throw from this effect.
186
+ // The one-shot `publish()` above already ran, so a test DOM still gets a height when it has a
187
+ // layout engine — it just never re-measures.
188
+ if (typeof ResizeObserver === 'undefined') {
189
+ return () => document.documentElement.style.removeProperty(PAGE_BAR_HEIGHT_VAR)
190
+ }
191
+ const observer = new ResizeObserver(publish)
192
+ observer.observe(node)
193
+ return () => {
194
+ observer.disconnect()
195
+ document.documentElement.style.removeProperty(PAGE_BAR_HEIGHT_VAR)
196
+ }
197
+ }, [active])
198
+
199
+ return ref
200
+ }
201
+
202
+ /** The bar root's own class plus the consumer's, in that order (`content/toc.tsx`'s idiom). */
203
+ function rootClass(own: string, extra: string | undefined): string {
204
+ return [own, extra].filter(Boolean).join(' ')
205
+ }
206
+
207
+ export function PageBar({
208
+ title,
209
+ icon,
210
+ actions,
211
+ sync,
212
+ filters,
213
+ filtersEnd,
214
+ tabs,
215
+ className,
216
+ }: PageBarProps): ReactNode {
217
+ const { target, inShell } = useContext(PageBarContext)
218
+
219
+ const filtersEndActions = filtersEnd ?? []
220
+ const hasRow2 = tabs !== undefined || filters !== undefined || filtersEndActions.length > 0
221
+ const hasLead = !inShell && (title !== undefined || icon !== undefined)
222
+ // `filtersEnd` keeps row 1 alive on its own: below `sm` its items fold into the ROW-1 kebab (the
223
+ // header's single one), so the row that hosts that kebab has to exist even with no `actions`.
224
+ const hasRow1 =
225
+ actions !== undefined || sync !== undefined || hasLead || filtersEndActions.length > 0
226
+ const measureRef = useMeasuredHeightVar(hasRow2 || (!inShell && hasRow1))
227
+
228
+ const row1 = hasRow1 ? (
229
+ <div className={classes.row1}>
230
+ {hasLead && (
231
+ <div className={classes.lead}>
232
+ {icon !== undefined && (
233
+ <span className={classes.icon} aria-hidden>
234
+ {icon}
235
+ </span>
236
+ )}
237
+ {title !== undefined && <h1 className={classes.title}>{title}</h1>}
238
+ </div>
239
+ )}
240
+ <div className={classes.row1End}>
241
+ {(actions !== undefined || filtersEndActions.length > 0 || sync !== undefined) && (
242
+ <CtlSlot>
243
+ {/* THE row-1 group: the only `host: 'page'` instance, so the shell's `mobile: 'more'`
244
+ global actions reach exactly one kebab (`BarActionRowProps.host`). `filtersEnd`
245
+ joins that kebab below `sm` and renders in row 2 above it.
246
+
247
+ `sync` is handed DOWN as `syncNode` rather than rendered as a sibling here, because
248
+ the row's order is custom chips · secondaries · `More` · sync · primary
249
+ (`docs/CONTROLS-SPEC.md` §2.1) and only `BarActionRow` knows where the primary is.
250
+ One `CtlSlot` for the whole row now, so the sync button and the buttons around it
251
+ resolve the tier from the same provider. */}
252
+ <BarActionRow
253
+ {...actions}
254
+ host="page"
255
+ mobileOnly={filtersEndActions}
256
+ {...(sync !== undefined && { syncNode: <SyncButton {...sync} scope="page" /> })}
257
+ />
258
+ </CtlSlot>
259
+ )}
260
+ </div>
261
+ </div>
262
+ ) : null
263
+
264
+ const row2 = hasRow2 ? (
265
+ <div className={classes.row2}>
266
+ {tabs !== undefined && <CtlSlot>{tabs}</CtlSlot>}
267
+ {filters !== undefined && (
268
+ <div className={classes.filters}>
269
+ <CtlSlot>{filters}</CtlSlot>
270
+ </div>
271
+ )}
272
+ {filtersEndActions.length > 0 && (
273
+ <div className={classes.filtersEnd}>
274
+ <CtlSlot>
275
+ {/* Desktop only — below `sm` these live in the row-1 kebab above, never in a second one
276
+ (spec §2.1: mobile row 2 is tabs + the first pill + `Filters (n)`, nothing else). */}
277
+ <BarActionRow secondary={filtersEndActions} host="slot" viewport="desktop" />
278
+ </CtlSlot>
279
+ </div>
280
+ )}
281
+ </div>
282
+ ) : null
283
+
284
+ // Shell-less: one sticky bar at the top of the document holding both rows, `title` leading.
285
+ if (!inShell) {
286
+ if (row1 === null && row2 === null) return null
287
+ return (
288
+ <div
289
+ ref={measureRef}
290
+ className={rootClass(classes.bar, className)}
291
+ data-basalt-page-bar="standalone"
292
+ >
293
+ {row1}
294
+ {row2}
295
+ </div>
296
+ )
297
+ }
298
+
299
+ // In a shell: row 1 lives in the header (a portal), row 2 stays in the page flow and sticks
300
+ // underneath it. `target` is null for the first commit only — the outlet's ref sets it.
301
+ return (
302
+ <>
303
+ {row1 !== null && target !== null && createPortal(row1, target)}
304
+ {row2 !== null && (
305
+ <div
306
+ ref={measureRef}
307
+ className={rootClass(classes.row2Sticky, className)}
308
+ data-basalt-page-bar="shell"
309
+ >
310
+ {row2}
311
+ </div>
312
+ )}
313
+ </>
314
+ )
315
+ }