@seed-ship/mcp-ui-solid 6.23.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 (172) hide show
  1. package/CHANGELOG.md +167 -0
  2. package/README.md +154 -2
  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 +406 -70
  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 +407 -71
  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 +35 -3
  30. package/dist/components/DegradedFallback.cjs.map +1 -1
  31. package/dist/components/DegradedFallback.d.ts +7 -0
  32. package/dist/components/DegradedFallback.d.ts.map +1 -1
  33. package/dist/components/DegradedFallback.js +35 -3
  34. package/dist/components/DegradedFallback.js.map +1 -1
  35. package/dist/components/ExpandableWrapper.cjs +10 -7
  36. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  37. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  38. package/dist/components/ExpandableWrapper.js +10 -7
  39. package/dist/components/ExpandableWrapper.js.map +1 -1
  40. package/dist/components/FormFieldRenderer.cjs +13 -12
  41. package/dist/components/FormFieldRenderer.cjs.map +1 -1
  42. package/dist/components/FormFieldRenderer.d.ts.map +1 -1
  43. package/dist/components/FormFieldRenderer.js +13 -12
  44. package/dist/components/FormFieldRenderer.js.map +1 -1
  45. package/dist/components/FormRenderer.cjs +1 -1
  46. package/dist/components/FormRenderer.cjs.map +1 -1
  47. package/dist/components/FormRenderer.js +1 -1
  48. package/dist/components/FormRenderer.js.map +1 -1
  49. package/dist/components/GenerativeUIErrorBoundary.cjs +1 -1
  50. package/dist/components/GenerativeUIErrorBoundary.cjs.map +1 -1
  51. package/dist/components/GenerativeUIErrorBoundary.js +1 -1
  52. package/dist/components/GenerativeUIErrorBoundary.js.map +1 -1
  53. package/dist/components/GhostText.cjs +2 -2
  54. package/dist/components/GhostText.cjs.map +1 -1
  55. package/dist/components/GhostText.js +2 -2
  56. package/dist/components/GhostText.js.map +1 -1
  57. package/dist/components/GraphRenderer.cjs +100 -29
  58. package/dist/components/GraphRenderer.cjs.map +1 -1
  59. package/dist/components/GraphRenderer.d.ts +1 -1
  60. package/dist/components/GraphRenderer.d.ts.map +1 -1
  61. package/dist/components/GraphRenderer.js +100 -29
  62. package/dist/components/GraphRenderer.js.map +1 -1
  63. package/dist/components/MissingValue.cjs +43 -0
  64. package/dist/components/MissingValue.cjs.map +1 -0
  65. package/dist/components/MissingValue.d.ts +27 -0
  66. package/dist/components/MissingValue.d.ts.map +1 -0
  67. package/dist/components/MissingValue.js +43 -0
  68. package/dist/components/MissingValue.js.map +1 -0
  69. package/dist/components/ModalRenderer.cjs +39 -31
  70. package/dist/components/ModalRenderer.cjs.map +1 -1
  71. package/dist/components/ModalRenderer.d.ts.map +1 -1
  72. package/dist/components/ModalRenderer.js +40 -32
  73. package/dist/components/ModalRenderer.js.map +1 -1
  74. package/dist/components/PortalDropdownMenu.cjs +6 -3
  75. package/dist/components/PortalDropdownMenu.cjs.map +1 -1
  76. package/dist/components/PortalDropdownMenu.d.ts.map +1 -1
  77. package/dist/components/PortalDropdownMenu.js +7 -4
  78. package/dist/components/PortalDropdownMenu.js.map +1 -1
  79. package/dist/components/ScratchpadPanel.cjs +38 -44
  80. package/dist/components/ScratchpadPanel.cjs.map +1 -1
  81. package/dist/components/ScratchpadPanel.d.ts.map +1 -1
  82. package/dist/components/ScratchpadPanel.js +38 -44
  83. package/dist/components/ScratchpadPanel.js.map +1 -1
  84. package/dist/components/UIResourceRenderer.cjs +255 -217
  85. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  86. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  87. package/dist/components/UIResourceRenderer.js +255 -217
  88. package/dist/components/UIResourceRenderer.js.map +1 -1
  89. package/dist/components/chart-data-table.cjs +30 -5
  90. package/dist/components/chart-data-table.cjs.map +1 -1
  91. package/dist/components/chart-data-table.d.ts +18 -1
  92. package/dist/components/chart-data-table.d.ts.map +1 -1
  93. package/dist/components/chart-data-table.js +30 -5
  94. package/dist/components/chart-data-table.js.map +1 -1
  95. package/dist/components/chart-missing-plugin.cjs +194 -0
  96. package/dist/components/chart-missing-plugin.cjs.map +1 -0
  97. package/dist/components/chart-missing-plugin.d.ts +129 -0
  98. package/dist/components/chart-missing-plugin.d.ts.map +1 -0
  99. package/dist/components/chart-missing-plugin.js +194 -0
  100. package/dist/components/chart-missing-plugin.js.map +1 -0
  101. package/dist/context/MCPUIStringsContext.cjs +12 -1
  102. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  103. package/dist/context/MCPUIStringsContext.d.ts +62 -0
  104. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  105. package/dist/context/MCPUIStringsContext.js +12 -1
  106. package/dist/context/MCPUIStringsContext.js.map +1 -1
  107. package/dist/services/component-registry.cjs +9 -2
  108. package/dist/services/component-registry.cjs.map +1 -1
  109. package/dist/services/component-registry.d.ts.map +1 -1
  110. package/dist/services/component-registry.js +9 -2
  111. package/dist/services/component-registry.js.map +1 -1
  112. package/dist/services/validation.cjs +31 -8
  113. package/dist/services/validation.cjs.map +1 -1
  114. package/dist/services/validation.d.ts.map +1 -1
  115. package/dist/services/validation.js +31 -8
  116. package/dist/services/validation.js.map +1 -1
  117. package/dist/types/index.d.ts +14 -2
  118. package/dist/types/index.d.ts.map +1 -1
  119. package/dist/utils/degraded-projections.cjs +19 -3
  120. package/dist/utils/degraded-projections.cjs.map +1 -1
  121. package/dist/utils/degraded-projections.d.ts +22 -2
  122. package/dist/utils/degraded-projections.d.ts.map +1 -1
  123. package/dist/utils/degraded-projections.js +19 -3
  124. package/dist/utils/degraded-projections.js.map +1 -1
  125. package/dist/utils/math-markdown.cjs +1 -1
  126. package/dist/utils/math-markdown.cjs.map +1 -1
  127. package/dist/utils/math-markdown.js +1 -1
  128. package/dist/utils/math-markdown.js.map +1 -1
  129. package/dist/utils/missing-value.cjs +60 -0
  130. package/dist/utils/missing-value.cjs.map +1 -0
  131. package/dist/utils/missing-value.d.ts +79 -0
  132. package/dist/utils/missing-value.d.ts.map +1 -0
  133. package/dist/utils/missing-value.js +60 -0
  134. package/dist/utils/missing-value.js.map +1 -0
  135. package/dist/utils/theme.cjs +91 -0
  136. package/dist/utils/theme.cjs.map +1 -0
  137. package/dist/utils/theme.d.ts +11 -0
  138. package/dist/utils/theme.d.ts.map +1 -0
  139. package/dist/utils/theme.js +91 -0
  140. package/dist/utils/theme.js.map +1 -0
  141. package/docs/SECURITY-DEPENDENCIES.md +81 -0
  142. package/docs/THEMING.md +205 -0
  143. package/package.json +7 -5
  144. package/src/components/ActionGroupRenderer.tsx +6 -6
  145. package/src/components/AutocompleteDropdown.tsx +17 -17
  146. package/src/components/AutocompleteFormField.tsx +10 -9
  147. package/src/components/ChartJSRenderer.tsx +609 -212
  148. package/src/components/ChatPrompt.tsx +16 -16
  149. package/src/components/ComponentToolbar.tsx +5 -5
  150. package/src/components/DegradedFallback.tsx +33 -11
  151. package/src/components/ExpandableWrapper.tsx +15 -11
  152. package/src/components/FormFieldRenderer.tsx +52 -51
  153. package/src/components/FormRenderer.tsx +12 -12
  154. package/src/components/GenerativeUIErrorBoundary.tsx +6 -6
  155. package/src/components/GhostText.tsx +7 -7
  156. package/src/components/GraphRenderer.tsx +83 -67
  157. package/src/components/MissingValue.tsx +61 -0
  158. package/src/components/ModalRenderer.tsx +11 -5
  159. package/src/components/PortalDropdownMenu.tsx +6 -2
  160. package/src/components/ScratchpadPanel.tsx +144 -141
  161. package/src/components/UIResourceRenderer.tsx +143 -110
  162. package/src/components/chart-data-table.ts +50 -6
  163. package/src/components/chart-missing-plugin.ts +336 -0
  164. package/src/context/MCPUIStringsContext.tsx +76 -0
  165. package/src/services/component-registry.ts +10 -2
  166. package/src/services/validation.ts +44 -9
  167. package/src/styles/autocomplete.css +53 -53
  168. package/src/types/index.ts +13 -1
  169. package/src/utils/degraded-projections.ts +44 -4
  170. package/src/utils/math-markdown.ts +1 -1
  171. package/src/utils/missing-value.ts +127 -0
  172. 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,
