basalt-ui 2.4.0 → 2.5.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 (49) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/MIGRATING.md +34 -12
  3. package/agent/rules/basalt-state.md +1 -1
  4. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  5. package/dist/agent/ai-sdk-transport.js +4 -1
  6. package/dist/agent/index.d.ts +1 -1
  7. package/dist/agent/index.d.ts.map +1 -1
  8. package/dist/agent/run-records.d.ts +12 -3
  9. package/dist/agent/run-records.d.ts.map +1 -1
  10. package/dist/agent/run-records.js +16 -3
  11. package/dist/agent/transport.d.ts +10 -3
  12. package/dist/agent/transport.d.ts.map +1 -1
  13. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  14. package/dist/agent/use-agent-thread-runs.js +23 -7
  15. package/dist/charts/DualPanel.d.ts.map +1 -1
  16. package/dist/charts/DualPanel.js +14 -5
  17. package/dist/charts/MirroredBars.d.ts.map +1 -1
  18. package/dist/charts/MirroredBars.js +6 -4
  19. package/dist/charts/compile.d.ts +4 -0
  20. package/dist/charts/compile.d.ts.map +1 -1
  21. package/dist/charts/compile.js +40 -11
  22. package/dist/charts/panel.d.ts +6 -0
  23. package/dist/charts/panel.d.ts.map +1 -1
  24. package/dist/charts/panel.js +10 -1
  25. package/dist/index.d.ts +1 -1
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/provider/index.d.ts.map +1 -1
  28. package/dist/provider/index.js +8 -2
  29. package/dist/state/fields.d.ts +10 -15
  30. package/dist/state/fields.d.ts.map +1 -1
  31. package/dist/state/fields.js +27 -1
  32. package/dist/telemetry.d.ts +58 -4
  33. package/dist/telemetry.d.ts.map +1 -1
  34. package/dist/telemetry.js +13 -5
  35. package/llms.txt +2 -2
  36. package/package.json +1 -1
  37. package/src/agent/ai-sdk-transport.ts +5 -2
  38. package/src/agent/index.ts +1 -1
  39. package/src/agent/run-records.ts +27 -3
  40. package/src/agent/transport.ts +11 -5
  41. package/src/agent/use-agent-thread-runs.ts +35 -8
  42. package/src/charts/DualPanel.tsx +13 -4
  43. package/src/charts/MirroredBars.tsx +5 -3
  44. package/src/charts/compile.ts +59 -12
  45. package/src/charts/panel.tsx +16 -1
  46. package/src/index.ts +1 -1
  47. package/src/provider/index.tsx +8 -2
  48. package/src/state/fields.ts +41 -19
  49. package/src/telemetry.ts +70 -7
@@ -12,10 +12,12 @@
12
12
  * covers: it outlives the run only until a render shows the outcome (`sweepFlushed`).
13
13
  */
14
14
 
15
+ import type { ChatTurnEnd, ChatTurnTrace } from '../telemetry'
16
+
15
17
  /** The part of a rendered `runs` entry this module reads. */
16
18
  type RenderedRun<TPart> = { readonly status: string; readonly parts: TPart[] }
17
19
 
18
- export type RunEnd = 'settled' | 'failed' | 'stopped'
20
+ export type RunEnd = ChatTurnEnd
19
21
 
