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
@@ -23,7 +23,9 @@ import type { ReactNode, Ref } from 'react'
23
23
  import { cx } from '../common/props'
24
24
  import type { BasaltProps, SlotStylesProps } from '../common/props'
25
25
  import { BASALT_PREFIX } from '../common/errors'
26
+ import { HIT_COARSE } from '../common/hit-floor'
26
27
  import { assertRequiredProps, useValidateProps } from '../common/validate'
28
+ import { readMediaQuery } from '../common/use-media-query'
27
29
  import { ErrorState } from '../dashboard/query-state'
28
30
  import type { QueryStateLike } from '../dashboard/query-state'
29
31
  import { dataQueryBranch } from './query-branch'
@@ -112,6 +114,16 @@ export type BasaltVirtualListProps<T> = BasaltProps &
112
114
  ref?: Ref<BasaltVirtualListHandle>
113
115
  }
114
116
 
117
+ /** `estimateSize` sets every row's rendered height — there is no per-row measurement in this
118
+ * component — so a value under the coarse floor understates the touch target for every row. */
119
+ function shortRowHeightMessage(estimateSize: number): string {
120
+ return (
121
+ `${BASALT_PREFIX} BasaltVirtualList: "estimateSize" is ${estimateSize}px under a coarse ` +
122
+ '(touch) pointer — every row renders at this height, below the `--vx-hit` floor (44px, WCAG ' +
123
+ '2.5.5). Raise "estimateSize" to at least 44, or pad the row content so it clears the floor.'
124
+ )
125
+ }
126
+
115
127
  /**
116
128
  * Imperative escape hatch for {@link BasaltVirtualList} — the virtualizer's own `scrollToIndex` /
117
129
  * `scrollToOffset` / `scrollToEnd` were otherwise unreachable, since the component owns the
@@ -198,6 +210,25 @@ export function BasaltVirtualList<T>(props: BasaltVirtualListProps<T>) {
198
210
  [query === undefined, props.isLoading === undefined],
199
211
  )
200
212
 
213
+ // A coarse pointer only — a mouse user's row height is a layout choice, not an accessibility
214
+ // floor. `estimateSize` sets every row's actual rendered height (this component never measures
215
+ // one), so a value under the floor is not a false positive to catch later.
216
+ //
217
+ // Reads `props.estimateSize`, not the destructured `estimateSize` above: the shipped DEFAULT
218
+ // (40) is itself under `HIT_COARSE` (44), so warning off the destructured value fired on every
219
+ // coarse-pointer mount that never named `estimateSize` at all. Only a caller's OWN too-short
220
+ // value is a defect to report.
221
+ useValidateProps(
222
+ 'BasaltVirtualList',
223
+ () =>
224
+ props.estimateSize !== undefined &&
225
+ props.estimateSize < HIT_COARSE &&
226
+ readMediaQuery('(pointer: coarse)', false)
227
+ ? shortRowHeightMessage(props.estimateSize)
228
+ : null,
229
+ [props.estimateSize],
230
+ )
231
+
201
232
  const parentRef = useRef<HTMLDivElement>(null)
202
233
 
203
234
  const rowVirtualizer = useVirtualizer({
@@ -6,8 +6,17 @@
6
6
  * deliberately rather than forked: a form row and a `SettingsRow` are the same shape at the same
7
7
  * rhythm, and a second pair of numbers is how the two drift apart.
8
8
  *
9
- * The `47.99375em` literal is the `sm` breakpoint (law C9 — the responsive swap is CSS, never a JS
10
- * media query). A `@media` condition cannot read a custom property, so the number is written.
9
+ * The label/control swap keys on the row's OWN measured width via `@container`, not the viewport
10
+ * (law C9 — the responsive swap is CSS, never a JS media query, but a viewport `@media` answers the
11
+ * wrong question here the same way `StatGroup`'s used to: a narrow column can leave a row too
12
+ * tight for two 250px+ cells well before the viewport itself crosses `sm`, and a wide one — a
13
+ * landscape phone, a split pane — can afford two columns well under it). `.row` establishes the
14
+ * containment and `.grid` is the queried child, the same split `StatGroup`'s `.container`/`.root`
15
+ * uses — a container cannot reliably query ITSELF for a layout-affecting property (measured here:
16
+ * Chrome silently keeps the un-queried value even once the container's own width crosses the
17
+ * condition), only a descendant. The `599.9px`/`47.99375em` literals: 600px is roughly the point
18
+ * two ~250px+ cells stop feeling cramped; `@container`/`@media` conditions cannot read a custom
19
+ * property, so both are written numbers, not `--vx-*` refs.
11
20
  */
