@seed-ship/mcp-ui-solid 6.17.0 → 6.18.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 (156) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +50 -3
  3. package/dist/adapters/index.d.ts +2 -0
  4. package/dist/adapters/index.d.ts.map +1 -1
  5. package/dist/adapters/presentation.cjs +141 -0
  6. package/dist/adapters/presentation.cjs.map +1 -0
  7. package/dist/adapters/presentation.d.ts +68 -0
  8. package/dist/adapters/presentation.d.ts.map +1 -0
  9. package/dist/adapters/presentation.js +141 -0
  10. package/dist/adapters/presentation.js.map +1 -0
  11. package/dist/adapters.cjs +4 -0
  12. package/dist/adapters.cjs.map +1 -1
  13. package/dist/adapters.d.cts +2 -0
  14. package/dist/adapters.d.ts +2 -0
  15. package/dist/adapters.js +4 -0
  16. package/dist/adapters.js.map +1 -1
  17. package/dist/components/ChartJSRenderer.cjs +165 -65
  18. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  19. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  20. package/dist/components/ChartJSRenderer.js +168 -68
  21. package/dist/components/ChartJSRenderer.js.map +1 -1
  22. package/dist/components/ComponentToolbar.cjs.map +1 -1
  23. package/dist/components/ComponentToolbar.d.ts.map +1 -1
  24. package/dist/components/ComponentToolbar.js.map +1 -1
  25. package/dist/components/EditableLayoutContext.cjs +17 -0
  26. package/dist/components/EditableLayoutContext.cjs.map +1 -0
  27. package/dist/components/EditableLayoutContext.d.ts +5 -0
  28. package/dist/components/EditableLayoutContext.d.ts.map +1 -0
  29. package/dist/components/EditableLayoutContext.js +17 -0
  30. package/dist/components/EditableLayoutContext.js.map +1 -0
  31. package/dist/components/EditableUIResourceRenderer.cjs +87 -84
  32. package/dist/components/EditableUIResourceRenderer.cjs.map +1 -1
  33. package/dist/components/EditableUIResourceRenderer.d.ts.map +1 -1
  34. package/dist/components/EditableUIResourceRenderer.js +87 -84
  35. package/dist/components/EditableUIResourceRenderer.js.map +1 -1
  36. package/dist/components/ElicitationForm.cjs.map +1 -1
  37. package/dist/components/ElicitationForm.js.map +1 -1
  38. package/dist/components/FeedbackInline.cjs +0 -1
  39. package/dist/components/FeedbackInline.cjs.map +1 -1
  40. package/dist/components/FeedbackInline.js +0 -1
  41. package/dist/components/FeedbackInline.js.map +1 -1
  42. package/dist/components/GraphRenderer.cjs.map +1 -1
  43. package/dist/components/GraphRenderer.d.ts.map +1 -1
  44. package/dist/components/GraphRenderer.js.map +1 -1
  45. package/dist/components/GridRenderer.cjs +12 -6
  46. package/dist/components/GridRenderer.cjs.map +1 -1
  47. package/dist/components/GridRenderer.d.ts.map +1 -1
  48. package/dist/components/GridRenderer.js +13 -7
  49. package/dist/components/GridRenderer.js.map +1 -1
  50. package/dist/components/MapRenderer.cjs.map +1 -1
  51. package/dist/components/MapRenderer.d.ts.map +1 -1
  52. package/dist/components/MapRenderer.js.map +1 -1
  53. package/dist/components/PresentationFeedback.cjs +0 -1
  54. package/dist/components/PresentationFeedback.cjs.map +1 -1
  55. package/dist/components/PresentationFeedback.js +0 -1
  56. package/dist/components/PresentationFeedback.js.map +1 -1
  57. package/dist/components/UIResourceRenderer.cjs +37 -22
  58. package/dist/components/UIResourceRenderer.cjs.map +1 -1
  59. package/dist/components/UIResourceRenderer.d.ts.map +1 -1
  60. package/dist/components/UIResourceRenderer.js +38 -23
  61. package/dist/components/UIResourceRenderer.js.map +1 -1
  62. package/dist/components/VerifiedText.cjs +0 -4
  63. package/dist/components/VerifiedText.cjs.map +1 -1
  64. package/dist/components/VerifiedText.d.ts.map +1 -1
  65. package/dist/components/VerifiedText.js +0 -4
  66. package/dist/components/VerifiedText.js.map +1 -1
  67. package/dist/components/chart-data-table.cjs +73 -0
  68. package/dist/components/chart-data-table.cjs.map +1 -0
  69. package/dist/components/chart-data-table.d.ts +23 -0
  70. package/dist/components/chart-data-table.d.ts.map +1 -0
  71. package/dist/components/chart-data-table.js +73 -0
  72. package/dist/components/chart-data-table.js.map +1 -0
  73. package/dist/context/MCPUIStringsContext.cjs +10 -9
  74. package/dist/context/MCPUIStringsContext.cjs.map +1 -1
  75. package/dist/context/MCPUIStringsContext.d.ts +17 -5
  76. package/dist/context/MCPUIStringsContext.d.ts.map +1 -1
  77. package/dist/context/MCPUIStringsContext.js +11 -10
  78. package/dist/context/MCPUIStringsContext.js.map +1 -1
  79. package/dist/hooks/createResponsiveGrid.cjs +37 -0
  80. package/dist/hooks/createResponsiveGrid.cjs.map +1 -0
  81. package/dist/hooks/createResponsiveGrid.d.ts +6 -0
  82. package/dist/hooks/createResponsiveGrid.d.ts.map +1 -0
  83. package/dist/hooks/createResponsiveGrid.js +37 -0
  84. package/dist/hooks/createResponsiveGrid.js.map +1 -0
  85. package/dist/index.cjs +1 -0
  86. package/dist/index.cjs.map +1 -1
  87. package/dist/index.js +1 -0
  88. package/dist/index.js.map +1 -1
  89. package/dist/mcp-ui-spec/dist/schemas.cjs +13 -1
  90. package/dist/mcp-ui-spec/dist/schemas.cjs.map +1 -1
  91. package/dist/mcp-ui-spec/dist/schemas.js +13 -1
  92. package/dist/mcp-ui-spec/dist/schemas.js.map +1 -1
  93. package/dist/services/chat-bus.cjs.map +1 -1
  94. package/dist/services/chat-bus.d.ts.map +1 -1
  95. package/dist/services/chat-bus.js.map +1 -1
  96. package/dist/services/chat-prompt-controller.cjs.map +1 -1
  97. package/dist/services/chat-prompt-controller.d.ts.map +1 -1
  98. package/dist/services/chat-prompt-controller.js.map +1 -1
  99. package/dist/services/component-registry.cjs +162 -22
  100. package/dist/services/component-registry.cjs.map +1 -1
  101. package/dist/services/component-registry.d.ts +6 -3
  102. package/dist/services/component-registry.d.ts.map +1 -1
  103. package/dist/services/component-registry.js +162 -22
  104. package/dist/services/component-registry.js.map +1 -1
  105. package/dist/services/telemetry.cjs +0 -1
  106. package/dist/services/telemetry.cjs.map +1 -1
  107. package/dist/services/telemetry.js +0 -1
  108. package/dist/services/telemetry.js.map +1 -1
  109. package/dist/services/validation.cjs +21 -7
  110. package/dist/services/validation.cjs.map +1 -1
  111. package/dist/services/validation.d.ts.map +1 -1
  112. package/dist/services/validation.js +21 -7
  113. package/dist/services/validation.js.map +1 -1
  114. package/dist/types/index.d.ts +43 -8
  115. package/dist/types/index.d.ts.map +1 -1
  116. package/dist/types.d.cts +43 -8
  117. package/dist/types.d.ts +43 -8
  118. package/docs/briefs/VISUALIZATION-FOUNDATION-2026-09-14.md +105 -0
  119. package/package.json +4 -4
  120. package/src/adapters/index.ts +18 -0
  121. package/src/adapters/presentation.test.ts +114 -0
  122. package/src/adapters/presentation.ts +229 -0
  123. package/src/components/ChartJSRenderer.test.tsx +325 -0
  124. package/src/components/ChartJSRenderer.tsx +162 -27
  125. package/src/components/ChatPrompt.test.tsx +1 -1
  126. package/src/components/ComponentToolbar.tsx +1 -1
  127. package/src/components/EditableLayoutContext.tsx +12 -0
  128. package/src/components/EditableUIResourceRenderer.tsx +11 -8
  129. package/src/components/ElicitationForm.tsx +1 -1
  130. package/src/components/FeedbackInline.tsx +1 -1
  131. package/src/components/GraphRenderer.test.tsx +1 -2
  132. package/src/components/GraphRenderer.tsx +0 -2
  133. package/src/components/GridRenderer.tsx +6 -4
  134. package/src/components/MapRenderer.tsx +0 -3
  135. package/src/components/PresentationFeedback.tsx +1 -1
  136. package/src/components/ResponsiveGrid.test.tsx +166 -0
  137. package/src/components/TableRenderer.pagination.test.tsx +31 -0
  138. package/src/components/UIResourceRenderer.tsx +14 -9
  139. package/src/components/VerifiedText.tsx +0 -5
  140. package/src/components/chart-data-table.ts +97 -0
  141. package/src/context/MCPUIStringsContext.test.tsx +60 -0
  142. package/src/context/MCPUIStringsContext.tsx +31 -12
  143. package/src/hooks/createResponsiveGrid.ts +41 -0
  144. package/src/services/chat-bus.test.ts +1 -1
  145. package/src/services/chat-bus.ts +0 -2
  146. package/src/services/chat-prompt-controller.ts +0 -8
  147. package/src/services/component-registry.test.ts +81 -2
  148. package/src/services/component-registry.ts +167 -23
  149. package/src/services/telemetry.ts +1 -1
  150. package/src/services/validation.test.ts +1 -14
  151. package/src/services/validation.ts +23 -6
  152. package/src/services/visualization-validation.test.ts +42 -0
  153. package/src/types/index.ts +50 -9
  154. package/src/types/visualization-contracts.test.ts +66 -0
  155. package/src/utils/perf.test.ts +3 -3
  156. package/tsconfig.tsbuildinfo +1 -1
