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
package/dist/tokens.css CHANGED
@@ -5,7 +5,7 @@
5
5
  --vx-radius-fine: 4px;
6
6
  --vx-radius-floating: 8px;
7
7
  --vx-radius-pill: 9999px;
8
- --vx-space-touch-target: 44px;
8
+ --vx-hit: 24px;
9
9
  --vx-space-row-inset-x: 10px;
10
10
  --vx-space-row-inset-y: 6px;
11
11
  --vx-space-row-line-height: 1.35;
@@ -14,7 +14,6 @@
14
14
  --vx-space-control-height-tag: 1.25rem;
15
15
  --vx-space-control-height-widget: 1.5rem;
16
16
  --vx-space-control-height-ctl: 2rem;
17
- --vx-space-touch-control-height: 2.25rem;
18
17
  --vx-space-stack-xs: 4px;
19
18
  --vx-space-stack-sm: 8px;
20
19
  --vx-space-stack-md: 12px;
@@ -103,7 +102,7 @@
103
102
  --vx-space-mobile-nav-icon-size: 24px;
104
103
  --vx-space-mobile-nav-tab-inset-y: 2px;
105
104
  --vx-space-mobile-nav-tab-inset-x: 12px;
106
- --vx-space-mobile-nav-row-height: 40px;
105
+ --vx-space-mobile-nav-row-height: 44px;
107
106
  --vx-space-agent-rail-inset-x: 10px;
108
107
  --vx-space-agent-part-gap-top: 6px;
109
108
  --vx-space-agent-code-inset: 8px;
@@ -190,12 +189,17 @@
190
189
  --vx-badRef: var(--vx-bad-ref);
191
190
  --vx-warnRef: var(--vx-warn-ref);
192
191
  }
193
- @media (max-width: 47.99375em) {
192
+ @media (max-width: 52.49375em) {
194
193
  :root {
195
194
  --vx-space-card-inset-x: 11px;
196
195
  --vx-space-section-gap: 8px;
197
196
  }
198
197
  }
198
+ @media (pointer: coarse) {
199
+ :root {
200
+ --vx-hit: 44px;
201
+ }
202
+ }
199
203
  :root,
200
204
  html[data-mantine-color-scheme='dark'] {
201
205
  --vx-status-excellent: #43bf4d;
package/llms.txt CHANGED
@@ -1,4 +1,4 @@
1
- # basalt-ui v1.30.2
1
+ # basalt-ui v1.31.0
2
2
  # Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
3
3
  #
4
4
  # basalt-ui is an opinionated framework for Mantine-based React apps.
@@ -19,11 +19,11 @@
19
19
  # ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
20
20
 
21
21
  Package: basalt-ui
22
- Version: 1.30.2
22
+ Version: 1.31.0
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
26
- Description: BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, PageBar, PageAside, useBreakpoint, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, WidgetHeader, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone), ConnectivityProvider/useConnectivity/ConnectivityIndicator (auto-mounted, C1: absorbed from the dropped ./connectivity), createBasaltQueryClient/unwrap/BasaltQueryDevtools (C1: absorbed from the dropped ./query), BasaltDevDock (C5: lazy Router/Query devtools + theme lab in one dock)
26
+ Description: BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, PageBar, PageAside, useSizeClass (useBreakpoint on notice), NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, WidgetHeader, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone), ConnectivityProvider/useConnectivity/ConnectivityIndicator (auto-mounted, C1: absorbed from the dropped ./connectivity), createBasaltQueryClient/unwrap/BasaltQueryDevtools (C1: absorbed from the dropped ./query), BasaltDevDock (C5: lazy Router/Query devtools + theme lab in one dock)
27
27
  Layer: mantine-coupled
28
28
  Rule: basalt-mantine
29
29
  OptionalPeers: react-markdown@^10.1.0, remark-gfm@^4.0.1, rehype-sanitize@^6.0.0, remend@1.3.0, shiki@^4.3.0, beautiful-mermaid@^1.1.0, @tanstack/react-router-devtools@^1.166.13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.30.2",
3
+ "version": "1.31.0",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -162,19 +162,19 @@
162
162
  "clean": "rm -rf dist"
163
163
  },
