basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * Guard types — Mantine-free, dependency-free (zero imports beyond TS types).
3
3
  *
4
- * GuardKind is the closed set of 25 violation kinds the theme guard can emit.
4
+ * GuardKind is the closed set of 27 violation kinds the theme guard can emit.
5
5
  * Finding is the structured result per violation, replacing the old `Violation` shape.
6
6
  * GuardConfig is the per-run configuration that drives checkSource.
7
7
  */
8
8
 
9
- /** The 25 theme-guard violation kinds. */
9
+ /** The 27 theme-guard violation kinds. */
10
10
  export type GuardKind =
11
11
  | 'raw-hex'
12
12
  | 'raw-color-fn'
@@ -33,6 +33,8 @@ export type GuardKind =
33
33
  | 'css-raw-surface'
34
34
  | 'inline-font-size'
35
35
  | 'hidden-inline-style'
36
+ | 'in-body-page-title'
37
+ | 'raw-selection-control'
36
38
 
37
39
  /**
38
40
  * How hard a finding lands. `error` fails the build; `warn` reports and passes.
package/src/index.ts CHANGED
@@ -14,6 +14,14 @@ export {
14
14
  } from './provider'
15
15
  export { createBasaltTheme, baseTheme, cssVariablesResolver } from './theme'
16
16
  export type { BasaltFontsConfig, CreateBasaltThemeOptions } from './theme'
17
+ export { CTL_THEME, CtlSlot } from './theme'
18
+ export type { CtlSlotProps } from './theme'
19
+ /**
20
+ * The action vocabulary every home's `actions` slot takes (`docs/CONTROLS-SPEC.md` §2.1). The
21
+ * COMPONENTS that project it (`ActionGroup`, `OverflowMenu`, `SyncButton`, and the filter family)
22
+ * live on `basalt-ui/controls` — only the types a `PageBar`/`BasaltShell` prop mentions are here.
23
+ */
24
+ export type { BarAction, ActionGroupProps, GlobalAction } from './controls/actions'
17
25
  export { ThemeToggle, type ThemeToggleProps } from './theme-toggle'
18
26
  export { MOTION_DURATION, MOTION_SPRING, MOTION_EASE_STANDARD } from './motion'