12
21
 
13
22
  /* ── FormSection ─────────────────────────────────────────────────────────────────────────────── */
@@ -32,6 +41,11 @@
32
41
  * itself against a one-line input.
33
42
  */
34
43
  .row {
44
+ container-type: inline-size;
45
+ container-name: basalt-form;
46
+ }
47
+
48
+ .grid {
35
49
  display: grid;
36
50
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
37
51
  align-items: start;
@@ -80,14 +94,25 @@
80
94
  color: var(--vx-status-bad);
81
95
  }
82
96
 
83
- /* The phone twin: the control needs the full width, so the label goes above it. */
84
- @media (max-width: 47.99375em) {
85
- .row {
97
+ /* The narrow twin: the control needs the full width, so the label goes above it. */
98
+ @container basalt-form (max-width: 599.9px) {
99
+ .grid {
86
100
  grid-template-columns: minmax(0, 1fr);
87
101
  gap: var(--vx-space-stack-xs);
88
102
  }
89
103
  }
90
104
 
105
+ /* Pre-container-query fallback (Safari < 16, Chrome < 106, Firefox < 110) — the old viewport law,
106
+ * reachable only where `@container` itself cannot parse (same reasoning as `stat-group.module.css`). */
107
+ @supports not (container-type: inline-size) {
108
+ @media (max-width: 47.99375em) {
109
+ .grid {
110
+ grid-template-columns: minmax(0, 1fr);
111
+ gap: var(--vx-space-stack-xs);
112
+ }
113
+ }
114
+ }
115
+
91
116
  /* ── FormGroup ───────────────────────────────────────────────────────────────────────────────── */
92
117
 
93
118
  /* Same UA reset as `.control`, same reason. */
@@ -6,6 +6,7 @@ declare const classes: {
6
6
  readonly section: string
7
7
  readonly sectionBody: string
8
8
  readonly row: string
9
+ readonly grid: string
9
10
  readonly rowLabel: string
10
11
  readonly label: string
11
12
  readonly hint: string
@@ -185,17 +185,19 @@ export function FormRow(props: FormRowProps) {
185
185
  className={cx(classes.row, classNames?.root, className)}
186
186
  {...(style !== undefined && { style })}
187
187
  >
188
- <div className={cx(classes.rowLabel, classNames?.label)}>
189
- <label className={classes.label} {...(htmlFor !== undefined && { htmlFor })}>
190
- {label}
191
- <RequiredMark required={required} />
192
- </label>
193
- {hint !== undefined && <span className={classes.hint}>{hint}</span>}
188
+ <div className={classes.grid}>
189
+ <div className={cx(classes.rowLabel, classNames?.label)}>
190
+ <label className={classes.label} {...(htmlFor !== undefined && { htmlFor })}>
191
+ {label}
192
+ <RequiredMark required={required} />
193
+ </label>
194
+ {hint !== undefined && <span className={classes.hint}>{hint}</span>}
195
+ </div>
196
+ <fieldset className={cx(classes.control, classNames?.control)} disabled={disabled}>
197
+ {children}
198
+ <FieldError error={error} />
199
+ </fieldset>
194
200
  </div>
195
- <fieldset className={cx(classes.control, classNames?.control)} disabled={disabled}>
196
- {children}
197
- <FieldError error={error} />
198
- </fieldset>
199
201
  </div>
200
202
  )
201
203
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ./guard — headless policy core. Mantine-free, dependency-free.
3
3
  *
4
- * GUARD_RULES: the closed registry of all 26 violation kinds.
4
+ * GUARD_RULES: the closed registry of all 27 violation kinds.
5
5
  * checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
6
6
  */
7
7
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
@@ -113,6 +113,126 @@ const SHADOW_IS_COMPOSED = /var\(|\$\{/
113
113
  const CSS_SURFACE_RADIUS = /(?<![\w-])border-radius\s*:\s*([^;}]+)/g
114
114
  const CSS_SURFACE_SHADOW = /(?<![\w-])box-shadow\s*:\s*([^;}]+)/g
115
115
 
116
+ /** One numeric width literal — the value half of every shape `CSS_WIDTH_MEDIA_QUERY` matches. */
117
+ const CSS_WIDTH_NUM = '[\\d.]+(?:px|em|rem|vw)'
118
+
119
+ /**
120
+ * A width `@media` condition — `(min-width: 52.5em)` / `(max-width: 767.9px)`, plus every range-
121
+ * comparison spelling CSS lets an author use instead (the SAME condition, just spelled
122
+ * differently): keyword-first (`width >= 600px`), value-first (`600px <= width`), and CHAINED, both
123
+ * sides at once (`400px <= width <= 700px`, `700px >= width >= 400px`). A regex that only covered
124
+ * the keyword-first comparison form let every value-first or chained range slip past unflagged —
125
+ * the exact shape `width >= 600px` catches that `600px <= width` (the same boundary, spelled the
126
+ * other way round) does not. The size-class axis
127
+ * (`docs/DESIGN-CORE.md` § Layout, elevation, shapes) names its own home (`shell/**`, `@media`
128
+ * against `SIZE_CLASSES` literals); anywhere else a hard-coded viewport breakpoint has skipped the
129
+ * framework's one seam for it (`useSizeClass()`, or the derived `theme.breakpoints`). See
130
+ * `raw-breakpoint`, the oxlint plugin's
131
+ * AST-side twin for the same law over JSX (`configs/oxlint-plugin.js`) — this is the CSS-text half,
132
+ * the shape the plugin cannot see. New file type for this check (CSS, same reasoning as
133
+ * `css-raw-surface`), so it lands under its own kind at `warn` for one minor rather than widening an
134
+ * existing one.
135
+ */
136
+ const CSS_WIDTH_MEDIA_QUERY = new RegExp(
137
+ '@media[^{]*\\(\\s*(?:' +
138
+ // keyword form: min-width: N / max-width: N
139
+ `(?:min|max)-width\\s*:\\s*${CSS_WIDTH_NUM}` +
140
+ // width-first comparison, with an optional CHAINED second boundary on the other side:
141
+ // width OP N, or N OP width OP N
142
+ `|(?:${CSS_WIDTH_NUM}\\s*[<>]=?\\s*)?width\\s*[<>]=?\\s*${CSS_WIDTH_NUM}` +
143
+ // value-first comparison with NO trailing boundary: N OP width. The lookahead excludes the
144
+ // chained shape (already matched whole by the alternative above) so this only fires for the
145
+ // plain value-first form.
146
+ `|${CSS_WIDTH_NUM}\\s*[<>]=?\\s*width(?!\\s*[<>]=?\\s*${CSS_WIDTH_NUM})` +
147
+ ')\\s*\\)',
148
+ 'g',
149
+ )
150
+
151
+ /**
152
+ * An `@container` condition — the declared NAME (optional: `@container (min-width: …)` with no
153
+ * name is legal CSS, scoping to the nearest ancestor container regardless of its name) plus every
154
+ * raw boundary across the WHOLE at-rule, chained `and (...)` clauses included
155
+ * (`@container basalt-stat-group (min-width: 768px) and (max-width: 1199.9px)` —
156
+ * `stat-group.module.css` ships exactly this shape). Capturing only the first parenthesized clause
157
+ * would silently skip every boundary after the first `and`; group 2 here is the full run of
158
+ * `(...)`/`and` text so `CSS_CONTAINER_LITERAL`'s own `matchAll` below finds all of them.
159
+ */
160
+ const CSS_CONTAINER_QUERY = /@container(?:\s+([\w-]+))?\s*((?:\([^)]*\)\s*(?:and\s*)?)+)/g
161
+
162
+ /**
163
+ * One numeric boundary inside an `@container` condition's parens, in either syntax CSS allows: the
164
+ * range-media form (`min-width:`/`max-width:`) and the newer comparison form (`width >= …`).
165
+ */
166
+ const CSS_CONTAINER_LITERAL = /(?:min-width|max-width|width)\s*(?:[<>]=?|:)\s*([\d.]+)(px|em)?/g
167
+
168
+ /**
169
+ * A `(pointer: …)` / `(any-pointer: …)` media condition whose value is neither sanctioned tier.
170
+ * `coarse` is the touch tier (`--vx-hit` 44px); `fine` is its standard companion for a hover-only
171
+ * affordance (`(hover: hover) and (pointer: fine)`), so both are legal — anything else is not.
172
+ */
173
+ const CSS_NON_COARSE_POINTER_QUERY =
174
+ /\(\s*(?:any-)?pointer\s*:\s*(?!(?:coarse|fine)\b)[a-z-]+\s*\)/g
175
+
176
+ /**
177
+ * The `@container` names the container-class axis declares — a consumer container carrying any
178
+ * other name has invented its own container-query breakpoint instead of using the framework's.
179
+ * `basalt-card` is the one shipped today (`src/dashboard/section.module.css`);
180
+ * `basalt-form`/`basalt-main`/`basalt-grid` are the declared remaining three, reserved here so a
181
+ * consumer adopting one ahead of the framework does not immediately trip this guard.
182
+ *
183
+ * Two container names already shipped ahead of this table — `basalt-stat-group` and
184
+ * `basalt-widget-grid` (`src/tokens/breakpoint-literals.test.ts`'s own `LEGACY_CONTAINER_LITERALS`
185
+ * names them) — predate the declared table and are deliberately NOT in this set: they surface as
186
+ * `warn` findings against basalt's own source the same way any other kind's incumbent violations do
187
+ * at grace time, rather than being silently grandfathered into a shipped rule's allowlist.
188
+ */
189
+ const DECLARED_CONTAINER_NAMES = new Set([
190
+ 'basalt-card',
191
+ 'basalt-form',
192
+ 'basalt-main',
193
+ 'basalt-grid',
194
+ ])
195
+
196
+ /**
197
+ * `CONTAINER_CLASSES` (`src/tokens/size-classes.ts`) boundaries, duplicated per the `CTL_THEME_TAGS`
198
+ * convention in the oxlint plugin — this module is dependency-free by contract (file header) and
199
+ * must not import `../tokens`. Pinned against the real table by `check-source.test.ts`. Judged only
200
+ * against `basalt-card`: the other three declared names have no shipped numeric table yet. `0` is
201
+ * the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate idiom for
202
+ * "scope this rule to the container, whatever its width" (`widget-header.module.css`'s `cqi` clamp)
203
+ * — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
204
+ */
205
+ const CONTAINER_CLASS_LITERALS_PX = new Set([0, 240, 239.9, 480, 479.9, 800, 799.9])
206
+
207
+ /**
208
+ * `SIZE_CLASSES`' two boundaries (840/1200px) as the em literals `theme.breakpoints`/`shell/**`
209
+ * actually write (`size-classes.ts`'s `toEm`/`sizeClassMaxEm`, 16px root): `52.5em`/`75em` (the
210
+ * `min-width` boundaries) and their `max-width` companions one hundredth of a px short,
211
+ * `52.49375em`/`74.99375em`. The size-class axis names `shell/**` its one CSS home — a width
212
+ * `@media` there against one of these four is the framework's own sanctioned implementation of the
213
+ * axis, not a raw breakpoint a consumer invented.
214
+ */
215
+ const SIZE_CLASS_LITERALS = new Set(['52.5em', '75em', '52.49375em', '74.99375em'])
216
+
217
+ /** The size-class axis's one CSS home is `shell/**`. Any `shell/`-pathed CSS, basalt's own or a
218
+ * consumer's own shell chrome alike — not a name list, because a consumer's shell directory is
219
+ * never one of `SHELL_HOME_NAMES`' three (or four) declared components. */
220
+ const SHELL_CSS_PATH = /(?:^|\/)shell\//
221
+
222
+ function isSanctionedShellMediaQuery(relPath: string, query: string): boolean {
223
+ if (!SHELL_CSS_PATH.test(relPath)) return false
224
+ // `query` is always one whole match of `CSS_WIDTH_MEDIA_QUERY`, which only ever matches a WIDTH
225
+ // condition — keyword (`min-width:`/`max-width:`), comparison (`width >= N` / `N <= width`), or
226
+ // chained (`N <= width <= N`) — so every `px`/`em`/`rem`/`vw`-suffixed literal anywhere in it IS a
227
+ // width boundary, regardless of which of those spellings produced it. Extracting every literal
228
+ // this way (rather than a keyword-only `(?:min|max)-width:` scan) is what lets this understand
229
+ // the comparison/chained spellings too, with no shape-specific regex of its own. All four units
230
+ // are captured (not `em` only) so a `px` width — never a `SIZE_CLASS_LITERALS` member — correctly
231
+ // fails the check below, rather than `.every()` vacuously passing on an empty match.
232
+ const widths = query.match(new RegExp(CSS_WIDTH_NUM, 'g')) ?? []
233
+ return widths.length > 0 && widths.every((w) => SIZE_CLASS_LITERALS.has(w))
234
+ }
235
+
116
236
  /** The largest CSS radius literal treated as a sub-scale micro-corner, in px — below the 4px floor. */
117
237
  const MICRO_RADIUS_CEILING_PX = 3
118
238
  /** Radius values that name a SHAPE rather than a surface corner — a circle and the pill idiom. */
@@ -740,8 +860,9 @@ export type GraceEntry = { since: string; promote: string; why: string }
740
860
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
741
861
  'raw-selection-control': {
742
862
  since: '1.26.0',
743
- promote: '1.31.0',
863
+ promote: '1.32.0',
744
864
  why:
865
+ 'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
745
866
  'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
746
867
  'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
747
868
  'reading in the guard, and the reason this one lands warn rather than error even though its ' +
@@ -757,6 +878,18 @@ export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
757
878
  'makes a rule satisfiable gives nobody a window to act. 1.31.0 is when the 29 are ' +
758
879
  're-measured against that number.',
759
880
  },
881
+ 'raw-media-query': {
882
+ since: '1.31.0',
883
+ promote: '1.32.0',
884
+ why:
885
+ 'new in the wave-11 responsive/touch guards — the CSS-text ' +
886
+ "twin of the oxlint plugin's `basalt/raw-breakpoint`. Reaching CSS is a NEW file type for this " +
887
+ "check, same reasoning as `css-raw-surface`, and basalt's own `src/dashboard/stat-group.module" +
888
+ '.css` / `widget-grid.module.css` already declare `@container` names (`basalt-stat-group`, ' +
889
+ "`basalt-widget-grid`) outside the declared four-name table, plus `shell/**`'s own " +
890
+ "legitimate width `@media` rules — real incumbents in basalt's own tree at ship time, so this " +
891
+ 'ships warn rather than error from the start. Promotes with its AST twin at 1.32.0.',
892
+ },
760
893
  }
761
894
 
762
895
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
@@ -950,6 +1083,10 @@ export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
950
1083
  'visx-tooltip',
951
1084
  'token-layer-boundary',
952
1085
  'no-import-meta-env',
1086
+ // AST-only, same reason as `bound-control-outside-home`: a responsive-object prop, a shell-home
1087
+ // exemption and a hook import all need the import graph and JSX ancestry, neither of which a
1088
+ // 12-line text window has. `raw-media-query` is its CSS-text twin and lives in `GUARD_RULES`.
1089
+ 'raw-breakpoint',
953
1090
  // These three honour `basalt-agent-allow`, never `theme-allow` — but they are still REAL ids, so
954
1091
  // a `theme-allow ai-sdk-major` must parse as a (useless) scoped annotation rather than as prose.
955
1092
  'agent-resume-guard',
@@ -1832,7 +1969,7 @@ type GuardRule = {
1832
1969
  }
1833
1970
 
1834
1971
  /**
1835
- * The closed registry of all 26 guard kinds. The triad test asserts
1972
+ * The closed registry of all 27 guard kinds. The triad test asserts
1836
1973
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
1837
1974
  *
1838
1975
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -2066,6 +2203,14 @@ export const GUARD_RULES = {
2066
2203
  message:
2067
2204
  'Raw font-size literal — route through VX.text.* (numbers) / --vx-text-* (CSS), or the Mantine fz token. Same rule the oxlint plugin enforces as basalt/no-raw-font-size; this is the half a check-theme-only CI can see.',
2068
2205
  },
2206
+ 'raw-media-query': {
2207
+ kind: 'raw-media-query',
2208
+ pattern: CSS_WIDTH_MEDIA_QUERY, // handled inline (three independent shapes); entry keeps the registry complete
2209
+ // CSS-only: a width @media, an @container name/literal or a non-coarse pointer query has no JSX
2210
+ // spelling — the AST-side twin of this law is the oxlint plugin's basalt/raw-breakpoint.
2211
+ appliesTo: (relPath) => relPath.endsWith('.css'),
2212
+ message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')} at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')}; and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
2213
+ },
2069
2214
  } as const satisfies Record<GuardKind, GuardRule>
2070
2215
 
2071
2216
  /**
@@ -2312,6 +2457,41 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2312
2457
  }
2313
2458
  }
2314
2459
 
2460
+ // raw-media-query: three independent CSS-only shapes — a width @media, an @container
2461
+ // name/literal outside the declared table, and a pointer query that is neither coarse nor fine.
2462
+ if (ruleApplies('raw-media-query', relPath)) {
2463
+ for (const m of line.matchAll(CSS_WIDTH_MEDIA_QUERY)) {
2464
+ if (isSanctionedShellMediaQuery(relPath, m[0])) continue
2465
+ push('raw-media-query', i + 1, m[0].trim())
2466
+ }
2467
+ for (const m of line.matchAll(CSS_CONTAINER_QUERY)) {
2468
+ const name = m[1] ?? ''
2469
+ const body = m[2] ?? ''
2470
+ // An UNNAMED `@container (...)` is legal CSS (scopes to the nearest ancestor container
2471
+ // regardless of its name) — only a name that IS present and NOT in the declared table is a
2472
+ // violation; the empty name itself never is.
2473
+ if (name !== '' && !DECLARED_CONTAINER_NAMES.has(name)) {
2474
+ push('raw-media-query', i + 1, m[0].trim())
2475
+ continue
2476
+ }
2477
+ // Literal boundaries are judged against `basalt-card`'s own table (see
2478
+ // `CONTAINER_CLASS_LITERALS_PX`'s doc) — an unnamed query has no OTHER table to judge
2479
+ // against, so it is judged against this one too; the other three declared names stay
2480
+ // unjudged until they ship their own.
2481
+ if (name !== '' && name !== 'basalt-card') continue
2482
+ for (const lit of body.matchAll(CSS_CONTAINER_LITERAL)) {
2483
+ const px = lit[2] === 'em' ? Number(lit[1]) * ROOT_FONT_SIZE_PX : Number(lit[1])
2484
+ if (!CONTAINER_CLASS_LITERALS_PX.has(px)) {
2485
+ push('raw-media-query', i + 1, m[0].trim())
2486
+ break
2487
+ }
2488
+ }
2489
+ }
2490
+ for (const m of line.matchAll(CSS_NON_COARSE_POINTER_QUERY)) {
2491
+ push('raw-media-query', i + 1, m[0].trim())
2492
+ }
2493
+ }
2494
+
2315
2495
  // off-system-surface-var — pattern + gating from GUARD_RULES.
2316
2496
  if (GUARD_RULES['off-system-surface-var'].enabled!(cfg)) {
2317
2497
  for (const m of line.matchAll(GUARD_RULES['off-system-surface-var'].pattern as RegExp)) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Guard types — Mantine-free, dependency-free (zero imports beyond TS types).
3
3
  *
4
- * GuardKind is the closed set of 26 violation kinds the theme guard can emit — `unframed-chart`
4
+ * GuardKind is the closed set of 27 violation kinds the theme guard can emit — `unframed-chart`
5
5
  * retired at the C2 consolidation wave: its only case (a hand-authored `<ChartLegend items={[...]}>`
6
6
  * array literal) is fully subsumed by the oxlint plugin's `basalt/chart-legend-literal`, which also
7
7
  * catches the `.map()`-over-a-non-series form the regex never could. `'unframed-chart'` stays a
@@ -11,7 +11,7 @@
11
11
  * GuardConfig is the per-run configuration that drives checkSource.
12
12
  */
13
13
 
14
- /** The 26 theme-guard violation kinds. */
14
+ /** The 27 theme-guard violation kinds. */
15
15
  export type GuardKind =
16
16
  | 'raw-hex'
17
17
  | 'raw-color-fn'
@@ -39,6 +39,7 @@ export type GuardKind =
39
39
  | 'hidden-inline-style'
40
40
  | 'in-body-page-title'
41
41
  | 'raw-selection-control'
42
+ | 'raw-media-query'
42
43
 
43
44
  /**
44
45
  * How hard a finding lands. `error` fails the build; `warn` reports and passes.
package/src/index.ts CHANGED
@@ -87,6 +87,8 @@ export {
87
87
  type PageAsideSlot,
88
88
  useBreakpoint,
89
89
  type BreakpointName,
90
+ useSizeClass,
91
+ type SizeClass,
90
92
  PageTitle,
91
93
  type PageTitleProps,
92
94
  type PageTitleSlot,
@@ -35,8 +35,12 @@ import type { BuildPaletteOpts } from '../tokens'
35
35
  import { isDefaultDeriveConfig } from '../tokens/derive'
36
36
  import { buildPaletteData } from '../tokens/palette'
37
37
  import { isDev } from '../common/is-dev'
38
+ import { useMediaQuery } from '../common/use-media-query'
38
39
  import { registerColorSchemeSetter } from '../commands/shell-bridge'
40
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
39
41
  import { PageTitle } from '../shell/page-title'
42
+ import { SizeClassHintContext } from '../shell/use-size-class'
43
+ import type { SizeClass } from '../shell/use-size-class'
40
44
 
41
45
  /**
42
46
  * Where an error surfaced — drives consumer routing (a render error vs a global rejection differ).
@@ -108,6 +112,14 @@ export type BasaltProviderProps = {
108
112
  * `ConnectivityProvider`).
109
113
  */
110
114
  connectivity?: Omit<ConnectivityProviderProps, 'children'>
115
+ /**
116
+ * The size class `useSizeClass()` reports on the server and during hydration, before `matchMedia`
117
+ * can answer. Default `'compact'` (mobile-first). An SSR app that knows the device class (UA
118
+ * hint, cookie) passes it so the first frame already matches. An SSR desktop app SHOULD pass it:
119
+ * without it the first paint is `'compact'`, and `PageAside`/`ThreadWorkspace` remount after
120
+ * hydration when the real class lands.
121
+ */
122
+ sizeClassHint?: SizeClass
111
123
  } & Omit<MantineProviderProps, 'children' | 'theme' | 'defaultColorScheme' | 'cssVariablesResolver'>
