basalt-ui 1.30.2 → 1.32.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 (357) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +54 -0
  3. package/MIGRATING.md +256 -12
  4. package/agent/rules/basalt-controls.md +19 -19
  5. package/agent/rules/basalt-mantine.md +5 -5
  6. package/configs/oxlint-plugin.js +371 -4
  7. package/configs/oxlint-plugin.test.ts +327 -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 +47 -82
  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 +138 -145
  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 +68 -111
  47. package/dist/charts/kinds/band-chrome.d.ts +31 -0
  48. package/dist/charts/kinds/band-chrome.d.ts.map +1 -0
  49. package/dist/charts/kinds/band-chrome.js +71 -0
  50. package/dist/charts/kinds/donut-layout.d.ts +47 -0
  51. package/dist/charts/kinds/donut-layout.d.ts.map +1 -0
  52. package/dist/charts/kinds/donut-layout.js +52 -0
  53. package/dist/charts/layout/auto-margin.d.ts +7 -2
  54. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  55. package/dist/charts/layout/auto-margin.js +12 -3
  56. package/dist/charts/layout/end-labels.d.ts +64 -0
  57. package/dist/charts/layout/end-labels.d.ts.map +1 -0
  58. package/dist/charts/layout/end-labels.js +76 -0
  59. package/dist/charts/primitives/Axes.d.ts +32 -4
  60. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  61. package/dist/charts/primitives/Axes.js +62 -16
  62. package/dist/charts/primitives/CartesianChart.d.ts +7 -6
  63. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  64. package/dist/charts/primitives/CartesianChart.js +264 -145
  65. package/dist/charts/primitives/ChartCard.d.ts +0 -14
  66. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  67. package/dist/charts/primitives/ChartCard.js +56 -21
  68. package/dist/charts/primitives/ChartFrame.d.ts +28 -22
  69. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  70. package/dist/charts/primitives/ChartFrame.js +116 -34
  71. package/dist/charts/primitives/ChartLegend.d.ts +19 -15
  72. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  73. package/dist/charts/primitives/ChartLegend.js +86 -73
  74. package/dist/charts/primitives/ChartTooltip.d.ts +5 -0
  75. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  76. package/dist/charts/primitives/ChartTooltip.js +20 -4
  77. package/dist/charts/primitives/Crosshair.js +2 -2
  78. package/dist/charts/primitives/HoverOverlay.d.ts +29 -9
  79. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  80. package/dist/charts/primitives/HoverOverlay.js +54 -27
  81. package/dist/charts/primitives/HoverOverlay.module.css +13 -0
  82. package/dist/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  83. package/dist/charts/primitives/chart-card-context.d.ts +33 -0
  84. package/dist/charts/primitives/chart-card-context.d.ts.map +1 -0
  85. package/dist/charts/primitives/chart-card-context.js +18 -0
  86. package/dist/charts/primitives/chart-frame-layout.d.ts +93 -113
  87. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  88. package/dist/charts/primitives/chart-frame-layout.js +75 -34
  89. package/dist/charts/primitives/chart-layout.d.ts +112 -0
  90. package/dist/charts/primitives/chart-layout.d.ts.map +1 -0
  91. package/dist/charts/primitives/chart-layout.js +175 -0
  92. package/dist/charts/primitives/chart-tier.d.ts +13 -11
  93. package/dist/charts/primitives/chart-tier.d.ts.map +1 -1
  94. package/dist/charts/primitives/chart-tier.js +42 -13
  95. package/dist/charts/primitives/legend-disclosure.d.ts +27 -0
  96. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -0
  97. package/dist/charts/primitives/legend-disclosure.js +153 -0
  98. package/dist/charts/series.d.ts +4 -1
  99. package/dist/charts/series.d.ts.map +1 -1
  100. package/dist/charts/utils/ticks.d.ts +27 -8
  101. package/dist/charts/utils/ticks.d.ts.map +1 -1
  102. package/dist/charts/utils/ticks.js +35 -11
  103. package/dist/common/errors.d.ts +11 -1
  104. package/dist/common/errors.d.ts.map +1 -1
  105. package/dist/common/errors.js +16 -2
  106. package/dist/common/hit-floor.d.ts +10 -0
  107. package/dist/common/hit-floor.d.ts.map +1 -0
  108. package/dist/common/hit-floor.js +6 -0
  109. package/dist/{shell → common}/isomorphic-layout-effect.d.ts +4 -3
  110. package/dist/common/isomorphic-layout-effect.d.ts.map +1 -0
  111. package/dist/common/use-measured-widths.d.ts +37 -0
  112. package/dist/common/use-measured-widths.d.ts.map +1 -0
  113. package/dist/common/use-measured-widths.js +51 -0
  114. package/dist/common/use-media-query.d.ts +6 -0
  115. package/dist/common/use-media-query.d.ts.map +1 -0
  116. package/dist/common/use-media-query.js +40 -0
  117. package/dist/common/validate.d.ts +9 -0
  118. package/dist/common/validate.d.ts.map +1 -1
  119. package/dist/common/validate.js +9 -4
  120. package/dist/content/article-layout.d.ts +4 -2
  121. package/dist/content/article-layout.d.ts.map +1 -1
  122. package/dist/content/article-layout.js +43 -3
  123. package/dist/content/article-layout.module.css +45 -4
  124. package/dist/content/article-layout.module.css.d.ts +3 -0
  125. package/dist/content/code-block.d.ts.map +1 -1
  126. package/dist/content/code-block.js +40 -2
  127. package/dist/content/code-block.module.css +32 -2
  128. package/dist/content/prose.module.css +13 -8
  129. package/dist/controls/actions.d.ts +29 -6
  130. package/dist/controls/actions.d.ts.map +1 -1
  131. package/dist/controls/actions.js +158 -27
  132. package/dist/controls/actions.module.css +14 -1
  133. package/dist/controls/actions.module.css.d.ts +1 -0
  134. package/dist/controls/controls.module.css +13 -31
  135. package/dist/controls/enum-filter.js +2 -2
  136. package/dist/controls/filter-context.d.ts +10 -0
  137. package/dist/controls/filter-context.d.ts.map +1 -1
  138. package/dist/controls/filter-context.js +2 -0
  139. package/dist/controls/filter-pill.d.ts.map +1 -1
  140. package/dist/controls/filter-pill.js +1 -0
  141. package/dist/controls/index.d.ts +1 -1
  142. package/dist/controls/index.d.ts.map +1 -1
  143. package/dist/controls/index.js +0 -2
  144. package/dist/controls/multi-select-filter.js +2 -2
  145. package/dist/controls/sync-button.module.css +2 -1
  146. package/dist/dashboard/section.d.ts.map +1 -1
  147. package/dist/dashboard/section.js +1 -0
  148. package/dist/dashboard/section.module.css +1 -26
  149. package/dist/dashboard/stat-card.d.ts +1 -1
  150. package/dist/dashboard/stat-card.d.ts.map +1 -1
  151. package/dist/dashboard/stat-card.js +4 -1
  152. package/dist/dashboard/stat-card.module.css +6 -9
  153. package/dist/dashboard/stat-group.module.css +33 -66
  154. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  155. package/dist/dashboard/widget-grid.module.css +11 -38
  156. package/dist/dashboard/widget-header.d.ts +14 -4
  157. package/dist/dashboard/widget-header.d.ts.map +1 -1
  158. package/dist/dashboard/widget-header.js +23 -6
  159. package/dist/dashboard/widget-header.module.css +145 -133
  160. package/dist/dashboard/widget-header.module.css.d.ts +2 -0
  161. package/dist/data/data-table-state.d.ts +83 -0
  162. package/dist/data/data-table-state.d.ts.map +1 -0
  163. package/dist/data/data-table-state.js +139 -0
  164. package/dist/data/data-table.d.ts +56 -2
  165. package/dist/data/data-table.d.ts.map +1 -1
  166. package/dist/data/data-table.js +436 -336
  167. package/dist/data/data-table.module.css +94 -0
  168. package/dist/data/data-table.module.css.d.ts +11 -0
  169. package/dist/data/virtual-list.d.ts.map +1 -1
  170. package/dist/data/virtual-list.js +10 -0
  171. package/dist/forms/form-layout.d.ts.map +1 -1
  172. package/dist/forms/form-layout.js +3 -3
  173. package/dist/forms/form-layout.module.css +20 -5
  174. package/dist/forms/form-layout.module.css.d.ts +1 -0
  175. package/dist/guard/index.d.ts +8 -2
  176. package/dist/guard/index.d.ts.map +1 -1
  177. package/dist/guard/index.js +68 -2
  178. package/dist/guard/types.d.ts +3 -3
  179. package/dist/guard/types.d.ts.map +1 -1
  180. package/dist/index.d.ts +1 -1
  181. package/dist/index.d.ts.map +1 -1
  182. package/dist/index.js +3 -1
  183. package/dist/notifications/center.d.ts.map +1 -1
  184. package/dist/provider/index.d.ts +20 -1
  185. package/dist/provider/index.d.ts.map +1 -1
  186. package/dist/provider/index.js +30 -8
  187. package/dist/shell/app-brand.d.ts.map +1 -1
  188. package/dist/shell/app-brand.js +1 -0
  189. package/dist/shell/app-brand.module.css +2 -2
  190. package/dist/shell/app-header.module.css +18 -8
  191. package/dist/shell/app-main.module.css +54 -3
  192. package/dist/shell/app-main.module.css.d.ts +1 -0
  193. package/dist/shell/app-mobile-nav.module.css +16 -4
  194. package/dist/shell/app-sidebar.module.css +4 -6
  195. package/dist/shell/aside-docking.d.ts +31 -0
  196. package/dist/shell/aside-docking.d.ts.map +1 -0
  197. package/dist/shell/aside-docking.js +19 -0
  198. package/dist/shell/index.d.ts +1 -0
  199. package/dist/shell/index.d.ts.map +1 -1
  200. package/dist/shell/index.js +56 -16
  201. package/dist/shell/mobile-nav-model.d.ts +1 -1
  202. package/dist/shell/page-aside.d.ts +22 -3
  203. package/dist/shell/page-aside.d.ts.map +1 -1
  204. package/dist/shell/page-aside.js +27 -13
  205. package/dist/shell/page-bar.d.ts.map +1 -1
  206. package/dist/shell/page-bar.js +10 -5
  207. package/dist/shell/page-bar.module.css +10 -34
  208. package/dist/shell/use-breakpoint.d.ts +3 -4
  209. package/dist/shell/use-breakpoint.d.ts.map +1 -1
  210. package/dist/shell/use-breakpoint.js +3 -25
  211. package/dist/shell/use-size-class.d.ts +6 -0
  212. package/dist/shell/use-size-class.d.ts.map +1 -0
  213. package/dist/shell/use-size-class.js +19 -0
  214. package/dist/state/persisted-or-local.d.ts +4 -1
  215. package/dist/state/persisted-or-local.d.ts.map +1 -1
  216. package/dist/state/persisted-or-local.js +4 -3
  217. package/dist/state/persisted.d.ts +10 -0
  218. package/dist/state/persisted.d.ts.map +1 -1
  219. package/dist/state/persisted.js +44 -14
  220. package/dist/styles.css +41 -0
  221. package/dist/surfaces.d.ts +4 -4
  222. package/dist/surfaces.d.ts.map +1 -1
  223. package/dist/surfaces.js +11 -3
  224. package/dist/theme/controls.module.css +7 -0
  225. package/dist/theme/ctl-theme.d.ts +1 -0
  226. package/dist/theme/ctl-theme.d.ts.map +1 -1
  227. package/dist/theme/ctl-theme.js +12 -4
  228. package/dist/theme/floating.module.css +37 -0
  229. package/dist/theme/floating.module.css.d.ts +3 -0
  230. package/dist/theme/index.d.ts +2 -2
  231. package/dist/theme/index.d.ts.map +1 -1
  232. package/dist/theme/index.js +34 -5
  233. package/dist/theme/nav-link.module.css +7 -0
  234. package/dist/theme-lab/derive-controls.d.ts.map +1 -1
  235. package/dist/tokens/index.d.ts +7 -16
  236. package/dist/tokens/index.d.ts.map +1 -1
  237. package/dist/tokens/index.js +23 -28
  238. package/dist/tokens/palette.d.ts +5 -27
  239. package/dist/tokens/palette.d.ts.map +1 -1
  240. package/dist/tokens/palette.js +8 -30
  241. package/dist/tokens/size-classes.d.ts +40 -0
  242. package/dist/tokens/size-classes.d.ts.map +1 -0
  243. package/dist/tokens/size-classes.js +29 -0
  244. package/dist/tokens.css +8 -4
  245. package/llms.txt +3 -3
  246. package/package.json +18 -17
  247. package/src/agent-chat/composer.tsx +74 -47
  248. package/src/agent-chat/thread-workspace.tsx +8 -11
  249. package/src/agent-chat/use-keyboard-inset.ts +68 -0
  250. package/src/charts/cursor/touch-pin.ts +138 -0
  251. package/src/charts/hooks/use-discrete-cursor.ts +335 -0
  252. package/src/charts/hooks/use-hysteresis.ts +26 -0
  253. package/src/charts/hooks/useBandPlot.ts +90 -27
  254. package/src/charts/hooks/useChartCursor.ts +211 -26
  255. package/src/charts/index.ts +0 -8
  256. package/src/charts/kinds/BandStrip.tsx +25 -61
  257. package/src/charts/kinds/Donut.tsx +153 -62
  258. package/src/charts/kinds/DualPanel.tsx +21 -26
  259. package/src/charts/kinds/Heatmap.tsx +61 -22
  260. package/src/charts/kinds/MirroredBars.tsx +23 -65
  261. package/src/charts/kinds/band-chrome.tsx +91 -0
  262. package/src/charts/kinds/donut-layout.ts +82 -0
  263. package/src/charts/layout/auto-margin.ts +24 -5
  264. package/src/charts/layout/end-labels.ts +135 -0
  265. package/src/charts/primitives/Axes.tsx +112 -21
  266. package/src/charts/primitives/CartesianChart.tsx +282 -111
  267. package/src/charts/primitives/ChartCard.tsx +119 -48
  268. package/src/charts/primitives/ChartFrame.tsx +204 -60
  269. package/src/charts/primitives/ChartLegend.tsx +115 -78
  270. package/src/charts/primitives/ChartTooltip.tsx +24 -3
  271. package/src/charts/primitives/Crosshair.tsx +2 -2
  272. package/src/charts/primitives/HoverOverlay.module.css +13 -0
  273. package/src/charts/primitives/HoverOverlay.module.css.d.ts +8 -0
  274. package/src/charts/primitives/HoverOverlay.tsx +62 -20
  275. package/src/charts/primitives/chart-card-context.ts +46 -0
  276. package/src/charts/primitives/chart-frame-layout.ts +207 -182
  277. package/src/charts/primitives/chart-layout.ts +318 -0
  278. package/src/charts/primitives/chart-tier.tsx +66 -21
  279. package/src/charts/primitives/legend-disclosure.tsx +177 -0
  280. package/src/charts/series.ts +4 -1
  281. package/src/charts/utils/ticks.ts +95 -19
  282. package/src/common/errors.ts +25 -1
  283. package/src/common/hit-floor.ts +9 -0
  284. package/src/{shell → common}/isomorphic-layout-effect.ts +4 -3
  285. package/src/common/use-measured-widths.ts +88 -0
  286. package/src/common/use-media-query.ts +56 -0
  287. package/src/common/validate.ts +17 -4
  288. package/src/content/article-layout.module.css +45 -4
  289. package/src/content/article-layout.module.css.d.ts +3 -0
  290. package/src/content/article-layout.tsx +60 -5
  291. package/src/content/code-block.module.css +32 -2
  292. package/src/content/code-block.tsx +47 -3
  293. package/src/content/prose.module.css +13 -8
  294. package/src/controls/actions.module.css +14 -1
  295. package/src/controls/actions.module.css.d.ts +1 -0
  296. package/src/controls/actions.tsx +223 -75
  297. package/src/controls/controls.module.css +13 -31
  298. package/src/controls/enum-filter.tsx +2 -2
  299. package/src/controls/filter-context.tsx +11 -0
  300. package/src/controls/filter-pill.tsx +1 -0
  301. package/src/controls/index.ts +0 -1
  302. package/src/controls/multi-select-filter.tsx +2 -2
  303. package/src/controls/sync-button.module.css +2 -1
  304. package/src/dashboard/section.module.css +1 -26
  305. package/src/dashboard/section.tsx +1 -0
  306. package/src/dashboard/stat-card.module.css +6 -9
  307. package/src/dashboard/stat-card.tsx +6 -3
  308. package/src/dashboard/stat-group.module.css +33 -66
  309. package/src/dashboard/widget-grid.module.css +11 -38
  310. package/src/dashboard/widget-grid.tsx +7 -6
  311. package/src/dashboard/widget-header.module.css +145 -133
  312. package/src/dashboard/widget-header.module.css.d.ts +2 -0
  313. package/src/dashboard/widget-header.tsx +45 -11
  314. package/src/data/data-table-state.ts +218 -0
  315. package/src/data/data-table.module.css +94 -0
  316. package/src/data/data-table.module.css.d.ts +11 -0
  317. package/src/data/data-table.tsx +547 -299
  318. package/src/data/virtual-list.tsx +31 -0
  319. package/src/forms/form-layout.module.css +20 -5
  320. package/src/forms/form-layout.module.css.d.ts +1 -0
  321. package/src/forms/form-layout.tsx +12 -10
  322. package/src/guard/index.ts +188 -3
  323. package/src/guard/types.ts +3 -2
  324. package/src/index.ts +2 -0
  325. package/src/notifications/center.tsx +3 -0
  326. package/src/provider/index.tsx +60 -7
  327. package/src/shell/app-brand.module.css +2 -2
  328. package/src/shell/app-brand.tsx +1 -0
  329. package/src/shell/app-header.module.css +18 -8
  330. package/src/shell/app-main.module.css +54 -3
  331. package/src/shell/app-main.module.css.d.ts +1 -0
  332. package/src/shell/app-mobile-nav.module.css +16 -4
  333. package/src/shell/app-sidebar.module.css +4 -6
  334. package/src/shell/aside-docking.ts +54 -0
  335. package/src/shell/index.tsx +178 -129
  336. package/src/shell/mobile-nav-model.ts +1 -1
  337. package/src/shell/page-aside.tsx +70 -21
  338. package/src/shell/page-bar.module.css +10 -34
  339. package/src/shell/page-bar.tsx +10 -5
  340. package/src/shell/use-breakpoint.ts +9 -45
  341. package/src/shell/use-size-class.ts +31 -0
  342. package/src/state/persisted-or-local.ts +10 -4
  343. package/src/state/persisted.ts +76 -17
  344. package/src/styles.css +41 -0
  345. package/src/surfaces.ts +9 -1
  346. package/src/theme/controls.module.css +7 -0
  347. package/src/theme/ctl-theme.tsx +15 -3
  348. package/src/theme/floating.module.css +37 -0
  349. package/src/theme/floating.module.css.d.ts +3 -0
  350. package/src/theme/index.ts +46 -8
  351. package/src/theme/nav-link.module.css +7 -0
  352. package/src/theme-lab/derive-controls.tsx +2 -0
  353. package/src/tokens/index.ts +20 -30
  354. package/src/tokens/palette.ts +6 -28
  355. package/src/tokens/size-classes.ts +46 -0
  356. package/dist/shell/isomorphic-layout-effect.d.ts.map +0 -1
  357. /package/dist/{shell → common}/isomorphic-layout-effect.js +0 -0