@@ -8,12 +8,23 @@
8
8
  * ```
9
9
  */
10
10
 
11
- import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js';
11
+ import {
12
+ Component,
13
+ For,
14
+ createEffect,
15
+ createMemo,
16
+ createUniqueId,
17
+ onCleanup,
18
+ createSignal,
19
+ Show,
20
+ } from 'solid-js';
21
+ import type { Accessor, JSX } from 'solid-js';
12
22
  import type { UIComponent, ChartComponentParams } from '../types';
13
23
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
14
24
  import { DegradedFallback } from './DegradedFallback';
15
- import { chartToDegradedTable } from '../utils/degraded-projections';
25
+ import { chartToDataTable } from './chart-data-table';
16
26
  import { useTelemetry } from '../context/MCPUITelemetryContext';
27
+ import { useMCPUIStrings } from '../context/MCPUIStringsContext';
17
28
 
18
29
  // Lazy load Chart.js to avoid bundling if not used
19
30
  let ChartJS: any = null;
@@ -67,6 +78,10 @@ export interface ChartJSRendererProps {
67
78
  toolbarVariant?: 'hover' | 'always-visible';
68
79
  }
69
80
 
81
+ const ContextAwareChartLayout: Component<{
82
+ render: (isExpanded: Accessor<boolean>) => JSX.Element;
83
+ }> = (props) => props.render(useExpanded());
84
+
70
85
  /**
71
86
  * Native Chart.js renderer component
72
87
  *
@@ -92,12 +107,17 @@ export interface ChartJSRendererProps {
92
107
  export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
93
108
  const [isLoading, setIsLoading] = createSignal(true);
94
109
  const [error, setError] = createSignal<string>();
110
+ const [activeView, setActiveView] = createSignal<'chart' | 'data'>('chart');
95
111
  let canvasRef: HTMLCanvasElement | undefined;
96
112
  let chartInstance: any;
113
+ let renderVersion = 0;
97
114
 
98
115
  const params = () => props.component.params as ChartComponentParams;
99
- const isExpanded = useExpanded();
116
+ const tableData = createMemo(() => chartToDataTable(params()));
100
117
  const telemetry = useTelemetry();
118
+ const strings = useMCPUIStrings();
119
+ const descriptionId = createUniqueId();
120
+ const title = () => params().title || strings.chartView;
101
121
 
102
122
  // v6.1.0 — export visibility :
103
123
  // - undefined / true → button shown (new default, was opt-in)
@@ -124,6 +144,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
124
144
 
125
145
  // Access params to track dependencies
126
146
  const chartParams = params();
147
+ const version = ++renderVersion;
127
148
 
128
149
  setIsLoading(true);
129
150
  setError(undefined);
@@ -131,6 +152,9 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
131
152
  try {
132
153
  const Chart = await loadChartJS();
133
154
 
155
+ // A newer prop update or an unmount superseded this async render.
156
+ if (version !== renderVersion || !canvasRef) return;
157
+
134
158
  // Destroy previous instance
135
159
  if (chartInstance) {
136
160
  chartInstance.destroy();
@@ -180,6 +204,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
180
204
 
181
205
  setIsLoading(false);
182
206
  } catch (err) {
207
+ if (version !== renderVersion) return;
183
208
  const error = err instanceof Error ? err : new Error('Chart rendering failed');
184
209
  setError(error.message);
185
210
  setIsLoading(false);
@@ -196,8 +221,16 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
196
221
  }
197
222
  });
198
223
 
224
+ // Chart.js may measure a zero-sized canvas while the data view is visible.
225
+ // Resize once Solid has revealed it again.
226
+ createEffect(() => {
227
+ if (activeView() !== 'chart' || !chartInstance) return;
228
+ queueMicrotask(() => chartInstance?.resize?.());
229
+ });
230
+
199
231
  // Cleanup on unmount
200
232
  onCleanup(() => {
233
+ renderVersion += 1;
201
234
  if (chartInstance) {
202
235
  chartInstance.destroy();
203
236
  chartInstance = null;
@@ -206,25 +239,61 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
206
239
 
207
240
  return (
208
241
  <ExpandableWrapper
209
- title={params().title || 'Chart'}
242
+ title={title()}
210
243
  copyData={copyDataJSON()}
211
244
  copyLabel="Copy chart data (JSON)"
212
245
  toolbarVariant={props.toolbarVariant}
213
246
  >
214
- <div
215
- 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 ${
216
- isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
217
- }`}
218
- >
219
- <Show when={params().title || exportEnabled()}>
220
- <div class="flex items-center justify-between mb-3 flex-shrink-0">
247
+ <ContextAwareChartLayout
248
+ render={(isExpanded) => (
249
+ <div
250
+ 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 ${
251
+ isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
252
+ } ${params().className || ''}`}
253
+ >
254
+ <div class="flex items-center justify-between gap-3 mb-3 flex-shrink-0">
255
+ <div class="min-w-0">
221
256
  <Show when={params().title}>
222
257
  <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{params().title}</h3>
223
258
  </Show>
259
+ </div>
260
+ <div class="flex items-center gap-2">
261
+ <div
262
+ class="inline-flex rounded-md border border-gray-200 p-0.5 dark:border-gray-600"
263
+ role="group"
264
+ aria-label={strings.chartViewSelector}
265
+ >
266
+ <button
267
+ type="button"
268
+ aria-pressed={activeView() === 'chart'}
269
+ class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
270
+ activeView() === 'chart'
271
+ ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
272
+ : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
273
+ }`}
274
+ onClick={() => setActiveView('chart')}
275
+ >
276
+ {strings.chartView}
277
+ </button>
278
+ <button
279
+ type="button"
280
+ aria-pressed={activeView() === 'data'}
281
+ class={`rounded px-2 py-1 text-xs font-medium transition-colors ${
282
+ activeView() === 'data'
283
+ ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900'
284
+ : 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
285
+ }`}
286
+ onClick={() => setActiveView('data')}
287
+ >
288
+ {strings.chartDataView}
289
+ </button>
290
+ </div>
224
291
  <Show when={exportEnabled()}>
225
292
  <button
293
+ type="button"
226
294
  onClick={handleExportPNG}
227
- class="opacity-0 group-hover: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"
295
+ disabled={activeView() !== 'chart' || isLoading() || Boolean(error())}
296
+ 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"
228
297
  title="Download PNG"
229
298
  aria-label="Download chart as PNG"
230
299
  >
@@ -244,40 +313,106 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
244
313
  </button>
245
314
  </Show>
246
315
  </div>
247
- </Show>
316
+ </div>
248
317
 
249
- <Show when={isLoading()}>
250
- <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
251
- <div class="flex flex-col items-center gap-2">
252
- <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
253
- <span class="text-sm text-gray-500 dark:text-gray-400">Loading chart...</span>
254
- </div>
255
- </div>
256
- </Show>
318
+ <p id={descriptionId} class="sr-only">
319
+ {title()}. {strings.chartDataSummary}
320
+ </p>
257
321
 
258
322
  {/* Fallback ladder (P2.5): degrade to a series table on render error
259
323
  instead of a bare "Chart Error" message. */}
260
- <Show when={error()}>
324
+ <Show when={error() && activeView() === 'chart'}>
261
325
  <DegradedFallback
262
326
  message={`Chart rendering failed: ${error()}`}
263
327
  caption="Showing the chart data as a table — the interactive chart is unavailable."
264
- {...chartToDegradedTable(params() ?? {})}
328
+ {...tableData()}
265
329
  />
266
330
  </Show>
267
331
 
332
+ <Show when={activeView() === 'data'}>
333
+ <div
334
+ class={`w-full overflow-auto rounded border border-gray-200 dark:border-gray-700 ${
335
+ isExpanded() ? 'flex-1 min-h-0' : ''
336
+ }`}
337
+ style={
338
+ isExpanded()
339
+ ? undefined
340
+ : { height: params().height || '250px', 'max-height': '70vh' }
341
+ }
342
+ >
343
+ <table class="w-full border-collapse text-left text-sm">
344
+ <caption class="sr-only">
345
+ {title()} — {strings.chartDataTable}
346
+ </caption>
347
+ <thead class="bg-gray-50 dark:bg-gray-900/40">
348
+ <tr>
349
+ <For each={tableData().columns}>
350
+ {(column) => (
351
+ <th scope="col" class="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">
352
+ {column}
353
+ </th>
354
+ )}
355
+ </For>
356
+ </tr>
357
+ </thead>
358
+ <tbody>
359
+ <For each={tableData().rows}>
360
+ {(row) => (
361
+ <tr class="border-t border-gray-100 dark:border-gray-700">
362
+ <For each={tableData().columns}>
363
+ {(_column, index) => (
364
+ <td class="px-3 py-2 text-gray-700 dark:text-gray-300">
365
+ {String(row[index()] ?? '')}
366
+ </td>
367
+ )}
368
+ </For>
369
+ </tr>
370
+ )}
371
+ </For>
372
+ <Show when={tableData().rows.length === 0}>
373
+ <tr>
374
+ <td
375
+ colSpan={Math.max(1, tableData().columns.length)}
376
+ class="px-3 py-6 text-center text-gray-500 dark:text-gray-400"
377
+ >
378
+ {strings.chartNoData}
379
+ </td>
380
+ </tr>
381
+ </Show>
382
+ </tbody>
383
+ </table>
384
+ </div>
385
+ </Show>
386
+
268
387
  <div
269
- class={`w-full ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
388
+ class={`relative w-full ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
270
389
  style={
271
- error()
390
+ error() || activeView() === 'data'
272
391
  ? { display: 'none' }
273
392
  : isExpanded()
274
393
  ? { height: '100%', display: 'block' }
275
394
  : { height: params().height || '250px', display: 'block' }
276
395
  }
277
396
  >
278
- <canvas ref={canvasRef} />
397
+ <canvas
398
+ ref={canvasRef}
399
+ role="img"
400
+ aria-label={title()}
401
+ aria-describedby={descriptionId}
402
+ aria-hidden={activeView() !== 'chart' || Boolean(error())}
403
+ />
404
+ <Show when={isLoading() && activeView() === 'chart'}>
405
+ <div class="absolute inset-0 flex items-center justify-center bg-white/80 dark:bg-gray-800/80">
406
+ <div class="flex flex-col items-center gap-2">
407
+ <div class="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" />
408
+ <span class="text-sm text-gray-500 dark:text-gray-400">Loading chart...</span>
409
+ </div>
410
+ </div>
411
+ </Show>
279
412
  </div>
280
- </div>
413
+ </div>
414
+ )}
415
+ />
281
416
  </ExpandableWrapper>
