@dbx-tools/ui-mastra 0.6.14 → 0.6.27

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.
package/README.md CHANGED
@@ -233,6 +233,41 @@ The UI understands the extra events produced by
233
233
  - MLflow trace headers enable per-message feedback controls when the server
234
234
  reports feedback is available.
235
235
 
236
+ ## Chart Theming
237
+
238
+ An ECharts chart draws to a canvas, so it inherits nothing from CSS the way its
239
+ frame does: a dark theme re-skins the border around the chart while the axis
240
+ labels, grid lines, and tooltip inside keep whatever colors the spec was born
241
+ with. And the spec is born on the server, in
242
+ [`@dbx-tools/appkit-mastra`](../../node/appkit-mastra), which cannot know the
243
+ reader's theme.
244
+
245
+ So the two halves are split. The planner inlines only what is
246
+ theme-independent - the brand's series palette and font stack. Everything
247
+ theme-dependent is resolved here at render time by `src/support/chart-theme.ts`,
248
+ which reads AppKit's `--chart-axis-label`, `--chart-axis-title`, `--chart-grid`,
249
+ and `--chart-tooltip-bg` (plus `--popover-foreground` and `--border` for the
250
+ tooltip) into a `ChartChrome`, and `normalizeChartOption` paints it onto the
251
+ axes, title, legend, and tooltip. Nothing to configure: a chart follows the
252
+ theme wherever it renders.
253
+
254
+ Two details worth knowing if you embed the chat:
255
+
256
+ - Tokens are read off the **chart's own element**, not `:root`, so a theme
257
+ scoped to a subtree - a chat panel inside an otherwise-light app - wins. The
258
+ value re-resolves when a `.dark` / `.light` class lands on the document root
259
+ and when the OS `prefers-color-scheme` flips, so a live theme switch recolors
260
+ charts in place.
261
+ - AppKit darkens its tokens under `@media (prefers-color-scheme: dark)` for any
262
+ `:root` that is not explicitly `.light`. If your host renders its own light
263
+ chrome, pin `.light` (or `.dark`) on `:root` rather than leaving AppKit on the
264
+ OS preference, or the chat will read as half-dark while everything around it
265
+ stays light.
266
+
267
+ The PDF export is the one caller that does not follow the page: it pins
268
+ `LIGHT_CHART_CHROME`, because its document forces `color-scheme: light` on a
269
+ white body for printing.
270
+
236
271
  ## Export Conversations
237
272
 
