basalt-ui 1.8.0 → 1.10.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 (99) hide show
  1. package/AGENTS.md +1 -0
  2. package/CHANGELOG.md +25 -0
  3. package/README.md +64 -34
  4. package/agent/rules/basalt-agent.md +49 -6
  5. package/agent/rules/basalt-charts.md +74 -5
  6. package/configs/oxlint-plugin.js +279 -0
  7. package/configs/oxlint-plugin.test.ts +216 -1
  8. package/configs/oxlint.json +5 -2
  9. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  10. package/dist/agent/use-agent-thread-runs.js +1 -0
  11. package/dist/agent-chat/index.d.ts +4 -3
  12. package/dist/agent-chat/index.d.ts.map +1 -1
  13. package/dist/charts/hooks/useHoverSync.d.ts +18 -2
  14. package/dist/charts/hooks/useHoverSync.d.ts.map +1 -1
  15. package/dist/charts/hooks/useHoverSync.js +12 -7
  16. package/dist/charts/index.d.ts +1 -0
  17. package/dist/charts/index.d.ts.map +1 -1
  18. package/dist/charts/index.js +6 -0
  19. package/dist/charts/kinds/Bars.d.ts +2 -0
  20. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  21. package/dist/charts/kinds/Bars.js +3 -1
  22. package/dist/charts/kinds/Donut.d.ts +2 -0
  23. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  24. package/dist/charts/kinds/Donut.js +8 -4
  25. package/dist/charts/kinds/DualPanel.d.ts +2 -0
  26. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  27. package/dist/charts/kinds/DualPanel.js +2 -1
  28. package/dist/charts/kinds/Heatmap.d.ts +8 -0
  29. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  30. package/dist/charts/kinds/Heatmap.js +7 -3
  31. package/dist/charts/kinds/MultiLine.d.ts +2 -0
  32. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.js +2 -1
  34. package/dist/charts/kinds/StackedArea.d.ts +2 -0
  35. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  36. package/dist/charts/kinds/StackedArea.js +2 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +2 -0
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/kinds/ZonedLine.js +2 -1
  40. package/dist/charts/primitives/Axes.d.ts +6 -1
  41. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  42. package/dist/charts/primitives/Axes.js +3 -2
  43. package/dist/charts/primitives/ChartFrame.d.ts +8 -1
  44. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  45. package/dist/charts/primitives/ChartFrame.js +8 -4
  46. package/dist/charts/primitives/ChartPending.d.ts +34 -0
  47. package/dist/charts/primitives/ChartPending.d.ts.map +1 -0
  48. package/dist/charts/primitives/ChartPending.js +23 -0
  49. package/dist/charts/primitives/ChartTooltip.d.ts +5 -2
  50. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  51. package/dist/charts/primitives/ChartTooltip.js +6 -1
  52. package/dist/charts/primitives/HoverOverlay.d.ts +5 -4
  53. package/dist/charts/primitives/HoverOverlay.d.ts.map +1 -1
  54. package/dist/charts/primitives/HoverOverlay.js +4 -2
  55. package/dist/cli/index.d.ts +55 -1
  56. package/dist/cli/index.d.ts.map +1 -1
  57. package/dist/cli/index.js +217 -5
  58. package/dist/dashboard/stat-card.d.ts.map +1 -1
  59. package/dist/guard/index.d.ts.map +1 -1
  60. package/dist/guard/index.js +13 -5
  61. package/dist/surfaces.d.ts +10 -0
  62. package/dist/surfaces.d.ts.map +1 -1
  63. package/dist/surfaces.js +21 -0
  64. package/llms.txt +9 -2
  65. package/package.json +8 -5
  66. package/src/agent/ai-sdk-transport.test.ts +269 -0
  67. package/src/agent/parts.test.ts +261 -0
  68. package/src/agent/thread.test.ts +147 -0
  69. package/src/agent/use-agent-stream.test.tsx +231 -0
  70. package/src/agent/use-agent-thread-runs.resume.test.tsx +261 -0
  71. package/src/agent/use-agent-thread-runs.test.tsx +398 -0
  72. package/src/agent/use-agent-thread-runs.ts +7 -1
  73. package/src/agent/use-agent-thread-runs.wedge.test.tsx +299 -0
  74. package/src/agent-chat/index.ts +4 -3
  75. package/src/charts/hooks/useHoverSync.ts +43 -7
  76. package/src/charts/index.ts +6 -0
  77. package/src/charts/kinds/Bars.tsx +4 -0
  78. package/src/charts/kinds/Donut.tsx +10 -4
  79. package/src/charts/kinds/DualPanel.tsx +5 -1
  80. package/src/charts/kinds/Heatmap.tsx +19 -2
  81. package/src/charts/kinds/MultiLine.tsx +4 -1
  82. package/src/charts/kinds/StackedArea.tsx +4 -1
  83. package/src/charts/kinds/ZonedLine.tsx +4 -1
  84. package/src/charts/primitives/Axes.tsx +7 -1
  85. package/src/charts/primitives/ChartFrame.test.tsx +67 -0
  86. package/src/charts/primitives/ChartFrame.tsx +17 -4
  87. package/src/charts/primitives/ChartPending.tsx +59 -0
  88. package/src/charts/primitives/ChartTooltip.tsx +15 -3
  89. package/src/charts/primitives/HoverOverlay.tsx +12 -6
  90. package/src/cli/index.ts +439 -5
  91. package/src/cli/placement-engine.test.ts +104 -0
  92. package/src/dashboard/stat-card.test.tsx +5 -3
  93. package/src/dashboard/stat-card.tsx +12 -0
  94. package/src/guard/check-source.test.ts +20 -0
  95. package/src/guard/index.ts +20 -9
  96. package/src/provider/index.test.tsx +6 -5
  97. package/src/surfaces.ts +22 -0
  98. package/src/theme/use-basalt-spacing.test.tsx +7 -4
  99. package/src/tokens/build-fonts-css.test.ts +3 -2