@@ -32,10 +33,228 @@ import type { Accessor, JSX } from 'solid-js';
32
33
  import type { UIComponent, ChartComponentParams } from '../types';
33
34
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
34
35
  import { DegradedFallback } from './DegradedFallback';
36
+ import { MissingValueLegend, MissingValueMark } from './MissingValue';
35
37
  import { chartToDataTable } from './chart-data-table';
38
+ import {
39
+ applyUnitAxisTitle,
40
+ createMissingLegendGenerator,
41
+ createMissingValuesPlugin,
42
+ createTooltipLabel,
43
+ missingNoteKind,
44
+ withDatasetsNoGapSpan,
45
+ } from './chart-missing-plugin';
46
+ import { PART_TO_WHOLE_CHART_TYPES, summarizeChartMissing } from '../utils/missing-value';
36
47
  import { useTelemetry } from '../context/MCPUITelemetryContext';
37
48
  import { useMCPUIConfig } from '../context/MCPUIConfigContext';
38
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
+ }
39
258
 
40
259
  // Lazy load Chart.js to avoid bundling if not used
41
260
  let ChartJS: any = null;
@@ -305,9 +524,20 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
305
524
  const [zoomPlugin, setZoomPlugin] = createSignal<unknown | null>(null);
306
525
  const [isZoomed, setIsZoomed] = createSignal(false);
