@seed-ship/mcp-ui-solid 6.24.0 → 6.25.1

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 (124) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +47 -0
  3. package/dist/components/ActionGroupRenderer.cjs +6 -6
  4. package/dist/components/ActionGroupRenderer.cjs.map +1 -1
  5. package/dist/components/ActionGroupRenderer.js +6 -6
  6. package/dist/components/ActionGroupRenderer.js.map +1 -1
  7. package/dist/components/AutocompleteDropdown.cjs +6 -6
  8. package/dist/components/AutocompleteDropdown.cjs.map +1 -1
  9. package/dist/components/AutocompleteDropdown.js +6 -6
  10. package/dist/components/AutocompleteDropdown.js.map +1 -1
  11. package/dist/components/AutocompleteFormField.cjs +6 -5
  12. package/dist/components/AutocompleteFormField.cjs.map +1 -1
  13. package/dist/components/AutocompleteFormField.d.ts.map +1 -1
  14. package/dist/components/AutocompleteFormField.js +6 -5
  15. package/dist/components/AutocompleteFormField.js.map +1 -1
  16. package/dist/components/ChartJSRenderer.cjs +216 -6
  17. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  18. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  19. package/dist/components/ChartJSRenderer.js +217 -7
  20. package/dist/components/ChartJSRenderer.js.map +1 -1
  21. package/dist/components/ChatPrompt.cjs +5 -5
  22. package/dist/components/ChatPrompt.cjs.map +1 -1
  23. package/dist/components/ChatPrompt.js +5 -5
  24. package/dist/components/ChatPrompt.js.map +1 -1
  25. package/dist/components/ComponentToolbar.cjs +5 -5
  26. package/dist/components/ComponentToolbar.cjs.map +1 -1
  27. package/dist/components/ComponentToolbar.js +5 -5
  28. package/dist/components/ComponentToolbar.js.map +1 -1
  29. package/dist/components/DegradedFallback.cjs +1 -1
  30. package/dist/components/DegradedFallback.cjs.map +1 -1
  31. package/dist/components/DegradedFallback.js +1 -1
  32. package/dist/components/DegradedFallback.js.map +1 -1
  33. package/dist/components/ExpandableWrapper.cjs +10 -7
  34. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  35. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  36. package/dist/components/ExpandableWrapper.js +10 -7
  37. package/dist/components/ExpandableWrapper.js.map +1 -1
  38. package/dist/components/FormFieldRenderer.cjs +13 -12
  39. package/dist/components/FormFieldRenderer.cjs.map +1 -1
  40. package/dist/components/FormFieldRenderer.d.ts.map +1 -1
  41. package/dist/components/FormFieldRenderer.js +13 -12
  42. package/dist/components/FormFieldRenderer.js.map +1 -1
  43. package/dist/components/FormRenderer.cjs +1 -1
  44. package/dist/components/FormRenderer.cjs.map +1 -1
  45. package/dist/components/FormRenderer.js +1 -1
  46. package/dist/components/FormRenderer.js.map +1 -1
  47. package/dist/components/GenerativeUIErrorBoundary.cjs +1 -1
  48. package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
  49. package/dist/components/GenerativeUIErrorBoundary.js +1 -1
  50. package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
  51. package/dist/components/GhostText.cjs +2 -2
  52. package/dist/components/GhostText.cjs.map +1 -1
  53. package/dist/components/GhostText.js +2 -2
  54. package/dist/components/GhostText.js.map +1 -1
  55. package/dist/components/GraphRenderer.cjs +100 -29
  56. package/dist/components/GraphRenderer.cjs.map +1 -1
  57. package/dist/components/GraphRenderer.d.ts +1 -1
  58. package/dist/components/GraphRenderer.d.ts.map +1 -1
  59. package/dist/components/GraphRenderer.js +100 -29
  60. package/dist/components/GraphRenderer.js.map +1 -1
  61. package/dist/components/MissingValue.cjs +2 -2
  62. package/dist/components/MissingValue.cjs.map +1 -1
  63. package/dist/components/MissingValue.js +2 -2
  64. package/dist/components/MissingValue.js.map +1 -1
  65. package/dist/components/ModalRenderer.cjs +39 -31
  66. package/dist/components/ModalRenderer.cjs.map +1 -1
  67. package/dist/components/ModalRenderer.d.ts.map +1 -1
  68. package/dist/components/ModalRenderer.js +40 -32
  69. package/dist/components/ModalRenderer.js.map +1 -1
  70. package/dist/components/PortalDropdownMenu.cjs +6 -3
  71. package/dist/components/PortalDropdownMenu.cjs.map +1 -1
  72. package/dist/components/PortalDropdownMenu.d.ts.map +1 -1
  73. package/dist/components/PortalDropdownMenu.js +7 -4
  74. package/dist/components/PortalDropdownMenu.js.map +1 -1
  75. package/dist/components/ScratchpadPanel.cjs +38 -44
  76. package/dist/components/ScratchpadPanel.cjs.map +1 -1
  77. package/dist/components/ScratchpadPanel.d.ts.map +1 -1
  78. package/dist/components/ScratchpadPanel.js +38 -44
  79. package/dist/components/ScratchpadPanel.js.map +1 -1
  80. package/dist/components/UIResourceRenderer.cjs +20 -20
  81. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  82. package/dist/components/UIResourceRenderer.js +20 -20
  83. package/dist/components/UIResourceRenderer.js.map +1 -1
  84. package/dist/components/chart-missing-plugin.cjs +4 -4
  85. package/dist/components/chart-missing-plugin.cjs.map +1 -1
  86. package/dist/components/chart-missing-plugin.d.ts +3 -1
  87. package/dist/components/chart-missing-plugin.d.ts.map +1 -1
  88. package/dist/components/chart-missing-plugin.js +4 -4
  89. package/dist/components/chart-missing-plugin.js.map +1 -1
  90. package/dist/utils/math-markdown.cjs +1 -1
  91. package/dist/utils/math-markdown.cjs.map +1 -1
  92. package/dist/utils/math-markdown.js +1 -1
  93. package/dist/utils/math-markdown.js.map +1 -1
  94. package/dist/utils/theme.cjs +91 -0
  95. package/dist/utils/theme.cjs.map +1 -0
  96. package/dist/utils/theme.d.ts +11 -0
  97. package/dist/utils/theme.d.ts.map +1 -0
  98. package/dist/utils/theme.js +91 -0
  99. package/dist/utils/theme.js.map +1 -0
  100. package/docs/SECURITY-DEPENDENCIES.md +81 -0
  101. package/docs/THEMING.md +205 -0
  102. package/package.json +5 -3
  103. package/src/components/ActionGroupRenderer.tsx +6 -6
  104. package/src/components/AutocompleteDropdown.tsx +17 -17
  105. package/src/components/AutocompleteFormField.tsx +10 -9
  106. package/src/components/ChartJSRenderer.tsx +483 -239
  107. package/src/components/ChatPrompt.tsx +16 -16
  108. package/src/components/ComponentToolbar.tsx +5 -5
  109. package/src/components/DegradedFallback.tsx +9 -9
  110. package/src/components/ExpandableWrapper.tsx +15 -11
  111. package/src/components/FormFieldRenderer.tsx +52 -51
  112. package/src/components/FormRenderer.tsx +12 -12
  113. package/src/components/GenerativeUIErrorBoundary.tsx +6 -6
  114. package/src/components/GhostText.tsx +7 -7
  115. package/src/components/GraphRenderer.tsx +83 -67
  116. package/src/components/MissingValue.tsx +2 -2
  117. package/src/components/ModalRenderer.tsx +11 -5
  118. package/src/components/PortalDropdownMenu.tsx +6 -2
  119. package/src/components/ScratchpadPanel.tsx +144 -141
  120. package/src/components/UIResourceRenderer.tsx +101 -101
  121. package/src/components/chart-missing-plugin.ts +5 -3
  122. package/src/styles/autocomplete.css +53 -53
  123. package/src/utils/math-markdown.ts +1 -1
  124. package/src/utils/theme.ts +101 -0
