@seed-ship/mcp-ui-solid 6.8.2 → 6.10.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 (41) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/components/ChartJSRenderer.cjs +27 -13
  3. package/dist/components/ChartJSRenderer.cjs.map +1 -1
  4. package/dist/components/ChartJSRenderer.d.ts.map +1 -1
  5. package/dist/components/ChartJSRenderer.js +28 -14
  6. package/dist/components/ChartJSRenderer.js.map +1 -1
  7. package/dist/components/DegradedFallback.cjs +73 -0
  8. package/dist/components/DegradedFallback.cjs.map +1 -0
  9. package/dist/components/DegradedFallback.d.ts +37 -0
  10. package/dist/components/DegradedFallback.d.ts.map +1 -0
  11. package/dist/components/DegradedFallback.js +73 -0
  12. package/dist/components/DegradedFallback.js.map +1 -0
  13. package/dist/components/GraphRenderer.cjs +30 -15
  14. package/dist/components/GraphRenderer.cjs.map +1 -1
  15. package/dist/components/GraphRenderer.d.ts.map +1 -1
  16. package/dist/components/GraphRenderer.js +31 -16
  17. package/dist/components/GraphRenderer.js.map +1 -1
  18. package/dist/components/MapRenderer.cjs +132 -110
  19. package/dist/components/MapRenderer.cjs.map +1 -1
  20. package/dist/components/MapRenderer.d.ts +37 -1
  21. package/dist/components/MapRenderer.d.ts.map +1 -1
  22. package/dist/components/MapRenderer.js +134 -112
  23. package/dist/components/MapRenderer.js.map +1 -1
  24. package/dist/index.cjs +4 -4
  25. package/dist/index.js +1 -1
  26. package/dist/utils/degraded-projections.cjs +87 -0
  27. package/dist/utils/degraded-projections.cjs.map +1 -0
  28. package/dist/utils/degraded-projections.d.ts +64 -0
  29. package/dist/utils/degraded-projections.d.ts.map +1 -0
  30. package/dist/utils/degraded-projections.js +87 -0
  31. package/dist/utils/degraded-projections.js.map +1 -0
  32. package/package.json +1 -1
  33. package/src/components/ChartJSRenderer.tsx +94 -85
  34. package/src/components/DegradedFallback.test.tsx +61 -0
  35. package/src/components/DegradedFallback.tsx +93 -0
  36. package/src/components/GraphRenderer.tsx +26 -4
  37. package/src/components/MapRenderer.security.test.ts +83 -0
  38. package/src/components/MapRenderer.tsx +502 -392
  39. package/src/utils/degraded-projections.test.ts +113 -0
  40. package/src/utils/degraded-projections.ts +149 -0
  41. package/tsconfig.tsbuildinfo +1 -1