164
164
  "devDependencies": {
165
- "@arethetypeswrong/cli": "0.18.3",
166
- "@fontsource-variable/hubot-sans": "5.2.8",
167
- "@fontsource-variable/jetbrains-mono": "5.2.8",
168
- "@fontsource-variable/nunito-sans": "5.2.7",
165
+ "@arethetypeswrong/cli": "0.18.5",
166
+ "@fontsource-variable/hubot-sans": "5.3.0",
167
+ "@fontsource-variable/jetbrains-mono": "5.3.0",
168
+ "@fontsource-variable/nunito-sans": "5.3.0",
169
169
  "@mantine/dates": "9.3.0",
170
- "@shikijs/langs": "^4.3.1",
171
- "@shikijs/themes": "^4.3.1",
170
+ "@shikijs/langs": "^4.4.3",
171
+ "@shikijs/themes": "^4.4.3",
172
172
  "@tanstack/react-hotkeys": "0.10.0",
173
- "@tanstack/react-router-devtools": "^1.166.13",
174
- "@types/bun": "^1.3.14",
175
- "@types/hast": "^3.0.4",
176
- "@types/react": "^19.2.17",
177
- "@types/react-dom": "^19.2.3",
173
+ "@tanstack/react-router-devtools": "^1.167.2",
174
+ "@types/bun": "^1.4.2",
175
+ "@types/hast": "^3.0.5",
176
+ "@types/react": "^19.3.0",
177
+ "@types/react-dom": "^19.3.0",
178
178
  "@visx/axis": "4.0.0",
179
179
  "@visx/curve": "4.0.0",
180
180
  "@visx/event": "4.0.0",
@@ -184,19 +184,19 @@
184
184
  "@visx/scale": "4.0.0",
185
185
  "@visx/shape": "4.0.0",
186
186
  "@visx/threshold": "4.0.0",
187
- "ai": "^7.0.15",
187
+ "ai": "^7.0.107",
188
188
  "beautiful-mermaid": "^1.1.3",
189
189
  "motion": "12.42.0",
190
- "publint": "0.3.21",
190
+ "publint": "0.3.24",
191
191
  "react-markdown": "^10.1.0",
192
192
  "rehype-sanitize": "6.0.0",
193
193
  "remark-gfm": "^4.0.1",
194
- "remend": "1.3.0",
195
- "shiki": "^4.3.1",
194
+ "remend": "1.3.1",
195
+ "shiki": "^4.4.3",
196
196
  "tsup": "^8.5.1",
197
197
  "typescript": "^6.0.3",
198
198
  "use-stick-to-bottom": "^1.1.6",
199
- "vite": "^8.0.16",
199
+ "vite": "^8.3.0",
200
200
  "vite-plugin-pwa": "^1.3.0"
201
201
  },