@@ -20,7 +20,13 @@ import type { ReactNode, RefObject } from 'react'
20
20
  import type { ChartMargin } from '../../tokens'
21
21
  import type { CursorResolution } from '../cursor/resolve'
22
22
  import { autoMargin } from '../layout/auto-margin'
23
- import { useChartTierMetrics } from '../primitives/chart-tier'
23
+ import {
24
+ isTightClass,
25
+ planXLabels,
26
+ resolveMarginFloor,
27
+ resolveYPlacement,
28
+ } from '../primitives/chart-layout'
29
+ import { useChartContainerClass, useChartMetrics } from '../primitives/chart-tier'
24
30
  import { maxTextWidth } from '../utils/measure-text'
25
31
  import { smartTicks, xLabelPxFor } from '../utils/ticks'
26
32
  import { useChartCursor } from './useChartCursor'
@@ -150,6 +156,13 @@ export type BandPlot<T> = {
150
156
  /** Drawn width of one band — the pitch minus a 1px separator, floored at 1. */
151
157
  bandWidth: number
152
158
  tickValues: string[]
159
+ /** Where the left axis paints: inside the plot (compact), not at all (micro), else outside. An
160
+ * explicit `margin.left` keeps it outside. Same law as `CartesianChart`. */
161
+ yPlacement: 'outside' | 'inside' | 'none'
162
+ /** Terminal x labels anchor inward (compact/micro). */
163
+ xAnchorTerminals: boolean
164
+ /** Width to wrap x labels to; undefined when they do not wrap. */
165
+ xWrapWidth: number | undefined
153
166
  svgRef: RefObject<SVGSVGElement | null>
154
167
  tooltipAnchor: CursorAnchor | null
155
168
  showTooltip: boolean
@@ -181,18 +194,44 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
181
194
  // two things `CartesianChart` reads off it: the tick font its gutters are MEASURED at (the same
182
195
  // one `AxisBottomDate` paints — measured labels must be the painted labels, §1) and the tightened
183
196
  // margin FLOORS (`docs/CHARTS-SPEC.md` §8).
184
- const tier = useChartTierMetrics()
197
+ const tier = useChartMetrics()
198
+ const containerClass = useChartContainerClass()
199
+ const tight = isTightClass(containerClass)
185
200
 
186
201
  const hasLeftAxis = leftLabels !== undefined && leftLabels.length > 0
202
+ // Ladder step 5: inside labels reserve no gutter. An explicit `margin.left` keeps them outside.
203
+ const yPlacement = resolveYPlacement(containerClass, {
204
+ hasLeftAxis,
205
+ marginOverrideLeft: marginOverride?.left,
206
+ })
187
207
  const xLabelsAll = useMemo(() => data.map((d) => formatX(getX(d))), [data, getX, formatX])
188
208
 
209
+ // Ladder step 4 (wrap; a band axis cannot rotate, so it thins instead). Sized from the token
210
+ // floors because the real gutters depend on the wrapped line count. A band chart's x labels are
211
+ // always categorical by construction (one slot per datum) — no whitespace heuristic needed.
212
+ const xPlan = useMemo(
213
+ () =>
214
+ planXLabels({
215
+ labels: xLabelsAll,
216
+ plotWidth: width - tier.margin.left - tier.margin.right,
217
+ fontPx: tier.axisFont,
218
+ categorical: tight,
219
+ }),
220
+ [xLabelsAll, width, tier.margin, tier.axisFont, tight],
221
+ )
222
+ const xWrapWidth = xPlan.wrap ? xPlan.wrapPx : undefined
223
+
189
224
  // ── Pass 1: gutters, from the labels that will actually be painted ──────────────────────────
190
225
  const margin = useMemo<ChartMargin>(() => {
191
226
  const measured = autoMargin({
192
- ...(leftLabels !== undefined && { left: leftLabels }),
227
+ ...(leftLabels !== undefined && { left: yPlacement === 'outside' ? leftLabels : [] }),
193
228
  bottom: xLabelsAll,
194
229
  fontPx: tier.axisFont,
195
- floor: tier.margin,
230
+ // An inside-placed left axis paints IN the plot, so the outside label-width floor is dead
231
+ // space there — same law as `CartesianChart`'s `marginInput.floor`.
232
+ floor: resolveMarginFloor(yPlacement, tier.margin),
233
+ anchorTerminals: tight,
234
+ bottomLines: xPlan.lines,
196
235
  ...(marginOverride !== undefined && { override: marginOverride }),
197
236
  })
198
237
  if (marginOverride?.left !== undefined && marginOverride.right !== undefined) return measured
@@ -201,24 +240,38 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
201
240
  // A band axis puts its FIRST tick on the plot's left edge, so the left gutter needs the same
202
241
  // half-label law `autoMargin` already applies on the right. `autoMargin` cannot know that —
203
242
  // its left law measures a left AXIS, which a band plot may not have.
204
- const left = hasLeftAxis ? measured.left : Math.max(measured.left, halfXLabel)
243
+ const left = hasLeftAxis || tight ? measured.left : Math.max(measured.left, halfXLabel)
205
244
  return {
206
245
  ...measured,
207
246
  ...(marginOverride?.left === undefined && {
208
- left: hasLeftAxis
209
- ? left
210
- : capTerminalGutter(left, width, TERMINAL_GUTTER_MAX_FRACTION.left, tier.margin.left),
247
+ left:
248
+ hasLeftAxis || tight
249
+ ? left
250
+ : capTerminalGutter(left, width, TERMINAL_GUTTER_MAX_FRACTION.left, tier.margin.left),
211
251
  }),
212
252
  ...(marginOverride?.right === undefined && {
213
- right: capTerminalGutter(
214
- measured.right,
215
- width,
216
- TERMINAL_GUTTER_MAX_FRACTION.right,
217
- tier.margin.right,
218
- ),
253
+ right: tight
254
+ ? measured.right
255
+ : capTerminalGutter(
256
+ measured.right,
257
+ width,
258
+ TERMINAL_GUTTER_MAX_FRACTION.right,
259
+ tier.margin.right,
260
+ ),
219
261
  }),
220
262
  }
221
- }, [leftLabels, xLabelsAll, marginOverride, hasLeftAxis, width, tier.axisFont, tier.margin])
263
+ }, [
264
+ leftLabels,
265
+ xLabelsAll,
266
+ marginOverride,
267
+ hasLeftAxis,
268
+ width,
269
+ tier.axisFont,
270
+ tier.margin,
271
+ tight,
272
+ yPlacement,
273
+ xPlan.lines,
274
+ ])
222
275
 
