basalt-ui 1.30.2 → 1.31.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 (334) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +31 -0
  3. package/MIGRATING.md +172 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/configs/oxlint-plugin.js +254 -4
  7. package/configs/oxlint-plugin.test.ts +145 -2
  8. package/configs/oxlint.json +2 -1
  9. package/dist/agent-chat/composer.d.ts.map +1 -1
  10. package/dist/agent-chat/composer.js +41 -43
  11. package/dist/agent-chat/thread-workspace.d.ts +2 -2
  12. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  13. package/dist/agent-chat/thread-workspace.js +3 -4
  14. package/dist/agent-chat/use-keyboard-inset.d.ts +2 -0
  15. package/dist/agent-chat/use-keyboard-inset.d.ts.map +1 -0
  16. package/dist/agent-chat/use-keyboard-inset.js +34 -0
  17. package/dist/charts/cursor/touch-pin.d.ts +45 -0
  18. package/dist/charts/cursor/touch-pin.d.ts.map +1 -0
  19. package/dist/charts/cursor/touch-pin.js +79 -0
  20. package/dist/charts/hooks/use-discrete-cursor.d.ts +66 -0
  21. package/dist/charts/hooks/use-discrete-cursor.d.ts.map +1 -0
  22. package/dist/charts/hooks/use-discrete-cursor.js +198 -0
  23. package/dist/charts/hooks/use-hysteresis.d.ts +18 -0
  24. package/dist/charts/hooks/use-hysteresis.d.ts.map +1 -0
  25. package/dist/charts/hooks/use-hysteresis.js +12 -0
  26. package/dist/charts/hooks/useBandPlot.d.ts +7 -0
  27. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  28. package/dist/charts/hooks/useBandPlot.js +62 -18
  29. package/dist/charts/hooks/useChartCursor.d.ts +40 -11
  30. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  31. package/dist/charts/hooks/useChartCursor.js +102 -11
  32. package/dist/charts/index.d.ts +0 -1
  33. package/dist/charts/index.d.ts.map +1 -1
  34. package/dist/charts/index.js +0 -10
  35. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  36. package/dist/charts/kinds/BandStrip.js +54 -41
  37. package/dist/charts/kinds/Donut.d.ts +9 -3
  38. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  39. package/dist/charts/kinds/Donut.js +110 -48
  40. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  41. package/dist/charts/kinds/DualPanel.js +145 -131
  42. package/dist/charts/kinds/Heatmap.d.ts +7 -0
  43. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  44. package/dist/charts/kinds/Heatmap.js +37 -16
  45. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  46. package/dist/charts/kinds/MirroredBars.js +83 -68
  47. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  48. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  49. package/dist/charts/kinds/donut-layout.js +52 -0
  50. package/dist/charts/layout/auto-margin.d.ts +7 -2
  51. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  52. package/dist/charts/layout/auto-margin.js +12 -3
  53. package/dist/charts/layout/end-labels.d.ts +64 -0
  54. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  55. package/dist/charts/layout/end-labels.js +76 -0
  56. package/dist/charts/primitives/Axes.d.ts +32 -4
  57. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  58. package/dist/charts/primitives/Axes.js +62 -16
  59. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  60. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  61. package/dist/charts/primitives/CartesianChart.js +271 -125
  62. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  63. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  64. package/dist/charts/primitives/ChartCard.js +74 -21
  65. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  66. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartFrame.js +133 -34
  68. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  69. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartLegend.js +212 -71
  71. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  72. package/dist/charts/primitives/ChartTooltip.js +4 -3
  73. package/dist/charts/primitives/Crosshair.js +2 -2
  74. package/dist/charts/primitives/HoverOverlay.d.ts +6 -1
  75. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  76. package/dist/charts/primitives/HoverOverlay.js +37 -26
  77. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  78. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  79. package/dist/charts/primitives/chart-card-context.d.ts +29 -0
  80. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  81. package/dist/charts/primitives/chart-card-context.js +13 -0
  82. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  83. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  84. package/dist/charts/primitives/chart-frame-layout.js +76 -29
  85. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  86. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  87. package/dist/charts/primitives/chart-layout.js +175 -0
  88. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  89. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  90. package/dist/charts/primitives/chart-tier.js +42 -13
  91. package/dist/charts/series.d.ts +4 -1
  92. package/dist/charts/series.d.ts.map +1 -1
  93. package/dist/charts/utils/ticks.d.ts +27 -8
  94. package/dist/charts/utils/ticks.d.ts.map +1 -1
  95. package/dist/charts/utils/ticks.js +35 -11
  96. package/dist/common/errors.d.ts +11 -1
  97. package/dist/common/errors.d.ts.map +1 -1
  98. package/dist/common/errors.js +16 -2
  99. package/dist/common/hit-floor.d.ts +10 -0
  100. package/dist/common/hit-floor.d.ts.map +1 -0
  101. package/dist/common/hit-floor.js +6 -0
  102. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  103. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  104. package/dist/common/use-measured-widths.d.ts +37 -0
  105. package/dist/common/use-measured-widths.d.ts.map +1 -0
  106. package/dist/common/use-measured-widths.js +51 -0
  107. package/dist/common/use-media-query.d.ts +6 -0
  108. package/dist/common/use-media-query.d.ts.map +1 -0
  109. package/dist/common/use-media-query.js +40 -0
  110. package/dist/common/validate.d.ts +9 -0
  111. package/dist/common/validate.d.ts.map +1 -1
  112. package/dist/common/validate.js +9 -4
  113. package/dist/content/article-layout.d.ts +4 -2
  114. package/dist/content/article-layout.d.ts.map +1 -1
  115. package/dist/content/article-layout.js +43 -3
  116. package/dist/content/article-layout.module.css +45 -4
  117. package/dist/content/article-layout.module.css.d.ts +3 -0
  118. package/dist/content/code-block.d.ts.map +1 -1
  119. package/dist/content/code-block.js +40 -2
  120. package/dist/content/code-block.module.css +32 -2
  121. package/dist/content/prose.module.css +13 -8
  122. package/dist/controls/actions.d.ts +29 -6
  123. package/dist/controls/actions.d.ts.map +1 -1
  124. package/dist/controls/actions.js +158 -27
  125. package/dist/controls/actions.module.css +14 -1
  126. package/dist/controls/actions.module.css.d.ts +1 -0
  127. package/dist/controls/controls.module.css +8 -31
  128. package/dist/controls/enum-filter.js +2 -2
  129. package/dist/controls/filter-context.d.ts +10 -0
  130. package/dist/controls/filter-context.d.ts.map +1 -1
  131. package/dist/controls/filter-context.js +2 -0
  132. package/dist/controls/filter-pill.d.ts.map +1 -1
  133. package/dist/controls/filter-pill.js +1 -0
  134. package/dist/controls/index.d.ts +1 -1
  135. package/dist/controls/index.d.ts.map +1 -1
  136. package/dist/controls/index.js +0 -2
  137. package/dist/controls/multi-select-filter.js +2 -2
  138. package/dist/controls/sync-button.module.css +1 -1
  139. package/dist/dashboard/section.d.ts.map +1 -1
  140. package/dist/dashboard/section.js +1 -0
  141. package/dist/dashboard/section.module.css +1 -26
  142. package/dist/dashboard/stat-card.d.ts +1 -1
  143. package/dist/dashboard/stat-card.d.ts.map +1 -1
  144. package/dist/dashboard/stat-card.js +4 -1
  145. package/dist/dashboard/stat-card.module.css +6 -9
  146. package/dist/dashboard/stat-group.module.css +16 -4
  147. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  148. package/dist/dashboard/widget-header.d.ts +14 -4
  149. package/dist/dashboard/widget-header.d.ts.map +1 -1
  150. package/dist/dashboard/widget-header.js +23 -6
  151. package/dist/dashboard/widget-header.module.css +145 -133
  152. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  153. package/dist/data/data-table.d.ts +23 -0
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +339 -141
  156. package/dist/data/data-table.module.css +94 -0
  157. package/dist/data/data-table.module.css.d.ts +11 -0
  158. package/dist/data/virtual-list.d.ts.map +1 -1
  159. package/dist/data/virtual-list.js +10 -0
  160. package/dist/forms/form-layout.d.ts.map +1 -1
  161. package/dist/forms/form-layout.js +3 -3
  162. package/dist/forms/form-layout.module.css +30 -5
  163. package/dist/forms/form-layout.module.css.d.ts +1 -0
  164. package/dist/guard/index.d.ts +8 -2
  165. package/dist/guard/index.d.ts.map +1 -1
  166. package/dist/guard/index.js +67 -2
  167. package/dist/guard/types.d.ts +3 -3
  168. package/dist/guard/types.d.ts.map +1 -1
  169. package/dist/index.d.ts +1 -1
  170. package/dist/index.d.ts.map +1 -1
  171. package/dist/index.js +3 -1
  172. package/dist/provider/index.d.ts +20 -1
  173. package/dist/provider/index.d.ts.map +1 -1
  174. package/dist/provider/index.js +22 -2
  175. package/dist/shell/app-brand.d.ts.map +1 -1
  176. package/dist/shell/app-brand.js +1 -0
  177. package/dist/shell/app-brand.module.css +2 -2
  178. package/dist/shell/app-header.module.css +18 -8
  179. package/dist/shell/app-main.module.css +54 -3
  180. package/dist/shell/app-main.module.css.d.ts +1 -0
  181. package/dist/shell/app-mobile-nav.module.css +16 -4
  182. package/dist/shell/app-sidebar.module.css +4 -6
  183. package/dist/shell/index.d.ts +12 -0
  184. package/dist/shell/index.d.ts.map +1 -1
  185. package/dist/shell/index.js +57 -16
  186. package/dist/shell/mobile-nav-model.d.ts +1 -1
  187. package/dist/shell/page-aside.d.ts +22 -3
  188. package/dist/shell/page-aside.d.ts.map +1 -1
  189. package/dist/shell/page-aside.js +27 -13
  190. package/dist/shell/page-bar.d.ts.map +1 -1
  191. package/dist/shell/page-bar.js +10 -5
  192. package/dist/shell/page-bar.module.css +10 -34
  193. package/dist/shell/use-breakpoint.d.ts +3 -4
  194. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  195. package/dist/shell/use-breakpoint.js +3 -25
  196. package/dist/shell/use-size-class.d.ts +6 -0
  197. package/dist/shell/use-size-class.d.ts.map +1 -0
  198. package/dist/shell/use-size-class.js +19 -0
  199. package/dist/state/persisted-or-local.d.ts +4 -1
  200. package/dist/state/persisted-or-local.d.ts.map +1 -1
  201. package/dist/state/persisted-or-local.js +4 -3
  202. package/dist/state/persisted.d.ts +10 -0
  203. package/dist/state/persisted.d.ts.map +1 -1
  204. package/dist/state/persisted.js +44 -14
  205. package/dist/styles.css +41 -0
  206. package/dist/surfaces.d.ts +4 -4
  207. package/dist/surfaces.d.ts.map +1 -1
  208. package/dist/surfaces.js +11 -3
  209. package/dist/theme/controls.module.css +7 -0
  210. package/dist/theme/ctl-theme.d.ts +1 -0
  211. package/dist/theme/ctl-theme.d.ts.map +1 -1
  212. package/dist/theme/ctl-theme.js +12 -4
  213. package/dist/theme/floating.module.css +36 -0
  214. package/dist/theme/floating.module.css.d.ts +3 -0
  215. package/dist/theme/index.d.ts +2 -2
  216. package/dist/theme/index.d.ts.map +1 -1
  217. package/dist/theme/index.js +34 -5
  218. package/dist/theme/nav-link.module.css +7 -0
  219. package/dist/tokens/index.d.ts +7 -16
  220. package/dist/tokens/index.d.ts.map +1 -1
  221. package/dist/tokens/index.js +23 -28
  222. package/dist/tokens/palette.d.ts +5 -27
  223. package/dist/tokens/palette.d.ts.map +1 -1
  224. package/dist/tokens/palette.js +8 -30
  225. package/dist/tokens/size-classes.d.ts +40 -0
  226. package/dist/tokens/size-classes.d.ts.map +1 -0
  227. package/dist/tokens/size-classes.js +29 -0
  228. package/dist/tokens.css +8 -4
  229. package/llms.txt +3 -3
  230. package/package.json +17 -17
  231. package/src/agent-chat/composer.tsx +74 -47
  232. package/src/agent-chat/thread-workspace.tsx +8 -11
  233. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  234. package/src/charts/cursor/touch-pin.ts +138 -0
  235. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  236. package/src/charts/hooks/use-hysteresis.ts +26 -0
  237. package/src/charts/hooks/useBandPlot.ts +90 -27
  238. package/src/charts/hooks/useChartCursor.ts +211 -26
  239. package/src/charts/index.ts +0 -8
  240. package/src/charts/kinds/BandStrip.tsx +11 -1
  241. package/src/charts/kinds/Donut.tsx +153 -62
  242. package/src/charts/kinds/DualPanel.tsx +19 -4
  243. package/src/charts/kinds/Heatmap.tsx +61 -22
  244. package/src/charts/kinds/MirroredBars.tsx +15 -3
  245. package/src/charts/kinds/donut-layout.ts +82 -0
  246. package/src/charts/layout/auto-margin.ts +24 -5
  247. package/src/charts/layout/end-labels.ts +135 -0
  248. package/src/charts/primitives/Axes.tsx +112 -21
  249. package/src/charts/primitives/CartesianChart.tsx +282 -68
  250. package/src/charts/primitives/ChartCard.tsx +141 -48
  251. package/src/charts/primitives/ChartFrame.tsx +243 -60
  252. package/src/charts/primitives/ChartLegend.tsx +257 -73
  253. package/src/charts/primitives/ChartTooltip.tsx +8 -3
  254. package/src/charts/primitives/Crosshair.tsx +2 -2
  255. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  256. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  257. package/src/charts/primitives/HoverOverlay.tsx +15 -4
  258. package/src/charts/primitives/chart-card-context.ts +37 -0
  259. package/src/charts/primitives/chart-frame-layout.ts +214 -177
  260. package/src/charts/primitives/chart-layout.ts +318 -0
  261. package/src/charts/primitives/chart-tier.tsx +66 -21
  262. package/src/charts/series.ts +4 -1
  263. package/src/charts/utils/ticks.ts +95 -19
  264. package/src/common/errors.ts +25 -1
  265. package/src/common/hit-floor.ts +9 -0
  266. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  267. package/src/common/use-measured-widths.ts +88 -0
  268. package/src/common/use-media-query.ts +56 -0
  269. package/src/common/validate.ts +17 -4
  270. package/src/content/article-layout.module.css +45 -4
  271. package/src/content/article-layout.module.css.d.ts +3 -0
  272. package/src/content/article-layout.tsx +60 -5
  273. package/src/content/code-block.module.css +32 -2
  274. package/src/content/code-block.tsx +47 -3
  275. package/src/content/prose.module.css +13 -8
  276. package/src/controls/actions.module.css +14 -1
  277. package/src/controls/actions.module.css.d.ts +1 -0
  278. package/src/controls/actions.tsx +223 -75
  279. package/src/controls/controls.module.css +8 -31
  280. package/src/controls/enum-filter.tsx +2 -2
  281. package/src/controls/filter-context.tsx +11 -0
  282. package/src/controls/filter-pill.tsx +1 -0
  283. package/src/controls/index.ts +0 -1
  284. package/src/controls/multi-select-filter.tsx +2 -2
  285. package/src/controls/sync-button.module.css +1 -1
  286. package/src/dashboard/section.module.css +1 -26
  287. package/src/dashboard/section.tsx +1 -0
  288. package/src/dashboard/stat-card.module.css +6 -9
  289. package/src/dashboard/stat-card.tsx +6 -3
  290. package/src/dashboard/stat-group.module.css +16 -4
  291. package/src/dashboard/widget-grid.tsx +7 -6
  292. package/src/dashboard/widget-header.module.css +145 -133
  293. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  294. package/src/dashboard/widget-header.tsx +45 -11
  295. package/src/data/data-table.module.css +94 -0
  296. package/src/data/data-table.module.css.d.ts +11 -0
  297. package/src/data/data-table.tsx +503 -164
  298. package/src/data/virtual-list.tsx +31 -0
  299. package/src/forms/form-layout.module.css +30 -5
  300. package/src/forms/form-layout.module.css.d.ts +1 -0
  301. package/src/forms/form-layout.tsx +12 -10
  302. package/src/guard/index.ts +183 -3
  303. package/src/guard/types.ts +3 -2
  304. package/src/index.ts +2 -0
  305. package/src/provider/index.tsx +53 -1
  306. package/src/shell/app-brand.module.css +2 -2
  307. package/src/shell/app-brand.tsx +1 -0
  308. package/src/shell/app-header.module.css +18 -8
  309. package/src/shell/app-main.module.css +54 -3
  310. package/src/shell/app-main.module.css.d.ts +1 -0
  311. package/src/shell/app-mobile-nav.module.css +16 -4
  312. package/src/shell/app-sidebar.module.css +4 -6
  313. package/src/shell/index.tsx +200 -129
  314. package/src/shell/mobile-nav-model.ts +1 -1
  315. package/src/shell/page-aside.tsx +70 -21
  316. package/src/shell/page-bar.module.css +10 -34
  317. package/src/shell/page-bar.tsx +10 -5
  318. package/src/shell/use-breakpoint.ts +9 -45
  319. package/src/shell/use-size-class.ts +31 -0
  320. package/src/state/persisted-or-local.ts +10 -4
  321. package/src/state/persisted.ts +76 -17
  322. package/src/styles.css +41 -0
  323. package/src/surfaces.ts +9 -1
  324. package/src/theme/controls.module.css +7 -0
  325. package/src/theme/ctl-theme.tsx +15 -3
  326. package/src/theme/floating.module.css +36 -0
  327. package/src/theme/floating.module.css.d.ts +3 -0
  328. package/src/theme/index.ts +46 -8
  329. package/src/theme/nav-link.module.css +7 -0
  330. package/src/tokens/index.ts +20 -30
  331. package/src/tokens/palette.ts +6 -28
  332. package/src/tokens/size-classes.ts +46 -0
  333. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  334. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -12,11 +12,12 @@