@@ -24,6 +24,7 @@ import {
24
24
  createMemo,
25
25
  createUniqueId,
26
26
  onCleanup,
27
+ onMount,
27
28
  createSignal,
28
29
  untrack,
29
30
  Show,
@@ -46,6 +47,214 @@ import { PART_TO_WHOLE_CHART_TYPES, summarizeChartMissing } from '../utils/missi
46
47
  import { useTelemetry } from '../context/MCPUITelemetryContext';
47
48
  import { useMCPUIConfig } from '../context/MCPUIConfigContext';
48
49
  import { formatMCPUIString, useMCPUIStrings } from '../context/MCPUIStringsContext';
50
+ import { observeTheme, readThemeTokens } from '../utils/theme';
51
+
52
+ const CHART_THEME_TOKENS = [
53
+ ...Array.from({ length: 8 }, (_, i) => `--mcp-ui-chart-${i + 1}`),
54
+ '--mcp-ui-chart-grid',
55
+ '--mcp-ui-chart-axis',
56
+ '--mcp-ui-chart-label',
57
+ '--mcp-ui-surface', '--mcp-ui-fg', '--mcp-ui-border',
58
+ ] as const;
59
+
60
+ /** A per-instance adapter: never changes Chart.defaults or the caller's data. */
61
+ function createChartTheme(
62
+ params: ChartComponentParams,
63
+ baseOptions: any,
64
+ root: HTMLElement,
65
+ chartConfig: any
66
+ ) {
67
+ let tokens = readThemeTokens(root, CHART_THEME_TOKENS);
68
+ const originalDatasets = params.data.datasets.map((dataset) => ({ ...dataset }));
69
+ let engineColors: Array<{ backgroundColor: any; borderColor: any }> | undefined;
70
+ const sharedPaintOptions = new WeakMap<object, any>();
71
+ const paths: Array<{ path: string[]; token: string }> = [
72
+ { path: ['plugins', 'legend', 'labels', 'color'], token: '--mcp-ui-chart-label' },
73
+ { path: ['plugins', 'tooltip', 'backgroundColor'], token: '--mcp-ui-surface' },
74
+ { path: ['plugins', 'tooltip', 'titleColor'], token: '--mcp-ui-fg' },
75
+ { path: ['plugins', 'tooltip', 'bodyColor'], token: '--mcp-ui-fg' },
76
+ { path: ['plugins', 'tooltip', 'footerColor'], token: '--mcp-ui-fg' },
77
+ { path: ['plugins', 'tooltip', 'borderColor'], token: '--mcp-ui-border' },
78
+ ];
79
+ const configuredScales = Object.keys(baseOptions.scales ?? {});
80
+ const scales = configuredScales.length ? configuredScales :
81
+ params.type === 'radar' || params.type === 'polarArea' ? ['r'] :
82
+ params.type === 'pie' || params.type === 'doughnut' ? [] : ['x', 'y'];
83
+ const knownScales = new Set<string>();
84
+ const addScalePaths = (names: string[]) => { for (const scale of names) {
85
+ if (knownScales.has(scale)) continue;
86
+ knownScales.add(scale);
87
+ for (const [part, token] of [
88
+ ['grid', '--mcp-ui-chart-grid'],
89
+ ['border', '--mcp-ui-chart-axis'],
90
+ ['ticks', '--mcp-ui-chart-label'],
91
+ ['title', '--mcp-ui-chart-label'],
92
+ ...(scale === 'r'
93
+ ? [
94
+ ['angleLines', '--mcp-ui-chart-grid'],
95
+ ['pointLabels', '--mcp-ui-chart-label'],
96
+ ]
97
+ : []),
98
+ ])
99
+ paths.push({ path: ['scales', scale, part, 'color'], token });
100
+ } };
101
+ addScalePaths(scales);
102
+ const read = (value: any, path: string[]) => path.reduce((part, key) => part?.[key], value);
103
+ const owned = new Set<string>();
104
+ const applyOptions = (options: any) => {
105
+ for (const { path, token } of paths) {
106
+ // Explicit caller options, including plugin/scale opt-outs, always win.
107
+ if (read(baseOptions, path) !== undefined) continue;
108
+ if (token === '--mcp-ui-chart-label' && (params.options as any)?.color !== undefined)
109
+ continue;
110
+ let blocked = false;
111
+ for (let i = 1; i < path.length; i++) {
112
+ const part = read(baseOptions, path.slice(0, i));
113
+ if (part === false || part === null) blocked = true;
114
+ }
115
+ if (blocked) continue;
116
+ const key = path.join('.');
117
+ const color = tokens[token];
118
+ if (!color && !owned.has(key)) continue;
119
+ let target = options;
120
+ for (const part of path.slice(0, -1)) {
121
+ // Copy along the changed path only, retaining untouched caller branches.
122
+ target[part] = { ...target[part] };
123
+ target = target[part];
124
+ }
125
+ if (color) {
126
+ target[path.at(-1)!] = color;
127
+ owned.add(key);
128
+ } else {
129
+ delete target[path.at(-1)!];
130
+ owned.delete(key);
131
+ }
132
+ }
133
+ };
134
+ const plugin = {
135
+ id: 'mcp-ui-theme',
136
+ beforeInit(chart: any) {
137
+ // Config has normalized implicit/default axes now. Never invent x/y
138
+ // beside a caller's named axes just to give them a color.
139
+ addScalePaths(Object.keys(chart.config.options.scales ?? {}));
140
+ applyOptions(chart.config.options);
141
+ },
142
+ // Global Colors runs first. Capture its actual output rather than copying
143
+ // a version-specific 7-color palette (or changing its opt-out semantics).
144
+ beforeLayout(chart: any) {
145
+ const datasets = chart.data.datasets;
146
+ engineColors ??= datasets.map((dataset: any) => ({
147
+ backgroundColor: Array.isArray(dataset.backgroundColor)
148
+ ? [...dataset.backgroundColor]
149
+ : dataset.backgroundColor,
150
+ borderColor: Array.isArray(dataset.borderColor)
151
+ ? [...dataset.borderColor]
152
+ : dataset.borderColor,
153
+ }));
154
+ const callerOptions = params.options as any;
155
+ let pointOffset = 0;
156
+ datasets.forEach((dataset: any, index: number) => {
157
+ const original = originalDatasets[index];
158
+ const baseline = engineColors![index];
159
+ if (!original || !baseline) return;
160
+ const datasetType = (original as any).type ?? params.type;
161
+ const perPoint = ['pie', 'doughnut', 'polarArea'].includes(datasetType);
162
+ const lineWithPoints = datasetType === 'line' || datasetType === 'radar';
163
+ const elementKinds = datasetType === 'bar' ? ['bar'] : perPoint ? ['arc'] :
164
+ lineWithPoints ? ['line'] : ['point'];
165
+ const hasSeriesToken = perPoint
166
+ ? dataset.data.some((_: unknown, i: number) => tokens[`--mcp-ui-chart-${((pointOffset + i) % 8) + 1}`])
167
+ : Boolean(tokens[`--mcp-ui-chart-${(index % 8) + 1}`]);
168
+ for (const field of ['backgroundColor', 'borderColor'] as const) {
169
+ if (original[field] !== undefined) continue;
170
+ const callerDatasetColor = callerOptions?.datasets?.[datasetType]?.[field];
171
+ if (callerDatasetColor !== undefined) {
172
+ // Chart.js's global Colors plugin does not check options.datasets
173
+ // and can mask this explicit paint with its generated dataset field.
174
+ // Retain the caller paint when adapting this palette slot, but keep
175
+ // the engine baseline unchanged when no series token is requested.
176
+ const color = hasSeriesToken ? callerDatasetColor : baseline[field];
177
+ if (color !== undefined) dataset[field] = color;
178
+ else delete dataset[field];
179
+ continue;
180
+ }
181
+ // A caller's border must not suppress a host fill (or vice versa),
182
+ // nor may options for an unrelated chart/element type mask this one.
183
+ if (callerOptions?.[field] !== undefined ||
184
+ elementKinds.some((kind) => callerOptions?.elements?.[kind]?.[field] !== undefined)) continue;
185
+ if (lineWithPoints && hasSeriesToken) {
186
+ // Dataset paints also feed point defaults. Preserve an explicit
187
+ // point paint under its native prefix before theming the line;
188
+ // otherwise the generic themed field would outrank elements.point.
189
+ const pointField = field === 'borderColor' ? 'pointBorderColor' : 'pointBackgroundColor';
190
+ const scopedPointPaint = callerOptions?.datasets?.[datasetType]?.elements?.point?.[field];
191
+ const pointPaint = scopedPointPaint !== undefined ? scopedPointPaint :
192
+ callerOptions?.elements?.point?.[field];
193
+ if ((original as any)[pointField] === undefined &&
194
+ callerOptions?.datasets?.[datasetType]?.[pointField] === undefined &&
195
+ callerOptions?.[pointField] === undefined && pointPaint !== undefined) {
196
+ dataset[pointField] = pointPaint;
197
+ }
198
+ }
199
+ // Pie-like charts have no automatic per-point border palette.
200
+ if (perPoint && field === 'borderColor') continue;
201
+ if (perPoint) {
202
+ const previous = baseline[field];
203
+ const colors = dataset.data.map(
204
+ (_: unknown, i: number) =>
205
+ tokens[`--mcp-ui-chart-${((pointOffset + i) % 8) + 1}`] ??
206
+ (Array.isArray(previous) ? previous[i % previous.length] : previous)
207
+ );
208
+ dataset[field] = colors.some(
209
+ (_: unknown, i: number) => tokens[`--mcp-ui-chart-${((pointOffset + i) % 8) + 1}`]
210
+ )
211
+ ? colors
212
+ : previous;
213
+ } else {
214
+ const color = tokens[`--mcp-ui-chart-${(index % 8) + 1}`];
215
+ if (color || baseline[field] !== undefined) dataset[field] = color ?? baseline[field];
216
+ else delete dataset[field];
217
+ }
218
+ }
219
+ if (perPoint) pointOffset += dataset.data.length;
220
+ });
221
+ },
222
+ afterDatasetUpdate(_chart: any, args: any) {
223
+ const meta = args.meta;
224
+ if (!meta?.data?.length || typeof meta.controller?.getStyle !== 'function') return;
225
+ const index = meta.data.findIndex((element: any) => element.options?.$shared && !element.active);
226
+ if (index >= 0 && !Object.isFrozen(meta.data[index].options)) {
227
+ sharedPaintOptions.set(meta.controller, meta.data[index].options);
228
+ }
229
+ if (args.mode !== 'none') return;
230
+ // Chart.js deliberately skips updateSharedOptions in direct ('none')
231
+ // mode. Refresh only its shared paint values from the public resolver;
232
+ // retain the object captured before hover, even when every element is
233
+ // active now, so later deactivation cannot revive an old theme paint.
234
+ const shared = sharedPaintOptions.get(meta.controller);
235
+ if (!shared) return;
236
+ const resolved = meta.controller.getStyle(index >= 0 ? index : 0, false);
237
+ if (!resolved.$shared) return;
238
+ shared.backgroundColor = resolved.backgroundColor;
239
+ shared.borderColor = resolved.borderColor;
240
+ },
241
+ };
242
+ // Bookkeeping must start before the first hover, even without host tokens.
243
+ // Without that capture a theme first enabled while all points are active
244
+ // cannot reach the native shared options using public APIs alone.
245
+ chartConfig.plugins = [...(chartConfig.plugins ?? []), plugin];
246
+ return {
247
+ applyOptions,
248
+ refresh(chart: any) {
249
+ const next = readThemeTokens(root, CHART_THEME_TOKENS);
250
+ if (CHART_THEME_TOKENS.every((name) => next[name] === tokens[name])) return;
251
+ tokens = next;
252
+ addScalePaths(Object.keys(chart.scales ?? chart.config.options.scales ?? {}));
253
+ applyOptions(chart.config.options);
254
+ chart.update('none');
255
+ },
256
+ };
257
+ }
49
258
 
50
259
  // Lazy load Chart.js to avoid bundling if not used
51
260
  let ChartJS: any = null;
@@ -315,9 +524,20 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
315
524
  const [zoomPlugin, setZoomPlugin] = createSignal<unknown | null>(null);
316
525
  const [isZoomed, setIsZoomed] = createSignal(false);
317
526
  let canvasRef: HTMLCanvasElement | undefined;
527
+ let themeRoot: HTMLDivElement | undefined;
318
528
  let chartInstance: any;
529
+ let chartTheme: ReturnType<typeof createChartTheme> | undefined;
319
530
  let renderVersion = 0;
320
531
 
532
+ onMount(() => {
533
+ if (themeRoot) {
534
+ const stop = observeTheme(themeRoot, () => {
535
+ if (chartInstance) chartTheme?.refresh(chartInstance);
536
+ });
537
+ onCleanup(stop);
538
+ }
539
+ });
540
+
321
541
  const params = () => props.component.params as ChartComponentParams;
322
542
  const telemetry = useTelemetry();
323
543
  const config = useMCPUIConfig();
@@ -458,8 +678,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
458
678
  * here (host policy × surface × chart type, and the peer actually loaded),
459
679
  * and the chart is what is currently being shown.
460
680
  */
461
- const zoomControlsShown = () =>
462
- Boolean(zoomOptions()) && activeView() === 'chart' && !error();
681
+ const zoomControlsShown = () => Boolean(zoomOptions()) && activeView() === 'chart' && !error();
463
682
 
464
683
  /**
465
684
  * Zoom the chart by a fixed ratio, about its centre.
@@ -585,7 +804,10 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
585
804
 
586
805
  const chartConfig: any = {
587
806
  type: chartParams.type,
588
- data: chartParams.data,
807
+ // The Colors plugin writes its generated palette onto datasets. Never
808
+ // let that turn a shared payload into caller-supplied colors for the
809
+ // next renderer, nor mutate data owned by a host application.
810
+ data: { ...chartParams.data, datasets: chartParams.data.datasets.map((dataset) => ({ ...dataset })) },
589
811
  options: baseOptions,
590
812
  };
591
813
 
@@ -636,10 +858,24 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
636
858
  allMissing: chartMissing.allMissing,
637
859
  getNoDataText: () => strings.chartNoData,
638
860
  getFontFamily: () => Chart?.defaults?.font?.family,
861
+ getTextColor: () => (chartParams.options as any)?.color ??
862
+ readThemeTokens(themeRoot, ['--mcp-ui-chart-label'])['--mcp-ui-chart-label'],
639
863
  }),
640
864
  ];
641
865
  }
642
866
 
867
+ if (themeRoot) {
868
+ // Keep the unthemed source separate: live updates must not mistake our
869
+ // own previously applied colors for explicit caller customization.
870
+ const originalOptions = {
871
+ ...baseOptions,
872
+ plugins: { ...baseOptions.plugins },
873
+ scales: baseOptions.scales ? { ...baseOptions.scales } : undefined,
874
+ };
875
+ chartTheme = createChartTheme(chartParams, originalOptions, themeRoot, chartConfig);
876
+ chartTheme.applyOptions(baseOptions);
877
+ }
878
+
643
879
  // Create new chart
644
880
  chartInstance = new Chart(canvasRef, chartConfig);
645
881
 
@@ -692,110 +928,139 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
692
928
  <ContextAwareChartLayout
693
929
  render={(isExpanded) => (
694
930
  <div
695
- class={`relative w-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden p-4 group ${
931
+ ref={themeRoot}
932
+ class={`mcp-ui-root relative w-full bg-[color:var(--mcp-ui-surface,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-surface,var(--color-gray-800,#1f2937))] rounded-lg shadow-[var(--mcp-ui-shadow,var(--shadow-sm))] border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-700,#374151))] overflow-hidden p-4 group ${
696
933
  isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
697
934
  } ${params().className || ''}`}
698
935
  >
699
- <div class="flex items-center justify-between gap-3 mb-3 flex-shrink-0">
700
- <div class="min-w-0">
701
- <Show when={params().title}>
702
- <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{params().title}</h3>
703
- </Show>
704
- </div>
705
- <div class="flex items-center gap-2">
706
- <div
707
- class="inline-flex rounded-md border border-gray-200 p-0.5 dark:border-gray-600"
708
- role="group"
709
- aria-label={strings.chartViewSelector}
710
- >
711
- <button
712
- type="button"
713
- aria-pressed={activeView() === 'chart'}
714
- class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
715
- activeView() === 'chart'
716
- ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
717
- : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
718
- }`}
719
- onClick={() => setActiveView('chart')}
720
- >
721
- {strings.chartView}
722
- </button>
723
- <button
724
- type="button"
725
- aria-pressed={activeView() === 'data'}
726
- class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
727
- activeView() === 'data'
728
- ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
729
- : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
730
- }`}
731
- onClick={() => setActiveView('data')}
732
- >
733
- {strings.chartDataView}
734
- </button>
735
- </div>
736
- {/* Zoom controls — permanently visible (not hover-revealed like
936
+ <div class="flex items-center justify-between gap-3 mb-3 flex-shrink-0">
937
+ <div class="min-w-0">
938
+ <Show when={params().title}>
939
+ <h3 class="text-sm font-semibold text-[color:var(--mcp-ui-fg,var(--color-gray-900,#111827))] dark:text-[color:var(--mcp-ui-fg,var(--color-white,#fff))]">
940
+ {params().title}
941
+ </h3>
942
+ </Show>
943
+ </div>
944
+ <div class="flex items-center gap-2">
945
+ <div
946
+ class="inline-flex rounded-md border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] p-0.5 dark:border-[color:var(--mcp-ui-border,var(--color-gray-600,#4b5563))]"
947
+ role="group"
948
+ aria-label={strings.chartViewSelector}
949
+ >
950
+ <button
951
+ type="button"
952
+ aria-pressed={activeView() === 'chart'}
953
+ class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
954
+ activeView() === 'chart'
955
+ ? 'bg-[color:var(--mcp-ui-accent,var(--color-gray-900,#111827))] text-[color:var(--mcp-ui-accent-fg,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-accent,var(--color-gray-100,#f3f4f6))] dark:text-[color:var(--mcp-ui-accent-fg,var(--color-gray-900,#111827))]'
956
+ : 'text-[color:var(--mcp-ui-fg-muted,var(--color-gray-600,#4b5563))] hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:text-[color:var(--mcp-ui-fg,var(--color-gray-300,#d1d5db))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))]'
957
+ }`}
958
+ onClick={() => setActiveView('chart')}
959
+ >
960
+ {strings.chartView}
961
+ </button>
962
+ <button
963
+ type="button"
964
+ aria-pressed={activeView() === 'data'}
965
+ class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
966
+ activeView() === 'data'
967
+ ? 'bg-[color:var(--mcp-ui-accent,var(--color-gray-900,#111827))] text-[color:var(--mcp-ui-accent-fg,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-accent,var(--color-gray-100,#f3f4f6))] dark:text-[color:var(--mcp-ui-accent-fg,var(--color-gray-900,#111827))]'
968
+ : 'text-[color:var(--mcp-ui-fg-muted,var(--color-gray-600,#4b5563))] hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:text-[color:var(--mcp-ui-fg,var(--color-gray-300,#d1d5db))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))]'
969
+ }`}
970
+ onClick={() => setActiveView('data')}
971
+ >
972
+ {strings.chartDataView}
973
+ </button>
974
+ </div>
975
+ {/* Zoom controls — permanently visible (not hover-revealed like
737
976
  the export button) for as long as this chart is zoomable at
738
977
  all. Zoom in/out come FIRST and unconditionally: they are the
739
978
  only keyboard route into a feature whose gestures are all
740
979
  pointer gestures, on a canvas that takes no focus. Reset joins
741
980
  them only once the chart is actually off its initial viewport,
742
981
  so the toolbar stays quiet on a chart nobody moved. */}