223
276
  const plotWidth = Math.max(width - margin.left - margin.right, 0)
224
277
 
@@ -249,12 +302,17 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
249
302
  [scale],
250
303
  )
251
304
 
305
+ const svgRef = useRef<SVGSVGElement>(null)
252
306
  const cursor = useChartCursor<T>({
253
307
  data: bands,
254
308
  chartId,
255
309
  getKey: getX,
256
310
  xScale: bandCenter,
257
311
  marginLeft: margin.left,
312
+ // Both callers (`BandStrip`, `MirroredBars`) mount exactly one `<svg ref={band.svgRef}>`, so
313
+ // it doubles as the tap-outside boundary (`docs/CHARTS-SPEC.md` §4, Tooltip) with no separate
314
+ // passthrough option — reusing the ref this hook already returns, not a new one.
315
+ boundaryRef: svgRef,
258
316
  ...(cursorResolution !== undefined && { resolution: cursorResolution }),
259
317
  })
260
318
 
@@ -262,20 +320,19 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
262
320
  // same measured-spacing law `CartesianChart` applies (`docs/CHARTS-SPEC.md` §1). `keys` (not
263
321
  // `xLabelsAll`) because ticks are chosen from the post-fold key list.
264
322
  const xLabelPx = useMemo(
265
- () => xLabelPxFor(keys.map(formatX), tier.axisFont),
266
- [keys, formatX, tier.axisFont],
323
+ () =>
324
+ xWrapWidth === undefined ? xLabelPxFor(keys.map(formatX), tier.axisFont) : xPlan.labelPx,
325
+ [keys, formatX, tier.axisFont, xWrapWidth, xPlan.labelPx],
267
326
  )
