@seed-ship/mcp-ui-solid 6.20.0 → 6.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/README.md +120 -3
  3. package/dist/components/ChartJSRenderer.cjs +244 -68
  4. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  5. package/dist/components/ChartJSRenderer.d.ts +9 -0
  6. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  7. package/dist/components/ChartJSRenderer.js +246 -70
  8. package/dist/components/ChartJSRenderer.js.map +1 -1
  9. package/dist/components/ExpandableWrapper.cjs +9 -1
  10. package/dist/components/ExpandableWrapper.cjs.map +1 -1
  11. package/dist/components/ExpandableWrapper.d.ts.map +1 -1
  12. package/dist/components/ExpandableWrapper.js +9 -1
  13. package/dist/components/ExpandableWrapper.js.map +1 -1
  14. package/dist/components/IframeFallbackLink.cjs +37 -0
  15. package/dist/components/IframeFallbackLink.cjs.map +1 -0
  16. package/dist/components/IframeFallbackLink.d.ts +23 -0
  17. package/dist/components/IframeFallbackLink.d.ts.map +1 -0
  18. package/dist/components/IframeFallbackLink.js +37 -0
  19. package/dist/components/IframeFallbackLink.js.map +1 -0
  20. package/dist/components/LightboxOverlay.cjs +139 -16
  21. package/dist/components/LightboxOverlay.cjs.map +1 -1
  22. package/dist/components/LightboxOverlay.d.ts +6 -0
  23. package/dist/components/LightboxOverlay.d.ts.map +1 -1
  24. package/dist/components/LightboxOverlay.js +141 -18
  25. package/dist/components/LightboxOverlay.js.map +1 -1
  26. package/dist/components/UIResourceRenderer.cjs +150 -127
  27. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  28. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  29. package/dist/components/UIResourceRenderer.js +151 -128
  30. package/dist/components/UIResourceRenderer.js.map +1 -1
  31. package/dist/components/VideoRenderer.cjs +45 -19
  32. package/dist/components/VideoRenderer.cjs.map +1 -1
  33. package/dist/components/VideoRenderer.d.ts.map +1 -1
  34. package/dist/components/VideoRenderer.js +45 -19
  35. package/dist/components/VideoRenderer.js.map +1 -1
  36. package/dist/context/MCPUIConfigContext.cjs +30 -0
  37. package/dist/context/MCPUIConfigContext.cjs.map +1 -0
  38. package/dist/context/MCPUIConfigContext.d.ts +173 -0
  39. package/dist/context/MCPUIConfigContext.d.ts.map +1 -0
  40. package/dist/context/MCPUIConfigContext.js +30 -0
  41. package/dist/context/MCPUIConfigContext.js.map +1 -0
  42. package/dist/context/MCPUIStringsContext.cjs +8 -1
  43. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  44. package/dist/context/MCPUIStringsContext.d.ts +29 -0
  45. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  46. package/dist/context/MCPUIStringsContext.js +8 -1
  47. package/dist/context/MCPUIStringsContext.js.map +1 -1
  48. package/dist/hooks/index.d.ts +2 -0
  49. package/dist/hooks/index.d.ts.map +1 -1
  50. package/dist/hooks.cjs +2 -0
  51. package/dist/hooks.cjs.map +1 -1
  52. package/dist/hooks.js +2 -0
  53. package/dist/hooks.js.map +1 -1
  54. package/dist/index.cjs +12 -0
  55. package/dist/index.cjs.map +1 -1
  56. package/dist/index.d.cts +5 -1
  57. package/dist/index.d.ts +5 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +14 -2
  60. package/dist/index.js.map +1 -1
  61. package/dist/services/index.d.ts +1 -1
  62. package/dist/services/index.d.ts.map +1 -1
  63. package/dist/services/validation.cjs +11 -10
  64. package/dist/services/validation.cjs.map +1 -1
  65. package/dist/services/validation.d.ts +20 -0
  66. package/dist/services/validation.d.ts.map +1 -1
  67. package/dist/services/validation.js +11 -10
  68. package/dist/services/validation.js.map +1 -1
  69. package/dist/utils/iframe-coep.cjs +30 -0
  70. package/dist/utils/iframe-coep.cjs.map +1 -0
  71. package/dist/utils/iframe-coep.d.ts +53 -0
  72. package/dist/utils/iframe-coep.d.ts.map +1 -0
  73. package/dist/utils/iframe-coep.js +30 -0
  74. package/dist/utils/iframe-coep.js.map +1 -0
  75. package/dist/utils/pan-zoom.cjs +312 -0
  76. package/dist/utils/pan-zoom.cjs.map +1 -0
  77. package/dist/utils/pan-zoom.d.ts +235 -0
  78. package/dist/utils/pan-zoom.d.ts.map +1 -0
  79. package/dist/utils/pan-zoom.js +312 -0
  80. package/dist/utils/pan-zoom.js.map +1 -0
  81. package/dist/validation.cjs +2 -0
  82. package/dist/validation.cjs.map +1 -1
  83. package/dist/validation.d.cts +1 -1
  84. package/dist/validation.d.ts +1 -1
  85. package/dist/validation.d.ts.map +1 -1
  86. package/dist/validation.js +3 -1
  87. package/package.json +5 -1
  88. package/src/components/ChartJSRenderer.tsx +396 -1
  89. package/src/components/ExpandableWrapper.tsx +26 -0
  90. package/src/components/IframeFallbackLink.tsx +57 -0
  91. package/src/components/LightboxOverlay.tsx +280 -4
  92. package/src/components/UIResourceRenderer.tsx +31 -4
  93. package/src/components/VideoRenderer.tsx +20 -0
  94. package/src/context/MCPUIConfigContext.tsx +202 -0
  95. package/src/context/MCPUIStringsContext.tsx +40 -0
  96. package/src/hooks/index.ts +13 -0
  97. package/src/index.ts +31 -0
  98. package/src/services/index.ts +1 -0
  99. package/src/services/validation.ts +38 -16
  100. package/src/solid-jsx-attrs.d.ts +24 -0
  101. package/src/utils/iframe-coep.ts +81 -0
  102. package/src/utils/pan-zoom.ts +779 -0
  103. package/src/validation.ts +4 -0
