@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
@@ -1,13 +1,32 @@
1
1
  /**
2
2
  * LightboxOverlay - Fullscreen image viewer
3
3
  * Sprint 5: Media Components
4
+ *
5
+ * v6.22.0 — zoom & pan. The overlay is a fullscreen `<Portal>`: it OWNS the
6
+ * viewport, nothing scrolls behind it (the effect below locks `body`), so
7
+ * hijacking the wheel here is legitimate in a way it would not be for a
8
+ * component sitting inline in a chat feed. Zoom is therefore always on in the
9
+ * lightbox and needs no host opt-in.
4
10
  */
5
11
 
6
- import { Component, Show, createEffect, onCleanup } from 'solid-js'
12
+ import { Component, Show, createEffect, createMemo, onCleanup } from 'solid-js'
7
13
  import { Portal } from 'solid-js/web'
8
14
  import type { GalleryImage } from '../types'
9
15
  import { safeUrl } from '../utils/safe-url'
10
- import { useMCPUIStrings } from '../context/MCPUIStringsContext'
16
+ import { createPanZoom, type PanZoomPoint } from '../utils/pan-zoom'
17
+ import { formatMCPUIString, useMCPUIStrings } from '../context/MCPUIStringsContext'
18
+
19
+ /** Highest magnification of the lightbox — 8× the fitted size. */
20
+ const MAX_ZOOM = 8
21
+
22
+ /** Ratio one press of a zoom button (or of `+` / `-`) applies. */
23
+ const ZOOM_BUTTON_FACTOR = 1.5
24
+
25
+ /**
26
+ * Scale a double-click jumps to. Deliberately modest: a double-click is a
27
+ * coarse gesture, and the wheel is right there for the rest.
28
+ */
29
+ const DOUBLE_CLICK_ZOOM = 2
11
30
 
12
31
  export interface LightboxOverlayProps {
13
32
  /**
@@ -33,6 +52,17 @@ export interface LightboxOverlayProps {
33
52
 
34
53
  export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
35
54
  const strings = useMCPUIStrings()
55
+ let imageRef: HTMLImageElement | undefined
56
+
57
+ /**
58
+ * The handlers are bound on the BACKDROP, not on the `<img>`: a pan that
59
+ * drags the image out from under the cursor must keep receiving the pointer
60
+ * stream, and a wheel event over the black margin around a portrait image
61
+ * must still zoom. `target` therefore points the maths back at the image,
62
+ * which is the element that actually carries the transform.
63
+ */
64
+ const panZoom = createPanZoom({ min: 1, max: MAX_ZOOM, target: () => imageRef })
65
+
36
66
  const isOpen = () => props.selectedIndex !== null
37
67
  const currentImage = () =>
38
68
  props.selectedIndex !== null ? props.images[props.selectedIndex] : null
@@ -52,21 +82,129 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
52
82
  }
53
83
  }
54
84
 
