basalt-ui 1.25.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 (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  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 +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/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -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
  }
@@ -1,91 +1,33 @@
1
1
  /**
2
- * createSearchParamStore — bridge between TanStack Router URL search params and
3
- * basalt-ui's localStorage persistence.
2
+ * @deprecated `createSearchParamStore` is a thin wrapper over `createSearchStore` and is
3
+ * removed in 1.29.0. It can express exactly one closed string enum on one param; the store it
4
+ * delegates to expresses six field kinds, per-field lanes, and a range's three params.
4
5
  *
5
- * Combines three concerns into one factory:
6
- * 1. `validateSearch` — pass directly to `createFileRoute({ validateSearch })`.
7
- * Falls back to localStorage when the URL param is absent or invalid.
8
- * 2. `useStore` — React hook: `const [value, persist] = store.useStore()`.
9
- * Call `persist(next)` alongside `navigate` so the selection survives
10
- * navigation away from and back to the route.
11
- * 3. `readStored` — plain function for non-React contexts (tests, guards).
12
- * 4. `linkSearch` — the click-time `search:` thunk a nav link needs, ready-made.
6
+ * Migration is mechanical:
13
7
  *
14
- * ## The half that is easy to skip
15
- *
16
- * `validateSearch` restores the value when you navigate TO the route with no param. It cannot do
17
- * anything about a nav link that declares the param itself — and a link written as
18
- * `search: { range: '30d' }` at module scope pins the fallback on every click, which silently
19
- * defeats the whole store. The reference consumer shipped exactly that: three features adopted the
20
- * persistence, the reader had ZERO call sites, and "remember my window" had never once worked.
21
- *
22
- * `linkSearch` exists so that failure is unreachable from the API rather than only warned about in
23
- * this comment — the mistake is made while typing `search:`, so the remedy has to be something
24
- * autocomplete offers at that keystroke. The JSDoc was already here when it happened.
25
- *
26
- * Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and
27
- * `useRouterBreadcrumbs`.
28
- *
29
- * ## Full integration recipe (5 steps)
30
- *
31
- * **1. Define** — one call per search param, e.g. in a shared lib/ module:
32
- *
33
- * export const dashboardRange = createSearchParamStore({
34
- * key: 'dashboard-range', param: 'range',
35
- * values: ['1d', '7d', '30d'] as const, fallback: '30d',
8
+ * // before
9
+ * const range = createSearchParamStore({
10
+ * key: 'dashboard-range', param: 'range', values: ['1d', '7d', '30d'] as const, fallback: '30d',
36
11
  * })
12
+ * const [, persist] = range.useStore() // localStorage only — never the URL
37
13
  *
38
- * **2. Route** — plug validateSearch into the parent layout route:
39
- *
40
- * export const Route = createFileRoute('/dashboard')({
41
- * validateSearch: dashboardRange.validateSearch,
42
- * component: DashboardLayout, // renders <DateFilter/> + <Outlet/>
14
+ * // after
15
+ * const dash = createSearchStore({
16
+ * key: 'dashboard-range',
17
+ * fields: { range: field.enum(['1d', '7d', '30d'], '30d') },
43
18
  * })
19
+ * const [value, set] = dash.field.range.use() // reads the URL, writes both lanes
44
20
  *
45
- * **3. Filter** — a component in the shell header (PageActions portal):
46
- *
47
- * function DateFilter() {
48
- * const { range } = useSearch({ from: '/dashboard' })
49
- * const [, persist] = dashboardRange.useStore()
50
- * return <SegmentedControl value={range}
51
- * onChange={(v) => { persist(v); navigate({ to: '.',
52
- * search: (prev) => ({ ...prev, range: v }) }) }} />
53
- * }
54
- *
55
- * **4. Page** — accepts the param as a **prop**, NOT from useSearch:
21
+ * The two are NOT equivalent in the one way that matters: `useStore` reads localStorage, so a deep
22
+ * link `?range=7d` rendered `30d` (A8). `field.range.use()` reads the URL first. See
23
+ * `docs/CONTROLS-SPEC.md` §4 and `MIGRATING.md`.
56
24
  *
57
- * // CRITICAL: never call useSearch({ from: '/dashboard' }) in a page
58
- * // component that renders on a sibling route (e.g. /activity).
59
- * // Sibling routes aren't children — the `from` fails.
60
- * function DashboardPage({ range = '30d' }: { range?: DateRange }) {
61
- * const data = useMemo(() => generateData(range), [range])
62
- * return <Charts data={data} range={range} />
63
- * }
64
- *
65
- * **5. Nav links** — carry the param across sub-pages, set per destination in
66
- * the nav definition so only the dashboard sub-tree inherits it and every
67
- * other link stays clean. Pass `linkSearch` BY REFERENCE — it is the click-time thunk:
68
- *
69
- * navGroup({ id: 'dash', label: 'Dashboard' }, [
70
- * { id: 'dash-overview', label: 'Overview', icon: <IconHome />,
71
- * link: linkOptions({ to: '/dashboard', search: dashboardRange.linkSearch }) },
72
- * { id: 'dash-activity', label: 'Activity', icon: <IconPulse />,
73
- * link: linkOptions({ to: '/dashboard/activity', search: dashboardRange.linkSearch }) },
74
- * ])
75
- *
76
- * `linkSearch` IS that thunk (`() => ({ [param]: readStored() ?? fallback })`) — hand-rolling it
77
- * over `readStored` still works and is what you need when the link also carries other params.
78
- *
79
- * NOT `search: true`. That flag means "keep the current search" and TanStack
80
- * only offers it where the target's search is OPTIONAL — a route wired to a
81
- * store's `validateSearch` always returns the param, so the router requires a
82
- * value and `search: true` is a type error. The thunk is also the better
83
- * behaviour: `<Link>` re-evaluates it at click time, so arriving from OUTSIDE
84
- * the sub-tree restores the last selection instead of landing on the factory
85
- * default, and a definition evaluated once at module scope never goes stale.
25
+ * What the wrapper preserves byte-for-byte, because `basalt-ui-obsidian` consumes it two hops
26
+ * downstream: the four returned members (`validateSearch`, `useStore`, `readStored`, `linkSearch`),
27
+ * their signatures, and the single-value localStorage envelope an existing browser already holds.
86
28
  */