@@ -6,6 +6,15 @@
6
6
  * ```
7
7
  * pnpm add chart.js
8
8
  * ```
9
+ *
10
+ * Wheel-zoom / drag-pan / pinch (v6.22.0) need a SECOND optional peer, since a
11
+ * canvas cannot be zoomed with a CSS transform without blurring:
12
+ * ```
13
+ * pnpm add chartjs-plugin-zoom
14
+ * ```
15
+ * Without it the chart renders exactly as it did before 6.22.0. Where zoom is
16
+ * offered is the host's call — see `MCPUIConfig.chartZoom`, default
17
+ * `'expanded'` (fullscreen modal only).
9
18
  */
10
19
 
11
20
  import {
@@ -16,6 +25,7 @@ import {
16
25
  createUniqueId,
17
26
  onCleanup,
18
27
  createSignal,
28
+ untrack,
19
29
  Show,
20
30
  } from 'solid-js';
21
31
  import type { Accessor, JSX } from 'solid-js';
@@ -24,6 +34,7 @@ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
24
34
  import { DegradedFallback } from './DegradedFallback';
25
35
  import { chartToDataTable } from './chart-data-table';
26
36
  import { useTelemetry } from '../context/MCPUITelemetryContext';
37
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext';
27
38
  import { formatMCPUIString, useMCPUIStrings } from '../context/MCPUIStringsContext';
28
39
 
29
40
  // Lazy load Chart.js to avoid bundling if not used
@@ -60,6 +71,184 @@ export async function isChartJSAvailable(): Promise<boolean> {
60
71
  }
61
72
  }
62
73
 