282
417
  );
283
418
  };
@@ -6,7 +6,7 @@
6
6
  import { describe, it, expect, vi, beforeEach } from 'vitest'
7
7
  import { render, fireEvent, cleanup } from '@solidjs/testing-library'
8
8
  import { ChatPrompt } from './ChatPrompt'
9
- import type { ChatPromptConfig, ChatPromptResponse } from '../types/chat-bus'
9
+ import type { ChatPromptConfig } from '../types/chat-bus'
10
10
 
11
11
  describe('ChatPrompt', () => {
12
12
  beforeEach(() => cleanup())
@@ -3,7 +3,7 @@
3
3
  * v2.2.5: Consistent icon set, position, and hover behavior across all components
4
4
  */
5
5
 
6
- import { Component, For, Show, createSignal } from 'solid-js'
6
+ import { Component, For, createSignal } from 'solid-js'
7
7
 
8
8
  /** SVG icon paths for toolbar actions */
9
9
  const ICONS = {
@@ -0,0 +1,12 @@
1
+ /** Private context: editor canvases must keep authored grid positions. */
2
+ import { createContext, useContext, type ParentComponent } from 'solid-js'
3
+
4
+ const EditableLayoutContext = createContext(false)
5
+
6
+ export const EditableLayoutProvider: ParentComponent = (props) => (
7
+ <EditableLayoutContext.Provider value={true}>{props.children}</EditableLayoutContext.Provider>
8
+ )
9
+
10
+ export function useEditableLayoutPositions(): boolean {
11
+ return useContext(EditableLayoutContext)
12
+ }
@@ -26,6 +26,7 @@ import { useDragDrop } from '../hooks/useDragDrop'
26
26
  import { useResize, ResizeEdge } from '../hooks/useResize'
27
27
  import { DraggableGridItem } from './DraggableGridItem'
28
28
  import { UIResourceRenderer } from './UIResourceRenderer'
29
+ import { EditableLayoutProvider } from './EditableLayoutContext'
29
30
 
30
31
  /**
31
32
  * Props for EditableUIResourceRenderer
@@ -204,13 +205,14 @@ export const EditableUIResourceRenderer: Component<EditableUIResourceRendererPro
204
205
  }
205
206
 
206
207
  return (
207
- <div class={`w-full editable-ui-renderer ${props.class || ''}`}>
208
- <div
209
- ref={setGridContainerRef}
210
- class="grid relative"
211
- style={gridContainerStyle()}
212
- data-grid-container
213
- >
208
+ <EditableLayoutProvider>
209
+ <div class={`w-full editable-ui-renderer ${props.class || ''}`}>
210
+ <div
211
+ ref={setGridContainerRef}
212
+ class="grid relative"
213
+ style={gridContainerStyle()}
214
+ data-grid-container
215
+ >
214
216
  {/* Grid overlay */}
215
217
  <GridOverlay
216
218
  columns={props.layout.grid.columns}
@@ -260,8 +262,9 @@ export const EditableUIResourceRenderer: Component<EditableUIResourceRendererPro
260
262
  )
261
263
  }}