743
- <Show when={zoomControlsShown()}>
744
- <div class="inline-flex items-center gap-1">
745
- <button
746
- type="button"
747
- onClick={() => handleZoomBy(1 / CHART_ZOOM_BUTTON_FACTOR)}
748
- class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
749
- title={strings.zoomOut}
750
- aria-label={strings.zoomOut}
751
- >
752
- <svg
753
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
754
- fill="none"
755
- viewBox="0 0 24 24"
756
- stroke="currentColor"
757
- >
758
- <path
759
- stroke-linecap="round"
760
- stroke-linejoin="round"
761
- stroke-width="2"
762
- d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM13.5 10.5h-6"
763
- />
764
- </svg>
765
- </button>
766
-
767
- <button
768
- type="button"
769
- onClick={() => handleZoomBy(CHART_ZOOM_BUTTON_FACTOR)}
770
- class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
771
- title={strings.zoomIn}
772
- aria-label={strings.zoomIn}
773
- >
774
- <svg
775
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
776
- fill="none"
777
- viewBox="0 0 24 24"
778
- stroke="currentColor"
779
- >
780
- <path
781
- stroke-linecap="round"
782
- stroke-linejoin="round"
783
- stroke-width="2"
784
- d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6"
785
- />
786
- </svg>
787
- </button>
788
-
789
- <Show when={isZoomed()}>
982
+ <Show when={zoomControlsShown()}>
983
+ <div class="inline-flex items-center gap-1">
984
+ <button
985
+ type="button"
986
+ onClick={() => handleZoomBy(1 / CHART_ZOOM_BUTTON_FACTOR)}
987
+ class="opacity-60 hover:opacity-100 px-2 py-1 bg-[color:var(--mcp-ui-surface,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-surface,var(--color-gray-800,#1f2937))] border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-600,#4b5563))] rounded-full hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))] transition-all shadow-[var(--mcp-ui-shadow,var(--shadow-sm))]"
988
+ title={strings.zoomOut}
989
+ aria-label={strings.zoomOut}
990
+ >
991
+ <svg
992
+ class="w-3 h-3 text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
993
+ fill="none"
994
+ viewBox="0 0 24 24"
995
+ stroke="currentColor"
996
+ >
997
+ <path
998
+ stroke-linecap="round"
999
+ stroke-linejoin="round"
1000
+ stroke-width="2"
1001
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM13.5 10.5h-6"
1002
+ />
1003
+ </svg>
1004
+ </button>
1005
+
1006
+ <button
1007
+ type="button"
1008
+ onClick={() => handleZoomBy(CHART_ZOOM_BUTTON_FACTOR)}
1009
+ class="opacity-60 hover:opacity-100 px-2 py-1 bg-[color:var(--mcp-ui-surface,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-surface,var(--color-gray-800,#1f2937))] border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-600,#4b5563))] rounded-full hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))] transition-all shadow-[var(--mcp-ui-shadow,var(--shadow-sm))]"
1010
+ title={strings.zoomIn}
1011
+ aria-label={strings.zoomIn}
1012
+ >
1013
+ <svg
1014
+ class="w-3 h-3 text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
1015
+ fill="none"
1016
+ viewBox="0 0 24 24"
1017
+ stroke="currentColor"
1018
+ >
1019
+ <path
1020
+ stroke-linecap="round"
1021
+ stroke-linejoin="round"
1022
+ stroke-width="2"
1023
+ d="M21 21l-5.197-5.197m0 0A7.5 7.5 0 105.196 5.196a7.5 7.5 0 0010.607 10.607zM10.5 7.5v6m3-3h-6"
1024
+ />
1025
+ </svg>
1026
+ </button>
1027
+
1028
+ <Show when={isZoomed()}>
1029
+ <button
1030
+ type="button"
1031
+ onClick={handleResetZoom}
1032
+ class="opacity-60 hover:opacity-100 px-2 py-1 bg-[color:var(--mcp-ui-surface,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-surface,var(--color-gray-800,#1f2937))] border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-600,#4b5563))] rounded-full hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))] transition-all shadow-[var(--mcp-ui-shadow,var(--shadow-sm))]"
1033
+ title={strings.zoomReset}
1034
+ aria-label={strings.zoomReset}
1035
+ >
1036
+ <svg
1037
+ class="w-3 h-3 text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
1038
+ fill="none"
1039
+ viewBox="0 0 24 24"
1040
+ stroke="currentColor"
1041
+ >
1042
+ <path
1043
+ stroke-linecap="round"
1044
+ stroke-linejoin="round"
1045
+ stroke-width="2"
1046
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1047
+ />
1048
+ </svg>
1049
+ </button>
1050
+ </Show>
1051
+ </div>
1052
+ </Show>
1053
+ <Show when={exportEnabled()}>
790
1054
  <button