307
526
  let canvasRef: HTMLCanvasElement | undefined;
527
+ let themeRoot: HTMLDivElement | undefined;
308
528
  let chartInstance: any;
529
+ let chartTheme: ReturnType<typeof createChartTheme> | undefined;
309
530
  let renderVersion = 0;
310
531
 
532
+ onMount(() => {
533
+ if (themeRoot) {
534
+ const stop = observeTheme(themeRoot, () => {
535
+ if (chartInstance) chartTheme?.refresh(chartInstance);
536
+ });
537
+ onCleanup(stop);
538
+ }
539
+ });
540
+
311
541
  const params = () => props.component.params as ChartComponentParams;
312
542
  const telemetry = useTelemetry();
313
543
  const config = useMCPUIConfig();
@@ -320,9 +550,51 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
320
550
  point: strings.chartTablePoint,
321
551
  label: strings.degradedColLabel,
322
552
  seriesName: strings.degradedSeries,
553
+ withUnit: strings.chartLabelWithUnit,
323
554
  })
324
555
  );
556
+ // Absence is never zero: which entries are missing, and which series have
557
+ // nothing at all. Drives the notes, the plugin, the legend and the guard.
558
+ const missingSummary = createMemo(() => summarizeChartMissing(params()));
559
+ const unit = () => {
560
+ const value = params().unit;
561
+ return typeof value === 'string' && value !== '' ? value : undefined;
562
+ };
563
+ const anyMissingCell = () => tableData().missing.some((row) => row.some(Boolean));
564
+ // Never for a part-to-whole chart: it refuses missing values outright (the
565
+ // render effect turns it into the data table), so no convention applies —
566
+ // and the async guard must not leave a wrong note in the SSR markup.
567
+ const notesShown = () =>
568
+ missingSummary().hasMissing &&
569
+ !PART_TO_WHOLE_CHART_TYPES.has(params().type) &&
570
+ activeView() === 'chart' &&
571
+ !error();
572
+ const noteKind = () => missingNoteKind(params().type, params().options);
573
+ // The convention line explains a mark the reader can SEE: every missing bar
574
+ // gets a dashed stub, but a line or a scatter only shows an absence inside a
575
+ // series that still has values — a series with no value at all draws
576
+ // nothing, and the "no data" line alone covers it.
577
+ const conventionNoteShown = () =>
578
+ noteKind() === 'bars'
579
+ ? missingSummary().hasMissing
580
+ : missingSummary().partiallyMissing.some(Boolean);
581
+ // The series the chart has no data for, as a locale-aware list.
582
+ const noDataSeries = () => {
583
+ const names = (params().data?.datasets ?? [])
584
+ .map((dataset, index) =>
585
+ missingSummary().fullyMissing[index]
586
+ ? dataset?.label || formatMCPUIString(strings.degradedSeries, { n: index + 1 })
587
+ : null
588
+ )
589
+ .filter((name): name is string => name !== null);
590
+ try {
591
+ return new Intl.ListFormat(strings.locale, { type: 'conjunction' }).format(names);
592
+ } catch {
593
+ return names.join(', ');
594
+ }
595
+ };
325
596
  const descriptionId = createUniqueId();