@@ -8,41 +8,44 @@
8
8
  * ```
9
9
  */
10
10
 
11
- import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js'
12
- import type { UIComponent, ChartComponentParams } from '../types'
13
- import { ExpandableWrapper, useExpanded } from './ExpandableWrapper'
11
+ import { Component, createEffect, onCleanup, createSignal, Show } from 'solid-js';
12
+ import type { UIComponent, ChartComponentParams } from '../types';
13
+ import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
14
+ import { DegradedFallback } from './DegradedFallback';
15
+ import { chartToDegradedTable } from '../utils/degraded-projections';
16
+ import { useTelemetry } from '../context/MCPUITelemetryContext';
14
17
 
15
18
  // Lazy load Chart.js to avoid bundling if not used
16
- let ChartJS: any = null
17
- let chartJSLoadPromise: Promise<any> | null = null
19
+ let ChartJS: any = null;
20
+ let chartJSLoadPromise: Promise<any> | null = null;
18
21
 
19
22
  const loadChartJS = async () => {
20
- if (ChartJS) return ChartJS
23
+ if (ChartJS) return ChartJS;
21
24
 
22
25
  if (!chartJSLoadPromise) {
23
26
  chartJSLoadPromise = import('chart.js/auto')
24
27
  .then((module) => {
25
- ChartJS = module.default || module.Chart
26
- return ChartJS
28
+ ChartJS = module.default || module.Chart;
29
+ return ChartJS;
27
30
  })
28
31
  .catch((err) => {
29
- chartJSLoadPromise = null
30
- throw err
31
- })
32
+ chartJSLoadPromise = null;
33
+ throw err;
34
+ });
32
35
  }
33
36
 
34
- return chartJSLoadPromise
35
- }
37
+ return chartJSLoadPromise;
38
+ };
36
39
 
37
40
  /**
38
41
  * Check if Chart.js is available
39
42
  */
40
43
  export async function isChartJSAvailable(): Promise<boolean> {
41
44
  try {
42
- await loadChartJS()
43
- return true
45
+ await loadChartJS();
46
+ return true;
44
47
  } catch {
45
- return false
48
+ return false;
46
49
  }
47
50
  }
48
51
 
@@ -50,18 +53,18 @@ export interface ChartJSRendererProps {
50
53
  /**
51
54
  * UIComponent with chart params
52
55
  */
53
- component: UIComponent
56
+ component: UIComponent;
54
57
 
55
58
  /**
56
59
  * Error callback
57
60
  */
58
- onError?: (error: Error) => void
61
+ onError?: (error: Error) => void;
59
62
 
60
63
  /**
61
64
  * Forwarded to the underlying `<ExpandableWrapper>` (v6.3.1).
62
65
  * @see ExpandableWrapperProps.toolbarVariant
63
66
  */
64
- toolbarVariant?: 'hover' | 'always-visible'
67
+ toolbarVariant?: 'hover' | 'always-visible';
65
68
  }
66
69
 
67
70
  /**
@@ -87,50 +90,51 @@ export interface ChartJSRendererProps {
87
90
  * ```
88
91
  */
89
92
  export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
90
- const [isLoading, setIsLoading] = createSignal(true)
91
- const [error, setError] = createSignal<string>()
92
- let canvasRef: HTMLCanvasElement | undefined
93
- let chartInstance: any
93
+ const [isLoading, setIsLoading] = createSignal(true);
94
+ const [error, setError] = createSignal<string>();
95
+ let canvasRef: HTMLCanvasElement | undefined;
96
+ let chartInstance: any;
94
97
 
95
- const params = () => props.component.params as ChartComponentParams
96
- const isExpanded = useExpanded()
98
+ const params = () => props.component.params as ChartComponentParams;
99
+ const isExpanded = useExpanded();
100
+ const telemetry = useTelemetry();
97
101
 
98
102
  // v6.1.0 — export visibility :
99
103
  // - undefined / true → button shown (new default, was opt-in)
100
104
  // - false → button hidden (explicit opt-out, unchanged)
101
- const exportEnabled = () => params().exportable !== false
105
+ const exportEnabled = () => params().exportable !== false;
102
106
 
103
107
  // v6.1.0 — copy data for the ExpandableWrapper modal-header copy button.
104
108
  // Lazy-stringified each time the button is clicked.
105
- const copyDataJSON = () => JSON.stringify({ type: params().type, data: params().data }, null, 2)
109
+ const copyDataJSON = () => JSON.stringify({ type: params().type, data: params().data }, null, 2);
106
110
 
107
111
  // Chart PNG export
108
112
  const handleExportPNG = () => {
109
- if (!canvasRef) return
110
- const url = canvasRef.toDataURL('image/png')
111
- const a = document.createElement('a')
112
- a.href = url
113
- a.download = `${(params().title || 'chart').replace(/\s+/g, '-').toLowerCase()}.png`
114
- a.click()
115
- }
113
+ if (!canvasRef) return;
114
+ const url = canvasRef.toDataURL('image/png');
115
+ const a = document.createElement('a');
116
+ a.href = url;
117
+ a.download = `${(params().title || 'chart').replace(/\s+/g, '-').toLowerCase()}.png`;
118
+ a.click();
119
+ };
116
120
 
117
121
  // Create/update chart when params change