791
1055
  type="button"
792
- onClick={handleResetZoom}
793
- class="opacity-60 hover:opacity-100 px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
794
- title={strings.zoomReset}
795
- aria-label={strings.zoomReset}
1056
+ onClick={handleExportPNG}
1057
+ disabled={activeView() !== 'chart' || isLoading() || Boolean(error())}
1058
+ class="opacity-0 group-hover:opacity-60 hover:!opacity-100 disabled:!opacity-30 disabled:cursor-not-allowed px-2 py-1 bg-[color:var(--mcp-ui-surface,var(--color-white,#fff))] dark:bg-[color:var(--mcp-ui-surface,var(--color-gray-800,#1f2937))] border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-600,#4b5563))] rounded-full hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-100,#f3f4f6))] dark:hover:bg-[color:var(--mcp-ui-surface-hover,var(--color-gray-700,#374151))] transition-all shadow-[var(--mcp-ui-shadow,var(--shadow-sm))]"
1059
+ title={strings.chartDownloadPng}
1060
+ aria-label={strings.chartDownloadPngAria}
796
1061
  >
797
1062
  <svg
798
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
1063
+ class="w-3 h-3 text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
799
1064
  fill="none"
800
1065
  viewBox="0 0 24 24"
801
1066
  stroke="currentColor"