85
+ /**
86
+ * Identity of the picture on screen, as a VALUE rather than as an object
87
+ * reference — `null` while the overlay is closed.
88
+ *
89
+ * The index alone does not name a picture. A host that replaces `images`
90
+ * under a held `selectedIndex` — a gallery whose list streams in, a refilter,
91
+ * a re-render that rebuilds the array — puts a different photo on screen
92
+ * without the index ever moving. `url` and `srcset` are what the `<img>`
93
+ * below actually resolves, so together with the index they are what "the
94
+ * displayed image changed" has to mean. (`sizes` is left out on purpose: it
95
+ * re-picks among the candidates of the SAME picture, so it changes the
96
+ * resolution fetched, never the subject the zoom is framing.)
97
+ *
98
+ * A memo, and a string, so the comparison is by value: `ImageGalleryRenderer`
99
+ * passes `params()?.images || []`, a fresh array on every parent update, and
100
+ * re-reading that must not by itself count as a new picture — it would throw
101
+ * away a zoom the user is in the middle of. The index stays in the key so
102
+ * that navigating between two entries sharing a URL is still the clean slate
103
+ * the arrows promise.
104
+ */
105
+ const displayedImage = createMemo(() => {
106
+ if (props.selectedIndex === null) return null
107
+ const image = props.images[props.selectedIndex]
108
+ return JSON.stringify([props.selectedIndex, image?.url ?? null, image?.srcset ?? null])
109
+ })
110
+
111
+ /**
112
+ * Every image opens at its fitted size. Without this the transform left over
113
+ * from the previous picture would be applied to the next one — a 6× zoom on
114
+ * a corner of a portrait photo, carried onto a landscape one, shows black.
115
+ * It also settles the "arrows vs. panning" question: navigating away is
116
+ * always a clean slate rather than a frame half-belonging to two images.
117
+ *
118
+ * The dependency is `displayedImage()` and not `selectedIndex`, for the same
119
+ * reason: a swapped-out `images` array reaches the user as exactly that black
120
+ * frame — the old picture's scale and offset applied to a new photo — with no
121
+ * navigation to blame it on.
122
+ *
123
+ * `reset()` reads its state through `untrack`, so this effect subscribes to
124
+ * that one memo and never re-runs on its own write. The scale and offset it
125
+ * writes are absent from the memo for the same reason: panning must not
126
+ * reset the pan.
127
+ *
128
+ * `cancelGesture()` comes first, and matters most on the CLOSE (`null`): the
129
+ * pan handlers live on the backdrop, inside the `<Show>` below, but the
130
+ * controller belongs to this component — which `ImageGalleryRenderer` mounts
131
+ * once and never unmounts. Close the overlay with a button still held
132
+ * (Escape mid-drag) and the backdrop is detached before its `pointerup`,
133
+ * which is then delivered to whatever is underneath and never to these
134
+ * handlers. Nothing else would end that gesture: `reset()` only writes the
135
+ * transform, and `onCleanup` belongs to an owner that is still alive. The
136
+ * overlay would reopen stuck at `cursor: grabbing`, with `isPanning()` true
137
+ * and the arrow keys dead behind their pan guards.
138
+ */
139
+ createEffect(() => {
140
+ displayedImage()
141
+ panZoom.cancelGesture()
142
+ panZoom.reset()
143
+ })
144
+
145
+ /**
146
+ * Is the keystroke going into something the user is typing in?
147
+ *
148
+ * The overlay binds its shortcuts on `document`, so it hears every key in
149
+ * the page — and it does not trap focus, so the host's own composer can keep
150
+ * it while the lightbox is open (`Tab` from the thumbnail that opened the
151
+ * overlay walks on into the host, since the `<Portal>` content is appended
152
+ * after it). The zoom keys are `-`, `+`, `=` and `0`: characters. Calling
153
+ * `preventDefault()` on them without this check silently eats them out of
154
+ * the host's text field — typing a date would zoom the photo instead.
155
+ *
156
+ * `Escape` stays global, as it was before the zoom keys existed: it produces
157
+ * no character, and dismissing the modal is what it is for.
158
+ */
159
+ const isEditableTarget = (target: EventTarget | null): boolean => {
160
+ if (!(target instanceof HTMLElement)) return false
161
+ if (target.isContentEditable) return true
162
+ return ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
163
+ }
164
+
55
165
  // Keyboard navigation
56
166
  createEffect(() => {
57
167
  if (!isOpen()) return
58
168
 
59
169
  const handleKeydown = (e: KeyboardEvent) => {
170
+ // Ctrl/Cmd + `+` / `-` / `0` is the browser's own page zoom; the overlay
171
+ // must not swallow it.
172
+ const isZoomChord = e.ctrlKey || e.metaKey
173
+
174
+ // A field the user is typing in owns its keys — all of them but the one
175
+ // that dismisses a modal.
176
+ if (e.key !== 'Escape' && isEditableTarget(e.target)) return
177
+
60
178
  switch (e.key) {
61
179
  case 'Escape':
62
180
  props.onClose()
63
181
  break
64
182
  case 'ArrowLeft':
183
+ // Mid-drag the arrows would move the picture the user is holding.
184
+ if (panZoom.isPanning()) break
65
185
  handlePrev()
66
186
  break
67
187
  case 'ArrowRight':
188
+ if (panZoom.isPanning()) break
68
189
  handleNext()
69
190
  break
191
+ case '+':
192
+ case '=':
193
+ // `=` is `+` unshifted — the key people actually press.
194
+ if (isZoomChord) break
195
+ e.preventDefault()
196
+ panZoom.zoomBy(ZOOM_BUTTON_FACTOR)
197
+ break
198
+ case '-':
199
+ if (isZoomChord) break
200
+ e.preventDefault()
201
+ panZoom.zoomBy(1 / ZOOM_BUTTON_FACTOR)
202
+ break
203
+ case '0':
204
+ if (isZoomChord) break
205
+ e.preventDefault()
206
+ panZoom.reset()
207
+ break
70
208
  }
71
209
  }
72
210
 
@@ -79,12 +217,74 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
79
217
  })