112
124
 
113
125
  // ── Default error handler ─────────────────────────────────────────────────────────────────────────
@@ -228,6 +240,42 @@ export function composeInjectedCss(
228
240
  return [base, fontsCss, radiusCss, densityCss].filter(Boolean).join('\n')
229
241
  }
230
242
 
243
+ // ── Host attribute ────────────────────────────────────────────────────────────────────────────────
244
+
245
+ /** Mount count backing `useHostAttribute`'s ref-counted removal — see that function's doc. */
246
+ let hostAttributeCount = 0
247
+
248
+ /**
249
+ * `data-basalt-host` on `<html>` — the one place a `display-mode` query is read; CSS keys off it.
250
+ *
251
+ * The layout effect below lands the attribute before the browser paints the CLIENT render — true
252
+ * only for a client-rendered app. An SSR'd document paints its server HTML (with no
253
+ * `data-basalt-host` yet, since `display-mode` is a client-only read) before hydration ever reaches
254
+ * this effect, so a PWA can still flash web chrome across that server-to-hydration gap; this only
255
+ * closes the gap between hydration and the next client paint.
256
+ */
257
+ export function useHostAttribute(): void {
258
+ const isPwa = useMediaQuery('(display-mode: standalone)', false)
259
+
260
+ useIsomorphicLayoutEffect(() => {
261
+ document.documentElement.setAttribute('data-basalt-host', isPwa ? 'pwa' : 'web')
262
+ }, [isPwa])
263
+
264
+ // Ref-counted across every mounted `BasaltProvider` — mirrors `useDuplicateProviderGuard`'s own
265
+ // module-level counter just below. A second provider (a nested route wrapping its own, two trees
266
+ // on one page) unmounting must not rip the attribute out from under a first one still mounted;
267
+ // only the LAST unmount removes it. Deliberately a SEPARATE effect from the one above: sharing one
268
+ // effect keyed on `[isPwa]` would decrement (and possibly zero out) this counter on every
269
+ // `isPwa` change, not only on a genuine unmount.
270
+ useIsomorphicLayoutEffect(() => {
271
+ hostAttributeCount++
272
+ return () => {
273
+ hostAttributeCount--
274
+ if (hostAttributeCount === 0) document.documentElement.removeAttribute('data-basalt-host')
275
+ }
276
+ }, [])
277
+ }
278
+
231
279
  // ── Inner bridge ──────────────────────────────────────────────────────────────────────────────────