19
27
  export {
@@ -22,6 +30,7 @@ export {
22
30
  AppSidebar,
23
31
  SidebarSearch,
24
32
  type SidebarSearchConfig,
33
+ type SidebarSearchActions,
25
34
  SidebarAccount,
26
35
  MobileNav,
27
36
  type MobileNavProps,
@@ -31,11 +40,16 @@ export {
31
40
  MOBILE_MENU_MAX_DEFAULT,
32
41
  MOBILE_MORE_KEY,
33
42
  AppBreadcrumbs,
34
- PageHeaderProvider,
35
- PageActions,
36
- PageActionsOutlet,
43
+ PageBar,
44
+ type PageBarProps,
37
45
  type SidebarSection,
38
46
  type SidebarItem,
47
+ type SidebarBlock,
48
+ type SidebarBlockItem,
49
+ type SidebarBlockTone,
50
+ type SidebarListBlock,
51
+ type SidebarProgressBlock,
52
+ type SidebarCustomBlock,
39
53
  type NavAnchor,
40
54
  type NavAnchorProps,
41
55
  type NavMobilePlacement,
@@ -72,14 +86,41 @@ export {
72
86
  type StandardSchemaV1,
73
87
  } from './register'
74
88
 
75
- // ── Persisted state + online hook (state.ts) ─────────────────────────────────────────────────────
89
+ // ── Persisted state + the headless store kernel (state.ts) ───────────────────────────────────────
90
+ // The `field.*` VALUE is deliberately not re-exported here — `basalt-ui/forms` already owns the
91
+ // name `field` on its own barrel. Import it from `basalt-ui/router-tanstack` (beside
92
+ // `createSearchStore`) or from `basalt-ui/state` (beside `createLocalStore`).
76
93
  export {
77
94
  createPersistedState,
78
95
  type PersistedStateOptions,
79
- useOnlineStatus,
80
96
  readPersistedValue,
97
+ createLocalStore,
98
+ type LocalStore,
99
+ type AnyField,
100
+ type BooleanField,
101
+ type EnumField,
102
+ type FieldHandle,
103
+ type FieldLane,
104
+ type FieldOption,
105
+ type FieldValue,
106
+ type MultiField,
107
+ type NumberField,
108
+ type RangeField,
109
+ type RangeParams,
110
+ type RangePresets,
111
+ type RangeValue,
112
+ type ResolvedLane,
113
+ type SearchValues,
114
+ type StoredValues,
115
+ type StringField,
81
116
  } from './state'
82
117
 
118
+ // ── WidgetHeader (unified section/widget/card heading primitive, docs/CONTROLS-SPEC.md §2.2) ─────
119
+ export { WidgetHeader, type WidgetHeaderProps } from './widget-header'
120
+
121
+ // ── Section (tier-2 heading composer over WidgetHeader, docs/CONTROLS-SPEC.md §2.2) ───────────────
122
+ export { Section, type SectionProps } from './section'
123
+
83
124
  // ── Dashboard composites (KPI atoms + settings building blocks) ──────────────────────────────────
84
125
  export {
85
126
  DeltaBadge,
package/src/nav/types.ts CHANGED
@@ -40,6 +40,8 @@ export type SidebarItem = {
40
40
  /** Plain href — the no-router fallback, and the breadcrumb parent target. */
41
41
  href?: string
42
42
  active?: boolean
43
+ /** A parent on the path to the active row. NOT active itself — never carries `aria-current`. */
44
+ ancestor?: boolean
43
45
  disabled?: boolean
44
46
  onClick?: (e: MouseEvent) => void
45
47
  /** Rich desktop badge (e.g. `NavCountBadge`). Desktop only. */
@@ -70,6 +72,88 @@ export type SidebarSection = {
70
72
  mobile?: false | NavSectionMobile
71
73
  }
72
74
 
75
+ /**
76
+ * Status tone on a sidebar-block item — the same three-member vocabulary as `StatCardTone`, whose
77
+ * members are `VX.status` keys by construction. Declared here rather than imported from
78
+ * `../dashboard/stat-card`: this module is the one `./router-tanstack` reads, and even a
79
+ * type-only edge into the Mantine layer would make `nav/types.d.ts` unresolvable without
80
+ * `@mantine/core` installed.
81
+ */
82
+ export type SidebarBlockTone = 'good' | 'warn' | 'bad'
83
+
84
+ /** One row inside a `kind: 'list'` block. Same router seam as `SidebarItem` — `Anchor`, then href. */
85
+ export type SidebarBlockItem = {
86
+ key: string
87
+ label: string
88
+ /** Trailing muted text (a timestamp, a count, an owner). */
89
+ meta?: string
90
+ icon?: ReactNode
91
+ /** Renders a status dot in place of a missing `icon`; reads `--vx-status-*`, never a hex. */
92
+ tone?: SidebarBlockTone
93
+ Anchor?: NavAnchor
94
+ href?: string
95
+ onClick?: () => void
96
+ }
97
+
98
+ /**
99
+ * A list of non-destination rows under a micro-label — "Awaiting action", "Recents", a project
100
+ * list. `count` is the header badge (and what earns a rail dot); `max` shows the first N with a
101
+ * "Show more" toggle past it.
102
+ */
103
+ export type SidebarListBlock = {
104
+ kind: 'list'
105
+ key: string
106
+ label: string
107
+ icon?: ReactNode
108
+ count?: number
109
+ /** Rows shown before the "Show more" toggle. Omit to show every row. */
110
+ max?: number
111
+ items: SidebarBlockItem[]
112
+ /** @default 'nav' */
113
+ placement?: 'nav' | 'bottom'
114
+ collapsible?: boolean
115
+ /** Collapsed-rail projection. @default 'dot' when `count` is set, `'hidden'` otherwise */
116
+ rail?: 'dot' | 'hidden'
117
+ /** @default 'more' */
118
+ mobile?: 'more' | 'hidden'
119
+ }
120
+
121
+ /** A "Getting started 1 of 5" progress row. Pinned above the settings footer; never in the nav. */
122
+ export type SidebarProgressBlock = {
123
+ kind: 'progress'
124
+ key: string
125
+ label: string
126
+ value: number
127
+ total: number
128
+ onClick?: () => void
129
+ /** @default 'bottom' — the only placement a progress row has. */
130
+ placement?: 'bottom'
131
+ /** Collapsed-rail projection: a ring on the settings row. @default 'ring' */
132
+ rail?: 'ring' | 'hidden'
133
+ /** @default 'hidden' */
134
+ mobile?: 'more' | 'hidden'
135
+ }
136
+
137
+ /**
138
+ * DESKTOP ONLY — arbitrary consumer content (a tree, a filter panel) that no set of rows can
139
+ * express. Replaced `BasaltShellProps.sidebarNavExtra`; hidden on the collapsed rail and absent
140
+ * from mobile for the same reason that prop was.
141
+ */
142
+ export type SidebarCustomBlock = {
143
+ kind: 'custom'
144
+ key: string
145
+ node: ReactNode
146
+ /** @default 'nav' */
147
+ placement?: 'nav' | 'bottom'
148
+ }
149
+
150
+ /**
151
+ * A sidebar block — DECLARED DATA, never a `ReactNode` slot (law C13, `docs/CONTROLS-SPEC.md`
152
+ * §2.3), which is what lets basalt own the collapsed-rail badge and the mobile More-sheet
153
+ * projection instead of each consumer hand-rolling both.
154
+ */
155
+ export type SidebarBlock = SidebarListBlock | SidebarProgressBlock | SidebarCustomBlock
156
+
73
157
  export type MobileNavSurface = 'link' | 'menu' | 'sheet'
74
158
 
75
159
  /** One row inside a `menu`/`sheet` slot, grouped under its source section. */
@@ -108,8 +192,6 @@ export type MobileNavConfig = {
108
192
  menuMax?: number
109
193
  /** @default 'More' */
110
194
  moreLabel?: string
111
- /** Extra content pinned to the bottom of the More surface. */
112
- moreExtra?: ReactNode
113
195
  /** Scroll container for active-slot re-tap. @default document.scrollingElement */
114
196
  getScrollElement?: () => HTMLElement | null
115
197
  }
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * NotificationBell — ActionIcon + Indicator showing the unread Inbox count, opens a
3
- * NotificationCenter in a Popover. Designed for the shell `globalActions` slot (ReactNode).
3
+ * NotificationCenter in a Popover. Designed for the shell `globalActions` slot (`GlobalAction[]`).
4
4
  *
5
5
  * The badge counts unread Inbox items (errors / warnings / actionable notifications you haven't
6
6
  * seen) — not every toast that fired. Opening the bell does NOT mark anything read; items are
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * // In the shell's globalActions prop:
13
13
  * <BasaltShell
14
- * globalActions={<NotificationBell />}
14
+ * globalActions={[{ key: 'notifications', node: <NotificationBell />, mobile: 'bar' }]}
15
15
  * {...rest}
16
16
  * />
17
17
  */
@@ -33,7 +33,10 @@ export type NotificationBellProps = {
33
33
  * Bell icon button with an unread-Inbox indicator. Opens a NotificationCenter popover.
34
34
  *
35
35
  * @example
36
- * <BasaltShell globalActions={<NotificationBell />} {...rest} />
36
+ * <BasaltShell
37
+ * globalActions={[{ key: 'notifications', node: <NotificationBell />, mobile: 'bar' }]}
38
+ * {...rest}
39
+ * />
37
40
  */
38
41
  export function NotificationBell({ label = 'Notifications' }: NotificationBellProps) {
39
42
  const [opened, { open, toggle, close }] = useDisclosure(false)
@@ -20,7 +20,7 @@
20
20
  * notifyPromise(save(), { loading: 'Saving…', success: 'Saved', error: 'Failed' })
21
21
  *
22
22
  * // Shell bell (globalActions slot):
23
- * <BasaltShell globalActions={<NotificationBell />} ... />
23
+ * <BasaltShell globalActions={[{ key: 'notifications', node: <NotificationBell />, mobile: 'bar' }]} ... />
24
24
  */
25
25
 
26
26
  // ── notify core ───────────────────────────────────────────────────────────────
@@ -0,0 +1,31 @@
1
+ /**
2
+ * `field.*` — the field vocabulary `createSearchStore` takes, re-exported at the surface a consumer
3
+ * imports it from (`basalt-ui/router-tanstack`, beside the store factory).
4
+ *
5
+ * The builders and their codecs LIVE in `../state` on purpose: a field is declarative data with no
6
+ * router in it, and `createLocalStore` — the router-free lane, published on `basalt-ui/state` —
7
+ * has to build the same fields without pulling in `@tanstack/react-router`. One definition, two
8
+ * surfaces, no second description of the same param.
9
+ *
10
+ * Full field reference: the JSDoc on `field` itself, and `docs/CONTROLS-SPEC.md` §4.
11
+ */
12
+ export { field } from '../state'
13
+ export type {
14
+ AnyField,
15
+ BooleanField,
16
+ EnumField,
17
+ FieldHandle,
18
+ FieldLane,
19
+ FieldOption,
20
+ FieldValue,
21
+ MultiField,
22
+ NumberField,
23
+ RangeField,
24
+ RangeParams,
25
+ RangePresets,
26
+ RangeValue,
27
+ ResolvedLane,
28
+ SearchValues,
29
+ StoredValues,
30
+ StringField,
31
+ } from '../state'
@@ -3,10 +3,32 @@
3
3
  * Optional peer: @tanstack/react-router.
4
4
  * Carries the typed navigation definition (`defineNav`/`navGroup`/`navTarget`/`flattenNav`) and
5
5
  * `useNav`, which resolves it into `BasaltShell`'s `sections` + `mobileNav` props; plus the
6
- * StaticDataRouteOption augmentation (title/icon/navSection) for router-derived breadcrumbs and
7
- * the search-param stores — no Mantine, no JSX.
6
+ * StaticDataRouteOption augmentation (title/icon/navSection) for router-derived breadcrumbs, plus
7
+ * `createSearchStore` + the `field.*` vocabulary (and the two deprecated enum-only wrappers it
8
+ * replaced) — no Mantine, no JSX.
8
9
  */
9
10
 
11
+ export { createSearchStore, type CreateSearchStoreOptions, type SearchStore } from './search-store'
12
+ export { field } from './field'
13
+ export type {
14
+ AnyField,
15
+ BooleanField,
16
+ EnumField,
17
+ FieldHandle,
18
+ FieldLane,
19
+ FieldOption,
20
+ FieldValue,
21
+ MultiField,
22
+ NumberField,
23
+ RangeField,
24
+ RangeParams,
25
+ RangePresets,
26
+ RangeValue,
27
+ ResolvedLane,
28
+ SearchValues,
29
+ StoredValues,
30
+ StringField,
31
+ } from './field'
10
32
  export {
11
33
  createSearchParamStore,
12
34
  type SearchParamStore,
@@ -1,8 +1,26 @@
1
- import { describe, expect, test } from 'bun:test'
1
+ /**
2
+ * The deprecated multi wrapper's contract, unchanged across the rewrite onto
3
+ * `createSearchStore` — plus the storage path two of these tests used to claim was unreachable.
4
+ *
5
+ * `bun test` runs under happy-dom (root `bunfig.toml` preload), so `localStorage` has always been
6
+ * REAL here: "no localStorage in this environment" was false, and it meant the fallback-to-storage
7
+ * half of `validateSearch` — the entire reason the store exists — was asserted only in the
8
+ * single-value store's tests (D12).
9
+ */
10
+ import { beforeEach, describe, expect, test } from 'bun:test'
2
11
  import { createMultiSearchParamStore } from './multi-search-param-store'
3
12
 
4
13
  const VALUES = ['api', 'design', 'guide'] as const
5
14
 
15
+ /** Writes the wrapper's single-value envelope — the layout it has used since 1.0.0. */
16
+ function persist(key: string, value: readonly string[], version = 1): void {
17
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: version, value }))
18
+ }
19
+
20
+ beforeEach(() => {
21
+ localStorage.clear()
22
+ })
23
+
6
24
  describe('createMultiSearchParamStore', () => {
7
25
  describe('validateSearch', () => {
8
26
  test('URL value wins when it decodes to a non-empty array', () => {
@@ -30,10 +48,17 @@ describe('createMultiSearchParamStore', () => {
30
48
  expect(a.tags).toEqual(['api', 'design'])
31
49
  })
32
50
 
33
- test('a non-array URL value falls through to the fallback (no localStorage in this env)', () => {
51
+ test('a non-array URL value falls through to storage, then to the fallback', () => {
34
52
  const store = createMultiSearchParamStore({ key: 'tags-e', param: 'tags', values: VALUES })
35
53
  expect(store.validateSearch({ tags: 'api' })).toEqual({ tags: [] })
36
54
  expect(store.validateSearch({})).toEqual({ tags: [] })
55
+
56
+ persist('tags-e', ['guide', 'api'])
57
+ // Storage is read, and normalized on the way out like any other source.
58
+ expect(store.validateSearch({ tags: 'api' })).toEqual({ tags: ['api', 'guide'] })
59
+ expect(store.validateSearch({})).toEqual({ tags: ['api', 'guide'] })
60
+ // A valid URL array still wins over it.
61
+ expect(store.validateSearch({ tags: ['design'] })).toEqual({ tags: ['design'] })
37
62
  })
38
63
 
39
64
  test('an empty URL array falls through to the fallback', () => {
@@ -48,9 +73,52 @@ describe('createMultiSearchParamStore', () => {
48
73
  })
49
74
 
50
75
  describe('readStored', () => {
51
- test('returns null when nothing is persisted (no localStorage in this test environment)', () => {
76
+ test('returns null when nothing is persisted', () => {
52
77
  const store = createMultiSearchParamStore({ key: 'tags-g', param: 'tags', values: VALUES })
53
78
  expect(store.readStored()).toBeNull()
54
79
  })
80
+
81
+ test('returns the normalized stored selection, and null for a stale envelope version', () => {
82
+ const store = createMultiSearchParamStore({ key: 'tags-h', param: 'tags', values: VALUES })
83
+ persist('tags-h', ['guide', 'nope', 'api'])
84
+ expect(store.readStored()).toEqual(['api', 'guide'])
85
+
86
+ const versioned = createMultiSearchParamStore({
87
+ key: 'tags-i',
88
+ param: 'tags',
89
+ values: VALUES,
90
+ version: 1,
91
+ })
92
+ persist('tags-i', ['api'], 2)
93
+ expect(versioned.readStored()).toBeNull()
94
+ })
95
+
96
+ test('a stored EMPTY selection reads back as empty — a cleared filter stays cleared', () => {
97
+ // `{ v: 1, value: [] }` is exactly what `useStore()`'s setter writes when a user clears every
98
+ // filter. The empty-array-means-absent rule is a URL rule; applying it to storage too would
99
+ // resurrect the fallback on upgrade, which is not what "byte-compatible" means.
100
+ const store = createMultiSearchParamStore({
101
+ key: 'tags-empty',
102
+ param: 'tags',
103
+ values: VALUES,
104
+ fallback: ['guide'],
105
+ })
106
+ persist('tags-empty', [])
107
+
108
+ expect(store.readStored()).toEqual([])
109
+ expect(store.validateSearch({})).toEqual({ tags: [] })
110
+ expect(store.linkSearch()).toEqual({ tags: [] })
111
+ // …while an empty URL array still falls through to storage, as it always has.
112
+ persist('tags-empty', ['api'])
113
+ expect(store.validateSearch({ tags: [] })).toEqual({ tags: ['api'] })
114
+ })
115
+
116
+ test('linkSearch carries the stored selection at CALL time', () => {
117
+ const store = createMultiSearchParamStore({ key: 'tags-j', param: 'tags', values: VALUES })
118
+ expect(store.linkSearch()).toEqual({ tags: [] })
119
+
120
+ persist('tags-j', ['design'])
121
+ expect(store.linkSearch()).toEqual({ tags: ['design'] })
122
+ })
55
123
  })
56
124
  })
@@ -1,54 +1,34 @@
1
1
  /**
2
- * createMultiSearchParamStore — the multi-select sibling of `createSearchParamStore`, for search
3
- * params backed by an ANY-OF set (e.g. a tag filter) instead of a single value (e.g. a tab).
2
+ * @deprecated `createMultiSearchParamStore` is a thin wrapper over `createSearchStore`
3
+ * and is removed in 1.29.0. Replace it with `field.multi`:
4
4
  *
5
- * Same three-concern factory shape as `createSearchParamStore`:
6
- * 1. `validateSearch` — pass directly to `createFileRoute({ validateSearch })`.
7
- * Falls back to localStorage when the URL param is absent or invalid, then to `fallback`.
8
- * 2. `useStore` — React hook: `const [values, persist] = store.useStore()`.
9
- * Call `persist(next)` alongside `navigate` so the selection survives navigation.
10
- * 3. `readStored` — plain function for non-React contexts (tests, guards).
11
- * 4. `linkSearch` — the click-time `search:` thunk for a nav link, ready-made.
5
+ * const store = createSearchStore({
6
+ * key: 'article-tags',
7
+ * fields: { tags: field.multi(['api', 'design', 'guide'], []) },
8
+ * })
12
9
  *
13
- * Headless — no Mantine, no JSX. Same tier as `createSearchParamStore`.
10
+ * Two differences to know when you migrate:
14
11
  *
15
- * ## Encoding — array-native, not CSV
12
+ * - **An empty URL array is a VALUE in the new store**, not "absent". This wrapper keeps the old
13
+ * rule (`?tags=[]` falls through to localStorage, then to `fallback`) so an existing consumer's
14
+ * links behave identically; `field.multi` treats a cleared selection as a cleared selection.
15
+ * - `useStore` is localStorage-only. `store.field.tags.use()` reads the URL first (A8).
16
16
  *
17
- * TanStack Router's default search serialization is `stringifySearchWith(JSON.stringify,
18
- * JSON.parse)`, and its `stringifyValue` branches on `typeof val === 'object' && val !== null` —
19
- * an array is an object, so `{ tags: ['a', 'b'] }` serializes to `?tags=%5B%22a%22%2C%22b%22%5D`
20
- * and `parseSearchWith(JSON.parse)` reverses it losslessly. This store deals entirely in arrays
21
- * (what `validateSearch` returns is exactly what the router stringifies), so a CSV encoding is not
22
- * reachable from here — there is no string-building step to intercept. A consumer who wants
23
- * prettier URLs (e.g. `?tags=a,b`) configures router-global `parseSearch`/`stringifySearch` (see
24
- * TanStack Router's documented custom-search-param-serialization recipe); this store keeps working
25
- * untouched either way, because it hands the router an array, not an encoding.
17
+ * Encoding is unchanged and array-native: TanStack Router's default
18
+ * `stringifySearchWith(JSON.stringify, JSON.parse)` round-trips `{ tags: ['a', 'b'] }` losslessly,
19
+ * so there is no CSV step to intercept — a consumer wanting `?tags=a,b` configures router-global
20
+ * `parseSearch`/`stringifySearch` and this store keeps working untouched, because it hands the
21
+ * router an array rather than an encoding.
26
22
  *
27
- * ## Decoding — defensive and canonical
23
+ * Decoding is unchanged too: array-only input, allowlist-filtered, deduped, then re-sorted into
24
+ * `values` DECLARATION order — so `['api', 'design']` and `['design', 'api']` normalize to the same
25
+ * array, and one logical selection has one URL.
28
26
  *
29
- * Any value pulled from the URL or localStorage is normalized in this order:
30
- * 1. Accept only `Array.isArray` input — anything else decodes to empty.
31
- * 2. Keep only entries present in `opts.values` (drops tampered values and values dropped from the
32
- * vocabulary since the URL/localStorage entry was written).
33
- * 3. Dedupe.
34
- * 4. Re-sort into `opts.values` DECLARATION order.
35
- *
36
- * Canonical ordering means `['api', 'design']` and `['design', 'api']` normalize to the SAME
37
- * array — one URL for one logical selection, so history entries and any loader cache keyed on the
38
- * search stay stable regardless of the order a user toggled filters in.
39
- *
40
- * @example
41
- * export const articleTags = createMultiSearchParamStore({
42
- * key: 'article-tags', param: 'tags',
43
- * values: ['api', 'design', 'guide'] as const,
44
- * })
45
- *
46
- * export const Route = createFileRoute('/content')({
47
- * validateSearch: articleTags.validateSearch,
48
- * })
27
+ * The four returned members (`validateSearch`, `useStore`, `readStored`, `linkSearch`), their
28
+ * signatures and the single-value localStorage envelope are preserved byte-for-byte.
49
29
  */
50
-
51
- import { createPersistedState, readPersistedValue } from '../state'
30
+ import { createPersistedState, field } from '../state'
31
+ import { buildSearchStore } from './search-store'
52
32
 
53
33
  // ── Types ──────────────────────────────────────────────────────────────────
54
34
 
@@ -73,7 +53,7 @@ export type MultiSearchParamStore<T extends string, P extends string = string> =
73
53
  validateSearch: (search: Record<string, unknown>) => { [K in P]: readonly T[] }
74
54
  /**
75
55
  * React hook: `const [current, persist] = store.useStore()`.
76
- * Call `persist(next)` in your filter's `onChange` alongside `navigate()`.
56
+ * localStorage only — it does NOT read or write the URL.
77
57
  */
78
58
  useStore: () => readonly [readonly T[], (next: readonly T[]) => void]
79
59
  /** Plain read — for use outside React (tests, guards, fallback reads). */
@@ -84,54 +64,43 @@ export type MultiSearchParamStore<T extends string, P extends string = string> =
84
64
  * for why a module-scope literal silently defeats the store.
85
65
  *
86
66
  * No dev warning rides on this one, unlike the single-value store: an empty array in the URL is
87
- * indistinguishable from an absent param here (`validateSearch` falls back to storage for both),
88
- * so a link declaring `{ tags: [] }` restores correctly and there is no broken state to detect.
67
+ * indistinguishable from an absent param here, so a link declaring `{ tags: [] }` restores
68
+ * correctly and there is no broken state to detect.
89
69
  */
90
70
  linkSearch: () => { [K in P]: readonly T[] }
91
71
  }
92
72
 
93
73
  // ── Implementation ─────────────────────────────────────────────────────────
94
74
 
75
+ /** @deprecated Use `createSearchStore` + `field.multi`. Removed in 1.29.0. */
95
76
  export function createMultiSearchParamStore<const T extends string, const P extends string>(
96
77
  opts: MultiSearchParamStoreOptions<T, P>,
97
78
  ): MultiSearchParamStore<T, P> {
98
79
  const version = opts.version ?? 1
99
80
  const fallback = opts.fallback ?? []
100
- const allowed = new Set<string>(opts.values)
101
-
102
- /** Array-only, allowlist-filtered, deduped, canonically re-sorted into `opts.values` order. */
103
- const normalize = (raw: unknown): readonly T[] => {
104
- if (!Array.isArray(raw)) return []
105
- const present = new Set<T>()
106
- for (const entry of raw) {
107
- if (typeof entry === 'string' && allowed.has(entry)) present.add(entry as T)
108
- }
109
- return opts.values.filter((value) => present.has(value))
110
- }
111
-
112
- const readStored = (): readonly T[] | null => {
113
- const raw = readPersistedValue(opts.key, version)
114
- if (!Array.isArray(raw)) return null
115
- return normalize(raw)
116
- }
117
-
118
- const linkSearch = (): { [K in P]: readonly T[] } => {
119
- return { [opts.param]: readStored() ?? fallback } as { [K in P]: readonly T[] }
120
- }
121
-
122
- const validateSearch = (search: Record<string, unknown>): { [K in P]: readonly T[] } => {
123
- const fromUrl = normalize(search[opts.param])
124
- if (fromUrl.length > 0) {
125
- return { [opts.param]: fromUrl } as { [K in P]: readonly T[] }
126
- }
127
- return { [opts.param]: readStored() ?? fallback } as { [K in P]: readonly T[] }
128
- }
81
+ const store = buildSearchStore({
82
+ key: opts.key,
83
+ version,
84
+ fields: { [opts.param]: field.multi(opts.values, fallback) },
85
+ legacyValueField: opts.param,
86
+ })
129
87
 
130
- const useStore = createPersistedState<readonly T[]>({
88
+ const persisted = createPersistedState<readonly T[]>({
131
89
  key: opts.key,
132
90
  version,
133
91
  initial: fallback,
134
92
  })
93
+ // A reader, like the single-value wrapper's — see the note there.
94
+ const useStore = (): readonly [readonly T[], (next: readonly T[]) => void] => {
95
+ store.markReaderWired()
96
+ return persisted()
97
+ }
135
98
 
136
- return { validateSearch, useStore, readStored, linkSearch }
99
+ return {
100
+ validateSearch: (search) => store.validateSearch(search) as { [K in P]: readonly T[] },
101
+ useStore,
102
+ readStored: () =>
103
+ (store.readStored() as Record<string, readonly T[] | undefined>)[opts.param] ?? null,
104
+ linkSearch: () => store.linkSearch() as { [K in P]: readonly T[] },
105
+ }
137
106
  }