80
218
  })
81
219
 
220
+ /**
221
+ * Closing on a backdrop click is the behaviour users expect — but a pan
222
+ * releases the pointer over the backdrop as often as not, and the browser
223
+ * then fires a `click` on the common ancestor of the press and the release.
224
+ * `wasDragged()` is exactly that distinction: it stays true from the moment
225
+ * the pointer travelled more than a few pixels until the next `pointerdown`,
226
+ * so the click that closes the overlay is only ever a click that did not move.
227
+ */
228
+ const handleBackdropClick = () => {
229
+ if (panZoom.wasDragged()) return
230
+ props.onClose()
231
+ }
232
+
233
+ /**
234
+ * The chrome (close, prev/next, the zoom toolbar) sits on top of the pan
235
+ * surface. Pressing a button must not open a gesture: while zoomed
236
+ * `onPointerDown` calls `preventDefault()`, which costs the button its focus,
237
+ * and a hand that shakes on the way to "next" would pan the image instead.
238
+ */
239
+ const handlePointerDown = (event: PointerEvent) => {
240
+ const target = event.target
241
+ if (target instanceof Element && target.closest('button')) return
242
+ panZoom.onPointerDown(event)
243
+ }
244
+
245
+ /**
246
+ * Viewport point of a mouse event, in the space `zoomTo` anchors on: client
247
+ * pixels measured from the image's UNTRANSFORMED top-left corner. That corner
248
+ * is not directly observable — `getBoundingClientRect()` already includes the
249
+ * transform — but with `transform-origin: 0 0` the rendered left edge is
250
+ * exactly `corner + offset`, so subtracting the current offset recovers it
251
+ * whatever the flex centring did.
252
+ *
253
+ * `createPanZoom` computes the same point for the wheel but keeps it private;
254
+ * this is the double-click's copy of it.
255
+ */
256
+ const anchorOf = (event: MouseEvent): PanZoomPoint | undefined => {
257
+ if (!imageRef) return undefined
258
+ const rect = imageRef.getBoundingClientRect()
259
+ const offset = panZoom.offset()
260
+ return {
261
+ x: event.clientX - (rect.left - offset.x),
262
+ y: event.clientY - (rect.top - offset.y),
263
+ }
264
+ }
265
+
266
+ /** Double-click toggles: in at the spot that was double-clicked, or back out. */
267
+ const handleDoubleClick = (event: MouseEvent) => {
268
+ event.stopPropagation()
269
+ if (panZoom.isZoomed()) {
270
+ panZoom.reset()
271
+ } else {
272
+ panZoom.zoomTo(DOUBLE_CLICK_ZOOM, anchorOf(event))
273
+ }
274
+ }
275
+
82
276
  return (
83
277
  <Show when={isOpen()}>
84
278
  <Portal>
85
279
  <div
86
280
  class="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
87
- onClick={props.onClose}
281
+ style={panZoom.surfaceStyle()}
282
+ onClick={handleBackdropClick}
283
+ onWheel={panZoom.onWheel}
284
+ onPointerDown={handlePointerDown}
285
+ onPointerMove={panZoom.onPointerMove}
286
+ onPointerUp={panZoom.onPointerUp}
287
+ onPointerCancel={panZoom.onPointerCancel}
88
288
  role="dialog"
89
289
  aria-modal="true"
90
290
  aria-label={strings.lightboxLabel}
@@ -147,14 +347,18 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
147
347
  </button>
148
348
  </Show>
149
349
 
150
- {/* Image */}
350
+ {/* Image — `max-w/max-h` fit it, the transform zooms it from there */}
151
351
  <img
352
+ ref={imageRef}
152
353
  src={safeUrl(currentImage()?.url ?? '', { allowDataImage: true })}
153
354
  alt={currentImage()?.alt || ''}
154
355
  srcset={currentImage()?.srcset}
155
356
  sizes={currentImage()?.sizes}
156
357
  class="max-w-[90vw] max-h-[90vh] object-contain"
358
+ style={panZoom.contentStyle()}
359
+ draggable={false}
157
360
  onClick={(e) => e.stopPropagation()}
361
+ onDblClick={handleDoubleClick}
158
362
  />
159
363
 
160
364
  {/* Caption */}
@@ -168,6 +372,78 @@ export const LightboxOverlay: Component<LightboxOverlayProps> = (props) => {
168
372
  <div class="absolute top-4 left-4 text-white/80 bg-black/40 px-3 py-1 rounded-full text-sm">
169
373
  {(props.selectedIndex ?? 0) + 1} / {props.images.length}
170
374
  </div>
375
+
376
+ {/* Zoom controls — the pointer-only gestures made reachable by
377
+ keyboard and touch. The wrapper swallows the click so the
378
+ backdrop's close handler never sees it. */}
379
+ <div
380
+ class="absolute bottom-4 left-1/2 -translate-x-1/2 flex items-center gap-1 bg-black/50 rounded-full px-2 py-1"
381
+ onClick={(e) => e.stopPropagation()}
382
+ >
383
+ <button
384
+ class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors disabled:opacity-40 disabled:hover:bg-transparent"
385
+ onClick={() => panZoom.zoomBy(1 / ZOOM_BUTTON_FACTOR)}
386
+ disabled={panZoom.scale() <= panZoom.min}
387
+ aria-label={strings.zoomOut}
388
+ title={strings.zoomOut}
389
+ type="button"
390
+ >
391
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
392
+ <path
393
+ stroke-linecap="round"
394
+ stroke-linejoin="round"
395
+ stroke-width="2"
396
+ 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"
397
+ />
398
+ </svg>
399
+ </button>
400
+
401
+ <span
402
+ class="text-white/80 text-xs tabular-nums text-center min-w-[4rem]"
403
+ aria-live="polite"
404
+ >
405
+ {formatMCPUIString(strings.zoomLevel, { percent: panZoom.percent() })}
406
+ </span>
407
+
408
+ <button
409
+ class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors disabled:opacity-40 disabled:hover:bg-transparent"
410
+ onClick={() => panZoom.zoomBy(ZOOM_BUTTON_FACTOR)}
411
+ disabled={panZoom.scale() >= panZoom.max}
412
+ aria-label={strings.zoomIn}
413
+ title={strings.zoomIn}
414
+ type="button"
415
+ >
416
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
417
+ <path
418
+ stroke-linecap="round"
419
+ stroke-linejoin="round"
420
+ stroke-width="2"
421
+ 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"
422
+ />
423
+ </svg>
424
+ </button>
425
+
426
+ {/* Only while there is something to reset — at 100% it would be a
427
+ button that does nothing. */}
428
+ <Show when={panZoom.isZoomed()}>
429
+ <button
430
+ class="text-white/80 hover:text-white p-2 rounded-full hover:bg-white/10 transition-colors"
431
+ onClick={() => panZoom.reset()}
432
+ aria-label={strings.zoomReset}
433
+ title={strings.zoomReset}
434
+ type="button"
435
+ >
436
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
437
+ <path
438
+ stroke-linecap="round"
439
+ stroke-linejoin="round"
440
+ stroke-width="2"
441
+ d="M16.023 9.348h4.992V4.356m0 4.992l-3.181-3.183a8.25 8.25 0 00-13.803 3.7M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7"
442
+ />
443
+ </svg>
444
+ </button>
445
+ </Show>
446
+ </div>
171
447
  </div>
172
448
  </Portal>
173
449
  </Show>
@@ -10,7 +10,7 @@ import { escapeHtml } from '../utils/escape-html'
10
10
  import { safeUrl } from '../utils/safe-url'
11
11
  import { SafeHtml } from './SafeHtml'
12
12
  import type { UIComponent, UILayout, RendererError, TableVirtualizeOptions } from '../types'
13
- import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox } from '../services/validation'
13
+ import { validateComponent, DEFAULT_RESOURCE_LIMITS, getIframeSandbox, isTrustedIframeDomain } from '../services/validation'
14
14
  import { GenerativeUIErrorBoundary } from './GenerativeUIErrorBoundary'
15
15
  import { markRenderStart, markRenderEnd, PERF_PREFIX } from '../utils/perf'
16
16
  import { isDebugEnabled } from '../utils/logger'
@@ -27,6 +27,9 @@ import {
27
27
  formatMCPUIString,
28
28
  useMCPUIStrings,
29
29
  } from '../context/MCPUIStringsContext'
30
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
31
+ import { shouldSetCredentialless } from '../utils/iframe-coep'
32
+ import { IframeFallbackLink } from './IframeFallbackLink'
30
33
 
31
34
  /**
32
35
  * How `<UIResourceRenderer>` reacts when `validateComponent()` rejects a
@@ -1458,7 +1461,12 @@ function TextRenderer(props: { component: UIComponent }) {
1458
1461
  */
1459
1462
  function IframeRenderer(props: { component: UIComponent }) {
1460
1463
  const strings = useMCPUIStrings()
1464
+ const config = useMCPUIConfig()
1461
1465
  const params = props.component.params as any
1466
+ // v6.21.0 — the host's custom trusted hosts now reach BOTH the sandbox (they
1467
+ // used not to: `getIframeSandbox(params.url)` was called with no options) and
1468
+ // the `credentialless` decision.
1469
+ const trustOptions = () => ({ customTrustedDomains: config.customTrustedIframeDomains })
1462
1470
  return (
1463
1471
  <div class="w-full h-full bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden flex flex-col">
1464
1472
  <Show when={params.title}>
@@ -1471,9 +1479,22 @@ function IframeRenderer(props: { component: UIComponent }) {
1471
1479
  title={params.title || strings.iframeTitle}
1472
1480
  class="w-full border-0 flex-1"
1473
1481
  style={`height: ${params.height || '400px'}; min-height: 300px;`}
1474
- sandbox={getIframeSandbox(params.url)}
1482
+ sandbox={getIframeSandbox(params.url, trustOptions())}
1483
+ // `attr:` is required: `credentialless` is in dom-expressions'
1484
+ // `Properties` set, so a plain `credentialless={…}` compiles to a JS
1485
+ // property assignment and never reaches the DOM as an attribute.
1486
+ // `true | undefined` keeps it a BOOLEAN attribute — bare in SSR
1487
+ // markup, removed (not `="false"`) when it does not apply.
1488
+ attr:credentialless={
1489
+ shouldSetCredentialless(params.url, config, {
1490
+ // This iframe's sandbox never carries `allow-same-origin` for an
1491
+ // untrusted host, so it already runs without cookies.
1492
+ sandboxedWithoutSameOrigin: !isTrustedIframeDomain(params.url, trustOptions()),
1493
+ }) || undefined
1494
+ }
1475
1495
  loading="lazy"
1476
1496
  />
1497
+ <IframeFallbackLink url={params.url} class="border-t border-gray-200 dark:border-gray-700" />
1477
1498
  </div>
1478
1499
  )
1479
1500
  }
@@ -1595,6 +1616,7 @@ function ComponentRenderer(props: {
1595
1616
  // opt in see zero behavior change.
1596
1617
  const telemetry = useTelemetry()
1597
1618
  const strings = useMCPUIStrings()
1619
+ const componentConfig = useMCPUIConfig()
1598
1620
 
1599
1621
  onMount(() => {
1600
1622
  markRenderEnd(props.component.id)
@@ -1636,8 +1658,13 @@ function ComponentRenderer(props: {
1636
1658
  }
1637
1659
  })
1638
1660
 
1639
- // Validate component before rendering
1640
- const validation = validateComponent(props.component)
1661
+ // Validate component before rendering. The host's allow-list extension
1662
+ // (v6.21.0) has to reach validation too: an unlisted host is replaced by the
1663
+ // validation card before any renderer runs.
1664
+ const validation = validateComponent(props.component, {
1665
+ iframePolicy: componentConfig.iframePolicy,
1666
+ customIframeDomains: componentConfig.customIframeDomains,
1667
+ })
1641
1668
  if (!validation.valid) {
1642
1669
  props.onError?.({
1643
1670
  type: 'validation',
@@ -9,6 +9,9 @@ import { Component, createMemo, Show } from 'solid-js'
9
9
  import type { UIComponent, VideoComponentParams } from '../types'
10
10
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
11
11
  import { useMCPUIStrings } from '../context/MCPUIStringsContext'
12
+ import { useMCPUIConfig } from '../context/MCPUIConfigContext'
13
+ import { shouldSetCredentialless } from '../utils/iframe-coep'
14
+ import { IframeFallbackLink } from './IframeFallbackLink'
12
15
 
13
16
  export interface VideoRendererProps {
14
17
  /**
@@ -76,6 +79,7 @@ function parseVideoUrl(url: string): VideoInfo {
76
79
 
77
80
  export const VideoRenderer: Component<VideoRendererProps> = (props) => {
78
81
  const strings = useMCPUIStrings()
82
+ const config = useMCPUIConfig()
79
83
  const params = () => props.params || (props.component?.params as VideoComponentParams)
80
84
  const isExpanded = useExpanded()
81
85
 
@@ -181,11 +185,27 @@ export const VideoRenderer: Component<VideoRendererProps> = (props) => {
181
185
  class="absolute inset-0 w-full h-full"
182
186
  allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
183
187
  allowfullscreen
188
+ // v6.21.0 — youtube-nocookie / player.vimeo are not trusted hosts,
189
+ // so under COEP `credentialless` is what lets them load at all.
190
+ // `attr:` + `true | undefined` — see the note in `IframeRenderer`.
191
+ attr:credentialless={
192
+ // No sandbox here: a YouTube/Vimeo embed keeps its provider
193
+ // cookies, so `'auto'` leaves it alone and a COEP host opts in
194
+ // with `iframeCredentialless: 'always'`.
195
+ shouldSetCredentialless(embedUrl()!, config, {
196
+ sandboxedWithoutSameOrigin: false,
197
+ }) || undefined
198
+ }
184
199
  loading="lazy"
185
200
  />
186
201
  </Show>
187
202
  </div>
188
203
 
204
+ {/* v6.21.0 — a COEP-blocked embed is silent; this link is the way out. */}
205
+ <Show when={embedUrl()}>
206
+ <IframeFallbackLink url={params()?.url || ''} />
207
+ </Show>
208
+
189
209
  {/* Caption */}
190
210
  <Show when={params()?.caption}>
191
211
  <div class="px-4 py-3 border-t border-gray-200 dark:border-gray-700 flex-shrink-0">
@@ -0,0 +1,202 @@
1
+ /**
2
+ * MCPUIConfigContext — host-level rendering policy: the library's iframes
3
+ * (v6.21.0) and, since v6.22.0, where a chart may capture the wheel.
4
+ *
5
+ * @since v6.21.0
6
+ *
7
+ * ## Why
8
+ *
9
+ * A host that serves `Cross-Origin-Embedder-Policy: credentialless` makes the
10
+ * browser refuse every cross-origin iframe whose own document does not send a
11
+ * COEP header — which is YouTube, Vimeo, Google Docs, Notion and nearly all of
12
+ * `DEFAULT_IFRAME_DOMAINS`. The escape hatch is the boolean HTML attribute
13
+ * `credentialless` on the `<iframe>` itself: the document then loads, but
14
+ * without access to the cookies and storage of its own origin.
15
+ *
16
+ * That trade-off is free for a public video or chart and fatal for an
17
+ * authenticated embed, so the library keys it on the distinction it already
18
+ * makes: `TRUSTED_IFRAME_DOMAINS` (the hosts that receive `allow-same-origin`
19
+ * because they need their own cookies) do NOT get the attribute; everything
20
+ * else does. This context lets a host override that, add its own trusted
21
+ * hosts, and control the "open in a new tab" fallback link.
22
+ *
23
+ * ## Scope — policy only, NOT strings
24
+ *
25
+ * Text lives in `MCPUIStrings` (`MCPUIStringsContext`). This context carries
26
+ * only behavioural switches, and like the strings context every renderer works
27
+ * standalone: with no provider mounted, `DEFAULT_MCPUI_CONFIG` applies.
28
+ *
29
+ * @example
30
+ * ```tsx
31
+ * import { MCPUIConfigProvider } from '@seed-ship/mcp-ui-solid'
32
+ *
33
+ * <MCPUIConfigProvider config={{ customTrustedIframeDomains: ['embed.acme.com'] }}>
34
+ * <App />
35
+ * </MCPUIConfigProvider>
36
+ * ```
37
+ */
38
+
39
+ import { createContext, mergeProps, useContext, type JSX } from 'solid-js'
40
+ import type { IframePolicy } from '../types'
41
+
42
+ /**
43
+ * Host-level rendering policy. Every field has a default — a provider passes
44
+ * a partial override.
45
+ *
46
+ * ## Why every field is optional
47
+ *
48
+ * This interface grows a key every time the library gains a policy switch,
49
+ * and those releases are MINOR. Were the fields required, adding one would
50
+ * break the build of any consumer holding a COMPLETE `MCPUIConfig` value —
51
+ * a typed constant, a helper that returns one, or a direct
52
+ * `<MCPUIConfigContext.Provider value={…}>` — because their object would
53
+ * suddenly miss a key. Nothing about their behaviour changed; only `tsc`
54
+ * would fail. Optional fields make each new key additive.
55
+ *
56
+ * The completeness guarantee moves from the type to the two values that
57
+ * actually need it, exactly as `MCPUIStrings` does it:
58
+ * `DEFAULT_MCPUI_CONFIG` is `Required<MCPUIConfig>`, so forgetting a default
59
+ * is a compile error HERE, and {@link useMCPUIConfig} returns
60
+ * `Required<MCPUIConfig>`, so every renderer still reads a fully resolved
61
+ * value and never has to null-check a policy.
62
+ */
63
+ export interface MCPUIConfig {
64
+ /**
65
+ * When the library puts the boolean `credentialless` attribute on an
66
+ * `<iframe>` it renders.
67
+ *
68
+ * - `'auto'` (default) — on every iframe whose host is NOT in
69
+ * `TRUSTED_IFRAME_DOMAINS` (plus `customTrustedIframeDomains`). Those
70
+ * hosts already ran without cookies, since their sandbox has no
71
+ * `allow-same-origin`; the attribute is what unblocks them under COEP.
72
+ * - `'always'` — on every iframe, trusted hosts included. An authenticated
73
+ * embed will then render its login screen.
74
+ * - `'never'` — never. The right value for a host that sends no COEP header
75
+ * and does not want its embeds to lose their cookies.
76
+ *
77
+ * Chrome/Edge 110+ honour the attribute; Firefox ignores it (iframes stay
78
+ * blocked while the host sends COEP); Safari ignores the COEP value itself,
79
+ * so nothing is blocked there in the first place.
80
+ */
81
+ iframeCredentialless?: 'auto' | 'always' | 'never'
82
+
83
+ /**
84
+ * Extra hosts treated as trusted, on top of `TRUSTED_IFRAME_DOMAINS`:
85
+ * no `credentialless` attribute, and `allow-same-origin` in the sandbox.
86
+ * A subdomain of a listed host matches too.
87
+ *
88
+ * This only RECLASSIFIES a host the allow-list already accepts. To render an
89
+ * iframe whose host is outside `DEFAULT_IFRAME_DOMAINS`, add it to
90
+ * {@link MCPUIConfig.customIframeDomains} with `iframePolicy: 'extend'`.
91
+ */
92
+ customTrustedIframeDomains?: string[]
93
+
94
+ /**
95
+ * How `UIResourceRenderer` validates an `iframe` component's host:
96
+ * `'strict'` (default) accepts `DEFAULT_IFRAME_DOMAINS` only, `'extend'`
97
+ * also accepts {@link MCPUIConfig.customIframeDomains}.
98
+ */
99
+ iframePolicy?: IframePolicy
100
+
101
+ /**
102
+ * Extra hosts the allow-list accepts when `iframePolicy` is `'extend'`.
103
+ * Without this, a component pointing at an unlisted host is replaced by the
104
+ * validation card before any renderer runs.
105
+ */
106
+ customIframeDomains?: string[]
107
+
108
+ /**
109
+ * When the library renders an "open in a new tab" link under an embed
110
+ * (`strings.iframeOpenInNewTab`).
111
+ *
112
+ * A COEP-blocked iframe fires no event the parent page can observe, so
113
+ * without this the user sees an unexplained blank frame.
114
+ *
115
+ * - `'auto'` (default) — only when the page is cross-origin isolated
116
+ * (`window.crossOriginIsolated`). That flag needs COOP `same-origin` AND
117
+ * COEP, so a host that sends COEP alone still blocks embeds while the
118
+ * flag is false: set `'always'` there. Read after mount, never during
119
+ * render, so SSR markup and hydration agree.
120
+ * - `'always'` / `'never'` — unconditional.
121
+ */
122
+ iframeFallbackLink?: 'auto' | 'always' | 'never'
123
+
124
+ /**
125
+ * Where `ChartJSRenderer` enables wheel-zoom / drag-pan / pinch on a chart
126
+ * (through the optional `chartjs-plugin-zoom` peer dependency — absent, the
127
+ * chart renders exactly as it does without this setting).
128
+ *
129
+ * Hijacking the mouse wheel on a component sitting inline in a scrolling
130
+ * chat feed is hostile: the user scrolls the conversation and the chart eats
131
+ * the gesture. So zoom belongs to a surface that OWNS its viewport, which
132
+ * here is `ExpandableWrapper`'s fullscreen modal.
133
+ *
134
+ * - `'expanded'` (default) — only inside the fullscreen modal.
135
+ * - `'always'` — inline as well. For a host that lays charts out in a
136
+ * dashboard rather than in a feed, where the wheel has nothing to steal.
137
+ * - `'never'` — off everywhere; the plugin is never registered.
138
+ *
139
+ * @since 6.22.0
140
+ */
141
+ chartZoom?: 'expanded' | 'always' | 'never'
142
+ }
143
+
144
+ /**
145
+ * Defaults. Chosen so a host that sends no COEP header sees no change from
146
+ * 6.20.0 apart from the fallback link, which `'auto'` keeps hidden there —
147
+ * and, since 6.22.0, so no inline component ever captures the wheel
148
+ * (`chartZoom: 'expanded'`).
149
+ *
150
+ * Typed `Required<MCPUIConfig>`: the interface's fields are optional so that
151
+ * adding one stays backward compatible, and this annotation is what keeps a
152
+ * new key from shipping without a default.
153
+ */
154
+ export const DEFAULT_MCPUI_CONFIG: Required<MCPUIConfig> = {
155
+ iframeCredentialless: 'auto',
156
+ customTrustedIframeDomains: [],
157
+ iframePolicy: 'strict',
158
+ customIframeDomains: [],
159
+ iframeFallbackLink: 'auto',
160
+ chartZoom: 'expanded',
161
+ }
162
+
163
+ /**
164
+ * Deliberately typed with the PARTIAL `MCPUIConfig`, not `Required<…>`: a
165
+ * host that bypasses `MCPUIConfigProvider` and feeds this provider directly
166
+ * may pass whichever keys it cares about, and `useMCPUIConfig` fills the
167
+ * rest. Widening it this way only ever accepts more than before.
168
+ */
169
+ export const MCPUIConfigContext = createContext<MCPUIConfig>(DEFAULT_MCPUI_CONFIG)
170
+
171
+ /**
172
+ * Reads the active host config, resolved: every key is present, whether it
173
+ * came from the provider or from `DEFAULT_MCPUI_CONFIG`. Returns the defaults
174
+ * outright when no `<MCPUIConfigProvider>` is mounted above.
175
+ */
176
+ export function useMCPUIConfig(): Required<MCPUIConfig> {
177
+ return mergeProps(DEFAULT_MCPUI_CONFIG, useContext(MCPUIConfigContext))
178
+ }
179
+
180
+ export interface MCPUIConfigProviderProps {
181
+ /**
182
+ * Partial override of the host config. Any key left unset falls back to
183
+ * `DEFAULT_MCPUI_CONFIG`.
184
+ */
185
+ config?: Partial<MCPUIConfig>
186
+ children: JSX.Element
187
+ }
188
+
189
+ /**
190
+ * Provides the host config to every MCP-UI renderer below it.
191
+ * Merges the partial `config` override over the defaults.
192
+ */
193
+ export function MCPUIConfigProvider(props: MCPUIConfigProviderProps): JSX.Element {
194
+ // The function source keeps replacement `config` objects reactive while
195
+ // mergeProps fills any omitted key without taking a one-time snapshot.
196
+ const value = mergeProps(DEFAULT_MCPUI_CONFIG, () => props.config)
197
+ return (
198
+ <MCPUIConfigContext.Provider value={value}>
199
+ {props.children}
200
+ </MCPUIConfigContext.Provider>
201
+ )
202
+ }