597
+ const notesId = createUniqueId();
326
598
  const title = () => params().title || strings.chartView;
327
599
 
328
600
  // v6.1.0 — export visibility :
@@ -406,8 +678,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
406
678
  * here (host policy × surface × chart type, and the peer actually loaded),
407
679
  * and the chart is what is currently being shown.
408
680
  */
409
- const zoomControlsShown = () =>
410
- Boolean(zoomOptions()) && activeView() === 'chart' && !error();
681
+ const zoomControlsShown = () => Boolean(zoomOptions()) && activeView() === 'chart' && !error();
411
682
 
412
683
  /**
413
684
  * Zoom the chart by a fixed ratio, about its centre.
@@ -456,6 +727,8 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
456
727
  // plugin options at construction and the plugin only hooks charts created
457
728
  // after it was registered).
458
729
  const chartParams = params();
730
+ const chartMissing = missingSummary();
731
+ const chartUnit = unit();
459
732
  const zoom = zoomOptions();
460
733
  const version = ++renderVersion;
461
734
 
@@ -474,6 +747,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
474
747
  chartInstance = null;
475
748
  }
476
749
 
750
+ // A share of a whole cannot be missing: Chart.js parses `null` as 0 and
751
+ // would draw it as a zero slice. Validation rejects this earlier; this
752
+ // is the guard for a host that bypassed it. Thrown here so the catch
753
+ // below shows the data table (with the missing cells marked).
754
+ if (chartMissing.hasMissing && PART_TO_WHOLE_CHART_TYPES.has(chartParams.type)) {
755
+ throw new Error(strings.chartMissingUnsupported);
756
+ }
757
+
477
758
  // Build options, merging time-axis config if present (v3.1.0)
478
759
  const hostZoomOptions = chartParams.options?.plugins?.zoom;
479
760
  const baseOptions: any = {
@@ -517,12 +798,86 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
517
798
  };
518
799
  }
519
800
 
520
- // Create new chart
521
- chartInstance = new Chart(canvasRef, {
801
+ // Unit (v6.24.0): value-axis title and tooltip suffix. Both are no-ops
802
+ // on a payload without a unit.
803
+ applyUnitAxisTitle(baseOptions, chartParams.type, chartUnit);
804
+
805
+ const chartConfig: any = {
522
806
  type: chartParams.type,
523
- 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 })) },
524
811
  options: baseOptions,
525
- });
812
+ };
813
+
814
+ if (chartUnit !== undefined || chartMissing.hasMissing) {
815
+ const tooltip = baseOptions.plugins.tooltip;
816
+ // `tooltip: false` (or `null`) is Chart.js's per-chart plugin opt-out:
817
+ // the payload turned tooltips off, and adding a callback object here
818
+ // would turn them back on. A host-supplied label callback is the
819
+ // host's word too. Both are left exactly as written.
820
+ if (tooltip !== false && tooltip !== null && !tooltip?.callbacks?.label) {
821
+ baseOptions.plugins.tooltip = {
822
+ ...tooltip,
823
+ callbacks: {
824
+ ...tooltip?.callbacks,
825
+ label: createTooltipLabel(() => strings, chartUnit, chartParams.type),
826
+ },
827
+ };
828
+ }
829
+ }
830
+
831
+ if (chartMissing.hasMissing) {
832
+ // A line must break at a missing value, not join its neighbours.
833
+ chartConfig.data = withDatasetsNoGapSpan(chartParams.data);
834
+
835
+ // Legend of a fully-missing series: "{series} (no data)". Dataset
836
+ // legends only — pie-like legends list categories, not series.
837
+ const defaultGenerate =
838
+ baseOptions.plugins.legend?.labels?.generateLabels ??
839
+ Chart?.defaults?.plugins?.legend?.labels?.generateLabels;
840
+ if (typeof defaultGenerate === 'function') {
841
+ baseOptions.plugins.legend = {
842
+ ...baseOptions.plugins.legend,
843
+ labels: {
844
+ ...baseOptions.plugins.legend?.labels,
845
+ generateLabels: createMissingLegendGenerator(
846
+ defaultGenerate,
847
+ chartMissing.fullyMissing,
848
+ () => strings
849
+ ),
850
+ },
851
+ };
852
+ }
853
+
854
+ // Per-chart plugin (never `Chart.register`): the dashed stub of a
855
+ // missing bar, and "no data" on a chart with nothing to plot.
856
+ chartConfig.plugins = [
857
+ createMissingValuesPlugin({
858
+ allMissing: chartMissing.allMissing,
859
+ getNoDataText: () => strings.chartNoData,
860
+ getFontFamily: () => Chart?.defaults?.font?.family,
861
+ getTextColor: () => (chartParams.options as any)?.color ??
862
+ readThemeTokens(themeRoot, ['--mcp-ui-chart-label'])['--mcp-ui-chart-label'],
863
+ }),
864
+ ];
865
+ }
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
+
879
+ // Create new chart
880
+ chartInstance = new Chart(canvasRef, chartConfig);
526
881
 
527
882
  // A freshly built chart always sits on its initial viewport, so the
528
883
  // reset control goes away until the user zooms again.
@@ -573,110 +928,139 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
573
928
  <ContextAwareChartLayout
574
929
  render={(isExpanded) => (
575
930
  <div
576
- 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 ${
577
933
  isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
578
934
  } ${params().className || ''}`}
579
935
  >
580
- <div class="flex items-center justify-between gap-3 mb-3 flex-shrink-0">
581
- <div class="min-w-0">
582
- <Show when={params().title}>
583
- <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{params().title}</h3>
584
- </Show>
585
- </div>
586
- <div class="flex items-center gap-2">
587
- <div
588
- class="inline-flex rounded-md border border-gray-200 p-0.5 dark:border-gray-600"
589
- role="group"
590
- aria-label={strings.chartViewSelector}
591
- >
592
- <button
593
- type="button"
594
- aria-pressed={activeView() === 'chart'}
595
- class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
596
- activeView() === 'chart'
597
- ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
598
- : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
599
- }`}
600
- onClick={() => setActiveView('chart')}
601
- >
602
- {strings.chartView}
603
- </button>
604
- <button
605
- type="button"
606
- aria-pressed={activeView() === 'data'}
607
- class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
608
- activeView() === 'data'
609
- ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
610
- : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
611
- }`}
612
- onClick={() => setActiveView('data')}
613
- >
614
- {strings.chartDataView}
615
- </button>
616
- </div>
617
- {/* 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
618
976
  the export button) for as long as this chart is zoomable at
619
977
  all. Zoom in/out come FIRST and unconditionally: they are the
620
978
  only keyboard route into a feature whose gestures are all
621
979
  pointer gestures, on a canvas that takes no focus. Reset joins
622
980
  them only once the chart is actually off its initial viewport,
623
981
  so the toolbar stays quiet on a chart nobody moved. */}