@@ -0,0 +1,299 @@
1
+ /**
2
+ * F3 — the mount-reconcile / unmount-cleanup wedge in useAgentThreadRuns.
3
+ *
4
+ * The bug: the unmount-cleanup effect (`controllersRef.current.forEach(c => c.abort())`) aborted
5
+ * every in-flight controller but never CLEARED the map. The mount-reconcile effect treats a thread
6
+ * as "orphaned" (worth a resume attempt) only when `!controllersRef.current.has(thread.id)`. If
7
+ * this fiber's effects re-run WITHOUT the fiber itself unmounting — React 19 StrictMode's
8
+ * dev-only double-invoke, or a `<Activity>` boundary being hidden then shown again — the stale,
9
+ * now-aborted entry survives into the second reconcile pass, `orphaned` reads `false`, no new
10
+ * resume is attempted, and the one live consumer that WAS attempted already returned silently on
11
+ * its AbortError (`consumeAndFinalize`'s guard at the top of its try block). The thread is left in
12
+ * `'streaming'` forever with nothing left to resolve it.
13
+ *
14
+ * Both halves matter equally here:
15
+ *
16
+ * (a) REFUTED — a genuine unmount + remount (a fresh component instance, not a re-run of THIS
17
+ * fiber's effects) gets a brand-new `useRef` Map. There is nothing for the fix to protect
18
+ * against here; the test exists so nobody "fixes" this non-bug later.
19
+ * (b) CONFIRMED — React 19 StrictMode's dev-only double-invoke reuses the SAME fiber (and thus
20
+ * the same ref) across a simulated unmount+remount within one commit.
21
+ * (c) CONFIRMED — React 19.2's `<Activity>` does the SAME thing in PRODUCTION: hiding an
22
+ * Activity boundary destroys the subtree's effects and re-creates them on show, while state,
23
+ * DOM, and ref identity survive. This is the reachable-in-prod half of the wedge, not a
24
+ * StrictMode-only curiosity — hence testing it directly against `<Activity>` from `react`
25
+ * rather than only through Mantine's `Collapse`. At the `@mantine/core` 9.3.0 pinned in THIS
26
+ * repo, `Collapse` has no `keepMounted` DEFAULT — with the prop unset it keeps `children`
27
+ * permanently mounted (the `else` branch below), so a bare `<Collapse>` cannot wedge and a
28
+ * default-Collapse-based test would not reproduce the bug. But a consumer writing
29
+ * `<Collapse keepMounted>` on this SAME 9.3.0 does hit the `<Activity>` path — read
30
+ * the installed `@mantine/core` 9.3.0 `Collapse.mjs` source directly: it imports
31
+ * `Activity` from `react` and, when `keepMounted === true`, renders
32
+ * `jsx(Activity, { mode: isExited ? 'hidden' : 'visible', … })` on the ordinary non-zero-
33
+ * duration path (only the `duration === 0` fast path carries a `&& env !== 'test'` guard). So
34
+ * the Activity hazard is live on THIS pinned version, not only on the 9.4.1 argo resolves —
35
+ * testing `<Activity>` directly is still the right call because it is what a `keepMounted`
36
+ * consumer actually gets here today, not because 9.3.0 is exempt.
37
+ *
38
+ * Verified manually while writing this file (reported alongside the test suite, not re-asserted
39
+ * here as a runtime check): reverting the one-line `controllersRef.current.clear()` fix makes (b)
40
+ * and (c) fail — the thread's status stays `'streaming'` instead of settling — while (a) passes
41
+ * either way, exactly as the "refutation" framing above expects.
42
+ *
43
+ * Both (b) and (c) observe `resume()` invoked TWICE post-fix (once for the pass that gets aborted,
44
+ * once for the pass that survives), not once — React's whole justification for double-invoking a
45
+ * mount effect with a side effect (a network call here) is to surface exactly this
46
+ * non-idempotence; a spec that anticipated "resume() attempted exactly once" for this scenario
47
+ * does not match observed React 19.2.7 behavior. What's invariant, and what these tests assert, is
48
+ * that the thread SETTLES (leaves `'streaming'`) rather than wedging — the resume() call count is
49
+ * asserted too, but as an observed constant with the doubling explained, not as evidence of a
50
+ * single attempt.
51
+ */
52
+ import { describe, expect, test } from 'bun:test'
53
+ import { Activity, StrictMode, useState } from 'react'
54
+ import { act, render, renderHook, waitFor } from '@testing-library/react'
55
+ import { useAgentThreadRuns } from './use-agent-thread-runs'
56
+ import type { AgentThread, ThreadsStore } from './thread'
57
+ import type { AgentTransport } from './transport'
58
+ import type { AgentPart } from './parts'
59
+ import type { ChatMessage } from './history'
60
+ import type { AgentOutcome } from './outcome'
61
+
62
+ // ── test-only ThreadsStore double ────────────────────────────────────────────
63
+ //
64
+ // A plain mutable object satisfying the ThreadsStore contract — no React state, no
65
+ // localStorage/useSyncExternalStore. useAgentThreadRuns only ever reads `store.threads` and calls
66
+ // its action methods synchronously (via `storeRef.current`), so a hand-rolled mutable double gives
67
+ // the test full, synchronous, non-persisted control over what a "reload mid-stream" looked like —
68
+ // exactly what these mount-reconcile tests need to seed.
69
+ function createTestThreadsStore(initial: AgentThread<AgentPart>[] = []): ThreadsStore<AgentPart> {
70
+ let threads = initial
71
+ let activeId: string | null = null
72
+ return {
73
+ get threads() {
74
+ return threads
75
+ },
76
+ get activeId() {
77
+ return activeId
78
+ },
79
+ select(id) {
80
+ activeId = id
81
+ },
82
+ create(opts) {
83
+ const id = crypto.randomUUID()
84
+ const now = Date.now()
85
+ const thread: AgentThread<AgentPart> = {
86
+ id,
87
+ messages: [],
88
+ outcome: null,
89
+ status: 'pending',
90
+ read: false,
91
+ createdAt: now,
92
+ updatedAt: now,
93
+ ...(opts?.meta !== undefined ? { meta: opts.meta } : {}),
94
+ }
95
+ threads = [thread, ...threads]
96
+ return id
97
+ },
98
+ appendMessage(id, message) {
99
+ threads = threads.map((thread) =>
100
+ thread.id === id
101
+ ? { ...thread, messages: [...thread.messages, message], updatedAt: Date.now() }
102
+ : thread,
103
+ )
104
+ },
105
+ setOutcome(id, outcome) {
106
+ threads = threads.map((thread) =>
107
+ thread.id === id ? { ...thread, outcome, updatedAt: Date.now() } : thread,
108
+ )
109
+ },
110
+ setStatus(id, status) {
111
+ threads = threads.map((thread) =>
112
+ thread.id === id ? { ...thread, status, updatedAt: Date.now() } : thread,
113
+ )
114
+ },
115
+ setResumeToken(id, token) {
116
+ threads = threads.map((thread) => {
117
+ if (thread.id !== id) return thread
118
+ const { resumeToken: _resumeToken, ...rest } = thread
119
+ return {
120
+ ...rest,
121
+ ...(token !== undefined ? { resumeToken: token } : {}),
122
+ updatedAt: Date.now(),
123
+ }
124
+ })
125
+ },
126
+ markRead(id) {
127
+ threads = threads.map((thread) => (thread.id === id ? { ...thread, read: true } : thread))
128
+ },
129
+ remove(id) {
130
+ threads = threads.filter((thread) => thread.id !== id)
131
+ if (activeId === id) activeId = null
132
+ },
133
+ clear() {
134
+ threads = []
135
+ activeId = null
136
+ },
137
+ }
138
+ }
139
+
140
+ function makeThread(overrides: Partial<AgentThread<AgentPart>> = {}): AgentThread<AgentPart> {
141
+ const now = Date.now()
142
+ return {
143
+ id: crypto.randomUUID(),
144
+ messages: [],
145
+ outcome: null,
146
+ status: 'pending',
147
+ read: false,
148
+ createdAt: now,
149
+ updatedAt: now,
150
+ ...overrides,
151
+ }
152
+ }
153
+
154
+ function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
155
+ let resolve!: (value: T) => void
156
+ const promise = new Promise<T>((r) => {
157
+ resolve = r
158
+ })
159
+ return { promise, resolve }
160
+ }
161
+
162
+ const resolveOutcome = (): AgentOutcome => ({ title: 'title', summary: 'summary', status: 'done' })
163
+
164
+ describe('useAgentThreadRuns — F3 mount-reconcile / unmount-cleanup wedge', () => {
165
+ test('(a) REFUTED: a genuine unmount + remount gets a fresh controllers map — resolves to interrupted, never re-enters streaming', () => {
166
+ const store = createTestThreadsStore()
167
+ const threadId = store.create()
168
+
169
+ // No `resume` on this transport: on remount, the orphaned thread must fall straight to
170
+ // 'interrupted' with no async wait involved.
171
+ const transport: AgentTransport<AgentPart, string> = {
172
+ // oxlint-disable-next-line require-yield -- hangs until aborted; never has anything to yield
173
+ async *stream(_input, signal) {
174
+ await new Promise((resolve) => {
175
+ signal?.addEventListener('abort', () => resolve(undefined))
176
+ })
177
+ },
178
+ }
179
+
180
+ const { result, unmount } = renderHook(() =>
181
+ useAgentThreadRuns({ transport, store, resolveOutcome }),
182
+ )
183
+
184
+ act(() => {
185
+ result.current.start(threadId, 'hello')
186
+ })
187
+ expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('streaming')
188
+
189
+ unmount()
190
+
191
+ // A brand-new component instance against the SAME store object — a fresh fiber, fresh
192
+ // `useRef` Map. This must hold both before AND after the F3 fix.
193
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }))
194
+
195
+ expect(store.threads.find((thread) => thread.id === threadId)?.status).toBe('interrupted')
196
+ })
197
+
198
+ test('(b) CONFIRMED: React 19 StrictMode double-invoke resumes the orphaned thread instead of wedging it in streaming', async () => {
199
+ const userMessage: ChatMessage<AgentPart> = {
200
+ id: crypto.randomUUID(),
201
+ role: 'user',
202
+ parts: [{ type: 'text', text: 'hello' }],
203
+ createdAt: Date.now(),
204
+ }
205
+ const thread = makeThread({
206
+ status: 'streaming',
207
+ resumeToken: 'resume-token-1',
208
+ messages: [userMessage],
209
+ })
210
+ const store = createTestThreadsStore([thread])
211
+
212
+ let resumeCalls = 0
213
+ const transport: AgentTransport<AgentPart, string> = {
214
+ async *stream() {},
215
+ async *resume() {
216
+ resumeCalls++
217
+ yield { type: 'text', text: 'resumed' }
218
+ },
219
+ }
220
+
221
+ renderHook(() => useAgentThreadRuns({ transport, store, resolveOutcome }), {
222
+ wrapper: StrictMode,
223
+ })
224
+
225
+ await waitFor(() => {
226
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
227
+ })
228
+ // Observed constant for this scenario (see file doc): the aborted first pass plus the
229
+ // surviving second pass, not "exactly once".
230
+ expect(resumeCalls).toBe(2)
231
+ })
232
+
233
+ test('(c) CONFIRMED: an <Activity> hide/show cycle resumes the orphaned thread instead of wedging it in streaming', async () => {
234
+ const userMessage: ChatMessage<AgentPart> = {
235
+ id: crypto.randomUUID(),
236
+ role: 'user',
237
+ parts: [{ type: 'text', text: 'hello' }],
238
+ createdAt: Date.now(),
239
+ }
240
+ const thread = makeThread({
241
+ status: 'streaming',
242
+ resumeToken: 'resume-token-2',
243
+ messages: [userMessage],
244
+ })
245
+ const store = createTestThreadsStore([thread])
246
+
247
+ // Gates the first resume() attempt so the hide happens strictly before it settles — an
248
+ // explicit happens-before, not a scheduler coincidence a `setTimeout(…, 0)` would only
249
+ // usually win.
250
+ const gate = deferred<void>()
251
+
252
+ let resumeCalls = 0
253
+ const transport: AgentTransport<AgentPart, string> = {
254
+ async *stream() {},
255
+ async *resume() {
256
+ resumeCalls++
257
+ await gate.promise
258
+ yield { type: 'text', text: 'resumed' }
259
+ },
260
+ }
261
+
262
+ let externalSetMode: ((mode: 'visible' | 'hidden') => void) | undefined
263
+
264
+ function Probe(): null {
265
+ useAgentThreadRuns({ transport, store, resolveOutcome })
266
+ return null
267
+ }
268
+
269
+ function Harness(): JSX.Element {
270
+ const [mode, setMode] = useState<'visible' | 'hidden'>('visible')
271
+ externalSetMode = setMode
272
+ return (
273
+ <Activity mode={mode}>
274
+ <Probe />
275
+ </Activity>
276
+ )
277
+ }
278
+
279
+ await act(async () => {
280
+ render(<Harness />)
281
+ })
282
+
283
+ // First resume attempt is in flight (its gate hasn't been released yet) — hide now to abort
284
+ // it via the F3-fixed cleanup effect. `gate.resolve()` only runs after this completes, so the
285
+ // hide is guaranteed to land before the first attempt could ever settle.
286
+ await act(async () => {
287
+ externalSetMode?.('hidden')
288
+ })
289
+ gate.resolve()
290
+ await act(async () => {
291
+ externalSetMode?.('visible')
292
+ })
293
+
294
+ await waitFor(() => {
295
+ expect(store.threads.find((t) => t.id === thread.id)?.status).toBe('done')
296
+ })
297
+ expect(resumeCalls).toBe(2)
298
+ })
299
+ })
@@ -2,11 +2,12 @@
2
2
  * Mantine-styled thread-chat components over the headless `basalt-ui/agent` layer, capped by the
