basalt-ui 1.25.0 → 1.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,512 @@
1
+ /**
2
+ * `createLocalStore` — the router-free lane of the store family.
3
+ *
4
+ * Rendered with NO `RouterProvider` on purpose: the local lane must not touch
5
+ * `@tanstack/react-router`, because that is the whole difference between it and `createSearchStore`
6
+ * (argo's five in-chart `useState` selects, linewatch's compact toggle). A router hook would throw
7
+ * here, so these tests are the proof, not a claim.
8
+ */
9
+ import { beforeEach, describe, expect, test } from 'bun:test'
10
+ import { act, renderHook } from '@testing-library/react'
11
+ import { createLocalStore, field } from './fields'
12
+
13
+ function storedRecord(key: string): Record<string, unknown> | null {
14
+ const raw = localStorage.getItem(`basalt:${key}`)
15
+ if (raw === null) return null
16
+ return (JSON.parse(raw) as { value: Record<string, unknown> }).value
17
+ }
18
+
19
+ function persist(key: string, record: Record<string, unknown>): void {
20
+ localStorage.setItem(`basalt:${key}`, JSON.stringify({ v: 1, value: record }))
21
+ }
22
+
23
+ beforeEach(() => {
24
+ localStorage.clear()
25
+ })
26
+
27
+ describe('createLocalStore', () => {
28
+ test('use() reads the mirror, falls back, and writes with no router in sight', () => {
29
+ const store = createLocalStore({
30
+ key: 'l-metric',
31
+ fields: { metric: field.enum(['load', 'volume'], 'load') },
32
+ })
33
+
34
+ const { result } = renderHook(() => store.field.metric.use())
35
+ expect(result.current[0]).toBe('load')
36
+
37
+ act(() => {
38
+ result.current[1]('volume')
39
+ })
40
+ expect(result.current[0]).toBe('volume')
41
+ expect(storedRecord('l-metric')).toEqual({ metric: 'volume' })
42
+ })
43
+
44
+ test('a pre-existing mirror is read on first render', () => {
45
+ const store = createLocalStore({
46
+ key: 'l-restore',
47
+ fields: { view: field.enum(['chart', 'table'], 'chart') },
48
+ })
49
+ persist('l-restore', { view: 'table' })
50
+
51
+ const { result } = renderHook(() => store.field.view.use())
52
+ expect(result.current[0]).toBe('table')
53
+ })
54
+
55
+ test('an invalid mirror entry decodes to the fallback rather than through it', () => {
56
+ const store = createLocalStore({
57
+ key: 'l-invalid',
58
+ fields: { view: field.enum(['chart', 'table'], 'chart') },
59
+ })
60
+ persist('l-invalid', { view: 'spreadsheet' })
61
+
62
+ const { result } = renderHook(() => store.field.view.use())
63
+ expect(result.current[0]).toBe('chart')
64
+ })
65
+
66
+ test('every field kind round-trips through the mirror', () => {
67
+ const store = createLocalStore({
68
+ key: 'l-kinds',
69
+ fields: {
70
+ tags: field.multi(['api', 'design', 'guide']),
71
+ range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
72
+ rows: field.number({ fallback: 10, min: 1, max: 50, int: true }),
73
+ compact: field.boolean(false),
74
+ q: field.string(),
75
+ },
76
+ })
77
+
78
+ const tags = renderHook(() => store.field.tags.use())
79
+ act(() => {
80
+ tags.result.current[1](['guide', 'api'])
81
+ })
82
+ // Canonical order is enforced on the way back out, not on the way in.
83
+ expect(store.readStored()['tags']).toEqual(['api', 'guide'])
84
+
85
+ const range = renderHook(() => store.field.range.use())
86
+ act(() => {
87
+ range.result.current[1]({ preset: 'custom', from: '2026-01-01', to: '2026-02-01' })
88
+ })
89
+ expect(range.result.current[0]).toEqual({
90
+ preset: 'custom',
91
+ from: '2026-01-01',
92
+ to: '2026-02-01',
93
+ })
94
+
95
+ const rows = renderHook(() => store.field.rows.use())
96
+ act(() => {
97
+ rows.result.current[1](25)
98
+ })
99
+ expect(rows.result.current[0]).toBe(25)
100
+
101
+ const compact = renderHook(() => store.field.compact.use())
102
+ act(() => {
103
+ compact.result.current[1](true)
104
+ })
105
+ expect(compact.result.current[0]).toBe(true)
106
+
107
+ const q = renderHook(() => store.field.q.use())
108
+ act(() => {
109
+ q.result.current[1]('sled')
110
+ })
111
+ expect(q.result.current[0]).toBe('sled')
112
+
113
+ // One localStorage entry per store, holding every field.
114
+ expect(storedRecord('l-kinds')).toEqual({
115
+ tags: ['guide', 'api'],
116
+ range: { preset: 'custom', from: '2026-01-01', to: '2026-02-01' },
117
+ rows: 25,
118
+ compact: true,
119
+ q: 'sled',
120
+ })
121
+ })
122
+
123
+ test('two fields written in the same tick do not clobber each other', () => {
124
+ const store = createLocalStore({
125
+ key: 'l-concurrent',
126
+ fields: {
127
+ view: field.enum(['chart', 'table'], 'chart'),
128
+ compact: field.boolean(false),
129
+ },
130
+ })
131
+
132
+ const view = renderHook(() => store.field.view.use())
133
+ const compact = renderHook(() => store.field.compact.use())
134
+
135
+ act(() => {
136
+ view.result.current[1]('table')
137
+ compact.result.current[1](true)
138
+ })
139
+
140
+ expect(storedRecord('l-concurrent')).toEqual({ view: 'table', compact: true })
141
+ })
142
+
143
+ test('readStored is flat and omits a field that has never been written', () => {
144
+ const store = createLocalStore({
145
+ key: 'l-read',
146
+ fields: {
147
+ view: field.enum(['chart', 'table'], 'chart'),
148
+ compact: field.boolean(false),
149
+ },
150
+ })
151
+ persist('l-read', { view: 'table' })
152
+
153
+ expect(store.readStored()).toEqual({ view: 'table' })
154
+ })
155
+
156
+ test('handles carry the same contract a search store hands a control', () => {
157
+ const store = createLocalStore({
158
+ key: 'l-handle',
159
+ fields: { view: field.enum(['chart', 'table'], 'chart') },
160
+ }).field.view
161
+
162
+ expect(store.kind).toBe('enum')
163
+ expect(store.fallback).toBe('chart')
164
+ expect(store.isDefault('chart')).toBe(true)
165
+ expect(store.options).toEqual([
166
+ { value: 'chart', label: 'chart' },
167
+ { value: 'table', label: 'table' },
168
+ ])
169
+ })
170
+ })
171
+
172
+ /**
173
+ * `persist: false` in a local store has no URL to fall back on, so it is the IN-MEMORY lane rather
174
+ * than a field frozen on its fallback: shared across mounts for the session, gone on reload.
175
+ */
176
+ describe('createLocalStore — the in-memory lane', () => {
177
+ test('a persist: false field survives a remount and never touches localStorage', () => {
178
+ const store = createLocalStore({
179
+ key: 'l-memory',
180
+ fields: { zoomed: field.boolean(false, { persist: false }) },
181
+ })
182
+
183
+ const first = renderHook(() => store.field.zoomed.use())
184
+ expect(first.result.current[0]).toBe(false)
185
+ act(() => {
186
+ first.result.current[1](true)
187
+ })
188
+ expect(first.result.current[0]).toBe(true)
189
+ first.unmount()
190
+
191
+ const second = renderHook(() => store.field.zoomed.use())
192
+ expect(second.result.current[0]).toBe(true)
193
+ expect(localStorage.getItem('basalt:l-memory')).toBeNull()
194
+ expect(localStorage.length).toBe(0)
195
+ })
196
+
197
+ test('two live mounts of the same field see one value', () => {
198
+ const store = createLocalStore({
199
+ key: 'l-memory-shared',
200
+ fields: { metric: field.enum(['load', 'volume'], 'load', { persist: false }) },
201
+ })
202
+
203
+ const a = renderHook(() => store.field.metric.use())
204
+ const b = renderHook(() => store.field.metric.use())
205
+
206
+ act(() => {
207
+ a.result.current[1]('volume')
208
+ })
209
+
210
+ expect(b.result.current[0]).toBe('volume')
211
+ })
212
+
213
+ test('a mirrored field beside it still persists — the lane is per field', () => {
214
+ const store = createLocalStore({
215
+ key: 'l-memory-mixed',
216
+ fields: {
217
+ view: field.enum(['chart', 'table'], 'chart'),
218
+ zoomed: field.boolean(false, { persist: false }),
219
+ },
220
+ })
221
+
222
+ const view = renderHook(() => store.field.view.use())
223
+ const zoomed = renderHook(() => store.field.zoomed.use())
224
+ act(() => {
225
+ view.result.current[1]('table')
226
+ zoomed.result.current[1](true)
227
+ })
228
+
229
+ expect(storedRecord('l-memory-mixed')).toEqual({ view: 'table' })
230
+ expect(zoomed.result.current[0]).toBe(true)
231
+ })
232
+ })
233
+
234
+ describe('createLocalStore — labels()', () => {
235
+ test('labels reach field.<name>.options, and the call is chainable', () => {
236
+ const store = createLocalStore({
237
+ key: 'l-labels',
238
+ fields: { metric: field.enum(['load', 'volume'], 'load') },
239
+ }).labels({ metric: { load: 'Training load', volume: 'Total volume' } })
240
+
241
+ expect(store.field.metric.options).toEqual([
242
+ { value: 'load', label: 'Training load' },
243
+ { value: 'volume', label: 'Total volume' },
244
+ ])
245
+ })
246
+
247
+ test('an unlabelled value falls back to the value itself', () => {
248
+ const store = createLocalStore({
249
+ key: 'l-labels-partial',
250
+ fields: { metric: field.enum(['load', 'volume'], 'load') },
251
+ }).labels({ metric: { load: 'Training load' } })
252
+
253
+ expect(store.field.metric.options.map((option) => option.label)).toEqual([
254
+ 'Training load',
255
+ 'volume',
256
+ ])
257
+ })
258
+ })
259
+
260
+ /**
261
+ * A lazy fallback — `fallback: () => T`. The local store is its home: `createSearchStore` allows it
262
+ * off the URL lane only (a thunk in `validateSearch` would pin a computed value into a deep link),
263
+ * and every lane here is off the URL by construction.
264
+ */
265
+ describe('createLocalStore — a lazy fallback', () => {
266
+ test('is not called at definition, resolves at read, and is re-read while nothing is written', () => {
267
+ let calls = 0
268
+ let today = '2026-01-01'
269
+ const store = createLocalStore({
270
+ key: 'l-lazy',
271
+ fields: {
272
+ since: field.string({
273
+ fallback: () => {
274
+ calls += 1
275
+ return today
276
+ },
277
+ }),
278
+ },
279
+ })
280
+ // Definition must not run it: a store is built at module scope, where "now" is meaningless.
281
+ expect(calls).toBe(0)
282
+
283
+ const { result } = renderHook(() => store.field.since.use())
284
+ expect(result.current[0]).toBe('2026-01-01')
285
+ expect(calls).toBeGreaterThan(0)
286
+
287
+ // The thunk is the value while the field is unset — a later read sees what it returns NOW.
288
+ today = '2026-02-01'
289
+ expect(store.field.since.fallback).toBe('2026-02-01')
290
+ expect(store.field.since.isDefault('2026-02-01')).toBe(true)
291
+ expect(store.field.since.isDefault('2026-01-01')).toBe(false)
292
+
293
+ // Nothing was persisted by reading — a fallback is not a value until a write makes it one.
294
+ expect(localStorage.getItem('basalt:l-lazy')).toBeNull()
295
+ })
296
+
297
+ test('a write wins over the thunk, and the thunk stops being consulted', () => {
298
+ let calls = 0
299
+ const store = createLocalStore({
300
+ key: 'l-lazy-write',
301
+ fields: {
302
+ rows: field.number({
303
+ fallback: () => {
304
+ calls += 1
305
+ return 10
306
+ },
307
+ min: 1,
308
+ }),
309
+ },
310
+ })
311
+
312
+ const { result } = renderHook(() => store.field.rows.use())
313
+ expect(result.current[0]).toBe(10)
314
+ act(() => {
315
+ result.current[1](25)
316
+ })
317
+
318
+ const after = calls
319
+ expect(result.current[0]).toBe(25)
320
+ expect(storedRecord('l-lazy-write')).toEqual({ rows: 25 })
321
+ expect(calls).toBe(after)
322
+ })
323
+
324
+ test('the memory lane resolves it too — every kind takes the thunk form', () => {
325
+ let preset = '7d'
326
+ let n = 1
327
+ const store = createLocalStore({
328
+ key: 'l-lazy-memory',
329
+ fields: {
330
+ metric: field.enum(['load', 'volume'], () => 'volume', { persist: false }),
331
+ range: field.range({ presets: ['7d', '30d'], fallback: () => preset as '7d' | '30d' }),
332
+ tags: field.multi(['api', 'design'], () => ['design']),
333
+ compact: field.boolean(() => true),
334
+ // The two kinds this test used to omit. `number` builds its codec with an EXTRA member
335
+ // (`bounds`), which is exactly how it once ended up spreading the shell and freezing this
336
+ // fallback at definition — the whole kind list is here so that cannot recur unnoticed.
337
+ count: field.number({ fallback: () => n, min: 0 }),
338
+ label: field.string({ fallback: () => String(n) }),
339
+ },
340
+ })
341
+
342
+ const metric = renderHook(() => store.field.metric.use())
343
+ expect(metric.result.current[0]).toBe('volume')
344
+ expect(store.field.range.fallback).toEqual({ preset: '7d' })
345
+ preset = '30d'
346
+ expect(store.field.range.fallback).toEqual({ preset: '30d' })
347
+ expect(store.field.tags.fallback).toEqual(['design'])
348
+ expect(store.field.compact.fallback).toBe(true)
349
+ expect([store.field.count.fallback, store.field.label.fallback]).toEqual([1, '1'])
350
+
351
+ // Read at READ time, not at definition: a later read sees what the thunk returns now.
352
+ n = 2
353
+ expect([store.field.count.fallback, store.field.label.fallback]).toEqual([2, '2'])
354
+ const count = renderHook(() => store.field.count.use())
355
+ expect(count.result.current[0]).toBe(2)
356
+ })
357
+
358
+ test('clear() unsets the mirror, so the thunk resolves again', () => {
359
+ let today = '2026-08-27'
360
+ const store = createLocalStore({
361
+ key: 'l-lazy-clear',
362
+ fields: {
363
+ since: field.string({ fallback: () => today }),
364
+ metric: field.enum(['load', 'volume'], 'load'),
365
+ },
366
+ })
367
+ persist('l-lazy-clear', { since: '2026-01-01', metric: 'volume' })
368
+
369
+ const { result } = renderHook(() => store.field.since.use())
370
+ expect(result.current[0]).toBe('2026-01-01')
371
+
372
+ act(() => {
373
+ store.field.since.clear()
374
+ })
375
+
376
+ // The KEY is gone — writing the resolved fallback instead would hand a reader '2026-08-27'
377
+ // tomorrow, as a value they never chose. The sibling field is untouched.
378
+ expect(storedRecord('l-lazy-clear')).toEqual({ metric: 'volume' })
379
+ expect(result.current[0]).toBe('2026-08-27')
380
+ today = '2026-08-28'
381
+ expect(store.field.since.fallback).toBe('2026-08-28')
382
+ expect(store.field.since.isDefault('2026-08-28')).toBe(true)
383
+ })
384
+
385
+ test('clear() on the memory lane drops that field only', () => {
386
+ const store = createLocalStore({
387
+ key: 'l-clear-memory',
388
+ fields: {
389
+ scratch: field.enum(['a', 'b'], 'a', { persist: false }),
390
+ other: field.enum(['a', 'b'], 'a', { persist: false }),
391
+ },
392
+ })
393
+
394
+ const scratch = renderHook(() => store.field.scratch.use())
395
+ const other = renderHook(() => store.field.other.use())
396
+ act(() => {
397
+ scratch.result.current[1]('b')
398
+ other.result.current[1]('b')
399
+ })
400
+ expect([scratch.result.current[0], other.result.current[0]]).toEqual(['b', 'b'])
401
+
402
+ act(() => {
403
+ store.field.scratch.clear()
404
+ })
405
+ expect([scratch.result.current[0], other.result.current[0]]).toEqual(['a', 'b'])
406
+ expect(localStorage.getItem('basalt:l-clear-memory')).toBeNull()
407
+ })
408
+
409
+ /**
410
+ * `toWindow`'s TYPE, not just its runtime: the presets that declared a resolver are excluded from
411
+ * the `{ window }` branch, so the result assigns to an API param type naming only the
412
+ * server-understood windows. Before that, a consumer replacing its `presetToParams` switch with
413
+ * `toWindow` needed a cast — the workaround moved rather than going away.
414
+ */
415
+ test('toWindow drops the resolved presets from its return TYPE', () => {
416
+ const store = createLocalStore({
417
+ key: 'l-towindow',
418
+ fields: {
419
+ window: field.range({
420
+ presets: ['7d', '30d', '90d', '3m', 'ytd', 'all'],
421
+ fallback: '30d',
422
+ window: {
423
+ '3m': () => ({ from: '2026-06-01', to: '2026-08-27' }),
424
+ ytd: () => ({ from: '2026-01-01', to: '2026-08-27' }),
425
+ },
426
+ }),
427
+ },
428
+ })
429
+
430
+ type SummaryParams = { window: '7d' | '30d' | '90d' | 'all' } | { from: string; to: string }
431
+
432
+ // The assignment IS the assertion — no cast, which is what a consumer's `resolveWindow` was.
433
+ const resolve = (preset: '7d' | '30d' | '90d' | '3m' | 'ytd' | 'all'): SummaryParams =>
434
+ store.field.window.toWindow({ preset })
435
+
436
+ expect(resolve('30d')).toEqual({ window: '30d' })
437
+ expect(resolve('3m')).toEqual({ from: '2026-06-01', to: '2026-08-27' })
438
+ expect(resolve('ytd')).toEqual({ from: '2026-01-01', to: '2026-08-27' })
439
+
440
+ // @ts-expect-error `3m` declared a resolver, so it can never come back in the window branch.
441
+ const pinned: { window: '3m' } | { from: string; to: string } = store.field.window.toWindow({
442
+ preset: '3m',
443
+ })
444
+ expect(pinned).toEqual({ from: '2026-06-01', to: '2026-08-27' })
445
+ })
446
+ })
447
+
448
+ /**
449
+ * The number handle's republished bounds (`NumberHandleExtras`). They are on the handle for exactly
450
+ * one reason: a control never sees the field descriptor, so before this the only way a `NumberFilter`
451
+ * could bound its own input was for the call site to pass `min`/`max` a second time — a second answer
452
+ * to a question the field already owns.
453
+ */
454
+ describe('createLocalStore — a number handle republishes its bounds', () => {
455
+ test('min, max and int come off the field declaration', () => {
456
+ const store = createLocalStore({
457
+ key: 'l-bounds',
458
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
459
+ })
460
+
461
+ expect({
462
+ min: store.field.nights.min,
463
+ max: store.field.nights.max,
464
+ int: store.field.nights.int,
465
+ }).toEqual({ min: 1, max: 14, int: true })
466
+ })
467
+
468
+ test('an undeclared bound is undefined, and int defaults to false — never 0/true by accident', () => {
469
+ const store = createLocalStore({
470
+ key: 'l-bounds-open',
471
+ fields: { threshold: field.number({ fallback: 0 }) },
472
+ })
473
+
474
+ expect({
475
+ min: store.field.threshold.min,
476
+ max: store.field.threshold.max,
477
+ int: store.field.threshold.int,
478
+ }).toEqual({ min: undefined, max: undefined, int: false })
479
+ })
480
+
481
+ // The negative branch of the extras type, asserted at RUNTIME as well: a non-number handle must
482
+ // not grow a `max` off `StringField`'s own `max`, which is the one collision this shape could have.
483
+ test('a non-number handle carries none of the three', () => {
484
+ const store = createLocalStore({
485
+ key: 'l-bounds-other',
486
+ fields: { q: field.string({ max: 40 }), metric: field.enum(['load', 'volume'], 'load') },
487
+ })
488
+
489
+ expect([store.field.q.max, store.field.q.min, store.field.q.int]).toEqual([
490
+ undefined,
491
+ undefined,
492
+ undefined,
493
+ ])
494
+ expect(store.field.metric.int).toBeUndefined()
495
+ })
496
+
497
+ // The bounds are what the codec was ALREADY clamping to — the handle is a readout of that law,
498
+ // not a second copy of it.
499
+ test('the value the codec clamps to is the value the handle reports', () => {
500
+ const store = createLocalStore({
501
+ key: 'l-bounds-clamp',
502
+ fields: { nights: field.number({ fallback: 2, min: 1, max: 14, int: true }) },
503
+ })
504
+ persist('l-bounds-clamp', { nights: 99 })
505
+
506
+ const { result } = renderHook(() => store.field.nights.use())
507
+ // Both sides in ONE assertion, so the pair has to agree rather than each matching 14 alone —
508
+ // `max` is `number | undefined` on the handle by construction, which is why it is not the
509
+ // `expected` argument.
510
+ expect([result.current[0], store.field.nights.max]).toEqual([14, 14])
511
+ })
512
+ })