118
122
  createEffect(async () => {
119
- if (!canvasRef) return
123
+ if (!canvasRef) return;
120
124
 
121
125
  // Access params to track dependencies
122
- const chartParams = params()
126
+ const chartParams = params();
123
127
 
124
- setIsLoading(true)
125
- setError(undefined)
128
+ setIsLoading(true);
129
+ setError(undefined);
126
130
 
127
131
  try {
128
- const Chart = await loadChartJS()
132
+ const Chart = await loadChartJS();
129
133
 
130
134
  // Destroy previous instance
131
135
  if (chartInstance) {
132
- chartInstance.destroy()
133
- chartInstance = null
136
+ chartInstance.destroy();
137
+ chartInstance = null;
134
138
  }
135
139
 
136
140
  // Build options, merging time-axis config if present (v3.1.0)
@@ -146,11 +150,11 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
146
150
  ...chartParams.options?.plugins?.legend,
147
151
  },
148
152
  },
149
- }
153
+ };
150
154
 
151
155
  // Time-series axis (v3.1.0)
152
156
  if (chartParams.timeAxis) {
153
- const ta = chartParams.timeAxis
157
+ const ta = chartParams.timeAxis;
154
158
  baseOptions.scales = {
155
159
  ...baseOptions.scales,
156
160
  x: {
@@ -164,7 +168,7 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
164
168
  ...(ta.min ? { min: ta.min } : {}),
165
169
  ...(ta.max ? { max: ta.max } : {}),
166
170
  },
167
- }
171
+ };
168
172
  }
169
173
 
170
174
  // Create new chart
@@ -172,24 +176,33 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
172
176
  type: chartParams.type,
173
177
  data: chartParams.data,
174
178
  options: baseOptions,
175
- })
179
+ });
176
180
 
177
- setIsLoading(false)
181
+ setIsLoading(false);
178
182
  } catch (err) {
179
- const error = err instanceof Error ? err : new Error('Chart rendering failed')
180
- setError(error.message)
181
- setIsLoading(false)
182
- props.onError?.(error)
183
+ const error = err instanceof Error ? err : new Error('Chart rendering failed');
184
+ setError(error.message);
185
+ setIsLoading(false);
186
+ // Fallback ladder (P2.5): record the failure so it's observable, then
187
+ // degrade to the series table below instead of a blank canvas.
188
+ telemetry?.dispatch({
189
+ type: 'render:error',
190
+ errorMessage: error.message,
191
+ id: props.component?.id ?? '',
192
+ componentType: 'chart',
193
+ ts: Date.now(),
194
+ });
195
+ props.onError?.(error);
183
196
  }
184
- })
197
+ });
185
198
 
186
199
  // Cleanup on unmount
187
200
  onCleanup(() => {
188
201
  if (chartInstance) {
189
- chartInstance.destroy()
190
- chartInstance = null
202
+ chartInstance.destroy();
203
+ chartInstance = null;
191
204
  }
192
- })
205
+ });
193
206
 
