basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,892 @@
1
+ /**
2
+ * The store field vocabulary — `field.*`, its codecs, the `FieldHandle` contract every basalt
3
+ * control binds to, and `createLocalStore`, the router-free lane.
4
+ *
5
+ * The router-coupled `createSearchStore` lives in `../router-tanstack/search-store` and is built on
6
+ * the same `createStoreCore` below, so both factories resolve a value the same way:
7
+ * URL ⊳ localStorage ⊳ fallback (C4). Ground truth: `docs/CONTROLS-SPEC.md` §4.
8
+ *
9
+ * Published through the `basalt-ui/state` barrel (`../state.ts`) — never imported directly by a
10
+ * consumer.
11
+ */
12
+ import { useSyncExternalStore } from 'react'
13
+ import { createPersistedState, readPersistedValue } from './persisted'
14
+
15
+ // ── Store fields — the vocabulary both store factories share ──────────────────────────────────
16
+
17
+ /**
18
+ * Which lanes a field lives on, declared ONCE at definition (C4).
19
+ *
20
+ * `url` is the truth; `persist` is the localStorage mirror UNDER it. `history` picks the entry a
21
+ * write creates. Declare the lane inline (`field.boolean(false, { url: false })`) — a lane held in
22
+ * a widened variable still behaves correctly at runtime, but the TYPE-level lane filter that keeps
23
+ * a local-only field out of `validateSearch` can only narrow a literal.
24
+ *
25
+ * @default `{ url: true, persist: true, history: 'replace' }`
26
+ */
27
+ export type FieldLane = {
28
+ /** Put the field in the URL search. `false` = the mirror-only lane (per-chart selects, compact). */
29
+ url?: boolean
30
+ /**
31
+ * Mirror the field in localStorage. `false` = the URL-only lane (pagination, one-shot filters);
32
+ * with `url: false` as well, the MEMORY-only lane — shared across every mount of the store for
33
+ * the session, gone on reload, never in localStorage. `createLocalStore` ignores `url`, so
34
+ * `persist: false` alone lands a field there.
35
+ */
36
+ persist?: boolean
37
+ /** History entry a write creates. @default 'replace' */
38
+ history?: 'push' | 'replace'
39
+ }
40
+
41
+ /** A `FieldLane` with every default applied — what a field descriptor actually carries. */
42
+ export type ResolvedLane = { url: boolean; persist: boolean; history: 'push' | 'replace' }
43
+
44
+ /** Lane defaults at the type level. Only an inline (const-inferred) lane object narrows. */
45
+ export type ResolveLane<L> = {
46
+ url: L extends { url: false } ? false : true
47
+ persist: L extends { persist: false } ? false : true
48
+ history: L extends { history: 'push' } ? 'push' : 'replace'
49
+ }
50
+
51
+ /** The value of a range field: one preset, plus two ISO dates when the preset is `'custom'`. */
52
+ export type RangeValue<P extends string> = {
53
+ preset: P
54
+ from?: string | undefined
55
+ to?: string | undefined
56
+ }
57
+
58
+ /** Renamed URL params for a range field. Defaults: the field's own name, `from`, `to`. */
59
+ export type RangeParams = { preset?: string; from?: string; to?: string }
60
+
61
+ export type EnumField<T extends string = string, Ln extends ResolvedLane = ResolvedLane> = {
62
+ readonly kind: 'enum'
63
+ readonly values: readonly T[]
64
+ readonly fallback: T
65
+ readonly lane: Ln
66
+ }
67
+
68
+ export type MultiField<T extends string = string, Ln extends ResolvedLane = ResolvedLane> = {
69
+ readonly kind: 'multi'
70
+ readonly values: readonly T[]
71
+ readonly fallback: readonly T[]
72
+ readonly lane: Ln
73
+ }
74
+
75
+ export type RangeField<
76
+ P extends string = string,
77
+ C extends boolean = boolean,
78
+ Pm extends RangeParams = RangeParams,
79
+ Ln extends ResolvedLane = ResolvedLane,
80
+ > = {
81
+ readonly kind: 'range'
82
+ /** The declared presets — `'custom'` is NOT one of them, it is `custom: true`'s consequence. */
83
+ readonly presets: readonly P[]
84
+ readonly fallback: RangeValue<P>
85
+ /** `true` → the preset `'custom'` plus `from`/`to` ISO dates are legal values. */
86
+ readonly custom: C
87
+ readonly params: Pm
88
+ readonly lane: Ln
89
+ }
90
+
91
+ /**
92
+ * The preset union a range field's VALUES range over: the declared presets, plus `'custom'` unless
93
+ * the field opted out. `C` widened to `boolean` (a `RangeField<P>` in a control's props) keeps
94
+ * `'custom'`, so a widened handle can still carry a custom window.
95
+ */
96
+ export type RangePresets<P, C> = (P & string) | (C extends false ? never : 'custom')
97
+
98
+ export type NumberField<Ln extends ResolvedLane = ResolvedLane> = {
99
+ readonly kind: 'number'
100
+ readonly fallback: number
101
+ readonly min: number | undefined
102
+ readonly max: number | undefined
103
+ readonly int: boolean
104
+ readonly lane: Ln
105
+ }
106
+
107
+ export type BooleanField<Ln extends ResolvedLane = ResolvedLane> = {
108
+ readonly kind: 'boolean'
109
+ readonly fallback: boolean
110
+ readonly lane: Ln
111
+ }
112
+
113
+ export type StringField<Ln extends ResolvedLane = ResolvedLane> = {
114
+ readonly kind: 'string'
115
+ readonly fallback: string
116
+ readonly max: number | undefined
117
+ readonly lane: Ln
118
+ }
119
+
120
+ /** Every field kind, widened — the constraint a store's `fields` map satisfies. */
121
+ export type AnyField =
122
+ | EnumField
123
+ | MultiField
124
+ | RangeField
125
+ | NumberField
126
+ | BooleanField
127
+ | StringField
128
+
129
+ /** The value `use()` reads and writes for one field. Range fields deal in `RangeValue`. */
130
+ export type FieldValue<F extends AnyField> = F extends {
131
+ kind: 'enum'
132
+ values: readonly (infer T)[]
133
+ }
134
+ ? T
135
+ : F extends { kind: 'multi'; values: readonly (infer T)[] }
136
+ ? readonly T[]
137
+ : F extends { kind: 'range'; presets: readonly (infer P)[]; custom: infer C }
138
+ ? RangeValue<RangePresets<P, C>>
139
+ : F extends { kind: 'number' }
140
+ ? number
141
+ : F extends { kind: 'boolean' }
142
+ ? boolean
143
+ : F extends { kind: 'string' }
144
+ ? string
145
+ : never
146
+
147
+ /** One option of an enum / multi / range field, labelled through `store.labels()`. */
148
+ export type FieldOption = { value: string; label: string }
149
+
150
+ /** `toWindow` exists on a range handle and nowhere else — it is not an optional convenience. */
151
+ type RangeHandleExtras<F> = F extends {
152
+ kind: 'range'
153
+ presets: readonly (infer P)[]
154
+ custom: infer C
155
+ }
156
+ ? {
157
+ /**
158
+ * The API-facing projection of a range value: a preset becomes `{ window }`, a custom range
159
+ * becomes `{ from, to }`. Replaces every hand-rolled `presetToParams`.
160
+ */
161
+ readonly toWindow: (
162
+ v: RangeValue<RangePresets<P, C>>,
163
+ ) => { window: RangePresets<P, C> } | { from: string; to: string }
164
+ }
165
+ : { readonly toWindow?: undefined }
166
+
167
+ /**
168
+ * The binding every basalt control takes instead of `value`/`onChange` (C2). One field, both lanes,
169
+ * one resolution law. Handed out by `createSearchStore().field.<name>` /
170
+ * `createLocalStore().field.<name>`; never constructed by hand.
171
+ */
172
+ export type FieldHandle<F extends AnyField> = {
173
+ readonly kind: F['kind']
174
+ readonly fallback: FieldValue<F>
175
+ /** enum / multi / range options, labelled by `labels()`. Empty for the other kinds. */
176
+ readonly options: readonly FieldOption[]
177
+ /**
178
+ * Read + write. Reads the URL when the field is on the URL lane (`useSearch({ strict: false })`,
179
+ * so a control renders on a sibling or child route with no `from`), else localStorage, else the
180
+ * fallback. Writes navigate when the matched route validates the param, then persist.
181
+ */
182
+ use(): readonly [FieldValue<F>, (next: FieldValue<F>) => void]
183
+ isDefault(v: FieldValue<F>): boolean
184
+ } & RangeHandleExtras<F>
185
+
186
+ // ── field.* — declarative field builders ──────────────────────────────────────────────────────
187
+
188
+ /**
189
+ * The field vocabulary. Every builder returns plain declarative data — no closures, no router, no
190
+ * storage — which is what lets one field definition drive `createSearchStore` (URL + storage) and
191
+ * `createLocalStore` (storage only) without a second description of the same param.
192
+ *
193
+ * @example
194
+ * const store = createSearchStore({
195
+ * key: 'analytics',
196
+ * fields: {
197
+ * range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
198
+ * compare: field.enum(['none', 'previous'], 'none'),
199
+ * channels: field.multi(CHANNELS, []),
200
+ * minDuration: field.number({ fallback: 0, min: 0 }, { persist: false }),
201
+ * compact: field.boolean(false, { url: false }),
202
+ * },
203
+ * })
204
+ */
205
+ export const field = {
206
+ /** A closed string enum — a tab, a compare mode, a single-select filter. */
207
+ enum<const T extends string, const L extends FieldLane = FieldLane>(
208
+ values: readonly T[],
209
+ fallback: NoInfer<T>,
210
+ lane?: L,
211
+ ): EnumField<T, ResolveLane<L>> {
212
+ return { kind: 'enum', values, fallback, lane: resolveLane(lane) as ResolveLane<L> }
213
+ },
214
+
215
+ /** An any-of set over the same closed enum. Decoding is deduped and canonically re-sorted. */
216
+ multi<const T extends string, const L extends FieldLane = FieldLane>(
217
+ values: readonly T[],
218
+ fallback: readonly NoInfer<T>[] = [],
219
+ lane?: L,
220
+ ): MultiField<T, ResolveLane<L>> {
221
+ return { kind: 'multi', values, fallback, lane: resolveLane(lane) as ResolveLane<L> }
222
+ },
223
+
224
+ /**
225
+ * A time window: THREE URL params (preset + `from` + `to`, each renamable through `params`), so a
226
+ * consumer's existing deep links and loaders keep their shape. `custom: true` additionally
227
+ * allows the preset `'custom'` with two ISO dates.
228
+ *
229
+ * A second `field.range` in the same store — or a second range store composed into the same
230
+ * route's `validateSearch` — MUST rename `from`/`to` via `params`: every range defaults to the
231
+ * literal param names `'from'`/`'to'`, and two fields silently sharing them makes the later one's
232
+ * `toSearch` overwrite the earlier one's dates. `createStoreCore` throws at definition for the
233
+ * in-store case; the cross-store case is not detectable by either store alone.
234
+ */
235
+ range<
236
+ const P extends string,
237
+ const C extends boolean = false,
238
+ const Pm extends RangeParams = RangeParams,
239
+ const L extends FieldLane = FieldLane,
240
+ >(
241
+ o: { presets: readonly P[]; fallback: NoInfer<P>; custom?: C; params?: Pm },
242
+ lane?: L,
243
+ ): RangeField<P, C, Pm, ResolveLane<L>> {
244
+ return {
245
+ kind: 'range',
246
+ presets: o.presets,
247
+ fallback: { preset: o.fallback },
248
+ // The only cast left, and it is narrow: `C` is inferred from this very literal, so the
249
+ // runtime boolean IS `C` — `?? false` is what the compiler cannot see through.
250
+ custom: (o.custom ?? false) as C,
251
+ params: (o.params ?? {}) as Pm,
252
+ lane: resolveLane(lane) as ResolveLane<L>,
253
+ }
254
+ },
255
+
256
+ /** A number — pagination, a threshold. Out-of-range input clamps to `min`/`max`. */
257
+ number<const L extends FieldLane = FieldLane>(
258
+ o: { fallback: number; min?: number; max?: number; int?: boolean },
259
+ lane?: L,
260
+ ): NumberField<ResolveLane<L>> {
261
+ return {
262
+ kind: 'number',
263
+ fallback: o.fallback,
264
+ min: o.min,
265
+ max: o.max,
266
+ int: o.int === true,
267
+ lane: resolveLane(lane) as ResolveLane<L>,
268
+ }
269
+ },
270
+
271
+ /** A toggle. Accepts `true`/`false` and the strings `'true'`/`'false'` from a hand-typed URL. */
272
+ boolean<const L extends FieldLane = FieldLane>(
273
+ fallback: boolean,
274
+ lane?: L,
275
+ ): BooleanField<ResolveLane<L>> {
276
+ return { kind: 'boolean', fallback, lane: resolveLane(lane) as ResolveLane<L> }
277
+ },
278
+
279
+ /** Free text — a search box. Defaults to `history: 'replace'` like every other field. */
280
+ string<const L extends FieldLane = FieldLane>(
281
+ o?: { fallback?: string; max?: number },
282
+ lane?: L,
283
+ ): StringField<ResolveLane<L>> {
284
+ return {
285
+ kind: 'string',
286
+ fallback: o?.fallback ?? '',
287
+ max: o?.max,
288
+ lane: resolveLane(lane) as ResolveLane<L>,
289
+ }
290
+ },
291
+ }
292
+
293
+ function resolveLane(lane: FieldLane | undefined): ResolvedLane {
294
+ return {
295
+ url: lane?.url !== false,
296
+ persist: lane?.persist !== false,
297
+ history: lane?.history === 'push' ? 'push' : 'replace',
298
+ }
299
+ }
300
+
301
+ // ── Flat search shapes — what a route's search actually holds ──────────────────────────────────
302
+
303
+ type UnionToIntersection<U> = (U extends unknown ? (x: U) => void : never) extends (
304
+ x: infer I,
305
+ ) => void
306
+ ? I
307
+ : never
308
+
309
+ type Prettify<T> = { [K in keyof T]: T[K] } & {}
310
+
311
+ type RangeParamName<Pm, K extends keyof RangeParams, D extends string> =
312
+ Pm extends Record<K, infer N> ? (N extends string ? N : D) : D
313
+
314
+ /** The param entries ONE field owns. A range field owns three; every other kind owns one. */
315
+ type FieldSearch<K extends string, F extends AnyField> = F extends {
316
+ kind: 'range'
317
+ presets: readonly (infer P)[]
318
+ custom: infer C
319
+ params: infer Pm
320
+ }
321
+ ? { [Q in RangeParamName<Pm, 'preset', K>]: RangePresets<P, C> } & {
322
+ [Q in RangeParamName<Pm, 'from', 'from'>]?: string | undefined
323
+ } & { [Q in RangeParamName<Pm, 'to', 'to'>]?: string | undefined }
324
+ : { [Q in K]: FieldValue<F> }
325
+
326
+ type FieldNames<S> = keyof S & string
327
+
328
+ type UrlFieldNames<S> = {
329
+ [K in FieldNames<S>]: S[K] extends { lane: { url: false } } ? never : K
330
+ }[FieldNames<S>]
331
+
332
+ /** The search object of a store's URL-lane fields — `validateSearch`'s return, flat. */
333
+ export type SearchValues<S extends Record<string, AnyField>> = Prettify<
334
+ UnionToIntersection<{ [K in UrlFieldNames<S>]: FieldSearch<K, S[K]> }[UrlFieldNames<S>]>
335
+ >
336
+
337
+ /** The same flat shape over EVERY field, both lanes — what `readStored()` can return. */
338
+ export type StoredValues<S extends Record<string, AnyField>> = Prettify<
339
+ UnionToIntersection<{ [K in FieldNames<S>]: FieldSearch<K, S[K]> }[FieldNames<S>]>
340
+ >
341
+
342
+ // ── Field codecs — one place that knows how a kind crosses the URL / storage boundary ──────────
343
+
344
+ /**
345
+ * Everything a store needs to move one field between the URL, localStorage and a control.
346
+ *
347
+ * @internal Reachable on `basalt-ui/state` only because `./router-tanstack`'s store is built on it
348
+ * — the two store factories are the public API, not this seam.
349
+ */
350
+ export type FieldCodec = {
351
+ readonly kind: AnyField['kind']
352
+ readonly lane: ResolvedLane
353
+ /** Every URL param this field owns. */
354
+ readonly params: readonly string[]
355
+ /**
356
+ * The param whose PRESENCE in the current search means "the matched route validates this field".
357
+ * A store's `validateSearch` always returns it, so a route that ran it has the key; a foreign
358
+ * route does not, and a write there persists only (A1).
359
+ */
360
+ readonly primary: string
361
+ readonly fallback: unknown
362
+ /**
363
+ * Validate a STORED (or caller-supplied) value. `null` = unusable.
364
+ *
365
+ * Deliberately a different function from `fromSearch` for one kind: the deprecated multi store
366
+ * reads an empty URL array as "absent", and reusing that rule on the storage side would turn a
367
+ * deliberately CLEARED filter into a miss and resurrect the fallback on upgrade.
368
+ */
369
+ decode(raw: unknown): unknown
370
+ /** Pull the field out of a router search object. `null` = absent or invalid. */
371
+ fromSearch(search: Record<string, unknown>): unknown
372
+ /** The param entries a write puts on the URL. `undefined` values clear a stale param. */
373
+ toSearch(value: unknown): Record<string, unknown>
374
+ equals(a: unknown, b: unknown): boolean
375
+ /** Option values in declaration order — enum, multi and range only. */
376
+ readonly optionValues: readonly string[]
377
+ }
378
+
379
+ const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}(?:T[\d:.]+(?:Z|[+-]\d{2}:\d{2})?)?$/
380
+
381
+ function isIsoDate(raw: unknown): raw is string {
382
+ return typeof raw === 'string' && ISO_DATE_RE.test(raw) && !Number.isNaN(Date.parse(raw))
383
+ }
384
+
385
+ /**
386
+ * A plain decimal — no blank/whitespace, no hex, no exponent. `Number('')`/`Number(' ')` are `0`
387
+ * and `Number('0x10')` is `16`, so a bare `Number(raw)` coercion silently manufactures a value the
388
+ * codec itself would never have written (`toSearch` never emits `''` or a hex literal) — a
389
+ * hand-edited or blank-linked `?count=` must fall through to the fallback, not become `0`.
390
+ */
391
+ const NUMERIC_RE = /^-?\d+(\.\d+)?$/
392
+
393
+ /** Module-scoped because it closes over nothing — `true`/`false`, or the two string forms. */
394
+ function decodeBoolean(raw: unknown): unknown {
395
+ if (typeof raw === 'boolean') return raw
396
+ if (raw === 'true') return true
397
+ if (raw === 'false') return false
398
+ return null
399
+ }
400
+
401
+ function sameList(a: unknown, b: unknown): boolean {
402
+ if (!Array.isArray(a) || !Array.isArray(b)) return false
403
+ return a.length === b.length && a.every((entry, i) => entry === b[i])
404
+ }
405
+
406
+ /**
407
+ * Build the codec for one field. `legacyMultiEmpty` restores the DEPRECATED multi-store's rule that
408
+ * an EMPTY url array means "absent, fall through to storage" — the deprecated wrapper's contract,
409
+ * never the new store's, where an empty selection is a value like any other.
410
+ *
411
+ * @internal — see `FieldCodec`.
412
+ */
413
+ export function resolveFieldCodec(
414
+ name: string,
415
+ f: AnyField,
416
+ opts?: { legacyMultiEmpty?: boolean },
417
+ ): FieldCodec {
418
+ switch (f.kind) {
419
+ case 'enum': {
420
+ const allowed = new Set<string>(f.values)
421
+ const decode = (raw: unknown): unknown =>
422
+ typeof raw === 'string' && allowed.has(raw) ? raw : null
423
+ return single(name, f, decode, { optionValues: f.values })
424
+ }
425
+ case 'multi': {
426
+ const present = new Set<string>(f.values)
427
+ const decode = (raw: unknown): unknown => {
428
+ if (!Array.isArray(raw)) return null
429
+ const picked = new Set<string>()
430
+ for (const entry of raw) {
431
+ if (typeof entry === 'string' && present.has(entry)) picked.add(entry)
432
+ }
433
+ return f.values.filter((value) => picked.has(value))
434
+ }
435
+ // The legacy empty-array rule is a URL rule ONLY. A stored `[]` is what "I cleared every
436
+ // filter" looks like, and the deprecated store has always read it back as `[]`.
437
+ const decodeUrl =
438
+ opts?.legacyMultiEmpty === true
439
+ ? (raw: unknown): unknown => {
440
+ const value = decode(raw)
441
+ return Array.isArray(value) && value.length === 0 ? null : value
442
+ }
443
+ : decode
444
+ return single(name, f, decode, {
445
+ optionValues: f.values,
446
+ equals: sameList,
447
+ decodeUrl,
448
+ })
449
+ }
450
+ case 'range': {
451
+ const presetParam = f.params.preset ?? name
452
+ const fromParam = f.params.from ?? 'from'
453
+ const toParam = f.params.to ?? 'to'
454
+ const allowed = new Set<string>(f.presets)
455
+ const decode = (raw: unknown): unknown => {
456
+ if (typeof raw !== 'object' || raw === null) return null
457
+ const value = raw as { preset?: unknown; from?: unknown; to?: unknown }
458
+ if (typeof value.preset !== 'string') return null
459
+ if (value.preset === 'custom') {
460
+ if (!f.custom || !isIsoDate(value.from) || !isIsoDate(value.to)) return null
461
+ return { preset: 'custom', from: value.from, to: value.to }
462
+ }
463
+ return allowed.has(value.preset) ? { preset: value.preset } : null
464
+ }
465
+ return {
466
+ kind: 'range',
467
+ lane: f.lane,
468
+ params: [presetParam, fromParam, toParam],
469
+ primary: presetParam,
470
+ fallback: f.fallback,
471
+ decode,
472
+ fromSearch: (search) =>
473
+ decode({
474
+ preset: search[presetParam],
475
+ from: search[fromParam],
476
+ to: search[toParam],
477
+ }),
478
+ toSearch: (value) => {
479
+ const range = value as RangeValue<string>
480
+ return {
481
+ [presetParam]: range.preset,
482
+ [fromParam]: range.from,
483
+ [toParam]: range.to,
484
+ }
485
+ },
486
+ equals: (a, b) => {
487
+ const x = a as RangeValue<string> | null
488
+ const y = b as RangeValue<string> | null
489
+ if (x === null || y === null) return x === y
490
+ return x.preset === y.preset && x.from === y.from && x.to === y.to
491
+ },
492
+ optionValues: f.custom ? [...f.presets, 'custom'] : f.presets,
493
+ }
494
+ }
495
+ case 'number': {
496
+ const decode = (raw: unknown): unknown => {
497
+ const parsed =
498
+ typeof raw === 'number'
499
+ ? raw
500
+ : typeof raw === 'string' && NUMERIC_RE.test(raw)
501
+ ? Number(raw)
502
+ : NaN
503
+ if (!Number.isFinite(parsed)) return null
504
+ if (f.int && !Number.isInteger(parsed)) return null
505
+ let value = parsed
506
+ if (f.min !== undefined && value < f.min) value = f.min
507
+ if (f.max !== undefined && value > f.max) value = f.max
508
+ return value
509
+ }
510
+ return single(name, f, decode)
511
+ }
512
+ case 'boolean': {
513
+ return single(name, f, decodeBoolean)
514
+ }
515
+ case 'string': {
516
+ const decode = (raw: unknown): unknown => {
517
+ if (typeof raw !== 'string') return null
518
+ return f.max !== undefined && raw.length > f.max ? raw.slice(0, f.max) : raw
519
+ }
520
+ return single(name, f, decode)
521
+ }
522
+ default: {
523
+ throw new Error('basalt-ui: createSearchStore received a field of an unknown kind')
524
+ }
525
+ }
526
+ }
527
+
528
+ /** Codec shell for every kind that owns exactly one URL param. */
529
+ function single(
530
+ name: string,
531
+ f: AnyField,
532
+ decode: (raw: unknown) => unknown,
533
+ extra?: {
534
+ optionValues?: readonly string[]
535
+ equals?: (a: unknown, b: unknown) => boolean
536
+ /** URL-side decoder when it differs from the storage-side one (multi, legacy layout only). */
537
+ decodeUrl?: (raw: unknown) => unknown
538
+ },
539
+ ): FieldCodec {
540
+ const decodeUrl = extra?.decodeUrl ?? decode
541
+ return {
542
+ kind: f.kind,
543
+ lane: f.lane,
544
+ params: [name],
545
+ primary: name,
546
+ fallback: f.fallback,
547
+ decode,
548
+ fromSearch: (search) => decodeUrl(search[name]),
549
+ toSearch: (value) => ({ [name]: value }),
550
+ equals: extra?.equals ?? ((a, b) => a === b),
551
+ optionValues: extra?.optionValues ?? [],
552
+ }
553
+ }
554
+
555
+ // ── Store core — the half `createSearchStore` and `createLocalStore` share ─────────────────────
556
+
557
+ type StoreRecord = Record<string, unknown>
558
+
559
+ /** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
560
+ export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
561
+
562
+ export type StoreCoreOptions = {
563
+ readonly key: string
564
+ readonly fields: Record<string, AnyField>
565
+ readonly version?: number | undefined
566
+ /**
567
+ * Legacy single-value storage layout: the whole envelope value IS this field's value, the shape
568
+ * `createSearchParamStore` has written since 1.0.0. Set ONLY by the deprecated wrappers — a
569
+ * consumer's already-persisted selection has to keep resolving byte-for-byte.
570
+ */
571
+ readonly legacyValueField?: string
572
+ }
573
+
574
+ /**
575
+ * The storage + resolution kernel. Deliberately NOT exported from any barrel: it is the seam
576
+ * between `./state`'s router-free store and `./router-tanstack`'s router-coupled one, and both of
577
+ * those are the public API.
578
+ *
579
+ * @internal — see `FieldCodec`.
580
+ */
581
+ export type StoreCore = {
582
+ readonly entries: readonly StoreEntry[]
583
+ readonly urlEntries: readonly StoreEntry[]
584
+ readonly anyPersisted: boolean
585
+ usePersistedRecord(): readonly [StoreRecord, (next: StoreRecord) => void]
586
+ readRecord(): StoreRecord
587
+ setLabels(map: Record<string, Record<string, string> | undefined>): void
588
+ optionsFor(entry: StoreEntry): readonly FieldOption[]
589
+ /** The resolution law, once: URL ⊳ localStorage ⊳ fallback. `search: null` skips the URL lane. */
590
+ resolve(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown
591
+ writeField(setRecord: (next: StoreRecord) => void, name: string, value: unknown): void
592
+ /**
593
+ * The IN-MEMORY lane's `use()` for one field — a field with neither the URL nor the mirror.
594
+ * Lives here rather than in either factory because BOTH have such a field: `createLocalStore`'s
595
+ * `persist: false`, and `createSearchStore`'s `{ url: false, persist: false }`.
596
+ */
597
+ memoryUse(entry: StoreEntry): () => readonly [unknown, (next: unknown) => void]
598
+ /** The memory lane's value for one field, resolved — its fallback while nothing was written. */
599
+ readMemoryValue(entry: StoreEntry): unknown
600
+ /** Subscribes to the whole memory lane. The snapshot is a write counter, so it is always stable. */
601
+ useMemoryVersion(): number
602
+ /** Drops every memory value, so the next read resolves to the fallback. What `useReset` calls. */
603
+ resetMemory(): void
604
+ /** Every stored field, flattened into URL-param shape, `undefined` entries dropped. */
605
+ readStoredFlat(): StoreRecord
606
+ handle(
607
+ entry: StoreEntry,
608
+ use: () => readonly [unknown, (next: unknown) => void],
609
+ ): FieldHandle<AnyField>
610
+ }
611
+
612
+ function isRecord(raw: unknown): raw is StoreRecord {
613
+ return typeof raw === 'object' && raw !== null && !Array.isArray(raw)
614
+ }
615
+
616
+ /** Copy `from` onto `into`, dropping `undefined` values (a stored range carries no from/to). */
617
+ function assignDefined(into: StoreRecord, from: StoreRecord): void {
618
+ for (const [key, value] of Object.entries(from)) {
619
+ if (value !== undefined) into[key] = value
620
+ }
621
+ }
622
+
623
+ /**
624
+ * The resolution law, in one place: URL ⊳ localStorage ⊳ fallback (C4). `search: null` skips the
625
+ * URL lane — what the local lane and `linkSearch` pass.
626
+ */
627
+ function resolveField(entry: StoreEntry, search: StoreRecord | null, record: StoreRecord): unknown {
628
+ const fromUrl = search === null ? null : entry.codec.fromSearch(search)
629
+ if (fromUrl !== null) return fromUrl
630
+ if (entry.codec.lane.persist) {
631
+ const stored = entry.codec.decode(record[entry.name])
632
+ if (stored !== null) return stored
633
+ }
634
+ return entry.codec.fallback
635
+ }
636
+
637
+ /**
638
+ * The IN-MEMORY lane, one per store: the home of a field that has neither the URL nor the
639
+ * localStorage mirror (`createLocalStore`'s `persist: false`, `createSearchStore`'s
640
+ * `{ url: false, persist: false }`). Session-scoped and SHARED across every mount of that store —
641
+ * two charts binding one field agree, and a remount inside the session still reads the value back —
642
+ * while nothing is written to or read from localStorage.
643
+ *
644
+ * An external store rather than `useState`, for exactly that sharing. The SNAPSHOT is a write
645
+ * counter and never the value: `useSyncExternalStore` requires a referentially stable snapshot, and
646
+ * a counter is stable by construction for every field kind, object values included.
647
+ */
648
+ function createMemoryLane(): {
649
+ subscribe: (cb: () => void) => () => void
650
+ getVersion: () => number
651
+ resolve: (entry: StoreEntry) => unknown
652
+ write: (name: string, value: unknown) => void
653
+ clear: () => void
654
+ } {
655
+ const values = new Map<string, unknown>()
656
+ const listeners = new Set<() => void>()
657
+ let version = 0
658
+
659
+ const bump = (): void => {
660
+ version += 1
661
+ for (const cb of listeners) cb()
662
+ }
663
+
664
+ return {
665
+ subscribe: (cb) => {
666
+ listeners.add(cb)
667
+ return () => {
668
+ listeners.delete(cb)
669
+ }
670
+ },
671
+ getVersion: () => version,
672
+ // `undefined` is the only "never written" marker the lane needs: no field kind has `undefined`
673
+ // as a legal value, so it can never collide with a written one.
674
+ resolve: (entry) => {
675
+ const stored = values.get(entry.name)
676
+ return stored === undefined ? entry.codec.fallback : stored
677
+ },
678
+ write: (name, value) => {
679
+ values.set(name, value)
680
+ bump()
681
+ },
682
+ clear: () => {
683
+ if (values.size === 0) return
684
+ values.clear()
685
+ bump()
686
+ },
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Two fields sharing a URL param name silently overwrite each other: `flatten` (search-store.ts)
692
+ * and `readStoredFlat` (below) both `Object.assign`/`assignDefined` per entry in declaration order,
693
+ * so a later field's `toSearch` clobbers an earlier one — a second `field.range` in the same store
694
+ * is the ordinary way to hit it, since every range defaults its `from`/`to` params to those literal
695
+ * names. Checked over ALL entries, not just the URL lane: a mirror-only range collides the same way
696
+ * through `readStoredFlat`.
697
+ */
698
+ function assertNoParamCollision(key: string, entries: readonly StoreEntry[]): void {
699
+ const owner = new Map<string, string>()
700
+ for (const entry of entries) {
701
+ for (const param of entry.codec.params) {
702
+ const existing = owner.get(param)
703
+ if (existing !== undefined && existing !== entry.name) {
704
+ throw new Error(
705
+ `basalt-ui: createSearchStore('${key}'): fields '${existing}' and '${entry.name}' both ` +
706
+ `own the URL param '${param}' — rename one via field.range({ params: { ... } }).`,
707
+ )
708
+ }
709
+ owner.set(param, entry.name)
710
+ }
711
+ }
712
+ }
713
+
714
+ /** @internal — the seam behind `createSearchStore` and `createLocalStore`. */
715
+ export function createStoreCore(o: StoreCoreOptions): StoreCore {
716
+ const version = o.version ?? 1
717
+ const legacy = o.legacyValueField
718
+ const entries: StoreEntry[] = Object.entries(o.fields).map(([name, f]) => ({
719
+ name,
720
+ codec: resolveFieldCodec(name, f, { legacyMultiEmpty: legacy !== undefined }),
721
+ }))
722
+ assertNoParamCollision(o.key, entries)
723
+ const urlEntries = entries.filter((entry) => entry.codec.lane.url)
724
+ const anyPersisted = entries.some((entry) => entry.codec.lane.persist)
725
+ const labels = new Map<string, Record<string, string>>()
726
+
727
+ // ONE localStorage entry per store, holding a `{ fieldName: value }` record — so every field of
728
+ // a store costs exactly one hook (`react/rules-of-hooks` forbids a per-field hook in a loop) and
729
+ // `useReset` can write every field in one go.
730
+ const usePersistedRaw = createPersistedState<unknown>({ key: o.key, version, initial: null })
731
+ const memory = createMemoryLane()
732
+
733
+ const toRecord = (raw: unknown): StoreRecord => {
734
+ if (legacy !== undefined) return raw === null || raw === undefined ? {} : { [legacy]: raw }
735
+ return isRecord(raw) ? raw : {}
736
+ }
737
+ const fromRecord = (record: StoreRecord): unknown =>
738
+ legacy !== undefined ? (record[legacy] ?? null) : record
739
+
740
+ const readRecord = (): StoreRecord => toRecord(readPersistedValue(o.key, version))
741
+
742
+ const optionsFor = (entry: StoreEntry): readonly FieldOption[] => {
743
+ const map = labels.get(entry.name)
744
+ return entry.codec.optionValues.map((value) => ({ value, label: map?.[value] ?? value }))
745
+ }
746
+
747
+ const writeField = (
748
+ setRecord: (next: StoreRecord) => void,
749
+ name: string,
750
+ value: unknown,
751
+ ): void => {
752
+ // Re-read rather than closing over the render-time record: two fields written in one tick
753
+ // would otherwise clobber each other.
754
+ setRecord({ ...readRecord(), [name]: value })
755
+ }
756
+
757
+ const handle = (
758
+ entry: StoreEntry,
759
+ use: () => readonly [unknown, (next: unknown) => void],
760
+ ): FieldHandle<AnyField> => {
761
+ const { codec } = entry
762
+ const built: Record<string, unknown> = {
763
+ kind: codec.kind,
764
+ fallback: codec.fallback,
765
+ use,
766
+ isDefault: (v: unknown) => codec.equals(v, codec.fallback),
767
+ }
768
+ if (codec.kind === 'range') {
769
+ built['toWindow'] = (v: RangeValue<string>) =>
770
+ v.preset === 'custom' && v.from !== undefined && v.to !== undefined
771
+ ? { from: v.from, to: v.to }
772
+ : { window: v.preset }
773
+ }
774
+ // A GETTER, not a snapshot, and installed with defineProperty rather than declared in the
775
+ // literal: `labels()` runs on the store after the handles exist, and an object spread would
776
+ // evaluate the getter once at spread time and freeze the unlabelled options.
777
+ Object.defineProperty(built, 'options', {
778
+ enumerable: true,
779
+ get: (): readonly FieldOption[] => optionsFor(entry),
780
+ })
781
+ return built as unknown as FieldHandle<AnyField>
782
+ }
783
+
784
+ return {
785
+ entries,
786
+ urlEntries,
787
+ anyPersisted,
788
+ usePersistedRecord() {
789
+ const [raw, setRaw] = usePersistedRaw()
790
+ return [toRecord(raw), (next: StoreRecord) => setRaw(fromRecord(next))] as const
791
+ },
792
+ readRecord,
793
+ memoryUse(entry) {
794
+ return () => {
795
+ // Subscribing to the VERSION (never to the value) is what re-renders every other mount of
796
+ // this field when one of them writes, with no snapshot-stability trap.
797
+ useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion)
798
+ return [memory.resolve(entry), (next: unknown) => memory.write(entry.name, next)] as const
799
+ }
800
+ },
801
+ readMemoryValue: (entry) => memory.resolve(entry),
802
+ useMemoryVersion: () =>
803
+ useSyncExternalStore(memory.subscribe, memory.getVersion, memory.getVersion),
804
+ resetMemory: () => memory.clear(),
805
+ setLabels(map) {
806
+ for (const [name, entry] of Object.entries(map)) {
807
+ if (entry !== undefined) labels.set(name, entry)
808
+ }
809
+ },
810
+ optionsFor,
811
+ resolve: resolveField,
812
+ writeField,
813
+ readStoredFlat() {
814
+ const record = readRecord()
815
+ const out: StoreRecord = {}
816
+ for (const entry of entries) {
817
+ const stored = entry.codec.decode(record[entry.name])
818
+ if (stored !== null) assignDefined(out, entry.codec.toSearch(stored))
819
+ }
820
+ return out
821
+ },
822
+ handle,
823
+ }
824
+ }
825
+
826
+ // ── createLocalStore — the router-free lane ────────────────────────────────────────────────────
827
+
828
+ /** `Pick<SearchStore<S>, 'field' | 'readStored' | 'labels'>` — the same handles, no router. */
829
+ export type LocalStore<S extends Record<string, AnyField>> = {
830
+ readonly field: { [K in keyof S]: FieldHandle<S[K]> }
831
+ readStored: () => Partial<StoredValues<S>>
832
+ /**
833
+ * Option labels for enum / multi / range fields — the same chainable contract
834
+ * `createSearchStore` has, so `SelectFilter`/`ViewTabs` read their option labels off a local
835
+ * store too. Call it once, at definition.
836
+ */
837
+ labels: (map: Partial<{ [K in keyof S]: Record<string, string> }>) => LocalStore<S>
838
+ }
839
+
840
+ /**
841
+ * A store with no URL lane and no router import: per-chart selects, a section's view tab, a compact
842
+ * toggle. Same `field.*` vocabulary and the same `FieldHandle` every basalt control binds to, so a
843
+ * control cannot tell the two stores apart — which is the point, and why the local lane is not an
844
+ * excuse to reach for `useState` (C3).
845
+ *
846
+ * A field's `url` lane is ignored here (there is no URL to write); `persist: false` is the
847
+ * IN-MEMORY lane — shared across mounts for the session, gone on reload, never in localStorage.
848
+ * That is the honest home for a value a reader should not be handed back tomorrow (a scratch
849
+ * comparison, a temporary drill-down) without dropping to `useState` and losing the handle.
850
+ *
851
+ * @example
852
+ * const chart = createLocalStore({
853
+ * key: 'momentum-chart',
854
+ * fields: {
855
+ * metric: field.enum(['load', 'volume'], 'load'),
856
+ * zoomed: field.boolean(false, { persist: false }), // in-memory, this session only
857
+ * },
858
+ * }).labels({ metric: { load: 'Load', volume: 'Volume' } })
859
+ * // <SelectFilter field={chart.field.metric} label="Metric" />
860
+ */
861
+ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
862
+ key: string
863
+ fields: S
864
+ version?: number
865
+ }): LocalStore<S> {
866
+ const core = createStoreCore({ key: o.key, fields: o.fields, version: o.version })
867
+
868
+ const persistedUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
869
+ const [record, setRecord] = core.usePersistedRecord()
870
+ return [
871
+ core.resolve(entry, null, record),
872
+ (next: unknown) => core.writeField(setRecord, entry.name, next),
873
+ ] as const
874
+ }
875
+
876
+ const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
877
+ for (const entry of core.entries) {
878
+ const use = entry.codec.lane.persist ? persistedUse(entry) : core.memoryUse(entry)
879
+ field[entry.name as keyof S] = core.handle(entry, use) as unknown as FieldHandle<S[keyof S]>
880
+ }
881
+
882
+ const store: LocalStore<S> = {
883
+ field,
884
+ readStored: () => core.readStoredFlat() as Partial<StoredValues<S>>,
885
+ labels: (map) => {
886
+ core.setLabels(map as Record<string, Record<string, string> | undefined>)
887
+ return store
888
+ },
889
+ }
890
+
891
+ return store
892
+ }