87
-
88
- import { createPersistedState, readPersistedValue } from '../state'
29
+ import { createPersistedState, field } from '../state'
30
+ import { buildSearchStore } from './search-store'
89
31
 
90
32
  // ── Types ──────────────────────────────────────────────────────────────────
91
33
 
@@ -110,7 +52,8 @@ export type SearchParamStore<T extends string, P extends string = string> = {
110
52
  validateSearch: (search: Record<string, unknown>) => { [K in P]: T }
111
53
  /**
112
54
  * React hook: `const [current, persist] = store.useStore()`.
113
- * Call `persist(next)` in your filter's `onChange` alongside `navigate()`.
55
+ * localStorage only — it does NOT read or write the URL. `createSearchStore`'s
56
+ * `field.<name>.use()` is the replacement that does both.
114
57
  */
115
58
  useStore: () => readonly [T, (next: T) => void]
116
59
  /** Plain read — for use outside React (tests, guards, fallback reads). */
@@ -125,93 +68,33 @@ export type SearchParamStore<T extends string, P extends string = string> = {
125
68
  linkSearch: () => { [K in P]: T }
126
69
  }
127
70
 
128
- /**
129
- * Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
130
- * neither reader has ever been called. That combination has exactly one realistic cause — a nav
131
- * link declaring the param as a literal — and it is invisible otherwise, because every individual
132
- * piece looks correct and the feature merely never remembers anything.
133
- *
134
- * Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
135
- * store, and it cannot say what to do about it. This fires only in the broken state, and names the
136
- * fix.
137
- */
138
- function warnLinkPinsFallback(input: {
139
- key: string
140
- param: string
141
- urlValue: string
142
- storedValue: string
143
- }): void {
144
- const { key, param, urlValue, storedValue } = input
145
- // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
146
- console.warn(
147
- `[basalt-ui] createSearchParamStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
148
- '(the fallback) ' +
149
- `while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
150
- `almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
151
- "overrides the stored value on every click — pass the store's own reader instead: " +
152
- '`search: <store>.linkSearch`. (dev only)',
153
- )
154
- }
155
-
156
71
  // ── Implementation ─────────────────────────────────────────────────────────
157
72
 
73
+ /** @deprecated Use `createSearchStore` + `field.enum`. Removed in 1.29.0. */
158
74
  export function createSearchParamStore<const T extends string, const P extends string>(
159
75
  opts: SearchParamStoreOptions<T, P>,
160
76
  ): SearchParamStore<T, P> {
161
77
  const version = opts.version ?? 1
162
-
163
- // `validateSearch` reads the store internally on every navigation, which says nothing about
164
- // whether the CONSUMER wired a reader — only the two public readers below set this.
165
- let readerWired = false
166
- let warned = false
167
-
168
- const read = (): T | null => {
169
- const raw = readPersistedValue(opts.key, version)
170
- if (typeof raw !== 'string') return null
171
- if ((opts.values as readonly string[]).includes(raw)) return raw as T
172
- return null
173
- }
174
-
175
- const readStored = (): T | null => {
176
- readerWired = true
177
- return read()
178
- }
179
-
180
- const linkSearch = (): { [K in P]: T } => {
181
- readerWired = true
182
- return { [opts.param]: (read() ?? opts.fallback) as T } as { [K in P]: T }
183
- }
184
-
185
- const validateSearch = (search: Record<string, unknown>): { [K in P]: T } => {
186
- const raw = search[opts.param]
187
- if (typeof raw === 'string' && (opts.values as readonly string[]).includes(raw)) {
188
- if (
189
- !warned &&
190
- !readerWired &&
191
- raw === opts.fallback &&
192
- process.env['NODE_ENV'] !== 'production'
193
- ) {
194
- const stored = read()
195
- if (stored !== null && stored !== raw) {
196
- warned = true
197
- warnLinkPinsFallback({
198
- key: opts.key,
199
- param: opts.param,
200
- urlValue: raw,
201
- storedValue: stored,
202
- })
203
- }
204
- }
205
- return { [opts.param]: raw as T } as { [K in P]: T }
206
- }
207
- return { [opts.param]: (read() ?? opts.fallback) as T } as { [K in P]: T }
208
- }
209
-
210
- const useStore = createPersistedState<T>({
78
+ const store = buildSearchStore({
211
79
  key: opts.key,
212
80
  version,
213
- initial: opts.fallback,
81
+ fields: { [opts.param]: field.enum(opts.values, opts.fallback) },
82
+ legacyValueField: opts.param,
214
83
  })
215
84
 
216
- return { validateSearch, useStore, readStored, linkSearch }
85
+ const persisted = createPersistedState<T>({ key: opts.key, version, initial: opts.fallback })
86
+ // `useStore` is a reader too. Without this, a consumer whose ONLY reader is `useStore()` — the
87
+ // documented wiring for this factory — would start seeing the pinned-link warning it never saw
88
+ // before, purely because the wrapper now delegates.
89
+ const useStore = (): readonly [T, (next: T) => void] => {
90
+ store.markReaderWired()
91
+ return persisted()
92
+ }
93
+
94
+ return {
95
+ validateSearch: (search) => store.validateSearch(search) as { [K in P]: T },
96
+ useStore,
97
+ readStored: () => (store.readStored() as Record<string, T | undefined>)[opts.param] ?? null,
98
+ linkSearch: () => store.linkSearch() as { [K in P]: T },
99
+ }
217
100
  }