basalt-ui 1.25.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,637 @@
1
+ /**
2
+ * Every control, bound to a REAL `createSearchStore` under a REAL memory-history router — the half
3
+ * that cannot be tested headlessly. What each control reads on a deep link, what its pill says, and
4
+ * what lands on the URL when it is operated (C2/C3: there is no `value`/`onChange` to stub, so
5
+ * nothing here can pass against a control that only pretends to write).
6
+ *
7
+ * The harness is the one from `router-tanstack/search-store.router.test.tsx`, narrowed to one route
8
+ * (the position questions are that file's job) and wrapped in a `MantineProvider`.
9
+ */
10
+ import { beforeEach, describe, expect, test } from 'bun:test'
11
+ import type { ReactNode } from 'react'
12
+ import { MantineProvider } from '@mantine/core'
13
+ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
14
+ import {
15
+ Outlet,
16
+ RouterProvider,
17
+ createMemoryHistory,
18
+ createRootRoute,
19
+ createRoute,
20
+ createRouter,
21
+ } from '@tanstack/react-router'
22
+ import { field } from '../router-tanstack/field'
23
+ import { createSearchStore } from '../router-tanstack/search-store'
24
+ import { COMPARE_LABELS, COMPARE_VALUES, CompareFilter } from './compare-filter'
25
+ import { MultiSelectFilter } from './multi-select-filter'
26
+ import { RangeFilter } from './range-filter'
27
+ import type { RangeCustomPickerProps } from './range-filter'
28
+ import { SearchFilter } from './search-filter'
29
+ import { SelectFilter } from './select-filter'
30
+ import { ToggleFilter } from './toggle-filter'
31
+ import { ViewTabs } from './view-tabs'
32
+
33
+ type Router = { state: { status: string; location: { search: unknown } } }
34
+
35
+ async function mountPage(input: {
36
+ validateSearch: (raw: Record<string, unknown>) => Record<string, unknown>
37
+ entry: string
38
+ Page: () => ReactNode
39
+ }): Promise<Router> {
40
+ const rootRoute = createRootRoute({ component: () => <Outlet /> })
41
+ const pageRoute = createRoute({
42
+ getParentRoute: () => rootRoute,
43
+ path: '/dashboard',
44
+ validateSearch: input.validateSearch,
45
+ component: input.Page,
46
+ })
47
+ const router = createRouter({
48
+ routeTree: rootRoute.addChildren([pageRoute]),
49
+ history: createMemoryHistory({ initialEntries: [input.entry] }),
50
+ })
51
+
52
+ render(
53
+ <MantineProvider>
54
+ <RouterProvider router={router} />
55
+ </MantineProvider>,
56
+ )
57
+ await waitFor(() => {
58
+ expect(router.state.status).toBe('idle')
59
+ })
60
+ return router as unknown as Router
61
+ }
62
+
63
+ function search(router: Router): Record<string, unknown> {
64
+ return router.state.location.search as Record<string, unknown>
65
+ }
66
+
67
+ /**
68
+ * Press a pill and let its popover mount. The `act` wrapper is load-bearing: Mantine's `Popover` is
69
+ * a controlled surface behind a `Transition`, so the dropdown appears one flushed effect cycle after
70
+ * the click — a bare `fireEvent.click` leaves the body holding the button and nothing else.
71
+ *
72
+ * Queries INSIDE a dropdown pass `{ hidden: true }`: the dropdown is a portal still carrying its
73
+ * transition styles, which Testing Library reads as outside the accessibility tree. That is a
74
+ * harness fact, not a claim about the control — where visibility IS the assertion (`ViewTabs`'
75
+ * CSS swap below) the bare query is used deliberately.
76
+ */
77
+ async function openPill(name: string | RegExp): Promise<void> {
78
+ await act(async () => {
79
+ fireEvent.click(screen.getByRole('button', { name }))
80
+ })
81
+ }
82
+
83
+ /** A stand-in for `basalt-ui/controls-dates`' `DateRangePicker` — the injected-seam contract, and
84
+ * nothing else, so the range tests need no `@mantine/dates` at all. */
85
+ function Picker({ onChange }: RangeCustomPickerProps): ReactNode {
86
+ return (
87
+ <button
88
+ type="button"
89
+ onClick={() => {
90
+ onChange({ from: '2026-03-01', to: '2026-03-14' })
91
+ }}
92
+ >
93
+ pick march
94
+ </button>
95
+ )
96
+ }
97
+
98
+ beforeEach(() => {
99
+ localStorage.clear()
100
+ })
101
+
102
+ describe('SelectFilter', () => {
103
+ const store = createSearchStore({
104
+ key: 'c-select',
105
+ fields: { currency: field.enum(['USD', 'EUR'], 'USD') },
106
+ })
107
+
108
+ test('reads the SELECTED option at the fallback too, and writes the URL', async () => {
109
+ // The pill is a READOUT, so it prints the value at every value — including the field's default.
110
+ // It used to print the filter's NAME while `isDefault` held, which is how the playground's bar
111
+ // read `Compare` over a field holding `'previous'`. `data-active` is the channel that says
112
+ // "touched", and it is still the only one.
113
+ const router = await mountPage({
114
+ validateSearch: store.validateSearch,
115
+ entry: '/dashboard',
116
+ Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
117
+ })
118
+
119
+ const pill = screen.getByRole('button', { name: 'USD' })
120
+ expect(pill.hasAttribute('data-active')).toBe(false)
121
+ expect(screen.queryByRole('button', { name: 'Currency' })).toBeNull()
122
+
123
+ await openPill('USD')
124
+ fireEvent.click(screen.getByRole('radio', { name: 'EUR', hidden: true }))
125
+
126
+ await waitFor(() => {
127
+ expect(search(router)['currency']).toBe('EUR')
128
+ })
129
+ const active = screen.getByRole('button', { name: 'EUR' })
130
+ expect(active.hasAttribute('data-active')).toBe(true)
131
+ })
132
+
133
+ test('the popover names the FILTER, not the value the pill happens to read', async () => {
134
+ // Without this the dropdown announces 'EUR, radio button, 1 of 2' and never the word Currency:
135
+ // the pill's visible text is the VALUE once set, so it cannot be the group's name.
136
+ await mountPage({
137
+ validateSearch: store.validateSearch,
138
+ entry: '/dashboard?currency=EUR',
139
+ Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
140
+ })
141
+ await openPill('EUR')
142
+ expect(screen.getByRole('radiogroup', { name: 'Currency', hidden: true })).toBeDefined()
143
+ })
144
+
145
+ test('a deep link is what the pill reads, with no interaction at all', async () => {
146
+ await mountPage({
147
+ validateSearch: store.validateSearch,
148
+ entry: '/dashboard?currency=EUR',
149
+ Page: () => <SelectFilter field={store.field.currency} label="Currency" />,
150
+ })
151
+ expect(screen.getByRole('button', { name: 'EUR' })).toBeDefined()
152
+ })
153
+ })
154
+
155
+ /**
156
+ * The runtime-catalogue shape: `options` overrides the field's own rows, and a `StringField` handle
157
+ * becomes legal once it does. Both halves are asserted through a REAL `createSearchStore`, because
158
+ * the string lane is where a hand-rolled `value`/`onChange` would look identical and write nothing.
159
+ */
160
+ describe('SelectFilter — options as a runtime catalogue', () => {
161
+ const store = createSearchStore({
162
+ key: 'c-select-options',
163
+ fields: { currency: field.enum(['USD', 'EUR'], 'USD'), projectId: field.string() },
164
+ })
165
+
166
+ const RATES = [
167
+ { value: 'USD', label: 'USD · 1.00' },
168
+ { value: 'EUR', label: 'EUR · 1.08' },
169
+ ]
170
+ const PROJECTS = [
171
+ { value: 'argo', label: 'Argo' },
172
+ { value: 'linewatch', label: 'Linewatch' },
173
+ { value: 'retired', label: 'Retired app', disabled: true },
174
+ ]
175
+
176
+ test('the rows and their labels come from the prop, and the field is still what is written', async () => {
177
+ const router = await mountPage({
178
+ validateSearch: store.validateSearch,
179
+ entry: '/dashboard',
180
+ Page: () => <SelectFilter field={store.field.currency} label="Currency" options={RATES} />,
181
+ })
182
+
183
+ // The pill reads the catalogue's label for the CURRENT value, fallback included — `USD · 1.00`,
184
+ // never `Currency`.
185
+ await openPill('USD · 1.00')
186
+ // The override is whole, not a merge: the field's own bare `EUR` row is GONE, which is the
187
+ // property a merge would silently break.
188
+ expect(screen.queryByRole('radio', { name: 'EUR', hidden: true })).toBeNull()
189
+ fireEvent.click(screen.getByRole('radio', { name: 'EUR · 1.08', hidden: true }))
190
+
191
+ await waitFor(() => {
192
+ expect(search(router)['currency']).toBe('EUR')
193
+ })
194
+ expect(screen.getByRole('button', { name: 'EUR · 1.08' })).toBeDefined()
195
+ })
196
+
197
+ test('a string field plus options round-trips an id no enum could have declared', async () => {
198
+ const router = await mountPage({
199
+ validateSearch: store.validateSearch,
200
+ entry: '/dashboard',
201
+ Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
202
+ })
203
+
204
+ expect(screen.getByRole('button', { name: 'Project' })).toBeDefined()
205
+ await openPill('Project')
206
+ fireEvent.click(screen.getByRole('radio', { name: 'Argo', hidden: true }))
207
+
208
+ await waitFor(() => {
209
+ expect(search(router)['projectId']).toBe('argo')
210
+ })
211
+ expect(screen.getByRole('button', { name: 'Argo' })).toBeDefined()
212
+ })
213
+
214
+ test('a deep link into that id set is read with no interaction at all', async () => {
215
+ await mountPage({
216
+ validateSearch: store.validateSearch,
217
+ entry: '/dashboard?projectId=linewatch',
218
+ Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
219
+ })
220
+ expect(screen.getByRole('button', { name: 'Linewatch' })).toBeDefined()
221
+ })
222
+
223
+ test('a disabled catalogue row is offered and refused — it still labels the value it names', async () => {
224
+ await mountPage({
225
+ validateSearch: store.validateSearch,
226
+ entry: '/dashboard?projectId=retired',
227
+ Page: () => <SelectFilter field={store.field.projectId} label="Project" options={PROJECTS} />,
228
+ })
229
+ // A live catalogue cannot express "archived" by omitting the row — the URL may already point at
230
+ // it, and a missing row would read as the filter's default.
231
+ expect(screen.getByRole('button', { name: 'Retired app' })).toBeDefined()
232
+ await openPill('Retired app')
233
+ expect(
234
+ screen.getByRole('radio', { name: 'Retired app', hidden: true }).hasAttribute('disabled'),
235
+ ).toBe(true)
236
+ })
237
+ })
238
+
239
+ describe('CompareFilter', () => {
240
+ const store = createSearchStore({
241
+ key: 'c-compare',
242
+ fields: { compare: field.enum(['none', 'previous', 'year'], 'none') },
243
+ }).labels({ compare: { none: 'No comparison', previous: 'Previous period', year: 'Last year' } })
244
+
245
+ test('reads the selected basis at the fallback too, and writes the chosen one', async () => {
246
+ const router = await mountPage({
247
+ validateSearch: store.validateSearch,
248
+ entry: '/dashboard',
249
+ Page: () => <CompareFilter field={store.field.compare} />,
250
+ })
251
+
252
+ // At the fallback the pill reads `No comparison`, not `Compare` — the exact regression the
253
+ // playground's bar showed, where the pill said `Compare` while the URL said `previous`.
254
+ await openPill('No comparison')
255
+ fireEvent.click(screen.getByRole('radio', { name: 'Previous period', hidden: true }))
256
+
257
+ await waitFor(() => {
258
+ expect(search(router)['compare']).toBe('previous')
259
+ })
260
+ expect(screen.getByRole('button', { name: 'Previous period' })).toBeDefined()
261
+ expect(screen.queryByRole('button', { name: 'Compare' })).toBeNull()
262
+ })
263
+
264
+ test('a consumer label wins over basalt’s default for the same value', async () => {
265
+ // This store labels `year` as `Last year`. basalt's default is `Same period last year`; a
266
+ // deliberate label is not something the framework overrides.
267
+ await mountPage({
268
+ validateSearch: store.validateSearch,
269
+ entry: '/dashboard?compare=year',
270
+ Page: () => <CompareFilter field={store.field.compare} />,
271
+ })
272
+ expect(screen.getByRole('button', { name: 'Last year' })).toBeDefined()
273
+ })
274
+
275
+ test('label= still names the POPOVER, which is the only thing it names now', async () => {
276
+ await mountPage({
277
+ validateSearch: store.validateSearch,
278
+ entry: '/dashboard',
279
+ Page: () => <CompareFilter field={store.field.compare} />,
280
+ })
281
+ await openPill('No comparison')
282
+ expect(screen.getByRole('radiogroup', { name: 'Compare', hidden: true })).toBeDefined()
283
+ })
284
+ })
285
+
286
+ describe('CompareFilter — the three option labels are basalt’s, not each app’s', () => {
287
+ // An UNLABELLED field: `field.enum` labels each option with the raw value until `store.labels()`
288
+ // is called, so this popover used to read `none` / `previous` / `year` and every consumer wrote
289
+ // the same three strings itself. `COMPARE_LABELS` is the default the control now supplies.
290
+ const store = createSearchStore({
291
+ key: 'c-compare-unlabelled',
292
+ fields: { compare: field.enum(['none', 'previous', 'year'], 'none') },
293
+ })
294
+
295
+ test('an unlabelled field still renders the three spec’d strings', async () => {
296
+ await mountPage({
297
+ validateSearch: store.validateSearch,
298
+ entry: '/dashboard',
299
+ Page: () => <CompareFilter field={store.field.compare} />,
300
+ })
301
+
302
+ expect(COMPARE_LABELS).toEqual({
303
+ none: 'No comparison',
304
+ previous: 'Previous period',
305
+ year: 'Same period last year',
306
+ })
307
+
308
+ await openPill('No comparison')
309
+ for (const value of COMPARE_VALUES) {
310
+ expect(screen.getByRole('radio', { name: COMPARE_LABELS[value], hidden: true })).toBeDefined()
311
+ }
312
+ expect(screen.queryByRole('radio', { name: 'previous', hidden: true })).toBeNull()
313
+ })
314
+ })
315
+
316
+ describe('MultiSelectFilter', () => {
317
+ const store = createSearchStore({
318
+ key: 'c-multi',
319
+ fields: { channels: field.multi(['web', 'email', 'social'], []) },
320
+ })
321
+
322
+ test('reads the label while the selection carries no information, then a count', async () => {
323
+ const router = await mountPage({
324
+ validateSearch: store.validateSearch,
325
+ entry: '/dashboard',
326
+ Page: () => (
327
+ <MultiSelectFilter field={store.field.channels} label="All channels" noun="channels" />
328
+ ),
329
+ })
330
+
331
+ expect(screen.getByRole('button', { name: 'All channels' })).toBeDefined()
332
+
333
+ await openPill('All channels')
334
+ fireEvent.click(screen.getByRole('checkbox', { name: 'web', hidden: true }))
335
+
336
+ await waitFor(() => {
337
+ expect(search(router)['channels']).toEqual(['web'])
338
+ })
339
+ expect(screen.getByRole('button', { name: '1 channels' })).toBeDefined()
340
+ })
341
+
342
+ test('every option selected is the same information as none — the label comes back', async () => {
343
+ await mountPage({
344
+ validateSearch: store.validateSearch,
345
+ entry: '/dashboard?channels=web&channels=email&channels=social',
346
+ Page: () => <MultiSelectFilter field={store.field.channels} label="All channels" />,
347
+ })
348
+ expect(screen.getByRole('button', { name: 'All channels' })).toBeDefined()
349
+ })
350
+
351
+ test("options relabels the boxes while the values stay the field's own", async () => {
352
+ const router = await mountPage({
353
+ validateSearch: store.validateSearch,
354
+ entry: '/dashboard',
355
+ Page: () => (
356
+ <MultiSelectFilter
357
+ field={store.field.channels}
358
+ label="All channels"
359
+ noun="channels"
360
+ options={[
361
+ { value: 'web', label: 'web · 1.2k' },
362
+ { value: 'email', label: 'email · 340' },
363
+ ]}
364
+ />
365
+ ),
366
+ })
367
+
368
+ await openPill('All channels')
369
+ fireEvent.click(screen.getByRole('checkbox', { name: 'web · 1.2k', hidden: true }))
370
+
371
+ await waitFor(() => {
372
+ expect(search(router)['channels']).toEqual(['web'])
373
+ })
374
+ // Two rows shown, not the field's three — so `1 channels` is counted against what is offered.
375
+ expect(screen.getByRole('button', { name: '1 channels' })).toBeDefined()
376
+ })
377
+ })
378
+
379
+ describe('ToggleFilter', () => {
380
+ const store = createSearchStore({
381
+ key: 'c-toggle',
382
+ fields: { errorsOnly: field.boolean(false) },
383
+ })
384
+
385
+ test('the pill IS the control — one press writes the field, no popover', async () => {
386
+ const router = await mountPage({
387
+ validateSearch: store.validateSearch,
388
+ entry: '/dashboard',
389
+ Page: () => <ToggleFilter field={store.field.errorsOnly} label="Errors only" />,
390
+ })
391
+
392
+ fireEvent.click(screen.getByRole('button', { name: 'Errors only' }))
393
+ await waitFor(() => {
394
+ expect(search(router)['errorsOnly']).toBe(true)
395
+ })
396
+ expect(screen.getByRole('button', { name: 'Errors only' }).hasAttribute('data-active')).toBe(
397
+ true,
398
+ )
399
+ })
400
+
401
+ test('the on/off state is announced, not only drawn as a border colour', async () => {
402
+ const router = await mountPage({
403
+ validateSearch: store.validateSearch,
404
+ entry: '/dashboard',
405
+ Page: () => <ToggleFilter field={store.field.errorsOnly} label="Errors only" />,
406
+ })
407
+
408
+ expect(screen.getByRole('button', { name: 'Errors only', pressed: false })).toBeDefined()
409
+ fireEvent.click(screen.getByRole('button', { name: 'Errors only' }))
410
+ await waitFor(() => {
411
+ expect(search(router)['errorsOnly']).toBe(true)
412
+ })
413
+ expect(screen.getByRole('button', { name: 'Errors only', pressed: true })).toBeDefined()
414
+ })
415
+ })
416
+
417
+ describe('RangeFilter', () => {
418
+ const store = createSearchStore({
419
+ key: 'c-range',
420
+ fields: {
421
+ range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
422
+ },
423
+ })
424
+
425
+ test('the pill reads the preset label and a press writes the preset param', async () => {
426
+ const router = await mountPage({
427
+ validateSearch: store.validateSearch,
428
+ entry: '/dashboard',
429
+ Page: () => <RangeFilter field={store.field.range} customPicker={Picker} />,
430
+ })
431
+
432
+ expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
433
+
434
+ await openPill('30d')
435
+ fireEvent.click(screen.getByRole('radio', { name: '7d', hidden: true }))
436
+
437
+ await waitFor(() => {
438
+ expect(search(router)['range']).toBe('7d')
439
+ })
440
+ })
441
+
442
+ test('the pill never reads `label`, at the fallback or anywhere else', async () => {
443
+ // Same law as `SelectFilter`/`CompareFilter`: `label` is the popover heading and the accessible
444
+ // name, and the pill text is the value. `30d` here IS the fallback.
445
+ await mountPage({
446
+ validateSearch: store.validateSearch,
447
+ entry: '/dashboard',
448
+ Page: () => <RangeFilter field={store.field.range} label="Window" customPicker={Picker} />,
449
+ })
450
+ expect(screen.getByRole('button', { name: '30d' })).toBeDefined()
451
+ expect(screen.queryByRole('button', { name: 'Window' })).toBeNull()
452
+ })
453
+
454
+ test('the preset track carries the filter name — a radiogroup must not be anonymous', async () => {
455
+ await mountPage({
456
+ validateSearch: store.validateSearch,
457
+ entry: '/dashboard',
458
+ Page: () => <RangeFilter field={store.field.range} label="Window" />,
459
+ })
460
+ await openPill('30d')
461
+ expect(screen.getByRole('radiogroup', { name: 'Window', hidden: true })).toBeDefined()
462
+ })
463
+
464
+ test('numeric preset labels get the mono treatment via data-numeric', async () => {
465
+ await mountPage({
466
+ validateSearch: store.validateSearch,
467
+ entry: '/dashboard',
468
+ Page: () => <RangeFilter field={store.field.range} />,
469
+ })
470
+ expect(screen.getByRole('button', { name: '30d' }).hasAttribute('data-numeric')).toBe(true)
471
+ })
472
+
473
+ test('the injected picker writes preset=custom plus both ISO dates, and the pill reads the span', async () => {
474
+ const router = await mountPage({
475
+ validateSearch: store.validateSearch,
476
+ entry: '/dashboard',
477
+ Page: () => <RangeFilter field={store.field.range} customPicker={Picker} />,
478
+ })
479
+
480
+ await openPill('30d')
481
+ fireEvent.click(screen.getByRole('button', { name: 'pick march', hidden: true }))
482
+
483
+ await waitFor(() => {
484
+ expect(search(router)['range']).toBe('custom')
485
+ })
486
+ expect(search(router)['from']).toBe('2026-03-01')
487
+ expect(search(router)['to']).toBe('2026-03-14')
488
+ expect(screen.getByRole('button', { name: /Mar 1/ }).textContent).toContain('Mar 1 – Mar 14')
489
+ })
490
+
491
+ test('no picker is injected → no custom affordance, presets only', async () => {
492
+ await mountPage({
493
+ validateSearch: store.validateSearch,
494
+ entry: '/dashboard',
495
+ Page: () => <RangeFilter field={store.field.range} />,
496
+ })
497
+ await openPill('30d')
498
+ expect(screen.queryByRole('button', { name: 'pick march', hidden: true })).toBeNull()
499
+ expect(screen.queryByRole('radio', { name: 'custom', hidden: true })).toBeNull()
500
+ })
501
+ })
502
+
503
+ describe('SearchFilter', () => {
504
+ const store = createSearchStore({
505
+ key: 'c-search',
506
+ fields: { q: field.string({ max: 40 }) },
507
+ })
508
+
509
+ test('keystrokes land on the field only after the debounce', async () => {
510
+ const router = await mountPage({
511
+ validateSearch: store.validateSearch,
512
+ entry: '/dashboard',
513
+ Page: () => <SearchFilter field={store.field.q} placeholder="Search" />,
514
+ })
515
+
516
+ fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'invo' } })
517
+ // Same tick: the box shows it, the URL does not.
518
+ expect((screen.getByPlaceholderText('Search') as HTMLInputElement).value).toBe('invo')
519
+ expect(search(router)['q']).toBe('')
520
+
521
+ await waitFor(() => {
522
+ expect(search(router)['q']).toBe('invo')
523
+ })
524
+ })
525
+
526
+ test('the box is named even with no placeholder — and stays named while typing', async () => {
527
+ await mountPage({
528
+ validateSearch: store.validateSearch,
529
+ entry: '/dashboard',
530
+ Page: () => <SearchFilter field={store.field.q} />,
531
+ })
532
+ expect(screen.getByRole('textbox', { name: 'Search' })).toBeDefined()
533
+ })
534
+ })
535
+
536
+ /**
537
+ * `ViewTabs` — the CSS swap, asserted rather than assumed. `MantineProvider` injects the
538
+ * `visible-from`/`hidden-from` media rules as a real stylesheet, and happy-dom evaluates them at its
539
+ * default (desktop) viewport — so the DESKTOP form is in the accessibility tree and the phone form is
540
+ * `display: none`. `{ hidden: true }` counts what is MOUNTED, the bare query counts what is
541
+ * REACHABLE, and the pair is what proves "one mount each, switched by CSS" (C9).
542
+ */
543
+ describe('ViewTabs', () => {
544
+ test('three options or fewer: both forms are tracks, one mounted each, only one reachable', async () => {
545
+ const store = createSearchStore({
546
+ key: 'c-tabs-3',
547
+ fields: { tab: field.enum(['a', 'b', 'c'], 'a') },
548
+ })
549
+ const router = await mountPage({
550
+ validateSearch: store.validateSearch,
551
+ entry: '/dashboard',
552
+ Page: () => <ViewTabs field={store.field.tab} />,
553
+ })
554
+
555
+ expect(screen.getAllByRole('radio', { name: 'b', hidden: true })).toHaveLength(2)
556
+ expect(screen.getAllByRole('radio', { name: 'b' })).toHaveLength(1)
557
+ expect(screen.queryByRole('combobox', { hidden: true })).toBeNull()
558
+
559
+ fireEvent.click(screen.getByRole('radio', { name: 'b' }))
560
+ await waitFor(() => {
561
+ expect(search(router)['tab']).toBe('b')
562
+ })
563
+ })
564
+
565
+ test('past three options the phone form becomes a Select, not a track', async () => {
566
+ const store = createSearchStore({
567
+ key: 'c-tabs-4',
568
+ fields: { tab: field.enum(['a', 'b', 'c', 'd'], 'a') },
569
+ })
570
+ await mountPage({
571
+ validateSearch: store.validateSearch,
572
+ entry: '/dashboard',
573
+ Page: () => <ViewTabs field={store.field.tab} />,
574
+ })
575
+
576
+ expect(screen.getAllByRole('radio', { name: 'b', hidden: true })).toHaveLength(1)
577
+ expect(screen.getByRole('combobox', { hidden: true })).toBeDefined()
578
+ })
579
+
580
+ test('both forms carry an accessible name — neither is an anonymous radiogroup/combobox', async () => {
581
+ const store = createSearchStore({
582
+ key: 'c-tabs-named',
583
+ fields: { tab: field.enum(['a', 'b', 'c', 'd'], 'a') },
584
+ })
585
+ await mountPage({
586
+ validateSearch: store.validateSearch,
587
+ entry: '/dashboard',
588
+ Page: () => <ViewTabs field={store.field.tab} label="Section" />,
589
+ })
590
+
591
+ expect(screen.getByRole('radiogroup', { name: 'Section', hidden: true })).toBeDefined()
592
+ expect(screen.getByRole('combobox', { name: 'Section', hidden: true })).toBeDefined()
593
+ })
594
+
595
+ test("the name defaults to 'View' rather than being absent", async () => {
596
+ const store = createSearchStore({
597
+ key: 'c-tabs-default-name',
598
+ fields: { tab: field.enum(['a', 'b'], 'a') },
599
+ })
600
+ await mountPage({
601
+ validateSearch: store.validateSearch,
602
+ entry: '/dashboard',
603
+ Page: () => <ViewTabs field={store.field.tab} />,
604
+ })
605
+ // Read off the attribute rather than through the accessible-name query: the phone twin is
606
+ // `display: none` on this viewport, and `dom-accessibility-api` declines to compute a name for a
607
+ // hidden `radiogroup` — which would make the assertion pass for the wrong reason.
608
+ const groups = screen.getAllByRole('radiogroup', { hidden: true })
609
+ expect(groups).toHaveLength(2)
610
+ expect(groups.map((group) => group.getAttribute('aria-label'))).toEqual(['View', 'View'])
611
+ })
612
+
613
+ test("only: 'sm-down' keeps an option off the desktop form entirely", async () => {
614
+ const store = createSearchStore({
615
+ key: 'c-tabs-only',
616
+ fields: { tab: field.enum(['overview', 'train'], 'overview') },
617
+ })
618
+ await mountPage({
619
+ validateSearch: store.validateSearch,
620
+ entry: '/dashboard',
621
+ Page: () => (
622
+ <ViewTabs
623
+ field={store.field.tab}
624
+ options={[
625
+ { value: 'overview', label: 'Overview' },
626
+ { value: 'train', label: 'Train', only: 'sm-down' },
627
+ ]}
628
+ />
629
+ ),
630
+ })
631
+
632
+ expect(screen.getAllByRole('radio', { name: 'Overview', hidden: true })).toHaveLength(2)
633
+ expect(screen.getAllByRole('radio', { name: 'Train', hidden: true })).toHaveLength(1)
634
+ // And the one that exists is the PHONE one — unreachable on this viewport.
635
+ expect(screen.queryAllByRole('radio', { name: 'Train' })).toHaveLength(0)
636
+ })
637
+ })