12
12
  * rendered, once each, and Mantine's `visibleFrom`/`hiddenFrom` decides which one paints. No JS
13
13
  * media query, so there is no first-paint flash and no hook that re-renders on resize.
14
14
  */
15
- import { ActionIcon, Button, Group, Menu } from '@mantine/core'
16
- import type { MantineBreakpoint } from '@mantine/core'
17
- import { createContext, isValidElement, useContext, useEffect } from 'react'
18
- import type { ReactNode } from 'react'
15
+ import { ActionIcon, Box, Button, Group, Menu } from '@mantine/core'
16
+ import { createContext, isValidElement, useContext, useEffect, useRef, useState } from 'react'
17
+ import type { ReactNode, RefObject } from 'react'
19
18
  import type { BasaltProps } from '../common/props'
19
+ import { useMeasuredWidths } from '../common/use-measured-widths'
20
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
20
21
  import type { NavAnchor } from '../shell/nav-types'
21
22
  import type { AnyNavLink } from '../router-tanstack/nav'
22
23
  import { IconSlot } from '../theme/icon-slot'
@@ -98,9 +99,6 @@ export type GlobalAction = {
98
99
  mobile?: 'bar' | 'more' | 'hidden'
99
100
  }
100
101
 
101
- /** Secondaries rendered inline on desktop; the rest fold into `More` (`docs/CONTROLS-SPEC.md` §2.1). */
102
- export const DESKTOP_SECONDARY_MAX = 3
103
-
104
102
  /** `GlobalAction`s without an explicit `mobile` that still land on the mobile bar. */
