@webmcp-auto-ui/ui 2.5.36 → 2.5.37

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,245 @@
1
+ <script lang="ts">
2
+ import { fly } from 'svelte/transition';
3
+ import type { RunTab } from '@webmcp-auto-ui/sdk';
4
+ import { safeStringify } from '@webmcp-auto-ui/sdk';
5
+ import { WidgetRenderer } from '@webmcp-auto-ui/ui';
6
+ import type { WebMcpServer } from '@webmcp-auto-ui/core';
7
+
8
+ interface Props {
9
+ open: boolean;
10
+ runs: RunTab[];
11
+ activeTabId: string | null;
12
+ onclose: () => void;
13
+ onreplay: (tabId: string) => void;
14
+ onselectTab: (tabId: string) => void;
15
+ /** When true, render as an inline panel inside the host modal instead of a floating side panel. */
16
+ inline?: boolean;
17
+ /** Connected WebMCP servers — needed for custom widget renderers (e.g. canvas2d). */
18
+ servers?: WebMcpServer[];
19
+ }
20
+
21
+ let {
22
+ open,
23
+ runs,
24
+ activeTabId,
25
+ onclose,
26
+ onreplay,
27
+ onselectTab,
28
+ inline = false,
29
+ servers = [],
30
+ }: Props = $props();
31
+
32
+ const active = $derived(runs.find((r) => r.id === activeTabId) ?? runs[runs.length - 1] ?? null);
33
+
34
+ let logsOpen = $state(true);
35
+ let copyState = $state<'idle' | 'copied'>('idle');
36
+ let copyTimer: ReturnType<typeof setTimeout> | undefined;
37
+
38
+ function formatTokens(n: number | undefined): string {
39
+ if (n == null) return '—';
40
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
41
+ return `${n}`;
42
+ }
43
+
44
+ async function copyOutput() {
45
+ if (!active) return;
46
+ try {
47
+ const text =
48
+ active.result.status === 'error'
49
+ ? (active.result.error ?? '')
50
+ : safeStringify(active.result.output);
51
+ await navigator.clipboard.writeText(text);
52
+ copyState = 'copied';
53
+ if (copyTimer) clearTimeout(copyTimer);
54
+ copyTimer = setTimeout(() => { copyState = 'idle'; }, 1500);
55
+ } catch {
56
+ /* ignore */
57
+ }
58
+ }
59
+ </script>
60
+
61
+ {#if open}
62
+ <div
63
+ class="run-panel {inline ? 'inline' : 'side'} bg-surface border border-border2 rounded-2xl flex flex-col shadow-2xl overflow-hidden"
64
+ transition:fly={{ x: inline ? 0 : 24, y: inline ? 12 : 0, duration: 200 }}
65
+ >
66
+ <!-- Header -->
67
+ <div class="flex items-center gap-3 px-4 py-3 border-b border-border flex-shrink-0">
68
+ <span class="font-mono text-xs font-bold text-text1 flex-1 truncate">
69
+ {#if active}
70
+ {@html '&#x25B6;'} Run · <span class="text-accent">{active.label}</span>
71
+ {:else}
72
+ Run
73
+ {/if}
74
+ </span>
75
+ <button
76
+ class="text-text2 hover:text-text1 font-mono text-base leading-none transition-colors"
77
+ onclick={onclose}
78
+ title="Close"
79
+ >x</button>
80
+ </div>
81
+
82
+ <!-- Tabs (if multiple) -->
83
+ {#if runs.length > 1}
84
+ <div class="flex items-center gap-1 px-3 py-1.5 border-b border-border overflow-x-auto flex-shrink-0">
85
+ {#each runs as tab (tab.id)}
86
+ <button
87
+ class="font-mono text-[10px] px-2 py-1 rounded border transition-colors whitespace-nowrap
88
+ {tab.id === active?.id
89
+ ? 'border-accent/50 text-accent bg-accent/10'
90
+ : 'border-border2 text-text2 hover:text-text1'}"
91
+ onclick={() => onselectTab(tab.id)}
92
+ >
93
+ {tab.label}
94
+ </button>
95
+ {/each}
96
+ </div>
97
+ {/if}
98
+
99
+ {#if active}
100
+ <!-- Stats row -->
101
+ <div class="flex items-center gap-4 px-4 py-2 border-b border-border flex-shrink-0 font-mono text-[11px]">
102
+ <span class="text-text2">
103
+ {@html '&#x23F1;'} <span class="text-text1">{active.result.durationMs ?? '—'}{active.result.durationMs != null ? 'ms' : ''}</span>
104
+ </span>
105
+ <span class="text-text2">
106
+ {@html '&#x25FC;'} <span class="text-text1">{formatTokens(active.result.tokens)} tok</span>
107
+ </span>
108
+ <span class="ml-auto">
109
+ {#if active.result.status === 'running'}
110
+ <span class="text-accent">{@html '&#x25D0;'} running</span>
111
+ {:else if active.result.status === 'done'}
112
+ <span class="text-teal">{@html '&#x2713;'} done</span>
113
+ {:else if active.result.status === 'error'}
114
+ <span class="text-red-400">! error</span>
115
+ {:else}
116
+ <span class="text-text2">idle</span>
117
+ {/if}
118
+ </span>
119
+ <button
120
+ class="font-mono text-xs h-6 px-2 rounded border border-border2 text-text2 hover:text-text1 transition-colors"
121
+ onclick={() => onreplay(active.id)}
122
+ disabled={active.result.status === 'running'}
123
+ title="Replay"
124
+ >{@html '&#x21BB;'}</button>
125
+ </div>
126
+
127
+ <!-- Body -->
128
+ <div class="flex-1 overflow-y-auto px-4 py-3 flex flex-col gap-3">
129
+ <!-- Output -->
130
+ <div>
131
+ <div class="flex items-center mb-1">
132
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">
133
+ {active.result.status === 'error'
134
+ ? 'Error'
135
+ : (active.result.widgets && active.result.widgets.length > 0) || active.result.widget
136
+ ? `Widget${active.result.widgets && active.result.widgets.length > 1 ? `s (${active.result.widgets.length})` : ''}`
137
+ : 'Output'}
138
+ </span>
139
+ {#if !active.result.widget && !(active.result.widgets && active.result.widgets.length > 0)}
140
+ <button
141
+ class="ml-auto font-mono text-[10px] px-2 py-0.5 rounded border transition-colors
142
+ {copyState === 'copied' ? 'border-teal/40 text-teal' : 'border-border2 text-text2 hover:text-text1'}"
143
+ onclick={copyOutput}
144
+ >
145
+ {copyState === 'copied' ? 'copied' : 'copy'}
146
+ </button>
147
+ {/if}
148
+ </div>
149
+ {#if active.result.status === 'done' && active.result.widgets && active.result.widgets.length > 0}
150
+ <div class="widget-host flex flex-col gap-3">
151
+ {#key active.id}
152
+ {#each active.result.widgets as w, i (i)}
153
+ <WidgetRenderer type={w.name} data={w.params} {servers} />
154
+ {/each}
155
+ {/key}
156
+ </div>
157
+ {:else if active.result.status === 'done' && active.result.widget}
158
+ <div class="widget-host">
159
+ {#key active.id}
160
+ <WidgetRenderer
161
+ type={active.result.widget.name}
162
+ data={active.result.widget.params}
163
+ {servers}
164
+ />
165
+ {/key}
166
+ </div>
167
+ {:else}
168
+ <pre class="output-pre font-mono"><code>{
169
+ active.result.status === 'running'
170
+ ? '...'
171
+ : active.result.status === 'error'
172
+ ? (active.result.error ?? '(unknown error)')
173
+ : safeStringify(active.result.output)
174
+ }</code></pre>
175
+ {/if}
176
+ </div>
177
+
178
+ <!-- Logs -->
179
+ {#if active.result.logs.length > 0}
180
+ <div>
181
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
182
+ <div
183
+ class="flex items-center gap-1 cursor-pointer select-none"
184
+ onclick={() => logsOpen = !logsOpen}
185
+ >
186
+ <span class="text-[9px] font-mono text-text2 uppercase tracking-wider">Logs ({active.result.logs.length})</span>
187
+ <span class="text-[10px] text-text2 ml-1 transition-transform {logsOpen ? 'rotate-90' : ''}">{@html '&#x25B6;'}</span>
188
+ </div>
189
+ {#if logsOpen}
190
+ <div class="mt-1 flex flex-col gap-0.5">
191
+ {#each active.result.logs as entry, i (i)}
192
+ <div class="font-mono text-[10px] text-text2">
193
+ <span class="text-text2/60">[+{entry.t}ms]</span>
194
+ <span class="text-text1">{entry.msg}</span>
195
+ </div>
196
+ {/each}
197
+ </div>
198
+ {/if}
199
+ </div>
200
+ {/if}
201
+ </div>
202
+ {:else}
203
+ <div class="flex-1 flex items-center justify-center">
204
+ <span class="font-mono text-xs text-text2">No run yet</span>
205
+ </div>
206
+ {/if}
207
+ </div>
208
+ {/if}
209
+
210
+ <style>
211
+ .run-panel.side {
212
+ width: 100%;
213
+ height: 100%;
214
+ }
215
+ .run-panel.inline {
216
+ width: 100%;
217
+ max-height: 50vh;
218
+ }
219
+ .widget-host {
220
+ background: #0d1117;
221
+ border: 1px solid rgba(255, 255, 255, 0.08);
222
+ border-radius: 0.375rem;
223
+ padding: 0.6rem 0.7rem;
224
+ min-height: 200px;
225
+ display: flex;
226
+ flex-direction: column;
227
+ }
228
+ .widget-host :global(> *) {
229
+ flex: 1;
230
+ min-height: 0;
231
+ }
232
+ .output-pre {
233
+ background: #0d1117;
234
+ border: 1px solid rgba(255, 255, 255, 0.08);
235
+ border-radius: 0.375rem;
236
+ padding: 0.6rem 0.7rem;
237
+ margin: 0;
238
+ font-size: 0.7rem;
239
+ line-height: 1.5;
240
+ color: rgb(220, 220, 220);
241
+ overflow-x: auto;
242
+ white-space: pre-wrap;
243
+ word-break: break-word;
244
+ }
245
+ </style>
@@ -72,6 +72,7 @@
72
72
  import './notebook/notebook.svelte';
73
73
  // Agent browsers (registered as widgets for widget_display)
74
74
  import '../agent/RecipeBrowser.svelte';
75
+ import '../agent/ToolBrowser.svelte';
75
76
 
76
77
  /** Native widget types served as custom elements (`<auto-${type}>`). */
77
78
  const NATIVE_CUSTOM_ELEMENTS = new Set<string>([
@@ -85,6 +86,7 @@
85
86
  'notebook',
86
87
  // Agent browsers
87
88
  'recipe-browser',
89
+ 'tool-browser',
88
90
  ]);
89
91
 
90
92
  /** A vanilla renderer: returns cleanup or Promise thereof. Still used for
@@ -1,14 +1,16 @@
1
- import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
1
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
2
2
  import { findCodeParamName, buildToolArgs } from '@webmcp-auto-ui/sdk';
3
3
  import type { CellExecutor, CellExecContext, CellResult, DataServerDescriptor, DataServerTool } from '../shared.js';
4
4
 
5
5
  const PATTERN_PRIMARY = /^.*query_sql$/i;
6
6
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
7
7
 
8
- function findSqlTool(servers: DataServerDescriptor[]): DataServerTool | null {
8
+ interface SqlHit { srv: DataServerDescriptor; tool: DataServerTool; }
9
+
10
+ function findSqlTool(servers: DataServerDescriptor[]): SqlHit | null {
9
11
  for (const p of [PATTERN_PRIMARY, PATTERN_FALLBACK]) {
10
12
  for (const srv of servers) {
11
- for (const t of srv.tools ?? []) if (p.test(t.name)) return t;
13
+ for (const t of srv.tools ?? []) if (p.test(t.name)) return { srv, tool: t };
12
14
  }
13
15
  }
14
16
  return null;
@@ -17,10 +19,11 @@ function findSqlTool(servers: DataServerDescriptor[]): DataServerTool | null {
17
19
  export function createSqlExecutor(getServers: () => DataServerDescriptor[]): CellExecutor {
18
20
  return async (ctx: CellExecContext): Promise<CellResult> => {
19
21
  const startedAt = Date.now();
20
- const tool = findSqlTool(getServers());
21
- if (!tool) {
22
+ const hit = findSqlTool(getServers());
23
+ if (!hit) {
22
24
  return { ok: false, error: 'No SQL tool available on connected servers.', errorKind: 'schema', durationMs: Date.now() - startedAt };
23
25
  }
26
+ const { srv, tool } = hit;
24
27
  const sql = (ctx.cell.content ?? '').trim();
25
28
  if (!sql) return { ok: true, kind: 'empty', durationMs: Date.now() - startedAt };
26
29
 
@@ -36,7 +39,7 @@ export function createSqlExecutor(getServers: () => DataServerDescriptor[]): Cel
36
39
 
37
40
  let raw: unknown;
38
41
  try {
39
- raw = await callToolViaPostMessage(tool.name, args);
42
+ raw = await canvas.callTool(srv.name, tool.name, args);
40
43
  } catch (err) {
41
44
  return { ok: false, error: String((err as { message?: unknown })?.message ?? err), errorKind: 'runtime', durationMs: Date.now() - startedAt };
42
45
  }
@@ -55,19 +55,15 @@ type ModalEl = HTMLElement & {
55
55
  let _modal: ModalEl | null = null;
56
56
  let _cleanup: (() => void) | null = null;
57
57
 
58
- function ensureModal(): ModalEl {
58
+ async function ensureModal(): Promise<ModalEl> {
59
59
  if (_modal && document.contains(_modal)) return _modal;
60
60
 
61
- // Register the CE if not already done (Svelte registers it on first import
62
- // but we ensure it here for safety).
63
- if (!customElements.get('auto-import-modal')) {
64
- // Dynamic import triggers CE registration via Svelte's customElement decorator.
65
- // Since this module is already bundled with the CE, it's already registered.
66
- // If somehow not registered, fall back gracefully.
67
- }
68
-
69
61
  const el = document.createElement('auto-import-modal') as ModalEl;
70
62
  document.body.appendChild(el);
63
+ // Svelte 5's connectedCallback is async (awaits a microtask before creating
64
+ // $$c), and exported methods are exposed via getters that read $$c. Yield
65
+ // one microtask so el.openModal/closeModal are defined when we call them.
66
+ await Promise.resolve();
71
67
  _modal = el;
72
68
  return el;
73
69
  }
@@ -84,8 +80,8 @@ export function closeImportModal(): void {
84
80
  // openAddMdModal
85
81
  // ---------------------------------------------------------------------------
86
82
 
87
- export function openAddMdModal(onPick: (content: string) => void): void {
88
- const el = ensureModal();
83
+ export async function openAddMdModal(onPick: (content: string) => void): Promise<void> {
84
+ const el = await ensureModal();
89
85
 
90
86
  // Clean up previous listener
91
87
  _cleanup?.();
@@ -111,8 +107,8 @@ export function openAddMdModal(onPick: (content: string) => void): void {
111
107
  // openAddRecipeModal
112
108
  // ---------------------------------------------------------------------------
113
109
 
114
- export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
115
- const el = ensureModal();
110
+ export async function openAddRecipeModal(opts: AddRecipeModalOptions): Promise<void> {
111
+ const el = await ensureModal();
116
112
 
117
113
  _cleanup?.();
118
114
 
@@ -141,11 +137,11 @@ export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
141
137
  // openRecipeViewerModal
142
138
  // ---------------------------------------------------------------------------
143
139
 
144
- export function openRecipeViewerModal(
140
+ export async function openRecipeViewerModal(
145
141
  recipe: ImportedRecipe,
146
142
  onInjectCell: (cell: NotebookCell) => void,
147
- ): void {
148
- const el = ensureModal();
143
+ ): Promise<void> {
144
+ const el = await ensureModal();
149
145
 
150
146
  _cleanup?.();
151
147
 
@@ -184,11 +180,11 @@ export function openRecipeViewerModal(
184
180
  // openToolViewerModal
185
181
  // ---------------------------------------------------------------------------
186
182
 
187
- export function openToolViewerModal(
183
+ export async function openToolViewerModal(
188
184
  tool: McpToolLike,
189
185
  onInjectCells: (cells: NotebookCell[]) => void,
190
- ): void {
191
- const el = ensureModal();
186
+ ): Promise<void> {
187
+ const el = await ensureModal();
192
188
 
193
189
  _cleanup?.();
194
190
 
@@ -18,7 +18,7 @@
18
18
  // ---------------------------------------------------------------------------
19
19
 
20
20
  import { filterRecipes, sortRecipes, WEBMCP_RECIPES } from '@webmcp-auto-ui/agent';
21
- import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
21
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
22
22
  import { parseBody } from '@webmcp-auto-ui/sdk';
23
23
  import MarkdownView from '../../primitives/MarkdownView.svelte';
24
24
  import RecipeCodeBlock from '../../recipe/RecipeCodeBlock.svelte';
@@ -157,7 +157,7 @@
157
157
  if (d.mcpServers?.length) {
158
158
  const fetches = d.mcpServers.map(async (srv) => {
159
159
  try {
160
- const res: any = await callToolViaPostMessage(`${srv.name}_list_recipes`, {});
160
+ const res: any = await canvas.callTool(srv.name, 'list_recipes', {});
161
161
  const items = extractRecipeItems(res, srv);
162
162
  if (items.length) recipes = [...recipes, ...items];
163
163
  } catch { /* ignore */ }
@@ -239,8 +239,9 @@
239
239
  // Fetch body on demand if missing
240
240
  if (!r.body && r.serverName && r.serverName !== 'webmcp') {
241
241
  try {
242
- const res: any = await callToolViaPostMessage(
243
- `${r.serverName}_get_recipe`,
242
+ const res: any = await canvas.callTool(
243
+ r.serverName,
244
+ 'get_recipe',
244
245
  { name: r.originalName ?? r.name, id: r.id ?? r.name },
245
246
  );
246
247
  r = { ...r, body: extractRecipeBody(res) ?? '' };
@@ -6,7 +6,7 @@
6
6
  // Collapsed by default.
7
7
  // ---------------------------------------------------------------------------
8
8
 
9
- import { callToolViaPostMessage } from '@webmcp-auto-ui/core';
9
+ import { canvas } from '@webmcp-auto-ui/sdk/canvas';
10
10
  import { openRecipeViewerModal, openToolViewerModal, type ImportedRecipe } from './import-modal-api.js';
11
11
  import type { NotebookCell, NotebookState, DataServerDescriptor } from './shared.js';
12
12
 
@@ -73,7 +73,7 @@ export function mountLeftPane(
73
73
  section.innerHTML = `
74
74
  <header class="nb-lp-srv-head">
75
75
  <span class="nb-lp-srv-dot"></span>
76
- <span class="nb-lp-srv-name">${escapeHtml(srv.name)}</span>
76
+ <span class="nb-lp-srv-name">${escapeHtml(srv.serverName ?? srv.label ?? srv.name)}</span>
77
77
  </header>
78
78
  <div class="nb-lp-srv-groups">
79
79
  ${srv.recipes?.length ? `
@@ -134,7 +134,7 @@ export function mountLeftPane(
134
134
  }
135
135
  if (!imported.body) {
136
136
  try {
137
- const res: any = await callToolViaPostMessage(`${srv.name}_get_recipe`, { name: r.name, id: r.name });
137
+ const res: any = await canvas.callTool(srv.name, 'get_recipe', { name: r.name, id: r.name });
138
138
  const text = res?.content?.find?.((c: any) => c.type === 'text')?.text;
139
139
  if (text) {
140
140
  let body = text;
@@ -23,7 +23,6 @@
23
23
  title?: string;
24
24
  mode?: 'edit' | 'view';
25
25
  autoRun?: boolean;
26
- hideLiveToggle?: boolean;
27
26
  cells?: NotebookCell[];
28
27
  /** MCP servers for SQL execution (array of {name, url}) */
29
28
  servers?: Array<{ name: string; url?: string }>;
@@ -36,6 +36,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
36
36
  mode: (data.mode as any) ?? 'edit',
37
37
  cells: data.cells as any,
38
38
  autoRun: (data as any).autoRun === true,
39
+ publishedSlug: (data as any).publishedSlug,
40
+ publishedToken: (data as any).publishedToken,
39
41
  });
40
42
 
41
43
  // Live mode runtime overlay (created lazily). Never mutates state.
@@ -54,7 +56,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
54
56
  <div class="nbe-leftpane-slot"></div>
55
57
  <div class="nbe-shell">
56
58
  <div class="nbe-kicker">
57
- <span class="nbe-live-toggle-slot"></span>
58
59
  <div class="nb-mode-switch" style="margin-left:auto;">
59
60
  <button class="nb-mode-edit nb-on">edit</button>
60
61
  <button class="nb-mode-view">view</button>
@@ -104,24 +105,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
104
105
  });
105
106
  }
106
107
 
107
- const hideLiveToggle = (data as any).hideLiveToggle === true;
108
-
109
- function renderLiveToggle() {
110
- const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
111
- if (hideLiveToggle) { slot.innerHTML = ''; return; }
112
- if (state.mode === 'edit') {
113
- const checked = state.autoRun === true ? 'checked' : '';
114
- slot.innerHTML = `<label class="nbe-live-toggle" title="Re-execute SQL cells against connected servers when this notebook is opened in view mode."><input type="checkbox" ${checked} />Live data</label>`;
115
- const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
116
- cb.addEventListener('change', () => {
117
- state.autoRun = cb.checked;
118
- rerender();
119
- });
120
- } else {
121
- slot.innerHTML = '';
122
- }
123
- }
124
-
125
108
  function renderLiveBadge() {
126
109
  const slot = shell.querySelector('.nbe-live-badge-slot') as HTMLElement;
127
110
  if (state.mode === 'view' && state.autoRun === true) {
@@ -194,7 +177,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
194
177
  function rerender() {
195
178
  const restore = preserveScrollAround(cellsEl);
196
179
  mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
197
- renderLiveToggle();
198
180
  renderLiveBadge();
199
181
  renderEmptyState();
200
182
  renderCells();
@@ -221,9 +203,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
221
203
  rerender();
222
204
  });
223
205
  } else if (which === 'recipe') {
224
- const mcpServers = (Array.isArray((data as any)?.servers) ? (data as any).servers : [])
225
- .map((s: any) => ({ name: String(s?.name ?? ''), url: s?.url ? String(s.url) : undefined }))
226
- .filter((s: any) => s.name);
206
+ const mcpServers = collectDataServers(data)
207
+ .map((s) => ({ name: s.name, url: s.url }));
227
208
  openAddRecipeModal({
228
209
  mcpServers,
229
210
  scope: 'data',
@@ -287,7 +268,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
287
268
  });
288
269
 
289
270
  // Auto-connect data servers declared in the recipe frontmatter (data.servers).
290
- // The notebook reads MCP state passively from globalThis.__multiMcp (singleton).
271
+ // The notebook reads MCP state passively from canvas.dataServers.
291
272
  autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
292
273
 
293
274
  // Keep pane servers in sync with canvas changes
@@ -801,18 +782,6 @@ function injectLayoutStyles(): void {
801
782
  .nbe-toast.nbe-toast-in { opacity: 1; transform: translateX(-50%) translateY(0); }
802
783
  .nbe-toast.nbe-toast-error { color: var(--color-accent2); border-color: var(--color-accent2); }
803
784
 
804
- /* Live mode — discreet toggle in header (edit mode only) */
805
- .nbe-live-toggle {
806
- display: inline-flex; align-items: center; gap: 6px;
807
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
808
- font-size: 10.5px; color: var(--color-text2);
809
- letter-spacing: 0.06em; text-transform: uppercase;
810
- cursor: pointer; user-select: none;
811
- padding: 2px 7px; border: 1px solid var(--color-border); border-radius: 4px;
812
- }
813
- .nbe-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
814
- .nbe-live-toggle input { margin: 0; cursor: pointer; }
815
-
816
785
  /* Title row + Live badge (view mode + autoRun) */
817
786
  .nbe-title-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
818
787
  .nbe-title-row .nbe-title { flex: 1; min-width: 0; }
@@ -5,6 +5,7 @@
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
7
  import { encode, buildShortUrl } from '@webmcp-auto-ui/sdk';
8
+ import { canvasVanilla } from '@webmcp-auto-ui/sdk/canvas-vanilla';
8
9
  import type { NotebookState, NotebookCell } from './shared.js';
9
10
 
10
11
  // ---------------------------------------------------------------------------
@@ -27,8 +28,23 @@ export async function shareAsMarkdown(state: NotebookState): Promise<void> {
27
28
  triggerDownload(blob, sanitizeFilename(state.title || 'notebook') + '.md');
28
29
  }
29
30
 
30
- function serializeToMarkdown(state: NotebookState): string {
31
+ /**
32
+ * Serialize a notebook state as a HyperSkill standalone markdown:
33
+ * ---
34
+ * title: "..."
35
+ * description: "..."
36
+ * servers:
37
+ * - name: foo
38
+ * url: https://...
39
+ * ---
40
+ * <body with ```sql / ```js fenced cells>
41
+ *
42
+ * Re-parsable via @webmcp-auto-ui/core::parseFrontmatter + @webmcp-auto-ui/sdk::parseBody.
43
+ */
44
+ export function serializeToMarkdown(state: NotebookState): string {
45
+ const fm = buildFrontmatter(state);
31
46
  const parts: string[] = [];
47
+ if (fm) parts.push(fm);
32
48
  if (state.title) parts.push(`# ${state.title}`, '');
33
49
  for (const cell of state.cells) {
34
50
  if (cell.type === 'md') {
@@ -46,6 +62,60 @@ function serializeToMarkdown(state: NotebookState): string {
46
62
  return parts.join('\n').trim() + '\n';
47
63
  }
48
64
 
65
+ /**
66
+ * Emit YAML frontmatter for HyperSkill format. Reads connected MCP servers from
67
+ * the canvas store. Returns '' when nothing useful to declare (no title, no
68
+ * description, no servers) — caller can skip prepending.
69
+ */
70
+ function buildFrontmatter(state: NotebookState): string {
71
+ const title = (state.title || '').trim();
72
+ const description = extractDescription(state);
73
+ const servers = collectEnabledServers();
74
+ if (!title && !description && servers.length === 0) return '';
75
+
76
+ const lines: string[] = ['---'];
77
+ if (title) lines.push(`title: ${yamlQuote(title)}`);
78
+ if (description) lines.push(`description: ${yamlQuote(description)}`);
79
+ if (servers.length > 0) {
80
+ lines.push('servers:');
81
+ for (const s of servers) {
82
+ lines.push(` - name: ${yamlQuote(s.name)}`);
83
+ lines.push(` url: ${yamlQuote(s.url)}`);
84
+ }
85
+ }
86
+ lines.push('---', '');
87
+ return lines.join('\n');
88
+ }
89
+
90
+ function extractDescription(state: NotebookState): string {
91
+ for (const cell of state.cells) {
92
+ if (cell.type !== 'md') continue;
93
+ const text = stripHtml(cell.content).trim();
94
+ if (!text) continue;
95
+ // First non-heading line of the first md cell.
96
+ const lines = text.split('\n').map((l) => l.trim()).filter(Boolean);
97
+ const prose = lines.find((l) => !/^#{1,6}\s/.test(l) && !/^[-*]\s/.test(l));
98
+ if (prose) return prose.slice(0, 200);
99
+ }
100
+ return '';
101
+ }
102
+
103
+ function collectEnabledServers(): { name: string; url: string }[] {
104
+ try {
105
+ const servers = canvasVanilla.dataServers ?? [];
106
+ return servers
107
+ .filter((s: any) => s?.enabled && s?.url && s?.name && s.name !== 'autoui' && s.kind !== 'ui' && s.kind !== 'webmcp')
108
+ .map((s: any) => ({ name: String(s.name), url: String(s.url) }));
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /** Quote a YAML scalar safely. Conservative: always double-quote. */
115
+ function yamlQuote(s: string): string {
116
+ return '"' + s.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n') + '"';
117
+ }
118
+
49
119
  function stripHtml(s: string): string {
50
120
  if (typeof document === 'undefined') return s;
51
121
  const d = document.createElement('div');