basalt-ui 2.4.0 → 2.6.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 (59) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/MIGRATING.md +51 -12
  3. package/agent/rules/basalt-batteries.md +1 -1
  4. package/agent/rules/basalt-charts.md +1 -1
  5. package/agent/rules/basalt-mantine.md +1 -1
  6. package/agent/rules/basalt-state.md +2 -2
  7. package/agent/skills/basalt-app/SKILL.md +5 -5
  8. package/configs/oxlint-plugin.js +6 -3
  9. package/configs/oxlint-plugin.test.ts +8 -0
  10. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  11. package/dist/agent/ai-sdk-transport.js +4 -1
  12. package/dist/agent/index.d.ts +1 -1
  13. package/dist/agent/index.d.ts.map +1 -1
  14. package/dist/agent/run-records.d.ts +12 -3
  15. package/dist/agent/run-records.d.ts.map +1 -1
  16. package/dist/agent/run-records.js +16 -3
  17. package/dist/agent/transport.d.ts +10 -3
  18. package/dist/agent/transport.d.ts.map +1 -1
  19. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  20. package/dist/agent/use-agent-thread-runs.js +23 -7
  21. package/dist/charts/DualPanel.d.ts.map +1 -1
  22. package/dist/charts/DualPanel.js +14 -5
  23. package/dist/charts/MirroredBars.d.ts.map +1 -1
  24. package/dist/charts/MirroredBars.js +6 -4
  25. package/dist/charts/compile.d.ts +4 -0
  26. package/dist/charts/compile.d.ts.map +1 -1
  27. package/dist/charts/compile.js +40 -11
  28. package/dist/charts/panel.d.ts +6 -0
  29. package/dist/charts/panel.d.ts.map +1 -1
  30. package/dist/charts/panel.js +10 -1
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/provider/index.d.ts.map +1 -1
  34. package/dist/provider/index.js +8 -2
  35. package/dist/state/fields.d.ts +52 -15
  36. package/dist/state/fields.d.ts.map +1 -1
  37. package/dist/state/fields.js +49 -3
  38. package/dist/state.d.ts +2 -1
  39. package/dist/state.d.ts.map +1 -1
  40. package/dist/state.js +2 -0
  41. package/dist/telemetry.d.ts +58 -4
  42. package/dist/telemetry.d.ts.map +1 -1
  43. package/dist/telemetry.js +13 -5
  44. package/llms.txt +2 -2
  45. package/package.json +1 -1
  46. package/src/agent/ai-sdk-transport.ts +5 -2
  47. package/src/agent/index.ts +1 -1
  48. package/src/agent/run-records.ts +27 -3
  49. package/src/agent/transport.ts +11 -5
  50. package/src/agent/use-agent-thread-runs.ts +35 -8
  51. package/src/charts/DualPanel.tsx +13 -4
  52. package/src/charts/MirroredBars.tsx +5 -3
  53. package/src/charts/compile.ts +59 -12
  54. package/src/charts/panel.tsx +16 -1
  55. package/src/index.ts +1 -1
  56. package/src/provider/index.tsx +8 -2
  57. package/src/state/fields.ts +112 -21
  58. package/src/state.ts +2 -0
  59. package/src/telemetry.ts +70 -7