232
280
 
233
281
  /**
@@ -355,9 +403,11 @@ export function BasaltProvider({
355
403
  onError,
356
404
  nonce,
357
405
  connectivity,
406
+ sizeClassHint = 'compact',
358
407
  ...rest
359
408
  }: BasaltProviderProps) {
360
409
  useDuplicateProviderGuard()
410
+ useHostAttribute()
361
411
  const errorHandler = onError ?? defaultOnError
362
412
 
363
413
  // `createBasaltTheme(theme)` — plus, when the theme lab's "Apply" switch is on, the delta of its
@@ -386,7 +436,9 @@ export function BasaltProvider({
386
436
  nonce={nonce}
387
437
  onError={errorHandler}
388
438
  >
389
- <ConnectivityProvider {...resolvedConnectivity}>{children}</ConnectivityProvider>
439
+ <SizeClassHintContext.Provider value={sizeClassHint}>
440
+ <ConnectivityProvider {...resolvedConnectivity}>{children}</ConnectivityProvider>
441
+ </SizeClassHintContext.Provider>
390
442
  </BasaltBridge>
391
443
  </BasaltErrorBoundary>
392
444
  </MantineProvider>
@@ -36,7 +36,7 @@
36
36
 
37
37
  /* Below `sm` there is no navbar at all (the bottom bar is the whole nav), so the zone would be a
38
38
  * zero-width box holding a hidden toggle. The breadcrumb owns the phone header's lead. */
