@webmcp-auto-ui/ui 2.5.32 → 2.5.33

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 (80) hide show
  1. package/package.json +15 -2
  2. package/src/agent/DiagnosticModal.svelte +126 -50
  3. package/src/agent/EphemeralBubble.svelte +13 -3
  4. package/src/agent/MCPserversList.svelte +147 -0
  5. package/src/agent/McpConnector.svelte +10 -1
  6. package/src/agent/RecipeBrowser.svelte +384 -0
  7. package/src/agent/RemoteMCPserversDemo.svelte +5 -121
  8. package/src/agent/ToolBrowser.svelte +133 -0
  9. package/src/agent/WebMCPserversList.svelte +2 -0
  10. package/src/agent/useAgentLoop.svelte.ts +396 -0
  11. package/src/base/chat-inline.svelte +64 -0
  12. package/src/base/dialog-content.svelte +3 -1
  13. package/src/components/HeaderControls.svelte +78 -0
  14. package/src/index.ts +13 -35
  15. package/src/stores/canvas.svelte.ts +0 -6
  16. package/src/widgets/SafeImage.svelte +67 -0
  17. package/src/widgets/WidgetRenderer.svelte +153 -78
  18. package/src/widgets/notebook/executors/index.ts +0 -1
  19. package/src/widgets/notebook/executors/sql.ts +32 -182
  20. package/src/widgets/notebook/import-modal-api.ts +237 -0
  21. package/src/widgets/notebook/import-modal.svelte +738 -0
  22. package/src/widgets/notebook/left-pane.ts +1 -1
  23. package/src/widgets/notebook/notebook.svelte +75 -0
  24. package/src/widgets/notebook/notebook.ts +38 -73
  25. package/src/widgets/notebook/prose.ts +6 -3
  26. package/src/widgets/notebook/shared.ts +68 -49
  27. package/src/widgets/rich/cards.svelte +74 -0
  28. package/src/widgets/rich/carousel.svelte +126 -0
  29. package/src/widgets/rich/chart-rich.svelte +221 -0
  30. package/src/widgets/rich/chat-input.svelte +52 -0
  31. package/src/widgets/rich/data-table.svelte +132 -0
  32. package/src/widgets/rich/gallery.svelte +115 -0
  33. package/src/widgets/rich/grid-data.svelte +85 -0
  34. package/src/widgets/rich/hemicycle.svelte +95 -0
  35. package/src/widgets/rich/js-sandbox.svelte +67 -0
  36. package/src/widgets/rich/json-viewer.svelte +82 -0
  37. package/src/widgets/rich/log.svelte +62 -0
  38. package/src/widgets/rich/profile.svelte +91 -0
  39. package/src/widgets/rich/sankey.svelte +73 -0
  40. package/src/widgets/rich/stat-card.svelte +60 -0
  41. package/src/widgets/rich/timeline.svelte +95 -0
  42. package/src/widgets/rich/trombinoscope.svelte +87 -0
  43. package/src/widgets/simple/actions.svelte +36 -0
  44. package/src/widgets/simple/alert.svelte +52 -0
  45. package/src/widgets/simple/chart.svelte +38 -0
  46. package/src/widgets/simple/code.svelte +30 -0
  47. package/src/widgets/simple/kv.svelte +31 -0
  48. package/src/widgets/simple/list.svelte +35 -0
  49. package/src/widgets/simple/stat.svelte +36 -0
  50. package/src/widgets/simple/tags.svelte +34 -0
  51. package/src/widgets/simple/text.svelte +130 -0
  52. package/src/widgets/helpers/safe-image.ts +0 -78
  53. package/src/widgets/notebook/import-modals.ts +0 -560
  54. package/src/widgets/notebook/recipe-browser.ts +0 -350
  55. package/src/widgets/rich/cards.ts +0 -181
  56. package/src/widgets/rich/carousel.ts +0 -319
  57. package/src/widgets/rich/chart-rich.ts +0 -386
  58. package/src/widgets/rich/d3.ts +0 -503
  59. package/src/widgets/rich/data-table.ts +0 -342
  60. package/src/widgets/rich/gallery.ts +0 -350
  61. package/src/widgets/rich/grid-data.ts +0 -173
  62. package/src/widgets/rich/hemicycle.ts +0 -313
  63. package/src/widgets/rich/js-sandbox.ts +0 -122
  64. package/src/widgets/rich/json-viewer.ts +0 -202
  65. package/src/widgets/rich/log.ts +0 -143
  66. package/src/widgets/rich/map.ts +0 -218
  67. package/src/widgets/rich/profile.ts +0 -256
  68. package/src/widgets/rich/sankey.ts +0 -257
  69. package/src/widgets/rich/stat-card.ts +0 -125
  70. package/src/widgets/rich/timeline.ts +0 -179
  71. package/src/widgets/rich/trombinoscope.ts +0 -246
  72. package/src/widgets/simple/actions.ts +0 -89
  73. package/src/widgets/simple/alert.ts +0 -100
  74. package/src/widgets/simple/chart.ts +0 -189
  75. package/src/widgets/simple/code.ts +0 -79
  76. package/src/widgets/simple/kv.ts +0 -68
  77. package/src/widgets/simple/list.ts +0 -89
  78. package/src/widgets/simple/stat.ts +0 -58
  79. package/src/widgets/simple/tags.ts +0 -125
  80. package/src/widgets/simple/text.ts +0 -198