624
- <Show when={zoomControlsShown()}>
625
- <div class="inline-flex items-center gap-1">
626
- <button
627
- type="button"
628
- onClick={() => handleZoomBy(1 / CHART_ZOOM_BUTTON_FACTOR)}
629
- 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"
630
- title={strings.zoomOut}
631
- aria-label={strings.zoomOut}
632
- >
633
- <svg
634
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
635
- fill="none"
636
- viewBox="0 0 24 24"
637
- stroke="currentColor"
638
- >
639
- <path
640
- stroke-linecap="round"
641
- stroke-linejoin="round"
642
- stroke-width="2"
643
- 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"
644
- />
645
- </svg>
646
- </button>
647
-
648
- <button
649
- type="button"
650
- onClick={() => handleZoomBy(CHART_ZOOM_BUTTON_FACTOR)}
651
- 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"
652
- title={strings.zoomIn}
653
- aria-label={strings.zoomIn}
654
- >
655
- <svg
656
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
657
- fill="none"
658
- viewBox="0 0 24 24"
659
- stroke="currentColor"
660
- >
661
- <path
662
- stroke-linecap="round"
663
- stroke-linejoin="round"
664
- stroke-width="2"
665
- 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"
666
- />
667
- </svg>
668
- </button>
669
-
670
- <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()}>
671
1054
  <button