20
22
  export type RunRecord<TPart> = {
21
23
  readonly controller: AbortController
@@ -28,15 +30,22 @@ export type RunRecord<TPart> = {
28
30
  ended: RunEnd | undefined
29
31
  /** A newer run began on the same thread (set by `beginRun` on the record it replaces). */
30
32
  superseded: boolean
33
+ /** The turn's telemetry `settle`, run once by `settleTrace`. */
34
+ settle: ChatTurnTrace['settle']
31
35
  }
32
36
 
33
37
  export type RunRecords<TPart> = Map<string, RunRecord<TPart>>
34
38
 
35
- /** Registers a live run for `threadId`, replacing whatever record the previous run left. */
39
+ /**
40
+ * Registers a live run for `threadId`, replacing whatever record the previous run left. A previous
41
+ * run still being driven is cut short: its trace settles as `stopped`. `settle` is the new turn's
42
+ * telemetry hook (absent for a resume).
43
+ */
36
44
  export function beginRun<TPart>(
37
45
  records: RunRecords<TPart>,
38
46
  threadId: string,
39
47
  controller: AbortController,
48
+ settle?: ChatTurnTrace['settle'],
40
49
  ): RunRecord<TPart> {
41
50
  const record: RunRecord<TPart> = {
42
51
  controller,
@@ -45,13 +54,28 @@ export function beginRun<TPart>(
45
54
  resolving: false,
46
55
  ended: undefined,
47
56
  superseded: false,
57
+ settle,
48
58
  }
49
59
  const previous = records.get(threadId)
50
- if (previous !== undefined) previous.superseded = true
60
+ if (previous !== undefined) {
61
+ previous.superseded = true
62
+ settleTrace(previous, 'stopped')
63
+ }
51
64
  records.set(threadId, record)
52
65
  return record
53
66
  }
54
67
 
68
+ /** Runs the turn's telemetry `settle` once; a throwing consumer hook never breaks the run. */
69
+ export function settleTrace<TPart>(record: RunRecord<TPart>, end: RunEnd): void {
70
+ const { settle } = record
71
+ record.settle = undefined
72
+ try {
73
+ settle?.(end)
74
+ } catch {
75
+ // a throwing sink must never break the app (§ 7 delivery rules)
76
+ }
77
+ }
78
+
55
79
  /** Marks a run as no longer driven and drops the content it held. */
56
80
  export function endRun<TPart>(record: RunRecord<TPart>, reason: RunEnd): void {
57
81
  record.ended = reason
@@ -16,6 +16,16 @@ import type { AgentPartDraft } from './parts'
16
16
 
17
17
  // ── AgentTransport ────────────────────────────────────────────────────────────
18
18
 
19
+ /**
20
+ * The third `stream` argument. `headers` are extra request headers for THIS turn's request to the
21
+ * transport's own endpoint — today the W3C trace context a telemetry sink's `startChatTurn`
22
+ * returned. Absent unless a sink contributed; a transport that ignores them keeps working.
23
+ */
24
+ export type AgentStreamContext = {
25
+ readonly messageId: string
26
+ readonly headers?: Readonly<Record<string, string>> | undefined
27
+ }
28
+
19
29
  /**
20
30
  * The injected transport seam. `stream(input, signal, ctx)` returns an async generator of parts.
21
31
  * Implement this interface for any backend — fetch-based SSE, AI SDK, or a mock.
@@ -43,11 +53,7 @@ import type { AgentPartDraft } from './parts'
43
53
  * }
44
54
  */
45
55
  export type AgentTransport<TPart = AgentPartDraft, TInput = string> = {
46
- stream: (
47
- input: TInput,
48
- signal?: AbortSignal,
49
- ctx?: { readonly messageId: string },
50
- ) => AsyncGenerator<TPart>
56
+ stream: (input: TInput, signal?: AbortSignal, ctx?: AgentStreamContext) => AsyncGenerator<TPart>
51
57
  /** Optional: resume a previously-started run from its resumeToken (see StartPart). Transports that don't support resumption simply omit this. */
52
58
  resume?: ((resumeToken: string, signal?: AbortSignal) => AsyncGenerator<TPart>) | undefined
53
59
  }
@@ -50,6 +50,7 @@ import { createRunCommitter, isContentPart } from './run-committer'
50
50
  import {
51
51
  beginRun,
52
52
  endRun,
53
+ settleTrace,
53
54
  failedThreadIds,
54
55
  dismissRun,
55
56
  hasLiveRun,
@@ -62,8 +63,8 @@ import type { RunRecord, RunRecords } from './run-records'
62
63
  import type { AgentThread, LiveRunStatus, ThreadStatus, ThreadsStore } from './thread'
63
64
  import { isResumable } from './transport'
64
65
  import type { AgentTransport } from './transport'
65
- import { TelemetryContext, createEmitter, errorName, now } from '../telemetry'
66
- import type { BasaltEvent, Telemetry } from '../telemetry'
66
+ import { TelemetryContext, createLiveEmitter, errorName, now } from '../telemetry'
67
+ import type { BasaltEvent, Telemetry, TraceHeaders } from '../telemetry'
67
68
 
68
69
  // ── ThreadRunState ────────────────────────────────────────────────────────────
69
70
 
@@ -388,6 +389,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
388
389
  status: outcome.status,
389
390
  }),
390
391
  )
392
+ settleTrace(record, 'settled')
391
393
  } catch (err) {
392
394
  // Guard: the run ended (stop(), unmount — which abort the controller, so their AbortError lands
393
395
  // here) or a newer stream took the thread over. Only an abort from somewhere else gets past.
@@ -421,6 +423,7 @@ async function consumeAndFinalize<TPart extends PartLike>(args: {
421
423
  failure: { error: err, agentError },
422
424
  }),
423
425
  )
