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,183 @@
1
+ /**
2
+ * `SyncButton` — the age formatting and the syncing state (law C12, `docs/CONTROLS-SPEC.md` §3).
3
+ *
4
+ * The age is the part that had four hand-rolled copies across consumers, each with its own
5
+ * rounding; `formatAge` is asserted directly so the boundaries are pinned, not inferred from one
6
+ * rendered string.
7
+ */
8
+ import { MantineProvider } from '@mantine/core'
9
+ import { fireEvent, render, screen } from '@testing-library/react'
10
+ import { renderToStaticMarkup } from 'react-dom/server'
11
+ import { describe, expect, test } from 'bun:test'
12
+ import { AGE_REFRESH_MS, SyncButton, formatAge } from './sync-button'
13
+
14
+ function renderSync(props: Partial<Parameters<typeof SyncButton>[0]> = {}) {
15
+ return render(
16
+ <MantineProvider>
17
+ <SyncButton syncing={false} scope="page" onSync={() => {}} {...props} />
18
+ </MantineProvider>,
19
+ )
20
+ }
21
+
22
+ describe('formatAge', () => {
23
+ test('anything under a minute is `just now`, so a fresh sync stops flickering', () => {
24
+ expect(formatAge(0)).toBe('just now')
25
+ expect(formatAge(59_000)).toBe('just now')
26
+ })
27
+
28
+ test('minutes, then hours, then days', () => {
29
+ expect(formatAge(60_000)).toBe('1m ago')
30
+ expect(formatAge(2 * 60_000)).toBe('2m ago')
31
+ expect(formatAge(59 * 60_000)).toBe('59m ago')
32
+ expect(formatAge(60 * 60_000)).toBe('1h ago')
33
+ expect(formatAge(23 * 60 * 60_000)).toBe('23h ago')
34
+ expect(formatAge(24 * 60 * 60_000)).toBe('1d ago')
35
+ })
36
+
37
+ test('the refresh interval is coarser than the smallest unit it renders', () => {
38
+ expect(AGE_REFRESH_MS).toBeLessThanOrEqual(60_000)
39
+ })
40
+ })
41
+
42
+ describe('SyncButton', () => {
43
+ test('renders the age beside the label on desktop', () => {
44
+ renderSync({ lastCompletedAt: Date.now() - 2 * 60_000 })
45
+ expect(screen.getByRole('button').textContent).toContain('Sync')
46
+ expect(screen.getByRole('button').textContent).toContain('2m ago')
47
+ })
48
+
49
+ test('a Date and an epoch number are the same input', () => {
50
+ renderSync({ lastCompletedAt: new Date(Date.now() - 3 * 60_000) })
51
+ expect(screen.getByRole('button').textContent).toContain('3m ago')
52
+ })
53
+
54
+ test('no age at all when nothing has completed yet', () => {
55
+ renderSync({ lastCompletedAt: null })
56
+ expect(screen.getByRole('button').textContent).toBe('Sync')
57
+ })
58
+
59
+ test('syncing announces itself and refuses a second click — WITHOUT losing focus', () => {
60
+ let presses = 0
61
+ renderSync({
62
+ syncing: true,
63
+ onSync: () => {
64
+ presses += 1
65
+ },
66
+ })
67
+ const button = screen.getByRole('button')
68
+
69
+ expect(button.getAttribute('aria-busy')).toBe('true')
70
+ expect(button.getAttribute('aria-disabled')).toBe('true')
71
+ // The NATIVE attribute is what drops focus to <body> mid-press, so it must stay off: a keyboard
72
+ // user who presses Sync has to still be standing on the button when the sync lands.
73
+ expect(button.hasAttribute('disabled')).toBe(false)
74
+ expect(button.hasAttribute('data-disabled')).toBe(true)
75
+
76
+ button.focus()
77
+ fireEvent.click(button)
78
+ expect(presses).toBe(0)
79
+ expect(document.activeElement).toBe(button)
80
+ })
81
+
82
+ test('an idle press does call onSync — the refusal above is not just a dead handler', () => {
83
+ let presses = 0
84
+ renderSync({
85
+ onSync: () => {
86
+ presses += 1
87
+ },
88
+ })
89
+ fireEvent.click(screen.getByRole('button'))
90
+ expect(presses).toBe(1)
91
+ })
92
+
93
+ test('the age is absent on the server, so hydration cannot mismatch it', () => {
94
+ // `Date.now()` during render is the whole bug: the server renders one age and a client hydrating
95
+ // a minute later renders another. Both must render NO age; the effect fills it in after mount.
96
+ const html = renderToStaticMarkup(
97
+ <MantineProvider>
98
+ <SyncButton
99
+ syncing={false}
100
+ scope="page"
101
+ onSync={() => {}}
102
+ lastCompletedAt={Date.now() - 5 * 60_000}
103
+ />
104
+ </MantineProvider>,
105
+ )
106
+ expect(html).not.toContain('5m ago')
107
+ expect(html).toContain('Sync')
108
+ })
109
+
110
+ test('an error puts the control in the danger tone', () => {
111
+ renderSync({ error: 'Upstream 503' })
112
+ expect(screen.getByRole('button').getAttribute('data-variant')).toBe('default')
113
+ expect(document.querySelector('[data-basalt-sync-scope="page"]')).not.toBeNull()
114
+ })
115
+
116
+ test('the error message is in the accessible name, not only in a hover tooltip', () => {
117
+ // A tooltip announces only while open, and Mantine's opens on hover ALONE by default — so
118
+ // without this the red colour is the entire signal for a keyboard or screen-reader user.
119
+ renderSync({ error: 'Rate limited' })
120
+ expect(screen.getByRole('button', { name: 'Sync — Rate limited' })).toBeDefined()
121
+ })
122
+
123
+ test('scope reaches the DOM so a call site is readable without reading the props', () => {
124
+ renderSync({ scope: 'global' })
125
+ expect(document.querySelector('[data-basalt-sync-scope="global"]')).not.toBeNull()
126
+ })
127
+ })
128
+
129
+ /**
130
+ * The shape per scope. `scope` used to be documentation only; it now picks the form, because the
131
+ * shell header has no room for a labelled button beside the breadcrumb, `PageBar` row 1 and every
132
+ * other global action — and a page bar does (spec §3, law C12).
133
+ */
134
+ describe('the shape follows the scope', () => {
135
+ test('global renders an ActionIcon with no label text at any width', () => {
136
+ renderSync({ scope: 'global', label: 'Sync all' })
137
+ const button = screen.getByRole('button')
138
+ // Mantine's own class names are static strings (unlike a CSS-module hash), so which PRIMITIVE
139
+ // rendered is assertable — and it is the whole difference between the two forms.
140
+ expect(button.classList.contains('mantine-ActionIcon-root')).toBe(true)
141
+ // Icon-only on EVERY viewport, so there is no text to hide with a media query in the first
142
+ // place: the label reaches the user as the accessible name only.
143
+ expect(button.textContent).toBe('')
144
+ expect(screen.getByRole('button', { name: 'Sync all' })).toBeDefined()
145
+ })
146
+
147
+ test('a global button carries no rendered age either — the tooltip is its only home', () => {
148
+ renderSync({ scope: 'global', lastCompletedAt: Date.now() - 4 * 60_000 })
149
+ expect(screen.getByRole('button').textContent).toBe('')
150
+ })
151
+
152
+ test('page renders the labelled Button, and is named even once the label is hidden below sm', () => {
153
+ renderSync({ scope: 'page' })
154
+ const button = screen.getByRole('button')
155
+ expect(button.classList.contains('mantine-Button-root')).toBe(true)
156
+ expect(button.textContent).toBe('Sync')
157
+ // jsdom evaluates no media query, so the `display: none` below `sm` is not observable here —
158
+ // what IS observable is the half that makes it safe: `display: none` text leaves the
159
+ // accessibility tree, so the name has to come from `aria-label`, not from the children.
160
+ expect(button.getAttribute('aria-label')).toBe('Sync')
161
+ })
162
+
163
+ test('both scopes fold an error into the accessible name, not only into the tone', () => {
164
+ renderSync({ scope: 'global', error: 'Upstream 503' })
165
+ expect(screen.getByRole('button', { name: 'Sync — Upstream 503' })).toBeDefined()
166
+ })
167
+
168
+ test('a global button refuses a second click while syncing, exactly as the page form does', () => {
169
+ let presses = 0
170
+ renderSync({
171
+ scope: 'global',
172
+ syncing: true,
173
+ onSync: () => {
174
+ presses += 1
175
+ },
176
+ })
177
+ const button = screen.getByRole('button')
178
+ expect(button.getAttribute('aria-disabled')).toBe('true')
179
+ expect(button.hasAttribute('disabled')).toBe(false)
180
+ fireEvent.click(button)
181
+ expect(presses).toBe(0)
182
+ })
183
+ })
@@ -0,0 +1,197 @@
1
+ /**
2
+ * `SyncButton` — the ONE shape refresh/sync takes anywhere in a basalt app (law C12). It replaced
3
+ * a per-consumer zoo: a `RefreshButton`, a `sync-control`, a reading `SyncButton` and a bare
4
+ * `ActionIcon` in one app alone, each with its own age formatting and its own spinner.
5
+ *
6
+ * `scope` names the home, and the home decides the SHAPE — one component, two forms, so the age
7
+ * formatting, the spinner, the error tone and the accessible name can never drift apart between
8
+ * them (and the home stays queryable in the DOM via `data-basalt-sync-scope`):
9
+ *
10
+ * - `scope: 'global'` → the shell's `globalActions`. Icon-only on EVERY viewport, an `ActionIcon`
11
+ * carrying the spinning glyph. The shell header is 48px of width shared with the breadcrumb,
12
+ * `PageBar` row 1 and every other global action, so a labelled button there is the one that
13
+ * pushes a page's own actions into the kebab. The age and the error live in the tooltip; the
14
+ * accessible name is `label`.
15
+ * - `scope: 'page'` → `PageBar.sync` (the page's own data). The labelled `Button` with the age
16
+ * inline beside it on desktop, icon-only below `sm` where that width is gone — CSS only, one
17
+ * mount, never a `visibleFrom` twin (law C9).
18
+ *
19
+ * Because the label is hidden rather than unmounted below `sm`, the accessible name comes from
20
+ * `aria-label` in BOTH forms: `display: none` text is out of the accessibility tree, so a page
21
+ * button relying on its visible children would be an unnamed icon on a phone.
22
+ */
23
+ import { ActionIcon, Button, Tooltip } from '@mantine/core'
24
+ import { useEffect, useState } from 'react'
25
+ import type { ReactNode } from 'react'
26
+ import { IconSlot } from '../theme/icon-slot'
27
+ import classes from './sync-button.module.css'
28
+
29
+ export type SyncButtonProps = {
30
+ syncing: boolean
31
+ /** Last successful completion. `undefined`/`null` renders no age at all. */
32
+ lastCompletedAt?: number | Date | null
33
+ onSync: () => void
34
+ /** The home, and with it the shape: `'global'` is icon-only at every width, `'page'` is labelled
35
+ * on desktop and icon-only below `sm` — see this module's doc. */
36
+ scope: 'page' | 'global'
37
+ /** Visible label at `scope: 'page'` on desktop, and the accessible name in every other form.
38
+ * @default 'Sync' */
39
+ label?: string
40
+ /**
41
+ * Last failure. Puts the control in the danger tone, carries the message in the tooltip (which
42
+ * opens on hover, focus AND touch) and folds it into the button's accessible name, so the red is
43
+ * never the only signal.
44
+ */
45
+ error?: string
46
+ }
47
+
48
+ /** How often the relative age re-renders. One interval, only while mounted. */
49
+ export const AGE_REFRESH_MS = 30_000
50
+
51
+ /**
52
+ * Relative age, coarse on purpose: a sync indicator answers "is this stale?", not "how stale to
53
+ * the second". Anything under a minute is `just now`, so the label stops flickering while a sync
54
+ * has just landed.
55
+ */
56
+ export function formatAge(ms: number): string {
57
+ if (ms < 60_000) return 'just now'
58
+ const minutes = Math.floor(ms / 60_000)
59
+ if (minutes < 60) return `${minutes}m ago`
60
+ const hours = Math.floor(minutes / 60)
61
+ if (hours < 24) return `${hours}h ago`
62
+ return `${Math.floor(hours / 24)}d ago`
63
+ }
64
+
65
+ function toMillis(at: number | Date | null | undefined): number | undefined {
66
+ if (at === null || at === undefined) return undefined
67
+ return at instanceof Date ? at.getTime() : at
68
+ }
69
+
70
+ /**
71
+ * The age string, refreshed on an interval that exists ONLY while the component is mounted and
72
+ * only while there is an age to show — a sync control on a background route costs no timer.
73
+ *
74
+ * The clock is read in the EFFECT, never during render. A global `SyncButton` in `globalActions`
75
+ * renders inline in the shell header (no portal), so under SSR the server would emit `just now` and
76
+ * a client hydrating a minute later would emit `1m ago` — a React text mismatch that discards the
77
+ * subtree. Server and first client paint both omit the age; the effect fills it in after mount.
78
+ * That also makes the interval the only thing that re-renders, so the old `setTick` counter is gone.
79
+ */
80
+ function useRelativeAge(at: number | Date | null | undefined): string | undefined {
81
+ const [age, setAge] = useState<string | undefined>(undefined)
82
+ const timestamp = toMillis(at)
83
+
84
+ useEffect(() => {
85
+ if (timestamp === undefined) {
86
+ setAge(undefined)
87
+ return
88
+ }
89
+ const publish = (): void => {
90
+ setAge(formatAge(Date.now() - timestamp))
91
+ }
92
+ publish()
93
+ const id = setInterval(publish, AGE_REFRESH_MS)
94
+ return () => clearInterval(id)
95
+ }, [timestamp])
96
+
97
+ return age
98
+ }
99
+
100
+ function RefreshGlyph({ spinning }: { spinning: boolean }) {
101
+ return (
102
+ <svg
103
+ width={16}
104
+ height={16}
105
+ viewBox="0 0 24 24"
106
+ fill="none"
107
+ aria-hidden
108
+ className={spinning ? classes.spin : undefined}
109
+ >
110
+ <path
111
+ d="M20 11a8 8 0 1 0-2.34 5.66M20 5v6h-6"
112
+ stroke="currentColor"
113
+ strokeWidth={2}
114
+ strokeLinecap="round"
115
+ strokeLinejoin="round"
116
+ />
117
+ </svg>
118
+ )
119
+ }
120
+
121
+ /**
122
+ * @example
123
+ * // page scope — the page's own data
124
+ * <PageBar sync={{ syncing, lastCompletedAt, onSync: refetch }} />
125
+ *
126
+ * // global scope — the shell header, icon-only at every width
127
+ * <BasaltShell globalActions={[{ key: 'sync', mobile: 'bar',
128
+ * node: <SyncButton scope="global" syncing={s} lastCompletedAt={t} onSync={run} /> }]} />
129
+ */
130
+ export function SyncButton({
131
+ syncing,
132
+ lastCompletedAt,
133
+ onSync,
134
+ scope,
135
+ label = 'Sync',
136
+ error,
137
+ }: SyncButtonProps): ReactNode {
138
+ const age = useRelativeAge(lastCompletedAt)
139
+ // Wherever the label is not painted — every `global` mount, and a `page` mount below `sm` — the
140
+ // tooltip is where the age lives; an `error` outranks it everywhere. ONE tooltip, one mount.
141
+ const tooltip = error ?? age
142
+ // The accessible name in both forms. The visible label is `display: none` below `sm`, which takes
143
+ // it out of the accessibility tree, and an error must be readable without opening a tooltip.
144
+ const name = error !== undefined ? `${label} — ${error}` : label
145
+
146
+ const control = {
147
+ 'data-basalt-sync-scope': scope,
148
+ 'aria-busy': syncing,
149
+ // NOT the native `disabled`. That attribute drops focus to <body> the moment a keyboard user
150
+ // presses the button, so when the sync lands focus is gone and the next Tab restarts from
151
+ // the top of the document — and an `aria-busy` element you cannot focus announces nothing.
152
+ // `aria-disabled` + Mantine's `data-disabled` styling say the same thing and keep the button
153
+ // where the user left it; the handler does the actual refusing.
154
+ 'aria-disabled': syncing,
155
+ 'aria-label': name,
156
+ ...(syncing && { 'data-disabled': true }),
157
+ ...(error !== undefined && { color: 'red' }),
158
+ onClick: () => {
159
+ if (!syncing) onSync()
160
+ },
161
+ }
162
+
163
+ return (
164
+ // `events` is not a default: Mantine's Tooltip opens on HOVER only, so on the stock settings a
165
+ // keyboard user and a phone user never reach the error — and the age's "the tooltip carries it
166
+ // below `sm`" claim in the CSS was false on touch. `aria-label` is the belt to that braces: a
167
+ // tooltip announces only while open, and an error has to be readable without one.
168
+ <Tooltip
169
+ label={tooltip ?? ''}
170
+ disabled={tooltip === undefined}
171
+ events={{ hover: true, focus: true, touch: true }}
172
+ withArrow
173
+ >
174
+ {scope === 'global' ? (
175
+ <ActionIcon variant="default" {...control}>
176
+ <IconSlot>
177
+ <RefreshGlyph spinning={syncing} />
178
+ </IconSlot>
179
+ </ActionIcon>
180
+ ) : (
181
+ <Button
182
+ variant="default"
183
+ className={classes.pageButton}
184
+ leftSection={
185
+ <IconSlot>
186
+ <RefreshGlyph spinning={syncing} />
187
+ </IconSlot>
188
+ }
189
+ {...control}
190
+ >
191
+ <span className={classes.label}>{label}</span>
192
+ {age !== undefined && <span className={classes.age}>{age}</span>}
193
+ </Button>
194
+ )}
195
+ </Tooltip>
196
+ )
197
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `ToggleFilter` — a boolean field as one pill (`docs/CONTROLS-SPEC.md` §3). The only filter whose
3
+ * pill has no popover: there is nothing to choose, so the pill IS the control and a press flips it.
4
+ * In the sheet it becomes a `Switch` row, where a 44px row with an explicit on/off affordance reads
5
+ * better than a chip whose state is a border colour.
6
+ *
7
+ * @example
8
+ * // errorsOnly: field.boolean(false)
9
+ * <ToggleFilter field={filters.field.errorsOnly} label="Errors only" />
10
+ */
11
+ import { Switch } from '@mantine/core'
12
+ import type { ReactNode } from 'react'
13
+ import type { BooleanField, FieldHandle } from '../state'
14
+ import { useFilterRegistration, useFilterSurface } from './filter-context'
15
+ import { FilterPill } from './filter-pill'
16
+ import { sheetRowClassNames } from './filter-sheet'
17
+
18
+ export type ToggleFilterProps = {
19
+ readonly field: FieldHandle<BooleanField>
20
+ readonly label: string
21
+ readonly icon?: ReactNode
22
+ }
23
+
24
+ export function ToggleFilter({ field, label, icon }: ToggleFilterProps): ReactNode {
25
+ const [value, setValue] = field.use()
26
+ const surface = useFilterSurface()
27
+ useFilterRegistration(!field.isDefault(value), () => {
28
+ setValue(field.fallback)
29
+ })
30
+
31
+ if (surface === 'sheet') {
32
+ // The `Switch` IS the row — no wrapping div. `sheetRowClassNames` stretches its own `<label>`
33
+ // across the full 44px, so the whole row is the touch target rather than the middle 20px of it
34
+ // (C15). A wrapper div would put the height on a non-interactive box instead.
35
+ return (
36
+ <Switch
37
+ classNames={sheetRowClassNames}
38
+ checked={value}
39
+ label={label}
40
+ labelPosition="left"
41
+ onChange={(event) => {
42
+ setValue(event.currentTarget.checked)
43
+ }}
44
+ />
45
+ )
46
+ }
47
+
48
+ return (
49
+ <FilterPill
50
+ label={label}
51
+ active={value}
52
+ pressed={value}
53
+ hideGlyph
54
+ {...(icon !== undefined && { icon })}
55
+ onClick={() => {
56
+ setValue(!value)
57
+ }}
58
+ />
59
+ )
60
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * `ViewTabs` — the page's or section's view switch (`docs/CONTROLS-SPEC.md` §3). A tab is store
3
+ * state like any filter (C3): it takes `field`, never `value`/`onChange`, so a deep link and a back
4
+ * navigation land on the same tab the user left.
5
+ *
6
+ * The responsive swap lives HERE, not at the call site (C9), and it is CSS — `visibleFrom` /
7
+ * `hiddenFrom`, one mount each, never a JS media query (which renders differently on the server than
8
+ * on the first client paint). Below `sm` a set of three or fewer stays a full-width
9
+ * `SegmentedControl`; past that a track cannot hold readable labels on a phone, so it becomes a
10
+ * `Select`.
11
+ *
12
+ * `only` is how a tab that exists on ONE viewport is declared instead of hand-rolling a second
13
+ * control: `'sm-up'` keeps it off the phone form, `'sm-down'` keeps it off the desktop form.
14
+ *
15
+ * @example
16
+ * <ViewTabs
17
+ * field={strength.field.tab}
18
+ * options={[
19
+ * { value: 'overview', label: 'Overview' },
20
+ * { value: 'history', label: 'History' },
21
+ * { value: 'train', label: 'Train', only: 'sm-down' },
22
+ * ]}
23
+ * />
24
+ */
25
+ import { SegmentedControl, Select } from '@mantine/core'
26
+ import type { ReactNode } from 'react'
27
+ import type { EnumField, FieldHandle } from '../state'
28
+ import classes from './controls.module.css'
29
+
30
+ /** Past three options the phone form is a `Select`, not a track. */
31
+ const PHONE_TRACK_MAX = 3
32
+
33
+ export type ViewTabsOption<T extends string> = {
34
+ readonly value: T
35
+ readonly label: string
36
+ /** Render this option on ONE viewport only. Omitted → both. */
37
+ readonly only?: 'sm-up' | 'sm-down'
38
+ }
39
+
40
+ export type ViewTabsProps<T extends string> = {
41
+ readonly field: FieldHandle<EnumField<T>>
42
+ /** Defaults to `field.options` (every declared value, labelled by `store.labels()`). */
43
+ readonly options?: readonly ViewTabsOption<T>[]
44
+ /**
45
+ * The switch's accessible name. Both forms render without a visible Mantine `label` (a home has
46
+ * no room for one), so without this the desktop track is an unnamed `radiogroup` and the phone
47
+ * form an unnamed `combobox`.
48
+ *
49
+ * @default 'View'
50
+ */
51
+ readonly label?: string
52
+ }
53
+
54
+ export function ViewTabs<T extends string>({
55
+ field,
56
+ options,
57
+ label = 'View',
58
+ }: ViewTabsProps<T>): ReactNode {
59
+ const [value, setValue] = field.use()
60
+ const all: readonly ViewTabsOption<T>[] =
61
+ options ?? field.options.map((option) => ({ value: option.value as T, label: option.label }))
62
+
63
+ const desktop = all.filter((option) => option.only !== 'sm-down')
64
+ const phone = all.filter((option) => option.only !== 'sm-up')
65
+ // The segments/items are rendered from the option list, so `next` is always a declared value —
66
+ // the cast restores what the codec already guarantees.
67
+ const commit = (next: string): void => {
68
+ setValue(next as T)
69
+ }
70
+
71
+ return (
72
+ <>
73
+ <SegmentedControl
74
+ aria-label={label}
75
+ visibleFrom="sm"
76
+ size="ctl"
77
+ value={value}
78
+ data={desktop.map((option) => ({ value: option.value, label: option.label }))}
79
+ onChange={commit}
80
+ />
81
+ {phone.length <= PHONE_TRACK_MAX ? (
82
+ <SegmentedControl
83
+ aria-label={label}
84
+ hiddenFrom="sm"
85
+ size="ctl"
86
+ fullWidth
87
+ value={value}
88
+ data={phone.map((option) => ({ value: option.value, label: option.label }))}
89
+ onChange={commit}
90
+ />
91
+ ) : (
92
+ <Select
93
+ aria-label={label}
94
+ hiddenFrom="sm"
95
+ size="ctl"
96
+ // BOUNDED, not greedy — see `.phoneSelect`'s comment in `controls.module.css`. This is the
97
+ // control owning its own responsive form (law C9), which is also why it is not a `w` prop
98
+ // at the call site (C5, `basalt/control-size-literal`).
99
+ className={classes.phoneSelect}
100
+ value={value}
101
+ allowDeselect={false}
102
+ data={phone.map((option) => ({ value: option.value, label: option.label }))}
103
+ onChange={(next) => {
104
+ if (next !== null) commit(next)
105
+ }}
106
+ />
107
+ )}
108
+ </>
109
+ )
110
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * `DateRangePicker` — the `@mantine/dates` implementation of `RangeFilter`'s custom-window picker
3
+ * (`docs/CONTROLS-SPEC.md` §3). Its OWN subpath, and the reason is packaging, not taste:
4
+ * `@mantine/dates` is an optional peer, `basaltViteConfig` pre-bundles `@mantine/*`, and a consumer
5
+ * without the peer (linewatch) would break on an absent import — so nothing under `src/controls/**`
6
+ * may reference it, statically or lazily. `RangeFilter` takes the picker as a `customPicker` prop
7
+ * instead, which is the whole seam.
8
+ *
9
+ * `DatePicker type="range"` deals in ISO `YYYY-MM-DD` strings in Mantine 9 (`DateStringValue`), which
10
+ * is exactly what `field.range` stores — so there is no date parsing, formatting or timezone
11
+ * handling in this adapter, and that is deliberate.
12
+ *
13
+ * No `size` prop: the calendar is a POPOVER BODY, not a control in a home, so it carries none of the
14
+ * `ctl` tier's var set (`docs/CONTROLS-SPEC.md` §5 declares `-ctl` vars for Button/ActionIcon/Input/
15
+ * SegmentedControl/Combobox — not for the calendar), and pinning one would be a size with no
16
+ * declaration behind it.
17
+ *
18
+ * The consumer imports `@mantine/dates/styles.layer.css` alongside the other Mantine layer bundles
19
+ * (before `basalt-ui/styles.css`); this module ships no CSS of its own.
20
+ *
21
+ * @example
22
+ * import { DateRangePicker } from 'basalt-ui/controls-dates'
23
+ * <RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
24
+ */
25
+ import { DatePicker } from '@mantine/dates'
26
+ import type { ReactNode } from 'react'
27
+ import type { RangeCustomPickerProps } from '../controls/range-filter'
28
+
29
+ export function DateRangePicker({ value, onChange }: RangeCustomPickerProps): ReactNode {
30
+ return (
31
+ <DatePicker
32
+ type="range"
33
+ allowSingleDateInRange
34
+ value={[value.from ?? null, value.to ?? null]}
35
+ onChange={([from, to]) => {
36
+ // A range is only a value once BOTH ends are picked — the first click of a new range hands
37
+ // back `[from, null]`, and committing that would write a half range onto the URL.
38
+ if (from !== null && to !== null) onChange({ from, to })
39
+ }}
40
+ />
41
+ )
42
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * `basalt-ui/controls-dates` — the one export that needs `@mantine/dates`
3
+ * (`docs/CONTROLS-SPEC.md` §3). Split off `./controls` on purpose: `./controls` resolves and renders
4
+ * with no `@mantine/dates` installed, and this subpath is the only place that stops being true.
5
+ * Proven from outside the package by `scripts/pack-test.sh`.
6
+ */
7
+ export { DateRangePicker } from './date-range-picker'
8
+ export type { RangeCustomPickerProps } from '../controls/range-filter'