74
+ // ─── Optional zoom peer (v6.22.0) ─────────────────────────────────────────
75
+ //
76
+ // A chart is a CANVAS, so the CSS-transform approach used by `createPanZoom`
77
+ // for DOM surfaces would just magnify a bitmap into a blurry one. Real chart
78
+ // zoom means re-projecting the scales, which only Chart.js itself can do —
79
+ // hence `chartjs-plugin-zoom`, wired here as an OPTIONAL peer dependency
80
+ // alongside `chart.js`, `leaflet` and `@antv/g6`. A host that does not install
81
+ // it gets exactly the chart this renderer built before 6.22.0.
82
+
83
+ let zoomPluginPromise: Promise<unknown | null> | null = null;
84
+ let zoomPluginRegistered = false;
85
+
86
+ /**
87
+ * Import `chartjs-plugin-zoom` once, lazily.
88
+ *
89
+ * Resolves to `null` instead of rejecting when the peer is absent: an optional
90
+ * dependency that is simply not installed is not an error condition, and the
91
+ * caller must be able to fall through to the unzoomable chart without an error
92
+ * state or console noise. The `null` is cached with the promise — a peer that
93
+ * is missing now is still missing on the next chart, and retrying the import
94
+ * per render would re-log the module-resolution failure every time.
95
+ *
96
+ * Only ever called from an effect, never at module scope or during render, so
97
+ * SSR never reaches `hammerjs` (a plugin dependency that reads `document` as
98
+ * it loads).
99
+ *
100
+ * `@vite-ignore` for the same reason `duckdb.ts` and `MapRenderer.tsx` carry
101
+ * it on THEIR optional peers: this library is built with the peer externalised,
102
+ * so the bare specifier survives into the published bundle and a consumer's
103
+ * bundler resolves it statically — an app that never installed the plugin then
104
+ * fails its own dependency scan at build time, long before the `.catch()`
105
+ * below could turn the absence into the supported `null`. The four peers that
106
+ * anyone using the feature at all has installed (`chart.js`, `leaflet`,
107
+ * `@antv/g6`, `highlight.js`) stay bare on purpose: they benefit from the
108
+ * pre-bundling this comment opts out of.
109
+ */
110
+ const loadZoomPlugin = (): Promise<unknown | null> => {
111
+ if (!zoomPluginPromise) {
112
+ zoomPluginPromise = import(/* @vite-ignore */ 'chartjs-plugin-zoom')
113
+ .then((module: any) => module.default ?? module)
114
+ .catch(() => null);
115
+ }
116
+
117
+ return zoomPluginPromise;
118
+ };
119
+
120
+ /**
121
+ * Load the plugin and register it on the Chart.js singleton, once per process.
122
+ *
123
+ * Registration is global and not undoable, which is precisely why it is
124
+ * deferred to the first surface that actually asks for zoom
125
+ * (`MCPUIConfig.chartZoom`): with the default `'expanded'`, a chart sitting
126
+ * inline in a chat feed never even downloads the plugin.
127
+ *
128
+ * Chart.js only gives the plugin's hooks to charts constructed AFTER the
129
+ * `register()` call, so the caller rebuilds its chart once this resolves.
130
+ *
131
+ * Resolves to `null` on EVERY failure and never rejects. Its one call site is
132
+ * a fire-and-forget `.then()` inside an effect, with nowhere to put a rejection
133
+ * handler that would mean anything to the host: `loadZoomPlugin()` already
134
+ * swallows the missing optional peer, but `loadChartJS()` below rejects on a
135
+ * host that installed neither, and that rejection would surface as an
136
+ * unhandled one. The chart's own render effect reports the missing `chart.js`
137
+ * through `onError` and the degraded table; zoom just quietly is not offered.
138
+ */
139
+ const ensureZoomPlugin = async (): Promise<unknown | null> => {
140
+ try {
141
+ const plugin = await loadZoomPlugin();
142
+ if (!plugin) return null;
143
+
144
+ if (!zoomPluginRegistered) {
145
+ const Chart: any = await loadChartJS();
146
+ // A stubbed or partial Chart build may not expose `register`; treat that
147
+ // like an absent peer. Returning the plugin here would be worse than
148
+ // returning nothing: the renderer would hang zoom options off a chart
149
+ // that has no zoom hooks, and paint a toolbar whose buttons do nothing.
150
+ if (typeof Chart?.register !== 'function') return null;
151
+
152
+ Chart.register(plugin);
153
+ // Set only now that a registration has actually happened, so a later
154
+ // call still gets its chance on a Chart build that does expose it.
155
+ zoomPluginRegistered = true;
156
+ }
157
+
158
+ return plugin;
159
+ } catch {
160
+ return null;
161
+ }
162
+ };
163
+
164
+ /**
165
+ * Chart types whose axes zoom meaningfully.
166
+ *
167
+ * `pie`, `doughnut`, `polarArea` and `radar` carry no cartesian scales, so the
168
+ * plugin would capture the wheel and move nothing — strictly worse than not
169
+ * offering zoom at all.
170
+ */
171
+ const ZOOMABLE_CHART_TYPES = new Set(['bar', 'line', 'scatter', 'bubble']);
172
+
173
+ /**
174
+ * Ratio one press of the chart's zoom buttons applies.
175
+ *
176
+ * The same 1.5 the lightbox uses for `+` / `-`: coarse enough that a couple of
177
+ * presses get somewhere, fine enough to stop on a region of interest.
178
+ */
179
+ const CHART_ZOOM_BUTTON_FACTOR = 1.5;
180
+
181
+ /**
182
+ * Whether a chart instance currently sits off its initial viewport.
183
+ *
184
+ * `isZoomedOrPanned()` is the plugin's own API (2.x). The `getZoomLevel()`
185
+ * branch covers an older plugin build that only exposes the zoom factor, and
186
+ * the `false` covers a chart created before the plugin was registered.
187
+ */
188
+ const isChartZoomed = (chart: any): boolean => {
189
+ if (typeof chart?.isZoomedOrPanned === 'function') return Boolean(chart.isZoomedOrPanned());
190
+ const level = chart?.getZoomLevel?.();
191
+ return typeof level === 'number' ? level !== 1 : false;
192
+ };
193
+
194
+ /** A JSON-ish object — the only shape a zoom option tree is ever built from. */
195
+ const isPlainZoomObject = (value: unknown): value is Record<string, unknown> =>
196
+ typeof value === 'object' && value !== null && !Array.isArray(value);
197
+
198
+ /**
199
+ * Deep-merge this renderer's zoom block with the consumer's own.
200
+ *
201
+ * `ChartComponentParams.options` is the public Chart.js escape hatch, and
202
+ * `plugins.zoom` is where a host tunes what this renderer cannot guess:
203
+ * `limits`, wheel `speed`, a pan `modifierKey`, its own callbacks. Spreading
204
+ * the renderer's block over it would silently drop the lot, so the two trees
205
+ * are merged branch by branch (`zoom.wheel`, `zoom.pinch`, `zoom.drag`, `pan`,
206
+ * `limits`, …).
207
+ *
208
+ * Two rules, both deliberate:
209
+ * - on a plain value the CALLER wins — an explicit `mode: 'x'` is a more
210
+ * specific instruction than this renderer's default `'xy'`;
211
+ * - on a function both run, renderer first. The only functions the renderer
212
+ * puts in that tree are `onZoom` / `onPan`, and they are what keeps the
213
+ * reset control in sync with the viewport — letting a caller's callback
214
+ * REPLACE them would leave the toolbar lying about the chart, so they are
215
+ * composed instead.
216
+ */
217
+ const mergeZoomOptions = (
218
+ base: Record<string, unknown>,
219
+ override: unknown
220
+ ): Record<string, unknown> => {
221
+ if (!isPlainZoomObject(override)) return base;
222
+
223
+ const merged: Record<string, unknown> = { ...base };
224
+
225
+ for (const [key, value] of Object.entries(override)) {
226
+ // An explicitly-undefined key is an OMISSION, not an instruction to erase.
227
+ // A host forwarding an optional prop — `zoom: { onZoom: props.onZoom }`
228
+ // with nothing passed — would otherwise delete the renderer's own hook,
229
+ // which is exactly what keeps the reset control honest. This is also the
230
+ // semantics `mergeProps` gives every other merge in this package.
231
+ if (value === undefined) continue;
232
+
233
+ const ours = merged[key];
234
+
235
+ if (isPlainZoomObject(ours) && isPlainZoomObject(value)) {
236
+ merged[key] = mergeZoomOptions(ours, value);
237
+ } else if (typeof ours === 'function' && typeof value === 'function') {
238
+ const rendererHook = ours as (...args: unknown[]) => unknown;
239
+ const callerHook = value as (...args: unknown[]) => unknown;
240
+ merged[key] = (...args: unknown[]) => {
241
+ rendererHook(...args);
242
+ return callerHook(...args);
243
+ };
244
+ } else {
245
+ merged[key] = value;
246
+ }
247
+ }
248
+
249
+ return merged;
250
+ };
251
+
63
252
  export interface ChartJSRendererProps {
64
253
  /**
65
254
  * UIComponent with chart params
@@ -108,12 +297,20 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
108
297
  const [isLoading, setIsLoading] = createSignal(true);
109
298
  const [error, setError] = createSignal<string>();
110
299
  const [activeView, setActiveView] = createSignal<'chart' | 'data'>('chart');
300
+ // Reported by `<ExpandableWrapper onExpandedChange>` rather than read from
301
+ // `useExpanded()`: this component IS the wrapper's owner, so its own
302
+ // `useExpanded()` call sits above the wrapper's provider and would only ever
303
+ // see an outer wrapper (see ExpandableWrapperProps.onExpandedChange).
304
+ const [isExpanded, setIsExpanded] = createSignal(false);
305
+ const [zoomPlugin, setZoomPlugin] = createSignal<unknown | null>(null);
306
+ const [isZoomed, setIsZoomed] = createSignal(false);
111
307
  let canvasRef: HTMLCanvasElement | undefined;
112
308
  let chartInstance: any;
113
309
  let renderVersion = 0;
114
310
 
115
311
  const params = () => props.component.params as ChartComponentParams;
116
312
  const telemetry = useTelemetry();
313
+ const config = useMCPUIConfig();
117
314
  const strings = useMCPUIStrings();
118
315
  // The accessible data table is a pure projection: its column headers come
119
316
  // from the chrome strings, injected rather than hardcoded in the helper.
@@ -137,6 +334,107 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
137
334
  // Lazy-stringified each time the button is clicked.
138
335
  const copyDataJSON = () => JSON.stringify({ type: params().type, data: params().data }, null, 2);
139
336
 
337
+ // ── Zoom / pan (v6.22.0) ───────────────────────────────────────────────
338
+
339
+ /**
340
+ * Whether this chart should be capturing the wheel right now: the host
341
+ * policy (`MCPUIConfig.chartZoom`) crossed with the surface the chart
342
+ * currently lives on, and with the chart type.
343
+ *
344
+ * `'expanded'` (the default) only says yes inside `ExpandableWrapper`'s
345
+ * fullscreen modal — the one surface that owns its viewport. Inline, the
346
+ * wheel belongs to the page the chart is scrolling in.
347
+ */
348
+ const zoomWanted = createMemo(() => {
349
+ if (!ZOOMABLE_CHART_TYPES.has(params().type)) return false;
350
+ // `false` (or `null`) on the host's own `plugins.zoom` is Chart.js's
351
+ // documented per-chart plugin opt-out. It speaks about THIS chart, so it
352
+ // beats the host-wide `chartZoom` policy — and gating here rather than at
353
+ // the options merge keeps one source of truth, so the plugin is never even
354
+ // downloaded and the toolbar never offers controls that would do nothing.
355
+ const hostZoom = params().options?.plugins?.zoom;
356
+ if (hostZoom === false || hostZoom === null) return false;
357
+ const mode = config.chartZoom;
358
+ if (mode === 'never') return false;
359
+ if (mode === 'always') return true;
360
+ return isExpanded();
361
+ });
362
+
363
+ // Download + register the plugin the first time a surface asks for zoom.
364
+ // `zoomPlugin` is read untracked: setting it must not re-run this effect.
365
+ createEffect(() => {
366
+ if (!zoomWanted() || untrack(zoomPlugin)) return;
367
+ void ensureZoomPlugin().then((plugin) => {
368
+ if (plugin) setZoomPlugin(() => plugin);
369
+ });
370
+ });
371
+
372
+ /**
373
+ * The plugin's Chart.js options. Built once per renderer instance so that
374
+ * `zoomOptions()` keeps a STABLE identity: a fresh object each read would
375
+ * look like a config change and rebuild the chart on every update.
376
+ */
377
+ const zoomPluginOptions = {
378
+ zoom: {
379
+ wheel: { enabled: true },
380
+ pinch: { enabled: true },
381
+ // Drag is reserved for panning below; a drag-to-select zoom would make
382
+ // the two gestures fight over the same mouse button.
383
+ drag: { enabled: false },
384
+ mode: 'xy',
385
+ onZoom: ({ chart }: { chart: unknown }) => setIsZoomed(isChartZoomed(chart)),
386
+ },
387
+ pan: {
388
+ enabled: true,
389
+ mode: 'xy',
390
+ onPan: ({ chart }: { chart: unknown }) => setIsZoomed(isChartZoomed(chart)),
391
+ },
392
+ };
393
+
394
+ /**
395
+ * The `plugins.zoom` block to merge into the chart config, or `null`.
396
+ *
397
+ * `null` while the peer is absent, which keeps the built config byte-for-byte
398
+ * what it was before 6.22.0 — and, because the memo then never changes value,
399
+ * also keeps expanding the modal from rebuilding the chart at all on a host
400
+ * that did not install the plugin.
401
+ */
402
+ const zoomOptions = createMemo(() => (zoomWanted() && zoomPlugin() ? zoomPluginOptions : null));
403
+
404
+ /**
405
+ * Whether the zoom toolbar belongs on screen at all: the chart is zoomable
406
+ * here (host policy × surface × chart type, and the peer actually loaded),
407
+ * and the chart is what is currently being shown.
408
+ */
409
+ const zoomControlsShown = () =>
410
+ Boolean(zoomOptions()) && activeView() === 'chart' && !error();
411
+
412
+ /**
413
+ * Zoom the chart by a fixed ratio, about its centre.
414
+ *
415
+ * Every gesture the plugin offers — wheel, drag-pan, pinch — is a POINTER
416
+ * gesture, and the canvas is not focusable, so without these buttons the
417
+ * whole feature is unreachable by keyboard, switch or voice (WCAG 2.1.1) —
418
+ * including the reset control, which only exists once a gesture has already
419
+ * moved the chart. The lightbox settled the same question the same way, with
420
+ * persistent `+` / `-` controls next to its pointer gestures.
421
+ *
422
+ * `chart.zoom()` is `chartjs-plugin-zoom`'s own imperative API; the `?.`
423
+ * covers a chart built before the plugin was registered. The plugin calls
424
+ * `onZoom` itself, but the state is written here too so the reset control
425
+ * appears even on a build whose hook does not fire for a programmatic zoom.
426
+ */
427
+ const handleZoomBy = (factor: number) => {
428
+ chartInstance?.zoom?.(factor);
429
+ setIsZoomed(isChartZoomed(chartInstance));
430
+ };
431
+
432
+ /** Back to the chart's initial viewport. */
433
+ const handleResetZoom = () => {
434
+ chartInstance?.resetZoom?.();
435
+ setIsZoomed(false);
436
+ };
437
+
140
438
  // Chart PNG export
141
439
  const handleExportPNG = () => {
142
440
  if (!canvasRef) return;
@@ -151,8 +449,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
151
449
  createEffect(async () => {
152
450
  if (!canvasRef) return;
153
451
 
154
- // Access params to track dependencies
452
+ // Access params to track dependencies. `zoomOptions()` is read here, before
453
+ // the first `await`, for the same reason: an async effect only tracks what
454
+ // it reads synchronously, and toggling the expanded state has to rebuild
455
+ // the chart rather than leave it on a stale config (Chart.js reads the
456
+ // plugin options at construction and the plugin only hooks charts created
457
+ // after it was registered).
155
458
  const chartParams = params();
459
+ const zoom = zoomOptions();
156
460
  const version = ++renderVersion;
157
461
 
158
462
  setIsLoading(true);
@@ -171,6 +475,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
171
475
  }
172
476
 
173
477
  // Build options, merging time-axis config if present (v3.1.0)
478
+ const hostZoomOptions = chartParams.options?.plugins?.zoom;
174
479
  const baseOptions: any = {
175
480
  responsive: true,
176
481
  maintainAspectRatio: false,
@@ -182,6 +487,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
182
487
  position: 'bottom',
183
488
  ...chartParams.options?.plugins?.legend,
184
489
  },
490
+ // Present only once the optional peer has loaded AND the current
491
+ // surface wants zoom. Merged rather than assigned, so a consumer's
492
+ // own `plugins.zoom` survives; when zoom is off, the spread above
493
+ // has already left their block exactly as they wrote it.
494
+ // An explicit `plugins.zoom: false` never reaches here: `zoomWanted`
495
+ // already returned false for it, so `zoom` is null and the spread
496
+ // above carries the host's own value through untouched.
497
+ ...(zoom ? { zoom: mergeZoomOptions(zoom, hostZoomOptions) } : {}),
185
498
  },
186
499
  };
187
500
 
@@ -211,6 +524,9 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
211
524
  options: baseOptions,
212
525
  });