238
273
  ```tsx
@@ -283,6 +318,13 @@ touching `navigator.clipboard` or `URL.createObjectURL` again.
283
318
  - `src/support/thread-labels.ts` - `threadTitle` / `relativeTime`, shared by
284
319
  the sidebar and the tab strip so a row and a tab never disagree about how an
285
320
  untitled or freshly-updated conversation reads.
321
+ - `src/support/chart-theme.ts` - `ChartChrome`, `LIGHT_CHART_CHROME`,
322
+ `resolveChartChrome`, and the `useChartChrome` hook that keeps an inline
323
+ chart's colors in step with the active theme.
324
+ - `src/support/chart-option.ts` - `normalizeChartOption`, the pure pass that
325
+ patches layout (compact ticks, axis-name placement, title/grid spacing) and
326
+ an optional `ChartChrome` into a planner spec, shared by the inline chart and
327
+ the PDF export so both read the same.
286
328
  - Types - `ChatViewProps`, `MastraChatProps`, `UseMastraChatOptions`,
287
329
  `ThreadPlacement`, `ThreadSummary`, `ToolEvent`, `ToolProgress`,
288
330
  `PendingApproval`, `FeedbackSubmission`, and related UI contract types.
package/index.ts CHANGED
@@ -17,6 +17,7 @@ export * as reactThreadTabs from "./src/react/thread-tabs.tsx";
17
17
  export * as reactToolPill from "./src/react/tool-pill.tsx";
18
18
  export * as reactTypes from "./src/react/types.ts";
19
19
  export * as supportChartOption from "./src/support/chart-option.ts";
20
+ export * as supportChartTheme from "./src/support/chart-theme.ts";
20
21
  export * as supportClipboard from "./src/support/clipboard.ts";
21
22
  export * as supportDownload from "./src/support/download.ts";
22
23
  export * as supportExport from "./src/support/export.ts";
@@ -45,6 +46,8 @@ export { ThreadTabs } from "./src/react/thread-tabs.tsx";
45
46
  export type { ThreadTabsProps } from "./src/react/thread-tabs.tsx";
46
47
  export { humanizeToolName, ToolSessionPill } from "./src/react/tool-pill.tsx";
47
48
  export type { ChatStatus, ToolEvent, ToolProgress, ChatModelOption, QueuedSteer, FeedbackValue, FeedbackSubmission, MessageFeedback, ThreadSummary, ThreadPlacement, ChatViewProps, ApprovalDecision, PendingApproval } from "./src/react/types.ts";
49
+ export { LIGHT_CHART_CHROME } from "./src/support/chart-theme.ts";
50
+ export type { ChartChrome } from "./src/support/chart-theme.ts";
48
51
  export type { ExportFormat, ExportBrand, EmbedResolver, ExportChatOptions } from "./src/support/export.ts";
49
52
  export { MastraPluginClient, useMastraConfig, useMastraClient, useMastraModels, useMastraDefaultModel, useMastraSuggestions, useMastraThreads, useChartFetch, useStatementFetch } from "./src/support/mastra-client.ts";
50
53
  export type { ByIdFetchState } from "./src/support/mastra-client.ts";
package/package.json CHANGED
@@ -25,18 +25,18 @@
25
25
  "shiki": "^3.0.0",
26
26
  "sql-formatter": "^15.6.9",
27
27
  "streamdown": "^2.5.0",
28
- "@dbx-tools/shared-core": "0.6.14",
29
- "@dbx-tools/shared-mastra": "0.6.14",
30
- "@dbx-tools/shared-genie": "0.6.14",
31
- "@dbx-tools/ui-branding": "0.6.14",
32
- "@dbx-tools/shared-model": "0.6.14",
33
- "@dbx-tools/ui-appkit": "0.6.14"
28
+ "@dbx-tools/shared-model": "0.6.27",
29
+ "@dbx-tools/ui-appkit": "0.6.27",
30
+ "@dbx-tools/shared-mastra": "0.6.27",
31
+ "@dbx-tools/ui-branding": "0.6.27",
32
+ "@dbx-tools/shared-core": "0.6.27",
33
+ "@dbx-tools/shared-genie": "0.6.27"
34
34
  },
35
35
  "license": "UNLICENSED",
36
36
  "publishConfig": {
37
37
  "access": "public"
38
38
  },
39
- "version": "0.6.14",
39
+ "version": "0.6.27",
40
40
  "type": "module",
41
41
  "exports": {
42
42
  "./react": "./src/react/index.ts",
@@ -342,6 +342,12 @@ export const ChatView = ({
342
342
  e.preventDefault();
343
343
  const text = input.trim();
344
344
  if (!text) return;
345
+ // The composer is disabled while history loads, but Enter-to-send routes
346
+ // here directly and a disabled textarea still receives no keydown only as
347
+ // long as the browser agrees - so gate the action itself too. Sending now
348
+ // would race the fetch and append a turn to a transcript that is about to
349
+ // be replaced by the loaded one.
350
+ if (isLoadingHistory) return;
345
351
  // Submitting while a turn streams is a steer: the driver hands the text
346
352
  // to the live run (or interrupts + resends). Idle submits start a turn.
347
353
  sendMessage({ text });
@@ -758,6 +764,7 @@ export const ChatView = ({
758
764
  <SuggestionPills
759
765
  questions={suggestions}
760
766
  onSelect={(s) => sendMessage({ text: s })}
767
+ disabled={isLoadingHistory}
761
768
  className="mx-auto w-full max-w-4xl px-4 pb-2 md:px-6"
762
769
  />
763
770
  )}
@@ -876,8 +883,9 @@ export const ChatView = ({
876
883
  handleSubmit(e as unknown as React.FormEvent);
877
884
  }
878
885
  }}
879
- placeholder="Send a message..."
886
+ placeholder={isLoadingHistory ? "Loading history..." : "Send a message..."}
880
887
  rows={1}
888
+ disabled={isLoadingHistory}
881
889
  className="max-h-48 text-base md:text-sm"
882
890
  />
883
891
  <InputGroupAddon align="inline-end">
@@ -904,7 +912,7 @@ export const ChatView = ({
904
912
  type="submit"
905
913
  size="icon-sm"
906
914
  variant="default"
907
- disabled={!input.trim()}
915
+ disabled={!input.trim() || isLoadingHistory}
908
916
  aria-label={isRunning ? "Send now (interrupts)" : "Send message"}
909
917
  >
910
918
  <SendIcon className="size-3" />
@@ -920,6 +928,7 @@ export const ChatView = ({
920
928
  <Select
921
929
  value={model ? model : DEFAULT_MODEL_VALUE}
922
930
  onValueChange={(v) => onModelChange?.(v === DEFAULT_MODEL_VALUE ? "" : v)}
931
+ disabled={isLoadingHistory}
923
932
  >
924
933
  <SelectTrigger
925
934
  size="sm"
@@ -945,6 +954,7 @@ export const ChatView = ({
945
954
  <ExportMenu
946
955
  onExport={(format) => void onExportConversation?.(format)}
947
956
  tooltip="Export conversation"
957
+ disabled={isLoadingHistory}
948
958
  />
949
959
  )}
950
960
  {showClear && (
@@ -956,6 +966,7 @@ export const ChatView = ({
956
966
  variant="outline"
957
967
  size="sm"
958
968
  onClick={() => setClearOpen(true)}
969
+ disabled={isLoadingHistory}
959
970
  className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
960
971
  >
961
972
  <Trash2Icon className="size-3" />
@@ -2,10 +2,11 @@ import { marker as markers, type ParsedMarker } from "@dbx-tools/shared-mastra";
2
2
  import { Spinner } from "@dbx-tools/ui-appkit/react";
3
3
  import ReactECharts from "echarts-for-react";
4
4
  import { ClockIcon } from "lucide-react";
5
- import { useMemo } from "react";
5
+ import { useMemo, useRef } from "react";
6
6
  import { DataGrid, humanizeLabel } from "./data-grid.tsx";
7
7
  import { AssistantMarkdown } from "./markdown.tsx";
8
8
  import { normalizeChartOption } from "../support/chart-option.ts";
9
+ import { useChartChrome } from "../support/chart-theme.ts";
9
10
  import { useChartFetch, useStatementFetch } from "../support/mastra-client.ts";
10
11
 
11
12
  // Inline embed slots: chart / data tables resolved from `[chart:<id>]`
@@ -19,9 +20,16 @@ import { useChartFetch, useStatementFetch } from "../support/mastra-client.ts";
19
20
  * is fixed so Echarts has a deterministic canvas regardless of
20
21
  * the parent's flex layout. `not-prose` opts out of Tailwind
21
22
  * Typography.
23
+ *
24
+ * The surface is OPAQUE (`bg-card`, not a translucent
25
+ * `bg-background/40`). A partially transparent theme token composites
26
+ * against whatever the host painted underneath, so an embedded chat
27
+ * whose host surface disagrees with AppKit's resolved theme - a light
28
+ * app that never opted out of AppKit's `prefers-color-scheme: dark`
29
+ * fallback, say - renders the chart on a muddy mid-grey plate that
30
+ * matches neither theme.
22
31
  */
23
- const CHART_FRAME_CLASSES =
24
- "not-prose my-3 max-w-full rounded border border-border/60 bg-background/40 p-2";
32
+ const CHART_FRAME_CLASSES = "not-prose my-3 max-w-full rounded border border-border bg-card p-2";
25
33
  const CHART_HEIGHT_PX = 320;
26
34
 
27
35
  /**
@@ -73,44 +81,48 @@ const ExpiredSlot = ({ type }: { type: string }) => (
73
81
  */
74
82
  const ChartSlot = ({ chartId }: { chartId: string }) => {
75
83
  const { data: chart, loading, error } = useChartFetch(chartId);
84
+ // Resolved off the frame itself, so a theme scoped to this subtree
85
+ // (an embedded chat panel) wins over the document root's.
86
+ const frameRef = useRef<HTMLDivElement>(null);
87
+ const chrome = useChartChrome(frameRef);
76
88
  // Patch presentation (compact ticks, axis-name placement, legible
77
- // category labels) into the JSON-safe planner spec before rendering,
78
- // matching the export path. Memoized on the resolved option identity.
89
+ // category labels) and the current theme's chrome into the JSON-safe
90
+ // planner spec before rendering. Memoized on the resolved option and
91
+ // the theme, so a light/dark switch recolors the chart in place.
79
92
  const option = useMemo(
80
- () => (chart?.result ? normalizeChartOption(chart.result.option) : undefined),
81
- [chart?.result],
93
+ () => (chart?.result ? normalizeChartOption(chart.result.option, chrome) : undefined),
94
+ [chart?.result, chrome],
82
95
  );
83
- if (option) {
84
- return (
85
- <div className={CHART_FRAME_CLASSES}>
96
+ // Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
97
+ // Hard-error / non-terminal payloads render nothing.
98
+ if (!option && !loading) {
99
+ return !error && chart === undefined ? <ExpiredSlot type="chart" /> : null;
100
+ }
101
+ // One frame for both states: the chart's footprint is known ahead of
102
+ // time, so an in-flight fetch reserves it with a spinner rather than
103
+ // collapsing, and the chart fades in without shifting the prose
104
+ // below. Keeping it a single element also keeps `frameRef` mounted
105
+ // across the transition, so the theme is already resolved when the
106
+ // spec lands.
107
+ return (
108
+ <div ref={frameRef} className={CHART_FRAME_CLASSES}>
109
+ {option ? (
86
110
  <ReactECharts
87
111
  option={option}
88
112
  style={{ height: CHART_HEIGHT_PX, width: "100%" }}
89
113
  notMerge
90
114
  lazyUpdate
91
115
  />
92
- </div>
93
- );
94
- }
95
- // In-flight fetch: the chart's footprint is known ahead of time, so
96
- // reserve the same frame + height with a spinner instead of
97
- // collapsing - the chart fades in without shifting the prose below.
98
- if (loading) {
99
- return (
100
- <div className={CHART_FRAME_CLASSES}>
116
+ ) : (
101
117
  <div
102
118
  className="flex items-center justify-center"
103
119
  style={{ height: CHART_HEIGHT_PX, width: "100%" }}
104
120
  >
105
121
  <Spinner className="size-5 text-muted-foreground" />
106
122
  </div>
107
- </div>
108
- );
109
- }
110
- // Settled 404 (unknown / TTL-expired id) -> small "expired" notice.
111
- // Hard-error / non-terminal payloads render nothing.
112
- if (!error && chart === undefined) return <ExpiredSlot type="chart" />;
113
- return null;
123
+ )}
124
+ </div>
125
+ );
114
126
  };
115
127
 
116
128
  /**
@@ -30,18 +30,22 @@ const FORMATS: ReadonlyArray<{
30
30
  * {@link ExportFormat}. `iconOnly` renders a compact icon trigger (used
31
31
  * inside message bubbles) with the label surfaced as a tooltip; the
32
32
  * default renders an icon + "Export" text button (used in the header).
33
+ * `disabled` renders the trigger inert, for when there is nothing complete
34
+ * to export yet (a thread whose history is still loading).
33
35
  */
34
36
  export const ExportMenu = ({
35
37
  onExport,
36
38
  iconOnly = false,
37
39
  tooltip = "Export",
40
+ disabled = false,
38
41
  }: {
39
42
  onExport: (format: ExportFormat) => void;
40
43
  iconOnly?: boolean;
41
44
  tooltip?: string;
45
+ disabled?: boolean;
42
46
  }) => {
43
47
  const trigger = iconOnly ? (
44
- <Button type="button" size="icon" variant="ghost" className="size-7">
48
+ <Button type="button" size="icon" variant="ghost" className="size-7" disabled={disabled}>
45
49
  <DownloadIcon className="size-3" />
46
50
  </Button>
47
51
  ) : (
@@ -50,6 +54,7 @@ export const ExportMenu = ({
50
54
  size="sm"
51
55
  variant="outline"
52
56
  className="h-7 gap-1 rounded-full px-2.5 text-xs [&_svg]:size-3"
57
+ disabled={disabled}
53
58
  >
54
59
  <DownloadIcon className="size-3" />
55
60
  Export
@@ -10,6 +10,12 @@ export interface SuggestionPillsProps {
10
10
  questions: string[];
11
11
  /** Invoked with the question text when a pill is clicked. */
12
12
  onSelect?: (question: string) => void;
13
+ /**
14
+ * Render the pills inert. Used while a thread's history is still loading,
15
+ * where a click would start a turn against a transcript that has not
16
+ * arrived yet.
17
+ */
18
+ disabled?: boolean;
13
19
  /** Extra classes for the wrapping flex row (layout / spacing). */
14
20
  className?: string;
15
21
  }
@@ -18,9 +24,14 @@ export interface SuggestionPillsProps {
18
24
  * Render a flex-wrapped row of suggestion pills. Pills grow vertically
19
25
  * for long questions (`h-auto` + `whitespace-normal`) and keep a
20
26
  * capsule shape that scales cleanly when text wraps to multiple lines.
21
- * Disabled when no `onSelect` is provided.
27
+ * Disabled when no `onSelect` is provided, or when `disabled` is set.
22
28
  */
23
- export const SuggestionPills = ({ questions, onSelect, className }: SuggestionPillsProps) => {
29
+ export const SuggestionPills = ({
30
+ questions,
31
+ onSelect,
32
+ disabled = false,
33
+ className,
34
+ }: SuggestionPillsProps) => {
24
35
  if (questions.length === 0) return null;
25
36
  return (
26
37
  <div className={cn("flex flex-wrap gap-1.5", className)}>
@@ -32,7 +43,7 @@ export const SuggestionPills = ({ questions, onSelect, className }: SuggestionPi
32
43
  variant="outline"
33
44
  className="h-auto max-w-full whitespace-normal rounded-2xl px-3 py-1.5 text-left text-xs font-normal leading-snug"
34
45
  onClick={() => onSelect?.(q)}
35
- disabled={!onSelect}
46
+ disabled={disabled || !onSelect}
36
47
  >
37
48
  {q}
38
49
  </Button>
@@ -15,14 +15,20 @@
15
15
  * the axis ends where they collide with the centered title;
16
16
  * - category labels stay legible (shown, rotated, de-overlapped) rather
17
17
  * than silently decimated when many bars share a narrow canvas;
18
- * - the title and grid leave room for one another.
18
+ * - the title and grid leave room for one another;
19
+ * - the chrome (tick labels, axis names, grid lines, tooltip) is painted
20
+ * in the reader's current theme, which a canvas cannot inherit from
21
+ * CSS the way the chart's frame does.
19
22
  *
20
- * It only fills gaps: any field the spec already sets (an explicit
21
- * `axisLabel.formatter`, `nameLocation`, etc.) is preserved.
23
+ * Layout only fills gaps: any field the spec already sets (an explicit
24
+ * `axisLabel.formatter`, `nameLocation`, etc.) is preserved. Chrome
25
+ * colors are the exception - they OVERRIDE, because the whole point is
26
+ * to replace whatever was baked in at plan time with the live theme.
22
27
  *
23
28
  * @module
24
29
  */
25
30
  import { object } from "@dbx-tools/shared-core";
31
+ import type { ChartChrome } from "./chart-theme.ts";
26
32
 
27
33
  /** Compact SI formatter shared across every value-axis tick. */
28
34
  const COMPACT_NUMBER = new Intl.NumberFormat("en-US", {
@@ -51,11 +57,30 @@ function hasTitleText(title: unknown): boolean {
51
57
  return entries.some((t) => isObj(t) && typeof t.text === "string" && t.text.trim().length > 0);
52
58
  }
53
59
 
60
+ /**
61
+ * Shallow-merge `patch` over the object living at `node[key]`, treating a
62
+ * missing or non-object value as `{}`. Sibling fields survive, so a
63
+ * chrome color can be painted onto a node the layout pass already
64
+ * populated (an `axisLabel` that carries a `formatter`, say).
65
+ */
66
+ function patched(node: unknown, key: string, patch: Obj): Obj {
67
+ const base = isObj(node) ? node : {};
68
+ const child = isObj(base[key]) ? (base[key] as Obj) : {};
69
+ return { ...base, [key]: { ...child, ...patch } };
70
+ }
71
+
72
+ /** {@link patched} for the `lineStyle.color` one level down. */
73
+ function lineColored(node: unknown, key: string, color: string): Obj {
74
+ const base = isObj(node) ? node : {};
75
+ return patched(base, key, patched(base[key], "lineStyle", { color }));
76
+ }
77
+
54
78
  /** Pin a title to the top-center so it clears the plot / axis names. */
55
- function normalizeTitle(title: unknown): unknown {
56
- if (Array.isArray(title)) return title.map(normalizeTitle);
79
+ function normalizeTitle(title: unknown, chrome?: ChartChrome): unknown {
80
+ if (Array.isArray(title)) return title.map((t) => normalizeTitle(t, chrome));
57
81
  if (!isObj(title)) return title;
58
- return { left: "center", top: 8, ...title };
82
+ const next: Obj = { left: "center", top: 8, ...title };
83
+ return chrome ? patched(next, "textStyle", { color: chrome.axisTitle }) : next;
59
84
  }
60
85
 
61
86
  /**
@@ -78,17 +103,25 @@ function normalizeGrid(grid: unknown, opts: { hasTitle: boolean }): unknown {
78
103
  }
79
104
 
80
105
  /** Patch a single axis node in place-safe fashion (`x` or `y`). */
81
- function normalizeAxis(axis: Obj, pos: "x" | "y"): Obj {
82
- const next: Obj = { ...axis };
106
+ function normalizeAxis(axis: Obj, pos: "x" | "y", chrome?: ChartChrome): Obj {
107
+ let next: Obj = { ...axis };
83
108
  const existingLabel = isObj(next.axisLabel) ? next.axisLabel : {};
84
109
 
85
110
  if (next.type === "value") {
86
111
  // Compact big-number ticks unless the spec pinned its own formatter.
87
112
  next.axisLabel = { formatter: compactAxisLabel, ...existingLabel };
88
113
  } else if (next.type === "category") {
89
- // Show every category, rotated and de-overlapped, rather than
90
- // letting Echarts drop labels on a crowded axis.
91
- next.axisLabel = { interval: 0, rotate: 30, hideOverlap: true, ...existingLabel };
114
+ // Show every category and de-overlap rather than letting Echarts
115
+ // drop labels on a crowded axis. Rotation is x-only: labels on a
116
+ // category y-axis (a horizontal bar chart, a heatmap's rows) run
117
+ // along their own row and have the full left margin to sit in, so
118
+ // tilting them only makes them harder to read.
119
+ next.axisLabel = {
120
+ interval: 0,
121
+ ...(pos === "x" ? { rotate: 30 } : {}),
122
+ hideOverlap: true,
123
+ ...existingLabel,
124
+ };
92
125
  }
93
126
 
94
127
  // Move a set axis name to a conventional spot so it never collides
@@ -102,13 +135,57 @@ function normalizeAxis(axis: Obj, pos: "x" | "y"): Obj {
102
135
  next.nameGap = next.nameGap ?? 56;
103
136
  }
104
137
  }
138
+
139
+ if (chrome) {
140
+ // Echarts defaults every one of these to a near-black that vanishes
141
+ // on a dark surface, so all four follow the theme together: the
142
+ // ticks and their labels, the axis name, and the split lines.
143
+ next = patched(next, "axisLabel", { color: chrome.axisLabel });
144
+ next = patched(next, "nameTextStyle", { color: chrome.axisTitle });
145
+ next = lineColored(next, "axisLine", chrome.grid);
146
+ next = lineColored(next, "axisTick", chrome.grid);
147
+ next = lineColored(next, "splitLine", chrome.grid);
148
+ }
105
149
  return next;
106
150
  }
107
151
 
108
152
  /** Apply {@link normalizeAxis} across an axis field (object or array). */
109
- function normalizeAxisField(axis: unknown, pos: "x" | "y"): unknown {
110
- if (Array.isArray(axis)) return axis.map((a) => (isObj(a) ? normalizeAxis(a, pos) : a));
111
- return isObj(axis) ? normalizeAxis(axis, pos) : axis;
153
+ function normalizeAxisField(axis: unknown, pos: "x" | "y", chrome?: ChartChrome): unknown {
154
+ if (Array.isArray(axis)) return axis.map((a) => (isObj(a) ? normalizeAxis(a, pos, chrome) : a));
155
+ return isObj(axis) ? normalizeAxis(axis, pos, chrome) : axis;
156
+ }
157
+
158
+ /**
159
+ * Paint the theme onto the option nodes that are not per-axis: the base
160
+ * text style (which legend and series labels inherit), the legend's own
161
+ * labels, and the tooltip's surface, outline, and text.
162
+ *
163
+ * The base `textStyle` keeps whatever `fontFamily` the planner's brand
164
+ * theme set - the font is brand identity and the same in either theme;
165
+ * only the color is theme-dependent, so only the color is replaced.
166
+ */
167
+ function normalizeChrome(option: Obj, chrome: ChartChrome): Obj {
168
+ const next = patched(option, "textStyle", { color: chrome.axisTitle });
169
+ // Legend and tooltip are patched only where the spec already declares
170
+ // them: in Echarts a bare `legend: {}` is a SHOWN legend, so
171
+ // conjuring one to hold a color would add a legend to a chart that
172
+ // deliberately has none.
173
+ if (isObj(next.legend)) {
174
+ next.legend = patched(next.legend, "textStyle", { color: chrome.axisLabel });
175
+ }
176
+ if (isObj(next.tooltip)) {
177
+ next.tooltip = {
178
+ ...patched(next.tooltip, "textStyle", { color: chrome.tooltipForeground }),
179
+ backgroundColor: chrome.tooltipBackground,
180
+ borderColor: chrome.tooltipBorder,
181
+ };
182
+ }
183
+ // A heatmap's visualMap prints its range bounds in the same near-black
184
+ // default as the axes, so it needs the same treatment.
185
+ if (isObj(next.visualMap)) {
186
+ next.visualMap = patched(next.visualMap, "textStyle", { color: chrome.axisLabel });
187
+ }
188
+ return next;
112
189
  }
113
190
 
114
191
  /**
@@ -117,13 +194,21 @@ function normalizeAxisField(axis: unknown, pos: "x" | "y"): unknown {
117
194
  * labels, and title/grid spacing. Pure and shallow-cloning - the input
118
195
  * (which may be a shared/cached object) is never mutated. Non-object
119
196
  * input is returned untouched.
197
+ *
198
+ * Pass `chrome` to also color the chart for a theme (see
199
+ * {@link ChartChrome}): the live one for an on-screen chart, or
200
+ * `LIGHT_CHART_CHROME` for output that is always read on white, such as
201
+ * the PDF export. Omit it and only layout is normalized, leaving
202
+ * Echarts' own near-black defaults - legible on a light surface,
203
+ * invisible on a dark one.
120
204
  */
121
- export function normalizeChartOption<T>(option: T): T {
205
+ export function normalizeChartOption<T>(option: T, chrome?: ChartChrome): T {
122
206
  if (!isObj(option)) return option;
123
- const next: Obj = { ...option };
124
- next.title = normalizeTitle(next.title);
207
+ let next: Obj = { ...option };
208
+ next.title = normalizeTitle(next.title, chrome);
125
209
  next.grid = normalizeGrid(next.grid, { hasTitle: hasTitleText(next.title) });
126
- next.xAxis = normalizeAxisField(next.xAxis, "x");
127
- next.yAxis = normalizeAxisField(next.yAxis, "y");
210
+ next.xAxis = normalizeAxisField(next.xAxis, "x", chrome);
211
+ next.yAxis = normalizeAxisField(next.yAxis, "y", chrome);
212
+ if (chrome) next = normalizeChrome(next, chrome);
128
213
  return next as T;
129
214
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * Live theme resolution for planner-produced Echarts specs.
3
+ *
4
+ * An Echarts chart draws to a canvas, so none of AppKit's CSS custom
5
+ * properties reach it the way they reach the styled DOM around it: a
6
+ * `.dark` root re-skins the chart's frame while the axis labels, grid
7
+ * lines, and tooltip inside keep whatever colors were baked into the
8
+ * spec. The planner (`@dbx-tools/appkit-mastra`) runs on the server and
9
+ * cannot know the reader's theme at all, so the spec deliberately
10
+ * carries only theme-INDEPENDENT identity (the brand series palette and
11
+ * font stack) and leaves every chrome color to be resolved here.
12
+ *
13
+ * This module reads AppKit's chart tokens off a live element and hands
14
+ * the result to `normalizeChartOption`, which paints them onto the axes,
15
+ * title, legend, and tooltip. Reading from the CHART's own element
16
+ * rather than `:root` matters because a host may scope `.dark` to a
17
+ * subtree (an embedded chat panel inside an otherwise-light app);
18
+ * custom properties inherit, so the chart element always sees the theme
19
+ * that actually applies to it.
20
+ *
21
+ * @module
22
+ */
23
+ import { object } from "@dbx-tools/shared-core";
24
+ import { type RefObject, useEffect, useState } from "react";
25
+
26
+ /**
27
+ * The theme-dependent colors an Echarts spec needs, resolved from
28
+ * AppKit's stylesheet. Deliberately chrome only - series colors are
29
+ * brand identity and stay as the planner emitted them.
30
+ */
31
+ export interface ChartChrome {
32
+ /** Tick labels and legend entries. */
33
+ axisLabel: string;
34
+ /** Chart title and axis names - the stronger of the two foregrounds. */
35
+ axisTitle: string;
36
+ /** Split lines, axis lines, and ticks. */
37
+ grid: string;
38
+ /** Tooltip surface. */
39
+ tooltipBackground: string;
40
+ /** Tooltip text. */
41
+ tooltipForeground: string;
42
+ /** Tooltip outline. */
43
+ tooltipBorder: string;
44
+ }
45
+
46
+ /**
47
+ * AppKit's light-theme chart chrome (`:root` in
48
+ * `@databricks/appkit-ui/dist/styles.css`), as literals.
49
+ *
50
+ * Two jobs: the fallback for every token that fails to resolve (a host
51
+ * that never imported AppKit's stylesheet), and the fixed theme for
52
+ * server-side rendering, where there is no document to read and the
53
+ * output - a PDF export - is printed on white regardless of the
54
+ * reader's theme.
55
+ */
56
+ export const LIGHT_CHART_CHROME: ChartChrome = {
57
+ axisLabel: "hsla(240, 4%, 46%, 1)",
58
+ axisTitle: "hsla(240, 6%, 10%, 1)",
59
+ grid: "hsla(240, 5%, 90%, 1)",
60
+ tooltipBackground: "hsla(0, 0%, 100%, 1)",
61
+ tooltipForeground: "hsla(240, 6%, 10%, 1)",
62
+ tooltipBorder: "hsla(240, 5%, 90%, 1)",
63
+ };
64
+
65
+ /**
66
+ * Which AppKit custom property backs each {@link ChartChrome} field.
67
+ * The first four are AppKit's dedicated chart tokens; the tooltip's text
68
+ * and outline reuse the generic popover/border tokens, which AppKit
69
+ * redefines under `.dark` alongside them.
70
+ */
71
+ const CHROME_TOKENS: Record<keyof ChartChrome, string> = {
72
+ axisLabel: "--chart-axis-label",
73
+ axisTitle: "--chart-axis-title",
74
+ grid: "--chart-grid",
75
+ tooltipBackground: "--chart-tooltip-bg",
76
+ tooltipForeground: "--popover-foreground",
77
+ tooltipBorder: "--border",
78
+ };
79
+
80
+ /**
81
+ * Read AppKit's chart tokens as they resolve for `element`, falling back
82
+ * to {@link LIGHT_CHART_CHROME} per-token when one is unset. Pass the
83
+ * chart's own container so a theme scoped to a subtree is honored;
84
+ * `null` (not yet mounted) reads the document root instead.
85
+ */
86
+ export function resolveChartChrome(element: Element | null): ChartChrome {
87
+ if (typeof window === "undefined") return LIGHT_CHART_CHROME;
88
+ const target = element ?? document.documentElement;
89
+ const styles = window.getComputedStyle(target);
90
+ const chrome = { ...LIGHT_CHART_CHROME };
91
+ for (const [field, token] of Object.entries(CHROME_TOKENS)) {
92
+ const value = styles.getPropertyValue(token).trim();
93
+ if (value) chrome[field as keyof ChartChrome] = value;
94
+ }
95
+ return chrome;
96
+ }
97
+
98
+ /** Root attributes whose change can re-theme the tokens below them. */
99
+ const THEME_ATTRIBUTES = ["class", "style", "data-theme", "data-brand"];
100
+
101
+ /**
102
+ * Resolve the chart chrome for `ref`'s element and keep it current as
103
+ * the theme changes.
104
+ *
105
+ * Two triggers, matching the two ways AppKit's stylesheet switches
106
+ * themes: an explicit `.dark` / `.light` class (watched with a
107
+ * `MutationObserver` on the document root, which is where hosts toggle
108
+ * it) and the OS preference that `:root:not(.light)` falls back to
109
+ * (watched with `matchMedia`). The resolved value is compared
110
+ * structurally before being stored, so an unrelated attribute change
111
+ * does not hand callers a new object and re-render every chart.
112
+ *
113
+ * Returns {@link LIGHT_CHART_CHROME} on the first render - the effect
114
+ * that reads the real tokens runs after mount, by which point the chart
115
+ * itself is usually still long-polling its spec.
116
+ */
117
+ export function useChartChrome(ref: RefObject<Element | null>): ChartChrome {
118
+ const [chrome, setChrome] = useState<ChartChrome>(LIGHT_CHART_CHROME);
119
+ useEffect(() => {
120
+ const read = () => {
121
+ const next = resolveChartChrome(ref.current);
122
+ setChrome((prev) => (object.deepEqual(prev, next) ? prev : next));
123
+ };
124
+ read();
125
+ const observer = new MutationObserver(read);
126
+ observer.observe(document.documentElement, {
127
+ attributes: true,
128
+ attributeFilter: THEME_ATTRIBUTES,
129
+ });
130
+ const media = window.matchMedia("(prefers-color-scheme: dark)");
131
+ media.addEventListener("change", read);
132
+ return () => {
133
+ observer.disconnect();
134
+ media.removeEventListener("change", read);
135
+ };
136
+ }, [ref]);
137
+ return chrome;
138
+ }
@@ -29,6 +29,7 @@ import type { UIMessage } from "ai";
29
29
  import * as echarts from "echarts";
30
30
  import { marked } from "marked";
31
31
  import { normalizeChartOption } from "./chart-option.ts";
32
+ import { LIGHT_CHART_CHROME } from "./chart-theme.ts";
32
33
  import { downloadFile } from "./download.ts";
33
34
 
34
35
  /**
@@ -295,7 +296,12 @@ function markdownToHtml(md: string): string {
295
296
  * via server-side rendering (no DOM). The JSON-safe planner spec is run
296
297
  * through {@link normalizeChartOption} first - same as the live inline
297
298
  * chart - so the export gets compact value ticks, conventionally-placed
298
- * axis names, and legible category labels rather than a raw spec. Returns
299
+ * axis names, and legible category labels rather than a raw spec.
300
+ *
301
+ * The theme is pinned LIGHT rather than read from the page: the document
302
+ * this SVG lands in forces `color-scheme: light` on a white body because
303
+ * it is headed for a printer or a PDF, so a chart themed for the
304
+ * reader's dark UI would come out as pale labels on white. Returns
299
305
  * `null` when the id is unknown / expired / still processing, or if
300
306
  * rendering throws.
301
307
  */
@@ -310,7 +316,9 @@ async function chartSvg(resolver: EmbedResolver, id: string): Promise<string | n
310
316
  height: CHART_HEIGHT_PX,
311
317
  });
312
318
  try {
313
- instance.setOption(normalizeChartOption(chart.result.option) as echarts.EChartsCoreOption);
319
+ instance.setOption(
320
+ normalizeChartOption(chart.result.option, LIGHT_CHART_CHROME) as echarts.EChartsCoreOption,
321
+ );
314
322
  return instance.renderToSVGString();
315
323
  } finally {
316
324
  instance.dispose();