672
1055
  type="button"
673
- onClick={handleResetZoom}
674
- 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"
675
- title={strings.zoomReset}
676
- 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}
677
1061
  >
678
1062
  <svg
679
- 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))]"
680
1064
  fill="none"
681
1065
  viewBox="0 0 24 24"
682
1066
  stroke="currentColor"
@@ -685,135 +1069,148 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
685
1069
  stroke-linecap="round"
686
1070
  stroke-linejoin="round"
687
1071
  stroke-width="2"
688
- 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"
689
1073
  />
690
1074
  </svg>
691
1075
  </button>
692
1076
  </Show>
693
1077
  </div>
694
- </Show>
695
- <Show when={exportEnabled()}>
696
- <button
697
- type="button"
698
- onClick={handleExportPNG}
699
- disabled={activeView() !== 'chart' || isLoading() || Boolean(error())}
700
- 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"
701
- title={strings.chartDownloadPng}
702
- aria-label={strings.chartDownloadPngAria}
703
- >
704
- <svg
705
- class="w-3 h-3 text-gray-500 dark:text-gray-400"
706
- fill="none"
707
- viewBox="0 0 24 24"
708
- stroke="currentColor"
709
- >
710
- <path
711
- stroke-linecap="round"
712
- stroke-linejoin="round"
713
- stroke-width="2"
714
- 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"
715
- />
716
- </svg>
717
- </button>
718
- </Show>
719
- </div>
720
- </div>
1078
+ </div>
721
1079
 