213
526
 
527
+ // A freshly built chart always sits on its initial viewport, so the
528
+ // reset control goes away until the user zooms again.
529
+ setIsZoomed(false);
214
530
  setIsLoading(false);
215
531
  } catch (err) {
216
532
  if (version !== renderVersion) return;
@@ -252,6 +568,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
252
568
  copyData={copyDataJSON()}
253
569
  copyLabel={strings.chartCopy}
254
570
  toolbarVariant={props.toolbarVariant}
571
+ onExpandedChange={setIsExpanded}
255
572
  >
256
573
  <ContextAwareChartLayout
257
574
  render={(isExpanded) => (
@@ -297,6 +614,84 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
297
614
  {strings.chartDataView}
298
615
  </button>
299
616
  </div>
617
+ {/* Zoom controls — permanently visible (not hover-revealed like
618
+ the export button) for as long as this chart is zoomable at
619
+ all. Zoom in/out come FIRST and unconditionally: they are the
620
+ only keyboard route into a feature whose gestures are all
621
+ pointer gestures, on a canvas that takes no focus. Reset joins
622
+ them only once the chart is actually off its initial viewport,
623
+ 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()}>
671
+ <button
672
+ 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}
677
+ >
678
+ <svg
679
+ class="w-3 h-3 text-gray-500 dark:text-gray-400"
680
+ fill="none"
681
+ viewBox="0 0 24 24"
682
+ stroke="currentColor"
683
+ >
684
+ <path
685
+ stroke-linecap="round"
686
+ stroke-linejoin="round"
687
+ 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"
689
+ />
690
+ </svg>
691
+ </button>
692
+ </Show>
693
+ </div>
694
+ </Show>
300
695
  <Show when={exportEnabled()}>
301
696
  <button
302
697
  type="button"
@@ -113,7 +113,32 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
113
113
  }
114
114
  })
115
115
 
116
+ /**
117
+ * Did the gesture that produced the current click START on the backdrop?
118
+ *
119
+ * `null` means no pointer press was seen — a synthetic or programmatic
120
+ * click, which stays allowed so keyboard and test-driven closes keep working.
121
+ */
122
+ let pressedOnBackdrop: boolean | null = null
123
+
124
+ const handleBackdropPointerDown = (e: PointerEvent) => {
125
+ pressedOnBackdrop = e.target === e.currentTarget
126
+ }
127
+
128
+ /**
129
+ * Closing on a backdrop click is only safe when the gesture also BEGAN there.
130
+ *
131
+ * When a press and its release have different targets the browser dispatches
132
+ * `click` on their nearest common ancestor — here the backdrop. So a drag
133
+ * that starts on the content and releases over the 16px margin around the
134
+ * panel (panning a zoomed chart, selecting text in a table) would otherwise
135
+ * land on this handler with `e.target === e.currentTarget` and tear the modal
136
+ * down mid-gesture.
137
+ */
116
138
  const handleBackdropClick = (e: MouseEvent) => {
139
+ const startedOnBackdrop = pressedOnBackdrop
140
+ pressedOnBackdrop = null
141
+ if (startedOnBackdrop === false) return
117
142
  if (e.target === e.currentTarget) handleClose()
118
143
  }
119
144
 
@@ -164,6 +189,7 @@ export const ExpandableWrapper: Component<ExpandableWrapperProps> = (props) => {
164
189
  class="fixed inset-0 z-50 flex flex-col bg-black/50 backdrop-blur-sm"
165
190
  style={{ animation: 'expandable-fade-in 0.15s ease-out' }}
166
191
  onClick={handleBackdropClick}
192
+ onPointerDown={handleBackdropPointerDown}
167
193
  role="dialog"
168
194
  data-mcp-ui-portal="dialog"
169
195
  aria-modal="true"
@@ -0,0 +1,57 @@
1
+ /**
2
+ * IframeFallbackLink — the way out of a silently blocked embed.
3
+ *
4
+ * @since v6.21.0
5
+ *
6
+ * An iframe refused by `Cross-Origin-Embedder-Policy` fires no event the
7
+ * parent page can observe (`onerror` does not fire for a rejected
8
+ * cross-origin document), so the user is left with a blank frame and no
9
+ * explanation. This link, rendered under the frame, always works.
10
+ *
11
+ * Visibility follows `MCPUIConfig.iframeFallbackLink`; `'auto'` (the default)
12
+ * shows it only on a cross-origin-isolated page. The URL goes through
13
+ * `safeUrl` — an unsafe one renders nothing at all.
14
+ */
15
+
16
+ import { createMemo, Show, type JSX } from 'solid-js'
17
+ import { safeUrl } from '../utils/safe-url'
18
+ import { useMCPUIStrings } from '../context/MCPUIStringsContext'
19
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
20
+ import {
21
+ createCrossOriginIsolated,
22
+ shouldShowIframeFallbackLink,
23
+ } from '../utils/iframe-coep'
24
+
25
+ export interface IframeFallbackLinkProps {
26
+ /** The embedded URL. Bound to `href` only when `safeUrl` accepts it. */
27
+ url: string
28
+ /** Extra classes on the wrapping `<div>`. */
29
+ class?: string
30
+ }
31
+
32
+ export function IframeFallbackLink(props: IframeFallbackLinkProps): JSX.Element {
33
+ const strings = useMCPUIStrings()
34
+ const config = useMCPUIConfig()
35
+ const crossOriginIsolated = createCrossOriginIsolated()
36
+
37
+ const href = createMemo(() => safeUrl(props.url))
38
+ const visible = createMemo(
39
+ () => !!href() && shouldShowIframeFallbackLink(config, crossOriginIsolated())
40
+ )
41
+
42
+ return (
43
+ <Show when={visible()}>
44
+ <div class={`px-4 py-2 text-right ${props.class || ''}`}>
45
+ <a
46
+ href={href()}
47
+ target="_blank"
48
+ rel="noopener noreferrer"
49
+ data-mcp-ui-action="open-external"
50
+ class="text-xs text-blue-600 dark:text-blue-400 hover:underline focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 rounded"
51
+ >
52
+ {strings.iframeOpenInNewTab}
53
+ </a>
54
+ </div>
55
+ </Show>
56
+ )
57
+ }