202
202
  "peerDependencies": {
@@ -36,13 +36,14 @@
36
36
  * onSubmit={({ text, attachments }) => send(text, attachments)}
37
37
  * />
38
38
  */
39
- import { ActionIcon, Group, Kbd, Stack, Text, Textarea } from '@mantine/core'
39
+ import { ActionIcon, Box, Group, Kbd, Stack, Text, Textarea } from '@mantine/core'
40
40
  import type { ClipboardEvent, ComponentProps, JSX, KeyboardEvent, ReactNode, Ref } from 'react'
41
41
  import { useImperativeHandle, useLayoutEffect, useRef, useSyncExternalStore } from 'react'
42
42
  import { cx } from '../common/props'
43
43
  import type { BasaltProps, SlotStylesProps } from '../common/props'
44
44
  import { readPersistedValue } from '../state'
45
45
  import { VX } from '../tokens'
46
+ import { useKeyboardInsetRef } from './use-keyboard-inset'
46
47
 
47
48
  /** A minimal, dependency-free send-arrow glyph (icons are passed in as ReactNode elsewhere; this
48
49
  * one is inline since Composer has no icon prop in its contract). */
@@ -202,6 +203,14 @@ export type ComposerSubmit = {
202
203
  /** Stable empty list — keeps the submit payload's `attachments` referentially constant. */
203
204
  const NO_ATTACHMENTS: readonly ComposerAttachment[] = []
204
205
 
206
+ /**
207
+ * The gap between the textarea/actions row and the hint row, inside the inner `Stack` the render
208
+ * below owns exclusively — the keyboard-inset spacer is deliberately NOT one of that Stack's flex
209
+ * children (see the render's own comment), so this value never has to be cancelled back out of
210
+ * anything the spacer contributes.
211
+ */
212
+ const ROOT_GAP = 6
213
+
205
214
  /**
206
215
  * Imperative escape hatch for slot content that must write into the composer from OUTSIDE the
207
216
  * render tree — a voice recorder streaming transcript chunks, a suggestion chip inserting at the
@@ -471,6 +480,13 @@ export function Composer({
471
480
 
472
481
  useImperativeHandle(ref, () => ({ insertText, setValue, focus: focusTextarea }))
473
482
 
483
+ // Pins the composer above a coarse-pointer on-screen keyboard — see use-keyboard-inset.ts. The
484
+ // published custom property defaults to 0px via CSS, so a Composer with no `visualViewport`
485
+ // support (SSR, an unsupporting engine) pays for exactly one no-op custom property on a trailing
486
+ // spacer (see the render below) rather than an inline `paddingBottom` that could ever beat a
487
+ // consumer's own `className`/`classNames.root` padding.
488
+ const keyboardInsetRef = useKeyboardInsetRef()
489
+
474
490
  const submit = (): void => {
475
491
  if (inputDisabled || !hasPayload) return
476
492
  const submittedText = trimmed
@@ -572,51 +588,62 @@ export function Composer({
572
588
  const showActions = showStop || showSend
573
589
 
574
590
  return (
575
- <Stack
576
- gap={6}
577
- className={cx(classNames?.root, className)}
578
- {...(style !== undefined && { style })}
579
- >
580
- <Group gap="xs" align="flex-end" wrap="nowrap">
581
- {leftSection}
582
- <Textarea {...textareaProps} ref={textareaRef} />
583
- {rightSection}
584
- {showActions && (
585
- <Group gap="xs" wrap="nowrap" className={cx(classNames?.actions)}>
586
- {showStop ? (
587
- <ActionIcon
588
- size={42}
589
- radius="md"
590
- variant="filled"
591
- color="red"
592
- onClick={onStop}
593
- aria-label="Stop generating"
594
- >
595
- <StopGlyph />
596
- </ActionIcon>
597
- ) : null}
598
- {showSend ? (
599
- <ActionIcon
600
- size={42}
601
- radius="md"
602
- variant="filled"
603
- onClick={submit}
604
- disabled={inputDisabled || !hasPayload}
605
- aria-label="Send message"
606
- // Send action (docs/DESIGN-SPEC.md §5): the one accent-filled control. It needs no
607
- // color override — `filled` resolves through the theme to `--vx-accent-fill` /
608
- // `--vx-on-accent`. (It used to hand-wire those two vars inline, which is why this
609
- // was the ONLY filled control that stayed legible while the rest of the chrome went
610
- // through Mantine's scheme-blind autoContrast. The theme owns it now, and hover
611
- // works again — an inline style can't express a `:hover` state.)
612
- >
613
- <SendGlyph />
614
- </ActionIcon>
615
- ) : null}
616
- </Group>
617
- )}
618
- </Group>
619
- {hint === undefined ? <DefaultHint /> : hint}
620
- </Stack>
591
+ <Box ref={keyboardInsetRef} className={cx(classNames?.root, className)} style={style}>
592
+ <Stack gap={ROOT_GAP}>
593
+ <Group gap="xs" align="flex-end" wrap="nowrap">
594
+ {leftSection}
595
+ <Textarea {...textareaProps} ref={textareaRef} />
596
+ {rightSection}
597
+ {showActions && (
598
+ <Group gap="xs" wrap="nowrap" className={cx(classNames?.actions)}>
599
+ {showStop ? (
600
+ <ActionIcon
601
+ size={42}
602
+ radius="md"
603
+ variant="filled"
604
+ color="red"
605
+ onClick={onStop}
606
+ aria-label="Stop generating"
607
+ >
608
+ <StopGlyph />
609
+ </ActionIcon>
610
+ ) : null}
611
+ {showSend ? (
612
+ <ActionIcon
613
+ size={42}
614
+ radius="md"
615
+ variant="filled"
616
+ onClick={submit}
617
+ disabled={inputDisabled || !hasPayload}
618
+ aria-label="Send message"
619
+ // Send action (docs/DESIGN-SPEC.md §5): the one accent-filled control. It needs
620
+ // no color override — `filled` resolves through the theme to `--vx-accent-fill`
621
+ // / `--vx-on-accent`. (It used to hand-wire those two vars inline, which is why
622
+ // this was the ONLY filled control that stayed legible while the rest of the
623
+ // chrome went through Mantine's scheme-blind autoContrast. The theme owns it
624
+ // now, and hover works again — an inline style can't express a `:hover` state.)
625
+ >
626
+ <SendGlyph />
627
+ </ActionIcon>
628
+ ) : null}
629
+ </Group>
630
+ )}
631
+ </Group>
632
+ {hint === undefined ? <DefaultHint /> : hint}
633
+ </Stack>
634
+ {/* Pins the composer above a coarse-pointer on-screen keyboard by growing THIS spacer, not
635
+ by padding the root — so a consumer's own `className`/`classNames.root` padding-bottom is
636
+ never overridden (see use-keyboard-inset.ts). It is a SIBLING of the gapped Stack above,
637
+ not one of its flex children, precisely so it never has to cancel that Stack's `gap`
638
+ back out: a consumer's own `style` (which lands on this outer, non-flex root, not on the
639
+ inner Stack) can carry an unrelated `gap` without ever touching what this spacer
640
+ contributes. A closed keyboard (the var's `0px` fallback) then really is 0 extra height,
641
+ regardless of anything the caller's `style`/`className` does to the root. (This used to
642
+ be the Stack's own trailing child, cancelled via a negative `margin-top` reading
643
+ `--stack-gap` back — exact against Stack's OWN `gap` prop, but blind to a `gap` a
644
+ consumer added on top through `style`, which changes the rendered gap without moving that
645
+ var.) */}
646
+ <Box aria-hidden style={{ height: 'var(--vx-keyboard-inset, 0px)' }} />
647
+ </Box>
621
648
  )