3
3
  * flagship `ThreadWorkspace` composite.
4
4
  *
5
- * This module is Mantine-coupled by design and lives under the root entry (unlike `basalt-ui/agent`,
6
- * which stays headless so it can be styled by any consumer).
5
+ * This module is Mantine-coupled by design and ships its own `basalt-ui/agent-chat` subpath
6
+ * (also re-exported from the root entry, unchanged for existing consumers) — unlike
7
+ * `basalt-ui/agent`, which stays headless so it can be styled by any consumer.
7
8
  *
8
9
  * @example
9
- * import { ThreadWorkspace } from 'basalt-ui'
10
+ * import { ThreadWorkspace } from 'basalt-ui/agent-chat'
10
11
  * import { createThreadsStore, edenTransport, heuristicOutcome } from 'basalt-ui/agent'
11
12
  */
12
13
 
@@ -24,12 +24,29 @@ export function useHoverSync<T>({
24
24
  getKey,
25
25
  xScale,
26
26
  marginLeft,
27
+ resolveKey,
27
28
  }: {
28
29
  data: T[]
29
30
  chartId: string
30
31
  getKey: (d: T) => string
31
32
  xScale: XScale
32
33
  marginLeft: number
34
+ /**
35
+ * Overrides how a SIBLING's broadcast key resolves to one of THIS chart's own points. Only
36
+ * affects reading another chart's hover — this chart's own hover (via `getKey`/`xScale`) is
37
+ * untouched.
38
+ *
39
+ * Cross-chart sync is exact string match by default (`pointByKey.get(ctx.key)`), so two charts
40
+ * share a cursor only if they emit identical key strings. A chart that downsamples/folds its
41
+ * domain to fit a narrow viewport (e.g. ~288 buckets folded into ~97 columns at 390px) no longer
42
+ * owns most of the keys its unfolded siblings broadcast — the shared crosshair then appears on
43
+ * roughly one hover in three, with no rule a reader can infer, which is worse than no shared
44
+ * cursor at all. Pass `resolveKey` to map a foreign key onto whichever of this chart's own points
45
+ * swallowed it (e.g. the folded bucket the key falls into).
46
+ *
47
+ * Omit it and behaviour is byte-identical to today.
48
+ */
49
+ resolveKey?: (key: string) => T | null
33
50
  }) {
34
51
  const ctx = useContext(HoverContext)
35
52
  // Latest context in a ref so the mouse callbacks stay referentially stable: the provider's value
@@ -53,13 +70,26 @@ export function useHoverSync<T>({
53
70
 
54
71
  const { tip, show, hide, tooltipRef, lastDateRef } = useChartTooltip<T>()
55
72
 
73
+ // Latest accessors in refs, same reasoning as `ctxRef` above: the natural call style is an
74
+ // inline arrow for `getKey`/`xScale`, which is fresh on every render, and this file already
75
+ // pays for that hazard once (see the comment above `ctxRef`) without applying the fix here — a
76
+ // several-hundred-entry `pointByKey` Map was rebuilding on every render of a page that
77
+ // re-renders on a heartbeat. Trade-off: because `getKey` is read through a ref, changing key
78
+ // SEMANTICS without changing `data` will NOT rebuild the map. That's the intended trade — a
79
+ // `getKey` that changes meaning for the same data is pathological; a `getKey` that changes
80
+ // identity every render is the normal case.
81
+ const getKeyRef = useRef(getKey)
82
+ getKeyRef.current = getKey
83
+ const xScaleRef = useRef(xScale)
84
+ xScaleRef.current = xScale
85
+
56
86
  // O(1) lookup of a point by its key. Under a provider every sibling resolves the broadcast
57
87
  // key each frame; an O(n) `data.find` per sibling per move is N×O(M). This makes it N×O(1).
58
88
  const pointByKey = useMemo(() => {
59
89
  const m = new Map<string, T>()
60
- for (const d of data) m.set(getKey(d), d)
90
+ for (const d of data) m.set(getKeyRef.current(d), d)
61
91
  return m
62
- }, [data, getKey])
92
+ }, [data])
63
93
 
64
94
  const handleMouse = useCallback(
65
95
  (event: React.MouseEvent<SVGRectElement>) => {
@@ -69,7 +99,7 @@ export function useHoverSync<T>({
69
99
  let closest: T = data[0] as T
70
100
  let minDist = Infinity
71
101
  for (const d of data) {
72
- const sx = xScale(getKey(d)) ?? 0
102
+ const sx = xScaleRef.current(getKeyRef.current(d)) ?? 0
73
103
  const dist = Math.abs(sx - px)
74
104
  if (dist < minDist) {
75
105
  minDist = dist
@@ -77,13 +107,13 @@ export function useHoverSync<T>({
77
107
  }
78
108
  }
79
109
  show(closest, event)
80
- const key = getKey(closest)
110
+ const key = getKeyRef.current(closest)
81
111
  if (lastDateRef.current !== key) {
82
112
  lastDateRef.current = key
83
113
  ctxRef.current.setHover(key, chartId)
84
114
  }
85
115
  },
86
- [data, xScale, getKey, chartId, marginLeft, show, lastDateRef],
116
+ [data, chartId, marginLeft, show, lastDateRef],
87
117
  )
88
118
 
89
119
  const handleLeave = useCallback(() => {
@@ -97,8 +127,14 @@ export function useHoverSync<T>({
97
127
  // The point the crosshair + synced dots track. Inside a <ChartHoverSync> this follows the
98
128
  // broadcast key (so every sibling paints a ghost crosshair at the same x). WITHOUT a provider the
99
129
  // chart is standalone, so it falls back to THIS chart's own hovered point (`tip.data`) — otherwise
100
- // a chart outside a provider would never draw a crosshair/dots at all.
101
- const syncedPoint = ctx.key ? (pointByKey.get(ctx.key) ?? null) : (tip?.data ?? null)
130
+ // a chart outside a provider would never draw a crosshair/dots at all. When `resolveKey` is
131
+ // supplied it replaces the default exact-match lookup for reading a SIBLING's broadcast key (see
132
+ // its doc comment above) — this chart's own hover is unaffected either way.
133
+ const syncedPoint = ctx.key
134
+ ? resolveKey
135
+ ? resolveKey(ctx.key)
136
+ : (pointByKey.get(ctx.key) ?? null)
137
+ : (tip?.data ?? null)
102
138
  // Which chart owns the floating tooltip. Inside a provider the hovered chart is the one whose id
103
139
  // matches the broadcast source (siblings show a ghost crosshair only). Standalone, its own local
104
140
  // hover (`tip`) drives the tooltip.
@@ -46,6 +46,12 @@ export {
46
46
  // ── Primitives ───────────────────────────────────────────────────────────
47
47
  export { ResponsiveChart, type ResponsiveChartProps } from './primitives/ResponsiveChart'
48
48
  export { ChartFrame, type ChartFrameProps, type ChartFrameLegend } from './primitives/ChartFrame'
49
+ export {
50
+ ChartCenter,
51
+ type ChartCenterProps,
52
+ ChartPending,
53
+ type ChartPendingProps,
54
+ } from './primitives/ChartPending'
49
55
  export { Crosshair, SeriesDot } from './primitives/Crosshair'
50
56
  export { ChartCard } from './primitives/ChartCard'
51
57
  export { ChartLegend, type LegendEntry } from './primitives/ChartLegend'
@@ -139,6 +139,8 @@ export type BarsProps<T> = {
139
139
  legend?: ChartLegendConfig | false
140
140
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
141
141
  ariaLabel?: string
142
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
143
+ isPending?: boolean
142
144
  }
143
145
 
144
146
  /**
@@ -164,6 +166,7 @@ function BarsInner<T>(props: BarsProps<T>) {
164
166
  height,
165
167
  legend,
166
168
  ariaLabel,
169
+ isPending,
167
170
  } = props
168
171
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
169
172
 
@@ -207,6 +210,7 @@ function BarsInner<T>(props: BarsProps<T>) {
207
210
  chartId={chartId}
208
211
  {...(height !== undefined && { height })}
209
212
  {...(ariaLabel !== undefined && { ariaLabel })}
213
+ {...(isPending !== undefined && { isPending })}
210
214
  legend={resolveLegend(legend, {
211
215
  highlighted: highlightedKey,
212
216
  onHighlight: setHighlightedKey,
@@ -53,6 +53,8 @@ export type DonutProps<K extends string = SeriesKey> = {
53
53
  padAngle?: number
54
54
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
55
55
  ariaLabel?: string
56
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
57
+ isPending?: boolean
56
58
  }
57
59
 
58
60
  /**
@@ -64,7 +66,7 @@ export type DonutProps<K extends string = SeriesKey> = {
64
66
  * (donut ↔ bar, via the generalized key) is a distinct, deliberately deferred feature.
65
67
  */
66
68
  function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
67
- const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel } = props
69
+ const { data, height, colorForKey, seriesLabel = (k) => k, ariaLabel, isPending } = props
68
70
 
69
71
  const series: SeriesStyle[] = data.map((d) => ({
70
72
  key: d.key,
@@ -78,6 +80,7 @@ function DonutInner<K extends string = SeriesKey>(props: DonutProps<K>) {
78
80
  series={series}
79
81
  {...(height !== undefined && { height })}
80
82
  {...(ariaLabel !== undefined && { ariaLabel })}
83
+ {...(isPending !== undefined && { isPending })}
81
84
  >
82
85
  {(plot) => <DonutPlot {...props} plot={plot} />}
83
86
  </ChartFrame>
@@ -136,13 +139,16 @@ function DonutPlot<K extends string = SeriesKey>(props: DonutPlotProps<K>) {
136
139
  return (
137
140
  <g
138
141
  key={key}
139
- onMouseEnter={(event) => {
142
+ onPointerEnter={(event) => {
140
143
  show(arc.data, event)
141
144
  }}
142
- onMouseMove={(event) => {
145
+ onPointerMove={(event) => {
143
146
  show(arc.data, event)
144
147
  }}
145
- onMouseLeave={() => {
148
+ onPointerLeave={() => {
149
+ hide()
150
+ }}
151
+ onPointerCancel={() => {
146
152
  hide()
147
153
  }}
148
154
  style={{ cursor: 'pointer' }}
@@ -57,6 +57,8 @@ export type DualPanelProps<T> = {
57
57
  legend?: ChartLegendConfig | false
58
58
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
59
59
  ariaLabel?: string
60
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
61
+ isPending?: boolean
60
62
  }
61
63
 
62
64
  const PANE_GAP = 12
@@ -74,7 +76,8 @@ const PANE_GAP = 12
74
76
  * when a series has nulls; lines/bars skip null points (visual gaps).
75
77
  */
76
78
  function DualPanelInner<T>(props: DualPanelProps<T>) {
77
- const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel } = props
79
+ const { series, chartId, height, barLabel, barColorPositive, legend, ariaLabel, isPending } =
80
+ props
78
81
 
79
82
  // Default the top-pane line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) —
80
83
  // applied once here so the plotted line, the derived legend swatch, and the derived tooltip row
@@ -101,6 +104,7 @@ function DualPanelInner<T>(props: DualPanelProps<T>) {
101
104
  chartId={chartId}
102
105
  {...(height !== undefined && { height })}
103
106
  {...(ariaLabel !== undefined && { ariaLabel })}
107
+ {...(isPending !== undefined && { isPending })}
104
108
  legend={resolveLegend(legend)}
105
109
  >
106
110
  {(plot) => <DualPanelPlot {...props} series={styledSeries} plot={plot} />}
@@ -8,6 +8,7 @@ import {
8
8
  TooltipRow,
9
9
  useTooltipStyles,
10
10
  } from '../primitives/ChartTooltip'
11
+ import { ChartPending } from '../primitives/ChartPending'
11
12
  import { useChartTooltip } from '../hooks/useChartTooltip'
12
13
  import { VX, alpha } from '../../tokens'
13
14
 
@@ -47,6 +48,14 @@ export type HeatmapProps<T> = {
47
48
  renderTooltip?: (cell: HeatmapCell) => ReactNode
48
49
  /** Optional gradient legend strip below the grid (faint → solid color). */
49
50
  legend?: { min: string; max: string }
51
+ /**
52
+ * The query behind this chart hasn't resolved yet. Renders `ChartPending` (see its JSDoc for the
53
+ * three-state rationale) over the full `width` × `height` box in place of the grid, suppressing
54
+ * the legend strip and every cell/label/tooltip along with it. Unlike the other six kinds,
55
+ * `Heatmap` doesn't compose `ChartFrame` (it takes `width`/`height` directly rather than
56
+ * measuring), so this is handled locally instead of forwarded.
57
+ */
58
+ isPending?: boolean
50
59
  }
51
60
 
52
61
  /** Build a first-seen-ordered list of unique keys from data via an accessor. */
@@ -106,6 +115,7 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
106
115
  colLabel = (c) => c,
107
116
  renderTooltip,
108
117
  legend,
118
+ isPending,
109
119
  } = props
110
120
 
111
121
  const tooltipStyles = useTooltipStyles()
@@ -126,6 +136,12 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
126
136
  return { lookup: map, max: m }
127
137
  }, [data, getRow, getCol, getValue])
128
138
 
139
+ // Every hook above must still run on a pending render (Rules of Hooks) — the early return comes
140
+ // after them. No `ChartFrame` to forward to here (Heatmap measures via required `width`/`height`
141
+ // props, not `useChartSize`), so the placeholder is rendered directly in its place, same footprint,
142
+ // legend strip and all other content omitted along with it.
143
+ if (isPending) return <ChartPending width={width} height={height} />
144
+
129
145
  const legendH = legend ? LEGEND_H + LEGEND_LABEL_H : 0
130
146
  const gridW = Math.max(0, width - PAD_LEFT)
131
147
  const gridH = Math.max(0, height - PAD_TOP - PAD_BOTTOM - legendH)
@@ -152,8 +168,9 @@ function HeatmapInner<T>(props: HeatmapProps<T>) {
152
168
  rx={cellRadius}
153
169
  fill={fill}
154
170
  style={{ cursor: has ? 'pointer' : 'default' }}
155
- onMouseMove={(e) => has && show({ row, col, value }, e)}
156
- onMouseLeave={hide}
171
+ onPointerMove={(e) => has && show({ row, col, value }, e)}
172
+ onPointerLeave={hide}
173
+ onPointerCancel={hide}
157
174
  />
158
175
  )
159
176
  }),
@@ -67,6 +67,8 @@ export type MultiLineProps<T> = {
67
67
  legend?: ChartLegendConfig | false
68
68
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
69
69
  ariaLabel?: string
70
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
71
+ isPending?: boolean
70
72
  }
71
73
 
72
74
  const STAR_R = 6
@@ -96,7 +98,7 @@ function starPath(cx: number, cy: number, r: number): string {
96
98
  * has nulls; each series line skips null points (creating visual gaps).
97
99
  */
98
100
  function MultiLineInner<T>(props: MultiLineProps<T>) {
99
- const { series, chartId, height, legend, ariaLabel } = props
101
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
100
102
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
101
103
 
102
104
  // Default line overlays to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied once
@@ -112,6 +114,7 @@ function MultiLineInner<T>(props: MultiLineProps<T>) {
112
114
  chartId={chartId}
113
115
  {...(height !== undefined && { height })}
114
116
  {...(ariaLabel !== undefined && { ariaLabel })}
117
+ {...(isPending !== undefined && { isPending })}
115
118
  legend={resolveLegend(legend, {
116
119
  highlighted: highlightedKey,
117
120
  onHighlight: setHighlightedKey,
@@ -40,6 +40,8 @@ export type StackedAreaProps<T> = {
40
40
  legend?: ChartLegendConfig | false
41
41
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
42
42
  ariaLabel?: string
43
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
44
+ isPending?: boolean
43
45
  }
44
46
 
45
47
  /**
@@ -48,7 +50,7 @@ export type StackedAreaProps<T> = {
48
50
  * The tooltip renders rows in reversed (top-to-bottom) stack order so it matches the visual stack.
49
51
  */
50
52
  function StackedAreaInner<T>(props: StackedAreaProps<T>) {
51
- const { series, chartId, height, legend, ariaLabel } = props
53
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
52
54
  const [highlightedKey, setHighlightedKey] = useState<string | null>(null)
53
55
 
54
56
  return (
@@ -57,6 +59,7 @@ function StackedAreaInner<T>(props: StackedAreaProps<T>) {
57
59
  chartId={chartId}
58
60
  {...(height !== undefined && { height })}
59
61
  {...(ariaLabel !== undefined && { ariaLabel })}
62
+ {...(isPending !== undefined && { isPending })}
60
63
  legend={resolveLegend(legend, {
61
64
  highlighted: highlightedKey,
62
65
  onHighlight: setHighlightedKey,
@@ -101,6 +101,8 @@ export type ZonedLineProps<T> = {
101
101
  legend?: ChartLegendConfig | false
102
102
  /** Accessible text alternative, forwarded to `ChartFrame` as `aria-label` (+ `role="img"`). */
103
103
  ariaLabel?: string
104
+ /** Forwarded to `ChartFrame` — see `ChartPending`'s JSDoc for the three-state rationale. */
105
+ isPending?: boolean
104
106
  }
105
107
 
106
108
  /**
@@ -116,7 +118,7 @@ export type ZonedLineProps<T> = {
116
118
  * visual gaps).
117
119
  */
118
120
  function ZonedLineInner<T>(props: ZonedLineProps<T>) {
119
- const { series, chartId, height, legend, ariaLabel } = props
121
+ const { series, chartId, height, legend, ariaLabel, isPending } = props
120
122
 
121
123
  // Default the line overlay to the redesign's 1.9px stroke (docs/DESIGN-SPEC.md §5) — applied
122
124
  // once here so the plotted line, the derived legend swatch, and the derived tooltip row agree.
@@ -131,6 +133,7 @@ function ZonedLineInner<T>(props: ZonedLineProps<T>) {
131
133
  chartId={chartId}
132
134
  {...(height !== undefined && { height })}
133
135
  {...(ariaLabel !== undefined && { ariaLabel })}
136
+ {...(isPending !== undefined && { isPending })}
134
137
  legend={resolveLegend(legend)}
135
138
  >
136
139
  {(plot) => <ZonedLinePlot {...props} series={styledSeries} plot={plot} />}