@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 +42 -0
- package/index.ts +3 -0
- package/package.json +7 -7
- package/src/react/chat-view.tsx +13 -2
- package/src/react/embed-slots.tsx +38 -26
- package/src/react/export-menu.tsx +6 -1
- package/src/react/suggestion-pills.tsx +14 -3
- package/src/support/chart-option.ts +104 -19
- package/src/support/chart-theme.ts +138 -0
- package/src/support/export.ts +10 -2
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-
|
|
29
|
-
"@dbx-tools/
|
|
30
|
-
"@dbx-tools/shared-
|
|
31
|
-
"@dbx-tools/ui-branding": "0.6.
|
|
32
|
-
"@dbx-tools/shared-
|
|
33
|
-
"@dbx-tools/
|
|
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.
|
|
39
|
+
"version": "0.6.27",
|
|
40
40
|
"type": "module",
|
|
41
41
|
"exports": {
|
|
42
42
|
"./react": "./src/react/index.ts",
|
package/src/react/chat-view.tsx
CHANGED
|
@@ -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
|
|
78
|
-
//
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 = ({
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
90
|
-
//
|
|
91
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/support/export.ts
CHANGED
|
@@ -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.
|
|
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(
|
|
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();
|