622
649
  }
@@ -4,7 +4,7 @@
4
4
  * Composes `ThreadFeed` + a new-thread `Composer` (left/main pane) with `ThreadDetailPanel`
5
5
  * (right pane) over `useAgentThreadRuns`, so a consumer wires one component instead of hand-
6
6
  * assembling the pieces. Responsive: wide viewports show both panes side by side; narrow
7
- * viewports (`<= 768px`) show exactly one — the feed when no thread is open, a full-screen
7
+ * viewports (the `compact` size class, < 840px) show exactly one — the feed when no thread is open, a full-screen
8
8
  * detail panel once one is.
9
9
  *
10
10
  * `useThreads` is a prop, not called internally, because `createThreadsStore` must be invoked
@@ -32,14 +32,14 @@
32
32
  * )
33
33
  * }
34
34
  */
35
- import { Box, Divider, Flex, Skeleton, Stack, Text, useMantineTheme } from '@mantine/core'
36
- import { useMediaQuery } from '@mantine/hooks'
35
+ import { Box, Divider, Flex, Skeleton, Stack, Text } from '@mantine/core'
37
36
  import type { JSX, ReactNode } from 'react'
38
37
  import type { AgentPart, AgentTransport, OutcomeResolver, ThreadsStore } from '../agent'
39
38
  import { useAgentThreadRuns } from '../agent'
40
39
  import { cx } from '../common/props'
41
40
  import type { BasaltProps, SlotStylesProps } from '../common/props'
42
41
  import { assertRequiredProps } from '../common/validate'
42
+ import { useSizeClass } from '../shell/use-size-class'
43
43
  import { VX } from '../tokens'
44
44
  import { Composer } from './composer'
45
45
  import { ThreadDetailPanel } from './thread-detail-panel'
@@ -53,7 +53,7 @@ const FULL_HEIGHT_STYLE = { height: '100%' } as const
53
53
 
54
54
  /** The four boxes `ThreadWorkspace` paints: `root` is the outermost pane split, `feed` is the
55
55
  * thread-list pane, `composer` is the new-thread composer pinned under the feed, `detail` is the
56
- * right-hand (or, below 768px, full-screen) open-thread pane. */
56
+ * right-hand (or, in the `compact` size class, full-screen) open-thread pane. */
57
57
  export type ThreadWorkspaceSlot = 'root' | 'feed' | 'composer' | 'detail'
58
58
 
59
59
  export type ThreadWorkspaceProps = BasaltProps &
