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,546 @@
1
+ /**
2
+ * `createSearchStore` — one factory over typed fields, replacing the enum-only
3
+ * `createSearchParamStore` / `createMultiSearchParamStore` pair.
4
+ *
5
+ * The URL is the truth; the localStorage mirror is a fallback UNDER it. Every field resolves the
6
+ * same way — URL ⊳ localStorage ⊳ fallback (C4) — and declares its lanes once, at definition.
7
+ *
8
+ * Ground truth for the placement/persistence law this implements: `docs/CONTROLS-SPEC.md` §4.
9
+ * What used to be a 100-line recipe in this module's sibling is that spec plus `agent/rules/
10
+ * basalt-state.md`; the API below is what those paragraphs were waiting for.
11
+ *
12
+ * Two behaviours worth knowing before reading the code:
13
+ *
14
+ * - **A deep link wins over the mirror.** `?range=7d` reads back `7d` even when localStorage says
15
+ * `30d`. The enum-only pair could not do this: its reader hook was the localStorage state, so a
16
+ * shared link opened on the wrong window (A8).
17
+ * - **A field with neither lane is in memory, not nowhere.** `{ url: false, persist: false }` is
18
+ * the memory-only lane: session-scoped, shared across every mount of the store, gone on reload —
19
+ * the same lane `createLocalStore` gives such a field, through the same store-core helper. It is
20
+ * the honest home for a value a reader should not be handed back tomorrow.
21
+ * - **A write from outside the owning route persists only.** `use()`'s setter navigates only when
22
+ * the matched route validates the param — detected by the param key being PRESENT in the current
23
+ * search, which a route running this store's `validateSearch` always is (it returns every
24
+ * URL-lane param unconditionally) and a foreign route is not. `validateSearch` then picks the
25
+ * persisted value up on the next visit (A1). The honest limit of that detection: two DIFFERENT
26
+ * stores using the same param name on overlapping routes read as one owner. Presence is what a
27
+ * store can see without the router telling it which route declared what, and the alternative — a
28
+ * context flag — would need a provider per store, which is the thing this API does not have.
29
+ *
30
+ * Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and `useRouterBreadcrumbs`.
31
+ */
32
+ import { useNavigate, useSearch } from '@tanstack/react-router'
33
+ import { createStoreCore } from '../state'
34
+ import type {
35
+ AnyField,
36
+ FieldHandle,
37
+ FieldSetOptions,
38
+ FieldWrite,
39
+ SearchValues,
40
+ StoreEntry,
41
+ StoredValues,
42
+ } from '../state'
43
+
44
+ // ── Types ──────────────────────────────────────────────────────────────────
45
+
46
+ export type CreateSearchStoreOptions<S extends Record<string, AnyField>> = {
47
+ /** localStorage key (namespaced `basalt:<key>` automatically). One entry per store. */
48
+ key: string
49
+ /** The fields, built with `field.*`. The key of each is its default URL param name. */
50
+ fields: S
51
+ /** Envelope version — bump when the field set changes to discard stale localStorage. */
52
+ version?: number
53
+ }
54
+
55
+ export type SearchStore<S extends Record<string, AnyField>> = {
56
+ /**
57
+ * Pass directly to `createFileRoute({ validateSearch })`. Returns every URL-lane param, resolved
58
+ * URL ⊳ localStorage ⊳ fallback — a route wired to it therefore always has the params, which is
59
+ * what makes `use()`'s "does this route own the field" check trivial and exact.
60
+ */
61
+ validateSearch: (raw: Record<string, unknown>) => SearchValues<S>
62
+ /**
63
+ * The click-time `search:` value for a nav link. Pass it BY REFERENCE
64
+ * (`search: store.linkSearch`) inside `defineNav` — never a literal object, which pins the
65
+ * fallback on every click and is why one consumer's reader had zero call sites (C10, A2).
66
+ */
67
+ linkSearch: () => SearchValues<S>
68
+ /** Plain read for non-React contexts (a redirect, a loader, a guard, a test). */
69
+ readStored: () => Partial<StoredValues<S>>
70
+ /** The per-field handles every basalt control takes instead of `value`/`onChange` (C2). */
71
+ field: { [K in keyof S]: FieldHandle<S[K]> }
72
+ /** Option labels for enum / multi / range fields. Chainable — call it once, at definition. */
73
+ labels: (map: Partial<{ [K in keyof S]: Record<string, string> }>) => SearchStore<S>
74
+ /** Every URL-lane param, resolved — the object a page hands to its query. */
75
+ useValues: () => SearchValues<S>
76
+ /** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
77
+ useActiveCount: () => number
78
+ /**
79
+ * Returns every field to its fallback: one navigate, one storage write. The mirror is UNSET
80
+ * rather than written — see `FieldHandle.clear()` for why writing the fallback pins a thunk.
81
+ */
82
+ useReset: () => () => void
83
+ }
84
+
85
+ /**
86
+ * Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
87
+ * neither reader has ever been called. That combination has exactly one realistic cause — a nav
88
+ * link declaring the param as a literal — and it is invisible otherwise, because every individual
89
+ * piece looks correct and the feature merely never remembers anything.
90
+ *
91
+ * Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
92
+ * store, and it cannot say what to do about it. This fires only in the broken state, and names the
93
+ * fix.
94
+ */
95
+ function warnLinkPinsFallback(input: {
96
+ key: string
97
+ param: string
98
+ urlValue: string
99
+ storedValue: string
100
+ }): void {
101
+ const { key, param, urlValue, storedValue } = input
102
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
103
+ console.warn(
104
+ `[basalt-ui] createSearchStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
105
+ '(the fallback) ' +
106
+ `while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
107
+ `almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
108
+ "overrides the stored value on every click — pass the store's own reader instead: " +
109
+ '`search: <store>.linkSearch`. (dev only)',
110
+ )
111
+ }
112
+
113
+ // ── Implementation ─────────────────────────────────────────────────────────
114
+
115
+ /** Reads the merged search of every matched route, so a child or sibling needs no `from` (A3). */
116
+ function useCurrentSearch(): Record<string, unknown> {
117
+ const search = useSearch({ strict: false }) as unknown
118
+ return typeof search === 'object' && search !== null ? (search as Record<string, unknown>) : {}
119
+ }
120
+
121
+ function describeValue(value: unknown): string {
122
+ return typeof value === 'string' ? value : JSON.stringify(value)
123
+ }
124
+
125
+ /**
126
+ * Dev-only, once per field: a `persist: false` field was written from a route that does not
127
+ * validate it, so the write had nowhere to go — no URL (the route owns no such param) and no mirror
128
+ * (the field opted out of one). Silence is the wrong answer here: every other lane combination does
129
+ * something, so a control that appears to work and changes nothing reads as a basalt bug.
130
+ */
131
+ function warnWriteHasNowhereToGo(input: { key: string; name: string; param: string }): void {
132
+ const { key, name, param } = input
133
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
134
+ console.warn(
135
+ `[basalt-ui] createSearchStore('${key}'): wrote field '${name}' from a route that does not ` +
136
+ `validate \`${param}\`, and the field is \`persist: false\` — so the write was dropped. ` +
137
+ 'Either render this control on the route wired to `validateSearch`, or give the field the ' +
138
+ 'localStorage lane (drop `persist: false`) so a write from elsewhere survives until the ' +
139
+ 'owning route reads it. (dev only)',
140
+ )
141
+ }
142
+
143
+ /**
144
+ * Dev-only, once per param: a `patch` named a search param ANOTHER field of the same store owns.
145
+ * The navigate would put it in the URL while the store's own write path never touched the mirror,
146
+ * so the next paramless visit and every `linkSearch` link resolve the OLD value — the URL and the
147
+ * mirror disagree from that click on, and nothing looks broken until a reader shares a link.
148
+ *
149
+ * Thrown in dev rather than warned: the call is a mistake with a one-line fix (write the other
150
+ * field through its own setter), and the damage is silent. Production keeps the write — a thrown
151
+ * error there would take a page down over a stale param — and logs it once.
152
+ */
153
+ function patchOwnsFieldMessage(input: {
154
+ key: string
155
+ name: string
156
+ owner: string
157
+ param: string
158
+ }): string {
159
+ const { key, name, owner, param } = input
160
+ return (
161
+ `basalt-ui: createSearchStore('${key}'): set('${name}', …, { patch }) carries \`${param}\`, ` +
162
+ `which this store's field '${owner}' owns — the URL would take it while the localStorage ` +
163
+ 'mirror kept the old value, so the next paramless visit and every `linkSearch` link disagree ' +
164
+ `with the page the write happened on. Write it through \`field.${owner}.use()[1]\` instead; ` +
165
+ 'a `patch` is for keys the store does NOT own.'
166
+ )
167
+ }
168
+
169
+ /**
170
+ * Dev-only, once per field: a patched write from a route that does not validate the field. The
171
+ * write persists (A1), but the patch has no navigate to merge into and is dropped — the one case
172
+ * where half of a two-part write silently disappears.
173
+ */
174
+ function warnPatchDropped(input: { key: string; name: string; param: string }): void {
175
+ const { key, name, param } = input
176
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
177
+ console.warn(
178
+ `[basalt-ui] createSearchStore('${key}'): wrote field '${name}' with a \`patch\` from a route ` +
179
+ `that does not validate \`${param}\` — the field's value persisted (A1), but the patch was ` +
180
+ 'DROPPED: there is no navigate to merge it into. Render the control on the route wired to ' +
181
+ '`validateSearch`, or apply that sibling param on the route that owns it. (dev only)',
182
+ )
183
+ }
184
+
185
+ /**
186
+ * The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
187
+ * consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
188
+ * the pinned-link warning it never used to see.
189
+ *
190
+ * @internal — absent from `SearchStore`, so it is invisible on `createSearchStore`'s return type.
191
+ */
192
+ export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStore<S> & {
193
+ markReaderWired: () => void
194
+ }
195
+
196
+ /**
197
+ * A thunk fallback belongs to the LOCAL and MEMORY lanes only. On the URL lane `validateSearch`
198
+ * would evaluate it on every navigation and write the result into the URL, so a deep link would
199
+ * carry a value nobody picked and two visits a second apart would disagree. Thrown at definition
200
+ * rather than warned at read time: the field set is static, so there is no run in which this is
201
+ * anything but a mistake.
202
+ */
203
+ function assertNoLazyUrlFallback(key: string, fields: Record<string, AnyField>): void {
204
+ for (const [name, f] of Object.entries(fields)) {
205
+ if (typeof f.fallback !== 'function' || !f.lane.url) continue
206
+ throw new Error(
207
+ `basalt-ui: createSearchStore('${key}'): field '${name}' has a thunk fallback on the URL ` +
208
+ 'lane — `validateSearch` would evaluate it on every navigation and pin the result into the ' +
209
+ 'URL. Pass a value instead, or move the field off the URL lane (`{ url: false }`, or ' +
210
+ '`createLocalStore`), where a lazy fallback is resolved per read.',
211
+ )
212
+ }
213
+ }
214
+
215
+ /** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
216
+ * session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
217
+ function isMemoryLane(entry: StoreEntry): boolean {
218
+ return !entry.codec.lane.url && !entry.codec.lane.persist
219
+ }
220
+
221
+ /**
222
+ * The store, plus the one knob the deprecated wrappers need: `legacyValueField` keeps the
223
+ * enum-only stores' single-value storage layout so an already-persisted selection still resolves.
224
+ * Not exported from the barrel — `createSearchStore` is the public door.
225
+ */
226
+ export function buildSearchStore<const S extends Record<string, AnyField>>(
227
+ o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
228
+ ): InternalSearchStore<S> {
229
+ assertNoLazyUrlFallback(o.key, o.fields)
230
+ const core = createStoreCore(o)
231
+
232
+ // `validateSearch` reads storage on every navigation, which says nothing about whether the
233
+ // CONSUMER wired a reader. Everything a consumer can read THROUGH sets this: `linkSearch`,
234
+ // `readStored`, and `field.<name>.use()` — the documented reader, and the one a page that never
235
+ // touches the other two uses exclusively. Missing it there made the pinned-link warning fire on
236
+ // correctly wired pages.
237
+ let readerWired = false
238
+ let warned = false
239
+ const markReaderWired = (): void => {
240
+ readerWired = true
241
+ }
242
+ const nowhereWarned = new Set<string>()
243
+ const patchDroppedWarned = new Set<string>()
244
+ const patchOwnerLogged = new Set<string>()
245
+
246
+ /** Every URL param this store owns, mapped to the field that owns it — what a `patch` may not name. */
247
+ const paramOwners = new Map<string, string>()
248
+ for (const entry of core.urlEntries) {
249
+ for (const param of entry.codec.params) paramOwners.set(param, entry.name)
250
+ }
251
+ const persistedNames = new Set(
252
+ core.entries.filter((entry) => entry.codec.lane.persist).map((entry) => entry.name),
253
+ )
254
+
255
+ /**
256
+ * The URL lane's `clear()` needs a `navigate`, which only exists inside a render. Each render of
257
+ * `urlUse` therefore leaves the navigate its mount would use here, keyed by field — so `clear()`
258
+ * from an event handler on that mount navigates exactly as its setter would, and a field no mount
259
+ * has read falls back to clearing the mirror alone.
260
+ */
261
+ const urlClear = new Map<string, () => void>()
262
+
263
+ const assertPatchNotOwned = (entry: StoreEntry, patch: Record<string, unknown>): void => {
264
+ for (const param of Object.keys(patch)) {
265
+ const owner = paramOwners.get(param)
266
+ // The field's OWN params are harmless: its `toSearch` is spread after the patch and wins.
267
+ if (owner === undefined || entry.codec.params.includes(param)) continue
268
+ const message = patchOwnsFieldMessage({ key: o.key, name: entry.name, owner, param })
269
+ if (process.env['NODE_ENV'] !== 'production') throw new Error(message)
270
+ if (patchOwnerLogged.has(param)) continue
271
+ patchOwnerLogged.add(param)
272
+ // oxlint-disable-next-line no-console -- production keeps the write; the log is the record
273
+ console.error(message)
274
+ }
275
+ }
276
+
277
+ const flatten = (
278
+ search: Record<string, unknown> | null,
279
+ record: Record<string, unknown>,
280
+ ): Record<string, unknown> => {
281
+ const out: Record<string, unknown> = {}
282
+ for (const entry of core.urlEntries) {
283
+ Object.assign(out, entry.codec.toSearch(core.resolve(entry, search, record)))
284
+ }
285
+ return out
286
+ }
287
+
288
+ const maybeWarn = (raw: Record<string, unknown>, record: Record<string, unknown>): void => {
289
+ if (warned || readerWired || process.env['NODE_ENV'] === 'production') return
290
+ for (const entry of core.urlEntries) {
291
+ const fromUrl = entry.codec.fromSearch(raw)
292
+ if (fromUrl === null || !entry.codec.equals(fromUrl, entry.codec.fallback)) continue
293
+ const stored = entry.codec.decode(record[entry.name])
294
+ if (stored === null || entry.codec.equals(stored, fromUrl)) continue
295
+ // Describe both sides as the URL spells them (a range prints `30d`, not its whole value
296
+ // object) — the message quotes a `search:` literal, so it has to be URL-shaped to be advice.
297
+ warned = true
298
+ warnLinkPinsFallback({
299
+ key: o.key,
300
+ param: entry.codec.primary,
301
+ urlValue: describeValue(entry.codec.toSearch(fromUrl)[entry.codec.primary]),
302
+ storedValue: describeValue(entry.codec.toSearch(stored)[entry.codec.primary]),
303
+ })
304
+ return
305
+ }
306
+ }
307
+
308
+ const validateSearch = (raw: Record<string, unknown>): SearchValues<S> => {
309
+ const record = core.readRecord()
310
+ maybeWarn(raw, record)
311
+ return flatten(raw, record) as SearchValues<S>
312
+ }
313
+
314
+ const linkSearch = (): SearchValues<S> => {
315
+ readerWired = true
316
+ return flatten(null, core.readRecord()) as SearchValues<S>
317
+ }
318
+
319
+ const readStored = (): Partial<StoredValues<S>> => {
320
+ readerWired = true
321
+ return core.readStoredFlat() as Partial<StoredValues<S>>
322
+ }
323
+
324
+ const urlUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
325
+ const search = useCurrentSearch()
326
+ const navigate = useNavigate()
327
+ const [record, setRecord] = core.usePersistedRecord()
328
+ markReaderWired()
329
+ const value = core.resolve(entry, search, record)
330
+ // The matched route validates this field iff its primary param is present (see the module
331
+ // header). A foreign route gets the persist-only lane.
332
+ const validated = Object.hasOwn(search, entry.codec.primary)
333
+ urlClear.set(entry.name, () => {
334
+ if (!validated) return
335
+ navigate({
336
+ to: '.',
337
+ search: (prev: Record<string, unknown>) => ({
338
+ ...prev,
339
+ ...entry.codec.toSearch(entry.codec.fallback),
340
+ }),
341
+ replace: true,
342
+ resetScroll: false,
343
+ })
344
+ })
345
+
346
+ return [
347
+ value,
348
+ (next: unknown, opts?: FieldSetOptions) => {
349
+ const patch = opts?.patch
350
+ if (patch !== undefined && Object.keys(patch).length > 0) {
351
+ assertPatchNotOwned(entry, patch)
352
+ if (
353
+ !validated &&
354
+ !patchDroppedWarned.has(entry.name) &&
355
+ process.env['NODE_ENV'] !== 'production'
356
+ ) {
357
+ patchDroppedWarned.add(entry.name)
358
+ warnPatchDropped({ key: o.key, name: entry.name, param: entry.codec.primary })
359
+ }
360
+ }
361
+ if (validated) {
362
+ navigate({
363
+ to: '.',
364
+ // `patch` FIRST, the field's own params last: a patch is for keys the store does not
365
+ // own (clearing a sibling `detailDate` with `undefined`), so it must never be able to
366
+ // overwrite the value this very call is setting.
367
+ search: (prev: Record<string, unknown>) => ({
368
+ ...prev,
369
+ ...opts?.patch,
370
+ ...entry.codec.toSearch(next),
371
+ }),
372
+ replace: entry.codec.lane.history === 'replace',
373
+ // A filter lives halfway down a page as often as it lives in the bar, and the router's
374
+ // default scroll restoration treats this same-route search write as a navigation — so
375
+ // without this a select two screens down jumps the reader back to the top on every
376
+ // change. Never a prop: there is no filter write that WANTS the page to scroll.
377
+ resetScroll: false,
378
+ })
379
+ }
380
+ if (entry.codec.lane.persist) {
381
+ core.writeField(setRecord, entry.name, next)
382
+ return
383
+ }
384
+ if (validated || nowhereWarned.has(entry.name)) return
385
+ if (process.env['NODE_ENV'] === 'production') return
386
+ nowhereWarned.add(entry.name)
387
+ warnWriteHasNowhereToGo({ key: o.key, name: entry.name, param: entry.codec.primary })
388
+ },
389
+ ] as const
390
+ }
391
+
392
+ // No `patch` here or in the memory lane: neither navigates, so there is no search object to merge
393
+ // into and nothing to silently half-apply.
394
+ const localUse = (entry: StoreEntry) => (): readonly [unknown, FieldWrite] => {
395
+ const [record, setRecord] = core.usePersistedRecord()
396
+ markReaderWired()
397
+ return [
398
+ core.resolve(entry, null, record),
399
+ (next: unknown) => core.writeField(setRecord, entry.name, next),
400
+ ] as const
401
+ }
402
+
403
+ // Three lanes, decided once per field at definition: URL (+ optional mirror), mirror-only, and
404
+ // memory-only. The third used to be the one combination that dropped its write.
405
+ const memoryEntries = core.entries.filter(isMemoryLane)
406
+
407
+ /**
408
+ * Unsetting one field, per lane: drop the mirror key (never write the fallback — a thunk fallback
409
+ * resolved into localStorage is a value nobody chose), drop the memory value, and on the URL lane
410
+ * navigate back to the fallback params through the last render of `use()`.
411
+ */
412
+ const clearFor = (entry: StoreEntry) => (): void => {
413
+ if (entry.codec.lane.persist) core.clearField(entry.name)
414
+ else if (!entry.codec.lane.url) core.clearMemory(entry.name)
415
+ if (!entry.codec.lane.url) return
416
+ const clearUrl = urlClear.get(entry.name)
417
+ if (clearUrl !== undefined) clearUrl()
418
+ }
419
+
420
+ const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
421
+ for (const entry of core.entries) {
422
+ const use = entry.codec.lane.url
423
+ ? urlUse(entry)
424
+ : entry.codec.lane.persist
425
+ ? localUse(entry)
426
+ : core.memoryUse(entry)
427
+ field[entry.name as keyof S] = core.handle(
428
+ entry,
429
+ use,
430
+ clearFor(entry),
431
+ ) as unknown as FieldHandle<S[keyof S]>
432
+ }
433
+
434
+ const useValues = (): SearchValues<S> => {
435
+ const search = useCurrentSearch()
436
+ const [record] = core.usePersistedRecord()
437
+ markReaderWired()
438
+ return flatten(search, record) as SearchValues<S>
439
+ }
440
+
441
+ const useActiveCount = (): number => {
442
+ const search = useCurrentSearch()
443
+ const [record] = core.usePersistedRecord()
444
+ // A memory field is as much a filter as any other, so the `Filters (n)` pill has to count it —
445
+ // which means subscribing to the lane, or the count would go stale the moment one is written.
446
+ core.useMemoryVersion()
447
+ let count = 0
448
+ for (const entry of core.entries) {
449
+ const value = isMemoryLane(entry)
450
+ ? core.readMemoryValue(entry)
451
+ : core.resolve(entry, entry.codec.lane.url ? search : null, record)
452
+ if (!entry.codec.equals(value, entry.codec.fallback)) count += 1
453
+ }
454
+ return count
455
+ }
456
+
457
+ const useReset = (): (() => void) => {
458
+ const search = useCurrentSearch()
459
+ const navigate = useNavigate()
460
+ const [, setRecord] = core.usePersistedRecord()
461
+
462
+ return () => {
463
+ // Same "does this route own the field" gate as `urlUse` — only reset the params a foreign
464
+ // route's `validateSearch` actually validates, or the reset navigates there and pollutes it.
465
+ const validatedEntries = core.urlEntries.filter((entry) =>
466
+ Object.hasOwn(search, entry.codec.primary),
467
+ )
468
+ if (validatedEntries.length > 0) {
469
+ const patch: Record<string, unknown> = {}
470
+ for (const entry of validatedEntries) {
471
+ Object.assign(patch, entry.codec.toSearch(entry.codec.fallback))
472
+ }
473
+ navigate({
474
+ to: '.',
475
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...patch }),
476
+ replace: true,
477
+ // Same reason as a single field's write above — a `Reset all` pressed from the mobile
478
+ // sheet must not also scroll the page it was pressed on.
479
+ resetScroll: false,
480
+ })
481
+ }
482
+ if (memoryEntries.length > 0) core.resetMemory()
483
+ if (!core.anyPersisted) return
484
+ // Reset means UNSET, not "write the fallback": `codec.fallback` is the RESOLVED value, so a
485
+ // thunk (`() => todayIso()`) would be pinned into the mirror by the very act of resetting and
486
+ // read back tomorrow as a choice nobody made. Deleting the key lets `resolve` fall through to
487
+ // the live fallback — which is what `resetMemory` above has always done for the memory lane.
488
+ const record = core.readRecord()
489
+ const next: Record<string, unknown> = {}
490
+ let dropped = false
491
+ for (const [name, value] of Object.entries(record)) {
492
+ if (persistedNames.has(name)) {
493
+ dropped = true
494
+ continue
495
+ }
496
+ next[name] = value
497
+ }
498
+ if (dropped) setRecord(next)
499
+ }
500
+ }
501
+
502
+ const store: InternalSearchStore<S> = {
503
+ validateSearch,
504
+ linkSearch,
505
+ readStored,
506
+ field,
507
+ labels: (map) => {
508
+ core.setLabels(map as Record<string, Record<string, string> | undefined>)
509
+ return store
510
+ },
511
+ useValues,
512
+ useActiveCount,
513
+ useReset,
514
+ markReaderWired,
515
+ }
516
+
517
+ return store
518
+ }
519
+
520
+ /**
521
+ * One store per page (or per feature), over typed fields. `key` is the localStorage key; each
522
+ * field's map key is its default URL param name.
523
+ *
524
+ * @example
525
+ * export const analytics = createSearchStore({
526
+ * key: 'analytics',
527
+ * fields: {
528
+ * range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
529
+ * compare: field.enum(['none', 'previous'], 'none'),
530
+ * channels: field.multi(CHANNELS, []),
531
+ * },
532
+ * }).labels({ range: { '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days' } })
533
+ *
534
+ * export const Route = createFileRoute('/analytics')({
535
+ * validateSearch: analytics.validateSearch,
536
+ * loaderDeps: ({ search }) => search,
537
+ * })
538
+ *
539
+ * // in the page: <RangeFilter field={analytics.field.range} />
540
+ * // in the nav: linkOptions({ to: '/analytics', search: analytics.linkSearch })
541
+ */
542
+ export function createSearchStore<const S extends Record<string, AnyField>>(
543
+ o: CreateSearchStoreOptions<S>,
544
+ ): SearchStore<S> {
545
+ return buildSearchStore(o)
546
+ }