@@ -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.
@@ -217,6 +217,14 @@ export type FieldSetOptions = {
217
217
  /** One option of an enum / multi / range field, labelled through `store.labels()`. */
218
218
  export type FieldOption = { value: string; label: string }
219
219
 
220
+ /** The one label law: a declared label, else the value itself. */
221
+ function labelledOptions(
222
+ values: readonly string[],
223
+ labels: Readonly<Record<string, string | undefined>> | undefined,
224
+ ): readonly FieldOption[] {
225
+ return values.map((value) => ({ value, label: labels?.[value] ?? value }))
226
+ }
227
+
220
228
  /** `toWindow` exists on a range handle and nowhere else — it is not an optional convenience. */
221
229
  type RangeHandleExtras<F> = F extends {
222
230
  kind: 'range'
@@ -279,22 +287,14 @@ type NumberHandleExtras<F> = F extends { kind: 'number' }
279
287
  readonly step?: undefined
280
288
  }
281
289
 
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 })`. */
290
+ /** A `createLocalStore` field: the shared handle plus `withFallback`. */
294
291
  export type LocalFieldHandle<F extends AnyField> = FieldHandle<F> & {
295
- use(
296
- read?: FieldReadOptions<F>,
297
- ): readonly [FieldValue<F>, (next: FieldValue<F>, opts?: FieldSetOptions) => void]
292
+ /**
293
+ * The same field with a call-site unset value (a prop-derived leader), for `use()` and any
294
+ * control as `field=`. Rule: `basalt-state`. Stable per fallback: a primitive is cached for the
295
+ * LAST value only, an object (range, multi) by identity — memoise objects at the caller.
296
+ */
297
+ withFallback(fallback: FieldValue<F>): FieldHandle<F>
298
298
  }
299
299
 
300
300
  /**
@@ -789,7 +789,7 @@ type StoreRecord = Record<string, unknown>
789
789
  export type FieldWrite = (next: unknown, opts?: FieldSetOptions) => void
790
790
 
791
791
  /** One field's `use()`, untyped. @internal — see `FieldCodec`. */
792
- export type FieldUse = (read?: FieldReadOptions<AnyField>) => readonly [unknown, FieldWrite]
792
+ export type FieldUse = (read?: { fallback?: unknown }) => readonly [unknown, FieldWrite]
793
793
 
794
794
  /** One named field plus its codec, in declaration order. @internal — see `FieldCodec`. */
795
795
  export type StoreEntry = { readonly name: string; readonly codec: FieldCodec }
@@ -986,8 +986,7 @@ export function createStoreCore(o: StoreCoreOptions): StoreCore {
986
986
  const readRecord = (): StoreRecord => toRecord(readPersistedValue(o.key, version))
987
987
 
988
988
  const optionsFor = (entry: StoreEntry): readonly FieldOption[] => {
989
- const map = labels.get(entry.name)
990
- return entry.codec.optionValues.map((value) => ({ value, label: map?.[value] ?? value }))
989
+ return labelledOptions(entry.codec.optionValues, labels.get(entry.name))
991
990
  }
992
991
 
993
992
  const clearField = (name: string): void => {
@@ -1165,9 +1164,39 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
1165
1164
  const persisted = entry.codec.lane.persist
1166
1165
  const use = persisted ? persistedUse(entry) : core.memoryUse(entry)
1167
1166
  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
- >
1167
+ // Two handles over one field: the public one carries `withFallback`, a derived one is built
1168
+ // on the bare one, so `derived.withFallback` is undefined at runtime as well as in the type.
1169
+ const bare = core.handle(entry, use, clear)
1170
+ // Object.create, never a spread: `options` and `fallback` are live getters, and a spread would
1171
+ // freeze them at derivation. Only what the call-site fallback changes is shadowed.
1172
+ const derive = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
1173
+ const useDerived = () => use({ fallback })
1174
+ return Object.create(bare, {
1175
+ use: { value: useDerived, enumerable: true },
1176
+ fallback: { get: () => fallback, enumerable: true },
1177
+ isDefault: { value: (v: unknown) => entry.codec.equals(v, fallback), enumerable: true },
1178
+ }) as FieldHandle<AnyField>
1179
+ }
1180
+ // Objects by identity (weak); a primitive only for the last value, so a high-cardinality
1181
+ // fallback (a latest date, an id) cannot grow the cache. A function is never cached.
1182
+ const byIdentity = new WeakMap<object, FieldHandle<AnyField>>()
1183
+ let last: { value: FieldValue<AnyField>; handle: FieldHandle<AnyField> } | undefined
1184
+ const withFallback = (fallback: FieldValue<AnyField>): FieldHandle<AnyField> => {
1185
+ if (typeof fallback === 'function') return derive(fallback)
1186
+ if (typeof fallback === 'object') {
1187
+ const cached = byIdentity.get(fallback)
1188
+ if (cached !== undefined) return cached
1189
+ const handle = derive(fallback)
1190
+ byIdentity.set(fallback, handle)
1191
+ return handle
1192
+ }
1193
+ if (last !== undefined && Object.is(last.value, fallback)) return last.handle
1194
+ last = { value: fallback, handle: derive(fallback) }
1195
+ return last.handle
1196
+ }
1197
+ field[entry.name as keyof S] = Object.assign(core.handle(entry, use, clear), {
1198
+ withFallback,
1199
+ }) as unknown as LocalFieldHandle<S[keyof S]>
1171
1200
  }
1172
1201
 
1173
1202
  const store: LocalStore<S> = {
@@ -1181,3 +1210,65 @@ export function createLocalStore<const S extends Record<string, AnyField>>(o: {
1181
1210
 
1182
1211
  return store
1183
1212
  }
1213
+
1214
+ // ── externalField — a handle over a value another store owns ──────────────────────────────────
1215
+
1216
+ /**
1217
+ * The non-hook surface of a store basalt does not own — what zustand's `store.getState` /
1218
+ * `store.setState` / `store.subscribe` already are, and what `useSyncExternalStore` wants.
1219
+ */
1220
+ export type ExternalStore<T> = {
1221
+ get: () => T
1222
+ set: (next: T) => void
1223
+ subscribe: (onChange: () => void) => () => void
1224
+ }
1225
+
1226
+ /**
1227
+ * A closed-enum field whose value lives in a store basalt does not own (a zustand slice, a context)
1228
+ * — the bridge for a runtime mode that is neither page view state (URL) nor a preference
1229
+ * (localStorage), so no `createSearchStore` / `createLocalStore` field may hold it without forking
1230
+ * the truth. Returns the SAME `FieldHandle` those stores hand out, so `ViewTabs`, `SelectFilter`
1231
+ * and `EnumFilter` bind to it as `field=` with no `value`/`onChange`.
1232
+ *
1233
+ * There is no lane: the handle reads and writes ONLY through `store`, which is taken at
1234
+ * DEFINITION so a write never depends on a render — `clear()` calls `store.set(fallback)` directly
1235
+ * (a foreign store has no "unset"), mounted or not, however many controls bind the handle. Labels
1236
+ * are declared here like every other field's. The setter ignores `FieldSetOptions`: `patch` merges
1237
+ * into a URL navigate and a lane-less write has none, the same contract the local lane documents.
1238
+ *
1239
+ * @example
1240
+ * const timerMode = externalField({
1241
+ * values: ['rest', 'interval'],
1242
+ * fallback: 'rest',
1243
+ * labels: { rest: 'Rest Timer', interval: 'Interval Timer' },
1244
+ * store: {
1245
+ * get: () => useTimerStore.getState().mode,
1246
+ * set: (mode) => useTimerStore.getState().setMode(mode),
1247
+ * subscribe: useTimerStore.subscribe,
1248
+ * },
1249
+ * })
1250
+ * // <WidgetHeader actions={<ViewTabs field={timerMode} label="Timer mode" />} />
1251
+ */
1252
+ export function externalField<const T extends string>(o: {
1253
+ values: readonly T[]
1254
+ fallback: NoInfer<T>
1255
+ /** Option labels, keyed by value; an unlabelled value shows as itself. */
1256
+ labels?: Partial<Record<T, string>>
1257
+ store: ExternalStore<T>
1258
+ }): FieldHandle<EnumField<T>> {
1259
+ const { values, fallback, store } = o
1260
+ if (!values.includes(fallback)) {
1261
+ throw new Error(
1262
+ `basalt-ui: externalField fallback '${fallback}' is not one of its values (${values.join(', ')}).`,
1263
+ )
1264
+ }
1265
+ const setter = (next: T): void => store.set(next)
1266
+ return {
1267
+ kind: 'enum',
1268
+ fallback,
1269
+ options: labelledOptions(values, o.labels),
1270
+ use: () => [useSyncExternalStore(store.subscribe, store.get, store.get), setter] as const,
1271
+ clear: () => store.set(fallback),
1272
+ isDefault: (v) => v === fallback,
1273
+ }
1274
+ }
package/src/state.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * - `createPersistedState` — the versioned localStorage primitive every basalt module persists through.
6
6
  * - `field.*`, `FieldHandle`, `EnumField`, `RangeWindow` and `createLocalStore` — the store field
7
7
  * vocabulary and the router-free store.
8
+ * - `externalField` — a `FieldHandle` over a value another store owns (a zustand slice).
8
9
  * - `createSearchStore` — the URL-backed store (the store half of 1.x `./router-tanstack`).
9
10
  *
10
11
  * Internal consumers import the wider field types from `./state/fields`.
@@ -14,6 +15,7 @@ export { createPersistedState } from './state/persisted'
14
15
 
15
16
  export {
16
17
  field,
18
+ externalField,
17
19
  createLocalStore,
18
20
  type EnumField,
19
21
  type FieldHandle,
package/src/telemetry.ts CHANGED
@@ -65,7 +65,61 @@ export type BasaltEvent =
65
65
  | Ok<'chat.turn', ChatAttrs>
66
66
  | Err<'chat.turn', ChatAttrs, Span>
67
67
 
68
- export type Telemetry = (event: BasaltEvent) => void
68
+ /** W3C trace-context request headers a sink contributes to one chat turn's request. */
69
+ export type TraceHeaders = {
70
+ readonly traceparent: string
71
+ readonly tracestate?: string | undefined
72
+ }
73
+
74
+ /**
75
+ * How a traced chat turn ended: its `chat.turn` event fired (`settled`/`failed`), or `stopped` — a
76
+ * `stop()`, an unmount or a newer run taking the thread over — which emits no event. The one
77
+ * lifecycle vocabulary of the run records too.
78
+ */
79
+ export type ChatTurnEnd = 'settled' | 'failed' | 'stopped'
80
+
81
+ /** The turn a sink is asked to trace. */
82
+ type ChatTurnInit = { readonly threadId: string; readonly messageId: string }
83
+
84
+ /**
85
+ * What a sink returns from `startChatTurn`. `settle` runs once, synchronously, right after the
86
+ * turn's `chat.turn` event (`settled`/`failed`), or when the turn is cut short (`stopped`) — the
87
+ * point to end a span opened in `startChatTurn`. It runs even when `headers` is absent or
88
+ * malformed (dropped, the request goes out untraced), so the span never leaks.
89
+ */
90
+ export type ChatTurnTrace = {
91
+ readonly headers?: TraceHeaders | undefined
92
+ readonly settle?: ((end: ChatTurnEnd) => void) | undefined
93
+ }
94
+
95
+ /**
96
+ * The event sink. It may carry one optional hook, `startChatTurn`, called when a new chat turn
97
+ * begins (not on a resume): open your span there and return its `traceparent` (and `tracestate`)
98
+ * and `aiSdkTransport` sends them with that turn's request to the chat endpoint — never anywhere
99
+ * else. A sink without the hook, or no sink, changes nothing on the wire. basalt has no OTel
100
+ * dependency; the hook is where the consumer's SDK does the work.
101
+ *
102
+ * @example
103
+ * export const telemetry: Telemetry = Object.assign(onEvent, {
104
+ * startChatTurn({ threadId }) {
105
+ * const span = tracer.startSpan('chat.turn', {
106
+ * attributes: { 'basalt.chat.thread_id': threadId },
107
+ * })
108
+ * const carrier: Record<string, string> = {}
109
+ * propagation.inject(trace.setSpan(context.active(), span), carrier)
110
+ * return {
111
+ * headers: { traceparent: carrier.traceparent ?? '', tracestate: carrier.tracestate },
112
+ * settle(end) {
113
+ * if (end === 'failed') span.setStatus({ code: SpanStatusCode.ERROR })
114
+ * span.end()
115
+ * },
116
+ * }
117
+ * },
118
+ * })
119
+ */
120
+ export type Telemetry = ((event: BasaltEvent) => void) & {
121
+ readonly startChatTurn?: ((turn: ChatTurnInit) => ChatTurnTrace | undefined) | undefined
122
+ }
69
123
 
70
124
  export const now = (): number => performance.timeOrigin + performance.now()
71
125
 
@@ -73,18 +127,27 @@ export const now = (): number => performance.timeOrigin + performance.now()
73
127
  export const errorName = (error: unknown): string => (error instanceof Error ? error.name : 'Error')
74
128
 
75
129
  /**
76
- * Wrap a sink so it is called synchronously and a throwing sink never breaks a render. No sink, no
77
- * events.
130
+ * The wrapped emitter over a sink read at call time (a ref), so a swapped sink is never stale. It is
131
+ * called synchronously and a throwing sink never breaks a render; no sink, no events. Its
132
+ * `startChatTurn` forwards the sink's hook; the hook's consumer validates the headers.
78
133
  */
79
- export function createEmitter(sink: Telemetry | undefined): Telemetry {
80
- if (sink === undefined) return noop
81
- return (event) => {
134
+ export function createLiveEmitter(read: () => Telemetry | undefined): Telemetry {
135
+ const emit = (event: BasaltEvent): void => {
82
136
  try {
83
- sink(event)
137
+ read()?.(event)
84
138
  } catch {
85
139
  // a throwing sink must never break the app (§ 7 delivery rules)
86
140
  }
87
141
  }
142
+ return Object.assign(emit, {
143
+ startChatTurn(turn: ChatTurnInit): ChatTurnTrace | undefined {
144
+ try {
145
+ return read()?.startChatTurn?.(turn)
146
+ } catch {
147
+ return undefined
148
+ }
149
+ },
150
+ })
88
151
  }
89
152
 
90
153
  function noop(): void {}