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,882 @@
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, FieldSetOptions } from '../state'
21
+ import { field } from './field'
22
+ import { createSearchStore } from './search-store'
23
+
24
+ type Probe = { value: unknown; set: (next: unknown, opts?: FieldSetOptions) => 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 Probe['set'] }
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, UNSETTING the mirror', 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
+ // Reset means UNSET: the persisted keys are DELETED, not overwritten with the resolved
455
+ // fallback — writing it would pin a thunk fallback into the mirror (see the lazy-fallback test
456
+ // below) and leave the field counted as active tomorrow.
457
+ expect(storedRecord('r-reset')).toEqual({})
458
+ navigate.mockRestore()
459
+ })
460
+
461
+ test('useReset UNSETS a thunk fallback rather than pinning it into the mirror', async () => {
462
+ let today = '2026-08-27'
463
+ const store = createSearchStore({
464
+ key: 'r-lazy-reset',
465
+ fields: {
466
+ range: field.enum(['1d', '7d'], '7d'),
467
+ since: field.string({ fallback: () => today }, { url: false }),
468
+ },
469
+ })
470
+ persist('r-lazy-reset', { since: '2026-01-01' })
471
+ const probe = sink()
472
+ let reset: (() => void) | null = null
473
+ let count = -1
474
+
475
+ await mountApp({
476
+ validateSearch: store.validateSearch,
477
+ entry: '/dashboard',
478
+ Dashboard: () => {
479
+ reset = store.useReset()
480
+ count = store.useActiveCount()
481
+ return fieldProbe(store.field.since, probe)()
482
+ },
483
+ })
484
+ expect(probe.current?.value).toBe('2026-01-01')
485
+
486
+ await act(async () => {
487
+ reset?.()
488
+ })
489
+
490
+ expect(probe.current?.value).toBe('2026-08-27')
491
+ expect(storedRecord('r-lazy-reset')).toEqual({})
492
+ expect(count).toBe(0)
493
+
494
+ // The next day the same field reads TODAY, not the day the reset was pressed — which is the
495
+ // whole point of a thunk fallback, and what writing the resolved value took away.
496
+ today = '2026-08-28'
497
+ expect(store.field.since.fallback).toBe('2026-08-28')
498
+ expect(store.readStored()['since']).toBeUndefined()
499
+ })
500
+
501
+ test('field.clear() returns the URL param to the fallback and drops the mirror key', async () => {
502
+ const store = createSearchStore({
503
+ key: 'r-clear-url',
504
+ fields: { range: field.enum(['1d', '7d', '30d'], '7d') },
505
+ })
506
+ persist('r-clear-url', { range: '30d' })
507
+ const probe = sink()
508
+
509
+ const router = await mountApp({
510
+ validateSearch: store.validateSearch,
511
+ entry: '/dashboard?range=30d',
512
+ Dashboard: fieldProbe(store.field.range, probe),
513
+ })
514
+ expect(probe.current?.value).toBe('30d')
515
+
516
+ await act(async () => {
517
+ store.field.range.clear()
518
+ })
519
+
520
+ await waitFor(() => {
521
+ expect(currentSearch(router)['range']).toBe('7d')
522
+ })
523
+ expect(storedRecord('r-clear-url')).toEqual({})
524
+ expect(probe.current?.value).toBe('7d')
525
+ })
526
+
527
+ test('useReset from a FOREIGN route persists only — it never navigates (A1)', async () => {
528
+ const store = createSearchStore({
529
+ key: 'r-reset-foreign',
530
+ fields: {
531
+ range: field.enum(['1d', '7d'], '7d'),
532
+ compare: field.enum(['none', 'prev'], 'none'),
533
+ },
534
+ })
535
+ let reset: (() => void) | null = null
536
+ const probe = sink()
537
+
538
+ const router = await mountApp({
539
+ validateSearch: store.validateSearch,
540
+ entry: '/other',
541
+ Other: () => {
542
+ reset = store.useReset()
543
+ const [value, set] = store.field.range.use()
544
+ probe.current = { value, set: set as (next: unknown) => void }
545
+ return null
546
+ },
547
+ })
548
+
549
+ const navigate = spyOn(router, 'navigate')
550
+ await act(async () => {
551
+ reset?.()
552
+ })
553
+
554
+ expect(navigate).not.toHaveBeenCalled()
555
+ expect(router.state.location.pathname).toBe('/other')
556
+ expect(currentSearch(router)['range']).toBeUndefined()
557
+ expect(currentSearch(router)['compare']).toBeUndefined()
558
+
559
+ // …and a subsequent field write on that same foreign route still persists only — the reset
560
+ // did not leak the params into the URL and flip the route into looking like an owner.
561
+ await act(async () => {
562
+ probe.current?.set('1d')
563
+ })
564
+ expect(navigate).not.toHaveBeenCalled()
565
+ expect(currentSearch(router)['range']).toBeUndefined()
566
+ navigate.mockRestore()
567
+ })
568
+ })
569
+
570
+ /**
571
+ * Every navigate a store issues carries `resetScroll: false`. A filter is as often halfway down a
572
+ * page as it is in the bar, and the router treats a same-route search write as a navigation — so
573
+ * the default would scroll the reader back to the top on every change. Asserted on the OPTION and
574
+ * not on a scroll position: happy-dom evaluates no layout, so the scroll itself is invisible here.
575
+ */
576
+ describe('store writes never scroll the page', () => {
577
+ test('a field write passes resetScroll: false', async () => {
578
+ const store = createSearchStore({
579
+ key: 'r-scroll-field',
580
+ fields: { range: field.enum(['1d', '7d'], '7d') },
581
+ })
582
+ const probe = sink()
583
+
584
+ const router = await mountApp({
585
+ validateSearch: store.validateSearch,
586
+ entry: '/dashboard',
587
+ Dashboard: fieldProbe(store.field.range, probe),
588
+ })
589
+
590
+ const navigate = spyOn(router, 'navigate')
591
+ await act(async () => {
592
+ probe.current?.set('1d')
593
+ })
594
+
595
+ expect(navigate).toHaveBeenCalledTimes(1)
596
+ expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
597
+ navigate.mockRestore()
598
+ })
599
+
600
+ test('useReset passes resetScroll: false', async () => {
601
+ const store = createSearchStore({
602
+ key: 'r-scroll-reset',
603
+ fields: { range: field.enum(['1d', '7d'], '7d') },
604
+ })
605
+ let reset: (() => void) | null = null
606
+
607
+ const router = await mountApp({
608
+ validateSearch: store.validateSearch,
609
+ entry: '/dashboard?range=1d',
610
+ Dashboard: () => {
611
+ reset = store.useReset()
612
+ return null
613
+ },
614
+ })
615
+
616
+ const navigate = spyOn(router, 'navigate')
617
+ await act(async () => {
618
+ reset?.()
619
+ })
620
+
621
+ expect(navigate).toHaveBeenCalledTimes(1)
622
+ expect(navigate.mock.calls[0]?.[0]).toMatchObject({ resetScroll: false })
623
+ navigate.mockRestore()
624
+ })
625
+ })
626
+
627
+ /**
628
+ * `{ url: false, persist: false }` — the third lane. It used to be the one combination that dropped
629
+ * its write (no URL to put it in, no mirror to keep it in); it is now the same session-shared
630
+ * in-memory lane `createLocalStore` gives such a field, through the same store-core helper.
631
+ */
632
+ const memoryStore = () =>
633
+ createSearchStore({
634
+ key: 'r-memory',
635
+ fields: {
636
+ range: field.enum(['1d', '7d'], '7d'),
637
+ scratch: field.enum(['a', 'b', 'c'], 'a', { url: false, persist: false }),
638
+ },
639
+ })
640
+
641
+ describe('the memory-only lane — url: false, persist: false', () => {
642
+ test('a write survives a remount, and touches neither the URL nor localStorage', async () => {
643
+ const store = memoryStore()
644
+ const probe = sink()
645
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
646
+
647
+ const router = await mountApp({
648
+ validateSearch: store.validateSearch,
649
+ entry: '/dashboard',
650
+ Dashboard: fieldProbe(store.field.scratch, probe),
651
+ })
652
+
653
+ await act(async () => {
654
+ probe.current?.set('c')
655
+ })
656
+ expect(probe.current?.value).toBe('c')
657
+
658
+ // No URL param (the field declares none, so `validateSearch` never emits one) and no mirror.
659
+ expect(currentSearch(router)['scratch']).toBeUndefined()
660
+ expect(storedRecord('r-memory')).toBeNull()
661
+ // …and no dev warning: the write went somewhere.
662
+ expect(warn).not.toHaveBeenCalled()
663
+ warn.mockRestore()
664
+
665
+ // A remount inside the same session reads it back — the lane is the store's, not the mount's.
666
+ cleanup()
667
+ const second = sink()
668
+ await mountApp({
669
+ validateSearch: store.validateSearch,
670
+ entry: '/dashboard',
671
+ Dashboard: fieldProbe(store.field.scratch, second),
672
+ })
673
+ expect(second.current?.value).toBe('c')
674
+ })
675
+
676
+ test('useActiveCount counts it, and useReset returns it to its fallback', async () => {
677
+ const store = memoryStore()
678
+ const probe = sink()
679
+ let count = -1
680
+ let reset: (() => void) | null = null
681
+
682
+ await mountApp({
683
+ validateSearch: store.validateSearch,
684
+ entry: '/dashboard',
685
+ Dashboard: () => {
686
+ count = store.useActiveCount()
687
+ reset = store.useReset()
688
+ return fieldProbe(store.field.scratch, probe)()
689
+ },
690
+ })
691
+
692
+ expect(count).toBe(0)
693
+
694
+ await act(async () => {
695
+ probe.current?.set('b')
696
+ })
697
+ expect(count).toBe(1)
698
+
699
+ await act(async () => {
700
+ reset?.()
701
+ })
702
+ expect(probe.current?.value).toBe('a')
703
+ expect(count).toBe(0)
704
+ // Nothing was ever persisted, so the reset had no key to drop and wrote no envelope — the
705
+ // mirror only ever holds values a control produced.
706
+ expect(storedRecord('r-memory')).toBeNull()
707
+ })
708
+
709
+ test('field.clear() drops the memory value, and only that one', async () => {
710
+ const store = memoryStore()
711
+ const probe = sink()
712
+
713
+ await mountApp({
714
+ validateSearch: store.validateSearch,
715
+ entry: '/dashboard',
716
+ Dashboard: fieldProbe(store.field.scratch, probe),
717
+ })
718
+
719
+ await act(async () => {
720
+ probe.current?.set('c')
721
+ })
722
+ expect(probe.current?.value).toBe('c')
723
+
724
+ await act(async () => {
725
+ store.field.scratch.clear()
726
+ })
727
+ expect(probe.current?.value).toBe('a')
728
+ expect(storedRecord('r-memory')).toBeNull()
729
+ })
730
+
731
+ test('two mounts of the same field see one value', async () => {
732
+ const store = memoryStore()
733
+ const a = sink()
734
+ const b = sink()
735
+
736
+ await mountApp({
737
+ validateSearch: store.validateSearch,
738
+ entry: '/dashboard/detail',
739
+ Dashboard: fieldProbe(store.field.scratch, a),
740
+ Detail: fieldProbe(store.field.scratch, b),
741
+ })
742
+
743
+ await act(async () => {
744
+ a.current?.set('b')
745
+ })
746
+
747
+ expect(b.current?.value).toBe('b')
748
+ })
749
+ })
750
+
751
+ /**
752
+ * `set(next, { patch })` — the sibling params a page owns and the store does not. Clearing one used
753
+ * to need a second `navigate` beside the setter, which either raced the field's own write or left
754
+ * two history entries where the reader made one change.
755
+ */
756
+ describe('use() — a patched write', () => {
757
+ const store = createSearchStore({
758
+ key: 'r-patch',
759
+ fields: { tab: field.enum(['overview', 'detail'], 'overview') },
760
+ })
761
+
762
+ /** The store's params PLUS one the page owns — the shape a real route composes. */
763
+ const validateSearch = (raw: Record<string, unknown>): Record<string, unknown> => ({
764
+ ...store.validateSearch(raw),
765
+ detailDate: typeof raw['detailDate'] === 'string' ? raw['detailDate'] : undefined,
766
+ })
767
+
768
+ test('clears a sibling param the store does not own, in the same navigate', async () => {
769
+ const probe = sink()
770
+ const router = await mountApp({
771
+ validateSearch,
772
+ entry: '/dashboard?tab=detail&detailDate=2026-03-01',
773
+ Dashboard: fieldProbe(store.field.tab, probe),
774
+ })
775
+ expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
776
+
777
+ await act(async () => {
778
+ probe.current?.set('overview', { patch: { detailDate: undefined } })
779
+ })
780
+
781
+ await waitFor(() => {
782
+ expect(currentSearch(router)['tab']).toBe('overview')
783
+ })
784
+ expect(currentSearch(router)['detailDate']).toBeUndefined()
785
+ expect(probe.current?.value).toBe('overview')
786
+ // The field's own lane is untouched by the patch — the write still mirrors.
787
+ expect(storedRecord('r-patch')).toEqual({ tab: 'overview' })
788
+ })
789
+
790
+ test('a patch cannot overwrite the value being set', async () => {
791
+ const probe = sink()
792
+ const router = await mountApp({
793
+ validateSearch,
794
+ entry: '/dashboard?tab=overview',
795
+ Dashboard: fieldProbe(store.field.tab, probe),
796
+ })
797
+
798
+ await act(async () => {
799
+ probe.current?.set('detail', { patch: { tab: 'overview', detailDate: '2026-04-01' } })
800
+ })
801
+
802
+ await waitFor(() => {
803
+ expect(currentSearch(router)['tab']).toBe('detail')
804
+ })
805
+ expect(currentSearch(router)['detailDate']).toBe('2026-04-01')
806
+ })
807
+
808
+ test('a patch naming a param ANOTHER field owns is refused, not silently desynced', async () => {
809
+ const twoFields = createSearchStore({
810
+ key: 'r-patch-owned',
811
+ fields: {
812
+ tab: field.enum(['overview', 'detail'], 'overview'),
813
+ range: field.enum(['7d', '30d'], '30d'),
814
+ },
815
+ })
816
+ const probe = sink()
817
+ const router = await mountApp({
818
+ validateSearch: twoFields.validateSearch,
819
+ entry: '/dashboard',
820
+ Dashboard: fieldProbe(twoFields.field.tab, probe),
821
+ })
822
+
823
+ // The URL would take `range=7d` while the mirror kept `30d`, so the next paramless visit and
824
+ // every `linkSearch` link would disagree with the page the write happened on.
825
+ expect(() => {
826
+ probe.current?.set('detail', { patch: { range: '7d' } })
827
+ }).toThrow(/field 'range' owns/)
828
+ expect(currentSearch(router)['tab']).toBe('overview')
829
+ expect(currentSearch(router)['range']).toBe('30d')
830
+ })
831
+
832
+ test('a patched write from a FOREIGN route drops the patch, and warns once', async () => {
833
+ const store = createSearchStore({
834
+ key: 'r-patch-foreign',
835
+ fields: { tab: field.enum(['overview', 'detail'], 'overview') },
836
+ })
837
+ const probe = sink()
838
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
839
+
840
+ const router = await mountApp({
841
+ validateSearch: store.validateSearch,
842
+ entry: '/other?detailDate=2026-03-01',
843
+ Other: fieldProbe(store.field.tab, probe),
844
+ })
845
+
846
+ await act(async () => {
847
+ probe.current?.set('detail', { patch: { detailDate: undefined } })
848
+ })
849
+
850
+ // The write persisted (A1); the patch had no navigate to merge into and is gone.
851
+ expect(storedRecord('r-patch-foreign')).toEqual({ tab: 'detail' })
852
+ expect(currentSearch(router)['detailDate']).toBe('2026-03-01')
853
+ expect(warn).toHaveBeenCalledTimes(1)
854
+
855
+ await act(async () => {
856
+ probe.current?.set('overview', { patch: { detailDate: undefined } })
857
+ })
858
+ expect(warn).toHaveBeenCalledTimes(1)
859
+ warn.mockRestore()
860
+ })
861
+
862
+ test('a field with no URL lane has no navigate to patch, and does not make one', async () => {
863
+ const local = createSearchStore({
864
+ key: 'r-patch-local',
865
+ fields: { compact: field.boolean(false, { url: false }) },
866
+ })
867
+ const probe = sink()
868
+ const router = await mountApp({
869
+ validateSearch,
870
+ entry: '/dashboard?tab=overview',
871
+ Dashboard: fieldProbe(local.field.compact, probe),
872
+ })
873
+
874
+ await act(async () => {
875
+ probe.current?.set(true, { patch: { detailDate: '2026-05-01' } })
876
+ })
877
+
878
+ expect(probe.current?.value).toBe(true)
879
+ expect(currentSearch(router)['detailDate']).toBeUndefined()
880
+ expect(storedRecord('r-patch-local')).toEqual({ compact: true })
881
+ })
882
+ })