262
264
  </For>
265
+ </div>
263
266
  </div>
264
- </div>
267
+ </EditableLayoutProvider>
265
268
  )
266
269
  }
267
270
 
@@ -78,7 +78,7 @@ export const ElicitationForm: Component<ElicitationFormProps> = (props) => {
78
78
 
79
79
  const handleSubmit = (response: ChatPromptResponse): void => {
80
80
  if (response.dismissed) {
81
- ;(props.onDecline ?? props.onCancel)?.()
81
+ (props.onDecline ?? props.onCancel)?.()
82
82
  return
83
83
  }
84
84
  props.onAccept(extractContent(response, props.event))
@@ -84,7 +84,7 @@ export const FeedbackInline: Component<FeedbackInlineProps> = (props) => {
84
84
  // swallow it — feedback is best-effort by design.
85
85
  const result = props.onSubmit(value, props.context)
86
86
  if (result && typeof (result as Promise<void>).catch === 'function') {
87
- ;(result as Promise<void>).catch(() => {
87
+ (result as Promise<void>).catch(() => {
88
88
  /* non-blocking */
89
89
  })
90
90
  }
@@ -16,14 +16,13 @@
16
16
  */
17
17
 
18
18
  import { describe, it, expect, beforeEach } from 'vitest'
19
- import { render, cleanup, waitFor } from '@solidjs/testing-library'
19
+ import { cleanup, waitFor } from '@solidjs/testing-library'
20
20
  import {
21
21
  graphToMermaid,
22
22
  graphToJSON,
23
23
  isG6Available,
24
24
  GraphRenderer,
25
25
  } from './GraphRenderer'
26
- import { UIResourceRenderer } from './UIResourceRenderer'
27
26
  import { validateComponent } from '../services/validation'
28
27
  import type { GraphComponentParams } from '@seed-ship/mcp-ui-spec'
29
28
  import type { UIComponent } from '../types'
@@ -23,8 +23,6 @@ import type { UIComponent } from '../types';
23
23
  import type {
24
24
  GraphComponentParams,
25
25
  GraphLayout,
26
- GraphNode,
27
- GraphEdge,
28
26
  } from '@seed-ship/mcp-ui-spec';
29
27
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
30
28
  import { PortalDropdownMenu } from './PortalDropdownMenu';
@@ -7,6 +7,7 @@
7
7
  import { Component, For, createMemo } from 'solid-js'
8
8
  import type { UIComponent, GridPosition } from '../types'
9
9
  import { useRenderContext } from './RenderContext'
10
+ import { createResponsiveGrid } from '../hooks/createResponsiveGrid'
10
11
 
11
12
  /**
12
13
  * Parameters for GridRenderer component
@@ -87,6 +88,7 @@ function buildGridTemplateAreas(areas: string[][]): string {
87
88
  * Renders a CSS Grid container with nested UIComponents
88
89
  */
89
90
  export const GridRenderer: Component<GridRendererProps> = (props) => {
91
+ const responsiveGrid = createResponsiveGrid()
90
92
  // Use render context to avoid circular dependency
91
93
  const { renderComponent } = useRenderContext()
92
94
 
@@ -105,13 +107,13 @@ export const GridRenderer: Component<GridRendererProps> = (props) => {
105
107
  // Build grid container style
106
108
  const gridContainerStyle = createMemo(() => {
107
109
  const p = params()
108
- let style = `display: grid; grid-template-columns: repeat(${p.columns}, 1fr); gap: ${p.gap}`
110
+ let style = `display: grid; grid-template-columns: repeat(${responsiveGrid.stacked() ? 1 : p.columns}, minmax(0, 1fr)); gap: ${p.gap}`
109
111
 
110
112
  if (p.minRowHeight) {
111
113
  style += `; grid-auto-rows: minmax(${p.minRowHeight}, auto)`
112
114
  }
113
115
 
114
- if (p.areas && p.areas.length > 0) {
116
+ if (!responsiveGrid.stacked() && p.areas && p.areas.length > 0) {
115
117
  style += `; grid-template-areas: ${buildGridTemplateAreas(p.areas)}`
116
118
  }
117
119
 
@@ -124,11 +126,11 @@ export const GridRenderer: Component<GridRendererProps> = (props) => {
124
126
  data-component-type="grid"
125
127
  data-component-id={props.component.id}
126
128
  >
127
- <div class="p-4 h-full" style={gridContainerStyle()}>
129
+ <div ref={responsiveGrid.ref} class="p-4 h-full" style={gridContainerStyle()} data-mcp-ui-grid data-mcp-ui-stacked={responsiveGrid.stacked()}>
128
130
  <For each={params().children}>
129
131
  {(child) => (
130
132
  <div
131
- style={getGridItemStyle(child.position, params().areas)}
133
+ style={responsiveGrid.stacked() ? 'grid-column: 1 / -1; grid-row: auto' : getGridItemStyle(child.position, params().areas)}
132
134
  class="min-w-0 h-full"
133
135
  >
134
136
  {/* Use RenderContext for recursive rendering (avoids circular dependency) */}
@@ -12,8 +12,6 @@ import type {
12
12
  MapClusterOptions,
13
13
  MapGeoJSONStyle,
14
14
  MapPopupConfig,
15
- MapLayer,
16
- MapPMTilesConfig,
17
15
  } from '../types';
18
16
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
19
17
  import { DegradedFallback } from './DegradedFallback';
@@ -458,7 +456,6 @@ export const MapRenderer: Component<MapRendererProps> = (props) => {
458
456
  setPmtilesError(null);
459
457
  if (p?.pmtiles) {
460
458
  try {
461
- // @ts-ignore — optional peer dependency, may not be installed
462
459
  const protomaps = await import(/* @vite-ignore */ 'protomaps-leaflet');
463
460
  const pmConfig = p.pmtiles;
464
461
 
@@ -170,7 +170,7 @@ export const PresentationFeedback: Component<PresentationFeedbackProps> = (props
170
170
  // break the UI (mirrors FeedbackInline).
171
171
  const result = props.onSubmit(feedback)
172
172
  if (result && typeof (result as Promise<void>).catch === 'function') {
173
- ;(result as Promise<void>).catch(() => {
173
+ (result as Promise<void>).catch(() => {
174
174
  /* non-blocking */
175
175
  })
176
176
  }
@@ -0,0 +1,166 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2
+ import { cleanup, render } from '@solidjs/testing-library'
3
+ import { createSignal } from 'solid-js'
4
+ import { UIResourceRenderer } from './UIResourceRenderer'
5
+ import { EditableUIResourceRenderer } from './EditableUIResourceRenderer'
6
+ import { GridRenderer } from './GridRenderer'
7
+ import { RenderProvider } from './RenderContext'
8
+ import type { UIComponent, UILayout } from '../types'
9
+
10
+ const observers: Array<{
11
+ callback: ResizeObserverCallback
12
+ element?: Element
13
+ disconnect: ReturnType<typeof vi.fn>
14
+ }> = []
15
+
16
+ function resize(element: Element, width: number) {
17
+ const observer = observers.find((item) => item.element === element)
18
+ expect(observer).toBeDefined()
19
+ observer!.callback([{ target: element, contentRect: { width } } as ResizeObserverEntry], {} as ResizeObserver)
20
+ }
21
+
22
+ function textComponent(id: string, colStart = 1, colSpan = 6): UIComponent {
23
+ return { id, type: 'text', position: { colStart, colSpan, rowStart: 2, rowSpan: 2 }, params: { content: id } }
24
+ }
25
+
26
+ function layout(): UILayout {
27
+ return { id: 'responsive-test', grid: { columns: 12, gap: '1rem' }, components: [textComponent('first'), textComponent('second', 7)] }
28
+ }
29
+
30
+ beforeEach(() => {
31
+ observers.length = 0
32
+ vi.stubGlobal('ResizeObserver', class {
33
+ item: typeof observers[number]
34
+ constructor(callback: ResizeObserverCallback) {
35
+ this.item = { callback, disconnect: vi.fn() }
36
+ observers.push(this.item)
37
+ }
38
+ observe(element: Element) { this.item.element = element }
39
+ disconnect() { this.item.disconnect() }
40
+ })
41
+ })
42
+
43
+ afterEach(() => {
44
+ cleanup()
45
+ vi.restoreAllMocks()
46
+ vi.unstubAllGlobals()
47
+ })
48
+
49
+ describe('responsive read-only grids', () => {
50
+ it('stacks inside a narrow container and restores explicit desktop positions without mutating input', () => {
51
+ const input = layout()
52
+ const original = JSON.stringify(input)
53
+ const { container } = render(() => <UIResourceRenderer content={input} />)
54
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
55
+ const first = grid.children[0] as HTMLElement
56
+ const second = grid.children[1] as HTMLElement
57
+ expect(grid.style.gridTemplateColumns).toBe('repeat(12, minmax(0, 1fr))')
58
+ resize(grid, 420)
59
+ expect(grid.dataset.mcpUiStacked).toBe('true')
60
+ expect(grid.style.gridTemplateColumns).toBe('repeat(1, minmax(0, 1fr))')
61
+ expect(first.style.gridColumn).toBe('1 / -1')
62
+ expect(second.style.gridRow).toBe('auto')
63
+ expect(grid.textContent!.indexOf('first')).toBeLessThan(grid.textContent!.indexOf('second'))
64
+ resize(grid, 640)
65
+ expect(grid.dataset.mcpUiStacked).toBe('false')
66
+ expect(second.style.gridColumn).toBe('7 / span 6')
67
+ expect(second.style.gridRow).toBe('2 / span 2')
68
+ expect(JSON.stringify(input)).toBe(original)
69
+ })
70
+
71
+ it('uses the container width on initial mount, independently of the viewport', () => {
72
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 360 } as DOMRect)
73
+ const { container } = render(() => <UIResourceRenderer content={layout()} />)
74
+ expect(window.innerWidth).toBeGreaterThan(640)
75
+ expect(container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!.dataset.mcpUiStacked).toBe('true')
76
+ })
77
+
78
+ it('does not switch on hidden or invalid observer measurements and disconnects on unmount', () => {
79
+ const { container, unmount } = render(() => <UIResourceRenderer content={layout()} />)
80
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
81
+ resize(grid, 320)
82
+ for (const width of [0, -1, Number.NaN]) resize(grid, width)
83
+ expect(grid.dataset.mcpUiStacked).toBe('true')
84
+ const observer = observers.find((item) => item.element === grid)!
85
+ unmount()
86
+ expect(observer.disconnect).toHaveBeenCalledOnce()
87
+ })
88
+
89
+ it('keeps components without a position inside the configured column count', () => {
90
+ const input = layout()
91
+ input.grid.columns = 2
92
+ input.components = [{ id: 'unpositioned', type: 'text', params: { content: 'Full width' } } as UIComponent]
93
+ const { container } = render(() => <UIResourceRenderer content={input} />)
94
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
95
+ expect((grid.children[0] as HTMLElement).style.gridColumn).toBe('1 / -1')
96
+ })
97
+
98
+ it('measures nested grids independently and removes named areas only while stacked', () => {
99
+ const input: UIComponent = {
100
+ id: 'nested', type: 'grid', position: { colStart: 1, colSpan: 12 },
101
+ params: { columns: 2, areas: [['left', 'right']], children: [textComponent('left', 1, 1), textComponent('right', 2, 1)] },
102
+ }
103
+ const { container } = render(() => (
104
+ <RenderProvider renderComponent={(component) => <span>{component.id}</span>}>
105
+ <GridRenderer component={input} />
106
+ </RenderProvider>
107
+ ))
108
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
109
+ expect(grid.style.gridTemplateAreas).toBe('"left right"')
110
+ resize(grid, 500)
111
+ expect(grid.style.gridTemplateAreas).toBe('')
112
+ expect((grid.children[1] as HTMLElement).style.gridColumn).toBe('1 / -1')
113
+ resize(grid, 800)
114
+ expect(grid.style.gridTemplateAreas).toBe('"left right"')
115
+ expect((grid.children[1] as HTMLElement).style.gridColumn).toBe('2 / span 1')
116
+ })
117
+
118
+ it('updates layout components while retaining the narrow-container presentation', () => {
119
+ const [input, setInput] = createSignal(layout())
120
+ const { container } = render(() => <UIResourceRenderer content={input()} />)
121
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
122
+ resize(grid, 400)
123
+ setInput({ ...layout(), components: [textComponent('replacement')] })
124
+ expect(grid.children.length).toBe(1)
125
+ expect(grid.textContent).toContain('replacement')
126
+ expect((grid.children[0] as HTMLElement).style.gridColumn).toBe('1 / -1')
127
+ })
128
+
129
+ it('falls back to window resize when ResizeObserver is unavailable and cleans up', () => {
130
+ vi.stubGlobal('ResizeObserver', undefined)
131
+ let width = 900
132
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(() => ({ width }) as DOMRect)
133
+ const removeListener = vi.spyOn(window, 'removeEventListener')
134
+ const { container, unmount } = render(() => <UIResourceRenderer content={layout()} />)
135
+ const grid = container.querySelector<HTMLElement>('[data-mcp-ui-grid]')!
136
+ width = 400
137
+ window.dispatchEvent(new Event('resize'))
138
+ expect(grid.dataset.mcpUiStacked).toBe('true')
139
+ unmount()
140
+ expect(removeListener).toHaveBeenCalledWith('resize', expect.any(Function))
141
+ })
142
+
143
+ it('keeps nested editor-grid coordinates below 640px while editing', () => {
144
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 420 } as DOMRect)
145
+ const nested: UIComponent = {
146
+ id: 'editor-nested', type: 'grid', position: { colStart: 1, colSpan: 12 },
147
+ params: { columns: 2, children: [textComponent('editor-left', 1, 1), textComponent('editor-right', 2, 1)] },
148
+ }
149
+ const editableLayout: UILayout = {
150
+ id: 'editor-layout', grid: { columns: 12, gap: '1rem' }, components: [nested],
151
+ }
152
+ const { container } = render(() => <EditableUIResourceRenderer layout={editableLayout} dragDrop={{ enabled: true }} />)
153
+ const nestedGrid = container.querySelector<HTMLElement>(
154
+ '[data-component-type="grid"][data-component-id="editor-nested"] > [data-mcp-ui-grid]'
155
+ )!
156
+ expect(nestedGrid.dataset.mcpUiStacked).toBe('false')
157
+ expect(nestedGrid.style.gridTemplateColumns).toBe('repeat(2, minmax(0, 1fr))')
158
+ expect((nestedGrid.children[1] as HTMLElement).style.gridColumn).toBe('2 / span 1')
159
+ })
160
+
161
+ it('keeps the disabled editor fallback responsive', () => {
162
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ width: 420 } as DOMRect)
163
+ const { container } = render(() => <EditableUIResourceRenderer layout={layout()} />)
164
+ expect(container.querySelector<HTMLElement>('[data-mcp-ui-grid]')?.dataset.mcpUiStacked).toBe('true')
165
+ })
166
+ })