basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,106 @@
1
+ /**
2
+ * C2 and C5 as COMPILER assertions. The claim "a basalt filter has no `value`/`onChange`/`size`" is
3
+ * only worth making if writing one fails the build, so each `@ts-expect-error` below is the actual
4
+ * enforcement: `bunx tsc --noEmit` fails if any of them stops being an error, which is what happens
5
+ * the moment someone widens a prop type back open.
6
+ *
7
+ * `tsc` covers `src/**` including test files, so this file is checked by the ordinary typecheck —
8
+ * there is nothing extra to run and nothing to remember.
9
+ */
10
+ import { describe, expect, test } from 'bun:test'
11
+ import type { ReactNode } from 'react'
12
+ import { createLocalStore, field } from '../state'
13
+ import { CompareFilter } from './compare-filter'
14
+ import { MultiSelectFilter } from './multi-select-filter'
15
+ import { RangeFilter } from './range-filter'
16
+ import { SearchFilter } from './search-filter'
17
+ import { SelectFilter } from './select-filter'
18
+ import { ToggleFilter } from './toggle-filter'
19
+ import { ViewTabs } from './view-tabs'
20
+
21
+ const local = createLocalStore({
22
+ key: 'props-contract',
23
+ fields: {
24
+ currency: field.enum(['USD', 'EUR'], 'USD'),
25
+ compare: field.enum(['none', 'previous', 'year'] as const, 'none'),
26
+ channels: field.multi(['web', 'email'], []),
27
+ range: field.range({ presets: ['7d', '30d'], fallback: '30d', custom: true }),
28
+ errorsOnly: field.boolean(false),
29
+ q: field.string(),
30
+ },
31
+ })
32
+
33
+ /** A runtime catalogue — the shape both `options` props take (`FilterOption[]`). */
34
+ const PROJECTS = [
35
+ { value: 'argo', label: 'Argo' },
36
+ { value: 'linewatch', label: 'Linewatch', disabled: true },
37
+ ]
38
+ const CHANNELS = [{ value: 'web', label: 'web · 1.2k' }]
39
+
40
+ /** Never rendered — the file's product is the type errors below, not a DOM assertion. */
41
+ function Rejected(): ReactNode {
42
+ return (
43
+ <>
44
+ {/* @ts-expect-error C2 — a filter has no `value`; the field owns it. */}
45
+ <SelectFilter field={local.field.currency} label="Currency" value="EUR" />
46
+ {/* @ts-expect-error C2 — nor an `onChange`; the write goes through the field. */}
47
+ <SelectFilter field={local.field.currency} label="Currency" onChange={() => {}} />
48
+ {/* @ts-expect-error C5 — the home sets the tier; a control carries no `size`. */}
49
+ <SelectFilter field={local.field.currency} label="Currency" size="xs" />
50
+ {/* @ts-expect-error C5 — nor a responsive override; the swap is the control's own. */}
51
+ <ViewTabs field={local.field.currency} visibleFrom="sm" />
52
+ {/* @ts-expect-error C2 — a tab is store state too. */}
53
+ <ViewTabs field={local.field.currency} value="USD" />
54
+ {/* @ts-expect-error a boolean field is not an enum field. */}
55
+ <ToggleFilter field={local.field.currency} label="Errors only" />
56
+ {/* @ts-expect-error a multi field is not an enum field. */}
57
+ <SelectFilter field={local.field.channels} label="Channels" />
58
+ {/* @ts-expect-error an enum field is not a range field. */}
59
+ <RangeFilter field={local.field.currency} />
60
+ {/* @ts-expect-error a string field is not a boolean field. */}
61
+ <ToggleFilter field={local.field.q} label="Errors only" />
62
+ {/* @ts-expect-error CompareFilter's enum is the three basalt names, not any enum. */}
63
+ <CompareFilter field={local.field.currency} />
64
+ {/* @ts-expect-error `label` is required on SelectFilter — the pill needs a name at rest. */}
65
+ <SelectFilter field={local.field.currency} />
66
+ {/* A string field declares no values, so `options` is the only possible row source — without
67
+ it the popover would render empty. Two overloads rather than one union prop type precisely
68
+ so the message names `options`; see `SelectFilter`'s own doc. */}
69
+ {/* @ts-expect-error a string field needs `options` — nothing else can supply the rows. */}
70
+ <SelectFilter field={local.field.q} label="Project" />
71
+ {/* @ts-expect-error `options` does not widen a MULTI field into a single-select either. */}
72
+ <SelectFilter field={local.field.channels} label="Channels" options={PROJECTS} />
73
+ </>
74
+ )
75
+ }
76
+
77
+ /** The same call sites, spelled correctly — so a type error above cannot be a false positive. */
78
+ function Accepted(): ReactNode {
79
+ return (
80
+ <>
81
+ <SelectFilter field={local.field.currency} label="Currency" clearable />
82
+ {/* A runtime catalogue over a closed enum — `options` is optional there. */}
83
+ <SelectFilter
84
+ field={local.field.currency}
85
+ label="Currency"
86
+ options={[{ value: 'USD', label: 'USD · 1.00' }]}
87
+ />
88
+ {/* A string field WITH options — the id set an enum cannot close over. */}
89
+ <SelectFilter field={local.field.q} label="Project" options={PROJECTS} />
90
+ <MultiSelectFilter field={local.field.channels} label="All channels" options={CHANNELS} />
91
+ <CompareFilter field={local.field.compare} />
92
+ <MultiSelectFilter field={local.field.channels} label="All channels" noun="channels" />
93
+ <RangeFilter field={local.field.range} />
94
+ <ToggleFilter field={local.field.errorsOnly} label="Errors only" />
95
+ <SearchFilter field={local.field.q} placeholder="Search" />
96
+ <ViewTabs field={local.field.currency} />
97
+ </>
98
+ )
99
+ }
100
+
101
+ describe('props contract', () => {
102
+ test('both fixtures exist — tsc, not bun, is the assertion (see this file header)', () => {
103
+ expect(typeof Rejected).toBe('function')
104
+ expect(typeof Accepted).toBe('function')
105
+ })
106
+ })
@@ -0,0 +1,184 @@
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
+ /**
45
+ * The contract a custom-range picker satisfies — `basalt-ui/controls-dates`' `DateRangePicker`, or a
46
+ * consumer's own. Dates are ISO (`YYYY-MM-DD`), the same shape `field.range` stores and
47
+ * `toWindow()` emits, so the picker never learns the store's encoding.
48
+ */
49
+ export type RangeCustomPickerProps = {
50
+ readonly value: { readonly from?: string | undefined; readonly to?: string | undefined }
51
+ readonly onChange: (next: { from: string; to: string }) => void
52
+ }
53
+
54
+ export type RangeFilterProps<P extends string> = {
55
+ readonly field: FieldHandle<RangeField<P>>
56
+ /** Leading pill icon. Defaults to a calendar glyph — a range filter reads as a date control, so
57
+ * the glyph is part of the control's identity, not a per-call-site decision. */
58
+ readonly icon?: ReactNode
59
+ /** Rendered when the field declares `custom: true`. Omitted → presets only. */
60
+ readonly customPicker?: ComponentType<RangeCustomPickerProps>
61
+ /** Sheet-form heading. The pill's own label is the VALUE, which is what a bar reads. @default 'Range' */
62
+ readonly label?: string
63
+ }
64
+
65
+ export function RangeFilter<P extends string>({
66
+ field,
67
+ icon,
68
+ customPicker,
69
+ label = 'Range',
70
+ }: RangeFilterProps<P>): ReactNode {
71
+ const [value, setValue] = field.use()
72
+ const surface = useFilterSurface()
73
+ const isDefault = field.isDefault(value)
74
+ useFilterRegistration(!isDefault, () => {
75
+ setValue(field.fallback)
76
+ })
77
+
78
+ const options = field.options
79
+ const presets = options.filter((option) => option.value !== 'custom')
80
+ const allowsCustom = options.length !== presets.length
81
+ const numeric = presets.length > 0 && presets.every((option) => NUMERIC_LABEL.test(option.label))
82
+ const inSheet = surface === 'sheet'
83
+ const Picker = customPicker
84
+ // The track is a `radiogroup`; without this it announces unnamed, and the pill it hangs off reads
85
+ // the VALUE (`30d`), not the filter.
86
+ const { labelId, nameProps } = useControlName(label, inSheet)
87
+ // Expanded when the field ALREADY holds a custom window — a reader who deep-linked one must see
88
+ // it without hunting for the row that holds it. Local, because it is an overlay's open flag and
89
+ // not a filter value (the same line `FilterPill`'s `opened` state draws).
90
+ const [customOpen, setCustomOpen] = useState(value.preset === 'custom')
91
+ const showPicker = allowsCustom && Picker !== undefined
92
+
93
+ if (inSheet) {
94
+ return (
95
+ <SheetField label={label} labelId={labelId}>
96
+ <SheetOptionList
97
+ mode="single"
98
+ labelId={labelId}
99
+ selected={[value.preset]}
100
+ options={presets.map((option) => ({ value: option.value, label: option.label }))}
101
+ onToggle={(next) => {
102
+ setCustomOpen(false)
103
+ // Rendered from `field.options`, so `next` is always a declared preset — the cast
104
+ // restores what the codec already guarantees.
105
+ setValue({ preset: next } as RangeValue<P | 'custom'>)
106
+ }}
107
+ />
108
+ {showPicker && (
109
+ <SheetDisclosure
110
+ label="Custom range…"
111
+ expanded={customOpen}
112
+ onToggle={() => {
113
+ setCustomOpen((open) => !open)
114
+ }}
115
+ >
116
+ <Picker
117
+ value={{ from: value.from, to: value.to }}
118
+ onChange={({ from, to }) => {
119
+ setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
120
+ }}
121
+ />
122
+ </SheetDisclosure>
123
+ )}
124
+ </SheetField>
125
+ )
126
+ }
127
+
128
+ const body = (
129
+ <Stack gap="xs">
130
+ <SegmentedControl
131
+ {...nameProps}
132
+ size="ctl"
133
+ orientation={presets.length >= VERTICAL_FROM ? 'vertical' : 'horizontal'}
134
+ value={value.preset}
135
+ data={presets.map((option) => ({ value: option.value, label: option.label }))}
136
+ {...(numeric && { 'data-numeric': true })}
137
+ onChange={(next) => {
138
+ // The segments are rendered from `field.options`, so `next` is always a declared preset —
139
+ // the cast restores what the codec already guarantees.
140
+ setValue({ preset: next } as RangeValue<P | 'custom'>)
141
+ }}
142
+ />
143
+ {showPicker && (
144
+ <Picker
145
+ value={{ from: value.from, to: value.to }}
146
+ onChange={({ from, to }) => {
147
+ setValue({ preset: 'custom', from, to } as RangeValue<P | 'custom'>)
148
+ }}
149
+ />
150
+ )}
151
+ </Stack>
152
+ )
153
+
154
+ return (
155
+ <FilterPill
156
+ label={rangeLabel(value, options)}
157
+ active={!isDefault}
158
+ numeric={numeric && value.preset !== 'custom'}
159
+ icon={icon ?? <CalendarGlyph />}
160
+ >
161
+ <div className={classes.optionList}>{body}</div>
162
+ </FilterPill>
163
+ )
164
+ }
165
+
166
+ const DAY_FORMAT = new Intl.DateTimeFormat('en-US', {
167
+ month: 'short',
168
+ day: 'numeric',
169
+ // ISO date strings parse as UTC midnight; formatting them in the runtime's zone would render the
170
+ // previous day west of Greenwich — and differently on server than on client (a hydration
171
+ // mismatch), the same hazard `formatArticleDate` avoids.
172
+ timeZone: 'UTC',
173
+ })
174
+
175
+ /** `Last 30 days`, or `Mar 1 – Mar 14` for a custom window. */
176
+ function rangeLabel(
177
+ value: RangeValue<string>,
178
+ options: readonly { value: string; label: string }[],
179
+ ): string {
180
+ if (value.preset === 'custom' && value.from !== undefined && value.to !== undefined) {
181
+ return `${DAY_FORMAT.format(new Date(value.from))} – ${DAY_FORMAT.format(new Date(value.to))}`
182
+ }
183
+ return options.find((option) => option.value === value.preset)?.label ?? value.preset
184
+ }
@@ -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
+ setValue(field.fallback)
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