39
- @media (max-width: 47.99375em) {
39
+ @media (max-width: 52.49375em) {
40
40
  .zone {
41
41
  display: none;
42
42
  }
@@ -103,7 +103,7 @@
103
103
  * (logo + name) hides and the toggle centres. The zone drops its inset so the toggle's centre lands
104
104
  * on the rail's icon column rather than 13px right of it.
105
105
  */
106
- @media (min-width: 48em) {
106
+ @media (min-width: 52.5em) {
107
107
  .zone[data-collapsed] {
108
108
  padding-inline: 0;
109
109
  }
@@ -130,6 +130,7 @@ export function AppBrand({ brand, collapsed, onToggleCollapse, className, style
130
130
  size={28}
131
131
  visibleFrom="sm"
132
132
  className={classes.ghostIcon}
133
+ data-basalt-hit
133
134
  onClick={onToggleCollapse}
134
135
  aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
135
136
  >
@@ -65,12 +65,10 @@
65
65
  * plus ~66px of mobile bar, ~66px of globals and ~26px of gaps inside a 374px content box, so the
66
66
  * floor is never the binding constraint there.
67
67
  *
68
- * The floor is NOT free above `sm`: on its own it adds its own 96px to that ~192px overflow. The
69
- * `md` label fold in `controls/actions.tsx` (`BarEntry`, same 62em tier) is what pays for it —
70
- * dropping up to `DESKTOP_SECONDARY_MAX` (3) labelled secondaries to `ctl`-tier icon buttons returns
71
- * roughly 180-240px on that same row. The two are one change; neither is correct alone, and
72
- * NEITHER is the real answer, which is measuring the row (a ResizeObserver fold) rather than
73
- * declaring breakpoints for it. This pair is the cheap, declared interim until that exists.
68
+ * Above `sm` the row is measured, not declared: `.lead` stops growing and `.pageBar` takes every
69
+ * remaining pixel (`flex: 1 1 0`), so its width IS the room `PageBar` row 1 folds its secondary
70
+ * actions against (`controls/actions.tsx`, `planBarFold`). `max-width` keeps a long breadcrumb from
71
+ * starving it.
74
72
  */
75
73
  .lead {
76
74
  display: flex;
@@ -111,6 +109,18 @@
111
109
  gap: var(--vx-space-control-gap);
112
110
  }
113
111
 
112
+ @media (min-width: 52.5em) {
113
+ .lead {
114
+ flex: 0 1 auto;
115
+ max-width: 40%;
116
+ }
117
+
118
+ .pageBar {
119
+ flex: 1 1 0;
120
+ min-width: 0;
121
+ }
122
+ }
123
+
114
124
  /*
115
125
  * The shell globals sit at the SAME 32px `ctl` tier as everything else in the row, and they get there
116
126
  * the
@@ -148,10 +158,10 @@
148
158
  * the row's elastic side truncated `Overview / Dashboard` to `O… / D…`, which is two ellipses and a
149
159
  * separator in place of the one word that says where the reader is.
150
160
  *
151
- * `47.99375em` is the `sm` breakpoint in the exact literal form every other basalt CSS module uses —
161
+ * `52.49375em` is the `sm` breakpoint in the exact literal form every other basalt CSS module uses —
152
162
  * a media query cannot resolve a custom property.
153
163
  */
154
- @media (max-width: 47.99375em) {
164
+ @media (max-width: 52.49375em) {
155
165
  .crumbAncestors {
156
166
  display: none;
157
167
  }