@seed-ship/mcp-ui-solid 6.7.0 → 6.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,559 @@
1
+ # Brief — `'graph'` ComponentType powered by `@antv/g6`
2
+
3
+ > **Status** : drafted 2026-05-02. NOT yet implemented. Intended to surface
4
+ > design choices + risks BEFORE coding so the API doesn't get baked in
5
+ > wrong.
6
+ >
7
+ > **Audience** : `@seed-ship/mcp-ui-solid` maintainer + deposium MCPs
8
+ > agent (will be the first emitter of `type: 'graph'` payloads).
9
+ >
10
+ > **PUBLIC PACKAGE — AGNOSTIC RULE** : `@seed-ship/mcp-ui-solid` and
11
+ > `@seed-ship/mcp-ui-spec` are public on npm. The lib code, CHANGELOG,
12
+ > README, and any committed doc must remain agnostic — no
13
+ > deposium-specific naming, no domain-specific naming. **This brief +
14
+ > its INSPIRATION companion stay LOCAL (untracked) ; they exist to
15
+ > stress-test the agnostic API against real consumer cases without
16
+ > baking those cases into shipped code.**
17
+ >
18
+ > **Effort** : ~1.5 days (spec schemas + renderer + lazy import + tests +
19
+ > CLI hookup). Backward compatible — new ComponentType, opt-in via peer
20
+ > dep installation.
21
+ >
22
+ > **Companion** : sibling pattern of `BRIEF-citations-in-table-cells.md`
23
+ > (data shape on the spec side, render logic on the solid side, peer
24
+ > optional for the heavy lib).
25
+
26
+ ---
27
+
28
+ ## 1. The user-facing problem
29
+
30
+ > Examples in this section name a specific consumer (deposium) for
31
+ > stress-testing only — the public lib must NOT reference any of them
32
+ > in code/CHANGELOG/README. The `'graph'` ComponentType ships as a
33
+ > generic node-link primitive ; the consumer wires meaning.
34
+
35
+ LLM-driven UI emitters increasingly want to show **relationships**, not
36
+ just rows or charts. Today mcp-ui has no primitive for this — consumers
37
+ fall back to (a) embedding an `iframe` to a third-party graph viewer
38
+ (heavy, sandboxed, can't pass LLM-generated data nicely) or (b) cobbling
39
+ something with `chart.js` (which doesn't do graph topology at all).
40
+
41
+ Three illustrative seed cases (LOCAL framing, not for the public README) :
42
+
43
+ 1. **Entity / organization networks** — entities extracted from docs
44
+ connected by relationship types. Layout : `force`. Without this,
45
+ topology gets lost in flat tables.
46
+ 2. **Tool / process DAGs** — facade-to-implementation dependencies, or
47
+ any hierarchical pipeline. Layout : `dagre`. Static DAG visualization
48
+ beats a tabular tool list for "why did this chain fire ?".
49
+ 3. **Mindmaps inline in chat answers** — synthesizing LLM reasoning as
50
+ a radial mindmap instead of nested bullet lists. Layout : `mindmap`.
51
+ Visual structure makes the synthesis scannable.
52
+
53
+ §5.5 stress-tests this design against 7 additional concrete cases drawn
54
+ from the INSPIRATION companion brief (chat UX, compliance/SOC2/RGPD,
55
+ runtime observability) — the same agnostic API supports all of them via
56
+ weight + style passthrough.
57
+
58
+ ## 2. What already works in MCP-UI v5.7.0
59
+
60
+ Adjacent renderers that informed the design choices below :
61
+
62
+ - **`<ChartRenderer>`** (`UIResourceRenderer.tsx`) — peer-optional
63
+ `chart.js` ; `isChartJSAvailable()` runtime check ; iframe fallback
64
+ to Quickchart.io. **Same lazy-load pattern** is the right precedent
65
+ for G6.
66
+ - **`<ImageGalleryRenderer>`**, **`<MapRenderer>`**, **DuckDB plugin** —
67
+ all peer-optional, all off the main bundle path.
68
+ - **`KNOWN_COMPONENT_TYPES`** + `SPEC_VALIDATORS` dispatch in
69
+ `services/validation.ts` — adding `'graph'` is a single-entry change
70
+ in each.
71
+
72
+ Also relevant : `MCPUITelemetryProvider` (v5.6.0) — graph mounts will
73
+ naturally emit `component:mounted` / `component:rendered` events with
74
+ `durationMs` from existing perf marks. Consumers get visibility for free.
75
+
76
+ ## 3. `@antv/g6 v5` quick primer
77
+
78
+ - **Latest line** : v5.x (rewrote from v4 — different API surface, do
79
+ NOT skim v4 docs).
80
+ - **Layouts shipped** : `force` (default for networks), `dagre`,
81
+ `antv-dagre`, `d3-force`, `circular`, `grid`, `concentric`, `radial`,
82
+ `mds`, `random`, `combo-combined`, `dendrogram`, `compact-box`,
83
+ `mindmap` (verify exact name during impl — may be a `compact-box`
84
+ config rather than a dedicated layout).
85
+ - **Renderer** : Canvas (default, fast) or SVG (DOM-friendly,
86
+ print/export). User picks.
87
+ - **Bundle weight** (estimated from `bundlephobia` for v5.0.x) : ~150-200
88
+ KB gzipped for the core + a couple of layouts. Tree-shakable but most
89
+ apps will pull a few layouts.
90
+ - **SSR** : NO — needs canvas/SVG DOM, same constraint as Chart.js
91
+ native renderer. Lazy-loaded after `onMount` is the safe pattern.
92
+ - **Peer-optional declared** : `peerDependencies` `^5.0.0` +
93
+ `peerDependenciesMeta.optional: true`, mirror of `chart.js`.
94
+
95
+ ## 4. Proposed API
96
+
97
+ ### 4.1 New ComponentType `'graph'`
98
+
99
+ `mcp-ui-spec` adds `'graph'` to `ComponentTypeSchema` enum + new
100
+ `GraphComponentParamsSchema` :
101
+
102
+ ```ts
103
+ GraphNodeSchema = z.object({
104
+ id: z.string().min(1),
105
+ label: z.string().optional(),
106
+ type: z.string().optional(), // 'circle' | 'rect' | 'image' | …
107
+ size: z.union([z.number(), z.tuple([z.number(), z.number()])]).optional(),
108
+ weight: z.number().optional(), // generic ranking signal (importance, frequency, score).
109
+ // Drives default node size if `size` omitted, and
110
+ // acts as the sort key for the `concentric` layout.
111
+ style: z.record(z.unknown()).optional(), // passthrough G6 NodeStyle
112
+ data: z.record(z.unknown()).optional(), // arbitrary metadata for tooltips/click handlers
113
+ })
114
+
115
+ GraphEdgeSchema = z.object({
116
+ source: z.string().min(1), // must match a node.id
117
+ target: z.string().min(1), // must match a node.id
118
+ label: z.string().optional(),
119
+ type: z.string().optional(), // 'line' | 'arc' | 'cubic' | 'polyline' | …
120
+ weight: z.number().optional(), // generic strength signal. Drives default stroke width
121
+ // on canvas, and acts as the attractive force in
122
+ // `force` layouts. Domain semantics opaque to the lib.
123
+ style: z.record(z.unknown()).optional(),
124
+ data: z.record(z.unknown()).optional(),
125
+ })
126
+
127
+ GraphLayoutNameSchema = z.enum([
128
+ 'force', // ← réseau d'organisations (default when edges.length > 0)
129
+ 'dagre', // ← dépendances tools MCP (DAG top-down or left-right)
130
+ 'mindmap', // ← mindmaps in chat answers
131
+ 'tree', // ← simple parent-child tree
132
+ 'circular',
133
+ 'grid',
134
+ 'concentric',
135
+ ])
136
+
137
+ GraphComponentParamsSchema = z.object({
138
+ title: z.string().optional(),
139
+ nodes: z.array(GraphNodeSchema).min(1),
140
+ edges: z.array(GraphEdgeSchema).optional().default([]),
141
+ // Layout: shorthand string OR object with G6 passthrough options
142
+ layout: z.union([
143
+ GraphLayoutNameSchema,
144
+ z.object({
145
+ type: GraphLayoutNameSchema,
146
+ options: z.record(z.unknown()).optional(), // direction, nodeSep, rankSep, …
147
+ }),
148
+ ]).optional(),
149
+ height: z.string().optional(), // default '400px'
150
+ width: z.string().optional(), // default '100%'
151
+ rendererPref: z.enum(['canvas', 'svg']).optional(), // default 'canvas'
152
+ fitView: z.boolean().optional(), // default true
153
+ enableZoom: z.boolean().optional(), // default true
154
+ enableDrag: z.boolean().optional(), // default true (drag nodes)
155
+ className: z.string().optional(),
156
+ })
157
+ ```
158
+
159
+ ### 4.2 Default layout heuristic
160
+
161
+ When `params.layout` is **omitted** :
162
+ - `edges.length === 0` → `'circular'` (nothing to lay out, just show nodes)
163
+ - `edges.length > 0` → `'force'` ✅ **confirmed 2026-05-02 by user** —
164
+ universal fallback, looks reasonable for networks, matches the most
165
+ common deposium use case (entity / org networks).
166
+
167
+ LLMs SHOULD pass `layout: 'dagre'` for hierarchies and `layout: 'mindmap'`
168
+ for radial structures. This is documented in the spec README + a recipe.
169
+
170
+ ### 4.3 Optional `params.onNodeClick` callback (consumer-side)
171
+
172
+ Consumers wire interactivity at the renderer level (NOT in spec —
173
+ function, not JSON-serializable) :
174
+
175
+ ```tsx
176
+ type GraphComponentParams = z.infer<...> & {
177
+ onNodeClick?: (nodeId: string, node: GraphNode) => void
178
+ onEdgeClick?: (edgeId: string, edge: GraphEdge) => void
179
+ }
180
+ ```
181
+
182
+ Same pattern as `<TableRenderer>`'s `citationRender` from v5.7.0 :
183
+ spec-side carries data, consumer-side wires functions.
184
+
185
+ ## 5. Implementation sketch
186
+
187
+ ### 5.1 `<GraphRenderer>` skeleton
188
+
189
+ New file `src/components/GraphRenderer.tsx`. Mirrors `ChartJSRenderer.tsx`
190
+ + `MapRenderer.tsx` patterns :
191
+
192
+ ```tsx
193
+ import { Component, createSignal, onCleanup, onMount, Show } from 'solid-js'
194
+ import type { UIComponent } from '../types'
195
+
196
+ let g6Modulep: Promise<typeof import('@antv/g6')> | undefined
197
+
198
+ export async function isG6Available(): Promise<boolean> {
199
+ try {
200
+ if (!g6Modulep) g6Modulep = import('@antv/g6')
201
+ await g6Modulep
202
+ return true
203
+ } catch {
204
+ return false
205
+ }
206
+ }
207
+
208
+ export const GraphRenderer: Component<{ component: UIComponent }> = (props) => {
209
+ const params = () => props.component.params as any
210
+ const [available, setAvailable] = createSignal<boolean | null>(null)
211
+ let containerRef: HTMLDivElement | undefined
212
+ let graphInstance: any | undefined
213
+
214
+ onMount(async () => {
215
+ if (!(await isG6Available())) {
216
+ setAvailable(false)
217
+ return
218
+ }
219
+ setAvailable(true)
220
+ const { Graph } = await g6Modulep!
221
+ const layoutCfg = resolveLayout(params()) // helper handles shorthand + heuristic
222
+ graphInstance = new Graph({
223
+ container: containerRef!,
224
+ data: { nodes: params().nodes, edges: params().edges ?? [] },
225
+ layout: layoutCfg,
226
+ autoFit: params().fitView !== false ? 'view' : false,
227
+ behaviors: collectBehaviors(params()), // drag-canvas, zoom-canvas, drag-element
228
+ renderer: params().rendererPref === 'svg' ? 'svg' : 'canvas',
229
+ })
230
+ await graphInstance.render()
231
+ })
232
+
233
+ onCleanup(() => {
234
+ graphInstance?.destroy()
235
+ graphInstance = undefined
236
+ })
237
+
238
+ return (
239
+ <Show
240
+ when={available() !== false}
241
+ fallback={
242
+ <div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
243
+ <p class="text-sm font-medium text-yellow-900 dark:text-yellow-100">Graph rendering unavailable</p>
244
+ <p class="text-xs text-yellow-700 dark:text-yellow-300 mt-1">
245
+ Install <code>@antv/g6</code> peer dependency to render graph components.
246
+ </p>
247
+ </div>
248
+ }
249
+ >
250
+ <div
251
+ ref={containerRef}
252
+ class={`w-full ${params().className || ''}`}
253
+ style={`height: ${params().height || '400px'}; width: ${params().width || '100%'};`}
254
+ />
255
+ </Show>
256
+ )
257
+ }
258
+ ```
259
+
260
+ ### 5.2 Wire into `UIResourceRenderer` switch
261
+
262
+ ```tsx
263
+ <Show when={props.component.type === 'graph'}>
264
+ <GraphRenderer component={props.component} />
265
+ </Show>
266
+ ```
267
+
268
+ ### 5.3 `validation.ts` dispatch
269
+
270
+ Add to `SPEC_VALIDATORS` :
271
+ ```ts
272
+ graph: { schema: GraphComponentParamsSchema, legacyCode: 'INVALID_GRAPH' },
273
+ ```
274
+
275
+ Add to `KNOWN_COMPONENT_TYPES` set. Adds graph to the count : **15/17
276
+ ComponentTypes Zod-driven** after this (chart, table, modal still
277
+ imperative as documented).
278
+
279
+ ### 5.4 Layout heuristic helper
280
+
281
+ ```ts
282
+ function resolveLayout(params: GraphComponentParams) {
283
+ if (typeof params.layout === 'object') {
284
+ return { type: params.layout.type, ...params.layout.options }
285
+ }
286
+ if (typeof params.layout === 'string') {
287
+ return { type: params.layout }
288
+ }
289
+ // Heuristic when layout omitted
290
+ return { type: (params.edges?.length ?? 0) > 0 ? 'force' : 'circular' }
291
+ }
292
+ ```
293
+
294
+ ## 5.5 Design stress-test against the 7 prioritized inspiration cases
295
+
296
+ > Source : `BRIEF-graph-component-g6-INSPIRATION.md` — both files stay
297
+ > LOCAL (untracked) since the package is public and these cases leak
298
+ > deposium internals. Listed here to prove the agnostic API covers them
299
+ > all without case-specific knobs.
300
+
301
+ | Case | Layout | Critical fields used | API gap ? |
302
+ |---|---|---|---|
303
+ | 1.1 Citation provenance graph | `concentric` | `node.weight` (rerank score → ring order), `edge.weight` (rerank → thickness), `node.style` (confidence color) | none |
304
+ | 1.2 Conversation thread map | `mindmap` (horizontal) OR `dagre` LR | `node.data` (parent_id, terminal_state), tooltip default | collapse-expand opt-in deferred (G6 behavior, can opt in later via `enableCollapse: true` flag) |
305
+ | 1.3 Space membership topology | `force` (or `circular` if <20 nodes) | `node.data` (role badges), `edge.data` (relationship type) | none |
306
+ | 2.1 Tenant Access Trust Path | `dagre` top-down | `edge.style` (color encoding HTTP status), `node.data` (resource kind) | none |
307
+ | 2.3 Rate Limit Pressure Map | `force` with grouping | `node.weight` (% consumption), `edge.weight` (correlation), passthrough `layout.options.cluster` | none |
308
+ | 3.2 Dream sense-making lineage | `tree` bottom-up | `edge.weight` (token contribution → branch width), `node.data` (run id) | none |
309
+ | **3.3 BM25 doc-similarity (PRIORITY)** | `concentric` (frequency at center) OR `force` (community detection) | `node.weight` (retrieval count → ring + size), `edge.weight` (co-retrieval count → thickness + force strength), `node.style.fill` (consumer-supplied community color) | community detection itself is consumer responsibility (compute on backend, supply colors via `node.style`) — out of v1 lib scope |
310
+
311
+ All 7 cases reduce to **`{ nodes, edges, layout }` + `weight` first-class
312
+ on both** + style passthrough for visual fine-tuning. The 7-layout enum
313
+ hits 5 distinct values (`force`, `dagre`, `concentric`, `mindmap`,
314
+ `tree`) ; `circular` + `grid` aren't strictly needed for these cases
315
+ but stay in the enum for completeness.
316
+
317
+ ### Implications for v1 implementation order
318
+
319
+ 3.3 BM25 is flagged **priority** in the focus directive. Its hard
320
+ requirement is `node.weight` + `edge.weight` driving (a) concentric ring
321
+ order, (b) default node size, (c) default edge thickness, (d) force
322
+ strength when `layout: 'force'`. **If the v1 implementation has to cut
323
+ scope, cut `mindmap` layout polish FIRST and keep `force` + `concentric`
324
+ + `weight` end-to-end working.** All other cases benefit from
325
+ `weight` too, so it's a high-leverage minimum viable.
326
+
327
+ ## 6. Mapping use cases → defaults
328
+
329
+ | User case | Recommended `layout` | Notes |
330
+ |---|---|---|
331
+ | Réseau d'organisations | `'force'` | Auto if edges present + layout omitted. Force-directed with collision = readable cluster topology. |
332
+ | Dépendances tools MCP | `'dagre'` | LLM passes explicitly. Default direction TB ; LLM can override via `layout: { type: 'dagre', options: { rankdir: 'LR' } }`. |
333
+ | Mindmap dans chat | `'mindmap'` | LLM passes explicitly. Root-centered radial. May need to verify exact G6 v5 layout name (could be `'compact-box'` or `'mindmap'`). |
334
+ | Knowledge graph dense | `'force-atlas2'` | Power user override via `{ type: 'force', options: {...} }` ; we don't expose all 13+ G6 layouts in the enum to keep the spec narrow. |
335
+ | Tree / dependency hierarchy simple | `'tree'` or `'dagre'` | LLM picks. |
336
+
337
+ The 7-layout enum in the spec is **deliberately narrow** — it covers
338
+ 80% of needs without bloating the type surface. Power users opt in
339
+ via the `{ type, options }` object form which passes through to G6.
340
+
341
+ ## 7. Test plan
342
+
343
+ ### 7.1 Spec (`mcp-ui-spec` — ~3 tests)
344
+
345
+ - `GraphComponentParamsSchema` accepts minimal `{ nodes: [{id:'a'}] }`
346
+ - Rejects empty `nodes` array (`min(1)`)
347
+ - Accepts both shorthand `layout: 'force'` and object `layout: { type: 'force', options: {...} }`
348
+
349
+ ### 7.2 Renderer (`mcp-ui-solid` — ~6-8 tests)
350
+
351
+ `@antv/g6` mocked in vitest setup (G6 needs canvas, jsdom doesn't have
352
+ one natively — same approach as chart.js mock). Test :
353
+
354
+ - `<GraphRenderer>` renders fallback UI when `isG6Available()` returns
355
+ false
356
+ - With G6 available + `nodes` only → `Graph` constructor called with
357
+ empty `edges` array
358
+ - With G6 available + nodes + edges → `Graph.render()` called
359
+ - `layout` shorthand `'dagre'` → resolved layout object `{ type: 'dagre' }`
360
+ - `layout` object form → options passed through
361
+ - `onCleanup` calls `graph.destroy()` (memory leak guard)
362
+ - canvas default (rendererPref absent/'canvas') → constructor config has NO `renderer` field (G6 v5 default)
363
+ - `rendererPref: 'svg'` → constructor still receives NO string `renderer` (svg degrades to canvas until the g-svg factory is wired)
364
+ - Telemetry integration (B.5) : `component:mounted` + `component:rendered`
365
+ fire (durationMs from perf marks)
366
+
367
+ ### 7.3 Integration
368
+
369
+ - `<UIResourceRenderer>` with a `type: 'graph'` component routes to
370
+ `<GraphRenderer>` (no UNKNOWN_COMPONENT_TYPE)
371
+ - `validation.ts` rejects empty `nodes` array with code `INVALID_GRAPH`
372
+ - DOMPurify whitelist : N/A (G6 renders to canvas/svg directly, no
373
+ HTML sanitization path)
374
+
375
+ ## 8. Bundle + perf impact
376
+
377
+ - **Apps that don't install `@antv/g6`** : 0 byte added to mcp-ui-solid
378
+ bundle. The dynamic `import('@antv/g6')` resolves to a bundler-handled
379
+ chunk that's never fetched.
380
+ - **Apps that DO install** : ~150-200 KB gzipped fetched on first graph
381
+ mount (asynchronously). Subsequent graphs reuse the loaded module.
382
+ - **Render perf** : G6 v5 canvas renderer handles ~1k nodes / 5k edges
383
+ smoothly. We should add a `RESOURCE_LIMIT_EXCEEDED` check in
384
+ `validateGraphComponent` (max 500 nodes default, configurable via
385
+ `ResourceLimits.maxGraphNodes`) similar to chart's `maxDataPoints`.
386
+ - **Telemetry** : `component:rendered` will report durationMs that
387
+ includes the async G6 init on first mount. May want a separate
388
+ `graph:layout-computed` event later if deposium asks for it (out of
389
+ scope v1).
390
+
391
+ ## 9. Open questions
392
+
393
+ 1. **G6 v5 mindmap layout name** — needs verification during impl.
394
+ Either `'mindmap'` exists as a dedicated layout, or we map it to
395
+ `'compact-box'` with `{ direction: 'RL' }` style options. If the
396
+ latter, the spec enum still says `'mindmap'` and the resolver
397
+ translates internally.
398
+
399
+ 2. **Edge id requirement** — G6 v5 may require an `id` field on edges
400
+ for hover/click handlers. The spec currently doesn't require it
401
+ (uniqueness via `source + target`). If G6 needs unique edge ids,
402
+ we either auto-generate `${source}-${target}-${i}` in the renderer
403
+ OR add `id` to `GraphEdgeSchema` as required. Recommendation : auto-
404
+ generate, keep spec lean.
405
+
406
+ 3. **Default `behaviors` set** — `'drag-canvas'`, `'zoom-canvas'`,
407
+ `'drag-element'` are sane defaults. But `'click-select'` (highlights
408
+ nodes on click) might be better default for MCP-rendered graphs that
409
+ typically expect interaction. Recommendation : enable click-select
410
+ by default, expose `enableSelect: false` opt-out.
411
+
412
+ 4. **`onNodeClick` / `onEdgeClick` payload shape** — what do consumers
413
+ actually want ? G6 click events return `{ itemType, itemId, item }`.
414
+ Should we surface that raw, or normalize to `{ id, label, data }`
415
+ matching our spec node shape ? Recommendation : normalize, hide G6
416
+ internals.
417
+
418
+ 5. **Resource limits** — proposed `maxGraphNodes: 500`,
419
+ `maxGraphEdges: 2000`. Tight enough to keep canvas perf, loose
420
+ enough for real org networks. Configurable via the existing
421
+ `ValidationOptions.limits` extension. **Confirm with deposium** :
422
+ are there cases where they'd want 1000+ nodes ?
423
+
424
+ 6. **Tooltip / hover behavior** — G6 v5 has `tooltip` plugin. Do we
425
+ wire it by default reading `node.label` + `node.data` ? Or leave
426
+ off and let consumers wire via callbacks ? Recommendation : wire
427
+ default tooltip showing label + data summary, opt-out via prop.
428
+
429
+ 7. **Combos (groupings)** — G6 supports node grouping. Real org
430
+ networks could benefit (group by department). Out of scope v1 ?
431
+ Recommendation : ship without combo support, add in v5.9.0 if
432
+ requested.
433
+
434
+ 8. **Server-side rendering of graph snapshots** — completely out of
435
+ scope. G6 needs DOM. If deposium wants graph thumbnails in static
436
+ contexts (PDF export, email summary), they'd render server-side
437
+ via headless Chrome → PNG, not via mcp-ui-solid.
438
+
439
+ ## 10. Cross-stack — what deposium needs to do
440
+
441
+ **Phase 1 (one-shot)** — install peer dep + nothing else :
442
+ ```bash
443
+ pnpm --filter deposium-solid add @antv/g6@^5
444
+ ```
445
+
446
+ **Phase 2 (per use case)** — emit `type: 'graph'` payloads from MCPs :
447
+
448
+ ```ts
449
+ // Org network example
450
+ {
451
+ type: 'graph',
452
+ position: { colStart: 1, colSpan: 12 },
453
+ params: {
454
+ title: 'Réseau d\'acteurs Mathilde',
455
+ nodes: extractedEntities.map(e => ({ id: e.id, label: e.name, type: 'circle' })),
456
+ edges: relationships.map(r => ({ source: r.from, target: r.to, label: r.type })),
457
+ // layout omitted → auto 'force'
458
+ }
459
+ }
460
+
461
+ // Tool dependency DAG example
462
+ {
463
+ type: 'graph',
464
+ position: { colStart: 1, colSpan: 12 },
465
+ params: {
466
+ title: 'Tool execution chain',
467
+ nodes: chain.map(t => ({ id: t.name, label: t.name })),
468
+ edges: chain.flatMap(t => t.delegates.map(d => ({ source: t.name, target: d }))),
469
+ layout: { type: 'dagre', options: { rankdir: 'TB' } },
470
+ height: '500px',
471
+ }
472
+ }
473
+
474
+ // Mindmap example
475
+ {
476
+ type: 'graph',
477
+ position: { colStart: 1, colSpan: 12 },
478
+ params: {
479
+ title: 'Synthèse Mathilde',
480
+ nodes: mindmapNodes,
481
+ edges: mindmapEdges,
482
+ layout: 'mindmap',
483
+ height: '600px',
484
+ }
485
+ }
486
+ ```
487
+
488
+ **Phase 3 (optional)** — wire `onNodeClick` to open the source-doc
489
+ panel for entity nodes (hosts have this hook for citations already).
490
+
491
+ ## 11. Migration / risk
492
+
493
+ - **Backward compatible** : new ComponentType, no existing API changed.
494
+ - **Apps without peer dep** : graph components render the fallback UI
495
+ ("Install @antv/g6 to render graphs") — non-blocking, informative.
496
+ - **Apps with peer dep** : graphs render normally. No behavior change
497
+ on other ComponentTypes.
498
+ - **Spec bump** : `mcp-ui-spec@5.0.4` (additive enum value + new
499
+ schemas).
500
+ - **Solid bump** : `mcp-ui-solid@5.8.0` (minor — new public component
501
+ + ComponentType).
502
+ - **CLI bump** : `mcp-ui-cli@5.0.1` (registers `'graph'` as known
503
+ type for `validate` + `generate-types`). ✅ **confirmed 2026-05-02
504
+ by user** — bundled in the v5.8.0 release wave.
505
+
506
+ ## 12. Decision needed before implementation
507
+
508
+ ### Already resolved (2026-05-02)
509
+
510
+ - ✅ Default layout when edges present + layout omitted = `'force'`
511
+ - ✅ `mcp-ui-cli` registers `'graph'` as known type (bundled in v5.8.0
512
+ release wave)
513
+ - ✅ Brief itself approved (this section)
514
+
515
+ ### Still open — pick defaults to unblock implementation
516
+
517
+ 1. **GO / NO-GO** on the overall design. Can be partial (e.g. ship
518
+ `force + dagre` only in v1, defer `mindmap` until G6 mindmap
519
+ layout name is verified during impl).
520
+ 2. **§9 #3 default behaviors** : default `behaviors` set in G6 ctor.
521
+ Proposed : `['drag-canvas', 'zoom-canvas', 'drag-element',
522
+ 'click-select']`. Click-select gives nodes a "selected" highlight
523
+ on click — useful for chat contexts where users want to focus on
524
+ one entity. Opt-out via `enableSelect: false` prop. **OK to ship
525
+ this default ?**
526
+ 3. **§9 #5 resource limits** : proposed `maxGraphNodes: 500`,
527
+ `maxGraphEdges: 2000`. **Are there real deposium use cases where
528
+ you'd emit > 500 nodes ?** (e.g. SIRENE org dump for a whole
529
+ département). If yes, raise the cap to 1000 or 2000.
530
+ 4. **§9 #6 default tooltip** : G6 has a `tooltip` plugin that shows
531
+ `node.label` + `node.data` summary on hover. Ship enabled by
532
+ default with a `tooltip: false` opt-out, OR ship disabled with
533
+ `tooltip: true` opt-in ? Recommendation : enabled by default
534
+ (typical chat UX expects hover info).
535
+ 5. **§9 #4 onNodeClick payload** : normalize to `{ id, label, data }`
536
+ matching our spec node shape (recommended) OR pass G6 raw event ?
537
+ 6. **Deposium emission examples §10** : are the 3 example payloads in
538
+ §10 realistic ? Specifically, for the **mindmap** case, do you
539
+ already have a way to extract a tree structure from Mathilde's
540
+ reasoning, or is that itself a separate PR ?
541
+
542
+ ## 13. References
543
+
544
+ - `@antv/g6` v5 docs : https://g6.antv.antgroup.com/en/manual
545
+ - `mcp-ui-solid/src/components/ChartJSRenderer.tsx` — the lazy-load
546
+ pattern to mirror
547
+ - `mcp-ui-solid/src/services/validation.ts` — `SPEC_VALIDATORS` +
548
+ `KNOWN_COMPONENT_TYPES` to extend
549
+ - `mcp-ui-solid/CHANGELOG.md` v5.7.0 — last stable shipped
550
+ - Companion brief : `docs/briefs/BRIEF-citations-in-table-cells.md` —
551
+ same data-on-spec / behavior-on-solid split, same peer-optional
552
+ pattern (citations is content not heavy, but the api shape pattern
553
+ matches).
554
+ - **Inspiration** : `docs/briefs/BRIEF-graph-component-g6-INSPIRATION.md`
555
+ — 9 additional use cases gathered 2026-05-02 from a multi-repo
556
+ brainstorm (chat UX / compliance / observability angles). Validates
557
+ the 7-layout enum against a wider intent space and surfaces 3
558
+ commercial angles (RGPD audit, SOC2 incident response, anti-hallu
559
+ forensics) not covered by the seed use cases above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seed-ship/mcp-ui-solid",
3
- "version": "6.7.0",
3
+ "version": "6.8.1",
4
4
  "description": "SolidJS components for rendering MCP-generated UI resources",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -0,0 +1,77 @@
1
+ /**
2
+ * GraphRenderer — G6 v5 config contract test (v6.8.1).
3
+ *
4
+ * This is the gap the 2026-05-30 audit (P0.2) identified: the existing
5
+ * `GraphRenderer.test.tsx` only covers the pure transforms (Mermaid/JSON) and
6
+ * `GraphRenderer.fallback.test.tsx` only covers the peer-missing path. NO test
7
+ * exercised the config actually handed to the G6 `Graph` constructor — which
8
+ * is exactly how the `renderer: 'canvas' | 'svg'` string regression (G6 v5
9
+ * expects a renderer *factory*, not a string → "renderer is not a function")
10
+ * shipped unnoticed, crashing every `type:'graph'`.
11
+ *
12
+ * Rather than drive the full component through jsdom (whose container ref is
13
+ * gated behind an async availability `<Show>`, so `new Graph(...)` is never
14
+ * reached in a test renderer), we assert the contract on the **pure**
15
+ * `buildGraphConfig()` helper the component delegates to. Same guarantee,
16
+ * deterministic, no DOM/canvas needed.
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import { buildGraphConfig } from './GraphRenderer';
21
+ import type { GraphComponentParams } from '@seed-ship/mcp-ui-spec';
22
+
23
+ const fakeContainer = {} as HTMLElement;
24
+
25
+ function params(overrides: Partial<GraphComponentParams> = {}): GraphComponentParams {
26
+ return { nodes: [{ id: 'a' }], ...overrides } as GraphComponentParams;
27
+ }
28
+
29
+ describe('buildGraphConfig — G6 v5 constructor contract', () => {
30
+ it('passes the container and node/edge data through', () => {
31
+ const config = buildGraphConfig(
32
+ params({
33
+ nodes: [{ id: 'a', label: 'A' }, { id: 'b' }],
34
+ edges: [{ source: 'a', target: 'b' }],
35
+ }),
36
+ fakeContainer
37
+ );
38
+ expect(config.container).toBe(fakeContainer);
39
+ expect((config.data as { nodes: unknown[] }).nodes).toHaveLength(2);
40
+ expect((config.data as { edges: unknown[] }).edges).toHaveLength(1);
41
+ });
42
+
43
+ it('does NOT pass a `renderer` field on the default (canvas) path', () => {
44
+ // The bug: `renderer: 'canvas'` (a string) → G6 v5 throws
45
+ // "renderer is not a function". The fix omits it entirely.
46
+ const config = buildGraphConfig(params(), fakeContainer);
47
+ expect('renderer' in config).toBe(false);
48
+ });
49
+
50
+ it('never passes a string `renderer` for rendererPref: "canvas" (regression guard)', () => {
51
+ const config = buildGraphConfig(params({ rendererPref: 'canvas' }), fakeContainer);
52
+ expect(typeof config.renderer).not.toBe('string');
53
+ expect('renderer' in config).toBe(false);
54
+ });
55
+
56
+ it('never passes a string `renderer` for rendererPref: "svg" (degrades to canvas)', () => {
57
+ // svg is not wired yet — it must NOT inject the string 'svg' (the bug).
58
+ // It degrades to the canvas default, i.e. no `renderer` field at all.
59
+ const config = buildGraphConfig(params({ rendererPref: 'svg' }), fakeContainer);
60
+ expect(typeof config.renderer).not.toBe('string');
61
+ expect('renderer' in config).toBe(false);
62
+ });
63
+
64
+ it('resolves a layout and behaviors array', () => {
65
+ const config = buildGraphConfig(
66
+ params({ edges: [{ source: 'a', target: 'a' }] }),
67
+ fakeContainer
68
+ );
69
+ expect((config.layout as { type: string }).type).toBeTruthy();
70
+ expect(Array.isArray(config.behaviors)).toBe(true);
71
+ });
72
+
73
+ it('omits autoFit when fitView is false', () => {
74
+ expect('autoFit' in buildGraphConfig(params(), fakeContainer)).toBe(true);
75
+ expect('autoFit' in buildGraphConfig(params({ fitView: false }), fakeContainer)).toBe(false);
76
+ });
77
+ });