268
327
 
269
- const tickValues = useMemo(
270
- () => [
271
- ...(xTickValues === undefined
272
- ? smartTicks(keys, plotWidth, xLabelPx)
273
- : xTickValues(keys, plotWidth)),
274
- ],
275
- [keys, plotWidth, xTickValues, xLabelPx],
276
- )
328
+ const tickValues = useMemo(() => {
329
+ if (xTickValues !== undefined) return [...xTickValues(keys, plotWidth)]
330
+ // Micro: the two terminals only.
331
+ if (containerClass === 'micro')
332
+ return keys.length > 1 ? [keys[0]!, keys[keys.length - 1]!] : [...keys]
333
+ return [...smartTicks(keys, plotWidth, xLabelPx, tight)]
334
+ }, [keys, plotWidth, xTickValues, xLabelPx, containerClass, tight])
277
335
 
278
- const svgRef = useRef<SVGSVGElement>(null)
279
336
  const point = cursor.point
280
337
  const crosshairX = point === null ? null : (bandCenter(getX(point)) ?? null)
281
338
 
@@ -284,7 +341,10 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
284
341
  // no pointer under it, so it is always positioned via the anchored (crosshair) path.
285
342
  const isFollowerRender = !cursor.isSource && cfg?.onFollow === true
286
343
  const showTooltip = tooltip !== false && point !== null && (cursor.isSource || isFollowerRender)