@@ -804,170 +1069,149 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
804
1069
  stroke-linecap="round"
805
1070
  stroke-linejoin="round"
806
1071
  stroke-width="2"
807
- d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
1072
+ d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
808
1073
  />
809
1074
  </svg>
810
1075
  </button>
811
1076
  </Show>
812
1077
  </div>
813
- </Show>
814
- <Show when={exportEnabled()}>
815
- <button
816
- type="button"
817
- onClick={handleExportPNG}
818
- disabled={activeView() !== 'chart' || isLoading() || Boolean(error())}
819
- class="opacity-0 group-hover:opacity-60 hover:!opacity-100 disabled:!opacity-30 disabled:cursor-not-allowed px-2 py-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-full hover:bg-gray-100 dark:hover:bg-gray-700 transition-all shadow-sm"
820
- title={strings.chartDownloadPng}
821
- aria-label={strings.chartDownloadPngAria}
822
- >
823
- <svg
824
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
825
- fill="none"
826
- viewBox="0 0 24 24"
827
- stroke="currentColor"
828
- >
829
- <path
830
- stroke-linecap="round"
831
- stroke-linejoin="round"
832
- stroke-width="2"
833
- d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
834
- />
835
- </svg>
836
- </button>
837
- </Show>
838
- </div>
839
- </div>
1078
+ </div>
840
1079
 