426
+ settleTrace(record, 'failed')
424
427
  onError?.({ threadId, error: err })
425
428
  } finally {
426
429
  committer.cancel()
@@ -555,6 +558,24 @@ async function finalizeStop<TPart extends PartLike>(args: {
555
558
  }
556
559
  }
557
560
 
561
+ const TRACEPARENT = /^[\da-f]{2}-[\da-f]{32}-[\da-f]{16}-[\da-f]{2}$/
562
+ const TRACESTATE_MEMBER = /^[\da-z][\w*/@-]{0,255}=[^\s,=]([^,=]{0,254}[^\s,=])?$/
563
+
564
+ /**
565
+ * The wire form of a turn's trace context: a well-formed `traceparent` (else none — the request
566
+ * goes out untraced, the sink's `settle` still runs) and a `tracestate` only if valid.
567
+ */
568
+ function traceRequestHeaders(headers: TraceHeaders): Record<string, string> | undefined {
569
+ const { traceparent, tracestate } = headers
570
+ if (typeof traceparent !== 'string' || !TRACEPARENT.test(traceparent)) return undefined
571
+ const members = typeof tracestate === 'string' ? tracestate.split(',') : []
572
+ const valid =
573
+ members.length > 0 &&
574
+ members.length <= 32 &&
575
+ members.every((member) => TRACESTATE_MEMBER.test(member.trim()))
576
+ return valid ? { traceparent, tracestate: tracestate as string } : { traceparent }
577
+ }
578
+
558
579
  /** The `chat.turn` event for a turn that completed (`failure` absent) or failed. */
559
580
  function buildTurnEvent(args: {
560
581
  threadId: string
@@ -654,10 +675,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
654
675
  toUserPartsRef.current = resolvedToUserParts
655
676
 
656
677
  // Stable over the sink ref, so an inline `telemetry` function never changes what `start` closes over.
657
- const emitTelemetry = useMemo<Telemetry>(
658
- () => createEmitter((event) => telemetryRef.current?.(event)),
659
- [],
660
- )
678
+ const emitTelemetry = useMemo<Telemetry>(() => createLiveEmitter(() => telemetryRef.current), [])
661
679
 
662
680
  // Resolves the transport for `threadId`: the shared object as-is for the plain-object form
663
681
  // (unchanged behavior), or the factory's result — resolved once per thread id and cached in
@@ -707,6 +725,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
707
725
  if (record.ended !== undefined) continue
708
726
  abortedThreadIds.push(threadId)
709
727
  record.controller.abort()
728
+ settleTrace(record, 'stopped')
710
729
  }
711
730
  recordsRef.current.clear()
712
731
  if (abortedThreadIds.length === 0) return
@@ -841,8 +860,12 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
841
860
  })
842
861
 
843
862
  const controller = new AbortController()
844
- const record = beginRun(recordsRef.current, threadId, controller)
863
+ // Opened before the request so its context rides on it; closed by `settleTrace` (a resume is
864
+ // the same turn's reconnect, not a new one, and carries none).
865
+ const trace = emitTelemetry.startChatTurn?.({ threadId, messageId: userMessage.id })
866
+ const record = beginRun(recordsRef.current, threadId, controller, trace?.settle)
845
867
  const resolvedTransport = resolveTransport(threadId)
868
+ const headers = trace?.headers === undefined ? undefined : traceRequestHeaders(trace.headers)
846
869
 
847
870
  // Non-awaited: this consumer runs in the background so start() returns immediately and
848
871
  // multiple threads can stream at once.
@@ -855,7 +878,10 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
855
878
  // id instead of a second, unrelated one (R1 — see AgentTransport.stream's doc).
856
879
  generator: withPartIds(
857
880
  threadId,
858
- resolvedTransport.stream(input, controller.signal, { messageId: userMessage.id }),
881
+ resolvedTransport.stream(input, controller.signal, {
882
+ messageId: userMessage.id,
883
+ ...(headers !== undefined ? { headers } : {}),
884
+ }),
859
885
  ),
