basalt-ui 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,213 @@
1
+ /**
2
+ * WidgetHeader — the one heading primitive behind every section, card and table title
3
+ * (docs/CONTROLS-SPEC.md §2.2, law C8). Plain elements + one CSS module, no `@mantine/*` import
4
+ * anywhere in this folder, so it can be composed from inside `charts/` without tripping
5
+ * `basalt/token-layer-boundary` or `check-dist-layering.mjs`.
6
+ *
7
+ * `tier: 'section'` renders an `<h2>` at the taller section-header height; `tier: 'widget'`
8
+ * renders an `<h3>` at the tighter, display-only icon-tier height — both share the same head-font
9
+ * 88%/550 title treatment. The title row holds title/icon/info/count/actions only; `value` +
10
+ * `delta` render on their own hero-metric row directly beneath it, for both tiers — never inline
11
+ * with the title. `StatCard`, `ChartCard`, `Section`, `SettingsSection`/`DangerZone` and
12
+ * `BasaltDataTable` each compose this and render nothing else above their body (wave 3,
13
+ * docs/CONTROLS-SPEC.md §2.2's composer table).
14
+ *
15
+ * @example
16
+ * import { WidgetHeader } from 'basalt-ui'
17
+ *
18
+ * <WidgetHeader
19
+ * tier="widget"
20
+ * title="Active Users"
21
+ * value="12,483"
22
+ * delta={4.2}
23
+ * deltaPeriod="MoM"
24
+ * actions={<ActionIcon variant="subtle"><IconDots /></ActionIcon>}
25
+ * />
26
+ */
27
+ import { useEffect, useId, useRef, useState } from 'react'
28
+ import type { ReactNode } from 'react'
29
+ import { IconSlot } from '../theme/icon-slot'
30
+ import { DeltaBadge } from './delta-badge'
31
+ import classes from './widget-header.module.css'
32
+
33
+ export type WidgetHeaderProps = {
34
+ /** `section` renders an `<h2>` at the section-header height; `widget` renders an `<h3>` at the
35
+ * tighter icon-tier height, display-only. */
36
+ tier: 'section' | 'widget'
37
+ /** Head-font title (88% stretch, weight 550). */
38
+ title: string
39
+ /** Optional leading icon, rendered before the title. Decorative — hidden from assistive tech. */
40
+ icon?: ReactNode
41
+ /** Optional muted line rendered below the title row. */
42
+ subtitle?: string
43
+ /**
44
+ * Renders an info glyph BESIDE the heading (never inside it — this text would otherwise become
45
+ * part of the heading's accessible name, and an `info` paragraph read out in every headings list
46
+ * is what that cost). The glyph is named `More information`; the text itself reaches assistive
47
+ * tech through `aria-describedby` on a `role="tooltip"` bubble that opens on hover, focus and
48
+ * click — so a keyboard user gets it too. Mantine-free: a plain state bubble, not a Tooltip.
49
+ */
50
+ info?: string
51
+ /** Pre-formatted metric value (mono, hero size). Renders on its own row with `delta`, directly
52
+ * under the title row — never inline with the title. */
53
+ value?: string
54
+ /**
55
+ * The value's unit, rendered immediately after it on the hero row: muted, mono, `--vx-text-sm`.
56
+ *
57
+ * It exists because `value` is a pre-formatted `string` and a KPI's unit is not part of the
58
+ * number: `412` and `TSS` are two facts at two weights, and jamming them into one string paints
59
+ * the unit at the hero's 24px — which reads as a second numeral. `subtitle` was the only place a
60
+ * unit could go before this, and a unit on its own line under the value is a different claim (the
61
+ * BASIS: `7-day rolling`), so cards using both had to pick one.
62
+ *
63
+ * Never a substitute for the number's own formatting: a thousands separator, a currency symbol or
64
+ * a `%` belong in `value`, where they are part of how the numeral reads.
65
+ */
66
+ unit?: string
67
+ /** Signed delta rendered via `DeltaBadge`, on the same hero-metric row as `value`. */
68
+ delta?: number
69
+ /** Comparison timeframe forwarded to `DeltaBadge` (e.g. `MoM`). */
70
+ deltaPeriod?: string
71
+ /**
72
+ * Formats `delta` into the chip's label — forwarded to `DeltaBadge`'s `format`. Defaults to
73
+ * `${Math.abs(delta).toFixed(1)}%`.
74
+ *
75
+ * It exists because not every delta is a percentage: a pace card's trend is `0:12 /km` and a
76
+ * throughput card's is `0.3 km/h`, and the default printed both as a percent — a wrong unit on a
77
+ * KPI, which is worse than no chip. A FUNCTION rather than a label string, so the number stays the
78
+ * number: `delta` still drives the tone and the glyph, and there is exactly one place the sign is
79
+ * decided.
80
+ *
81
+ * The value arrives SIGNED, so a formatter that wants to print the sign itself (`−0:12 /km`) can —
82
+ * pair it with `deltaGlyph={false}` so the ▼ does not say the same thing twice.
83
+ */
84
+ deltaFormat?: (delta: number) => string
85
+ /** Render `DeltaBadge`'s ▲/▼ glyph. Defaults to `true`; a zero delta never shows one. Pass
86
+ * `false` when `deltaFormat` prints the sign itself. */
87
+ deltaGlyph?: boolean
88
+ /** Trend visual slot, rendered below the metric row. Carries `data-placement="right"` so a
89
+ * wave-3 composer's `bleed` layout can select it. */
90
+ sparkline?: ReactNode
91
+ /** Mono count tag rendered after the title (C11 — every table/list states its count). `0` is a
92
+ * real count and renders, distinct from omitting the prop entirely. */
93
+ count?: number
94
+ /** Right-aligned slot (`margin-inline-start: auto`) — the C1/C5 home for header actions. */
95
+ actions?: ReactNode
96
+ }
97
+
98
+ /**
99
+ * The info affordance. A `title` attribute alone was never enough — it renders on hover only, so a
100
+ * keyboard user focusing the glyph saw nothing at all. This is the bubble `ChartCard` used to own
101
+ * before it composed `WidgetHeader`, moved down here so every tier gets the same behaviour:
102
+ * opens on hover/focus/click, closes on leave/blur/Escape/outside pointer-down, and is wired to the
103
+ * trigger with `aria-describedby` only while open.
104
+ */
105
+ function InfoGlyph({ text }: { text: string }) {
106
+ const [open, setOpen] = useState(false)
107
+ const tipId = useId()
108
+ const triggerRef = useRef<HTMLButtonElement>(null)
109
+
110
+ useEffect(() => {
111
+ if (!open) return
112
+ const handlePointerDown = (event: PointerEvent) => {
113
+ if (triggerRef.current?.contains(event.target as Node)) return
114
+ setOpen(false)
115
+ }
116
+ document.addEventListener('pointerdown', handlePointerDown)
117
+ return () => document.removeEventListener('pointerdown', handlePointerDown)
118
+ }, [open])
119
+
120
+ return (
121
+ <span className={classes.info}>
122
+ <button
123
+ ref={triggerRef}
124
+ type="button"
125
+ className={classes.infoTrigger}
126
+ // The GLYPH is named, not the text — `text` reaches AT through `aria-describedby` below, so
127
+ // it never lands in the heading's or the button's accessible name.
128
+ aria-label="More information"
129
+ aria-describedby={open ? tipId : undefined}
130
+ data-open={open}
131
+ onMouseEnter={() => setOpen(true)}
132
+ onMouseLeave={() => setOpen(false)}
133
+ onFocus={() => setOpen(true)}
134
+ onBlur={() => setOpen(false)}
135
+ onClick={() => setOpen(true)}
136
+ onKeyDown={(event) => {
137
+ if (event.key === 'Escape') setOpen(false)
138
+ }}
139
+ >
140
+ <svg width={11} height={11} viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
141
+ <path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm0 4a1.5 1.5 0 1 1 0 3 1.5 1.5 0 0 1 0-3zm0 5a1 1 0 0 1 1 1v7a1 1 0 1 1-2 0v-7a1 1 0 0 1 1-1z" />
142
+ </svg>
143
+ </button>
144
+ {open && (
145
+ <span id={tipId} role="tooltip" className={classes.infoBubble}>
146
+ {text}
147
+ </span>
148
+ )}
149
+ </span>
150
+ )
151
+ }
152
+
153
+ export function WidgetHeader({
154
+ tier,
155
+ title,
156
+ icon,
157
+ subtitle,
158
+ info,
159
+ value,
160
+ unit,
161
+ delta,
162
+ deltaPeriod,
163
+ deltaFormat,
164
+ deltaGlyph,
165
+ sparkline,
166
+ count,
167
+ actions,
168
+ }: WidgetHeaderProps) {
169
+ const Heading = tier === 'section' ? 'h2' : 'h3'
170
+
171
+ return (
172
+ <div className={classes.root} data-tier={tier}>
173
+ <div className={classes.titleRow}>
174
+ <Heading className={classes.heading}>
175
+ {/* `IconSlot` owns the box; the TIER owns its size, through the
176
+ `--vx-space-icon-size` this module sets per `data-tier`. So a `tier="widget"`
177
+ heading draws 14px and a `tier="section"` heading 16px with no prop and no
178
+ per-component `> svg` rule — see `theme/icon-slot.tsx`. */}
179
+ {icon !== undefined && <IconSlot className={classes.icon}>{icon}</IconSlot>}
180
+ <span className={classes.titleText}>{title}</span>
181
+ </Heading>
182
+ {/* OUTSIDE the heading on purpose — see `info`'s own doc. */}
183
+ {info !== undefined && <InfoGlyph text={info} />}
184
+ {count !== undefined && <span className={classes.count}>{count}</span>}
185
+ {actions !== undefined && <span className={classes.actions}>{actions}</span>}
186
+ </div>
187
+ {(value !== undefined || delta !== undefined) && (
188
+ <div className={classes.metrics}>
189
+ {value !== undefined && <span className={classes.value}>{value}</span>}
190
+ {/* Gated on `value`, not on itself: a unit with nothing to qualify is a stray word, and a
191
+ card that lost its number must not keep printing `TSS` beside the empty space. */}
192
+ {value !== undefined && unit !== undefined && (
193
+ <span className={classes.unit}>{unit}</span>
194
+ )}
195
+ {delta !== undefined && (
196
+ <DeltaBadge
197
+ value={delta}
198
+ period={deltaPeriod}
199
+ {...(deltaFormat !== undefined && { format: deltaFormat })}
200
+ {...(deltaGlyph !== undefined && { withGlyph: deltaGlyph })}
201
+ />
202
+ )}
203
+ </div>
204
+ )}
205
+ {subtitle !== undefined && <span className={classes.subtitle}>{subtitle}</span>}
206
+ {sparkline !== undefined && (
207
+ <div className={classes.sparkline} data-placement="right">
208
+ {sparkline}
209
+ </div>
210
+ )}
211
+ </div>
212
+ )
213
+ }
@@ -1,452 +0,0 @@
1
- ---
2
- source: basalt-ui
3
- description: Streaming-agent layer for basalt-ui apps — Eden-native transport, AgentPart exhaustive handling, StickToBottom, chat history, AND the multi-thread ThreadWorkspace (concurrent runs + a distilled-outcome feed + detail panel). Headless layer in ./agent (Mantine-free); the Mantine-coupled chrome ships from its own basalt-ui/agent-chat subpath (also re-exported from the root entry) and uses basalt-ui/content's Markdown for rendering. Covers authoring doctrine, the Eden #231 footgun, and the agent-chat lint guards (agent-resume-guard, agent-no-raw-usechat, ai-sdk-major) with their basalt-agent-allow escape token.
4
- paths:
5
- - 'src/**/*agent*'
6
- - 'src/**/chat*'
7
- - 'src/**/transport*'
8
- - 'apps/**/src/**/*agent*'
9
- - 'apps/**/src/**/chat*'
10
- ---
11
-
12
- # Basalt Agent — Streaming Chat Layer
13
-
14
- basalt-ui ships `./agent` — a headless, Mantine-free streaming-chat layer with an injected
15
- transport seam, exhaustive part rendering, auto-scroll, and persisted history. `./agent` ships no
16
- markdown renderer of its own — `agent/** -> content` is lint-blocked by design, so `PartList` takes
17
- a consumer-supplied `components.text`; basalt's own renderer is `basalt-ui/content`'s `Markdown`.
18
-
19
- ## AgentPart discriminated union
20
-
21
- ```ts
22
- import type { AgentPart } from 'basalt-ui/agent'
23
- // StartPart | TextPart | ReasoningPart | ToolCallPart | SourcePart | ErrorPart
24
- ```
25
-
26
- `StartPart` is a no-op resumption signal, not renderable content — see "Stream resumption" below.
27
-
28
- Every switch over `AgentPart` MUST end with `default: return assertNever(part)`. This is
29
- enforced by tsc — adding a new variant without a case is a compile error.
30
-
31
- ## AgentTransport — the injected seam
32
-
33
- ```ts
34
- import { useAgentStream, edenTransport, type AgentPart } from 'basalt-ui/agent'
35
-
36
- // Wrap your Eden call:
37
- const transport = edenTransport<AgentPart>((input, signal) =>
38
- api.chat.post({ body: { message: input }, fetch: { signal } }),
39
- )
40
-
41
- // Wire into the hook:
42
- const { parts, status, send, stop, regenerate } = useAgentStream({ transport })
43
- ```
44
-
45
- `edenTransport` is zero-dep: it awaits the Eden `{data, error}` envelope, throws on error,
46
- and yields from the `AsyncGenerator<TPart>` data value.
47
-
48
- The transport seam is generic (`AgentTransport<TPart, TInput>`) — consumers can extend AgentPart
49
- with domain variants and pass a custom input type. For a mock (tests/playground), implement the
50
- interface directly without `edenTransport`:
51
-
52
- ```ts
53
- const mockTransport: AgentTransport = {
54
- async *stream(input) {
55
- yield { type: 'text', text: `Echo: ${input}` }
56
- },
57
- }
58
- ```
59
-
60
- ## Eden #231 — the critical stream footgun
61
-
62
- **Issue (STILL OPEN):** applying a `t.Object` or `t.Union` response schema to an Elysia
63
- `async function*` stream route collapses the streamed union to `any` in Eden Treaty.
64
-
65
- **Mitigation (required doctrine):**
66
-
67
- 1. **NO response schema on the stream route.** Leave the `.post(handler)` call schema-free for
68
- the streaming generator.
69
- 2. **Validate at yield-time.** Check part shape before yielding, not in the Elysia schema layer.
70
- Use `parseAgentPart` from `basalt-ui/agent` to narrow untrusted values arriving over the wire:
71
-
72
- ```ts
73
- import { parseAgentPart } from 'basalt-ui/agent'
74
-
75
- // On the client — e.g. parsing raw NDJSON lines from a custom transport:
76
- const raw: unknown = JSON.parse(line)
77
- const part = parseAgentPart(raw) // AgentPart | null
78
- if (part !== null) handlePart(part)
79
-
80
- // On the server — guard before yielding from external tool output:
81
- const candidate = buildPartFromTool(toolResult)
82
- const part = parseAgentPart(candidate)
83
- if (part !== null) yield part
84
- ```
85
-
86
- `parseAgentPart` performs structural narrowing against every variant in the discriminated union
87
- and returns `null` for unknown types or malformed shapes — no exceptions thrown.
88
-
89
- 3. **Explicit return type annotation.** The handler MUST declare `: AsyncGenerator<AgentPart>`.
90
-
91
- ```ts
92
- // ✓ Correct — explicit return type, no response schema:
93
- app.post('/chat', async function* ({ body }): AsyncGenerator<AgentPart> {
94
- // validate body.message here, not via t.Object schema
95
- yield { type: 'text', text: 'Hello' }
96
- yield { type: 'text', text: ' world' }
97
- })
98
-
99
- // ✗ Wrong — response schema collapses the union to `any`:
100
- app.post(
101
- '/chat',
102
- async function* ({ body }): AsyncGenerator<AgentPart> {
103
- yield { type: 'text', text: 'Hello' }
104
- },
105
- {
106
- response: t.Object({ type: t.Literal('text'), text: t.String() }), // drops to any
107
- },
108
- )
109
- ```
110
-
111
- ## Two additional Eden silent-`any` footguns
112
-
113
- These apply to ALL Eden Treaty usage (not just streams):
114
-
115
- 1. **Non-chained routes.** Elysia routes MUST be method-chained (`app.get(...).post(...)`). A
116
- standalone `app.get(...)` that is not chained back drops the `App` type to `any`, losing all
117
- Treaty type safety.
118
-
119
- 2. **Mismatched tsconfig path aliases.** Client and server packages MUST share or extend one root
120
- `tsconfig` so path aliases (`~/*`, `@/*`) resolve identically. A mismatch causes Eden's type
121
- extraction to fail silently and degrade to `any`.
122
-
123
- Both footguns produce no TypeScript error at the point of breakage — they only manifest as `any`
124
- types on the Treaty client.
125
-
126
- ## useAgentStream
127
-
128
- ```ts
129
- const { parts, status, send, stop, regenerate } = useAgentStream({ transport })
130
- // status: 'idle' | 'streaming' | 'done' | 'error'
131
- // send(input): starts a new stream, resets parts
132
- // stop(): aborts the in-flight stream via AbortController
133
- // regenerate(): re-runs the last input
134
- ```
135
-
136
- ## PartList — exhaustive rendering
137
-
138
- ```tsx
139
- import { PartList } from 'basalt-ui/agent'
140
- import { Markdown } from 'basalt-ui/content'
141
-
142
- // Default headless renderers (plain HTML elements):
143
- <PartList parts={parts} />
144
-
145
- // With basalt's own Markdown for the text renderer:
146
- <PartList
147
- parts={parts}
148
- components={{
149
- text: ({ part }) => <Markdown streaming density="chat">{part.text}</Markdown>,
150
- }}
151
- />
152
- ```
153
-
154
- `PartList` ships five headless default renderers (zero Mantine):
155
-
156
- - `text` → `<div className="basalt-agent-text">`
157
- - `reasoning` → `<details className="basalt-agent-reasoning">`
158
- - `tool` → `<div className="basalt-agent-tool">` with labelled input/output `<pre>`
159
- - `source` → `<a className="basalt-agent-source">`
160
- - `error` → `<div className="basalt-agent-error" role="alert">`
161
-
162
- `./agent` deliberately ships no markdown renderer of its own — `agent/** -> content` is
163
- lint-blocked by design (the Mantine-free boundary). The package's only markdown renderer is
164
- `basalt-ui/content`'s `Markdown`; `threadPartRenderers` (re-exported from the root `basalt-ui`
165
- entry) already wires it in as the `text` renderer for `ThreadTranscript`/`ThreadWorkspace`.
166
-
167
- ## BasaltStickToBottom (optional peer)
168
-
169
- ```tsx
170
- import { BasaltStickToBottom } from 'basalt-ui/agent'
171
- ;<BasaltStickToBottom style={{ height: '400px', overflow: 'auto' }}>
172
- <PartList parts={parts} />
173
- </BasaltStickToBottom>
174
- ```
175
-
176
- **Install:** `bun add use-stick-to-bottom`
177
-
178
- Lazily loaded — `use-stick-to-bottom` is an optional peer. Falls back to a plain scrollable div
179
- while loading. Surfaces a scroll-to-bottom button (`.basalt-agent-scroll-to-bottom`) when the user
180
- scrolls up.
181
-
182
- ## createChatHistoryStore
183
-
184
- ```ts
185
- import { createChatHistoryStore } from 'basalt-ui/agent'
186
-
187
- // Call once at module scope with a stable key:
188
- const useChatHistory = createChatHistoryStore({ key: 'main-chat', version: 1, max: 100 })
189
-
190
- // In a component:
191
- const { messages, append, clear } = useChatHistory()
192
- append({ id: crypto.randomUUID(), role: 'user', parts: [...], createdAt: Date.now() })
193
- ```
194
-
195
- Built on `createPersistedState` — SSR-safe, cross-tab via the `storage` event. Ring-buffered to
196
- `max` (default 100). Increment `version` when the `ChatMessage` shape changes to clear stale data.
197
-
198
- ## Multi-thread workspace (shipped)
199
-
200
- For the "many short chats" pattern — each prompt a short-lived thread, the feed showing only a
201
- distilled outcome (title + summary), a right-hand detail panel to open and continue — basalt-ui
202
- ships a full workspace. The headless multi-thread layer lives in `./agent` (Mantine-free); the
203
- ready-built Mantine chrome ships from `basalt-ui/agent-chat` (also re-exported from the root
204
- entry).
205
-
206
- ### Headless (`basalt-ui/agent`)
207
-
208
- ```ts
209
- import { createThreadsStore, useAgentThreadRuns, heuristicOutcome } from 'basalt-ui/agent'
210
- import type { AgentThread, AgentOutcome, OutcomeResolver, ThreadStatus } from 'basalt-ui/agent'
211
-
212
- // Call ONCE at module scope — same doctrine as createChatHistoryStore:
213
- const useThreads = createThreadsStore({ key: 'main-threads', version: 1 })
214
- ```
215
-
216
- - `createThreadsStore({ key, version, maxThreads?, maxMessagesPerThread? })` — the multi-thread
217
- analog of `createChatHistoryStore`: a persisted, ring-buffered registry of `AgentThread`s (each
218
- carrying its own `ChatMessage[]`, a distilled `AgentOutcome | null`, a `ThreadStatus`, and a read
219
- flag). Threads are newest-first; `create()` returns the new id and never touches `activeId`.
220
- - `useAgentThreadRuns({ transport, store, resolveOutcome })` — the CONCURRENT run manager. Unlike
221
- `useAgentStream` (one in-flight turn), it runs N streams keyed by thread id, so many short chats
222
- stream and resolve in the background independently. `start(threadId, input)` / `stop(threadId)` /
223
- `stopAll()`; `runs` is the live per-thread `{ status, parts }` map for the detail view.
224
- - `AgentOutcome = { title; summary; status: 'done' | 'attention' | 'error' }` — the distilled feed
225
- projection, deliberately a DIFFERENT shape from the transcript so raw prompt/thinking can never
226
- leak into the feed (the enforced boundary).
227
-
228
- ### Outcome resolver — the summarize-to-outcome seam
229
-
230
- basalt ships ONLY the seam, never an LLM call. `OutcomeResolver = (thread) => AgentOutcome |
231
- Promise<AgentOutcome>` — the app supplies it. In production derive `{ title, summary }` from the
232
- finished run (e.g. a structured final step of your own model) and return it. `heuristicOutcome` is
233
- a demo-only fallback that truncates the last assistant text — never the production path.
234
-
235
- ### Ready-built UI (`basalt-ui/agent-chat`, Mantine)
236
-
237
- The Mantine-coupled chrome (`ThreadWorkspace` and the lower-level pieces below it) ships its own
238
- subpath, `basalt-ui/agent-chat` — added in 1.10.0. The root `basalt-ui` entry still re-exports the
239
- same components (nothing that imported from `basalt-ui` before 1.10.0 needs to change), but a
240
- consumer who only needs the thread-chat UI can import from `./agent-chat` directly, without pulling
241
- in `BasaltProvider`, `BasaltShell`, the dashboard composites, or `basalt-ui/connectivity`:
242
-
243
- ```tsx
244
- import { ThreadWorkspace } from 'basalt-ui/agent-chat'
245
- ;<ThreadWorkspace
246
- useThreads={useThreads}
247
- transport={transport}
248
- resolveOutcome={resolveOutcome}
249
- newThreadPlaceholder="Ask anything…"
250
- />
251
- ```
252
-
253
- `ThreadWorkspace` is the flagship composite: a `ThreadFeed` of distilled `ThreadOutcomeCard`s + an
254
- anchored new-thread `Composer` on the left, a `ThreadDetailPanel` (full transcript + a continue
255
- composer) on the right, collapsing to a single pane below 768px. The lower-level pieces
256
- (`ThreadFeed`, `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript`,
257
- `threadPartRenderers`) are exported too for bespoke layouts. Motion (feed insert, panel slide) runs
258
- on the shared `MOTION_*` tokens and honours `useReducedMotion`.
259
-
260
- `ThreadFeed` also takes a `variant` (`'outcome'`, the default above, or `'inline'` for
261
- `ThreadFeedRow`, the Slack-style row that expands in place instead of opening a separate detail
262
- panel — lazily mounted on first expand, then kept mounted and hidden via CSS on every collapse
263
- after that) and a `renderRow` override that takes priority over `variant` entirely, for full control
264
- over live-run wiring (`onSend`/`onStop`/`liveParts`/`liveStatus`) the built-in `'inline'` row
265
- doesn't expose on its own; wire a real `onSend` on `ThreadFeed` itself to make the built-in row's
266
- composer usable, or omit it and the row's composer renders disabled rather than a live control that
267
- silently discards input. `ThreadTranscript` also takes `groupConsecutive` (suppresses role
268
- label/chrome on same-speaker runs), a per-message `affordances` contract (timestamp/copy/regenerate/
269
- custom actions), and an optional `virtualize`/`height` windowing mode for very long threads. A
270
- windowed transcript owns its own scroll node (never nest it in `BasaltStickToBottom`) and scrolls
271
- itself to the newest message once on mount — `virtualize={{ initialScroll: 'start' }}` opts out, and
272
- is what a consumer restoring its own saved scroll position wants so the two don't fight.
273
-
274
- **Boundary:** the headless layer (`createThreadsStore`, `useAgentThreadRuns`, the outcome types)
275
- stays Mantine-free in `./agent`; the components are Mantine-coupled and ship from `./agent-chat`
276
- (also re-exported from the root entry). Never add `@mantine/*` under `src/agent/**` — it is
277
- oxlint-enforced Mantine-free.
278
-
279
- **No custom part types at the workspace level.** `ThreadWorkspace`, `ThreadDetailPanel`, and
280
- `ThreadTranscript` render the framework's `AgentPart` union only — they are not generic over a
281
- consumer-extended part type. If you need custom rendering for the existing part shapes, drop to
282
- the headless layer instead: compose your own transcript from `PartList` (`basalt-ui/agent`) with a
283
- custom `components` renderer map, rather than passing a wider type through `ThreadWorkspace`.
284
-
285
- ## Lint guards (agent-chat) — a SEPARATE escape token from `theme-allow`
286
-
287
- Three oxlint rules (1.10.0) protect the streaming/resume discipline above. All three share one
288
- escape comment, `basalt-agent-allow` — deliberately NOT `theme-allow`. The two tokens are unrelated:
289
- a `theme-allow` comment on a color/spacing exception never suppresses one of these, and a
290
- `basalt-agent-allow` comment never suppresses a `theme-allow` finding. Don't reach for the color
291
- exemption to quiet a streaming guard — mark the correct token, on the correct line, and only when
292
- you actually own the guard you're bypassing.
293
-
294
- - **`basalt/agent-resume-guard`** — flags an unguarded `useChat({ resume: true })` or a bare
295
- `resumeStream()` call outside `useAgentThreadRuns`. `useAgentThreadRuns` owns single-consumer
296
- discipline and StrictMode-safe reconnection; a raw resume call re-fires on every effect re-run
297
- (vercel/ai#7891, no merged fix upstream). Mark `basalt-agent-allow` only if you own the guard
298
- yourself.
299
- - **`basalt/agent-no-raw-usechat`** — flags importing `useChat`/`useCompletion` directly from
300
- `@ai-sdk/react` or `ai/react`. Use `useAgentStream`/`useAgentThreadRuns` over `aiSdkTransport`
301
- instead — they add unmount abort, supersede guards, and single-consumer resume that the raw hook
302
- doesn't. Type-only imports (`import type { useChat }`) are exempt; nothing to guard against at
303
- runtime.
304
- - **`basalt/ai-sdk-major`** — flags a file whose nearest `package.json` declares a different `ai`
305
- major than the one basalt-ui itself declares as a peer. A producer and consumer on different `ai`
306
- majors can throw `'Unknown chunk type'` at runtime — this is the exact defect one production
307
- consumer hit (one app package on `ai@5`, a sibling on `ai@7`). This rule is PER-FILE — it only
308
- sees the nearest `package.json` to the linted file, so a lint run scoped to one workspace package
309
- can't catch a skew against a _different_ package. `bunx basalt-ui doctor`'s `ai-major-parity`
310
- check is the cross-package counterpart: it walks every workspace manifest and is a HARD failure,
311
- not a lint warning — run both, they check different things. Like its two siblings above, it
312
- honours `basalt-agent-allow` on the flagged import (or the line above it) — mark it on an
313
- intentional producer/consumer file. The repo-wide equivalent for `doctor`'s check is
314
- `basalt.aiMajorSkewReason` in package.json (a mandatory written reason, not a line comment) — see
315
- the README and the `BasaltConfig` JSDoc.
316
-
317
- None of these three rules honour `theme-allow` — only `basalt-agent-allow`, on the flagged line or
318
- the line above it.
319
-
320
- ## Stream resumption — the reconnect seam
321
-
322
- basalt-ui ships the CLIENT-SIDE contract for resuming a run after a disconnect (reload, network
323
- drop) — NOT a concrete backend. A real resumable transport (e.g. Redis-backed run state) is the
324
- consumer's responsibility; this section documents the seam a consumer transport plugs into.
325
-
326
- ### StartPart
327
-
328
- A sixth `AgentPart` variant, emitted once at the top of a run:
329
-
330
- ```ts
331
- export type StartPart = {
332
- readonly type: 'start'
333
- readonly runId: string
334
- readonly resumeToken?: string
335
- }
336
- ```
337
-
338
- Not renderable content — `PartList`'s exhaustive switch returns `null` for it, and it's stripped
339
- before the consumer ever sees it: `useAgentStream` skips it in its accumulation loop; `useAgentThreadRuns`
340
- skips it too but additionally reads `resumeToken` off it and stashes it on the persisted
341
- `AgentThread` via `store.setResumeToken`. `parseAgentPart` validates it like every other variant
342
- (`runId` required string, `resumeToken` optional string).
343
-
344
- ### AgentTransport.resume
345
-
346
- ```ts
347
- export type AgentTransport<TPart = AgentPart, TInput = string> = {
348
- stream: (input: TInput, signal?: AbortSignal) => AsyncGenerator<TPart>
349
- resume?: (resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>
350
- }
351
- ```
352
-
353
- Optional — a transport that doesn't support resumption simply omits `resume`. `edenTransport`
354
- takes an optional second `resumeCall` parameter (same shape as `call`, but keyed on `resumeToken`
355
- instead of the original input) and only adds a `resume` method to the returned transport object
356
- when `resumeCall` is provided (no `resume: undefined` key otherwise):
357
-
358
- ```ts
359
- const transport = edenTransport<AgentPart>(
360
- (input, signal) => api.chat.post({ body: { message: input }, fetch: { signal } }),
361
- (resumeToken, signal) => api.chat.resume.post({ body: { resumeToken }, fetch: { signal } }),
362
- )
363
- ```
364
-
365
- ### ThreadsStore.resumeToken / setResumeToken
366
-
367
- `AgentThread` carries an optional `resumeToken?: string`, set from the run's most recent
368
- `StartPart` and cleared once the run finalizes. `ThreadsStore.setResumeToken(id, token)` is the
369
- setter — same shape/pattern as `setStatus`/`setOutcome`.
370
-
371
- ### Mount-time resume-before-interrupted
372
-
373
- `useAgentThreadRuns`'s mount-reconciliation effect previously marked every orphaned thread
374
- (`'pending'`/`'streaming'` with no live controller — e.g. after a reload mid-stream) straight to
375
- `'interrupted'`. It now attempts a resume first:
376
-
377
- 1. Find the thread's last `role === 'user'` message (no new user message is created on resume — it
378
- was already appended before the disconnect).
379
- 2. If `transport.resume` is defined AND the thread has a `resumeToken` AND that last user message
380
- exists, call `transport.resume(resumeToken, signal)` and feed the resulting generator through
381
- the SAME accumulate/finalize logic as a fresh `start()` call (both share one internal
382
- `consumeAndFinalize` helper). The resumed run occupies the `runs` map exactly like a live turn —
383
- reusing the existing `'streaming'` status; there is no separate `'resuming'` ThreadStatus.
384
- 3. Any failure — no `resume()`, no `resumeToken`, no user message, or the resume itself throwing —
385
- falls back to `'interrupted'`, exactly as before.
386
-
387
- Resumption is strictly additive: a transport that never emits `StartPart` and never implements
388
- `resume` behaves identically to today (every orphaned thread still lands on `'interrupted'`).
389
-
390
- ## aiSdkTransport — the RECOMMENDED DEFAULT transport
391
-
392
- For LLM chat use cases, `aiSdkTransport` (backed by the `ai` npm package, an OPTIONAL peer) is the
393
- recommended default — `edenTransport` remains fully supported as the zero-extra-dependency
394
- alternative (see above); neither is deprecated.
395
-
396
- ```ts
397
- import { useAgentStream, aiSdkTransport } from 'basalt-ui/agent'
398
-
399
- const transport = aiSdkTransport({ api: '/api/chat' })
400
- const { parts, status, send } = useAgentStream({ transport })
401
- ```
402
-
403
- **Install:** `bun add ai`. Like `BasaltStickToBottom`, `ai` is lazily resolved via a memoized
404
- dynamic `import()` on first `stream()`/`resume()` — importing `basalt-ui/agent` (or calling
405
- `aiSdkTransport(...)`) never eagerly resolves it. Unlike that, there is no "plain text" degrade
406
- path if `ai` is missing: the rejected import propagates through the async generator
407
- into the consumer's existing error handling (`useAgentStream` → `status: 'error'`;
408
- `useAgentThreadRuns` → its `onFailureStatus`).
409
-
410
- **Why diffing is needed.** AI SDK's `readUIMessageStream` yields the FULL accumulated `UIMessage`
411
- snapshot on every update (a growing `parts` array; existing parts' content grows in place) — it is
412
- not itself a delta stream. basalt-ui's whole part-accumulation model is delta-based, so
413
- `aiSdkTransport` diffs consecutive snapshots internally and yields only the new `AgentPart` deltas.
414
- One consequence: `ToolCallPart` gained an optional `toolCallId` field, since AI SDK's tool parts
415
- carry one and re-emit on every state transition (`input-available` → `output-available`) at the
416
- SAME array index — a consumer's own coalescing can key on `toolCallId` to update a tool block in
417
- place instead of rendering a near-duplicate second block.
418
-
419
- **Chat id binding.** `aiSdkTransport(options)` mints one stable chat id at construction — the
420
- returned object is immediately usable with `useAgentStream`. Call `.forThread(threadId)` to bind a
421
- transport to a caller-supplied id instead — the form `useAgentThreadRuns` expects when `transport`
422
- is a per-thread factory (see "Multi-thread workspace" above):
423
-
424
- ```ts
425
- const transport = aiSdkTransport({ api: '/api/chat' })
426
- useAgentThreadRuns({
427
- transport: (threadId) => transport.forThread(threadId),
428
- store,
429
- resolveOutcome,
430
- })
431
- ```
432
-
433
- **Scope gaps (v1):** `source-document`, `file`, `reasoning-file`, `data-*`, `step-start`, `custom`,
434
- and `dynamic-tool` UIMessage parts have no `AgentPart` equivalent yet and are silently skipped by
435
- the diff — not an oversight, a deliberate v1 boundary (see the code comment in
436
- `ai-sdk-transport.ts`). Tool parts in the `'input-streaming'` state (partial/`DeepPartial` input)
437
- are also skipped — only `'input-available'` and later states emit, to avoid flooding the consumer
438
- with incomplete fragments.
439
-
440
- ## Deferred (advisory — not shipped)
441
-
442
- The following are explicitly deferred and MUST NOT be scaffolded:
443
-
444
- - Voice/audio streaming
445
- - `streamdown` integration (Tailwind-only)
446
- - Elysia stream route scaffold in app code (consumer's responsibility)
447
- - `agent-parts.ts` helper files beyond the basalt surface
448
-
449
- > The full thread-chat composite (`ThreadWorkspace`) is now **shipped** — see "Multi-thread
450
- > workspace" above. It was previously on this list; a real consumer drove it, so it graduated per
451
- > the "build-when-driven" rule rather than being scaffolded speculatively. `aiSdkTransport` graduated
452
- > the same way — see the section above; it is no longer deferred.