105
103
  export const MOBILE_GLOBAL_BAR_MAX = 2
106
104
 
@@ -189,17 +187,12 @@ const MOBILE_PRIMARY_PADDING_X = 10
189
187
  const BAR_GAP = 6
190
188
 
191
189
  /**
192
- * A declared WIDTH TIER for one rendered form of an action — Mantine's own `visibleFrom`/`hiddenFrom`
193
- * style props, forwarded verbatim, which is the same CSS-only mechanism this file's `sm` swap uses
194
- * (see the module header). Never a JS media query: no first-paint flash, no resize re-render.
195
- *
196
- * It exists because `sm` is not the only width at which this row runs out of room — see
197
- * {@link BarEntry}'s `md` label fold for the arithmetic.
190
+ * The DOM contract between `PageBar` row 1 and {@link useMeasuredFold} — module-internal, not part
191
+ * of any barrel. `PAGE_BAR_END_ATTR` marks the elastic row the fold measures; `BAR_KEY_ATTR` tags
192
+ * each rendered action with its `key` so a measured box maps back to its action.
198
193
  */
199
- type TierVisibility = {
200
- visibleFrom?: MantineBreakpoint
201
- hiddenFrom?: MantineBreakpoint
202
- }
194
+ export const PAGE_BAR_END_ATTR = 'data-basalt-page-bar-end'
195
+ export const BAR_KEY_ATTR = 'data-bar-key'
203
196
 