@@ -7,7 +7,7 @@
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
9
  import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
10
- import { openRecipeViewerModal, openToolViewerModal, type ImportedRecipe } from './import-modals.js';
10
+ import { openRecipeViewerModal, openToolViewerModal, type ImportedRecipe } from './import-modal-api.js';
11
11
  import type { NotebookCell, NotebookState, DataServerDescriptor } from './shared.js';
12
12
 
13
13
  export interface LeftPaneHandlers {
@@ -0,0 +1,75 @@
1
+ <svelte:options customElement={{ tag: 'auto-notebook', shadow: 'none' }} />
2
+
3
+ <script lang="ts">
4
+ import { onMount, onDestroy } from 'svelte';
5
+
6
+ // ---------------------------------------------------------------------------
7
+ // Types (mirror shared.ts without importing it — avoids circular deps)
8
+ // ---------------------------------------------------------------------------
9
+
10
+ export interface NotebookCell {
11
+ id: string;
12
+ type: 'md' | 'sql' | 'js';
13
+ content: string;
14
+ status?: 'idle' | 'running' | 'done' | 'error' | 'stale';
15
+ hideSource?: boolean;
16
+ hideResult?: boolean;
17
+ lastResult?: unknown;
18
+ lastMs?: number;
19
+ }
20
+
21
+ export interface NotebookData {
22
+ id?: string;
23
+ title?: string;
24
+ kicker?: string;
25
+ mode?: 'edit' | 'view';
26
+ autoRun?: boolean;
27
+ hideLiveToggle?: boolean;
28
+ cells?: NotebookCell[];
29
+ /** MCP servers for SQL execution (array of {name, url}) */
30
+ servers?: Array<{ name: string; url?: string }>;
31
+ /** Legacy flat field — also accepted */
32
+ mcpUrl?: string;
33
+ }
34
+
35
+ interface Props {
36
+ data?: NotebookData | null;
37
+ }
38
+
39
+ let { data = null }: Props = $props();
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // DOM ref and vanilla cleanup
43
+ // ---------------------------------------------------------------------------
44
+
45
+ let container: HTMLDivElement;
46
+ let cleanup: (() => void) | null = null;
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Mount / unmount
50
+ // ---------------------------------------------------------------------------
51
+
52
+ onMount(async () => {
53
+ if (!container) return;
54
+
55
+ // Lazy-import to avoid pulling 900 lines into the Svelte bundle at parse time.
56
+ const { render } = await import('./notebook.js');
57
+
58
+ const plainData: Record<string, unknown> = data
59
+ ? (data as Record<string, unknown>)
60
+ : {};
61
+
62
+ cleanup = await render(container, plainData);
63
+ });
64
+
65
+ onDestroy(() => {
66
+ cleanup?.();
67
+ cleanup = null;
68
+ });
69
+ </script>
70
+
71
+ <!--
72
+ The vanilla render() writes directly into this div.
73
+ No Svelte template content needed — the notebook manages its own DOM.
74
+ -->
75
+ <div bind:this={container}></div>
@@ -11,7 +11,7 @@ import {
11
11
  addImportedCells, registerExecutor, collectDataServers,
12
12
  autosize, openShareModal, registerHistoryObserver,
13
13
  renderCellLogs,
14
- createPublishControls, autoConnectFrontmatterServers, bootstrapMcpBridge,
14
+ createPublishControls, autoConnectFrontmatterServers,
15
15
  createRuntimeOverlay, effectiveResult, cellRuntimeStatus,
16
16
  lastRefreshedAt, bootstrapLiveRefresh, fmtRelTime, preserveScrollAround,
17
17
  type NotebookState, type NotebookCell, type CellResult, type CellExecContext,
@@ -20,10 +20,11 @@ import {
20
20
  import { renderChart } from './chart-renderer.js';
21
21
  import { dispatchShare } from './share-handlers.js';
22
22
  import { renderProse, mountEditableProse } from './prose.js';
23
- import { openAddMdModal, openAddRecipeModal } from './import-modals.js';
23
+ import { openAddMdModal, openAddRecipeModal } from './import-modal-api.js';
24
24
  import { extractCellsFromRecipe, extractCellFromMarkdown } from './resource-extractor.js';
25
25
  import { mountLeftPane } from './left-pane.js';
26
- import { callToolViaPostMessage, MultiMcpBridge } from '@webmcp-auto-ui/core';
26
+ import { highlightCode } from '../../primitives/markdown-renderer.js';
27
+ import { createSqlExecutor } from './executors/sql.js';
27
28
 
28
29
  export async function render(container: HTMLElement, data: Record<string, unknown>): Promise<() => void> {
29
30
  injectStyles();
@@ -45,7 +46,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
45
46
 
46
47
  // --- register executors -------------------------------------------------
47
48
  registerExecutor(state, 'js', jsExecutor);
48
- registerExecutor(state, 'sql', makeSqlExecutor(data));
49
+ registerExecutor(state, 'sql', createSqlExecutor(() => collectDataServers(data)));
49
50
 
50
51
  container.classList.add('nb-root');
51
52
  container.classList.toggle('nb-view-mode', state.mode === 'view');
@@ -170,15 +171,21 @@ export async function render(container: HTMLElement, data: Record<string, unknow
170
171
  state,
171
172
  data,
172
173
  overlay,
173
- MultiMcpBridgeCtor: MultiMcpBridge as any,
174
174
  onCellChange: (cellId) => {
175
175
  const node = cellsEl.querySelector(`[data-id="${cellId}"]`) as HTMLElement | null;
176
- if (!node) { renderCells(); return; }
176
+ if (!node) {
177
+ const restore = preserveScrollAround(cellsEl);
178
+ renderCells();
179
+ restore();
180
+ return;
181
+ }
177
182
  const idx = state.cells.findIndex((c) => c.id === cellId);
178
183
  if (idx < 0) return;
184
+ const restore = preserveScrollAround(cellsEl);
179
185
  const fresh = renderCell(state.cells[idx], state, overlay, rerender);
180
186
  fresh.addEventListener('focusin', () => { lastActiveIdx = idx; });
181
187
  node.replaceWith(fresh);
188
+ restore();
182
189
  },
183
190
  onTick: () => {
184
191
  renderLiveBadge();
@@ -286,13 +293,10 @@ export async function render(container: HTMLElement, data: Record<string, unknow
286
293
  },
287
294
  });
288
295
 
289
- // Auto-connect data servers declared in the recipe frontmatter (data.servers)
296
+ // Auto-connect data servers declared in the recipe frontmatter (data.servers).
297
+ // The notebook reads MCP state passively from globalThis.__multiMcp (singleton).
290
298
  autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
291
299
 
292
- // Start a persistent MCP bridge so the sql executor can find tools in edit mode
293
- // too (not just when live-refresh is running in view mode).
294
- const mcpBridgeCleanup = bootstrapMcpBridge({ data, MultiMcpBridgeCtor: MultiMcpBridge as any });
295
-
296
300
  // Keep pane servers in sync with canvas changes
297
301
  let canvasUnsub: (() => void) | null = null;
298
302
  try {
@@ -319,7 +323,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
319
323
  pane.destroy();
320
324
  publishCleanup();
321
325
  liveCleanup?.();
322
- mcpBridgeCleanup();
323
326
  };
324
327
  }
325
328
 
@@ -361,54 +364,6 @@ async function jsExecutor(ctx: CellExecContext): Promise<CellResult> {
361
364
  }
362
365
  }
363
366
 
364
- function makeSqlExecutor(data: Record<string, unknown>) {
365
- return async function sqlExecutor(ctx: CellExecContext): Promise<CellResult> {
366
- const start = Date.now();
367
- const sql = ctx.cell.content;
368
- const servers = collectDataServers(data);
369
- const candidates: string[] = [];
370
- for (const srv of servers) {
371
- for (const t of srv.tools ?? []) candidates.push(t.name);
372
- }
373
- const precise = candidates.find((n) => /^.*query_sql$/i.test(n));
374
- const loose = precise ?? candidates.find((n) => /^(query|run|execute)(_sql)?$/i.test(n));
375
- const toolName = precise ?? loose;
376
- if (!toolName) {
377
- return {
378
- ok: false,
379
- error: 'No SQL tool available on connected servers (looked for *query_sql or query/run/execute).',
380
- errorKind: 'schema',
381
- durationMs: Date.now() - start,
382
- };
383
- }
384
- try {
385
- const res: any = await callToolViaPostMessage(toolName, { sql });
386
- const text = res?.content?.find?.((c: any) => c.type === 'text')?.text ?? '';
387
- const durationMs = Date.now() - start;
388
- let parsed: any = null;
389
- try { parsed = JSON.parse(text); } catch { /* not JSON */ }
390
- if (parsed) {
391
- const rows: any[] = Array.isArray(parsed) ? parsed
392
- : Array.isArray(parsed?.rows) ? parsed.rows
393
- : Array.isArray(parsed?.data) ? parsed.data
394
- : Array.isArray(parsed?.results) ? parsed.results
395
- : [];
396
- if (rows.length && rows.every((r) => r && typeof r === 'object')) {
397
- const columns = Array.isArray(parsed?.columns)
398
- ? parsed.columns.map(String)
399
- : Array.from(new Set(rows.flatMap((r) => Object.keys(r))));
400
- return { ok: true, kind: 'table', rows, columns, rowCount: rows.length, durationMs };
401
- }
402
- return { ok: true, kind: 'value', value: parsed, durationMs };
403
- }
404
- if (!text) return { ok: true, kind: 'empty', durationMs };
405
- return { ok: true, kind: 'value', value: text, durationMs };
406
- } catch (err: any) {
407
- return { ok: false, error: String(err?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - start };
408
- }
409
- };
410
- }
411
-
412
367
  // ---------------------------------------------------------------------------
413
368
  // Cell rendering — prose + code share the unified flow, same DnD handle
414
369
  // ---------------------------------------------------------------------------
@@ -482,15 +437,25 @@ function renderCell(cell: NotebookCell, state: NotebookState, overlay: RuntimeOv
482
437
 
483
438
  const body = document.createElement('div');
484
439
  body.className = 'nbe-code-body' + (cell.hideSource ? ' nbe-hidden' : '');
485
- const ta = document.createElement('textarea');
486
- ta.className = 'nb-code-edit';
487
- ta.value = cell.content;
488
- ta.rows = 1;
489
- ta.spellcheck = false;
490
- ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); cell.status = 'stale'; });
491
- body.appendChild(ta);
440
+ if (state.mode === 'view') {
441
+ const lang = cell.type === 'js' ? 'javascript' : cell.type;
442
+ const pre = document.createElement('pre');
443
+ pre.className = 'hljs-pre nb-code-view';
444
+ pre.innerHTML = `<code class="hljs language-${lang}">${highlightCode(cell.content, lang)}</code>`;
445
+ body.appendChild(pre);
446
+ } else {
447
+ const ta = document.createElement('textarea');
448
+ ta.className = 'nb-code-edit';
449
+ ta.value = cell.content;
450
+ ta.rows = 1;
451
+ ta.spellcheck = false;
452
+ ta.addEventListener('input', () => { cell.content = ta.value; autosize(ta); cell.status = 'stale'; });
453
+ body.appendChild(ta);
454
+ requestAnimationFrame(() => requestAnimationFrame(() => autosize(ta)));
455
+ const ro = new ResizeObserver(() => autosize(ta));
456
+ ro.observe(ta);
457
+ }
492
458
  codeCell.appendChild(body);
493
- requestAnimationFrame(() => requestAnimationFrame(() => autosize(ta)));
494
459
 
495
460
  if (!cell.hideResult) {
496
461
  const res = document.createElement('div');
@@ -669,7 +634,7 @@ function injectLayoutStyles(): void {
669
634
  .nbe-kicker-input:focus { border-color: var(--color-border); background: var(--color-bg); color: var(--color-text1); }
670
635
  .nb-root.nb-view-mode .nbe-kicker-input { pointer-events: none; }
671
636
  .nbe-title {
672
- font-family: 'EB Garamond', Georgia, serif;
637
+ font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
673
638
  font-size: 30px; font-weight: 500;
674
639
  margin: 0 0 12px;
675
640
  letter-spacing: -0.01em; line-height: 1.2;
@@ -691,7 +656,7 @@ function injectLayoutStyles(): void {
691
656
  .nbe-del-abs:hover { opacity: 1 !important; }
692
657
 
693
658
  .nbe-prose {
694
- font-family: 'EB Garamond', Georgia, serif;
659
+ font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
695
660
  font-size: 17px; line-height: 1.7;
696
661
  color: var(--color-text1);
697
662
  max-width: 620px;
@@ -700,7 +665,7 @@ function injectLayoutStyles(): void {
700
665
  }
701
666
  .nbe-prose-render h1, .nbe-prose-render h2, .nbe-prose-render h3,
702
667
  .nbe-prose-render h4, .nbe-prose-render h5, .nbe-prose-render h6 {
703
- font-family: 'EB Garamond', Georgia, serif;
668
+ font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
704
669
  font-weight: 600; letter-spacing: -0.01em;
705
670
  margin: 0.6em 0 0.3em;
706
671
  }
@@ -788,7 +753,7 @@ function injectLayoutStyles(): void {
788
753
  font-variant-numeric: tabular-nums;
789
754
  }
790
755
  .nbe-result-table thead th {
791
- font-family: 'EB Garamond', Georgia, serif;
756
+ font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
792
757
  font-size: 12.5px; font-weight: 600;
793
758
  letter-spacing: 0.02em;
794
759
  color: var(--color-text2);
@@ -921,7 +886,7 @@ function injectLayoutStyles(): void {
921
886
  .nb-empty-icon { font-size: 22px; line-height: 1; }
922
887
  .nb-empty-body { flex: 1; min-width: 0; }
923
888
  .nb-empty-title {
924
- font-family: 'EB Garamond', Georgia, serif;
889
+ font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
925
890
  font-weight: 600; font-size: 14px; color: var(--color-text1);
926
891
  }
927
892
  .nb-empty-desc {
@@ -5,6 +5,8 @@
5
5
  // No external dependencies.
6
6
  // ---------------------------------------------------------------------------
7
7
 
8
+ import { highlightCode } from '../../primitives/markdown-renderer.js';
9
+
8
10
  const BLOCK_TAGS = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr', 'br']);
9
11
  const INLINE_TAGS = new Set(['strong', 'em', 'code', 'a', 'mark', 's', 'u']);
10
12
  const ALLOWED_TAGS = new Set([...BLOCK_TAGS, ...INLINE_TAGS]);
@@ -88,8 +90,9 @@ export function renderProse(content: string): string {
88
90
  i++;
89
91
  }
90
92
  i++; // skip closing fence
91
- const cls = lang ? ` class="language-${escapeHtml(lang)}"` : '';
92
- out.push(`<pre><code${cls}>${escapeHtml(code.join('\n'))}</code></pre>`);
93
+ const langKey = (lang || 'plaintext').toLowerCase();
94
+ const highlighted = highlightCode(code.join('\n'), langKey);
95
+ out.push(`<pre class="hljs-pre"><code class="hljs language-${escapeHtml(langKey)}">${highlighted}</code></pre>`);
93
96
  continue;
94
97
  }
95
98
 
@@ -601,7 +604,7 @@ export function renderMarkdownWithInjectButtons(
601
604
  <span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
602
605
  <button type="button" class="nb-md-fence-inject">↳ inject</button>
603
606
  </div>
604
- <pre><code class="language-${escapeHtml(lang)}">${escapeHtml(content)}</code></pre>
607
+ <pre class="hljs-pre"><code class="hljs language-${escapeHtml(lang)}">${highlightCode(content, lang)}</code></pre>
605
608
  `;
606
609
  const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
607
610
  const handler = () => onInject({ lang, content });
@@ -4,6 +4,8 @@
4
4
  // Used by the four notebook layout renderers (compact/workspace/document/editorial)
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
+ import { installMultiMcpBridge } from '@webmcp-auto-ui/core';
8
+
7
9
  export const NB_PUBLISH_HOST: string = (() => {
8
10
  try {
9
11
  const override = (import.meta as any)?.env?.PUBLIC_NB_HOST;
@@ -409,35 +411,30 @@ export function createBridgeSqlRunner(bridge: {
409
411
  * a bridge-backed runner, fire runAutoRefresh. Safe to call from any layout at
410
412
  * mount time when `state.autoRun && state.mode === 'view'`. Returns a cleanup.
411
413
  *
412
- * `MultiMcpBridgeCtor` is injected (via dynamic import from @webmcp-auto-ui/core
413
- * by the caller) to keep this file free of a hard import cycle.
414
+ * Reuses the global singleton bridge on `globalThis.__multiMcp` (installed via
415
+ * `installMultiMcpBridge` from @webmcp-auto-ui/core) never creates a parallel
416
+ * bridge. If no singleton exists yet we install it here, and only the installer
417
+ * is allowed to stop it on cleanup.
414
418
  */
415
419
  export interface BootstrapLiveRefreshOptions {
416
420
  state: NotebookState;
417
421
  data: Record<string, unknown>;
418
422
  overlay: RuntimeOverlay;
419
- MultiMcpBridgeCtor: new (opts: { getCanvas: () => unknown }) => {
420
- start(): void;
421
- stop(): void;
422
- waitForEnabledServers(timeoutMs?: number): Promise<void>;
423
- connectedServers(): string[];
424
- hasServer(name: string): boolean;
425
- callTool(serverName: string, toolName: string, args: unknown): Promise<unknown>;
426
- multiClient: unknown;
427
- };
428
423
  onCellChange?: (cellId: string) => void;
429
424
  onTick?: (overlay: RuntimeOverlay) => void;
430
425
  timeoutMs?: number;
431
426
  }
432
427
 
433
428
  export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
434
- const { state, data, overlay, MultiMcpBridgeCtor, onCellChange, onTick, timeoutMs } = opts;
429
+ const { state, data, overlay, onCellChange, onTick, timeoutMs } = opts;
435
430
  const ac = new AbortController();
431
+ let weCreatedBridge = false;
432
+ let bridgeRef: any = null;
436
433
 
437
434
  void (async () => {
438
435
  try {
439
436
  autoConnectFrontmatterServers(data);
440
- const canvas: unknown = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
437
+ const canvas: any = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
441
438
  .__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
442
439
  if (!canvas) {
443
440
  overlay.error = 'No canvas available';
@@ -445,8 +442,12 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
445
442
  onTick?.(overlay);
446
443
  return;
447
444
  }
448
- const bridge = new MultiMcpBridgeCtor({ getCanvas: () => canvas });
449
- bridge.start();
445
+ const existing = (globalThis as any).__multiMcp;
446
+ const bridge = existing ?? installMultiMcpBridge({ getCanvas: () => canvas });
447
+ weCreatedBridge = !existing;
448
+ bridgeRef = bridge;
449
+ // `installMultiMcpBridge` already starts the bridge; a pre-existing
450
+ // singleton is assumed to be running.
450
451
  await bridge.waitForEnabledServers(timeoutMs ?? 5000);
451
452
 
452
453
  const runner = createBridgeSqlRunner(bridge, () => {
@@ -463,7 +464,16 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
463
464
  }
464
465
  })();
465
466
 
466
- return () => { ac.abort(); };
467
+ return () => {
468
+ ac.abort();
469
+ if (weCreatedBridge && bridgeRef && typeof bridgeRef.stop === 'function') {
470
+ try { bridgeRef.stop(); } catch { /* ignore */ }
471
+ try {
472
+ const g: any = globalThis as any;
473
+ if (g.__multiMcp === bridgeRef) g.__multiMcp = undefined;
474
+ } catch { /* ignore */ }
475
+ }
476
+ };
467
477
  }
468
478
 
469
479
  export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
@@ -855,35 +865,6 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
855
865
  };
856
866
  }
857
867
 
858
- /**
859
- * Start a persistent MCP bridge that connects declared data servers and keeps
860
- * their tool/recipe metadata populated on the canvas store. Independent of
861
- * live-refresh — this runs even in edit mode, so the sql executor can discover
862
- * `*_query_sql` tools as soon as the bridge is connected.
863
- *
864
- * Returns a cleanup function. Caller is expected to start this once at mount
865
- * and call cleanup on unmount.
866
- */
867
- export function bootstrapMcpBridge(opts: {
868
- data: Record<string, unknown>;
869
- MultiMcpBridgeCtor: new (opts: { getCanvas: () => unknown }) => {
870
- start(): void;
871
- stop(): void;
872
- };
873
- }): () => void {
874
- try {
875
- autoConnectFrontmatterServers(opts.data);
876
- const canvas: unknown = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
877
- .__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
878
- if (!canvas) return () => { /* no-op */ };
879
- const bridge = new opts.MultiMcpBridgeCtor({ getCanvas: () => canvas });
880
- bridge.start();
881
- return () => { try { bridge.stop(); } catch { /* ignore */ } };
882
- } catch {
883
- return () => { /* no-op */ };
884
- }
885
- }
886
-
887
868
  /**
888
869
  * Auto-connect any data servers declared in recipe frontmatter (`data.servers`)
889
870
  * to the shared canvas store. No-op / no-throw if the canvas store is absent.
@@ -906,7 +887,10 @@ export function autoConnectFrontmatterServers(
906
887
  if (existing) {
907
888
  if (existing.enabled === false) canvas.setDataServerEnabled?.(name, true);
908
889
  } else {
909
- canvas.addDataServer({ name: String(name), url: url ? String(url) : undefined });
890
+ // Never register a server without a URL downstream connect logic
891
+ // requires it and would silently fail otherwise.
892
+ if (!url) continue;
893
+ canvas.addDataServer({ name: String(name), url: String(url) });
910
894
  }
911
895
  }
912
896
  refresh?.();
@@ -1391,6 +1375,31 @@ textarea.nb-md-edit {
1391
1375
  background: none; border: none; padding: 0; text-decoration: underline;
1392
1376
  }
1393
1377
  .nb-undo-toast-undo:hover { filter: brightness(1.15); }
1378
+
1379
+ /* ─── Syntax highlighting (highlight.js tokens, app-palette-driven) ─── */
1380
+ pre.hljs-pre {
1381
+ background: var(--color-surface2);
1382
+ border: 1px solid var(--color-border);
1383
+ border-radius: 6px;
1384
+ padding: 12px 14px;
1385
+ margin: 0;
1386
+ overflow-x: auto;
1387
+ font-family: var(--font-mono, 'IBM Plex Mono', monospace);
1388
+ font-size: 12.5px;
1389
+ line-height: 1.6;
1390
+ }
1391
+ pre.hljs-pre code.hljs { background: transparent; padding: 0; color: var(--color-text1); display: block; }
1392
+ .nb-code-view { margin: 0; }
1393
+ .hljs-comment, .hljs-quote { color: var(--color-text2); font-style: italic; }
1394
+ .hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-meta-keyword { color: var(--color-accent); font-weight: 500; }
1395
+ .hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute, .hljs-meta-string { color: var(--color-teal); }
1396
+ .hljs-number, .hljs-symbol, .hljs-bullet, .hljs-link { color: var(--color-amber); }
1397
+ .hljs-title, .hljs-section, .hljs-name, .hljs-selector-id, .hljs-selector-class { color: var(--color-accent2, var(--color-accent)); font-weight: 500; }
1398
+ .hljs-variable, .hljs-template-variable, .hljs-attr, .hljs-built_in, .hljs-builtin-name { color: var(--color-accent); }
1399
+ .hljs-tag, .hljs-type, .hljs-params { color: var(--color-text1); }
1400
+ .hljs-emphasis { font-style: italic; }
1401
+ .hljs-strong { font-weight: 600; }
1402
+ .hljs-deletion { color: var(--color-accent2, #fa6d7c); }
1394
1403
  `;
1395
1404
 
1396
1405
  // ---------------------------------------------------------------------------
@@ -1534,9 +1543,19 @@ export function deleteCellWithConfirm(
1534
1543
  const idx = state.cells.findIndex((c) => c.id === cell.id);
1535
1544
  if (idx < 0) return;
1536
1545
  const label = labelFor(cell);
1537
- const snapshotCell: NotebookCell = typeof (globalThis as any).structuredClone === 'function'
1538
- ? (globalThis as any).structuredClone(cell)
1539
- : JSON.parse(JSON.stringify(cell));
1546
+ let snapshotCell: NotebookCell;
1547
+ try { snapshotCell = structuredClone(cell); }
1548
+ catch {
1549
+ snapshotCell = JSON.parse(JSON.stringify(cell, (_k, v) => {
1550
+ if (v === null || v === undefined) return v;
1551
+ const t = typeof v;
1552
+ if (t === 'function' || t === 'symbol' || t === 'bigint') return undefined;
1553
+ if (t !== 'object') return v;
1554
+ if (typeof Node !== 'undefined' && v instanceof Node) return undefined;
1555
+ if (typeof Window !== 'undefined' && v instanceof Window) return undefined;
1556
+ return v;
1557
+ }));
1558
+ }
1540
1559
  // Manually push a history entry so we have a direct reference to remove on undo.
1541
1560
  const entry: HistoryEntry = {
1542
1561
  ts: Date.now(),
@@ -0,0 +1,74 @@
1
+ <svelte:options customElement={{ tag: 'auto-cards', shadow: 'none' }} />
2
+
3
+ <script lang="ts">
4
+ import SafeImage from '../SafeImage.svelte';
5
+
6
+ export interface CardItem {
7
+ title: string;
8
+ description?: string;
9
+ subtitle?: string;
10
+ image?: string;
11
+ tags?: string[];
12
+ href?: string;
13
+ }
14
+
15
+ export interface CardsData {
16
+ title?: string;
17
+ cards?: CardItem[];
18
+ minCardWidth?: string;
19
+ gap?: string;
20
+ emptyMessage?: string;
21
+ }
22
+
23
+ interface Props {
24
+ data?: CardsData | null;
25
+ oncardclick?: (c: CardItem) => void;
26
+ }
27
+
28
+ let { data = {}, oncardclick }: Props = $props();
29
+
30
+ const cards = $derived.by<CardItem[]>(() => {
31
+ if (Array.isArray(data?.cards) && (data!.cards as unknown[]).length) return data!.cards as CardItem[];
32
+ return [];
33
+ });
34
+ </script>
35
+
36
+ <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans">
37
+ {#if data?.title}<h3 class="text-sm font-semibold text-text1 mb-3">{data.title}</h3>{/if}
38
+ {#if cards.length === 0}
39
+ <p class="text-text2 text-sm">{data?.emptyMessage ?? 'No items'}</p>
40
+ {:else}
41
+ <div
42
+ class="grid"
43
+ style="grid-template-columns: repeat(auto-fill, minmax({data?.minCardWidth ?? '180px'}, 1fr)); gap: {data?.gap ?? '1rem'};"
44
+ >
45
+ {#each cards as card}
46
+ <div
47
+ class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick ? 'cursor-pointer' : ''}"
48
+ role={oncardclick ? 'button' : undefined}
49
+ tabindex={oncardclick ? 0 : undefined}
50
+ aria-label={oncardclick ? card.title : undefined}
51
+ title={oncardclick ? 'Double-cliquez pour interagir' : undefined}
52
+ ondblclick={() => oncardclick?.(card)}
53
+ onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); oncardclick?.(card); } }}
54
+ >
55
+ {#if card.image}
56
+ <SafeImage src={card.image} alt={card.title} class="w-full h-32 object-cover" hideOnError />
57
+ {/if}
58
+ <div class="p-3">
59
+ <div class="font-semibold text-sm text-text1 leading-tight">{card.title}</div>
60
+ {#if card.subtitle}<div class="text-xs text-text2 mt-0.5">{card.subtitle}</div>{/if}
61
+ {#if card.description}<div class="text-xs text-text2 mt-1.5 leading-relaxed">{card.description}</div>{/if}
62
+ {#if card.tags?.length}
63
+ <div class="flex gap-1 flex-wrap mt-2">
64
+ {#each card.tags as tag}
65
+ <span class="text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface2 text-text2">{tag}</span>
66
+ {/each}
67
+ </div>
68
+ {/if}
69
+ </div>
70
+ </div>
71
+ {/each}
72
+ </div>
73
+ {/if}
74
+ </div>