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
@@ -0,0 +1,88 @@
1
+ import { useRef } from 'react'
2
+ import type { MutableRefObject } from 'react'
3
+ import { useIsomorphicLayoutEffect } from './isomorphic-layout-effect'
4
+
5
+ /** One measured element's box, cached while it was still visible (a folded child is gone from the DOM). */
6
+ export type MeasuredBox = { width: number; height: number }
7
+
8
+ export interface UseMeasuredWidthsOptions {
9
+ /** The box to observe, resolved on each effect run. */
10
+ resolveRoot: () => HTMLElement | null
11
+ /** Change this string to drop every cached box — a folded child cannot report its unfolded width. */
12
+ signature: string
13
+ /** Skip measuring entirely (the feature is off). Default true. */
14
+ enabled?: boolean
15
+ /** Bump to re-measure on demand beyond a resize (a post-reset commit). */
16
+ nonce?: number
17
+ /** Extra descendants to observe besides the root (an inner table whose min-content can move). */
18
+ extra?: (root: HTMLElement) => (Element | null)[]
19
+ /** Read the current boxes from `root`, fill the cache, and plan — runs on every measure. */
20
+ onMeasure: (root: HTMLElement, boxes: MutableRefObject<Map<string, MeasuredBox>>) => void
21
+ }
22
+
23
+ export interface UseMeasuredWidthsResult {
24
+ boxes: MutableRefObject<Map<string, MeasuredBox>>
25
+ /** Re-run the measurement by hand (a web font settling changes every width without a resize). */
26
+ remeasure: () => void
27
+ }
28
+
29
+ /**
30
+ * The measured-fold core every fold planner shares: caches each measured box while it is visible,
31
+ * re-reads on a `ResizeObserver` of the root (plus {@link UseMeasuredWidthsOptions.extra}), and
32
+ * clears the cache when the {@link UseMeasuredWidthsOptions.signature} changes. The caller keeps its
33
+ * own element reading and pure plan function in `onMeasure`.
34
+ *
35
+ * A folded child is gone from the DOM but its cached box survives, so a resize never needs it back
36
+ * on screen; the caller's `onMeasure` decides which boxes to cache (e.g. skip an item that is
37
+ * currently folded, whose painted width is the collapsed one).
38
+ */
39
+ export function useMeasuredWidths(options: UseMeasuredWidthsOptions): UseMeasuredWidthsResult {
40
+ const { resolveRoot, signature, enabled = true, nonce = 0, extra, onMeasure } = options
41
+ const boxes = useRef<Map<string, MeasuredBox>>(new Map())
42
+ const signatureRef = useRef(signature)
43
+ const remeasureRef = useRef<() => void>(() => {})
44
+
45
+ // Read through refs so a fresh closure each render does not re-run the effect.
46
+ const resolveRef = useRef(resolveRoot)
47
+ resolveRef.current = resolveRoot
48
+ const extraRef = useRef(extra)
49
+ extraRef.current = extra
50
+ const onMeasureRef = useRef(onMeasure)
51
+ onMeasureRef.current = onMeasure
52
+
53
+ useIsomorphicLayoutEffect(() => {
54
+ if (signatureRef.current !== signature) {
55
+ signatureRef.current = signature
56
+ boxes.current.clear()
57
+ }
58
+ if (!enabled) {
59
+ // A disabled/detached root leaves no `measure` to re-run — without this a stale closure over
60
+ // the PREVIOUS root's `onMeasure` survives on `remeasureRef` and a later `remeasure()` call
61
+ // (the web-font effect in `controls/actions.tsx`) would measure a root that is no longer live.
62
+ remeasureRef.current = () => {}
63
+ return
64
+ }
65
+ const root = resolveRef.current()
66
+ if (root === null) {
67
+ remeasureRef.current = () => {}
68
+ return
69
+ }
70
+ const measure = (): void => {
71
+ onMeasureRef.current(root, boxes)
72
+ }
73
+ remeasureRef.current = measure
74
+ if (typeof ResizeObserver === 'undefined') {
75
+ measure()
76
+ return
77
+ }
78
+ const observer = new ResizeObserver(measure)
79
+ observer.observe(root)
80
+ for (const el of extraRef.current?.(root) ?? []) {
81
+ if (el !== null) observer.observe(el)
82
+ }
83
+ measure()
84
+ return () => observer.disconnect()
85
+ }, [enabled, signature, nonce])
86
+
87
+ return { boxes, remeasure: () => remeasureRef.current() }
88
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `useMediaQuery` — the one sanctioned raw `window.matchMedia` read
3
+ * (`basalt/raw-breakpoint`'s `RAW_BREAKPOINT_EXEMPT_FILE`). Every other breakpoint/pointer-tier
4
+ * hook (`useSizeClass`, `useCoarsePointer`, the shell's own min-width dock check) is built on this
5
+ * one primitive instead of allocating its own `MediaQueryList`.
6
+ *
7
+ * `useSyncExternalStore`, so SSR and the hydration pass read `serverFallback` and the real match
8
+ * lands one commit later — never a mismatch mid-hydration. One `MediaQueryList` PER QUERY STRING,
9
+ * shared module-wide: `useSizeClass`'s two boundaries and a chart's coarse-pointer read resolve
10
+ * through the same handful of live objects rather than each allocating their own.
11
+ */
12
+ import { useCallback, useSyncExternalStore } from 'react'
13
+
14
+ // Keyed by the LIVE `window.matchMedia` function, not just the query string: a test that swaps in
15
+ // its own stub (a real pattern in this repo — `provider/host.test.tsx`) gets a fresh inner cache
16
+ // for free, rather than reading a `MediaQueryList` a previous test's stub produced.
17
+ const cache = new WeakMap<typeof window.matchMedia, Map<string, MediaQueryList>>()
18
+
19
+ function mediaQueryList(query: string): MediaQueryList | null {
20
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return null
21
+ let byQuery = cache.get(window.matchMedia)
22
+ if (byQuery === undefined) {
23
+ byQuery = new Map()
24
+ cache.set(window.matchMedia, byQuery)
25
+ }
26
+ const cached = byQuery.get(query)
27
+ if (cached !== undefined) return cached
28
+ const list = window.matchMedia(query)
29
+ byQuery.set(query, list)
30
+ return list
31
+ }
32
+
33
+ /** A one-off, non-reactive read — for a dev-only check that runs once inside an effect/callback
34
+ * rather than subscribing for the component's lifetime. Prefer `useMediaQuery` for anything that
35
+ * should re-render on a match change. */
36
+ export function readMediaQuery(query: string, fallback: boolean): boolean {
37
+ return mediaQueryList(query)?.matches ?? fallback
38
+ }
39
+
40
+ export function useMediaQuery(query: string, serverFallback: boolean): boolean {
41
+ const subscribe = useCallback(
42
+ (onStoreChange: () => void) => {
43
+ const list = mediaQueryList(query)
44
+ if (list === null) return () => {}
45
+ list.addEventListener('change', onStoreChange)
46
+ return () => list.removeEventListener('change', onStoreChange)
47
+ },
48
+ [query],
49
+ )
50
+ const getSnapshot = useCallback(
51
+ () => mediaQueryList(query)?.matches ?? serverFallback,
52
+ [query, serverFallback],
53
+ )
54
+ const getServerSnapshot = useCallback(() => serverFallback, [serverFallback])
55
+ return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
56
+ }
@@ -51,16 +51,29 @@ export function useValidateProps(
51
51
  const messages = Array.isArray(result) ? result : [result]
52
52
  for (const raw of messages) {
53
53
  if (raw === null || raw === undefined) continue
54
- const key = `${component} ${raw}`
55
- if (reported.has(key)) continue
56
- reported.add(key)
57
- console.error(raw)
54
+ reportOnce(component, raw)
58
55
  }
59
56
  // The deps ARE the caller's contract: this hook cannot know what `check` closes over.
60
57
  // oxlint-disable-next-line react-hooks/exhaustive-deps -- the caller owns the dep list
61
58
  }, deps)
62
59
  }
63
60
 
61
+ /**
62
+ * The same once-per-`(component, message)` dev diagnostic {@link useValidateProps} logs, for a
63
+ * caller that already owns the effect a check would otherwise race — a decision made synchronously
64
+ * inside a `useLayoutEffect` (e.g. `ChartFrame`'s header-slot claim, P2-9) has no business waiting a
65
+ * further render for a REACTIVE check on state that effect itself just set: that state update lags
66
+ * one commit behind the decision, and a passive-effect check racing it warns about instances that
67
+ * are about to win in the very next render.
68
+ */
69
+ export function reportOnce(component: string, message: string): void {
70
+ if (!isDev()) return
71
+ const key = `${component} ${message}`
72
+ if (reported.has(key)) return
73
+ reported.add(key)
74
+ console.error(message)
75
+ }
76
+
64
77
  /** Test seam. The once-per-message Set is module state, so a suite has to be able to clear it. */
65
78
  export function resetValidatedProps(): void {
66
79
  reported.clear()
@@ -17,12 +17,19 @@
17
17
  grid-template-columns: minmax(0, var(--vx-prose-measure));
18
18
  }
19
19
 
20
- /* The literal below MUST equal `BREAKPOINTS.article` (src/tokens/index.ts) — a `@media`
20
+ /* The literal below MUST equal `BREAKPOINTS.article - 0.1` (src/tokens/index.ts) — a `@media`
21
21
  * condition is resolved before custom properties exist, so `max-width: var(--x)` is invalid CSS
22
- * and a stylesheet with a responsive rule has no choice but to write the number. The token is the
23
- * readable half; `article-layout.breakpoint.test.ts` asserts the two agree, the same
22
+ * and a stylesheet with a responsive rule has no choice but to write the number. The 0.1px offset
23
+ * is `tokens/size-classes.ts`' own `sizeClassMaxEm` convention (Mantine's own): the JS side
24
+ * (`article-layout.tsx`'s `useSizeClass() === 'expanded'`) reads a plain `min-width: 1200px`,
25
+ * inclusive AT 1200 — writing this query as a bare `max-width: 1200px` would ALSO be inclusive at
26
+ * exactly 1200px, so at that one width both conditions would fire at once: JS renders the rail,
27
+ * and this rule then hides it with `display: none`, leaving neither the rail nor the trigger
28
+ * visible. `1199.9px` makes the two conditions strict complements with no such gap. The token is
29
+ * the readable half; `article-layout.breakpoint.test.ts` asserts the two agree, the same
24
30
  * "literal must match the token" idiom as `styles.floor.test.ts`. Change both or neither. */
25
- @media (max-width: 1200px) {
31
+ /* theme-allow raw-media-query — deliberate literal pinned to BREAKPOINTS.article, outside shell/** */
32
+ @media (max-width: 1199.9px) {
26
33
  .root {
27
34
  grid-template-columns: minmax(0, var(--vx-prose-measure));
28
35
  }
@@ -34,6 +41,7 @@
34
41
 
35
42
  .header {
36
43
  grid-column: 1;
44
+ grid-row: 1;
37
45
  display: flex;
38
46
  flex-direction: column;
39
47
  gap: var(--vx-space-article-header-gap);
@@ -71,11 +79,13 @@
71
79
 
72
80
  .content {
73
81
  grid-column: 1;
82
+ grid-row: 2;
74
83
  min-width: 0;
75
84
  }
76
85
 
77
86
  .tocRail {
78
87
  grid-column: 2;
88
+ grid-row: 2;
79
89
  position: sticky;
80
90
  /* The rail sticks inside whatever scrolls it — `AppShell.Main` in a shell, the document without
81
91
  * one — and NOTHING overhangs that box: the app header and `PageBar` row 2's band are both shell
@@ -87,8 +97,39 @@
87
97
  align-self: start;
88
98
  }
89
99
 
100
+ /* The rail's below-breakpoint replacement (docs/CONTENT-SPEC.md §7): a small sticky trigger that
101
+ * opens the SAME `TableOfContents` in a `Popover` instead of the rail vanishing. Rendered by JS
102
+ * (`useSizeClass() !== 'expanded'`), never both at once with `.tocRail` — no media query needed
103
+ * here, only the sticky/alignment behaviour for whenever it IS mounted. `grid-row: 2` is EXPLICIT,
104
+ * not left to auto-placement: `.content` also being `grid-column: 1` moves the auto-placement
105
+ * cursor's row on past row 2 by the time this item (also column 1) is placed, landing it in its
106
+ * own row BELOW the content instead of at its top — the whole point of this trigger. */
107
+ .tocTrigger {
108
+ grid-column: 1;
109
+ grid-row: 2;
110
+ justify-self: end;
111
+ align-self: start;
112
+ position: sticky;
113
+ top: var(--vx-space-sticky-header-clearance);
114
+ z-index: 1;
115
+ }
116
+
117
+ .tocTriggerLabel {
118
+ font-family: var(--basalt-font-mono);
119
+ font-size: var(--vx-text-micro);
120
+ letter-spacing: 0.06em;
121
+ text-transform: uppercase;
122
+ color: var(--vx-faint);
123
+ }
124
+
125
+ .tocDropdown {
126
+ min-width: 220px;
127
+ max-width: min(320px, calc(100vw - 32px));
128
+ }
129
+
90
130
  .footer {
91
131
  grid-column: 1;
132
+ grid-row: 3;
92
133
  display: grid;
93
134
  grid-template-columns: 1fr 1fr;
94
135
  gap: var(--vx-space-article-footer-gap);
@@ -11,6 +11,9 @@ declare const classes: {
11
11
  readonly metaRow: string
12
12
  readonly content: string
13
13
  readonly tocRail: string
14
+ readonly tocTrigger: string
15
+ readonly tocTriggerLabel: string
16
+ readonly tocDropdown: string
14
17
  readonly footer: string
15
18
  readonly navCell: string
16
19
  readonly navCellNext: string
@@ -5,8 +5,10 @@
5
5
  * `ReadingProgress`) — it does not reimplement them.
6
6
  *
7
7
  * The header/content/footer all align to the SAME grid column, so the article body reads as one
8
- * consistent width whether or not the TOC rail is visible (hidden below the 1200px breakpoint,
9
- * where the content column takes the full centered width instead).
8
+ * consistent width whether or not the TOC rail is visible. Below the 1200px breakpoint the rail
9
+ * is replaced, not dropped: a sticky "On this page" trigger opens the same `TableOfContents` in a
10
+ * `Popover` (docs/CONTENT-SPEC.md §7's originally-documented behavior, `useSizeClass() !==
11
+ * 'expanded'` — the same numeric threshold, resolved in JS instead of `@media`).
10
12
  *
11
13
  * @example
12
14
  * import { ArticleLayout } from 'basalt-ui/content'
@@ -23,8 +25,9 @@
23
25
  * </ArticleLayout>
24
26
  */
25
27
  import type { CSSProperties, ReactNode } from 'react'
26
- import { useRef } from 'react'
27
- import { ScrollArea } from '@mantine/core'
28
+ import { useRef, useState } from 'react'
29
+ import { Button, Popover, ScrollArea } from '@mantine/core'
30
+ import { useSizeClass } from '../shell/use-size-class'
28
31
  import classes from './article-layout.module.css'
29
32
  import { formatArticleDate } from './article-model'
30
33
  import type { ProseDensity } from './prose'
@@ -115,6 +118,22 @@ export function ArticleLayout({
115
118
  const articleRef = useRef<HTMLDivElement>(null)
116
119
  const rootClass = [classes.root, !toc && classes.noToc, className].filter(Boolean).join(' ')
117
120
  const hasMetaRow = meta?.date !== undefined || meta?.readingTime !== undefined
121
+ // `useSizeClass()` is the JS-side twin of the `@media (max-width: 1200px)` rule below — both read
122
+ // `BREAKPOINTS.article`/`SIZE_CLASSES.expanded`, the same 1200 number (`article-layout.module.css`'s
123
+ // own comment). Below it the rail is replaced by a trigger, never both at once — a CSS-only swap
124
+ // has no hook to open a `Popover` from, so this is one of the JS reads that breakpoint doctrine
125
+ // reserves `useSizeClass()` for, not a second raw media query.
126
+ const showTocRail = useSizeClass() === 'expanded'
127
+ const [tocOpen, setTocOpen] = useState(false)
128
+ // Resizing across the breakpoint unmounts the `Popover` without closing it — `tocOpen` lives on
129
+ // THIS component, not the Popover, so crossing back below the breakpoint would otherwise remount
130
+ // it already open with no user action. Adjusted during render (React's own pattern for resetting
131
+ // state when a derived value changes), not an effect, so there is no open-then-close flash.
132
+ const prevShowTocRailRef = useRef(showTocRail)
133
+ if (prevShowTocRailRef.current !== showTocRail) {
134
+ prevShowTocRailRef.current = showTocRail
135
+ if (tocOpen) setTocOpen(false)
136
+ }
118
137
 
119
138
  return (
120
139
  <div className={rootClass} {...(style !== undefined && { style })}>
@@ -140,7 +159,7 @@ export function ArticleLayout({
140
159
  <Prose density={density}>{children}</Prose>
141
160
  </div>
142
161
 
143
- {toc && (
162
+ {toc && showTocRail && (
144
163
  <div className={classes.tocRail}>
145
164
  <ScrollArea.Autosize mah="calc(100vh - 108px)" type="hover">
146
165
  <TableOfContents containerRef={articleRef} />
@@ -148,6 +167,42 @@ export function ArticleLayout({
148
167
  </div>
149
168
  )}
150
169
 
170
+ {toc &&
171
+ !showTocRail && (
172
+ // `.tocTrigger`'s `grid-row: 2` (article-layout.module.css) is EXPLICIT, matching
173
+ // `.content`'s own row — not left to CSS Grid's auto-placement, which used to land this
174
+ // in its own row below the content instead of at its top (both are `grid-column: 1`, and
175
+ // the auto-placement cursor moves past row 2 once `.content` claims it).
176
+ <Popover
177
+ opened={tocOpen}
178
+ onChange={setTocOpen}
179
+ onDismiss={() => setTocOpen(false)}
180
+ position="bottom-end"
181
+ withinPortal
182
+ shadow="md"
183
+ radius="sm"
184
+ trapFocus
185
+ >
186
+ <Popover.Target>
187
+ <Button
188
+ variant="subtle"
189
+ color="gray"
190
+ size="compact-xs"
191
+ className={classes.tocTrigger}
192
+ classNames={{ label: classes.tocTriggerLabel }}
193
+ onClick={() => setTocOpen((current) => !current)}
194
+ >
195
+ On this page
196
+ </Button>
197
+ </Popover.Target>
198
+ <Popover.Dropdown className={classes.tocDropdown}>
199
+ <ScrollArea.Autosize mah="calc(100vh - 108px)" type="hover">
200
+ <TableOfContents containerRef={articleRef} onNavigate={() => setTocOpen(false)} />
201
+ </ScrollArea.Autosize>
202
+ </Popover.Dropdown>
203
+ </Popover>
204
+ )}
205
+
151
206
  {(prev !== undefined || next !== undefined) && (
152
207
  <footer className={classes.footer}>
153
208
  {prev !== undefined ? (
@@ -56,6 +56,27 @@
56
56
  overflow-x: auto;
57
57
  }
58
58
 
59
+ /* Trailing-edge overflow fade (docs/MATURATION-LEDGER.md) — `code-block.tsx` toggles this
60
+ * attribute from a scroll/resize measurement (`scrollWidth > clientWidth`, not yet at the end), so
61
+ * it never decorates a block that already fits and never lingers once scrolled to the end. The mask
62
+ * fades to TRANSPARENT, not to a solid color — what shows through is `.container`'s own translucent
63
+ * background, already identical to what borders the block everywhere else, so the fade reads as a
64
+ * soft edge rather than a hard cut. Reuses the content inset token rather than a new literal; LTR
65
+ * only, matching the rest of this file (no `dir` handling exists here to fade the other edge from).
66
+ */
67
+ .body[data-code-overflow] {
68
+ mask-image: linear-gradient(
69
+ to right,
70
+ black calc(100% - var(--vx-space-code-block-content-inset-x) * 2),
71
+ transparent 100%
72
+ );
73
+ -webkit-mask-image: linear-gradient(
74
+ to right,
75
+ black calc(100% - var(--vx-space-code-block-content-inset-x) * 2),
76
+ transparent 100%
77
+ );
78
+ }
79
+
59
80
  .body :global(pre.shiki) {
60
81
  margin: 0;
61
82
  padding: 0;
@@ -77,11 +98,20 @@ html[data-mantine-color-scheme='light'] .body :global(.shiki span) {
77
98
  position: absolute;
78
99
  top: var(--vx-space-code-block-floating-copy-offset);
79
100
  right: var(--vx-space-code-block-floating-copy-offset);
80
- opacity: 0;
81
101
  transition: opacity 120ms ease;
82
102
  }
83
103
 
84
- .container:hover .floatingCopy,
104
+ /* Hidden-until-hover only where hover exists (pointer tier, docs/DESIGN-CORE.md § Layout). */
105
+ @media (hover: hover) and (pointer: fine) {
106
+ .floatingCopy {
107
+ opacity: 0;
108
+ }
109
+
110
+ .container:hover .floatingCopy {
111
+ opacity: 1;
112
+ }
113
+ }
114
+
85
115
  .container:focus-within .floatingCopy {
86
116
  opacity: 1;
87
117
  }
@@ -13,7 +13,8 @@
13
13
  * <CodeBlock language="bash" code="bun add basalt-ui" showCopy />
14
14
  */
15
15
  import type { CSSProperties } from 'react'
16
- import { useEffect, useState } from 'react'
16
+ import { useEffect, useRef, useState } from 'react'
17
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
17
18
  import classes from './code-block.module.css'
18
19
  import { CopyAction } from './copy-action'
19
20
  import { highlightCode } from './highlighter'
@@ -39,6 +40,10 @@ export function CodeBlock({
39
40
  // Intentionally NOT reset on every code/language change — keeping the last-good html while a
40
41
  // re-highlight is in flight avoids a flash back to plain mono on every keystroke of streamed code.
41
42
  const [html, setHtml] = useState<string | null>(null)
43
+ const bodyRef = useRef<HTMLElement | null>(null)
44
+ const setBodyRef = (node: HTMLElement | null): void => {
45
+ bodyRef.current = node
46
+ }
42
47
 
43
48
  useEffect(() => {
44
49
  if (language === undefined) return
@@ -52,6 +57,36 @@ export function CodeBlock({
52
57
  }
53
58
  }, [code, language])
54
59
 
60
+ // Trailing-edge overflow fade (docs/MATURATION-LEDGER.md): visible only while `.body` is
61
+ // actually wider than its box AND not yet scrolled to the end — never a permanent decoration on a
62
+ // block that already fits, never a fade implying more content once the reader reaches it. LTR
63
+ // only (no `dir` handling exists anywhere else in this file); a future RTL pass would fade the
64
+ // LEADING edge instead. `[data-code-overflow]` (not React state driving the mask itself) is the
65
+ // toggle the CSS module reads, so the fade never re-renders the block — only this one attribute.
66
+ const [showOverflowFade, setShowOverflowFade] = useState(false)
67
+
68
+ // Layout effect, not a plain effect: measuring after paint would let a code block that's
69
+ // already too wide on mount flash one frame with no fade before this fires. Isomorphic so an SSR
70
+ // render never trips React's "useLayoutEffect does nothing on the server" warning.
71
+ useIsomorphicLayoutEffect(() => {
72
+ const body = bodyRef.current
73
+ if (!body) return
74
+
75
+ const update = (): void => {
76
+ const atEnd = body.scrollLeft + body.clientWidth >= body.scrollWidth - 1
77
+ setShowOverflowFade(body.scrollWidth > body.clientWidth && !atEnd)
78
+ }
79
+ update()
80
+
81
+ body.addEventListener('scroll', update, { passive: true })
82
+ const observer = new ResizeObserver(update)
83
+ observer.observe(body)
84
+ return () => {
85
+ body.removeEventListener('scroll', update)
86
+ observer.disconnect()
87
+ }
88
+ }, [html, code])
89
+
55
90
  const containerClass = [classes.container, className].filter(Boolean).join(' ')
56
91
 
57
92
  return (
@@ -66,9 +101,18 @@ export function CodeBlock({
66
101
  </div>
67
102
  )}
68
103
  {html !== null ? (
69
- <div className={classes.body} dangerouslySetInnerHTML={{ __html: html }} />
104
+ <div
105
+ ref={setBodyRef}
106
+ className={classes.body}
107
+ {...(showOverflowFade && { 'data-code-overflow': true })}
108
+ dangerouslySetInnerHTML={{ __html: html }}
109
+ />
70
110
  ) : (
71
- <pre className={classes.body}>
111
+ <pre
112
+ ref={setBodyRef}
113
+ className={classes.body}
114
+ {...(showOverflowFade && { 'data-code-overflow': true })}
115
+ >
72
116
  <code>{code}</code>
73
117
  </pre>
74
118
  )}
@@ -149,17 +149,22 @@
149
149
  display: inline-flex;
150
150
  vertical-align: middle;
151
151
  margin-left: var(--vx-space-prose-heading-anchor-gap);
152
- opacity: 0;
153
152
  transition: opacity 120ms ease;
154
153
  }
155
154
 
156
- .root h1:hover .headingAnchor,
157
- .root h2:hover .headingAnchor,
158
- .root h3:hover .headingAnchor,
159
- .root h4:hover .headingAnchor,
160
- .root h5:hover .headingAnchor,
161
- .root h6:hover .headingAnchor,
162
- .headingAnchor:focus-within {
155
+ /* Hidden-until-hover only where hover exists (pointer tier, docs/DESIGN-CORE.md § Layout,
156
+ elevation, shapes); touch shows it. Focus always reveals (the rule below outranks the hidden one). */
157
+ @media (hover: hover) and (pointer: fine) {
158
+ .root :is(h1, h2, h3, h4, h5, h6) .headingAnchor {
159
+ opacity: 0;
160
+ }
161
+
162
+ .root :is(h1, h2, h3, h4, h5, h6):hover .headingAnchor {
163
+ opacity: 1;
164
+ }
165
+ }
166
+
167
+ .root :is(h1, h2, h3, h4, h5, h6) .headingAnchor:focus-within {
163
168
  opacity: 1;
164
169
  }
165
170
 
@@ -24,7 +24,7 @@
24
24
  padding: 0 var(--vx-space-row-inset-x);
25
25
  }
26
26
 
27
- @media (max-width: 47.99375em) {
27
+ @media (pointer: coarse) {
28
28
  .menuItem,
29
29
  .customRow {
30
30
  min-height: var(--vx-space-sheet-row-height);
@@ -60,3 +60,16 @@
60
60
 
61
61
  color: var(--vx-faint);
62
62
  }
63
+
64
+ /*
65
+ * The `Box` wrapping a joined `ControlGroup` run (`BAR_KEY_ATTR`, one level up from the group
66
+ * itself) so `useMeasuredFold` can measure the whole run as a single box. Without `flex: none` this
67
+ * wrapper is an ORDINARY flex item of the desktop row (`flex: 0 1 auto`, Mantine `Box`'s default) —
68
+ * exactly the shrink target the row's own fold arithmetic squeezes first, so its `offsetWidth` read
69
+ * back a compressed width instead of the run's real max-content width. `ControlGroup` itself
70
+ * already carries this on its own root (`.group`, `controls.module.css`) — this wrapper needs the
71
+ * same property one box further out.
72
+ */
73
+ .joinBox {
74
+ flex: none;
75
+ }
@@ -9,5 +9,6 @@ declare const classes: {
9
9
  readonly customSlot: string
10
10
  readonly kebabIcon: string
11
11
  readonly chevronIcon: string
12
+ readonly joinBox: string
12
13
  }
13
14
  export default classes