722
- <p id={descriptionId} class="sr-only">
723
- {title()}. {strings.chartDataSummary}
724
- </p>
1080
+ <p id={descriptionId} class="sr-only">
1081
+ {title()}. {strings.chartDataSummary}
1082
+ </p>
725
1083
 
726
- {/* 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
727
1085
  instead of a bare "Chart Error" message. */}
728
- <Show when={error() && activeView() === 'chart'}>
729
- <DegradedFallback
730
- message={formatMCPUIString(strings.chartRenderFailed, { error: String(error()) })}
731
- caption={strings.chartDegradedCaption}
732
- {...tableData()}
733
- />
734
- </Show>
735
-
736
- <Show when={activeView() === 'data'}>
737
- <div
738
- class={`w-full overflow-auto rounded border border-gray-200 dark:border-gray-700 ${
739
- isExpanded() ? 'flex-1 min-h-0' : ''
740
- }`}
741
- style={
742
- isExpanded()
743
- ? undefined
744
- : { height: params().height || '250px', 'max-height': '70vh' }
745
- }
746
- >
747
- <table class="w-full border-collapse text-left text-sm">
748
- <caption class="sr-only">
749
- {title()} — {strings.chartDataTable}
750
- </caption>
751
- <thead class="bg-gray-50 dark:bg-gray-900/40">
752
- <tr>
753
- <For each={tableData().columns}>
754
- {(column) => (
755
- <th scope="col" class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
756
- {column}
757
- </th>
758
- )}
759
- </For>
760
- </tr>
761
- </thead>
762
- <tbody>
763
- <For each={tableData().rows}>
764
- {(row) => (
765
- <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>
766
1111
  <For each={tableData().columns}>
767
- {(_column, index) => (
768
- <td class="px-3 py-2 text-gray-700 dark:text-gray-300">
769
- {String(row[index()] ?? '')}
770
- </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>
771
1119
  )}
772
1120
  </For>
773
1121
  </tr>
774
- )}
775
- </For>
776
- <Show when={tableData().rows.length === 0}>
777
- <tr>
778
- <td
779
- colSpan={Math.max(1, tableData().columns.length)}
780
- class="px-3 py-6 text-center text-gray-500 dark:text-gray-400"
781
- >
782
- {strings.chartNoData}
783
- </td>
784
- </tr>
785
- </Show>
786
- </tbody>
787
- </table>
788
- </div>
789
- </Show>
790
-
791
- <div
792
- class={`relative w-full ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
793
- style={
794
- error() || activeView() === 'data'
795
- ? { display: 'none' }
796
- : isExpanded()
797
- ? { height: '100%', display: 'block' }
798
- : { height: params().height || '250px', display: 'block' }
799
- }
800
- >
801
- <canvas
802
- ref={canvasRef}
803
- role="img"
804
- aria-label={title()}
805
- aria-describedby={descriptionId}
806
- aria-hidden={activeView() !== 'chart' || Boolean(error())}
807
- />
808
- <Show when={isLoading() && activeView() === 'chart'}>
809
- <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
810
- <div class="flex flex-col items-center gap-2">
811
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
812
- <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>
813
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>
814
1187
  </div>
815
- </Show>
816
- </div>
1188
+
1189
+ {/* Missing values (v6.24.0): how THIS chart type shows them, and which
1190
+ series have no data at all. Linked from the canvas, so a screen
1191
+ reader hears the convention with the chart. */}
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>
1213
+ </Show>
817
1214
  </div>
818
1215
  )}
819
1216
  />