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,578 @@
1
+ /**
2
+ * `createSearchStore` — the resolution law, per field kind, without a router.
3
+ *
4
+ * The router half (what `use()` reads, when a write navigates) lives in
5
+ * `search-store.router.test.tsx`; this file pins the parts that run outside React: the codecs,
6
+ * the lanes, `validateSearch`/`linkSearch`/`readStored`, and the option labels.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test'
9
+ import { field } from './field'
10
+ import { createSearchStore } from './search-store'
11
+
12
+ /** Writes the store's envelope directly — one entry per store, `{ fieldName: value }`. */
13
+ function persist(key: string, record: Record<string, unknown>, version = 1): void {
14
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: version, value: record }))
15
+ }
16
+
17
+ let warn: ReturnType<typeof spyOn<Console, 'warn'>>
18
+
19
+ beforeEach(() => {
20
+ localStorage.clear()
21
+ warn = spyOn(console, 'warn').mockImplementation(() => {})
22
+ })
23
+
24
+ afterEach(() => {
25
+ warn.mockRestore()
26
+ })
27
+
28
+ describe('field kinds — encode / decode / invalid → fallback', () => {
29
+ test('enum: a valid URL value wins, an invalid one falls through to storage, then the fallback', () => {
30
+ const store = createSearchStore({
31
+ key: 'k-enum',
32
+ fields: { range: field.enum(['1d', '7d', '30d'], '30d') },
33
+ })
34
+ expect(store.validateSearch({ range: '7d' })).toEqual({ range: '7d' })
35
+
36
+ persist('k-enum', { range: '1d' })
37
+ expect(store.validateSearch({})).toEqual({ range: '1d' })
38
+ expect(store.validateSearch({ range: 'bogus' })).toEqual({ range: '1d' })
39
+
40
+ localStorage.clear()
41
+ expect(store.validateSearch({ range: 'bogus' })).toEqual({ range: '30d' })
42
+ })
43
+
44
+ test('multi: allowlisted, deduped, re-sorted into declaration order — and empty IS a value', () => {
45
+ const store = createSearchStore({
46
+ key: 'k-multi',
47
+ fields: { tags: field.multi(['api', 'design', 'guide'], ['guide']) },
48
+ })
49
+ expect(store.validateSearch({ tags: ['design', 'api', 'api', 'nope'] })).toEqual({
50
+ tags: ['api', 'design'],
51
+ })
52
+ // The deprecated wrapper treats `[]` as "absent"; the new store does not — a cleared
53
+ // selection is a selection.
54
+ expect(store.validateSearch({ tags: [] })).toEqual({ tags: [] })
55
+ expect(store.validateSearch({ tags: 'api' })).toEqual({ tags: ['guide'] })
56
+ expect(store.validateSearch({})).toEqual({ tags: ['guide'] })
57
+ })
58
+
59
+ test('range: three params, a preset, and a custom window of two ISO dates', () => {
60
+ const store = createSearchStore({
61
+ key: 'k-range',
62
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
63
+ })
64
+ expect(store.validateSearch({ range: '7d' })).toEqual({
65
+ range: '7d',
66
+ from: undefined,
67
+ to: undefined,
68
+ })
69
+ expect(store.validateSearch({ range: 'custom', from: '2026-01-01', to: '2026-02-01' })).toEqual(
70
+ { range: 'custom', from: '2026-01-01', to: '2026-02-01' },
71
+ )
72
+ // custom without both dates is not a value — it falls through to the fallback.
73
+ expect(store.validateSearch({ range: 'custom', from: '2026-01-01' })).toEqual({
74
+ range: '30d',
75
+ from: undefined,
76
+ to: undefined,
77
+ })
78
+ expect(store.validateSearch({ range: 'custom', from: 'yesterday', to: 'today' })).toEqual({
79
+ range: '30d',
80
+ from: undefined,
81
+ to: undefined,
82
+ })
83
+ })
84
+
85
+ test('range: `custom: false` rejects the custom preset outright', () => {
86
+ const store = createSearchStore({
87
+ key: 'k-range-nocustom',
88
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '7d' }) },
89
+ })
90
+ expect(store.validateSearch({ range: 'custom', from: '2026-01-01', to: '2026-02-01' })).toEqual(
91
+ {
92
+ range: '7d',
93
+ from: undefined,
94
+ to: undefined,
95
+ },
96
+ )
97
+ })
98
+
99
+ test('range: `params` renames all three params, so an existing deep link keeps its shape', () => {
100
+ const store = createSearchStore({
101
+ key: 'k-range-params',
102
+ fields: {
103
+ range: field.range({
104
+ presets: ['7d', '30d'],
105
+ fallback: '30d',
106
+ custom: true,
107
+ params: { preset: 'window', from: 'start', to: 'end' },
108
+ }),
109
+ },
110
+ })
111
+ expect(store.validateSearch({ window: '7d' })).toEqual({
112
+ window: '7d',
113
+ start: undefined,
114
+ end: undefined,
115
+ })
116
+ expect(
117
+ store.validateSearch({ window: 'custom', start: '2026-03-01', end: '2026-03-31' }),
118
+ ).toEqual({ window: 'custom', start: '2026-03-01', end: '2026-03-31' })
119
+ })
120
+
121
+ test('range: two range fields sharing default `from`/`to` throws at definition, not at read time', () => {
122
+ // Every `field.range` defaults `from`/`to` to those literal param names, so a second range
123
+ // field in the same store would otherwise clobber the first one's dates on every
124
+ // `validateSearch` — silently, since `flatten` just `Object.assign`s each entry's `toSearch`
125
+ // over the last. Caught once, at store definition, instead of shipping a lost custom window.
126
+ expect(() =>
127
+ createSearchStore({
128
+ key: 'k-range-collision',
129
+ fields: {
130
+ a: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
131
+ b: field.range({ presets: ['7d', '30d'], fallback: '7d', custom: true }),
132
+ },
133
+ }),
134
+ ).toThrow(/fields 'a' and 'b' both own the URL param 'from'/)
135
+ })
136
+
137
+ test("range: renaming one field's `params` clears the collision", () => {
138
+ const store = createSearchStore({
139
+ key: 'k-range-no-collision',
140
+ fields: {
141
+ a: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
142
+ b: field.range({
143
+ presets: ['7d', '30d'],
144
+ fallback: '7d',
145
+ custom: true,
146
+ params: { preset: 'bWindow', from: 'bFrom', to: 'bTo' },
147
+ }),
148
+ },
149
+ })
150
+ expect(
151
+ store.validateSearch({ a: 'custom', from: '2026-01-01', to: '2026-02-01', bWindow: '7d' }),
152
+ ).toEqual({
153
+ a: 'custom',
154
+ from: '2026-01-01',
155
+ to: '2026-02-01',
156
+ bWindow: '7d',
157
+ bFrom: undefined,
158
+ bTo: undefined,
159
+ })
160
+ })
161
+
162
+ test('number: coerces a numeric string, clamps to min/max, rejects a non-integer when int', () => {
163
+ const store = createSearchStore({
164
+ key: 'k-number',
165
+ fields: { min: field.number({ fallback: 10, min: 0, max: 100, int: true }) },
166
+ })
167
+ expect(store.validateSearch({ min: 42 })).toEqual({ min: 42 })
168
+ expect(store.validateSearch({ min: '42' })).toEqual({ min: 42 })
169
+ expect(store.validateSearch({ min: 500 })).toEqual({ min: 100 })
170
+ expect(store.validateSearch({ min: -5 })).toEqual({ min: 0 })
171
+ expect(store.validateSearch({ min: 4.2 })).toEqual({ min: 10 })
172
+ expect(store.validateSearch({ min: 'lots' })).toEqual({ min: 10 })
173
+ })
174
+
175
+ test("number: rejects '', whitespace, and hex rather than coercing them to a number", () => {
176
+ // `Number('')`/`Number(' ')` are `0` and `Number('0x10')` is `16` — none of those strings is
177
+ // one the codec's own `toSearch` would ever write, so a blank/hand-edited `?min=` link must
178
+ // fall through to the fallback instead of silently becoming `0` (or `16`).
179
+ const store = createSearchStore({
180
+ key: 'k-number-blank',
181
+ fields: { min: field.number({ fallback: 10 }) },
182
+ })
183
+ expect(store.validateSearch({ min: '' })).toEqual({ min: 10 })
184
+ expect(store.validateSearch({ min: ' ' })).toEqual({ min: 10 })
185
+ expect(store.validateSearch({ min: '0x10' })).toEqual({ min: 10 })
186
+ expect(store.validateSearch({ min: '1e3' })).toEqual({ min: 10 })
187
+ expect(store.validateSearch({ min: '42' })).toEqual({ min: 42 })
188
+ })
189
+
190
+ test('boolean: takes a real boolean and the two string forms a hand-typed URL carries', () => {
191
+ const store = createSearchStore({
192
+ key: 'k-boolean',
193
+ fields: { compact: field.boolean(false) },
194
+ })
195
+ expect(store.validateSearch({ compact: true })).toEqual({ compact: true })
196
+ expect(store.validateSearch({ compact: 'true' })).toEqual({ compact: true })
197
+ expect(store.validateSearch({ compact: 'false' })).toEqual({ compact: false })
198
+ expect(store.validateSearch({ compact: 'yes' })).toEqual({ compact: false })
199
+ })
200
+
201
+ test('string: truncates past `max`, rejects a non-string, keeps the empty string', () => {
202
+ const store = createSearchStore({
203
+ key: 'k-string',
204
+ fields: { q: field.string({ max: 4 }) },
205
+ })
206
+ expect(store.validateSearch({ q: 'abc' })).toEqual({ q: 'abc' })
207
+ expect(store.validateSearch({ q: 'abcdefg' })).toEqual({ q: 'abcd' })
208
+ expect(store.validateSearch({ q: '' })).toEqual({ q: '' })
209
+ expect(store.validateSearch({ q: 7 })).toEqual({ q: '' })
210
+ })
211
+
212
+ test('a stale envelope version discards the whole mirror', () => {
213
+ const store = createSearchStore({
214
+ key: 'k-version',
215
+ fields: { range: field.enum(['1d', '7d'], '7d') },
216
+ version: 2,
217
+ })
218
+ persist('k-version', { range: '1d' }, 1)
219
+ expect(store.validateSearch({})).toEqual({ range: '7d' })
220
+ expect(store.readStored()).toEqual({})
221
+ })
222
+ })
223
+
224
+ describe('lanes', () => {
225
+ test('a `url: false` field never appears in validateSearch or linkSearch', () => {
226
+ const store = createSearchStore({
227
+ key: 'k-lane-local',
228
+ fields: {
229
+ range: field.enum(['1d', '7d'], '7d'),
230
+ compact: field.boolean(false, { url: false }),
231
+ },
232
+ })
233
+ persist('k-lane-local', { compact: true })
234
+ expect(store.validateSearch({})).toEqual({ range: '7d' })
235
+ expect(store.linkSearch()).toEqual({ range: '7d' })
236
+ // …but it IS stored, and readStored covers both lanes.
237
+ expect(store.readStored()).toEqual({ compact: true })
238
+ })
239
+
240
+ test('a `persist: false` field ignores the mirror even when one exists', () => {
241
+ const store = createSearchStore({
242
+ key: 'k-lane-url',
243
+ fields: { page: field.number({ fallback: 1 }, { persist: false }) },
244
+ })
245
+ persist('k-lane-url', { page: 9 })
246
+ expect(store.validateSearch({})).toEqual({ page: 1 })
247
+ expect(store.linkSearch()).toEqual({ page: 1 })
248
+ expect(store.readStored()).toEqual({ page: 9 })
249
+ })
250
+ })
251
+
252
+ describe('linkSearch', () => {
253
+ test('is read at CALL time, so a definition held by reference never goes stale', () => {
254
+ const store = createSearchStore({
255
+ key: 'k-link',
256
+ fields: { range: field.enum(['1d', '7d', '30d'], '30d') },
257
+ })
258
+ expect(store.linkSearch()).toEqual({ range: '30d' })
259
+
260
+ persist('k-link', { range: '7d' })
261
+ expect(store.linkSearch()).toEqual({ range: '7d' })
262
+ })
263
+
264
+ test('is ONE function identity, so `search: store.linkSearch` in defineNav stays stable', () => {
265
+ const store = createSearchStore({
266
+ key: 'k-link-identity',
267
+ fields: { range: field.enum(['1d', '7d'], '7d') },
268
+ })
269
+ expect(store.linkSearch).toBe(store.linkSearch)
270
+ expect(store.validateSearch).toBe(store.validateSearch)
271
+ })
272
+
273
+ test('overlays the stored values of every URL-lane field onto the fallbacks', () => {
274
+ const store = createSearchStore({
275
+ key: 'k-link-multi',
276
+ fields: {
277
+ range: field.enum(['1d', '7d'], '7d'),
278
+ compare: field.enum(['none', 'previous'], 'none'),
279
+ },
280
+ })
281
+ persist('k-link-multi', { compare: 'previous' })
282
+ expect(store.linkSearch()).toEqual({ range: '7d', compare: 'previous' })
283
+ })
284
+ })
285
+
286
+ describe('the pinned-link warning', () => {
287
+ test('fires for a multi field — the fallback comparison is by CONTENT, not identity', () => {
288
+ const store = createSearchStore({
289
+ key: 'k-warn-multi',
290
+ fields: { tags: field.multi(['api', 'design'], ['api']) },
291
+ })
292
+ persist('k-warn-multi', { tags: ['design'] })
293
+
294
+ // `['api']` from the URL is a different array object than the fallback — `===` would miss it.
295
+ store.validateSearch({ tags: ['api'] })
296
+ expect(warn).toHaveBeenCalledTimes(1)
297
+ expect(String(warn.mock.calls[0]?.[0])).toContain('linkSearch')
298
+ })
299
+
300
+ test('fires for a range field, whose value is an object', () => {
301
+ const store = createSearchStore({
302
+ key: 'k-warn-range',
303
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d' }) },
304
+ })
305
+ persist('k-warn-range', { range: { preset: '7d' } })
306
+
307
+ store.validateSearch({ range: '30d' })
308
+ expect(warn).toHaveBeenCalledTimes(1)
309
+ expect(String(warn.mock.calls[0]?.[0])).toContain('range=30d')
310
+ })
311
+
312
+ test('stays silent when the URL and the mirror agree, whatever the kind', () => {
313
+ const store = createSearchStore({
314
+ key: 'k-warn-agree',
315
+ fields: { tags: field.multi(['api', 'design'], ['api']) },
316
+ })
317
+ persist('k-warn-agree', { tags: ['api'] })
318
+
319
+ store.validateSearch({ tags: ['api'] })
320
+ expect(warn).not.toHaveBeenCalled()
321
+ })
322
+
323
+ test('stays silent once any reader is wired — including field.use(), the documented one', () => {
324
+ const store = createSearchStore({
325
+ key: 'k-warn-wired',
326
+ fields: { range: field.enum(['1d', '7d', '30d'], '30d') },
327
+ })
328
+ persist('k-warn-wired', { range: '7d' })
329
+
330
+ store.readStored()
331
+ store.validateSearch({ range: '30d' })
332
+ expect(warn).not.toHaveBeenCalled()
333
+ })
334
+ })
335
+
336
+ describe('labels / options', () => {
337
+ test('labels() returns the SAME store, so it chains at the definition site', () => {
338
+ const store = createSearchStore({
339
+ key: 'k-labels',
340
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
341
+ })
342
+ expect(store.labels({ range: { '7d': 'Last 7 days' } })).toBe(store)
343
+ })
344
+
345
+ test('options derive from the values plus the labels — including a range’s custom preset', () => {
346
+ const store = createSearchStore({
347
+ key: 'k-options',
348
+ fields: {
349
+ range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
350
+ tags: field.multi(['api', 'design']),
351
+ page: field.number({ fallback: 1 }),
352
+ },
353
+ }).labels({ range: { '7d': 'Last 7 days', custom: 'Custom range' } })
354
+
355
+ expect(store.field.range.options).toEqual([
356
+ { value: '7d', label: 'Last 7 days' },
357
+ { value: '30d', label: '30d' },
358
+ { value: 'custom', label: 'Custom range' },
359
+ ])
360
+ expect(store.field.tags.options).toEqual([
361
+ { value: 'api', label: 'api' },
362
+ { value: 'design', label: 'design' },
363
+ ])
364
+ expect(store.field.page.options).toEqual([])
365
+ })
366
+ })
367
+
368
+ describe('the handle, outside React', () => {
369
+ test('kind, fallback and isDefault come off the field definition', () => {
370
+ const store = createSearchStore({
371
+ key: 'k-handle',
372
+ fields: {
373
+ range: field.enum(['1d', '7d'], '7d'),
374
+ tags: field.multi(['api', 'design'], ['api']),
375
+ },
376
+ })
377
+ expect(store.field.range.kind).toBe('enum')
378
+ expect(store.field.range.fallback).toBe('7d')
379
+ expect(store.field.range.isDefault('7d')).toBe(true)
380
+ expect(store.field.range.isDefault('1d')).toBe(false)
381
+ // Multi compares by content, not by reference.
382
+ expect(store.field.tags.isDefault(['api'])).toBe(true)
383
+ expect(store.field.tags.isDefault(['design'])).toBe(false)
384
+ })
385
+
386
+ test('toWindow projects a preset to `{ window }` and a custom range to `{ from, to }`', () => {
387
+ const store = createSearchStore({
388
+ key: 'k-towindow',
389
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
390
+ })
391
+ expect(store.field.range.toWindow({ preset: '7d' })).toEqual({ window: '7d' })
392
+ expect(
393
+ store.field.range.toWindow({ preset: 'custom', from: '2026-01-01', to: '2026-02-01' }),
394
+ ).toEqual({ from: '2026-01-01', to: '2026-02-01' })
395
+ // A custom preset with no dates has nothing to project — it degrades to the preset.
396
+ expect(store.field.range.toWindow({ preset: 'custom' })).toEqual({ window: 'custom' })
397
+ })
398
+
399
+ test('a preset declared with a `window` resolver projects to `{ from, to }`', () => {
400
+ // The derived presets three consumers hand-rolled beside the store (`3m`, `ytd`): one preset in
401
+ // the URL, two dates at the API boundary, no `presetToParams` in the page.
402
+ const seen: Date[] = []
403
+ const store = createSearchStore({
404
+ key: 'k-window-resolver',
405
+ fields: {
406
+ range: field.range({
407
+ presets: ['7d', '3m', 'ytd'],
408
+ fallback: '7d',
409
+ custom: true,
410
+ window: {
411
+ '3m': (now) => {
412
+ seen.push(now)
413
+ return { from: iso(addMonths(now, -3)), to: iso(now) }
414
+ },
415
+ ytd: (now) => ({ from: `${now.getUTCFullYear()}-01-01`, to: iso(now) }),
416
+ },
417
+ }),
418
+ },
419
+ })
420
+
421
+ const now = new Date()
422
+ expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
423
+ from: iso(addMonths(now, -3)),
424
+ to: iso(now),
425
+ })
426
+ expect(store.field.range.toWindow({ preset: 'ytd' })).toEqual({
427
+ from: `${now.getUTCFullYear()}-01-01`,
428
+ to: iso(now),
429
+ })
430
+ // Resolved at CALL time, with the current date — never captured at definition.
431
+ expect(seen[0] instanceof Date).toBe(true)
432
+
433
+ // A preset WITHOUT a resolver is untouched, in the same field.
434
+ expect(store.field.range.toWindow({ preset: '7d' })).toEqual({ window: '7d' })
435
+ // And a custom window still wins over any resolver.
436
+ expect(
437
+ store.field.range.toWindow({ preset: 'custom', from: '2026-03-01', to: '2026-03-14' }),
438
+ ).toEqual({ from: '2026-03-01', to: '2026-03-14' })
439
+ })
440
+
441
+ test('a resolver changes nothing about the URL — the preset is still what is stored', () => {
442
+ const store = createSearchStore({
443
+ key: 'k-window-url',
444
+ fields: {
445
+ range: field.range({
446
+ presets: ['7d', '3m'],
447
+ fallback: '7d',
448
+ window: { '3m': () => ({ from: '2026-01-01', to: '2026-04-01' }) },
449
+ }),
450
+ },
451
+ })
452
+ expect(store.validateSearch({ range: '3m' })).toEqual({
453
+ range: '3m',
454
+ from: undefined,
455
+ to: undefined,
456
+ })
457
+ expect(store.field.range.toWindow({ preset: '3m' })).toEqual({
458
+ from: '2026-01-01',
459
+ to: '2026-04-01',
460
+ })
461
+ })
462
+
463
+ test('a preset named like an Object.prototype member is not a resolver', () => {
464
+ const store = createSearchStore({
465
+ key: 'k-window-proto',
466
+ fields: { range: field.range({ presets: ['toString', '7d'], fallback: '7d' }) },
467
+ })
468
+ expect(store.field.range.toWindow({ preset: 'toString' })).toEqual({ window: 'toString' })
469
+ })
470
+
471
+ test('toWindow exists on a range handle and on no other kind', () => {
472
+ const store = createSearchStore({
473
+ key: 'k-towindow-absent',
474
+ fields: { range: field.enum(['1d', '7d'], '7d') },
475
+ })
476
+ expect(store.field.range.toWindow).toBeUndefined()
477
+ })
478
+ })
479
+
480
+ describe('types', () => {
481
+ test('a fallback outside the values is a compile error, not a widened union', () => {
482
+ const store = createSearchStore({
483
+ key: 'k-types',
484
+ // @ts-expect-error 'c' is not one of the declared values
485
+ fields: { tab: field.enum(['a', 'b'] as const, 'c') },
486
+ })
487
+ // The `@ts-expect-error` above IS the assertion; this keeps the store referenced.
488
+ expect(store.field.tab.kind).toBe('enum')
489
+ })
490
+ })
491
+
492
+ /** UTC `YYYY-MM-DD` — the shape `toWindow` deals in, with no timezone in the answer. */
493
+ function iso(d: Date): string {
494
+ return d.toISOString().slice(0, 10)
495
+ }
496
+
497
+ function addMonths(d: Date, months: number): Date {
498
+ const next = new Date(d)
499
+ next.setUTCMonth(next.getUTCMonth() + months)
500
+ return next
501
+ }
502
+
503
+ describe('a lazy fallback and the lanes', () => {
504
+ test('a thunk fallback on the URL lane throws at DEFINITION, naming the two ways out', () => {
505
+ expect(() =>
506
+ createSearchStore({
507
+ key: 'k-lazy-url',
508
+ fields: { since: field.string({ fallback: () => '2026-01-01' }) },
509
+ }),
510
+ ).toThrow(/thunk fallback on the URL lane/)
511
+ })
512
+
513
+ test('off the URL lane it resolves per read, in a search store too', () => {
514
+ let today = '2026-01-01'
515
+ const store = createSearchStore({
516
+ key: 'k-lazy-local',
517
+ fields: {
518
+ since: field.string({ fallback: () => today }, { url: false }),
519
+ scratch: field.string({ fallback: () => today }, { url: false, persist: false }),
520
+ },
521
+ })
522
+ expect(store.field.since.fallback).toBe('2026-01-01')
523
+ today = '2026-02-01'
524
+ expect(store.field.since.fallback).toBe('2026-02-01')
525
+ expect(store.field.scratch.fallback).toBe('2026-02-01')
526
+ })
527
+ })
528
+
529
+ describe('field.range — the `custom` flag is inferred, never widened', () => {
530
+ test('an omitted `custom` keeps `custom` out of the value type AND out of the options', () => {
531
+ const store = createSearchStore({
532
+ key: 'k-custom-inference',
533
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d' }) },
534
+ })
535
+ type Search = ReturnType<typeof store.validateSearch>
536
+
537
+ const preset: Search['range'] = '7d'
538
+ // @ts-expect-error 'custom' is NOT a value of a range that never declared `custom: true` —
539
+ // this is the whole bug: written inline in `fields`, `C` used to widen to `boolean` against
540
+ // `AnyField`, and every value type gained a preset the field rejects at runtime.
541
+ const widened: Search['range'] = 'custom'
542
+
543
+ expect(preset).toBe('7d')
544
+ // `String(...)` because `widened`'s DECLARED type no longer admits the value it holds — the
545
+ // `@ts-expect-error` above is the assertion, this only keeps the binding used.
546
+ expect(String(widened)).toBe('custom')
547
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d'])
548
+ // The runtime agrees with the type: a `custom` preset does not decode.
549
+ expect(store.validateSearch({ range: 'custom', from: '2026-01-01', to: '2026-02-01' })).toEqual(
550
+ { range: '30d', from: undefined, to: undefined },
551
+ )
552
+ })
553
+
554
+ test('`custom: true` still admits it, in the type and in the options', () => {
555
+ const store = createSearchStore({
556
+ key: 'k-custom-true',
557
+ fields: { range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
558
+ })
559
+ type Search = ReturnType<typeof store.validateSearch>
560
+ const custom: Search['range'] = 'custom'
561
+
562
+ expect(custom).toBe('custom')
563
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', '30d', 'custom'])
564
+ })
565
+
566
+ test('a `custom` flag held in a widened boolean still builds — it just stays widened', () => {
567
+ const allowCustom: boolean = true
568
+ const store = createSearchStore({
569
+ key: 'k-custom-widened',
570
+ fields: { range: field.range({ presets: ['7d'], fallback: '7d', custom: allowCustom }) },
571
+ })
572
+ type Search = ReturnType<typeof store.validateSearch>
573
+ const custom: Search['range'] = 'custom'
574
+
575
+ expect(custom).toBe('custom')
576
+ expect(store.field.range.options.map((option) => option.value)).toEqual(['7d', 'custom'])
577
+ })
578
+ })