860
886
  userMessage,
861
887
  recordsRef,
@@ -913,6 +939,7 @@ export function useAgentThreadRuns<TPart extends PartLike = AgentPart>({
913
939
  const { appended, resolving } = record
914
940
  endRun(record, 'stopped')
915
941
  record.controller.abort()
942
+ settleTrace(record, 'stopped')
916
943
  void finalizeStop({
917
944
  threadId,
918
945
  parts,
@@ -16,7 +16,7 @@ import { VX, alpha } from '../tokens'
16
16
  import { resolveContainerClass } from '../tokens/size-classes'
17
17
  import { defaultFormatX, defaultNumberFormat, engineRow, seriesRow, visibleSeries } from './compile'
18
18
  import { barMark, emphasis, lineMark, markersOf, thinFill } from './marks'
19
- import { PanelRung, paneX, paneY, rowsOf } from './panel'
19
+ import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
20
20
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
21
21
  import { xScaleFor } from './scales'
22
22
  import { rowX } from './types'
@@ -269,13 +269,21 @@ export function DualPanel<T, X extends Date | number = Date | number>(
269
269
  ],
270
270
  scales: {
271
271
  x: { scale: xScale(), axis: false },
272
- y: paneY({ scale: topY, domain: topDomain, format: topFormat, ticks: TOP_TICKS, cls }),
272
+ y: paneY({
273
+ scale: topY,
274
+ domain: topDomain,
275
+ format: topFormat,
276
+ ticks: TOP_TICKS,
277
+ cls,
278
+ height: chartHeightOf(h, topFraction, PANE_GAP),
279
+ }),
273
280
  },
274
281
  }
275
282
  }
276
283
 
277
- const barPane: PaneBuilder = ({ width }) => {
284
+ const barPane: PaneBuilder = ({ width, height: h }) => {
278
285
  const cls = resolveContainerClass(width)
286
+ const whole = chartHeightOf(h, 1 - topFraction, PANE_GAP)
279
287
  const barRows = barOn ? rowsOf(data, x, barSeries) : []
280
288
  const dim = emphasis(highlighted, { key: BAR_KEY })
281
289
  return {
@@ -290,13 +298,14 @@ export function DualPanel<T, X extends Date | number = Date | number>(
290
298
  }),
291
299
  ],
292
300
  scales: {
293
- x: paneX({ scale: xScale(), model, format, cls }),
301
+ x: paneX({ scale: xScale(), model, format, cls, height: whole }),
294
302
  y: paneY({
295
303
  scale: barScale,
296
304
  domain: barDomain,
297
305
  format: bar.format,
298
306
  ticks: BAR_TICKS,
299
307
  cls,
308
+ height: whole,
300
309
  }),
301
310
  },
302
311
  }
@@ -23,7 +23,7 @@ import { VX, alpha } from '../tokens'
23
23
  import { resolveContainerClass } from '../tokens/size-classes'
24
24
  import { defaultFormatX, defaultNumberFormat, seriesRow, visibleSeries } from './compile'
25
25
  import { barMark, emphasis } from './marks'
26
- import { PanelRung, paneX, paneY, rowsOf } from './panel'
26
+ import { PanelRung, chartHeightOf, paneX, paneY, rowsOf } from './panel'
27
27
  import type { PaneBuilder, PanelInput, PanelPlan } from './panel'
28
28
  import { xScaleFor } from './scales'
29
29
  import { rowX } from './types'
@@ -174,8 +174,9 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
174
174
  on: boolean,
175
175
  max: number,
176
176
  ): PaneBuilder =>
177
- ({ width: w }) => {
177
+ ({ width: w, height: h }) => {
178
178
  const cls = resolveContainerClass(w)
179
+ const whole = chartHeightOf(h, side === 'up' ? upFraction : 1 - upFraction, 0)
179
180
  // The down pane's zero is at its top.
180
181
  const domain: [number, number] = side === 'up' ? [0, max] : [max, 0]
181
182
  const rows: PlotRow<T, X>[] = on ? rowsOf(drawn, x, s) : []
@@ -249,7 +250,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
249
250
  scales: {
250
251
  x:
251
252
  side === 'down'
252
- ? paneX({ scale: bandScale(), model, format, cls })
253
+ ? paneX({ scale: bandScale(), model, format, cls, height: whole })
253
254
  : { scale: bandScale(), axis: false },
254
255
  y: paneY({
255
256
  scale: scaleLinear().domain(domain),
@@ -257,6 +258,7 @@ export function MirroredBars<T, X extends ChartX = ChartX>(
257
258
  format: cfg.format,
258
259
  ticks: cfg.ticks ?? DEFAULT_PANE_TICKS,
259
260
  cls,
261
+ height: whole,
260
262
  }),
261
263
  zx: { channel: 'x', scale: scaleLinear().domain([0, 1]), axis: false },
262
264
  },
@@ -23,6 +23,7 @@ import type {
23
23
  ResponsiveChartDefinition,
24
24
  StoredChartSpec,
25
25
  } from '@tanstack/charts/types/core'
26
+ import { SHORT_CHART_HEIGHT } from './box'
26
27
  import { fmtCompact } from '../format/number'
27
28
  import { VX } from '../tokens'
28
29
  import type { ContainerClass } from '../tokens/size-classes'
@@ -97,6 +98,14 @@ const LADDER = {
97
98
  const INSIDE_LABEL_DX = 4
98
99
  const INSIDE_LABEL_DY = -6
99
100
 
101
+ /**
102
+ * Y labels go inside the plot only for the compact class under `SHORT_CHART_HEIGHT` (the box's own
103
+ * short signal); otherwise they sit in a left gutter. `height` is the whole chart's, `0` before it
104
+ * is measured, which keeps the gutter rather than flipping a first paint inside.
105
+ */
106
+ const labelsInside = (cls: ContainerClass, height: number): boolean =>
107
+ cls === 'compact' && height > 0 && height < SHORT_CHART_HEIGHT
108
+
100
109
  export function defaultNumberFormat(v: number): string {
101
110
  return fmtCompact(v, { digits: Math.abs(v) >= 1000 ? 1 : 2 })
102
111
  }
@@ -180,6 +189,22 @@ const REF_KEY_PREFIX = '__ref-'
180
189
  /** The legend key of a reference line that names itself (`RefLine.legend`). */
181
190
  const refLegendKey = (index: number): string => `${REF_KEY_PREFIX}${index}`
182
191
 
192
+ /**
193
+ * A reference line is drawn translucent (`*-ref`, 20-30% alpha) so it recedes behind the data, but
194
+ * a 16x8 swatch at that alpha vanishes on the dark surface. The legend takes the status solid,
195
+ * which the color gate holds to 3:1 on the panel in both schemes, like a series swatch.
196
+ */
197
+ const SWATCH_SOLID: ReadonlyMap<string, string> = new Map([
198
+ [VX.goodRef, VX.goodSolid],
199
+ [VX.badRef, VX.badSolid],
200
+ [VX.warnRef, VX.warnSolid],
201
+ // The tinted status fills are as translucent, and a consumer may pass either as a line color.
202
+ [VX.good, VX.goodSolid],
203
+ [VX.goodSoft, VX.goodSolid],
204
+ [VX.bad, VX.badSolid],
205
+ [VX.warn, VX.warnSolid],
206
+ ])
207
+
183
208
  /** Legend-only entries for the reference lines that name themselves, in line order. */
184
209
  export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): ChartSeries<T>[] {
185
210
  return (refLines ?? []).flatMap((r, i) =>
@@ -189,7 +214,7 @@ export function refLegendSeries<T>(refLines: readonly RefLine[] | undefined): Ch
189
214
  {
190
215
  key: refLegendKey(i),
191
216
  label: r.legend.label,
192
- color: r.color,
217
+ color: SWATCH_SOLID.get(r.color) ?? r.color,
193
218
  role: 'reference' as const,
194
219
  tooltip: false,
195
220
  getValue: () => null,
@@ -218,8 +243,10 @@ export function yGuide<T>(opts: {
218
243
  cls: ContainerClass
219
244
  side: 'left' | 'right'
220
245
  grid: boolean
246
+ /** The whole chart's height: decides inside or gutter labels. */
247
+ height: number
221
248
  }): Omit<ChartPositionScaleOptions<number>, 'scale'> {
222
- const { cfg, axis, cls, side, grid } = opts
249
+ const { cfg, axis, cls, side, grid, height } = opts
223
250
  const ladder = LADDER[cls]
224
251
  const ticks =
225
252
  typeof cfg?.ticks === 'object'
@@ -227,8 +254,8 @@ export function yGuide<T>(opts: {
227
254
  : { count: typeof cfg?.ticks === 'number' ? cfg.ticks : ladder.yTicks }
228
255
  const gridLine = grid ? { grid: GRID_LINE } : {}
229
256
  if (cls === 'micro') return { ...gridLine, axis: false }
230
- // Compact: labels sit INSIDE the plot, above their grid line, so the plot keeps the full width.
231
- const inside = cls === 'compact'
257
+ // Labels sit INSIDE the plot, above their grid line, so the plot keeps the full width.
258
+ const inside = labelsInside(cls, height)
232
259
  return {
233
260
  ...gridLine,
234
261
  ...(side === 'right' && { side: 'right' as const }),
@@ -276,9 +303,12 @@ export function xGuide<X extends ChartX>(opts: {
276
303
  xTicks: number | readonly X[] | undefined
277
304
  format: (v: ChartValue) => string
278
305
  cls: ContainerClass
306
+ /** The whole chart's height: decides whether the left margin is locked to zero. */
307
+ height: number
279
308
  }): Omit<ChartPositionScaleOptions<ChartValue>, 'scale'> {
280
- const { model, xTicks, format, cls } = opts
309
+ const { model, xTicks, format, cls, height } = opts
281
310
  const ladder = LADDER[cls]
311
+ const inside = labelsInside(cls, height)
282
312
  const ticks = xTickPolicy(model, xTicks ?? ladder.xTicks)
283
313
  return {
284
314
  axis: {
@@ -288,8 +318,8 @@ export function xGuide<X extends ChartX>(opts: {
288
318
  fontSize: ladder.font,
289
319
  opacity: 1,
290
320
  thin: true,
291
- // A locked-zero left margin (compact) must not clip the first label.
292
- ...(cls === 'compact' && {
321
+ // A locked-zero left margin (inside labels) must not clip the first label.
322
+ ...(inside && {
293
323
  anchor: ({ index }: { index: number }) => (index === 0 ? ('start' as const) : undefined),
294
324
  }),
295
325
  },
@@ -634,18 +664,26 @@ function buildScales<T, X extends ChartX>(opts: {
634
664
  axes: Axes<T>
635
665
  formatX: (v: ChartValue) => string
636
666
  cls: ContainerClass
667
+ height: number
637
668
  zoneScale: boolean
638
669
  }): StoredChartSpec['scales'] {
639
- const { input, model, axes, formatX, cls, zoneScale } = opts
670
+ const { input, model, axes, formatX, cls, height, zoneScale } = opts
640
671
  const { left, right } = axes
641
672
  return {
642
673
  x: {
643
674
  scale: model.scale,
644
- ...xGuide({ model, xTicks: input.xTicks, format: formatX, cls }),
675
+ ...xGuide({ model, xTicks: input.xTicks, format: formatX, cls, height }),
645
676
  },
646
677
  y: {
647
678
  scale: yScaleFor(input.y, left.domain),
648
- ...yGuide({ cfg: input.y, axis: left, cls, side: 'left', grid: input.y?.grid ?? true }),
679
+ ...yGuide({
680
+ cfg: input.y,
681
+ axis: left,
682
+ cls,
683
+ height,
684
+ side: 'left',
685
+ grid: input.y?.grid ?? true,
686
+ }),
649
687
  },
650
688
  ...(right && {
651
689
  y2: {
@@ -655,6 +693,7 @@ function buildScales<T, X extends ChartX>(opts: {
655
693
  cfg: input.y2,
656
694
  axis: right,
657
695
  cls,
696
+ height,
658
697
  side: 'right',
659
698
  grid: input.y2?.grid ?? false,
660
699
  }),
@@ -816,11 +855,19 @@ export function compileCartesian<T, X extends ChartX>(
816
855
  })
817
856
  return {
818
857
  marks: [...under, ...kindMarks, ...dots, ...over, crosshair({ x: CROSSHAIR, y: false })],
819
- scales: buildScales({ input, model, axes, formatX, cls, zoneScale: under.length > 0 }),
858
+ scales: buildScales({
859
+ input,
860
+ model,
861
+ axes,
862
+ formatX,
863
+ cls,
864
+ height,
865
+ zoneScale: under.length > 0,
866
+ }),
820
867
  theme: CHART_THEME,
821
868
  ...(gradients.length > 0 && { gradients }),
822
869
  clip: true,
823
- ...(cls === 'compact' && { margin: { left: 0, ...(axes.right && { right: 0 }) } }),
870
+ ...(labelsInside(cls, height) && { margin: { left: 0, ...(axes.right && { right: 0 }) } }),
824
871
  }
825
872
  },
826
873
  // Followers paint the crosshair only, unless the chart opts into following tooltips.
@@ -83,12 +83,15 @@ export const paneY = (opts: {
83
83
  format: (v: number) => string
84
84
  ticks: number
85
85
  cls: ContainerClass
86
+ /** The whole chart's height ({@link chartHeightOf}). */
87
+ height: number
86
88
  }): StoredChartSpec['scales'][string] => ({
87
89
  scale: opts.scale,
88
90
  ...yGuide({
89
91
  cfg: { ticks: opts.ticks },
90
92
  axis: { domain: opts.domain, format: opts.format },
91
93
  cls: opts.cls,
94
+ height: opts.height,
92
95
  side: 'left',
93
96
  grid: true,
94
97
  }),
@@ -102,11 +105,23 @@ export const paneX = <X extends ChartX>(opts: {
102
105
  model: XScaleModel<X>
103
106
  format: (v: ChartValue) => string
104
107
  cls: ContainerClass
108
+ /** The whole chart's height ({@link chartHeightOf}). */
109
+ height: number
105
110
  }): StoredChartSpec['scales'][string] => ({
106
111
  scale: opts.scale,
107
- ...xGuide({ model: opts.model, xTicks: undefined, format: opts.format, cls: opts.cls }),
112
+ ...xGuide({
113
+ model: opts.model,
114
+ xTicks: undefined,
115
+ format: opts.format,
116
+ cls: opts.cls,
117
+ height: opts.height,
118
+ }),
108
119
  })
109
120
 
121
+ /** The whole chart's height from one pane's: a pane gets `share` of the height less the gap. */
122
+ export const chartHeightOf = (paneHeight: number, share: number, gap: number): number =>
123
+ paneHeight > 0 && share > 0 ? paneHeight / share + gap : 0
124
+
110
125
  /** One row per datum for a series: a `null` or non-finite value is kept as a gap. */
111
126
  export function rowsOf<T, X extends ChartX>(
112
127
  data: readonly T[],
package/src/index.ts CHANGED
@@ -12,7 +12,7 @@ export {
12
12
  type BasaltRouter,
13
13
  type BasaltRouterContext,
14
14
  } from './provider/create-router'
15
- export type { BasaltEvent } from './telemetry'
15
+ export type { BasaltEvent, ChatTurnEnd, ChatTurnTrace, Telemetry, TraceHeaders } from './telemetry'
16
16
  export {
17
17
  defineNav,
18
18
  navGroup,
@@ -36,7 +36,13 @@ import { useIsomorphicLayoutEffect } from '../common/isomorphic-layout-effect'
36
36
  import { PageTitle } from '../shell/page-title'
37
37
  import { SizeClassHintContext } from '../shell/use-size-class'
38
38
  import type { SizeClass } from '../shell/use-size-class'
39
- import { TelemetryContext, bindRouterTelemetry, createEmitter, errorName, now } from '../telemetry'
39
+ import {
40
+ TelemetryContext,
41
+ bindRouterTelemetry,
42
+ createLiveEmitter,
43
+ errorName,
44
+ now,
45
+ } from '../telemetry'
40
46
  import type { Telemetry } from '../telemetry'
41
47
  import type { BasaltDevDockTool } from '../dev-dock'
42
48
  import type { BasaltRouter, BasaltRouterContext } from './create-router'
@@ -426,7 +432,7 @@ export function BasaltProvider({
426
432
  useEffect(() => {
427
433
  sinkRef.current = telemetry
428
434
  })
429
- const emit = useMemo(() => createEmitter((event) => sinkRef.current?.(event)), [])
435
+ const emit = useMemo(() => createLiveEmitter(() => sinkRef.current), [])
430
436
  useWindowErrors(emit)
431
437
  useRegistryHotkeys()
432
438
  // `overlays.confirm` is an imperative call; this tells it a `ModalsProvider` is live.
@@ -279,22 +279,14 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
279
279
  readonly step?: undefined
280
280
  }
281
281
 
282
- /**
283
- * `createLocalStore`'s `use()` read options. `fallback` is a CALL-SITE default for a value that
284
- * arrives as a prop (a leader, a latest date): it applies only while the field is unset (nothing
285
- * stored), is applied in render AFTER the store read, and is never persisted — `clear()` unsets
286
- * back to it. `handle.fallback` and `isDefault` still report the definition's.
287
- *
288
- * An object fallback (range, multi) must be memoised by the caller: the value returned while unset
289
- * IS that object, so a fresh literal per render changes identity every render.
290
- */
291
- export type FieldReadOptions<F extends AnyField> = { fallback?: FieldValue<F> }
292
-
293
- /** A `createLocalStore` field: the shared handle, plus `use({ fallback })`. */
282
+ /** A `createLocalStore` field: the shared handle plus `withFallback`. */
294
283
  export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
295
- use(
296
- read?: FieldReadOptions<F>,
297
- ): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
284
+ /**
285
+ * The same field with a call-site unset value (a prop-derived leader), for `use()` and any
286
+ * control as `field=`. Rule: `basalt-state`. Stable per fallback: a primitive is cached for the
287
+ * LAST value only, an object (range, multi) by identity — memoise objects at the caller.
288
+ */
289
+ withFallback(fallback: FieldValue<F>): FieldHandle<F>
298
290
  }
299
291
 
300
292
  /**
@@ -789,7 +781,7 @@ type StoreRecord = Record<string, unknown>
789
781
  export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
790
782
 
791
783
  /** One field's `use()`, untyped. @internal — see `FieldCodec`. */
792
- export type FieldUse = (read?: FieldReadOptions<AnyField>) => readonly [unknown, FieldWrite]
784
+ export type FieldUse = (read?: { fallback?: unknown }) => readonly [unknown, FieldWrite]
793
785
 
794
786
  /** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
795
787
  export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
@@ -1165,9 +1157,39 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
1165
1157
  const persisted = entry.codec.lane.persist
1166
1158
  const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
1167
1159
  const clear = persisted ? () => core.clearField(entry.name) : () => core.clearMemory(entry.name)
1168
- field[entry.name as keyof S] = core.handle(entry, use, clear) as unknown as LocalFieldHandle<
1169
- S[keyof S]
1170
- >
1160
+ // Two handles over one field: the public one carries `withFallback`, a derived one is built
1161
+ // on the bare one, so `derived.withFallback` is undefined at runtime as well as in the type.
1162
+ const bare = core.handle(entry, use, clear)
1163
+ // Object.create, never a spread: `options` and `fallback` are live getters, and a spread would
1164
+ // freeze them at derivation. Only what the call-site fallback changes is shadowed.
1165
+ const derive = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
1166
+ const useDerived = () => use({ fallback })
1167
+ return Object.create(bare, {
1168
+ use: { value: useDerived, enumerable: true },
1169
+ fallback: { get: () => fallback, enumerable: true },
1170
+ isDefault: { value: (v: unknown) => entry.codec.equals(v, fallback), enumerable: true },
1171
+ }) as FieldHandle<AnyField>
1172
+ }
1173
+ // Objects by identity (weak); a primitive only for the last value, so a high-cardinality
1174
+ // fallback (a latest date, an id) cannot grow the cache. A function is never cached.
1175
+ const byIdentity = new WeakMap<object, FieldHandle<AnyField>>()
1176
+ let last: { value: FieldValue<AnyField>; handle: FieldHandle<AnyField> } | undefined
1177
+ const withFallback = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
1178
+ if (typeof fallback === 'function') return derive(fallback)
1179
+ if (typeof fallback === 'object') {
1180
+ const cached = byIdentity.get(fallback)
1181
+ if (cached !== undefined) return cached
1182
+ const handle = derive(fallback)
1183
+ byIdentity.set(fallback, handle)
1184
+ return handle
1185
+ }
1186
+ if (last !== undefined && Object.is(last.value, fallback)) return last.handle
1187
+ last = { value: fallback, handle: derive(fallback) }
1188
+ return last.handle
1189
+ }
1190
+ field[entry.name as keyof S] = Object.assign(core.handle(entry, use, clear), {
1191
+ withFallback,
1192
+ }) as unknown as LocalFieldHandle<S[keyof S]>
1171
1193
  }
1172
1194
 
1173
1195
  const store: LocalStore<S> = {