841
- <p id={descriptionId} class="sr-only">
842
- {title()}. {strings.chartDataSummary}
843
- </p>
1080
+ <p id={descriptionId} class="sr-only">
1081
+ {title()}. {strings.chartDataSummary}
1082
+ </p>
844
1083
 
845
- {/* Fallback ladder (P2.5): degrade to a series table on render error
1084
+ {/* Fallback ladder (P2.5): degrade to a series table on render error
846
1085
  instead of a bare "Chart Error" message. */}
847
- <Show when={error() && activeView() === 'chart'}>
848
- <DegradedFallback
849
- message={formatMCPUIString(strings.chartRenderFailed, { error: String(error()) })}
850
- caption={strings.chartDegradedCaption}
851
- {...tableData()}
852
- />
853
- </Show>
854
-
855
- <Show when={activeView() === 'data'}>
856
- <div
857
- class={`w-full overflow-auto rounded border border-gray-200 dark:border-gray-700 ${
858
- isExpanded() ? 'flex-1 min-h-0' : ''
859
- }`}
860
- style={
861
- isExpanded()
862
- ? undefined
863
- : { height: params().height || '250px', 'max-height': '70vh' }
864
- }
865
- >
866
- <table class="w-full border-collapse text-left text-sm">
867
- <caption class="sr-only">
868
- {title()} — {strings.chartDataTable}
869
- </caption>
870
- <thead class="bg-gray-50 dark:bg-gray-900/40">
871
- <tr>
872
- <For each={tableData().columns}>
873
- {(column) => (
874
- <th scope="col" class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
875
- {column}
876
- </th>
877
- )}
878
- </For>
879
- </tr>
880
- </thead>
881
- <tbody>
882
- <For each={tableData().rows}>
883
- {(row, rowIndex) => (
884
- <tr class="border-t border-gray-100 dark:border-gray-700">
1086
+ <Show when={error() && activeView() === 'chart'}>
1087
+ <DegradedFallback
1088
+ message={formatMCPUIString(strings.chartRenderFailed, { error: String(error()) })}
1089
+ caption={strings.chartDegradedCaption}
1090
+ {...tableData()}
1091
+ />
1092
+ </Show>
1093
+
1094
+ <Show when={activeView() === 'data'}>
1095
+ <div
1096
+ class={`w-full overflow-auto rounded border border-[color:var(--mcp-ui-border,var(--color-gray-200,#e5e7eb))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-700,#374151))] ${
1097
+ isExpanded() ? 'flex-1 min-h-0' : ''
1098
+ }`}
1099
+ style={
1100
+ isExpanded()
1101
+ ? undefined
1102
+ : { height: params().height || '250px', 'max-height': '70vh' }
1103
+ }
1104
+ >
1105
+ <table class="w-full border-collapse text-left text-sm">
1106
+ <caption class="sr-only">
1107
+ {title()} — {strings.chartDataTable}
1108
+ </caption>
1109
+ <thead class="bg-[color:var(--mcp-ui-surface-muted,var(--color-gray-50,#f9fafb))] dark:bg-[color:var(--mcp-ui-surface,color-mix(in_oklab,var(--color-gray-900,#111827)_40%,transparent))]">
1110
+ <tr>
885
1111
  <For each={tableData().columns}>
886
- {(_column, index) => (
887
- <td class="px-3 py-2 text-gray-700 dark:text-gray-300">
888
- <Show
889
- when={tableData().missing[rowIndex()]?.[index()]}
890
- fallback={String(row[index()] ?? '')}
891
- >
892
- <MissingValueMark />
893
- </Show>
894
- </td>
1112
+ {(column) => (
1113
+ <th
1114
+ scope="col"
1115
+ class="px-3 py-2 font-medium text-[color:var(--mcp-ui-fg,var(--color-gray-900,#111827))] dark:text-[color:var(--mcp-ui-fg,var(--color-gray-100,#f3f4f6))]"
1116
+ >
1117
+ {column}
1118
+ </th>
895
1119
  )}
896
1120
  </For>
897
1121
  </tr>
898
- )}
899
- </For>
900
- <Show when={tableData().rows.length === 0}>
901
- <tr>
902
- <td
903
- colSpan={Math.max(1, tableData().columns.length)}
904
- class="px-3 py-6 text-center text-gray-500 dark:text-gray-400"
905
- >
906
- {strings.chartNoData}
907
- </td>
908
- </tr>
909
- </Show>
910
- </tbody>
911
- </table>
912
- </div>
913
- <Show when={anyMissingCell()}>
914
- <MissingValueLegend class="mt-2" />
915
- </Show>
916
- </Show>
917
-
918
- <div
919
- class={`relative w-full ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
920
- style={
921
- error() || activeView() === 'data'
922
- ? { display: 'none' }
923
- : isExpanded()
924
- ? { height: '100%', display: 'block' }
925
- : { height: params().height || '250px', display: 'block' }
926
- }
927
- >
928
- <canvas
929
- ref={canvasRef}
930
- role="img"
931
- aria-label={title()}
932
- aria-describedby={notesShown() ? `${descriptionId} ${notesId}` : descriptionId}
933
- aria-hidden={activeView() !== 'chart' || Boolean(error())}
934
- />
935
- <Show when={isLoading() && activeView() === 'chart'}>
936
- <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
937
- <div class="flex flex-col items-center gap-2">
938
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
939
- <span class="text-sm text-gray-500 dark:text-gray-400">{strings.chartLoading}</span>
1122
+ </thead>
1123
+ <tbody>
1124
+ <For each={tableData().rows}>
1125
+ {(row, rowIndex) => (
1126
+ <tr class="border-t border-[color:var(--mcp-ui-border,var(--color-gray-100,#f3f4f6))] dark:border-[color:var(--mcp-ui-border,var(--color-gray-700,#374151))]">
1127
+ <For each={tableData().columns}>
1128
+ {(_column, index) => (
1129
+ <td class="px-3 py-2 text-[color:var(--mcp-ui-fg,var(--color-gray-700,#374151))] dark:text-[color:var(--mcp-ui-fg,var(--color-gray-300,#d1d5db))]">
1130
+ <Show
1131
+ when={tableData().missing[rowIndex()]?.[index()]}
1132
+ fallback={String(row[index()] ?? '')}
1133
+ >
1134
+ <MissingValueMark />
1135
+ </Show>
1136
+ </td>
1137
+ )}
1138
+ </For>
1139
+ </tr>
1140
+ )}
1141
+ </For>
1142
+ <Show when={tableData().rows.length === 0}>
1143
+ <tr>
1144
+ <td
1145
+ colSpan={Math.max(1, tableData().columns.length)}
1146
+ class="px-3 py-6 text-center text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
1147
+ >
1148
+ {strings.chartNoData}
1149
+ </td>
1150
+ </tr>
1151
+ </Show>
1152
+ </tbody>
1153
+ </table>
940
1154
  </div>
1155
+ <Show when={anyMissingCell()}>
1156
+ <MissingValueLegend class="mt-2" />
1157
+ </Show>
1158
+ </Show>
1159
+
1160
+ <div
1161
+ class={`relative w-full ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
1162
+ style={
1163
+ error() || activeView() === 'data'
1164
+ ? { display: 'none' }
1165
+ : isExpanded()
1166
+ ? { height: '100%', display: 'block' }
1167
+ : { height: params().height || '250px', display: 'block' }
1168
+ }
1169
+ >
1170
+ <canvas
1171
+ ref={canvasRef}
1172
+ role="img"
1173
+ aria-label={title()}
1174
+ aria-describedby={notesShown() ? `${descriptionId} ${notesId}` : descriptionId}
1175
+ aria-hidden={activeView() !== 'chart' || Boolean(error())}
1176
+ />
1177
+ <Show when={isLoading() && activeView() === 'chart'}>
1178
+ <div class="absolute inset-0 flex items-center justify-center bg-[color:var(--mcp-ui-surface,color-mix(in_oklab,var(--color-white,#fff)_80%,transparent))] dark:bg-[color:var(--mcp-ui-surface,color-mix(in_oklab,var(--color-gray-800,#1f2937)_80%,transparent))]">
1179
+ <div class="flex flex-col items-center gap-2">
1180
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-[color:var(--mcp-ui-accent-border,var(--color-blue-600))]" />
1181
+ <span class="text-sm text-[color:var(--mcp-ui-fg-muted,var(--color-gray-500,#6b7280))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]">
1182
+ {strings.chartLoading}
1183
+ </span>
1184
+ </div>
1185
+ </div>
1186
+ </Show>
941
1187
  </div>
942
- </Show>
943
- </div>
944
1188
 
945
- {/* Missing values (v6.24.0): how THIS chart type shows them, and which
1189
+ {/* Missing values (v6.24.0): how THIS chart type shows them, and which
946
1190
  series have no data at all. Linked from the canvas, so a screen
947
1191
  reader hears the convention with the chart. */}
948
- <Show when={notesShown()}>
949
- <div
950
- data-mcp-chart-notes=""
951
- id={notesId}
952
- class="mt-2 space-y-0.5 text-xs text-gray-600 dark:text-gray-400"
953
- >
954
- <Show when={conventionNoteShown()}>
955
- <p data-mcp-chart-note={noteKind()}>
956
- {noteKind() === 'bars'
957
- ? strings.chartMissingBars
958
- : noteKind() === 'omitted'
959
- ? strings.chartMissingPoints
960
- : strings.chartMissingGaps}
961
- </p>
962
- </Show>
963
- <Show when={missingSummary().fullyMissing.some(Boolean)}>
964
- <p data-mcp-chart-note="no-data">
965
- {formatMCPUIString(strings.chartSeriesNoData, { series: noDataSeries() })}
966
- </p>
1192
+ <Show when={notesShown()}>
1193
+ <div
1194
+ data-mcp-chart-notes=""
1195
+ id={notesId}
1196
+ class="mt-2 space-y-0.5 text-xs text-[color:var(--mcp-ui-fg-muted,var(--color-gray-600,#4b5563))] dark:text-[color:var(--mcp-ui-fg-subtle,var(--color-gray-400,#9ca3af))]"
1197
+ >
1198
+ <Show when={conventionNoteShown()}>
1199
+ <p data-mcp-chart-note={noteKind()}>
1200
+ {noteKind() === 'bars'
1201
+ ? strings.chartMissingBars
1202
+ : noteKind() === 'omitted'
1203
+ ? strings.chartMissingPoints
1204
+ : strings.chartMissingGaps}
1205
+ </p>
1206
+ </Show>
1207
+ <Show when={missingSummary().fullyMissing.some(Boolean)}>
1208
+ <p data-mcp-chart-note="no-data">
1209
+ {formatMCPUIString(strings.chartSeriesNoData, { series: noDataSeries() })}
1210
+ </p>
1211
+ </Show>
1212
+ </div>
967
1213
  </Show>
968
1214
  </div>
969
- </Show>
970
- </div>
971
1215
  )}
972
1216
  />
973
1217
  </ExpandableWrapper>