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,661 @@
1
+ /**
2
+ * `createSearchStore` under a REAL router — the half that cannot be tested headlessly: what
3
+ * `use()` reads on a deep link, whether a write navigates, and from where.
4
+ *
5
+ * A memory-history router with three routes covers the three positions a control can render in:
6
+ * the route that validates the params, a CHILD of it (A3 — no `from`, the param still resolves),
7
+ * and a sibling that knows nothing about the store (A1 — the write persists and does not navigate).
8
+ */
9
+ import { beforeEach, describe, expect, spyOn, test } from 'bun:test'
10
+ import type { ReactNode } from 'react'
11
+ import { act, cleanup, render, waitFor } from '@testing-library/react'
12
+ import {
13
+ Outlet,
14
+ RouterProvider,
15
+ createMemoryHistory,
16
+ createRootRoute,
17
+ createRoute,
18
+ createRouter,
19
+ } from '@tanstack/react-router'
20
+ import type { AnyField, FieldHandle } from '../state'
21
+ import { field } from './field'
22
+ import { createSearchStore } from './search-store'
23
+
24
+ type Probe = { value: unknown; set: (next: unknown) => void }
25
+ type Sink = { current: Probe | null }
26
+
27
+ function sink(): Sink {
28
+ return { current: null }
29
+ }
30
+
31
+ /** Renders nothing; publishes one field's `use()` result so a test can read and drive it. */
32
+ function fieldProbe<F extends AnyField>(handle: FieldHandle<F>, into: Sink) {
33
+ return function FieldProbe(): ReactNode {
34
+ const [value, set] = handle.use()
35
+ into.current = { value, set: set as (next: unknown) => void }
36
+ return null
37
+ }
38
+ }
39
+
40
+ type AppInput = {
41
+ validateSearch: (raw: Record<string, unknown>) => Record<string, unknown>
42
+ entry: string
43
+ Dashboard?: () => ReactNode
44
+ Detail?: () => ReactNode
45
+ Other?: () => ReactNode
46
+ }
47
+
48
+ const nothing = (): ReactNode => null
49
+
50
+ async function mountApp(input: AppInput) {
51
+ const Dashboard = input.Dashboard ?? nothing
52
+ const rootRoute = createRootRoute({ component: () => <Outlet /> })
53
+ const dashboardRoute = createRoute({
54
+ getParentRoute: () => rootRoute,
55
+ path: '/dashboard',
56
+ validateSearch: input.validateSearch,
57
+ component: () => (
58
+ <>
59
+ <Dashboard />
60
+ <Outlet />
61
+ </>
62
+ ),
63
+ })
64
+ const detailRoute = createRoute({
65
+ getParentRoute: () => dashboardRoute,
66
+ path: 'detail',
67
+ component: input.Detail ?? nothing,
68
+ })
69
+ const otherRoute = createRoute({
70
+ getParentRoute: () => rootRoute,
71
+ path: '/other',
72
+ component: input.Other ?? nothing,
73
+ })
74
+ const router = createRouter({
75
+ routeTree: rootRoute.addChildren([dashboardRoute.addChildren([detailRoute]), otherRoute]),
76
+ history: createMemoryHistory({ initialEntries: [input.entry] }),
77
+ })
78
+
79
+ render(<RouterProvider router={router} />)
80
+ await waitFor(() => {
81
+ expect(router.state.status).toBe('idle')
82
+ })
83
+ return router
84
+ }
85
+
86
+ function currentSearch(router: {
87
+ state: { location: { search: unknown } }
88
+ }): Record<string, unknown> {
89
+ return router.state.location.search as Record<string, unknown>
90
+ }
91
+
92
+ /** The `{ fieldName: value }` record the store persisted under `basalt:<key>`. */
93
+ function storedRecord(key: string): Record<string, unknown> | null {
94
+ const raw = localStorage.getItem(`basalt:${key}`)
95
+ if (raw === null) return null
96
+ return (JSON.parse(raw) as { value: Record<string, unknown> }).value
97
+ }
98
+
99
+ function persist(key: string, record: Record<string, unknown>): void {
100
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: 1, value: record }))
101
+ }
102
+
103
+ beforeEach(() => {
104
+ localStorage.clear()
105
+ })
106
+
107
+ describe('use() — reading', () => {
108
+ test('a deep link WINS over the localStorage mirror (A8)', async () => {
109
+ const store = createSearchStore({
110
+ key: 'r-deeplink',
111
+ fields: { range: field.enum(['1d', '7d', '30d'], '1d') },
112
+ })
113
+ persist('r-deeplink', { range: '30d' })
114
+ const probe = sink()
115
+
116
+ await mountApp({
117
+ validateSearch: store.validateSearch,
118
+ entry: '/dashboard?range=7d',
119
+ Dashboard: fieldProbe(store.field.range, probe),
120
+ })
121
+
122
+ // The enum-only store read its localStorage hook here and rendered '30d' — a shared link
123
+ // opened on the wrong window, and nothing said so.
124
+ expect(probe.current?.value).toBe('7d')
125
+ })
126
+
127
+ test('falls through to the mirror when the URL carries no value', async () => {
128
+ const store = createSearchStore({
129
+ key: 'r-mirror',
130
+ fields: { range: field.enum(['1d', '7d', '30d'], '1d') },
131
+ })
132
+ persist('r-mirror', { range: '30d' })
133
+ const probe = sink()
134
+
135
+ await mountApp({
136
+ validateSearch: store.validateSearch,
137
+ entry: '/dashboard',
138
+ Dashboard: fieldProbe(store.field.range, probe),
139
+ })
140
+
141
+ expect(probe.current?.value).toBe('30d')
142
+ })
143
+
144
+ test('falls through to the fallback when neither lane holds a value', async () => {
145
+ const store = createSearchStore({
146
+ key: 'r-fallback',
147
+ fields: { range: field.enum(['1d', '7d'], '7d') },
148
+ })
149
+ const probe = sink()
150
+
151
+ await mountApp({
152
+ validateSearch: store.validateSearch,
153
+ entry: '/dashboard',
154
+ Dashboard: fieldProbe(store.field.range, probe),
155
+ })
156
+
157
+ expect(probe.current?.value).toBe('7d')
158
+ })
159
+
160
+ test('a CHILD route reads the field with no `from` (A3)', async () => {
161
+ const store = createSearchStore({
162
+ key: 'r-child',
163
+ fields: { range: field.enum(['1d', '7d'], '1d') },
164
+ })
165
+ const probe = sink()
166
+
167
+ await mountApp({
168
+ validateSearch: store.validateSearch,
169
+ entry: '/dashboard/detail?range=7d',
170
+ Detail: fieldProbe(store.field.range, probe),
171
+ })
172
+
173
+ expect(probe.current?.value).toBe('7d')
174
+ })
175
+
176
+ test('a range field reads all three params back as one value', async () => {
177
+ const store = createSearchStore({
178
+ key: 'r-range',
179
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
180
+ })
181
+ const probe = sink()
182
+
183
+ await mountApp({
184
+ validateSearch: store.validateSearch,
185
+ entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01',
186
+ Dashboard: fieldProbe(store.field.range, probe),
187
+ })
188
+
189
+ expect(probe.current?.value).toEqual({
190
+ preset: 'custom',
191
+ from: '2026-01-01',
192
+ to: '2026-02-01',
193
+ })
194
+ })
195
+ })
196
+
197
+ describe('use() — writing', () => {
198
+ test('a write from the owning route lands in the URL AND in the mirror', async () => {
199
+ const store = createSearchStore({
200
+ key: 'r-write',
201
+ fields: { range: field.enum(['1d', '7d'], '7d') },
202
+ })
203
+ const probe = sink()
204
+
205
+ const router = await mountApp({
206
+ validateSearch: store.validateSearch,
207
+ entry: '/dashboard',
208
+ Dashboard: fieldProbe(store.field.range, probe),
209
+ })
210
+
211
+ await act(async () => {
212
+ probe.current?.set('1d')
213
+ })
214
+
215
+ await waitFor(() => {
216
+ expect(currentSearch(router)['range']).toBe('1d')
217
+ })
218
+ expect(storedRecord('r-write')).toEqual({ range: '1d' })
219
+ expect(probe.current?.value).toBe('1d')
220
+ })
221
+
222
+ test('a range write clears a stale from/to when the preset stops being custom', async () => {
223
+ const store = createSearchStore({
224
+ key: 'r-range-clear',
225
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
226
+ })
227
+ const probe = sink()
228
+
229
+ const router = await mountApp({
230
+ validateSearch: store.validateSearch,
231
+ entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01',
232
+ Dashboard: fieldProbe(store.field.range, probe),
233
+ })
234
+
235
+ await act(async () => {
236
+ probe.current?.set({ preset: '7d' })
237
+ })
238
+
239
+ await waitFor(() => {
240
+ expect(currentSearch(router)['range']).toBe('7d')
241
+ })
242
+ expect(currentSearch(router)['from']).toBeUndefined()
243
+ expect(currentSearch(router)['to']).toBeUndefined()
244
+ })
245
+
246
+ test('a write from a FOREIGN route persists only — it never navigates (A1)', async () => {
247
+ const store = createSearchStore({
248
+ key: 'r-foreign',
249
+ fields: { range: field.enum(['1d', '7d'], '7d') },
250
+ })
251
+ const probe = sink()
252
+
253
+ const router = await mountApp({
254
+ validateSearch: store.validateSearch,
255
+ entry: '/other',
256
+ Other: fieldProbe(store.field.range, probe),
257
+ })
258
+
259
+ await act(async () => {
260
+ probe.current?.set('1d')
261
+ })
262
+
263
+ expect(router.state.location.pathname).toBe('/other')
264
+ expect(currentSearch(router)['range']).toBeUndefined()
265
+ // …and `validateSearch` picks it up on the next visit to the owning route.
266
+ expect(storedRecord('r-foreign')).toEqual({ range: '1d' })
267
+ expect(store.validateSearch({})).toEqual({ range: '1d' })
268
+ })
269
+
270
+ test('`persist: false` writes the URL and never touches storage', async () => {
271
+ const store = createSearchStore({
272
+ key: 'r-nopersist',
273
+ fields: { page: field.number({ fallback: 1, int: true }, { persist: false }) },
274
+ })
275
+ const probe = sink()
276
+
277
+ const router = await mountApp({
278
+ validateSearch: store.validateSearch,
279
+ entry: '/dashboard',
280
+ Dashboard: fieldProbe(store.field.page, probe),
281
+ })
282
+
283
+ await act(async () => {
284
+ probe.current?.set(3)
285
+ })
286
+
287
+ await waitFor(() => {
288
+ expect(currentSearch(router)['page']).toBe(3)
289
+ })
290
+ expect(storedRecord('r-nopersist')).toBeNull()
291
+ })
292
+
293
+ test('`url: false` writes storage and never touches the URL', async () => {
294
+ const store = createSearchStore({
295
+ key: 'r-nourl',
296
+ fields: { compact: field.boolean(false, { url: false }) },
297
+ })
298
+ const probe = sink()
299
+
300
+ const router = await mountApp({
301
+ validateSearch: store.validateSearch,
302
+ entry: '/dashboard',
303
+ Dashboard: fieldProbe(store.field.compact, probe),
304
+ })
305
+
306
+ await act(async () => {
307
+ probe.current?.set(true)
308
+ })
309
+
310
+ await waitFor(() => {
311
+ expect(probe.current?.value).toBe(true)
312
+ })
313
+ expect(currentSearch(router)['compact']).toBeUndefined()
314
+ expect(storedRecord('r-nourl')).toEqual({ compact: true })
315
+ })
316
+ test('a `persist: false` write from a foreign route is a silent no-op — so it warns in dev', async () => {
317
+ const store = createSearchStore({
318
+ key: 'r-nowhere',
319
+ fields: { page: field.number({ fallback: 1, int: true }, { persist: false }) },
320
+ })
321
+ const probe = sink()
322
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
323
+
324
+ const router = await mountApp({
325
+ validateSearch: store.validateSearch,
326
+ entry: '/other',
327
+ Other: fieldProbe(store.field.page, probe),
328
+ })
329
+
330
+ await act(async () => {
331
+ probe.current?.set(3)
332
+ })
333
+
334
+ // No URL (this route validates no `page`), no mirror (the field opted out) — nothing happened.
335
+ expect(router.state.location.pathname).toBe('/other')
336
+ expect(currentSearch(router)['page']).toBeUndefined()
337
+ expect(storedRecord('r-nowhere')).toBeNull()
338
+ expect(warn).toHaveBeenCalledTimes(1)
339
+ expect(String(warn.mock.calls[0]?.[0])).toContain('persist: false')
340
+
341
+ // Once per field, not once per click.
342
+ await act(async () => {
343
+ probe.current?.set(4)
344
+ })
345
+ expect(warn).toHaveBeenCalledTimes(1)
346
+ warn.mockRestore()
347
+ })
348
+
349
+ test('a persisted field written from a foreign route does NOT warn — it has somewhere to go', async () => {
350
+ const store = createSearchStore({
351
+ key: 'r-nowhere-ok',
352
+ fields: { range: field.enum(['1d', '7d'], '7d') },
353
+ })
354
+ const probe = sink()
355
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
356
+
357
+ await mountApp({
358
+ validateSearch: store.validateSearch,
359
+ entry: '/other',
360
+ Other: fieldProbe(store.field.range, probe),
361
+ })
362
+ await act(async () => {
363
+ probe.current?.set('1d')
364
+ })
365
+
366
+ expect(storedRecord('r-nowhere-ok')).toEqual({ range: '1d' })
367
+ expect(warn).not.toHaveBeenCalled()
368
+ warn.mockRestore()
369
+ })
370
+ })
371
+
372
+ describe('store hooks', () => {
373
+ test('useValues returns every URL-lane param, resolved', async () => {
374
+ const store = createSearchStore({
375
+ key: 'r-values',
376
+ fields: {
377
+ range: field.enum(['1d', '7d'], '1d'),
378
+ compare: field.enum(['none', 'previous'], 'none'),
379
+ compact: field.boolean(false, { url: false }),
380
+ },
381
+ })
382
+ persist('r-values', { compare: 'previous', compact: true })
383
+ const seen: { current: Record<string, unknown> | null } = { current: null }
384
+
385
+ await mountApp({
386
+ validateSearch: store.validateSearch,
387
+ entry: '/dashboard?range=7d',
388
+ Dashboard: () => {
389
+ seen.current = store.useValues()
390
+ return null
391
+ },
392
+ })
393
+
394
+ expect(seen.current).toEqual({ range: '7d', compare: 'previous' })
395
+ })
396
+
397
+ test('useActiveCount counts the fields that differ from their fallback — both lanes', async () => {
398
+ const store = createSearchStore({
399
+ key: 'r-count',
400
+ fields: {
401
+ range: field.enum(['1d', '7d'], '1d'),
402
+ compare: field.enum(['none', 'previous'], 'none'),
403
+ compact: field.boolean(false, { url: false }),
404
+ },
405
+ })
406
+ persist('r-count', { compact: true })
407
+ let count = -1
408
+
409
+ await mountApp({
410
+ validateSearch: store.validateSearch,
411
+ entry: '/dashboard?range=7d',
412
+ Dashboard: () => {
413
+ count = store.useActiveCount()
414
+ return null
415
+ },
416
+ })
417
+
418
+ // range (URL) + compact (local); compare is still its fallback.
419
+ expect(count).toBe(2)
420
+ })
421
+
422
+ test('useReset returns every field to its fallback in ONE navigate call', async () => {
423
+ const store = createSearchStore({
424
+ key: 'r-reset',
425
+ fields: {
426
+ range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
427
+ compare: field.enum(['none', 'previous'], 'none'),
428
+ compact: field.boolean(false, { url: false }),
429
+ },
430
+ })
431
+ persist('r-reset', { compact: true })
432
+ let reset: (() => void) | null = null
433
+
434
+ const router = await mountApp({
435
+ validateSearch: store.validateSearch,
436
+ entry: '/dashboard?range=custom&from=2026-01-01&to=2026-02-01&compare=previous',
437
+ Dashboard: () => {
438
+ reset = store.useReset()
439
+ return null
440
+ },
441
+ })
442
+
443
+ const navigate = spyOn(router, 'navigate')
444
+ await act(async () => {
445
+ reset?.()
446
+ })
447
+
448
+ expect(navigate).toHaveBeenCalledTimes(1)
449
+ await waitFor(() => {
450
+ expect(currentSearch(router)['range']).toBe('30d')
451
+ })
452
+ expect(currentSearch(router)['compare']).toBe('none')
453
+ expect(currentSearch(router)['from']).toBeUndefined()
454
+ expect(storedRecord('r-reset')).toEqual({
455
+ range: { preset: '30d' },
456
+ compare: 'none',
457
+ compact: false,
458
+ })
459
+ navigate.mockRestore()
460
+ })
461
+
462
+ test('useReset from a FOREIGN route persists only — it never navigates (A1)', async () => {
463
+ const store = createSearchStore({
464
+ key: 'r-reset-foreign',
465
+ fields: {
466
+ range: field.enum(['1d', '7d'], '7d'),
467
+ compare: field.enum(['none', 'prev'], 'none'),
468
+ },
469
+ })
470
+ let reset: (() => void) | null = null
471
+ const probe = sink()
472
+
473
+ const router = await mountApp({
474
+ validateSearch: store.validateSearch,
475
+ entry: '/other',
476
+ Other: () => {
477
+ reset = store.useReset()
478
+ const [value, set] = store.field.range.use()
479
+ probe.current = { value, set: set as (next: unknown) => void }
480
+ return null
481
+ },
482
+ })
483
+
484
+ const navigate = spyOn(router, 'navigate')
485
+ await act(async () => {
486
+ reset?.()
487
+ })
488
+
489
+ expect(navigate).not.toHaveBeenCalled()
490
+ expect(router.state.location.pathname).toBe('/other')
491
+ expect(currentSearch(router)['range']).toBeUndefined()
492
+ expect(currentSearch(router)['compare']).toBeUndefined()
493
+
494
+ // …and a subsequent field write on that same foreign route still persists only — the reset
495
+ // did not leak the params into the URL and flip the route into looking like an owner.
496
+ await act(async () => {
497
+ probe.current?.set('1d')
498
+ })
499
+ expect(navigate).not.toHaveBeenCalled()
500
+ expect(currentSearch(router)['range']).toBeUndefined()
501
+ navigate.mockRestore()
502
+ })
503
+ })
504
+
505
+ /**
506
+ * Every navigate a store issues carries `resetScroll: false`. A filter is as often halfway down a
507
+ * page as it is in the bar, and the router treats a same-route search write as a navigation — so
508
+ * the default would scroll the reader back to the top on every change. Asserted on the OPTION and
509
+ * not on a scroll position: happy-dom evaluates no layout, so the scroll itself is invisible here.
510
+ */
511
+ describe('store writes never scroll the page', () => {
512
+ test('a field write passes resetScroll: false', async () => {
513
+ const store = createSearchStore({
514
+ key: 'r-scroll-field',
515
+ fields: { range: field.enum(['1d', '7d'], '7d') },
516
+ })
517
+ const probe = sink()
518
+
519
+ const router = await mountApp({
520
+ validateSearch: store.validateSearch,
521
+ entry: '/dashboard',
522
+ Dashboard: fieldProbe(store.field.range, probe),
523
+ })
524
+
525
+ const navigate = spyOn(router, 'navigate')
526
+ await act(async () => {
527
+ probe.current?.set('1d')
528
+ })
529
+
530
+ expect(navigate).toHaveBeenCalledTimes(1)
531
+ expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
532
+ navigate.mockRestore()
533
+ })
534
+
535
+ test('useReset passes resetScroll: false', async () => {
536
+ const store = createSearchStore({
537
+ key: 'r-scroll-reset',
538
+ fields: { range: field.enum(['1d', '7d'], '7d') },
539
+ })
540
+ let reset: (() => void) | null = null
541
+
542
+ const router = await mountApp({
543
+ validateSearch: store.validateSearch,
544
+ entry: '/dashboard?range=1d',
545
+ Dashboard: () => {
546
+ reset = store.useReset()
547
+ return null
548
+ },
549
+ })
550
+
551
+ const navigate = spyOn(router, 'navigate')
552
+ await act(async () => {
553
+ reset?.()
554
+ })
555
+
556
+ expect(navigate).toHaveBeenCalledTimes(1)
557
+ expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
558
+ navigate.mockRestore()
559
+ })
560
+ })
561
+
562
+ /**
563
+ * `{ url: false, persist: false }` — the third lane. It used to be the one combination that dropped
564
+ * its write (no URL to put it in, no mirror to keep it in); it is now the same session-shared
565
+ * in-memory lane `createLocalStore` gives such a field, through the same store-core helper.
566
+ */
567
+ const memoryStore = () =>
568
+ createSearchStore({
569
+ key: 'r-memory',
570
+ fields: {
571
+ range: field.enum(['1d', '7d'], '7d'),
572
+ scratch: field.enum(['a', 'b', 'c'], 'a', { url: false, persist: false }),
573
+ },
574
+ })
575
+
576
+ describe('the memory-only lane — url: false, persist: false', () => {
577
+ test('a write survives a remount, and touches neither the URL nor localStorage', async () => {
578
+ const store = memoryStore()
579
+ const probe = sink()
580
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
581
+
582
+ const router = await mountApp({
583
+ validateSearch: store.validateSearch,
584
+ entry: '/dashboard',
585
+ Dashboard: fieldProbe(store.field.scratch, probe),
586
+ })
587
+
588
+ await act(async () => {
589
+ probe.current?.set('c')
590
+ })
591
+ expect(probe.current?.value).toBe('c')
592
+
593
+ // No URL param (the field declares none, so `validateSearch` never emits one) and no mirror.
594
+ expect(currentSearch(router)['scratch']).toBeUndefined()
595
+ expect(storedRecord('r-memory')).toBeNull()
596
+ // …and no dev warning: the write went somewhere.
597
+ expect(warn).not.toHaveBeenCalled()
598
+ warn.mockRestore()
599
+
600
+ // A remount inside the same session reads it back — the lane is the store's, not the mount's.
601
+ cleanup()
602
+ const second = sink()
603
+ await mountApp({
604
+ validateSearch: store.validateSearch,
605
+ entry: '/dashboard',
606
+ Dashboard: fieldProbe(store.field.scratch, second),
607
+ })
608
+ expect(second.current?.value).toBe('c')
609
+ })
610
+
611
+ test('useActiveCount counts it, and useReset returns it to its fallback', async () => {
612
+ const store = memoryStore()
613
+ const probe = sink()
614
+ let count = -1
615
+ let reset: (() => void) | null = null
616
+
617
+ await mountApp({
618
+ validateSearch: store.validateSearch,
619
+ entry: '/dashboard',
620
+ Dashboard: () => {
621
+ count = store.useActiveCount()
622
+ reset = store.useReset()
623
+ return fieldProbe(store.field.scratch, probe)()
624
+ },
625
+ })
626
+
627
+ expect(count).toBe(0)
628
+
629
+ await act(async () => {
630
+ probe.current?.set('b')
631
+ })
632
+ expect(count).toBe(1)
633
+
634
+ await act(async () => {
635
+ reset?.()
636
+ })
637
+ expect(probe.current?.value).toBe('a')
638
+ expect(count).toBe(0)
639
+ // `useReset` wrote the MIRROR for the persisted field beside it; the memory field is not in it.
640
+ expect(storedRecord('r-memory')).toEqual({ range: '7d' })
641
+ })
642
+
643
+ test('two mounts of the same field see one value', async () => {
644
+ const store = memoryStore()
645
+ const a = sink()
646
+ const b = sink()
647
+
648
+ await mountApp({
649
+ validateSearch: store.validateSearch,
650
+ entry: '/dashboard/detail',
651
+ Dashboard: fieldProbe(store.field.scratch, a),
652
+ Detail: fieldProbe(store.field.scratch, b),
653
+ })
654
+
655
+ await act(async () => {
656
+ a.current?.set('b')
657
+ })
658
+
659
+ expect(b.current?.value).toBe('b')
660
+ })
661
+ })