287
- const useAnchored = cfg?.follow === false || isFollowerRender
344
+ // An unset `follow` also anchors on touch (spec §5) — same law as `CartesianChart`'s
345
+ // `useAnchoredPosition`; an EXPLICIT `follow` always wins over that default.
346
+ const useAnchored =
347
+ cfg?.follow === false || (cfg?.follow === undefined && cursor.isTouch) || isFollowerRender
288
348
  const anchorX = useAnchored ? crosshairX : null
289
349
  const svgRect = anchorX === null ? undefined : svgRef.current?.getBoundingClientRect()
290
350
  const tooltipAnchor =
@@ -307,6 +367,9 @@ export function useBandPlot<T>(input: UseBandPlotInput<T>): BandPlot<T> {
307
367
  step,
308
368
  bandWidth,
309
369
  tickValues,
370
+ yPlacement,
371
+ xAnchorTerminals: tight,
372
+ xWrapWidth,
310
373
  svgRef,
311
374
  tooltipAnchor,
312
375
  showTooltip,
@@ -1,9 +1,11 @@
1
1
  import { localPoint } from '@visx/event'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
- import type { KeyboardEvent, PointerEvent } from 'react'
3
+ import type { KeyboardEvent, PointerEvent, RefObject } from 'react'
4
4
  import type { CursorResolution } from '../cursor/resolve'
5
5
  import { buildDomainIndex, classifyDomain, resolveCursorPoint } from '../cursor/resolve'
6
6
  import { useCursorState, useCursorStore } from '../cursor/scope'
7
+ import type { CursorState } from '../cursor/store'
8
+ import { useDismissOnOutside, useTouchPin } from '../cursor/touch-pin'
7
9
 
8
10
  /** Viewport-space pointer anchor the tooltip positions against. */
9
11
  export type CursorAnchor = { x: number; y: number }
@@ -15,23 +17,45 @@ export type ChartCursor<T> = {
15
17
  isSource: boolean
16
18
  /** Latest pointer position in viewport coords, or null when not hovered. */
17
19
  anchor: CursorAnchor | null
20
+ /**
21
+ * True when the interaction that most recently placed THIS chart's cursor had
22
+ * `event.pointerType !== 'mouse'` (touch or pen). Callers fold it into the anchored-tooltip
23
+ * condition (`tooltip.follow` defaulting to `false` on touch, docs/CHARTS-SPEC.md §4) — a follower's own
24
+ * anchoring is unaffected, since a follower is never the one whose pointer set this flag.
25
+ */
26
+ isTouch: boolean
18
27
  onPointerMove: (event: PointerEvent<SVGRectElement>) => void
19
- onPointerLeave: () => void
28
+ /** Coarse-pointer equivalent of hover: resolves the nearest point and shows the tooltip
29
+ * immediately, with no preceding `pointermove` — a plain tap otherwise fires only
30
+ * `pointerdown`/`pointerup`. The press is PROVISIONAL: it shows the tooltip but does not commit a
31
+ * pin, which only `onPointerUp` does. Also what MOVES an existing touch pin to a new tap elsewhere
32
+ * in the same chart (fine pointers ignore it; `onPointerMove` already covers hover for them). */
33
+ onPointerDown: (event: PointerEvent<SVGRectElement>) => void
34
+ /** Commits the provisional touch press — the same `pointerId` as the matching `onPointerDown` —
35
+ * so the pin survives the lift. A `pointerup` for any other pointer is ignored. */
36
+ onPointerUp: (event: PointerEvent<SVGRectElement>) => void
37
+ /** `pointerleave`/`pointercancel`, sharing one handler. A still-UNCOMMITTED touch press cancelled
38
+ * here is the browser taking the gesture (a scroll) and clears; once committed, touch no-ops so a
39
+ * stray `pointercancel` cannot undo the pin. A fine pointer always clears. */
40
+ onPointerLeave: (event: PointerEvent<SVGRectElement>) => void
20
41
  onKeyDown: (event: KeyboardEvent<SVGRectElement>) => void
21
42
  onBlur: () => void
22
43
  }
23
44
 
24
45
  /**
25
46
  * Wires a chart into the ambient cursor store: snaps the pointer to the nearest own point,
26
- * broadcasts it, and reads back a sibling's broadcast through domain-aware resolution. Replaces
27
- * the removed `useHoverSync` + `useChartTooltip` pair, including their `resolveKey` escape hatch —
28
- * resolution is now automatic.
47
+ * broadcasts it, and reads back a sibling's broadcast through domain-aware resolution — automatic,
48
+ * no `resolveKey` escape hatch.
49
+ *
50
+ * The tooltip anchor is coalesced through `requestAnimationFrame` (one local write per frame on a
51
+ * fast scrub); the BROADCAST is deduped instead, so a within-bucket scrub costs nothing and a real
52
+ * bucket change reaches siblings on the same tick.
29
53
  *
30
- * The tooltip anchor is coalesced through `requestAnimationFrame`, so a fast scrub costs one local
31
- * state write per frame instead of one per event. The BROADCAST is not rAF-gated — it is deduped
32
- * instead (the store ignores a set that doesn't change the value), so a scrub within one x-bucket
33
- * costs nothing while a real bucket change reaches siblings on the same tick rather than a frame
34
- * late.
54
+ * Touch model (`docs/CHARTS-SPEC.md` §4): a fine pointer keeps hovering. A coarse pointer
55
+ * (`onPointerDown`) resolves and shows the tooltip immediately, drag-scrubs via `onPointerMove`,
56
+ * and COMMITS a pin on `onPointerUp` — `onPointerLeave` (also wired to `pointercancel`) no-ops once
57
+ * committed. A press the browser turns into a scroll clears instead. The pin moves on a fresh tap,
58
+ * clears on a `document` tap outside `boundaryRef`, on any scroll, or on Escape.
35
59
  */
36
60
  export function useChartCursor<T>({
37
61
  data,
@@ -40,6 +64,7 @@ export function useChartCursor<T>({
40
64
  xScale,
41
65
  marginLeft,
42
66
  resolution = 'nearest',
67
+ boundaryRef,
43
68
  }: {
44
69
  data: readonly T[]
45
70
  chartId: string
@@ -53,10 +78,38 @@ export function useChartCursor<T>({
53
78
  * series keyed by its Monday, a monthly series keyed by its 1st) — see {@link CursorResolution}.
54
79
  */
55
80
  resolution?: CursorResolution
81
+ /**
82
+ * Element a touch tap OUTSIDE dismisses the pin against (`docs/CHARTS-SPEC.md` §4). One
83
+ * capture-phase `document` `pointerdown` listener clears the pin when the target falls outside
84
+ * this ref — added on pinning, removed on unpin/unmount. Omit to skip outside-dismiss (a tap
85
+ * elsewhere in the chart and Escape still work); never throws for a missing ref.
86
+ */
87
+ boundaryRef?: RefObject<Element | null>
56
88
  }): ChartCursor<T> {
57
89
  const store = useCursorStore()
58
90
  const cursor = useCursorState()
59
91
  const [anchor, setAnchor] = useState<CursorAnchor | null>(null)
92
+ // Whether the interaction that most recently placed THIS chart's cursor was coarse (touch/pen).
93
+ // Branched per EVENT (`event.pointerType`), never a static media query — a hybrid device can
94
+ // carry both inputs, and a media query can't tell which one just fired.
95
+ const [isTouch, setIsTouch] = useState(false)
96
+ // Whether THIS chart currently holds a COMMITTED touch/pen pin — deliberately separate from
97
+ // `isTouch` above, which a subsequent fine-pointer move resets to `false` (that IS the bug this
98
+ // guards against) and which `clear()` never touches. Set on a successful `onPointerUp` commit,
99
+ // cleared by `clear()` OR by losing source ownership — mirrors `useDiscreteCursor`'s `pinned`
100
+ // state, kept as a ref here since nothing needs to re-render off it directly.
101
+ const pinnedRef = useRef(false)
102
+ // A SIBLING chart stealing the shared cursor source (its own touch tap) never calls THIS chart's
103
+ // `clear()` — nothing here observes that tap directly. Left unguarded, `pinnedRef` stayed `true`
104
+ // forever once set, so a later mouse hover on this chart — after it legitimately re-claims
105
+ // `source` — read a stale pin and froze the crosshair (`resolveNearestAt`'s
106
+ // `pointerType === 'mouse' && pinnedRef.current && store.get().source === chartId` guard blocking
107
+ // a hover that was never actually still pinned). Reset the moment this chart is no longer the
108
+ // cursor's source — the one signal that unambiguously means whatever touch pin this chart held
109
+ // is gone, stolen or not.
110
+ useEffect(() => {
111
+ if (cursor.source !== chartId) pinnedRef.current = false
112
+ }, [cursor.source, chartId])
60
113
 
61
114
  // Accessors read through refs so the pointer callbacks stay referentially stable: the natural
62
115
  // call style is an inline arrow, fresh every render, and re-creating these would re-bind the
@@ -76,9 +129,18 @@ export function useChartCursor<T>({
76
129
 
77
130
  const frameRef = useRef<number | null>(null)
78
131
  const pendingRef = useRef<CursorAnchor | null>(null)
132
+ // `pointermove` floods far faster than one nearest-point search is worth — an untethered scrub
133
+ // can fire dozens of native events between paints. `moveFrameRef`/`pendingMoveRef` coalesce the
134
+ // O(n) search itself (not just the anchor, which `frameRef`/`pendingRef` already batch) to one
135
+ // per animation frame — see `onPointerMove` below.
136
+ const moveFrameRef = useRef<number | null>(null)
137
+ const pendingMoveRef = useRef<{ px: number; anchor: CursorAnchor; pointerType: string } | null>(
138
+ null,
139
+ )
79
140
  useEffect(
80
141
  () => () => {
81
142
  if (frameRef.current !== null) cancelAnimationFrame(frameRef.current)
143
+ if (moveFrameRef.current !== null) cancelAnimationFrame(moveFrameRef.current)
82
144
  // Release the shared cursor if this chart still owns it. Unmounting while hovered (a filter
83
145
  // drops the chart, a tab switches) fires no leave/blur, so the store would keep pointing at
84
146
  // a dead chartId — and any sibling whose domain resolves that stale key would paint a ghost
@@ -97,17 +159,29 @@ export function useChartCursor<T>({
97
159
  })
98
160
  }, [])
99
161
 
100
- const onPointerMove = useCallback(
101
- (event: PointerEvent<SVGRectElement>) => {
102
- if (data.length === 0) return
103
- // Explicit reference node: `localPoint(event)` resolves it from `event.target`, which is the
104
- // overlay rect only while the overlay is genuinely the topmost hit element. Passing
105
- // `currentTarget` pins the coordinate space to the overlay regardless of what a kind draws
106
- // above it.
107
- const local = localPoint(event.currentTarget, event.nativeEvent)
108
- if (local === null) return
109
- const px = local.x - marginLeft
162
+ // The provisional-press commit/revert machine (`docs/CHARTS-SPEC.md` §4) — shared with
163
+ // `useDiscreteCursor`. `getCommitted` snapshots the STORE's current value (not this chart's own
164
+ // `point`), because the shared cursor is page-wide: a committed pin can belong to a different
165
+ // chart than the one now taking a second, cancelled press.
166
+ const touchPin = useTouchPin<CursorState>({
167
+ getCommitted: () => store.get(),
168
+ setCommitted: (v) => store.set(v.key, v.source, v.kind),
169
+ })
110
170
 
171
+ // The nearest-point search plus the broadcast — shared by the immediate (tap) and throttled
172
+ // (move) resolve paths below, so they cannot drift apart. `px` is the plot-local x, already run
173
+ // through `localPoint` by the caller: a `requestAnimationFrame` boundary has no business holding
174
+ // a live `PointerEvent`, only the plain numbers it already read out of one.
175
+ const resolveNearestAt = useCallback(
176
+ (px: number, anchor: CursorAnchor, pointerType: string) => {
177
+ if (data.length === 0) return
178
+ // A committed touch/pen pin must not be silently overridden by a fine-pointer hover on a
179
+ // hybrid device (a touchscreen laptop with a mouse/trackpad) — mirrors `useDiscreteCursor`'s
180
+ // `pinned` guard on its own hover path. Gated on `store.get().source` (not the reactive
181
+ // `isSource` derived later in this hook) so a pin a SIBLING chart has since stolen no longer
182
+ // blocks this chart's own mouse hover just because this chart's stale `pinnedRef` is still
183
+ // true.
184
+ if (pointerType === 'mouse' && pinnedRef.current && store.get().source === chartId) return
111
185
  let closest = data[0] as T
112
186
  let minDist = Infinity
113
187
  for (const d of data) {
@@ -118,11 +192,79 @@ export function useChartCursor<T>({
118
192
  closest = d
119
193
  }
120
194
  }
121
-
122
- scheduleAnchor({ x: event.clientX, y: event.clientY })
195
+ setIsTouch(pointerType !== 'mouse')
196
+ scheduleAnchor(anchor)
123
197
  store.set(getKeyRef.current(closest), chartId, kind)
124
198
  },
125
- [data, marginLeft, chartId, store, scheduleAnchor, kind],
199
+ [data, chartId, store, scheduleAnchor, kind],
200
+ )
201
+
202
+ // Explicit reference node: `localPoint(event)` resolves it from `event.target`, which is the
203
+ // overlay rect only while the overlay is genuinely the topmost hit element. Passing
204
+ // `currentTarget` pins the coordinate space to the overlay regardless of what a kind draws above it.
205
+ const localX = useCallback(
206
+ (event: PointerEvent<SVGRectElement>): number | null => {
207
+ const local = localPoint(event.currentTarget, event.nativeEvent)
208
+ return local === null ? null : local.x - marginLeft
209
+ },
210
+ [marginLeft],
211
+ )
212
+
213
+ // A tap resolves immediately — one discrete event, not a flood, so it needs no throttle.
214
+ const resolveImmediate = useCallback(
215
+ (event: PointerEvent<SVGRectElement>) => {
216
+ const px = localX(event)
217
+ if (px === null) return
218
+ resolveNearestAt(px, { x: event.clientX, y: event.clientY }, event.pointerType)
219
+ },
220
+ [localX, resolveNearestAt],
221
+ )
222
+
223
+ const onPointerMove = useCallback(
224
+ (event: PointerEvent<SVGRectElement>) => {
225
+ const px = localX(event)
226
+ if (px === null) return
227
+ pendingMoveRef.current = {
228
+ px,
229
+ anchor: { x: event.clientX, y: event.clientY },
230
+ pointerType: event.pointerType,
231
+ }
232
+ if (moveFrameRef.current !== null) return
233
+ moveFrameRef.current = requestAnimationFrame(() => {
234
+ moveFrameRef.current = null
235
+ const pending = pendingMoveRef.current
236
+ pendingMoveRef.current = null
237
+ if (pending !== null) resolveNearestAt(pending.px, pending.anchor, pending.pointerType)
238
+ })
239
+ },
240
+ [localX, resolveNearestAt],
241
+ )
242
+
243
+ // A tap fires `pointerdown` + `pointerup` with no `pointermove` in between, so hover's resolve
244
+ // path never runs for it — this is what makes a tap show anything at all. It also MOVES an
245
+ // existing touch pin: a fresh `pointerdown` elsewhere in the chart re-resolves and re-broadcasts
246
+ // exactly like the first one, with nothing extra to wire.
247
+ const onPointerDown = useCallback(
248
+ (event: PointerEvent<SVGRectElement>) => {
249
+ // A coarse press is PROVISIONAL: snapshot whatever is committed now (so a cancel can restore
250
+ // it) BEFORE the resolve below overwrites the store. A fine pointer never begins a press —
251
+ // its hover path has no pin to protect.
252
+ if (event.pointerType !== 'mouse') touchPin.begin(event.pointerId)
253
+ resolveImmediate(event)
254
+ },
255
+ [resolveImmediate, touchPin],
256
+ )
257
+ // A completed tap: the pin is now committed, so `onPointerLeave`'s touch no-op keeps it past the
258
+ // lift. Only the tracked pointer commits — a stray `pointerup` is ignored. `pointerType ===
259
+ // 'mouse'` is checked FIRST and unconditionally: some browsers alias a touch's `pointerId` to the
260
+ // same value (1) a mouse pointer uses, so without this an unrelated mouse `pointerup` could commit
261
+ // an abandoned touch press it never tracked (mirrors `useDiscreteCursor`'s identical guard).
262
+ const onPointerUp = useCallback(
263
+ (event: PointerEvent<SVGRectElement>) => {
264
+ if (event.pointerType === 'mouse') return
265
+ if (touchPin.commit(event.pointerId)) pinnedRef.current = true
266
+ },
267
+ [touchPin],
126
268
  )
127
269
 
128
270
  const clear = useCallback(() => {
@@ -130,12 +272,35 @@ export function useChartCursor<T>({
130
272
  cancelAnimationFrame(frameRef.current)
131
273
  frameRef.current = null
132
274
  }
275
+ if (moveFrameRef.current !== null) {
276
+ cancelAnimationFrame(moveFrameRef.current)
277
+ moveFrameRef.current = null
278
+ }
133
279
  pendingRef.current = null
280
+ pendingMoveRef.current = null
281
+ touchPin.reset()
282
+ pinnedRef.current = false
134
283
  setAnchor(null)
135
284
  // Only clear the SHARED cursor if this chart still owns it: moving fast from chart A to B lets
136
285
  // A's leave fire after B's move, and an unconditional clear would wipe B's cursor.
137
286
  if (store.get().source === chartId) store.set(null, null, null)
138
- }, [store, chartId])
287
+ }, [store, chartId, touchPin])
288
+
289
+ // `pointerleave`/`pointercancel` share this one handler (`HoverOverlay` wires both to `onLeave`).
290
+ // A still-uncommitted touch press cancelled here is the browser taking the gesture (a scroll):
291
+ // `touchPin.cancel` restores whatever pin was committed before this press started (or leaves it
292
+ // clear when nothing was), rather than dropping a pin this press never owned. Once COMMITTED by
293
+ // touch it is a no-op — a stray `pointercancel` must not undo the pin the tap was meant to set.
294
+ // Escape's `clear()` in `onKeyDown` bypasses this guard entirely, by design: it is the one
295
+ // dismissal that must work regardless of pointer type.
296
+ const onPointerLeave = useCallback(
297
+ (event: PointerEvent<SVGRectElement>) => {
298
+ if (touchPin.cancel(event.pointerId)) return
299
+ if (isTouch) return
300
+ clear()
301
+ },
302
+ [isTouch, clear, touchPin],
303
+ )
139
304
 
140
305
  const onKeyDown = useCallback(
141
306
  (event: KeyboardEvent<SVGRectElement>) => {
@@ -169,13 +334,33 @@ export function useChartCursor<T>({
169
334
  // always carries its own kind by construction.
170
335
  const point =
171
336
  cursor.key === null || cursor.kind !== kind ? null : resolveCursorPoint(index, cursor.key)
337
+ const isSource = cursor.source === chartId
338
+
339
+ // Tap-outside dismissal (spec §5): while THIS chart is both pinned by touch and still the cursor's
340
+ // source, a capture-phase `document` `pointerdown`/`scroll`/`keydown` (shared with
341
+ // `useDiscreteCursor`, see `touch-pin.ts`) clears it the moment the event target falls outside
342
+ // `boundaryRef`. Gating on `isSource` (not `isTouch` alone) means the listener follows the pin —
343
+ // a sibling chart stealing the source on its own touch tap detaches this one instead of leaving a
344
+ // second stale listener alive.
345
+ const isInsideBoundary = useCallback(
346
+ (target: Node): boolean => boundaryRef?.current?.contains(target) === true,
347
+ [boundaryRef],
348
+ )
349
+ useDismissOnOutside({
350
+ enabled: isTouch && isSource && boundaryRef !== undefined,
351
+ isInside: isInsideBoundary,
352
+ onDismiss: clear,
353
+ })
172
354
 
173
355
  return {
174
356
  point,
175
- isSource: cursor.source === chartId,
357
+ isSource,
176
358
  anchor,
359
+ isTouch,
177
360
  onPointerMove,
178
- onPointerLeave: clear,
361
+ onPointerDown,
362
+ onPointerUp,
363
+ onPointerLeave,
179
364
  onKeyDown,
180
365
  onBlur: clear,
181
366
  }
@@ -84,14 +84,6 @@ export {
84
84
  type ResolvedChartState,
85
85
  resolveChartState,
86
86
  } from './primitives/ChartPending'
87
- export {
88
- useChartTier,
89
- useChartTierMetrics,
90
- chartTierMetrics,
91
- resolveChartTier,
92
- type ChartTier,
93
- type ChartTierMetrics,
94
- } from './primitives/chart-tier'
95
87
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
96
88
  export { HatchPattern, hatchFill, hatchSizeFor } from './primitives/HatchPattern'
97
89
  export { ChartCard, type ChartCardProps, type ChartCardSlot } from './primitives/ChartCard'