194
207
  return (
195
208
  <ExpandableWrapper
@@ -198,15 +211,15 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
198
211
  copyLabel="Copy chart data (JSON)"
199
212
  toolbarVariant={props.toolbarVariant}
200
213
  >
201
- <div 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 ${
202
- isExpanded() ? 'flex-1 min-h-0 flex flex-col' : ''
203
- }`}>
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
+ >
204
219
  <Show when={params().title || exportEnabled()}>
205
220
  <div class="flex items-center justify-between mb-3 flex-shrink-0">
206
221
  <Show when={params().title}>
207
- <h3 class="text-sm font-semibold text-gray-900 dark:text-white">
208
- {params().title}
209
- </h3>
222
+ <h3 class="text-sm font-semibold text-gray-900 dark:text-white">{params().title}</h3>
210
223
  </Show>
211
224
  <Show when={exportEnabled()}>
212
225
  <button
@@ -215,8 +228,18 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
215
228
  title="Download PNG"
216
229
  aria-label="Download chart as PNG"
217
230
  >
218
- <svg class="w-3 h-3 text-gray-500 dark:text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
219
- <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
231
+ <svg
232
+ class="w-3 h-3 text-gray-500 dark:text-gray-400"
233
+ fill="none"
234
+ viewBox="0 0 24 24"
235
+ stroke="currentColor"
236
+ >
237
+ <path
238
+ stroke-linecap="round"
239
+ stroke-linejoin="round"
240
+ stroke-width="2"
241
+ d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
242
+ />
220
243
  </svg>
221
244
  </button>
222
245
  </Show>
@@ -232,28 +255,14 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
232
255
  </div>
233
256
  </Show>
234
257
 
258
+ {/* Fallback ladder (P2.5): degrade to a series table on render error
259
+ instead of a bare "Chart Error" message. */}
235
260
  <Show when={error()}>
236
- <div class="absolute inset-0 flex items-center justify-center p-4 bg-white dark:bg-gray-800">
237
- <div class="text-center">
238
- <div class="inline-flex items-center justify-center w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/20 mb-3">
239
- <svg
240
- class="w-6 h-6 text-red-600 dark:text-red-400"
241
- fill="none"
242
- viewBox="0 0 24 24"
243
- stroke="currentColor"
244
- >
245
- <path
246
- stroke-linecap="round"
247
- stroke-linejoin="round"
248
- stroke-width="2"
249
- d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
250
- />
251
- </svg>
252
- </div>
253
- <p class="text-red-600 dark:text-red-400 text-sm font-medium">Chart Error</p>
254
- <p class="text-gray-600 dark:text-gray-400 text-xs mt-1 max-w-xs">{error()}</p>
255
- </div>
256
- </div>
261
+ <DegradedFallback
262
+ message={`Chart rendering failed: ${error()}`}
263
+ caption="Showing the chart data as a table the interactive chart is unavailable."
264
+ {...chartToDegradedTable(params() ?? {})}
265
+ />
257
266
  </Show>
258
267
 
259
268
  <div
@@ -270,5 +279,5 @@ export const ChartJSRenderer: Component<ChartJSRendererProps> = (props) => {
270
279
  </div>
271
280
  </div>
272
281
  </ExpandableWrapper>
273
- )
274
- }
282
+ );
283
+ };
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Tests for <DegradedFallback> — the middle rung of the renderer fallback
3
+ * ladder (P2.5). Pure presentational component, no peers, no async.
4
+ */
5
+
6
+ import { describe, it, expect } from 'vitest';
7
+ import { render, cleanup } from '@solidjs/testing-library';
8
+ import { DegradedFallback } from './DegradedFallback';
9
+
10
+ describe('<DegradedFallback>', () => {
11
+ it('shows the message and a default caption', () => {
12
+ const { getByText, container } = render(() => (
13
+ <DegradedFallback message="Graph rendering failed" />
14
+ ));
15
+ expect(getByText('Graph rendering failed')).toBeTruthy();
16
+ expect(container.textContent).toContain('interactive view is unavailable');
17
+ cleanup();
18
+ });
19
+
20
+ it('renders a table when columns + rows are provided', () => {
21
+ const { container } = render(() => (
22
+ <DegradedFallback
23
+ message="failed"
24
+ columns={['Source', 'Target', 'Label']}
25
+ rows={[
26
+ ['a', 'b', 'rel'],
27
+ ['b', 'c', ''],
28
+ ]}
29
+ />
30
+ ));
31
+ const headers = container.querySelectorAll('th');
32
+ expect(headers).toHaveLength(3);
33
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
34
+ expect(container.textContent).toContain('rel');
35
+ cleanup();
36
+ });
37
+
38
+ it('shows no table when columns are empty', () => {
39
+ const { container } = render(() => <DegradedFallback message="failed" rows={[['x']]} />);
40
+ expect(container.querySelector('table')).toBeNull();
41
+ cleanup();
42
+ });
43
+
44
+ it('truncates rows past maxRows and notes the remainder', () => {
45
+ const rows = Array.from({ length: 5 }, (_, i) => [String(i)]);
46
+ const { container } = render(() => (
47
+ <DegradedFallback message="failed" columns={['n']} rows={rows} maxRows={2} />
48
+ ));
49
+ expect(container.querySelectorAll('tbody tr')).toHaveLength(2);
50
+ expect(container.textContent).toContain('+3 more rows');
51
+ cleanup();
52
+ });
53
+
54
+ it('uses a custom caption when provided', () => {
55
+ const { container } = render(() => (
56
+ <DegradedFallback message="failed" caption="custom caption here" />
57
+ ));
58
+ expect(container.textContent).toContain('custom caption here');
59
+ cleanup();
60
+ });
61
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * DegradedFallback — the middle rung of the renderer fallback ladder
3
+ * (audit 2026-05-30, P2.5).
4
+ *
5
+ * Each heavy renderer (graph / map / chart) follows the same contract:
6
+ * 1. native render when its peer lib is available and succeeds;
7
+ * 2. **degraded but useful** view when the native render throws — this
8
+ * component: a visible notice + a plain data table so the user still
9
+ * sees the underlying data instead of a blank space;
10
+ * 3. (the caller also emits a `component:error` telemetry event).
11
+ *
12
+ * Pure / presentational — no peer deps, no side effects — so a render-path
13
+ * failure in a heavy lib can never cascade into the fallback itself, and it
14
+ * is trivially unit-testable. Rows/cells are rendered as text; callers are
15
+ * responsible for stringifying complex cell values.
16
+ */
17
+
18
+ import { Component, For, Show } from 'solid-js';
19
+
20
+ export interface DegradedFallbackProps {
21
+ /** Short, human-readable reason the native render was skipped/failed. */
22
+ message: string;
23
+ /**
24
+ * Column headers for the degraded data table. When omitted (or empty),
25
+ * only the notice banner is shown.
26
+ */
27
+ columns?: string[];
28
+ /** Row data — each row is an array of cells aligned to `columns`. */
29
+ rows?: Array<Array<string | number>>;
30
+ /**
31
+ * Caption under the table. Defaults to a generic
32
+ * "interactive view unavailable" line.
33
+ */
34
+ caption?: string;
35
+ /** Max rows to render before truncating (default 50). */
36
+ maxRows?: number;
37
+ }
38
+
39
+ export const DegradedFallback: Component<DegradedFallbackProps> = (props) => {
40
+ const maxRows = () => props.maxRows ?? 50;
41
+ const allRows = () => props.rows ?? [];
42
+ const shownRows = () => allRows().slice(0, maxRows());
43
+ const hiddenCount = () => Math.max(0, allRows().length - shownRows().length);
44
+ const hasTable = () => (props.columns?.length ?? 0) > 0 && allRows().length > 0;
45
+
46
+ return (
47
+ <div
48
+ class="w-full rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20"
49
+ role="alert"
50
+ >
51
+ <p class="text-sm font-medium text-amber-900 dark:text-amber-100">{props.message}</p>
52
+ <p class="mt-0.5 text-xs text-amber-700 dark:text-amber-300">
53
+ {props.caption ?? 'Showing the underlying data — the interactive view is unavailable.'}
54
+ </p>
55
+
56
+ <Show when={hasTable()}>
57
+ <div class="mt-2 max-h-64 overflow-auto rounded border border-amber-200 dark:border-amber-800">
58
+ <table class="w-full border-collapse text-left text-xs">
59
+ <thead class="sticky top-0 bg-amber-100 dark:bg-amber-900/40">
60
+ <tr>
61
+ <For each={props.columns}>
62
+ {(col) => (
63
+ <th class="px-2 py-1 font-medium text-amber-900 dark:text-amber-100">{col}</th>
64
+ )}
65
+ </For>
66
+ </tr>
67
+ </thead>
68
+ <tbody>
69
+ <For each={shownRows()}>
70
+ {(row) => (
71
+ <tr class="border-t border-amber-100 dark:border-amber-800/60">
72
+ <For each={props.columns}>
73
+ {(_col, i) => (
74
+ <td class="px-2 py-1 text-amber-800 dark:text-amber-200">
75
+ {String(row[i()] ?? '')}
76
+ </td>
77
+ )}
78
+ </For>
79
+ </tr>
80
+ )}
81
+ </For>
82
+ </tbody>
83
+ </table>
84
+ </div>
85
+ <Show when={hiddenCount() > 0}>
86
+ <p class="mt-1 text-[10px] text-amber-600 dark:text-amber-400">
87
+ +{hiddenCount()} more rows not shown.
88
+ </p>
89
+ </Show>
90
+ </Show>
91
+ </div>
92
+ );
93
+ };
@@ -28,6 +28,9 @@ import type {
28
28
  } from '@seed-ship/mcp-ui-spec';
29
29
  import { ExpandableWrapper, useExpanded } from './ExpandableWrapper';
30
30
  import { PortalDropdownMenu } from './PortalDropdownMenu';
31
+ import { DegradedFallback } from './DegradedFallback';
32
+ import { graphToDegradedTable } from '../utils/degraded-projections';
33
+ import { useTelemetry } from '../context/MCPUITelemetryContext';
31
34
 
32
35
  // Module-scoped lazy import promise — first call triggers the dynamic
33
36
  // import, subsequent calls reuse the resolved module.
@@ -220,6 +223,7 @@ export interface GraphRendererProps {
220
223
  export const GraphRenderer: Component<GraphRendererProps> = (props) => {
221
224
  const params = () => props.component.params as GraphComponentParams;
222
225
  const isExpanded = useExpanded();
226
+ const telemetry = useTelemetry();
223
227
  const [available, setAvailable] = createSignal<boolean | null>(null);
224
228
  const [error, setError] = createSignal<string | undefined>();
225
229
  const [exportMenuOpen, setExportMenuOpen] = createSignal(false);
@@ -293,7 +297,18 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
293
297
  graphInstance = new (Graph as any)(config);
294
298
  await graphInstance.render();
295
299
  } catch (err) {
296
- setError(err instanceof Error ? err.message : 'Failed to render graph');
300
+ const message = err instanceof Error ? err.message : 'Failed to render graph';
301
+ setError(message);
302
+ // Fallback ladder (P2.5): the native G6 render threw — emit telemetry
303
+ // so the failure is observable, then degrade to the edge/node table
304
+ // below instead of leaving a blank canvas.
305
+ telemetry?.dispatch({
306
+ type: 'render:error',
307
+ errorMessage: message,
308
+ id: props.component.id ?? '',
309
+ componentType: 'graph',
310
+ ts: Date.now(),
311
+ });
297
312
  }
298
313
  });
299
314
 
@@ -424,19 +439,26 @@ export const GraphRenderer: Component<GraphRendererProps> = (props) => {
424
439
  </PortalDropdownMenu>
425
440
  </div>
426
441
 
442
+ {/* Native G6 canvas — hidden once a render error degrades us. */}
427
443
  <div
428
444
  ref={containerRef}
429
445
  class={`bg-white dark:bg-gray-800 rounded-lg shadow-sm border border-gray-200 dark:border-gray-700 overflow-hidden ${
430
- isExpanded() ? 'flex-1 min-h-0' : ''
431
- }`}
446
+ error() ? 'hidden' : ''
447
+ } ${isExpanded() ? 'flex-1 min-h-0' : ''}`}
432
448
  style={
433
449
  isExpanded()
434
450
  ? `height: 100%; width: ${params().width ?? '100%'};`
435
451
  : `height: ${params().height ?? '400px'}; width: ${params().width ?? '100%'};`
436
452
  }