204
197
  /**
205
198
  * One action as a button. No `size` prop anywhere: the enclosing `CtlSlot` (mounted by the home,
@@ -209,19 +202,16 @@ function BarButton({
209
202
  action,
210
203
  variant,
211
204
  px,
212
- tier,
213
205
  }: {
214
206
  action: BarActionItem
215
207
  variant: 'filled' | 'default'
216
208
  /** Overrides the tier's horizontal inset — only the compact mobile primary passes it. */
217
209
  px?: number
218
- /** The declared width tier this form paints at — see {@link TierVisibility}. */
219
- tier?: TierVisibility
220
210
  }): ReactNode {
221
211
  const shared = {
222
212
  variant,
223
213
  ...(px !== undefined && { px }),
224
- ...tier,
214
+ [BAR_KEY_ATTR]: action.key,
225
215
  disabled: action.disabled === true,
226
216
  loading: action.loading === true,
227
217
  // EVERY icon in this file goes through `IconSlot` — the box is the framework's, never the
@@ -252,17 +242,14 @@ function BarButton({
252
242
  function BarIconButton({
253
243
  action,
254
244
  variant,
255
- tier,
256
245
  }: {
257
246
  action: BarActionItem
258
247
  /** `default` is the bordered desktop form — a joined `ControlGroup` member (see {@link BarEntry}). */
259
248
  variant: 'filled' | 'subtle' | 'default'
260
- /** The declared width tier this form paints at — see {@link TierVisibility}. */
261
- tier?: TierVisibility
262
249
  }): ReactNode {
263
250
  const shared = {
264
251
  variant,
265
- ...tier,
252
+ [BAR_KEY_ATTR]: action.key,
266
253
  'aria-label': action.label,
267
254
  disabled: action.disabled === true,
268
255
  loading: action.loading === true,
@@ -470,16 +457,16 @@ type BarActionRowProps = ActionGroupProps & {
470
457
  * label, so a labelled joined set is unaffected; the rule reads what the caller actually supplied
471
458
  * rather than adding a prop to ask.
472
459
  */
473
- function joinRuns(
474
- actions: readonly BarAction[],
460
+ function joinRuns<T extends BarAction>(
461
+ actions: readonly T[],
475
462
  opts: { viewport: 'desktop' | 'mobile'; excludeKey?: string | undefined },
476
- ): BarAction[][] {
477
- const joinable = (action: BarAction): boolean =>
463
+ ): T[][] {
464
+ const joinable = (action: T): boolean =>
478
465
  action.kind === undefined &&
479
466
  action.key !== opts.excludeKey &&
480
467
  (action.group === true || (opts.viewport === 'mobile' && action.icon !== undefined))
481
468
 
482
- const runs: BarAction[][] = []
469
+ const runs: T[][] = []
483
470
  for (const action of actions) {
484
471
  const tail = runs.at(-1)
485
472
  if (joinable(action) && tail !== undefined && tail.every(joinable)) tail.push(action)
@@ -488,6 +475,166 @@ function joinRuns(
488
475
  return runs
489
476
  }
490
477
 
478
+ type FoldStep = 'icon' | 'overflow'
479
+
480
+ /** The `More` trigger's estimated width — reserved only once something has folded (cf. `FOLD_PILL_WIDTH`). */
481
+ const MORE_WIDTH = 80
482
+
483
+ /**
484
+ * The measured fold as a pure decision. `room` is the row's available width, `fixed` everything in it
485
+ * that never folds (primary, sync, joined runs, custom nodes), each foldable `item` its labelled and
486
+ * icon-only width (`icon` absent when it ships no icon). Folds from the LAST item back: first to
487
+ * icon-only where an icon exists, then into `More`. Items that stay whole are absent from the result.
488
+ */
489
+ export function planBarFold(input: {
490
+ room: number
491
+ fixed: number
492
+ gap: number
493
+ hasMenus: boolean
494
+ items: readonly { key: string; full: number; icon: number | undefined }[]
495
+ }): Record<string, FoldStep> {
496
+ const { room, fixed, gap, hasMenus, items } = input
497
+ const steps: (FoldStep | undefined)[] = items.map(() => undefined)
498
+ const used = (): number =>
499
+ items.reduce(
500
+ (sum, item, i) => {
501
+ const step = steps[i]
502
+ if (step === 'overflow') return sum
503
+ return sum + gap + (step === 'icon' ? (item.icon ?? item.full) : item.full)
504
+ },
505
+ fixed + (hasMenus || steps.includes('overflow') ? MORE_WIDTH + gap : 0),
506
+ )
507
+
508
+ for (let i = items.length - 1; i >= 0 && used() > room; i -= 1) {
509
+ if (items[i]!.icon !== undefined) steps[i] = 'icon'
510
+ }
511
+ for (let i = items.length - 1; i >= 0 && used() > room; i -= 1) steps[i] = 'overflow'
512
+ return Object.fromEntries(items.flatMap((item, i) => (steps[i] ? [[item.key, steps[i]]] : [])))
513
+ }
514
+
515
+ /** The row's flex items, looking through `display: contents` wrappers (`CtlSlot`). */
516
+ function flowItems(el: Element): Element[] {
517
+ return Array.from(el.children).flatMap((child) =>
518
+ getComputedStyle(child).display === 'contents' ? flowItems(child) : [child],
519
+ )
520
+ }
521
+
522
+ /**
523
+ * The desktop row's fold state — shared by `PageBar` row 1 (`host: 'page'`, whose ANCESTOR carries
524
+ * `PAGE_BAR_END_ATTR`, published by `PageBar` itself) and every other home's `ActionGroup` /
525
+ * `BarActionSlot` (`host: 'slot'`, which has no wider elastic row to measure against and so tags
526
+ * its OWN `Group` with the same attribute — `Element.closest` matches the element itself first,
527
+ * so one `resolveRoot` covers both). Measures the row and re-plans on every resize and once web
528
+ * fonts settle; a zero reading (hidden below `sm`, not yet laid out) is not overflow, so the row
529
+ * then stays whole. A changed action set or label drops every remembered width and re-measures
530
+ * from the whole row, because a folded run cannot report the width its new label would take.
531
+ *
532
+ * `runs` is `joinRuns`'s own output, not the flat action list: a `group: true` run (rendered as one
533
+ * `ControlGroup`) is measured and folded AS A UNIT, keyed by its first member — a run of length > 1
534
+ * has no `icon` step of its own (its members already render icon-only per {@link BarEntry}), so it
535
+ * can only stay whole or fold straight into `More`.
536
+ */
537
+ function useMeasuredFold(input: {
538
+ host: 'page' | 'slot'
539
+ runs: readonly (BarActionItem | BarActionCustom)[][]
540
+ hasMenus: boolean
541
+ }): { folds: Record<string, FoldStep>; groupRef: RefObject<HTMLDivElement | null> } {
542
+ const { host, runs, hasMenus } = input
543
+ const groupRef = useRef<HTMLDivElement>(null)
544
+ const [folds, setFolds] = useState<Record<string, FoldStep>>({})
545
+ const [epoch, setEpoch] = useState(0)
546
+ const foldsRef = useRef(folds)
547
+ const runsRef = useRef(runs)
548
+ // Menus are filtered out before `runs` is built, so `hasMenus` — which reserves `MORE_WIDTH` in
549
+ // `planBarFold` — rides the signature: a menu appearing or leaving is a re-measure trigger, not
550
+ // an invisible one. A run's signature is its representative key plus every member's label — its
551
+ // members never fold individually, so only the whole run's label set can move its measured width.
552
+ const signature = `${hasMenus ? 'menus' : 'nomenus'}|${runs
553
+ .map(
554
+ (run) => `${run[0]!.key}:${run.map((a) => (a.kind === undefined ? a.label : '')).join(',')}`,
555
+ )
556
+ .join('|')}`
557
+ const measuredSignature = useRef(signature)
558
+
559
+ // Declared before the measuring hook so it runs first on every commit.
560
+ useIsomorphicLayoutEffect(() => {
561
+ foldsRef.current = folds
562
+ runsRef.current = runs
563
+ })
564
+
565
+ const measured = useMeasuredWidths({
566
+ resolveRoot: () => groupRef.current?.closest(`[${PAGE_BAR_END_ATTR}]`) ?? null,
567
+ signature,
568
+ nonce: epoch,
569
+ onMeasure: (row, boxes) => {
570
+ if (measuredSignature.current !== signature) {
571
+ measuredSignature.current = signature
572
+ if (Object.keys(foldsRef.current).length > 0) {
573
+ // Render the row whole, then measure it: `epoch` re-runs this hook after that commit.
574
+ foldsRef.current = {}
575
+ setFolds({})
576
+ setEpoch((n) => n + 1)
577
+ return
578
+ }
579
+ }
580
+ const group = groupRef.current
581
+ if (group === null) return
582
+ const currentRuns = runsRef.current
583
+ // A `kind: 'custom'` entry is never a fold candidate (basalt does not draw it, so it has no
584
+ // form to fold into) — it is always a length-1 run, since `joinRuns` never joins one.
585
+ const foldable = new Set(
586
+ currentRuns.filter((run) => run[0]!.kind === undefined).map((run) => run[0]!.key),
587
+ )
588
+ const elements = Array.from(group.children).filter(
589
+ (el): el is HTMLElement =>
590
+ el instanceof HTMLElement && foldable.has(el.getAttribute(BAR_KEY_ATTR) ?? ''),
591
+ )
592
+ for (const el of elements) {
593
+ const key = el.getAttribute(BAR_KEY_ATTR)!
594
+ if (foldsRef.current[key] === undefined && el.offsetWidth > 0) {
595
+ boxes.current.set(key, { width: el.offsetWidth, height: el.offsetHeight })
596
+ }
597
+ }
598
+ const items = currentRuns.flatMap((run) => {
599
+ const rep = run[0]!
600
+ if (rep.kind !== undefined) return []
601
+ const seen = boxes.current.get(rep.key)
602
+ if (seen === undefined) return []
603
+ const icon = run.length === 1 && rep.icon !== undefined ? seen.height : undefined
604
+ return [{ key: rep.key, full: seen.width, icon }]
605
+ })
606
+ const rects = flowItems(row)
607
+ .map((el) => el.getBoundingClientRect())
608
+ .filter((r) => r.width > 0)
609
+ if (row.clientWidth <= 0 || rects.length === 0 || items.length === 0) return
610
+
611
+ const used = Math.max(...rects.map((r) => r.right)) - Math.min(...rects.map((r) => r.left))
612
+ const rendered = elements.reduce((sum, el) => sum + el.offsetWidth + BAR_GAP, 0)
613
+ const moreNow = hasMenus || Object.values(foldsRef.current).includes('overflow')
614
+ const fixed = used - rendered - (moreNow ? MORE_WIDTH + BAR_GAP : 0)
615
+ const next = planBarFold({ room: row.clientWidth, fixed, gap: BAR_GAP, hasMenus, items })
616
+ const prev = foldsRef.current
617
+ const same =
618
+ Object.keys(next).length === Object.keys(prev).length &&
619
+ Object.entries(next).every(([key, step]) => prev[key] === step)
620
+ if (!same) setFolds(next)
621
+ },
622
+ })
623
+
624
+ // The first measure can precede the web font, whose swap-in changes every label's width.
625
+ useIsomorphicLayoutEffect(() => {
626
+ let disposed = false
627
+ void document.fonts?.ready
628
+ ?.then(() => (disposed ? undefined : measured.remeasure()))
629
+ .catch(() => {})
630
+ return () => {
631
+ disposed = true
632
+ }
633
+ }, [host, signature])
634
+
635
+ return { folds, groupRef }
636
+ }
637
+
491
638
  export function BarActionRow({
492
639
  primary,
493
640
  secondary,
@@ -505,8 +652,13 @@ export function BarActionRow({
505
652
 
506
653
  const flat = list.filter((a): a is BarActionItem | BarActionCustom => a.kind !== 'menu')
507
654
  const menus = list.filter((a): a is BarActionMenu => a.kind === 'menu')
508
- const inline = flat.slice(0, DESKTOP_SECONDARY_MAX)
509
- const desktopOverflow = [...flat.slice(DESKTOP_SECONDARY_MAX), ...menus]
655
+ const allRuns = joinRuns(flat, { viewport: 'desktop' })
656
+ const { folds, groupRef } = useMeasuredFold({ host, runs: allRuns, hasMenus: menus.length > 0 })
657
+ const runs = allRuns.filter((run) => folds[run[0]!.key] !== 'overflow')
658
+ const desktopOverflow = [
659
+ ...allRuns.flatMap((run) => (folds[run[0]!.key] === 'overflow' ? run : [])),
660
+ ...menus,
661
+ ]
510
662
 
511
663
  const wantsMobile = viewport === 'both'
512
664
  const mobileList = [...list, ...mobileOnly]
@@ -551,7 +703,7 @@ export function BarActionRow({
551
703
  const hasMobile = mobileBar.length > 0 || claimsKebab
552
704
  if (!hasDesktop && !hasMobile && syncNode === undefined) return null
553
705
 
554
- const desktopLead = inline.length > 0 || desktopOverflow.length > 0 || syncNode === undefined
706
+ const desktopLead = flat.length > 0 || menus.length > 0 || syncNode === undefined
555
707
 
556
708
  // The caller's `className`/`style` land on the ONE desktop group and the ONE mobile group. A
557
709
  // `syncNode` splits the desktop row in two, and with nothing to lead with (no inline actions, no
@@ -566,16 +718,41 @@ export function BarActionRow({
566
718
  return (
567
719
  <>
568
720
  {hasDesktop && desktopLead && (
569
- <Group gap={BAR_GAP} wrap="nowrap" visibleFrom="sm" {...rootProps}>
570
- {joinRuns(inline, { viewport: 'desktop' }).map((run) =>
721
+ <Group
722
+ ref={groupRef}
723
+ gap={BAR_GAP}
724
+ wrap="nowrap"
725
+ visibleFrom="sm"
726
+ // A `slot` host has no wider elastic row to measure against (`PageBar` row 1 is the only
727
+ // one an ancestor tags) — it measures its OWN row instead, and `Element.closest` matches
728
+ // an element against itself before it walks up, so `useMeasuredFold`'s `resolveRoot` needs
729
+ // no branch for it.
730
+ {...(host === 'slot' && { [PAGE_BAR_END_ATTR]: '' })}
731
+ {...rootProps}
732
+ >
733
+ {runs.map((run) =>
571
734
  run.length === 1 ? (
572
- <BarEntry key={run[0]!.key} action={run[0]!} emphasis="secondary" />
735
+ <BarEntry
736
+ key={run[0]!.key}
737
+ action={run[0]!}
738
+ emphasis="secondary"
739
+ iconOnly={folds[run[0]!.key] === 'icon'}
740
+ />
573
741
  ) : (
574
- <ControlGroup key={`join-${run[0]!.key}`}>
575
- {run.map((action) => (
576
- <BarEntry key={action.key} action={action} emphasis="secondary" iconOnly />
577
- ))}
578
- </ControlGroup>
742
+ // Tagged so the joined set is measured and folded AS ONE BOX — its members are
743
+ // grandchildren of this `Group`, invisible to the direct-children scan
744
+ // `useMeasuredFold` runs, so the run needs its own `BAR_KEY_ATTR` one level up.
745
+ <Box
746
+ key={`join-${run[0]!.key}`}
747
+ className={classes.joinBox}
748
+ {...{ [BAR_KEY_ATTR]: run[0]!.key }}
749
+ >
750
+ <ControlGroup>
751
+ {run.map((action) => (
752
+ <BarEntry key={action.key} action={action} emphasis="secondary" iconOnly />
753
+ ))}
754
+ </ControlGroup>
755
+ </Box>
579
756
  ),
580
757
  )}
581
758
  <OverflowMenu actions={desktopOverflow} />
@@ -621,8 +798,8 @@ export function BarActionRow({
621
798
  /**
622
799
  * A home's `actions` slot, projected for both viewports.
623
800
  *
624
- * Desktop: `primary` as a `filled` button · up to `DESKTOP_SECONDARY_MAX` secondaries as `default`
625
- * buttons · everything past that, plus every `kind: 'menu'`, in one `More` menu.
801
+ * Desktop: `primary` as a `filled` button · secondaries as `default` buttons, folded by the row's
802
+ * measured width (icon-only, then into `More`) · every `kind: 'menu'` in that same `More` menu.
626
803
  * Mobile: `primary` as an icon button when it ships an icon and a compact labelled button when it
627
804
  * does not · one kebab holding every `mobile: 'more'` action · `mobile: 'hidden'` drops the action
628
805
  * entirely.
@@ -676,8 +853,8 @@ export function isBarActionList(actions: SlotActions): actions is BarAction[] {
676
853
 
677
854
  /**
678
855
  * Renders a {@link SlotActions} slot: the typed form goes through the SAME projection `PageBar` and
679
- * `ActionGroup` use — inline up to {@link DESKTOP_SECONDARY_MAX}, the rest folded into `More`, and a
680
- * mobile kebab below `sm` — and the node form is returned untouched.
856
+ * `ActionGroup` use — inline, `kind: 'menu'` folded into `More`, and a mobile kebab below `sm` — and
857
+ * the node form is returned untouched.
681
858
  *
682
859
  * Every entry lands in `secondary`: a `BarAction[]` carries no primary marker, and law C6's "exactly
683
860
  * one primary" is the page bar's rule, enforced by `ActionGroupProps.primary` being singular. A home
@@ -710,7 +887,7 @@ function BarEntry({
710
887
  }: {
711
888
  action: BarAction
712
889
  emphasis: 'primary' | 'secondary' | 'mobile-primary' | 'mobile-secondary'
713
- /** Set by a JOINED run — see {@link joinRuns}. The label survives as the accessible name. */
890
+ /** Set by a JOINED run (see {@link joinRuns}) or a measured fold. The label survives as the accessible name. */
714
891
  iconOnly?: boolean
715
892
  }): ReactNode {
716
893
  if (action.kind === 'custom') return <span className={classes.customSlot}>{action.node}</span>
@@ -720,35 +897,6 @@ function BarEntry({
720
897
  if (iconOnly === true && action.icon !== undefined) {
721
898
  return <BarIconButton action={action} variant="default" />
722
899
  }
723
- // THE `md` LABEL FOLD — the second declared tier, and the only fold this row had between `sm`
724
- // (48em, where the whole mobile swap happens) and the width at which a desktop header is
725
- // genuinely wide. Above `sm` the only fold was `DESKTOP_SECONDARY_MAX`, which is a COUNT and
726
- // never a width: three labelled secondaries are three labelled secondaries at 768px and at
727
- // 2560px.
728
- //
729
- // The arithmetic, on /dashboard at exactly 768px — the width where the navbar appears and takes
730
- // 256px: 512px of header left, against ~554px of row 1 + ~102px of globals + ~48px of gaps ≈
731
- // 704px. ~192px over, and nothing above `sm` folded — the breadcrumb absorbed it by shrinking to
732
- // zero (`shell/app-header.module.css`'s `.lead`, now floored at 96px, which this tier pays for).
733
- // Dropping up to `DESKTOP_SECONDARY_MAX` labelled secondaries to their 30px icon form returns
734
- // roughly 180-240px on that row: a labelled `ctl` button is its icon slot + label + insets
735
- // (~90-110px depending on the word), an icon-only one is the tier's 30px square.
736
- //
737
- // The PRIMARY keeps its label at every width — it is the one action a reader must be able to
738
- // name without hovering (law C6: exactly one per home), so `emphasis === 'primary'` never enters
739
- // this branch. An icon-LESS secondary keeps its label too: there is nothing to fall back to, the
740
- // same reason `MobileBarEntry` renders a compact labelled button below `sm`.
741
- //
742
- // Both forms are rendered and CSS picks one, the mechanism the `sm` swap already uses — that is
743
- // law C9 being HONOURED, not breached: the swap belongs to the control, and this is the control.
744
- if (emphasis === 'secondary' && action.icon !== undefined) {
745
- return (
746
- <>
747
- <BarIconButton action={action} variant="default" tier={{ hiddenFrom: 'md' }} />
748
- <BarButton action={action} variant="default" tier={{ visibleFrom: 'md' }} />
749
- </>
750
- )
751
- }
752
900
  return <BarButton action={action} variant={emphasis === 'primary' ? 'filled' : 'default'} />
753
901
  }
754
902
 
@@ -4,7 +4,7 @@
4
4
  * Two form factors per control, ONE mount each, switched by CSS only (C9): the `pill` surface
5
5
  * (bordered 30px chip + popover) and the `sheet` surface (`PanelRow` label-above-control rows
6
6
  * inside the mobile `Filters (n)` Drawer — the same rows the aside's `panel` surface renders;
7
- * `panel-row.module.css` owns their geometry). `48em` / `47.99375em` are the `sm` breakpoint in the
7
+ * `panel-row.module.css` owns their geometry). `52.5em` / `52.49375em` are the `sm` size-class boundary in the
8
8
  * exact literal form every other basalt CSS module uses — a media query cannot resolve a custom
9
9
  * property, so the breakpoint cannot be a token (see `content/prose.module.css`'s note).
10
10
  */
@@ -50,7 +50,7 @@
50
50
  }
51
51
 
52
52
  /* Below `sm` only the first `inline` slots are pills; the rest live in the sheet. */
53
- @media (max-width: 47.99375em) {
53
+ @media (max-width: 52.49375em) {
54
54
  .pillSlot:not([data-inline]) {
55
55
  display: none;
56
56
  }
@@ -60,7 +60,7 @@
60
60
  * Folding is a DESKTOP concern only — scoped to `min-width` so a width measured on a wide viewport
61
61
  * can never hide a slot the mobile `inline` budget is supposed to keep.
62
62
  */
63
- @media (min-width: 48em) {
63
+ @media (min-width: 52.5em) {
64
64
  .pillSlot[data-folded] {
65
65
  display: none;
66
66
  }
@@ -71,7 +71,7 @@
71
71
  display: none;
72
72
  }
73
73
 
74
- @media (min-width: 48em) {
74
+ @media (min-width: 52.5em) {
75
75
  .foldPill[data-shown] {
76
76
  display: inline-flex;
77
77
  }
@@ -81,7 +81,7 @@
81
81
  display: none;
82
82
  }
83
83
 
84
- @media (max-width: 47.99375em) {
84
+ @media (max-width: 52.49375em) {
85
85
  .sheetPill {
86
86
  display: inline-flex;
87
87
  }
@@ -101,10 +101,11 @@
101
101
  * outline the row reads as loose text. Declared here rather than by relaxing the resolver, which
102
102
  * would put a hairline back on every default button in the app.
103
103
  *
104
- * The ≥36px touch target below `sm` (C15) is drawn as a `::before` overlay so the visible box stays
105
- * 30px.
104
+ * The touch target (C15) is the shared `[data-basalt-hit]` overlay (`styles.css`), capped at the
105
+ * chip-row gap so neighbouring pills never overlap; the visible box stays 30px.
106
106
  */
107
107
  .pill {
108
+ --vx-hit-gap: var(--vx-space-control-gap);
108
109
  position: relative;
109
110
  max-width: 15rem;
110
111
  border-color: color-mix(in srgb, var(--vx-ink) 12%, transparent);
@@ -157,30 +158,6 @@
157
158
  color: var(--vx-ink);
158
159
  }
159
160
 
160
- @media (max-width: 47.99375em) {
161
- /* Mantine's Button root clips to its box; the overlay must escape it to be hit-testable. Loading
162
- * buttons keep the clip their overlay depends on (same pairing as page-bar.module.css). */
163
- .pill:not([data-loading]) {
164
- overflow: visible;
165
- }
166
-
167
- .pill::before {
168
- content: '';
169
- position: absolute;
170
- /* Both operands scale with `--mantine-scale` (same reconstruction the theme uses for every
171
- * other ctl-tier box, `theme/index.ts`'s `ctlSizeVars`) so the overlay stays centred on — and
172
- * sized off — the ACTUAL scaled pill box, not its unscaled rem value. */
173
- inset-block: calc(
174
- (
175
- calc(var(--vx-space-control-height-ctl, 1.875rem) * var(--mantine-scale, 1)) -
176
- calc(var(--vx-space-touch-control-height, 2.25rem) * var(--mantine-scale, 1))
177
- ) /
178
- 2
179
- );
180
- inset-inline: 0;
181
- }
182
- }
183
-
184
161
  /* Label at the tier's own step and weight (`docs/CONTROLS-SPEC.md` §3) — Mantine's Button label
185
162
  * inherits the root's `--button-fz` but carries the body weight, which reads light against the
186
163
  * bordered box. */
@@ -31,7 +31,7 @@ import type { ReactNode } from 'react'
31
31
  import type { BasaltProps } from '../common/props'
32
32
  import type { FieldOption } from '../state'
33
33
  import classes from './controls.module.css'
34
- import { useFilterRegistration, useFilterSurface } from './filter-context'
34
+ import { POPOVER_ROW_HIT_GAP, useFilterRegistration, useFilterSurface } from './filter-context'
35
35
  import { FilterPill } from './filter-pill'
36
36
  import { useControlName } from './filter-sheet'
37
37
  import { PanelChoice, PanelRow } from './panel-row'
@@ -147,7 +147,7 @@ export function EnumFilter<T extends string>({
147
147
  setValue(next as T)
148
148
  }}
149
149
  >
150
- <Stack gap={2}>
150
+ <Stack gap={POPOVER_ROW_HIT_GAP} style={{ '--vx-hit-gap': 'var(--stack-gap)' }}>
151
151
  {options.map((option) => (
152
152
  <Radio
153
153
  key={option.value}
@@ -32,6 +32,17 @@ import {
32
32
  } from 'react'
33
33
  import type { ReactNode } from 'react'
34
34
 
35
+ /**
36
+ * The real gap a popover row (`EnumFilter`'s `Radio.Group`, `MultiSelectFilter`'s `Checkbox.Group`)
37
+ * needs so its `[data-basalt-hit]::after` overlay can reach the full 44px coarse floor with no
38
+ * overlap into the next row — same derivation as the chart legend's dots mode (`DOTS_HIT_GAP`):
39
+ * `hit - rowHeight - 2 + 4` headroom, rowHeight being Mantine's default `Radio`/`Checkbox` label row
40
+ * (~21px). A bare `gap={2}` left the `--vx-hit-gap` cap able to buy only ~4px of the 44
41
+ * (code-foundations F1). Shared rather than duplicated per file: both popover rows share the exact
42
+ * same row height and floor, so one drift point beats two hand-kept copies.
43
+ */
44
+ export const POPOVER_ROW_HIT_GAP = 25
45
+
35
46
  /**
36
47
  * Which form a control renders. Provided by `FilterSet` (`'pill'` / `'sheet'`) and by `PageAside`
37
48
  * (`'panel'`); `'pill'` when there is neither.
@@ -71,6 +71,7 @@ export function FilterPill({
71
71
  variant="default"
72
72
  size="ctl"
73
73
  className={cx(classes.pill, className)}
74
+ data-basalt-hit
74
75
  classNames={{ label: classes.pillLabel }}
75
76
  {...(style !== undefined && { style })}
76
77
  {...(active === true && { 'data-active': true })}
@@ -8,7 +8,6 @@
8
8
  export {
9
9
  ActionGroup,
10
10
  OverflowMenu,
11
- DESKTOP_SECONDARY_MAX,
12
11
  MOBILE_GLOBAL_BAR_MAX,
13
12
  barActionMobile,
14
13
  globalActionMobile,
@@ -30,7 +30,7 @@ import { assertRequiredProps } from '../common/validate'
30
30
  import type { FieldHandle, MultiField } from '../state'
31
31
  import type { FilterOption } from './select-filter'
32
32
  import classes from './controls.module.css'
33
- import { useFilterRegistration, useFilterSurface } from './filter-context'
33
+ import { POPOVER_ROW_HIT_GAP, useFilterRegistration, useFilterSurface } from './filter-context'
34
34
  import { FilterPill } from './filter-pill'
35
35
  import { CheckGlyph, useControlName } from './filter-sheet'
36
36
  import { PanelRow } from './panel-row'
@@ -143,7 +143,7 @@ export function MultiSelectFilter<T extends string>(props: MultiSelectFilterProp
143
143
  setValue(next as readonly T[])
144
144
  }}
145
145
  >
146
- <Stack gap={2}>
146
+ <Stack gap={POPOVER_ROW_HIT_GAP} style={{ '--vx-hit-gap': 'var(--stack-gap)' }}>
147
147
  {options.map((option) => (
148
148
  <Checkbox
149
149
  key={option.value}
@@ -45,7 +45,7 @@
45
45
  * width itself, pinned to the `ctl` height so the box is square. This module is unlayered, so it
46
46
  * outranks `@layer mantine` regardless of specificity.
47
47
  */
48
- @media (max-width: 47.99375em) {
48
+ @media (max-width: 52.49375em) {
49
49
  .label,
50
50
  .age {
51
51
  display: none;
@@ -18,6 +18,7 @@
18
18
  * query at all).
19
19
  */
20
20
  .root {
21
+ container: basalt-card / inline-size;
21
22
  display: flex;
22
23
  flex-direction: column;
23
24
  gap: var(--vx-space-section-gap, 0.75rem);
@@ -95,29 +96,3 @@
95
96
  .chevron[data-open='false'] svg {
96
97
  transform: rotate(-90deg);
97
98
  }
98
-
99
- /*
100
- * Law C15 — the chevron is a 12px glyph in a 20px box, which is a fine mouse target and an
101
- * unusable thumb one. Below `sm` an `::after` overlay lifts the HIT area to
102
- * `--vx-space-touch-control-height` (36px at level 0) while the visible glyph stays put — the same
103
- * overlay `shell/page-bar.module.css` draws for the bar's own controls. `overflow: visible` is part
104
- * of the fix, not tidying: an overlay that extends past the box is clipped by any `overflow:
105
- * hidden` on the host (which is what made the identical overlay a no-op on Mantine's Button roots).
106
- */
107
- @media (max-width: 47.99375em) {
108
- .chevron {
109
- overflow: visible;
110
- }
111
-
112
- .chevron::after {
113
- content: '';
114
- position: absolute;
115
- top: 50%;
116
- left: 50%;
117
- translate: -50% -50%;
118
- width: 100%;
119
- height: 100%;
120
- min-width: var(--vx-space-touch-control-height, 2.25rem);
121
- min-height: var(--vx-space-touch-control-height, 2.25rem);
122
- }
123
- }
@@ -156,6 +156,7 @@ function ChevronToggle({
156
156
  <button
157
157
  type="button"
158
158
  className={classes.chevron}
159
+ data-basalt-hit
159
160
  data-open={open}
160
161
  aria-expanded={open}
161
162
  aria-controls={controls}