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,237 @@
1
+ /**
2
+ * `RangeFilter` — the time-window filter (`docs/CONTROLS-SPEC.md` §3). One pill over a
3
+ * `field.range`, which owns THREE URL params (preset + `from` + `to`), so a consumer's existing deep
4
+ * links and loaders keep their shape and `field.toWindow()` replaces every hand-rolled
5
+ * `presetToParams`.
6
+ *
7
+ * In the POPOVER the presets render as a `SegmentedControl` — vertical past four options, where a
8
+ * horizontal track would either overflow the popover or shrink each label below reading size.
9
+ * Numeric preset labels (`7d` / `30d`) get `data-numeric`, which is the mono treatment
10
+ * `theme/segmented-control.module.css` owns; that attribute is what retired the per-consumer
11
+ * `theme-allow` + inline `fontFamily` hack (C7).
12
+ *
13
+ * In the SHEET they render as a `SheetOptionList`, and the custom picker sits behind a
14
+ * `Custom range…` disclosure row. A vertical `SegmentedControl` stretched across a bottom drawer
15
+ * read as a broken control rather than a choice (see `SheetOptionList`'s doc), and an unconditional
16
+ * calendar pushed every OTHER filter in the sheet below the fold — the sheet holds all of them, and
17
+ * `RangeFilter` is one.
18
+ *
19
+ * The custom picker is INJECTED, never imported: `basalt-ui/controls-dates` holds the
20
+ * `@mantine/dates` implementation, and this module must resolve for a consumer who has no
21
+ * `@mantine/dates` at all (`docs/CONTROLS-SPEC.md` §3 — `basaltViteConfig`'s `optimizeDeps.include`
22
+ * for `@mantine/*` would break on an absent peer).
23
+ *
24
+ * @example
25
+ * import { DateRangePicker } from 'basalt-ui/controls-dates'
26
+ * // range: field.range({ presets: ['7d', '30d', '90d', 'ytd'], fallback: '30d', custom: true })
27
+ * <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
28
+ */
29
+ import { SegmentedControl, Stack } from '@mantine/core'
30
+ import { useState } from 'react'
31
+ import type { ComponentType, ReactNode } from 'react'
32
+ import type { FieldHandle, RangeField, RangeValue } from '../state'
33
+ import classes from './controls.module.css'
34
+ import { useFilterRegistration, useFilterSurface } from './filter-context'
35
+ import { FilterPill } from './filter-pill'
36
+ import { CalendarGlyph } from './glyphs'
37
+ import { SheetDisclosure, SheetField, SheetOptionList, useControlName } from './filter-sheet'
38
+
39
+ /** Past four presets the track goes vertical. */
40
+ const VERTICAL_FROM = 5
41
+ /** `7d`, `30d`, `24h`, `90` — a label the mono numeric treatment is for. */
42
+ const NUMERIC_LABEL = /^\d/
43
+
44
+ /** The house dev gate — `basaltViteConfig` defines `process.env.NODE_ENV`, so a production bundle
45
+ * constant-folds this to `false` and drops the warning. Read per call, never hoisted. */
46
+ function isDev(): boolean {
47
+ return process.env['NODE_ENV'] !== 'production'
48
+ }
49
+
50
+ /** Once per label — a filter renders on every navigation, and the wiring is fixed at definition. */
51
+ const noPickerWarned = new Set<string>()
52
+
53
+ /**
54
+ * Dev-only: the field declares `custom: true` and no picker was injected, so the custom window is
55
+ * unreachable — the popover shows presets only and the sheet's `Custom range…` row never renders.
56
+ * A warning rather than a type error: `custom: true` with presets only is a legal (and tested)
57
+ * configuration, and the picker is INJECTED precisely because `@mantine/dates` may be absent, so
58
+ * the type cannot tell "deliberately preset-only" from "forgot the import".
59
+ */
60
+ function warnCustomWithoutPicker(label: string, unreachable: boolean): void {
61
+ if (!unreachable || !isDev() || noPickerWarned.has(label)) return
62
+ noPickerWarned.add(label)
63
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
64
+ console.warn(
65
+ `[basalt-ui] RangeFilter('${label}'): the field declares \`custom: true\` but no ` +
66
+ '`customPicker` was injected, so the custom window cannot be reached. Pass ' +
67
+ '`customPicker={DateRangePicker}` (`basalt-ui/controls-dates`), or drop `custom: true` from ' +
68
+ 'the field. (dev only)',
69
+ )
70
+ }
71
+
72
+ /**
73
+ * The contract a custom-range picker satisfies — `basalt-ui/controls-dates`' `DateRangePicker`, or a
74
+ * consumer's own. Dates are ISO (`YYYY-MM-DD`), the same shape `field.range` stores and
75
+ * `toWindow()` emits, so the picker never learns the store's encoding.
76
+ */
77
+ export type RangeCustomPickerProps = {
78
+ readonly value: { readonly from?: string | undefined; readonly to?: string | undefined }
79
+ readonly onChange: (next: { from: string; to: string }) => void
80
+ }
81
+
82
+ /** Everything about the props that does not depend on the field's `custom` flag. */
83
+ type RangeFilterBase<P extends string, C extends boolean> = {
84
+ /**
85
+ * A range handle from either store factory. Generic over the field's `custom` flag so a
86
+ * `field.range({ ... })` WITHOUT `custom` (whose handle is `RangeField<P, false>`, and whose
87
+ * values therefore never include `'custom'`) binds with no cast — pinning `C` to its default
88
+ * `boolean` here made the setter contravariantly incompatible and forced one at every call site.
89
+ */
90
+ readonly field: FieldHandle<RangeField<P, C>>
91
+ /** Leading pill icon. Defaults to a calendar glyph — a range filter reads as a date control, so
92
+ * the glyph is part of the control's identity, not a per-call-site decision. */
93
+ readonly icon?: ReactNode
94
+ /** Sheet-form heading. The pill's own label is the VALUE, which is what a bar reads. @default 'Range' */
95
+ readonly label?: string
96
+ }
97
+
98
+ export type RangeFilterProps<P extends string, C extends boolean = boolean> = RangeFilterBase<
99
+ P,
100
+ C
101
+ > & {
102
+ /**
103
+ * Rendered when the field declares `custom: true`. Omitted → presets only, which stays LEGAL
104
+ * (a range that only ever shows presets is a real configuration) but warns once in dev when the
105
+ * field allows a custom window: the affordance is then unreachable, and nothing else says so.
106
+ */
107
+ readonly customPicker?: ComponentType<RangeCustomPickerProps>
108
+ }
109
+
110
+ export function RangeFilter<P extends string, C extends boolean = boolean>(
111
+ props: RangeFilterProps<P, C>,
112
+ ): ReactNode {
113
+ const {
114
+ field,
115
+ icon,
116
+ customPicker,
117
+ label = 'Range',
118
+ // One cast, at the one boundary a generic `C` cannot be read through: `C` only ever describes
119
+ // the FIELD's custom flag, and the body handles both.
120
+ } = props as unknown as RangeFilterBase<P, boolean> & {
121
+ readonly customPicker?: ComponentType<RangeCustomPickerProps>
122
+ }
123
+ const [value, setValue] = field.use()
124
+ const surface = useFilterSurface()
125
+ const isDefault = field.isDefault(value)
126
+ useFilterRegistration(!isDefault, () => {
127
+ field.clear()
128
+ })
129
+
130
+ const options = field.options
131
+ const presets = options.filter((option) => option.value !== 'custom')
132
+ const allowsCustom = options.length !== presets.length
133
+ const numeric = presets.length > 0 && presets.every((option) => NUMERIC_LABEL.test(option.label))
134
+ const inSheet = surface === 'sheet'
135
+ const Picker = customPicker
136
+ warnCustomWithoutPicker(label, allowsCustom && Picker === undefined)
137
+ // The track is a `radiogroup`; without this it announces unnamed, and the pill it hangs off reads
138
+ // the VALUE (`30d`), not the filter.
139
+ const { labelId, nameProps } = useControlName(label, inSheet)
140
+ // Expanded when the field ALREADY holds a custom window — a reader who deep-linked one must see
141
+ // it without hunting for the row that holds it. Local, because it is an overlay's open flag and
142
+ // not a filter value (the same line `FilterPill`'s `opened` state draws).
143
+ const [customOpen, setCustomOpen] = useState(value.preset === 'custom')
144
+ const showPicker = allowsCustom && Picker !== undefined
145
+
146
+ if (inSheet) {
147
+ return (
148
+ <SheetField label={label} labelId={labelId}>
149
+ <SheetOptionList
150
+ mode="single"
151
+ labelId={labelId}
152
+ selected={[value.preset]}
153
+ options={presets.map((option) => ({ value: option.value, label: option.label }))}
154
+ onToggle={(next) => {
155
+ setCustomOpen(false)
156
+ // Rendered from `field.options`, so `next` is always a declared preset — the cast
157
+ // restores what the codec already guarantees.
158
+ setValue({ preset: next } as RangeValue<P | 'custom'>)
159
+ }}
160
+ />
161
+ {showPicker && (
162
+ <SheetDisclosure
163
+ label="Custom range…"
164
+ expanded={customOpen}
165
+ onToggle={() => {
166
+ setCustomOpen((open) => !open)
167
+ }}
168
+ >
169
+ <Picker
170
+ value={{ from: value.from, to: value.to }}
171
+ onChange={({ from, to }) => {
172
+ setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
173
+ }}
174
+ />
175
+ </SheetDisclosure>
176
+ )}
177
+ </SheetField>
178
+ )
179
+ }
180
+
181
+ const body = (
182
+ <Stack gap="xs">
183
+ <SegmentedControl
184
+ {...nameProps}
185
+ size="ctl"
186
+ orientation={presets.length >= VERTICAL_FROM ? 'vertical' : 'horizontal'}
187
+ value={value.preset}
188
+ data={presets.map((option) => ({ value: option.value, label: option.label }))}
189
+ {...(numeric && { 'data-numeric': true })}
190
+ onChange={(next) => {
191
+ // The segments are rendered from `field.options`, so `next` is always a declared preset —
192
+ // the cast restores what the codec already guarantees.
193
+ setValue({ preset: next } as RangeValue<P | 'custom'>)
194
+ }}
195
+ />
196
+ {showPicker && (
197
+ <Picker
198
+ value={{ from: value.from, to: value.to }}
199
+ onChange={({ from, to }) => {
200
+ setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
201
+ }}
202
+ />
203
+ )}
204
+ </Stack>
205
+ )
206
+
207
+ return (
208
+ <FilterPill
209
+ label={rangeLabel(value, options)}
210
+ active={!isDefault}
211
+ numeric={numeric && value.preset !== 'custom'}
212
+ icon={icon ?? <CalendarGlyph />}
213
+ >
214
+ <div className={classes.optionList}>{body}</div>
215
+ </FilterPill>
216
+ )
217
+ }
218
+
219
+ const DAY_FORMAT = new Intl.DateTimeFormat('en-US', {
220
+ month: 'short',
221
+ day: 'numeric',
222
+ // ISO date strings parse as UTC midnight; formatting them in the runtime's zone would render the
223
+ // previous day west of Greenwich — and differently on server than on client (a hydration
224
+ // mismatch), the same hazard `formatArticleDate` avoids.
225
+ timeZone: 'UTC',
226
+ })
227
+
228
+ /** `Last 30 days`, or `Mar 1 – Mar 14` for a custom window. */
229
+ function rangeLabel(
230
+ value: RangeValue<string>,
231
+ options: readonly { value: string; label: string }[],
232
+ ): string {
233
+ if (value.preset === 'custom' && value.from !== undefined && value.to !== undefined) {
234
+ return `${DAY_FORMAT.format(new Date(value.from))} – ${DAY_FORMAT.format(new Date(value.to))}`
235
+ }
236
+ return options.find((option) => option.value === value.preset)?.label ?? value.preset
237
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * `RangeFilterProps` is generic over the FIELD's `custom` flag — the compile-time half, plus the one
3
+ * runtime consequence of leaving `customPicker` out.
4
+ *
5
+ * Why a file of its own (the `actions.type-guard.test.ts` convention): pinning `C` to its default
6
+ * `boolean` made `RangeFilter field={...}` reject a `field.range({ … })` that never declared
7
+ * `custom: true`. The handle's setter is contravariant, so a `RangeValue<P>` setter cannot stand in
8
+ * for a `RangeValue<P | 'custom'>` one — two consumers cast at the call site instead, which is
9
+ * exactly the diagnostic the cast was hiding.
10
+ */
11
+ import { describe, expect, spyOn, test } from 'bun:test'
12
+ import type { ReactNode } from 'react'
13
+ import { MantineProvider } from '@mantine/core'
14
+ import { render, screen } from '@testing-library/react'
15
+ import type { FieldHandle, RangeField } from '../state'
16
+ import { createLocalStore, field } from '../state'
17
+ import { RangeFilter } from './range-filter'
18
+ import type { RangeCustomPickerProps } from './range-filter'
19
+
20
+ // Two stores, not two range fields in one: every range defaults its `from`/`to` param names, and
21
+ // the store core rejects the collision at definition (see `assertNoParamCollision`).
22
+ const store = createLocalStore({
23
+ key: 'tg-range',
24
+ fields: {
25
+ presetsOnly: field.range({ presets: ['7d', '30d'], fallback: '30d' }),
26
+ view: field.enum(['chart', 'table'], 'chart'),
27
+ },
28
+ })
29
+
30
+ const customStore = createLocalStore({
31
+ key: 'tg-range-custom',
32
+ fields: { withCustom: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }) },
33
+ })
34
+
35
+ function Picker({ onChange }: RangeCustomPickerProps): ReactNode {
36
+ return (
37
+ <button
38
+ type="button"
39
+ onClick={() => {
40
+ onChange({ from: '2026-03-01', to: '2026-03-14' })
41
+ }}
42
+ >
43
+ pick march
44
+ </button>
45
+ )
46
+ }
47
+
48
+ /** A control that took a range handle from ITS OWN props — `C` widened, which must still bind. */
49
+ function Wrapper({ handle }: { handle: FieldHandle<RangeField<'7d' | '30d'>> }): ReactNode {
50
+ return <RangeFilter field={handle} customPicker={Picker} />
51
+ }
52
+
53
+ // ── Valid — must type-check with no error ─────────────────────────────────────
54
+
55
+ function Accepted(): ReactNode {
56
+ return (
57
+ <>
58
+ {/* The regression: no `custom`, therefore `RangeField<P, false>` — and no cast. */}
59
+ <RangeFilter field={store.field.presetsOnly} />
60
+ <RangeFilter field={store.field.presetsOnly} label="Window" />
61
+ <RangeFilter field={customStore.field.withCustom} customPicker={Picker} />
62
+ <Wrapper handle={customStore.field.withCustom} />
63
+ </>
64
+ )
65
+ }
66
+
67
+ // ── Invalid — each MUST be a tsc error, one directive per bad line ────────────
68
+
69
+ function Rejected(): ReactNode {
70
+ return (
71
+ <>
72
+ {/* @ts-expect-error an enum handle is not a range handle, whatever `C` is */}
73
+ <RangeFilter field={store.field.view} />
74
+ {/* @ts-expect-error `value`/`onChange` is not the binding a basalt control takes (C2) */}
75
+ <RangeFilter value={{ preset: '7d' }} onChange={() => {}} />
76
+ </>
77
+ )
78
+ }
79
+
80
+ /** The first argument of every captured warn call. */
81
+ function lines(warn: { mock: { calls: unknown[][] } }): string[] {
82
+ return warn.mock.calls.map((call) => String(call[0]))
83
+ }
84
+
85
+ describe('RangeFilterProps (type-guard)', () => {
86
+ test('both fixtures exist — tsc, not bun, is the assertion for the cases above', () => {
87
+ expect(typeof Accepted).toBe('function')
88
+ expect(typeof Rejected).toBe('function')
89
+ })
90
+
91
+ test('a custom-capable field with no picker warns once in dev — the affordance is unreachable', () => {
92
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
93
+ try {
94
+ render(
95
+ <MantineProvider>
96
+ <RangeFilter field={customStore.field.withCustom} label="Unreachable" />
97
+ </MantineProvider>,
98
+ )
99
+ expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
100
+ expect(
101
+ lines(warn).filter((line) => line.includes("RangeFilter('Unreachable')")),
102
+ ).toHaveLength(1)
103
+ expect(lines(warn)[0]).toContain('customPicker')
104
+ } finally {
105
+ warn.mockRestore()
106
+ }
107
+ })
108
+
109
+ test('a preset-only field is silent — presets only is a configuration, not a mistake', () => {
110
+ const warn = spyOn(console, 'warn').mockImplementation(() => {})
111
+ try {
112
+ render(
113
+ <MantineProvider>
114
+ <RangeFilter field={store.field.presetsOnly} label="Silent" />
115
+ </MantineProvider>,
116
+ )
117
+ expect(lines(warn).filter((line) => line.includes("RangeFilter('Silent')"))).toHaveLength(0)
118
+ } finally {
119
+ warn.mockRestore()
120
+ }
121
+ })
122
+ })
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `SearchFilter` — free text over a `field.string` (`docs/CONTROLS-SPEC.md` §3). The one filter that
3
+ * is not a pill: a text box states its own value, so wrapping it in a chip + popover would cost a
4
+ * press per keystroke session for nothing.
5
+ *
6
+ * Keystrokes are DEBOUNCED (200ms) before they reach the field. `field.string()` already writes with
7
+ * `history: 'replace'`, so an un-debounced input would still not stack history entries — what the
8
+ * debounce buys is one navigation per phrase instead of one per character, which is the difference
9
+ * between a typed query and a re-rendered route tree eight times over.
10
+ *
11
+ * @example
12
+ * // q: field.string({ max: 120 })
13
+ * <SearchFilter field={table.field.q} placeholder="Search pages" />
14
+ */
15
+ import { TextInput } from '@mantine/core'
16
+ import { useEffect, useRef, useState } from 'react'
17
+ import type { ReactNode } from 'react'
18
+ import type { FieldHandle, StringField } from '../state'
19
+ import { useFilterRegistration, useFilterSurface } from './filter-context'
20
+ import { SheetField, useControlName } from './filter-sheet'
21
+
22
+ /** One navigation per typed phrase, not per keystroke. */
23
+ const DEBOUNCE_MS = 200
24
+
25
+ export type SearchFilterProps = {
26
+ readonly field: FieldHandle<StringField>
27
+ readonly placeholder?: string
28
+ /** Sheet-form heading. @default 'Search' */
29
+ readonly label?: string
30
+ }
31
+
32
+ export function SearchFilter({
33
+ field,
34
+ placeholder,
35
+ label = 'Search',
36
+ }: SearchFilterProps): ReactNode {
37
+ const [value, setValue] = field.use()
38
+ const surface = useFilterSurface()
39
+ const draft = useDebouncedField(value, setValue)
40
+ useFilterRegistration(!field.isDefault(value), () => {
41
+ field.clear()
42
+ })
43
+
44
+ // `placeholder` is optional and, even when set, stops being the name the moment the user types —
45
+ // so the name comes from `label`, or from the sheet heading when there is one.
46
+ const { labelId, nameProps } = useControlName(label, surface === 'sheet')
47
+
48
+ const input = (
49
+ <TextInput
50
+ {...nameProps}
51
+ size="ctl"
52
+ value={draft.value}
53
+ leftSection={<SearchIcon />}
54
+ {...(placeholder !== undefined && { placeholder })}
55
+ onChange={(event) => {
56
+ draft.set(event.currentTarget.value)
57
+ }}
58
+ />
59
+ )
60
+
61
+ if (surface === 'sheet') {
62
+ return (
63
+ <SheetField label={label} labelId={labelId}>
64
+ {input}
65
+ </SheetField>
66
+ )
67
+ }
68
+ return input
69
+ }
70
+
71
+ /**
72
+ * A local draft that lands on the field `DEBOUNCE_MS` after the last keystroke, and that follows the
73
+ * field when it changes from OUTSIDE the input (`Reset all`, a back navigation, a deep link).
74
+ * `seen` is what separates the two directions: it holds the last value this hook itself committed,
75
+ * so an incoming change it did not cause is the only thing that overwrites the draft.
76
+ */
77
+ function useDebouncedField(
78
+ value: string,
79
+ setValue: (next: string) => void,
80
+ ): { value: string; set: (next: string) => void } {
81
+ const [draft, setDraft] = useState(value)
82
+ const seen = useRef(value)
83
+ const commit = useRef(setValue)
84
+ commit.current = setValue
85
+
86
+ useEffect(() => {
87
+ if (seen.current === value) return
88
+ seen.current = value
89
+ setDraft(value)
90
+ }, [value])
91
+
92
+ useEffect(() => {
93
+ if (draft === value) return
94
+ const timer = setTimeout(() => {
95
+ seen.current = draft
96
+ commit.current(draft)
97
+ }, DEBOUNCE_MS)
98
+ return () => {
99
+ clearTimeout(timer)
100
+ }
101
+ }, [draft, value])
102
+
103
+ return { value: draft, set: setDraft }
104
+ }
105
+
106
+ function SearchIcon(): ReactNode {
107
+ return (
108
+ <svg
109
+ width={16}
110
+ height={16}
111
+ viewBox="0 0 24 24"
112
+ fill="none"
113
+ stroke="currentColor"
114
+ strokeWidth={2}
115
+ strokeLinecap="round"
116
+ strokeLinejoin="round"
117
+ aria-hidden
118
+ >
119
+ <circle cx="11" cy="11" r="7" />
120
+ <path d="M21 21l-4.35 -4.35" />
121
+ </svg>
122
+ )
123
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `SelectFilter` — one choice, one pill (`docs/CONTROLS-SPEC.md` §3). Takes `field`, never
3
+ * `value`/`onChange`/`size` (C2/C5): the handle owns the URL write, the localStorage mirror and the
4
+ * option labels (`store.labels()`), so the call site is the field plus its human name.
5
+ *
6
+ * `options` is the escape hatch for a catalogue that only exists at runtime — the label carries live
7
+ * data (`EUR · 1.08`), or the id set is fetched rather than declared. It OVERRIDES `field.options`
8
+ * at render, and it is what makes the second `field` shape legal: a `StringField` handle plus
9
+ * `options` covers an id set no enum could close over (a project picker, a device list), while a
10
+ * `StringField` handle WITHOUT `options` stays a type error — nothing would supply the rows, so the
11
+ * filter would render an empty popover.
12
+ *
13
+ * @example
14
+ * <SelectFilter field={analytics.field.currency} label="Currency" clearable />
15
+ *
16
+ * @example
17
+ * // A runtime catalogue over a closed enum — same field, live labels.
18
+ * <SelectFilter
19
+ * field={analytics.field.currency}
20
+ * label="Currency"
21
+ * options={rates.map((r) => ({ value: r.code, label: `${r.code} · ${r.rate}` }))}
22
+ * />
23
+ *
24
+ * @example
25
+ * // An id set the enum cannot close over — a string field, options mandatory.
26
+ * <SelectFilter
27
+ * field={dashboard.field.projectId}
28
+ * label="Project"
29
+ * options={projects.map((p) => ({ value: p.id, label: p.name, disabled: p.archived }))}
30
+ * />
31
+ */
32
+ import type { ReactNode } from 'react'
33
+ import type { EnumField, FieldHandle, StringField } from '../state'
34
+ import { EnumFilter } from './enum-filter'
35
+
36
+ /**
37
+ * One row of a runtime catalogue. Wider than the store's own `FieldOption` by exactly one member:
38
+ * `disabled` renders the row and refuses the selection, which a declared enum expresses by leaving
39
+ * the value out and a live catalogue cannot (an archived project still labels the rows already
40
+ * pointing at it).
41
+ */
42
+ export type FilterOption = {
43
+ readonly value: string
44
+ readonly label: string
45
+ readonly disabled?: boolean
46
+ }
47
+
48
+ type SelectFilterCommon = {
49
+ readonly label: string
50
+ readonly icon?: ReactNode
51
+ /** Adds a `Clear` action that writes the field back to its fallback. */
52
+ readonly clearable?: boolean
53
+ }
54
+
55
+ /** A closed enum field: `options` relabels rows the field already declares, so it is optional. */
56
+ export type SelectFilterEnumProps<T extends string> = SelectFilterCommon & {
57
+ readonly field: FieldHandle<EnumField<T>>
58
+ /** Overrides `field.options` at render — a runtime catalogue, labels included. */
59
+ readonly options?: readonly FilterOption[]
60
+ }
61
+
62
+ /** A string field: it declares no values, so `options` is the only row source and is required. */
63
+ export type SelectFilterStringProps = SelectFilterCommon & {
64
+ readonly field: FieldHandle<StringField>
65
+ /** Required here — see this type's doc. */
66
+ readonly options: readonly FilterOption[]
67
+ }
68
+
69
+ export type SelectFilterProps<T extends string> = SelectFilterEnumProps<T> | SelectFilterStringProps
70
+
71
+ /**
72
+ * TWO OVERLOADS, not one conditional prop type, and the reason is the error text. A union prop type
73
+ * reports only the constituent it likes best — for a `StringField` handle passed without `options`
74
+ * that is the enum branch, so the message reads `Type '"string"' is not assignable to type '"enum"'`
75
+ * and never mentions the prop that would fix it. The overload form reports both halves, ending in
76
+ * `Property 'options' is missing`, which is the actual instruction.
77
+ */
78
+ export function SelectFilter<T extends string>(props: SelectFilterEnumProps<T>): ReactNode
79
+ export function SelectFilter(props: SelectFilterStringProps): ReactNode
80
+ export function SelectFilter<T extends string>(props: SelectFilterProps<T>): ReactNode {
81
+ const { field, ...rest } = props
82
+
83
+ // Two arms, one body — and they are not the same code to the compiler. `field.kind` is a NESTED
84
+ // discriminant, which narrows `field` but never `props`, so each arm is what fixes the shared
85
+ // body's value type: `string` for the string field, `T` for the enum. Written out rather than
86
+ // cast, so widening either branch fails here instead of at a consumer's call site.
87
+ if (field.kind === 'string') return <EnumFilter {...rest} field={field} />
88
+ return <EnumFilter {...rest} field={field} />
89
+ }
@@ -0,0 +1,63 @@
1
+ /*
2
+ * `SyncButton` chrome — the rotating glyph and the desktop-only age suffix.
3
+ */
4
+
5
+ .spin {
6
+ animation: basalt-sync-spin 900ms linear infinite;
7
+ }
8
+
9
+ @keyframes basalt-sync-spin {
10
+ to {
11
+ transform: rotate(360deg);
12
+ }
13
+ }
14
+
15
+ /*
16
+ * Reduced motion drops the rotation, never the icon: the `aria-busy`/`aria-disabled` pair is what
17
+ * actually announces "syncing", so removing the spin costs no information.
18
+ */
19
+ @media (prefers-reduced-motion: reduce) {
20
+ .spin {
21
+ animation: none;
22
+ }
23
+ }
24
+
25
+ /*
26
+ * The age rides beside the label on desktop and disappears below `sm`, where the tooltip carries
27
+ * it instead — one mount, a CSS-only swap (law C9).
28
+ */
29
+ .age {
30
+ margin-inline-start: var(--vx-space-control-gap);
31
+ color: var(--vx-muted);
32
+ font-family: var(--basalt-font-mono);
33
+ font-size: var(--vx-text-xs);
34
+ }
35
+
36
+ /*
37
+ * `scope: 'page'` below `sm`: the label and the age go, the glyph stays, and the button collapses
38
+ * to the square icon form (spec §3, "icon-only, age in tooltip"). Hidden, not unmounted — a second
39
+ * mount under `hiddenFrom` is exactly the responsive twin law C9 forbids, which is also why the
40
+ * accessible name lives on `aria-label` rather than in this text.
41
+ *
42
+ * Three declarations, because Mantine's Button spends its width in three places: `--button-padding-x`
43
+ * (the inline padding, which `[data-with-left-section]` divides by 1.5), the left section's own
44
+ * `margin-inline-end` (a fixed `--mantine-spacing-xs`, not a variable, so it takes a rule), and the
45
+ * width itself, pinned to the `ctl` height so the box is square. This module is unlayered, so it
46
+ * outranks `@layer mantine` regardless of specificity.
47
+ */
48
+ @media (max-width: 47.99375em) {
49
+ .label,
50
+ .age {
51
+ display: none;
52
+ }
53
+
54
+ .pageButton {
55
+ --button-padding-x: 0;
56
+
57
+ width: calc(var(--vx-space-control-height-ctl, 1.875rem) * var(--mantine-scale, 1));
58
+ }
59
+
60
+ .pageButton :global(.mantine-Button-section[data-position='left']) {
61
+ margin-inline-end: 0;
62
+ }
63
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Typed CSS-module declaration (the S4 CSS-modules path). Named string props — not an index
3
+ * signature — so dot access stays verbatim under the package's strict flags.
4
+ */
5
+ declare const classes: {
6
+ readonly spin: string
7
+ readonly age: string
8
+ readonly label: string
9
+ readonly pageButton: string
10
+ }
11
+ export default classes