437
453
  />
454
+ {/* Fallback ladder (P2.5): degrade to an edge/node table on error
455
+ rather than showing a bare message. Export menu stays usable. */}
438
456
  <Show when={error()}>
439
- <p class="text-xs text-red-600 dark:text-red-400 mt-1">Render error: {error()}</p>
457
+ <DegradedFallback
458
+ message={`Graph rendering failed: ${error()}`}
459
+ caption="Showing the graph data as a table — the interactive view is unavailable."
460
+ {...graphToDegradedTable(params())}
461
+ />
440
462
  </Show>
441
463
  </div>
442
464
  </ExpandableWrapper>
@@ -0,0 +1,83 @@
1
+ /**
2
+ * MapRenderer XSS hardening tests (audit P1.2, v6.10.0).
3
+ *
4
+ * Leaflet renders bound tooltip/popup strings as HTML, so untrusted payload
5
+ * content (`marker.tooltip`, `marker.popup`, GeoJSON `popup.template`) is an
6
+ * XSS vector. These lock the safe-by-default behavior and the host opt-in,
7
+ * via the pure exported helpers (the Leaflet binding itself is a thin wrapper
8
+ * and not exercisable in jsdom).
9
+ */
10
+
11
+ import { describe, it, expect } from 'vitest';
12
+ import { popupSafeText, buildPopupContent } from './MapRenderer';
13
+
14
+ const XSS = '<img src=x onerror=alert(1)>';
15
+
16
+ describe('popupSafeText — marker tooltip/popup (P1.2)', () => {
17
+ it('escapes HTML by default (untrusted payload path)', () => {
18
+ const out = popupSafeText(XSS);
19
+ expect(out).toBe('&lt;img src=x onerror=alert(1)&gt;');
20
+ expect(out).not.toContain('<img');
21
+ });
22
+
23
+ it('escapes < > & " so no tag can be injected', () => {
24
+ expect(popupSafeText('a & b <c> "d"')).toBe('a &amp; b &lt;c&gt; &quot;d&quot;');
25
+ });
26
+
27
+ it('passes raw HTML through when the host opts in (trusted)', () => {
28
+ expect(popupSafeText(XSS, true)).toBe(XSS);
29
+ });
30
+
31
+ it('returns undefined for absent content', () => {
32
+ expect(popupSafeText(undefined)).toBeUndefined();
33
+ expect(popupSafeText(undefined, true)).toBeUndefined();
34
+ });
35
+
36
+ it('leaves plain text visually unchanged', () => {
37
+ expect(popupSafeText('Paris')).toBe('Paris');
38
+ });
39
+ });
40
+
41
+ describe('buildPopupContent — GeoJSON popup (P1.2)', () => {
42
+ const feature = (props: Record<string, unknown>) => ({ properties: props });
43
+
44
+ it('ignores a raw `popup.template` on the default (untrusted) path', () => {
45
+ const html = buildPopupContent(
46
+ feature({ name: 'Zone' }),
47
+ { template: `<b>{{name}}</b><img src=x onerror=alert(1)>` }
48
+ // allowHtml defaults to false
49
+ );
50
+ // Template skipped → falls through to the auto popup (no <img>, no <b>).
51
+ expect(html).not.toContain('<img');
52
+ expect(html).not.toContain('<b>');
53
+ });
54
+
55
+ it('honors `popup.template` when the host opts in, but escapes substituted values', () => {
56
+ const html = buildPopupContent(
57
+ feature({ name: '<script>evil</script>' }),
58
+ { template: '<b>{{name}}</b>' },
59
+ true
60
+ );
61
+ // The authored structural HTML stays; the data value is escaped.
62
+ expect(html).toContain('<b>');
63
+ expect(html).toContain('&lt;script&gt;');
64
+ expect(html).not.toContain('<script>');
65
+ });
66
+
67
+ it('auto-generated popup always escapes values (safe by construction)', () => {
68
+ const html = buildPopupContent(
69
+ feature({ title: XSS, count: 5 }),
70
+ { titleField: 'title', fields: ['count'] }
71
+ // default untrusted path
72
+ );
73
+ expect(html).not.toContain('<img');
74
+ expect(html).toContain('&lt;img');
75
+ // structural HTML authored by the renderer is present
76
+ expect(html).toContain('<strong>');
77
+ });
78
+
79
+ it('returns null when there is no popup config or no properties', () => {
80
+ expect(buildPopupContent({ properties: { a: 1 } }, undefined)).toBeNull();
81
+ expect(buildPopupContent({}, { titleField: 'a' })).toBeNull();
82
+ });
83
+ });