@@ -146,7 +146,7 @@ function FeedErrorState(): JSX.Element {
146
146
  /**
147
147
  * A two-pane master-detail thread workspace: a scrollable feed of threads with an anchored
148
148
  * new-thread composer, and a detail panel for the open thread's live transcript. Collapses to a
149
- * single pane below 768px.
149
+ * single pane in the `compact` size class (< 840px).
150
150
  *
151
151
  * @example
152
152
  * <ThreadWorkspace useThreads={useThreads} transport={transport} resolveOutcome={heuristicOutcome} />
@@ -165,12 +165,9 @@ export function ThreadWorkspace({
165
165
  assertRequiredProps('ThreadWorkspace', { useThreads }, ['useThreads'])
166
166
  const store = useThreads()
167
167
  const runs = useAgentThreadRuns({ transport, store, resolveOutcome })
168
- // Matches `shell/page-aside.tsx`'s own `theme.breakpoints.sm` desktop check — read off the theme,
169
- // not hardcoded, so a consumer that retunes `breakpoints.sm` moves the aside and this workspace
170
- // together. `max-width` is the min-width complement: `sm − 0.00625em` (the same 0.1px offset
171
- // Mantine's own CSS breakpoints use), so the two checks never straddle the same pixel.
172
- const theme = useMantineTheme()
173
- const narrow = useMediaQuery(`(max-width: calc(${theme.breakpoints.sm} - 0.00625em))`)
168
+ // Narrow = the compact size class — the same viewport axis as the shell, and SSR/first-frame
169
+ // correct (the provider's `sizeClassHint` is the server snapshot, no post-mount flip).
170
+ const narrow = useSizeClass() === 'compact'
174
171
 
175
172
  const threads = store.threads
176
173
  const activeThread = threads.find((thread) => thread.id === store.activeId) ?? null
@@ -0,0 +1,68 @@
1
+ /**
2
+ * `useKeyboardInsetRef` — publishes the live on-screen-keyboard inset as `--vx-keyboard-inset` on
3
+ * a ref'd root element, so `Composer` can stay pinned above a coarse-pointer on-screen keyboard
4
+ * without the consumer hand-rolling a `dvh` clamp (`docs/MATURATION-LEDGER.md`, the wave 10
5
+ * agent-chat finding). Internal — not exported from the package.
6
+ *
7
+ * Reads `window.visualViewport`, guarded exactly like `useSizeClass`'s `matchMedia` guard
8
+ * (`shell/use-size-class.ts`): absent in SSR and in an engine with no `visualViewport` support
9
+ * (older Safari, and happy-dom/jsdom in tests), where the property is simply never published and
10
+ * CSS's `var(--vx-keyboard-inset, 0px)` fallback carries the zero.
11
+ *
12
+ * The inset is `document.documentElement.clientHeight - visualViewport.height -
13
+ * visualViewport.offsetTop`, floored at 0 — the slice of the LAYOUT viewport's bottom edge the
14
+ * visual viewport no longer covers. `clientHeight` (not `window.innerHeight`) is the layout
15
+ * viewport's height on every engine that also implements `visualViewport`. Both `resize` (fires
16
+ * when the on-screen keyboard opens/closes and shrinks `visualViewport.height`) and `scroll`
17
+ * (fires when the visual viewport's origin moves with no size change — iOS Safari scrolling the
18
+ * page to keep a focused field in view) are subscribed, because either can move the inset on its
19
+ * own.
20
+ *
21
+ * The state-node + layout-effect shape mirrors `shell/page-bar.tsx`'s `useMeasuredHeightVar`: a
22
+ * callback ref stored in state (so the effect re-runs once the node actually mounts) rather than a
23
+ * plain `RefObject` (which is not itself a dependency).
24
+ *
25
+ * Returns the callback ref only. It intentionally does NOT track the live inset (open/closed) —
26
+ * that stays an imperative, no-re-render style write, which is the entire point of publishing it
27
+ * outside React state.
28
+ */
29
+ import { useCallback, useState } from 'react'
30
+ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
31
+
32
+ const KEYBOARD_INSET_VAR = '--vx-keyboard-inset'
33
+
34
+ export function useKeyboardInsetRef(): (el: HTMLElement | null) => void {
35
+ const [node, setNode] = useState<HTMLElement | null>(null)
36
+
37
+ const attach = useCallback((el: HTMLElement | null) => {
38
+ setNode(el)
39
+ }, [])
40
+
41
+ useIsomorphicLayoutEffect(() => {
42
+ if (node === null) return
43
+ if (typeof window === 'undefined') return
44
+ const viewport = window.visualViewport
45
+ // `undefined`, not the typed `null`, is what an engine with no support actually returns
46
+ // (confirmed: happy-dom — this package's test DOM — has no `visualViewport` property at all).
47
+ if (viewport === null || viewport === undefined) return
48
+
49
+ const publish = (): void => {
50
+ const inset = Math.max(
51
+ 0,
52
+ document.documentElement.clientHeight - viewport.height - viewport.offsetTop,
53
+ )
54
+ node.style.setProperty(KEYBOARD_INSET_VAR, `${inset}px`)
55
+ }
56
+
57
+ publish()
58
+ viewport.addEventListener('resize', publish)
59
+ viewport.addEventListener('scroll', publish)
60
+ return () => {
61
+ viewport.removeEventListener('resize', publish)
62
+ viewport.removeEventListener('scroll', publish)
63
+ node.style.removeProperty(KEYBOARD_INSET_VAR)
64
+ }
65
+ }, [node])
66
+
67
+ return attach
68
+ }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Two small pieces shared by every touch-pinned surface in the chart layer — extracted so
3
+ * `useChartCursor`/`useDiscreteCursor`/`ChartLegend`'s disclosure cannot drift apart on the same
4
+ * contract (round 2's carried-forward gap: each had its own hand-rolled copy).
5
+ */
6
+ import { useCallback, useEffect, useMemo, useRef } from 'react'
7
+
8
+ /**
9
+ * Outside-tap / Escape / scroll (optionally resize) dismissal for a pinned/open floating element.
10
+ * Capture-phase `pointerdown` so a descendant that stops propagation on its own handler can't leave
11
+ * the dismissal stuck.
12
+ */
13
+ export function useDismissOnOutside({
14
+ enabled,
15
+ isInside,
16
+ onDismiss,
17
+ dismissOnResize = false,
18
+ scrollExemptInside = false,
19
+ }: {
20
+ enabled: boolean
21
+ /** A tap (or, with `scrollExemptInside`, a scroll) landing where this returns true doesn't
22
+ * dismiss. Omit to treat every tap as outside. */
23
+ isInside?: (target: Node) => boolean
24
+ onDismiss: () => void
25
+ /** Also dismiss on a window `resize` — `ChartLegend`'s disclosure needs this, the touch-pinned
26
+ * cursors don't (their host doesn't reflow independently of the chart it's pinned to). */
27
+ dismissOnResize?: boolean
28
+ /** Exempts a scroll whose target is `isInside` — `ChartLegend`'s own scrollable panel needs this
29
+ * (`overflowY: auto`), the cursor hooks don't (a chart has no internal scroller to protect). */
30
+ scrollExemptInside?: boolean
31
+ }): void {
32
+ useEffect(() => {
33
+ if (!enabled) return
34
+ const onPointerDown = (event: PointerEvent) => {
35
+ if (event.target instanceof Node && isInside?.(event.target) === true) return
36
+ onDismiss()
37
+ }
38
+ const onKeyDown = (event: KeyboardEvent) => {
39
+ if (event.key === 'Escape') onDismiss()
40
+ }
41
+ const onScroll = (event: Event) => {
42
+ if (scrollExemptInside && event.target instanceof Node && isInside?.(event.target) === true) {
43
+ return
44
+ }
45
+ onDismiss()
46
+ }
47
+ document.addEventListener('pointerdown', onPointerDown, true)
48
+ document.addEventListener('keydown', onKeyDown)
49
+ document.addEventListener('scroll', onScroll, true)
50
+ if (dismissOnResize) window.addEventListener('resize', onDismiss)
51
+ return () => {
52
+ document.removeEventListener('pointerdown', onPointerDown, true)
53
+ document.removeEventListener('keydown', onKeyDown)
54
+ document.removeEventListener('scroll', onScroll, true)
55
+ if (dismissOnResize) window.removeEventListener('resize', onDismiss)
56
+ }
57
+ }, [enabled, isInside, onDismiss, dismissOnResize, scrollExemptInside])
58
+ }
59
+
60
+ export type TouchPin = {
61
+ /** `onPointerDown` for a coarse press: snapshots whatever is currently committed (via
62
+ * `getCommitted`) so a later `cancel` can restore exactly that, then tracks `pointerId`. */
63
+ begin: (pointerId: number) => void
64
+ /** `onPointerUp` for the tracked pointer: the press is now committed — nothing left to revert. */
65
+ commit: (pointerId: number) => boolean
66
+ /** `onPointerCancel`/`onPointerLeave` for the tracked pointer: the browser took the gesture (a
67
+ * scroll) before the press committed — restores the pre-press value via `setCommitted`, or
68
+ * leaves it clear when nothing was committed before. Returns whether this pointer was tracked. */
69
+ cancel: (pointerId: number) => boolean
70
+ /** Hard reset — no restore, just drop any in-flight press bookkeeping (an explicit `clear()`, e.g.
71
+ * Escape or an outside tap, is about to overwrite the value directly anyway). */
72
+ reset: () => void
73
+ }
74
+
75
+ /**
76
+ * Provisional-press state machine shared by `useChartCursor`/`useDiscreteCursor`: a coarse press
77
+ * shows a value immediately (PROVISIONAL, not yet committed) — `commit` on `pointerup` leaves it in
78
+ * place, `cancel` on `pointercancel`/`pointerleave` (a scroll winning the gesture) restores whatever
79
+ * was committed before this press started, rather than dropping it. This is what keeps a previously
80
+ * COMMITTED pin alive through a second tap that gets cancelled instead of completed.
81
+ */
82
+ export function useTouchPin<V>({
83
+ getCommitted,
84
+ setCommitted,
85
+ }: {
86
+ /** Reads the value currently committed — snapshotted on `begin`, restored on `cancel`. */
87
+ getCommitted: () => V
88
+ setCommitted: (value: V) => void
89
+ }): TouchPin {
90
+ const pressRef = useRef<number | null>(null)
91
+ const backupRef = useRef<V | undefined>(undefined)
92
+ // Whether `backupRef` holds a real snapshot — `backupRef.current !== undefined` used to stand in
93
+ // for this, which silently dropped a genuinely committed `undefined` (a caller's `V` can legally
94
+ // include it): `cancel` would restore nothing at all instead of restoring "nothing was pinned".
95
+ const hasBackupRef = useRef(false)
96
+ const getCommittedRef = useRef(getCommitted)
97
+ getCommittedRef.current = getCommitted
98
+ const setCommittedRef = useRef(setCommitted)
99
+ setCommittedRef.current = setCommitted
100
+
101
+ const begin = useCallback((pointerId: number) => {
102
+ backupRef.current = getCommittedRef.current()
103
+ hasBackupRef.current = true
104
+ pressRef.current = pointerId
105
+ }, [])
106
+
107
+ const commit = useCallback((pointerId: number): boolean => {
108
+ if (pressRef.current !== pointerId) return false
109
+ pressRef.current = null
110
+ backupRef.current = undefined
111
+ hasBackupRef.current = false
112
+ return true
113
+ }, [])
114
+
115
+ const cancel = useCallback((pointerId: number): boolean => {
116
+ if (pressRef.current !== pointerId) return false
117
+ pressRef.current = null
118
+ const backup = backupRef.current
119
+ const hasBackup = hasBackupRef.current
120
+ backupRef.current = undefined
121
+ hasBackupRef.current = false
122
+ if (hasBackup) setCommittedRef.current(backup as V)
123
+ return true
124
+ }, [])
125
+
126
+ const reset = useCallback(() => {
127
+ pressRef.current = null
128
+ backupRef.current = undefined
129
+ hasBackupRef.current = false
130
+ }, [])
131
+
132
+ // Stable identity: `begin`/`commit`/`cancel`/`reset` are themselves already stable (empty-dep
133
+ // `useCallback`s), but the returned OBJECT wrapping them was a fresh literal every render — a
134
+ // consumer's own effect depending on the whole `TouchPin` (or a `useDismissOnOutside` closing
135
+ // over one of these) would re-subscribe on every render for no reason. Memoizing on the four
136
+ // (stable) functions costs nothing and makes the wrapper's identity match theirs.
137
+ return useMemo(() => ({ begin, commit, cancel, reset }), [begin, commit, cancel, reset])
138
+ }