@webmcp-auto-ui/ui 2.5.35 → 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.
@@ -16,7 +16,6 @@
16
16
  // Side-effect import: registers <auto-import-modal> custom element
17
17
  import './import-modal.svelte';
18
18
 
19
- import { renderMarkdownWithInjectButtons } from './prose.js';
20
19
  import { extractCellsFromRecipe, extractCellsFromTool, extractCellFromFence } from './resource-extractor.js';
21
20
  import type { NotebookCell } from './shared.js';
22
21
  import type { McpToolLike } from './resource-extractor.js';
@@ -56,19 +55,15 @@ type ModalEl = HTMLElement & {
56
55
  let _modal: ModalEl | null = null;
57
56
  let _cleanup: (() => void) | null = null;
58
57
 
59
- function ensureModal(): ModalEl {
58
+ async function ensureModal(): Promise<ModalEl> {
60
59
  if (_modal && document.contains(_modal)) return _modal;
61
60
 
62
- // Register the CE if not already done (Svelte registers it on first import
63
- // but we ensure it here for safety).
64
- if (!customElements.get('auto-import-modal')) {
65
- // Dynamic import triggers CE registration via Svelte's customElement decorator.
66
- // Since this module is already bundled with the CE, it's already registered.
67
- // If somehow not registered, fall back gracefully.
68
- }
69
-
70
61
  const el = document.createElement('auto-import-modal') as ModalEl;
71
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();
72
67
  _modal = el;
73
68
  return el;
74
69
  }
@@ -85,8 +80,8 @@ export function closeImportModal(): void {
85
80
  // openAddMdModal
86
81
  // ---------------------------------------------------------------------------
87
82
 
88
- export function openAddMdModal(onPick: (content: string) => void): void {
89
- const el = ensureModal();
83
+ export async function openAddMdModal(onPick: (content: string) => void): Promise<void> {
84
+ const el = await ensureModal();
90
85
 
91
86
  // Clean up previous listener
92
87
  _cleanup?.();
@@ -112,8 +107,8 @@ export function openAddMdModal(onPick: (content: string) => void): void {
112
107
  // openAddRecipeModal
113
108
  // ---------------------------------------------------------------------------
114
109
 
115
- export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
116
- const el = ensureModal();
110
+ export async function openAddRecipeModal(opts: AddRecipeModalOptions): Promise<void> {
111
+ const el = await ensureModal();
117
112
 
118
113
  _cleanup?.();
119
114
 
@@ -142,17 +137,14 @@ export function openAddRecipeModal(opts: AddRecipeModalOptions): void {
142
137
  // openRecipeViewerModal
143
138
  // ---------------------------------------------------------------------------
144
139
 
145
- export function openRecipeViewerModal(
140
+ export async function openRecipeViewerModal(
146
141
  recipe: ImportedRecipe,
147
142
  onInjectCell: (cell: NotebookCell) => void,
148
- ): void {
149
- const el = ensureModal();
143
+ ): Promise<void> {
144
+ const el = await ensureModal();
150
145
 
151
146
  _cleanup?.();
152
147
 
153
- // We'll hold a reference to the prose renderer's destroy fn.
154
- let proseDestroy: (() => void) | null = null;
155
-
156
148
  const handler = (e: CustomEvent) => {
157
149
  const { action, payload } = e.detail ?? {};
158
150
 
@@ -172,8 +164,6 @@ export function openRecipeViewerModal(
172
164
 
173
165
  if (action === 'inject-all' || action === 'close') {
174
166
  el.removeEventListener('widget:interact', handler as EventListener);
175
- proseDestroy?.();
176
- proseDestroy = null;
177
167
  _cleanup = null;
178
168
  }
179
169
  };
@@ -181,38 +171,20 @@ export function openRecipeViewerModal(
181
171
  el.addEventListener('widget:interact', handler as EventListener);
182
172
  _cleanup = () => {
183
173
  el.removeEventListener('widget:interact', handler as EventListener);
184
- proseDestroy?.();
185
- proseDestroy = null;
186
174
  };
187
175
 
188
176
  el.openModal({ mode: 'recipe-viewer', recipe });
189
-
190
- // After the CE opens, inject the rendered markdown into [data-role="render"].
191
- // requestAnimationFrame ensures Svelte has rendered the modal DOM.
192
- requestAnimationFrame(() => {
193
- const renderTarget = el.querySelector('[data-role="render"]') as HTMLElement | null;
194
- if (!renderTarget) return;
195
- const { root, destroy } = renderMarkdownWithInjectButtons(
196
- recipe.body ?? '',
197
- ({ lang, content }) => {
198
- const cell = extractCellFromFence(lang, content);
199
- onInjectCell(cell);
200
- },
201
- );
202
- renderTarget.appendChild(root);
203
- proseDestroy = destroy;
204
- });
205
177
  }
206
178
 
207
179
  // ---------------------------------------------------------------------------
208
180
  // openToolViewerModal
209
181
  // ---------------------------------------------------------------------------
210
182
 
211
- export function openToolViewerModal(
183
+ export async function openToolViewerModal(
212
184
  tool: McpToolLike,
213
185
  onInjectCells: (cells: NotebookCell[]) => void,
214
- ): void {
215
- const el = ensureModal();
186
+ ): Promise<void> {
187
+ const el = await ensureModal();
216
188
 
217
189
  _cleanup?.();
218
190
 
@@ -18,7 +18,10 @@
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
+ import { parseBody } from '@webmcp-auto-ui/sdk';
23
+ import MarkdownView from '../../primitives/MarkdownView.svelte';
24
+ import RecipeCodeBlock from '../../recipe/RecipeCodeBlock.svelte';
22
25
 
23
26
  // ---------------------------------------------------------------------------
24
27
  // Types (inlined to avoid import-modals.ts cycle)
@@ -90,6 +93,10 @@
90
93
  const mode = $derived(data?.mode ?? 'add-md');
91
94
  const recipe = $derived(data?.recipe);
92
95
  const tool = $derived(data?.tool);
96
+ const recipeSegments = $derived(parseBody(recipe?.body ?? ''));
97
+ const recipeCellCount = $derived(
98
+ recipeSegments.length + ((recipe?.name || recipe?.description) ? 1 : 0),
99
+ );
93
100
 
94
101
  // ---------------------------------------------------------------------------
95
102
  // Public API — called by wrapper functions
@@ -150,7 +157,7 @@
150
157
  if (d.mcpServers?.length) {
151
158
  const fetches = d.mcpServers.map(async (srv) => {
152
159
  try {
153
- const res: any = await callToolViaPostMessage(`${srv.name}_list_recipes`, {});
160
+ const res: any = await canvas.callTool(srv.name, 'list_recipes', {});
154
161
  const items = extractRecipeItems(res, srv);
155
162
  if (items.length) recipes = [...recipes, ...items];
156
163
  } catch { /* ignore */ }
@@ -232,8 +239,9 @@
232
239
  // Fetch body on demand if missing
233
240
  if (!r.body && r.serverName && r.serverName !== 'webmcp') {
234
241
  try {
235
- const res: any = await callToolViaPostMessage(
236
- `${r.serverName}_get_recipe`,
242
+ const res: any = await canvas.callTool(
243
+ r.serverName,
244
+ 'get_recipe',
237
245
  { name: r.originalName ?? r.name, id: r.id ?? r.name },
238
246
  );
239
247
  r = { ...r, body: extractRecipeBody(res) ?? '' };
@@ -255,25 +263,13 @@
255
263
  // Handlers — recipe-viewer
256
264
  // ---------------------------------------------------------------------------
257
265
 
258
- // prose rendering is done server-side for recipe-viewer; we re-use the
259
- // renderMarkdownWithInjectButtons helper injected via slot or data.
260
- // Since this is a CE, we render as raw HTML via {@html} after sanitizing.
261
- // The inject buttons are wired via event delegation on the section.
262
-
263
266
  function handleInjectAll() {
264
- // extractCellsFromRecipe is a pure function from resource-extractor.ts —
265
- // but import would create a cycle ui<->notebook. We emit the raw body
266
- // and let the consumer (notebook.ts) call extractCellsFromRecipe.
267
+ // Notebook owns extractCellsFromRecipe to avoid a ui<->notebook cycle.
267
268
  emitInteract('inject-all', { recipe: recipe });
268
269
  closeModal();
269
270
  }
270
271
 
271
- function handleInjectFence(e: Event) {
272
- // Delegated click on .nb-md-fence-inject buttons rendered via {@html}
273
- const btn = (e.target as HTMLElement).closest('[data-fence-inject]') as HTMLElement | null;
274
- if (!btn) return;
275
- const lang = btn.dataset.lang ?? '';
276
- const content = btn.dataset.content ?? '';
272
+ function handleInjectFence(content: string, lang: string) {
277
273
  emitInteract('inject-fence', { lang, content });
278
274
  }
279
275
 
@@ -426,12 +422,30 @@
426
422
  {#if recipe?.description}<p>{recipe.description}</p>{/if}
427
423
  {#if recipe?.serverName}<span class="nb-imp-recipe-srv">{recipe.serverName}</span>{/if}
428
424
  </div>
429
- <!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
430
- <section class="nb-imp-body nb-imp-body-recipe" data-role="render" onclick={handleInjectFence}>
431
- <!-- Content injected imperatively by notebook.ts via renderMarkdownWithInjectButtons -->
425
+ <section class="nb-imp-body nb-imp-body-recipe">
426
+ {#each recipeSegments as seg, i (i)}
427
+ {#if seg.type === 'markdown'}
428
+ <MarkdownView source={seg.content} />
429
+ {:else}
430
+ <RecipeCodeBlock
431
+ code={seg.content}
432
+ lang={seg.lang ?? 'text'}
433
+ actions={[{
434
+ icon: '+',
435
+ label: 'Inject as cell',
436
+ onclick: handleInjectFence,
437
+ }]}
438
+ />
439
+ {/if}
440
+ {/each}
432
441
  </section>
433
442
  <footer class="nb-imp-foot">
434
- <button type="button" class="nb-imp-btn" onclick={handleInjectAll}>Inject all cells</button>
443
+ <button
444
+ type="button"
445
+ class="nb-imp-btn nb-imp-primary"
446
+ onclick={handleInjectAll}
447
+ disabled={recipeCellCount === 0}
448
+ >Inject all cells{recipeCellCount > 0 ? ` (${recipeCellCount})` : ''}</button>
435
449
  </footer>
436
450
 
437
451
  <!-- ================================================================ -->
@@ -691,48 +705,4 @@
691
705
  font-size: 11.5px;
692
706
  }
693
707
 
694
- /* ---- Styles for prose rendered inside recipe-viewer (delegated via {@html} in consumers) ---- */
695
- :global(.nb-md-render) { font-size: 13px; line-height: 1.5; }
696
- :global(.nb-md-render h1),
697
- :global(.nb-md-render h2),
698
- :global(.nb-md-render h3) { margin: 12px 0 6px; }
699
- :global(.nb-md-render p) { margin: 6px 0; }
700
- :global(.nb-md-render ul),
701
- :global(.nb-md-render ol) { margin: 6px 0 6px 20px; }
702
- :global(.nb-md-render pre) {
703
- background: var(--color-surface2, #f4f4f5);
704
- padding: 10px 12px;
705
- border-radius: 6px;
706
- overflow-x: auto;
707
- font-size: 12px;
708
- }
709
- :global(.nb-md-render code) { font-family: var(--font-mono, monospace); font-size: 12px; }
710
- :global(.nb-md-fence) {
711
- border: 1px solid var(--color-border, #e4e4e7);
712
- border-radius: 8px;
713
- margin: 10px 0;
714
- overflow: hidden;
715
- }
716
- :global(.nb-md-fence-head) {
717
- display: flex;
718
- align-items: center;
719
- gap: 10px;
720
- padding: 6px 10px;
721
- background: var(--color-surface2, #f4f4f5);
722
- font-size: 11px;
723
- color: var(--color-text2, #666);
724
- border-bottom: 1px solid var(--color-border, #e4e4e7);
725
- }
726
- :global(.nb-md-fence-lang) { font-family: monospace; flex: 1; }
727
- :global(.nb-md-fence-inject) {
728
- background: var(--color-accent, #6a55ff);
729
- color: #fff;
730
- border: 0;
731
- border-radius: 4px;
732
- padding: 3px 9px;
733
- font-size: 11px;
734
- cursor: pointer;
735
- }
736
- :global(.nb-md-fence-inject:hover) { filter: brightness(1.08); }
737
- :global(.nb-md-fence pre) { margin: 0; border-radius: 0; background: transparent; }
738
708
  </style>
@@ -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,8 +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>
77
- <span class="nb-lp-srv-meta">${(srv.recipes?.length ?? 0)} recipes · ${(srv.tools?.length ?? 0)} tools</span>
76
+ <span class="nb-lp-srv-name">${escapeHtml(srv.serverName ?? srv.label ?? srv.name)}</span>
78
77
  </header>
79
78
  <div class="nb-lp-srv-groups">
80
79
  ${srv.recipes?.length ? `
@@ -135,25 +134,45 @@ export function mountLeftPane(
135
134
  }
136
135
  if (!imported.body) {
137
136
  try {
138
- 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 });
139
138
  const text = res?.content?.find?.((c: any) => c.type === 'text')?.text;
140
139
  if (text) {
141
140
  let body = text;
142
141
  try {
143
142
  const parsed = JSON.parse(text);
144
- // Recipe servers return either { content: "..." } (legacy) or
145
- // { name, description, body, ... } (autoui-style). Pick whichever
146
- // string field carries the markdown body, in priority order.
147
143
  if (parsed && typeof parsed === 'object') {
148
- if (typeof parsed.body === 'string') body = parsed.body;
149
- else if (typeof parsed.content === 'string') body = parsed.content;
150
- else if (typeof parsed.markdown === 'string') body = parsed.markdown;
144
+ const candidates = [
145
+ parsed.body,
146
+ parsed.content,
147
+ parsed.markdown,
148
+ parsed.recipe?.body,
149
+ parsed.recipe?.content,
150
+ parsed.recipe?.markdown,
151
+ parsed.result?.body,
152
+ parsed.result?.content,
153
+ parsed.data?.body,
154
+ parsed.data?.content,
155
+ ];
156
+ const hit = candidates.find((c) => typeof c === 'string' && c.trim().length > 0);
157
+ if (hit) {
158
+ body = hit;
159
+ } else {
160
+ console.warn(
161
+ `[notebook] get_recipe(${srv.name}/${r.name}): no recognized body key in parsed JSON. Keys:`,
162
+ Object.keys(parsed),
163
+ );
164
+ }
151
165
  }
152
166
  } catch { /* not JSON, use raw text */ }
153
167
  imported.body = body;
154
168
  recipeBodyCache.set(key, body);
155
169
  }
156
- } catch { /* pass empty body to viewer */ }
170
+ } catch (err) {
171
+ console.warn(`[notebook] get_recipe(${srv.name}/${r.name}) failed:`, err);
172
+ }
173
+ }
174
+ if (!imported.body) {
175
+ imported.body = `> ⚠ Failed to load recipe body from \`${srv.name}\`. The server returned no usable content for \`${r.name}\`.`;
157
176
  }
158
177
  openRecipeViewerModal(imported, (cell) => handlers.onInjectCells([cell]));
159
178
  }
@@ -236,7 +255,6 @@ function injectLeftPaneStyles() {
236
255
  flex-shrink: 0;
237
256
  }
238
257
  .nb-lp-srv-name { font-weight: 600; color: var(--color-text1, #111); }
239
- .nb-lp-srv-meta { margin-left: auto; font-family: monospace; font-size: 10.5px; }
240
258
  .nb-lp-group > summary {
241
259
  cursor: pointer; padding: 4px 2px; font-size: 11px;
242
260
  color: var(--color-text2, #666); font-family: monospace;
@@ -21,10 +21,8 @@
21
21
  export interface NotebookData {
22
22
  id?: string;
23
23
  title?: string;
24
- kicker?: string;
25
24
  mode?: 'edit' | 'view';
26
25
  autoRun?: boolean;
27
- hideLiveToggle?: boolean;
28
26
  cells?: NotebookCell[];
29
27
  /** MCP servers for SQL execution (array of {name, url}) */
30
28
  servers?: Array<{ name: string; url?: string }>;
@@ -35,10 +35,10 @@ export async function render(container: HTMLElement, data: Record<string, unknow
35
35
  title: data.title as string ?? 'Untitled notebook',
36
36
  mode: (data.mode as any) ?? 'edit',
37
37
  cells: data.cells as any,
38
- kicker: (data.kicker as string) ?? undefined,
39
38
  autoRun: (data as any).autoRun === true,
39
+ publishedSlug: (data as any).publishedSlug,
40
+ publishedToken: (data as any).publishedToken,
40
41
  });
41
- if (!state.kicker) state.kicker = (data.kicker as string) ?? 'untitled';
42
42
 
43
43
  // Live mode runtime overlay (created lazily). Never mutates state.
44
44
  let overlay: RuntimeOverlay | null = null;
@@ -56,8 +56,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
56
56
  <div class="nbe-leftpane-slot"></div>
57
57
  <div class="nbe-shell">
58
58
  <div class="nbe-kicker">
59
- <input class="nbe-kicker-input" value="${escapeAttr(state.kicker || '')}" placeholder="kicker…">
60
- <span class="nbe-live-toggle-slot"></span>
61
59
  <div class="nb-mode-switch" style="margin-left:auto;">
62
60
  <button class="nb-mode-edit nb-on">edit</button>
63
61
  <button class="nb-mode-view">view</button>
@@ -107,24 +105,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
107
105
  });
108
106
  }
109
107
 
110
- const hideLiveToggle = (data as any).hideLiveToggle === true;
111
-
112
- function renderLiveToggle() {
113
- const slot = shell.querySelector('.nbe-live-toggle-slot') as HTMLElement;
114
- if (hideLiveToggle) { slot.innerHTML = ''; return; }
115
- if (state.mode === 'edit') {
116
- const checked = state.autoRun === true ? 'checked' : '';
117
- 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>`;
118
- const cb = slot.querySelector('input[type=checkbox]') as HTMLInputElement;
119
- cb.addEventListener('change', () => {
120
- state.autoRun = cb.checked;
121
- rerender();
122
- });
123
- } else {
124
- slot.innerHTML = '';
125
- }
126
- }
127
-
128
108
  function renderLiveBadge() {
129
109
  const slot = shell.querySelector('.nbe-live-badge-slot') as HTMLElement;
130
110
  if (state.mode === 'view' && state.autoRun === true) {
@@ -197,7 +177,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
197
177
  function rerender() {
198
178
  const restore = preserveScrollAround(cellsEl);
199
179
  mountHistoryPanel(historyPanel, state, (snap) => { restoreCellFromSnapshot(state, snap); rerender(); });
200
- renderLiveToggle();
201
180
  renderLiveBadge();
202
181
  renderEmptyState();
203
182
  renderCells();
@@ -224,9 +203,8 @@ export async function render(container: HTMLElement, data: Record<string, unknow
224
203
  rerender();
225
204
  });
226
205
  } else if (which === 'recipe') {
227
- const mcpServers = (Array.isArray((data as any)?.servers) ? (data as any).servers : [])
228
- .map((s: any) => ({ name: String(s?.name ?? ''), url: s?.url ? String(s.url) : undefined }))
229
- .filter((s: any) => s.name);
206
+ const mcpServers = collectDataServers(data)
207
+ .map((s) => ({ name: s.name, url: s.url }));
230
208
  openAddRecipeModal({
231
209
  mcpServers,
232
210
  scope: 'data',
@@ -259,10 +237,6 @@ export async function render(container: HTMLElement, data: Record<string, unknow
259
237
  footerSlot: shell.querySelector('.nbe-publish-footer-slot') as HTMLElement,
260
238
  onPublished: () => rerender(),
261
239
  });
262
- (shell.querySelector('.nbe-kicker-input') as HTMLInputElement).addEventListener('input', (e) => {
263
- state.kicker = (e.target as HTMLInputElement).value;
264
- state.lastEditAt = Date.now();
265
- });
266
240
  (shell.querySelector('.nbe-title') as HTMLInputElement).addEventListener('input', (e) => {
267
241
  state.title = (e.target as HTMLInputElement).value;
268
242
  state.lastEditAt = Date.now();
@@ -294,7 +268,7 @@ export async function render(container: HTMLElement, data: Record<string, unknow
294
268
  });
295
269
 
296
270
  // Auto-connect data servers declared in the recipe frontmatter (data.servers).
297
- // The notebook reads MCP state passively from globalThis.__multiMcp (singleton).
271
+ // The notebook reads MCP state passively from canvas.dataServers.
298
272
  autoConnectFrontmatterServers(data, () => pane.setServers(collectDataServers(data)));
299
273
 
300
274
  // Keep pane servers in sync with canvas changes
@@ -623,16 +597,6 @@ function injectLayoutStyles(): void {
623
597
  letter-spacing: 0.1em; text-transform: uppercase;
624
598
  margin-bottom: 14px;
625
599
  }
626
- .nbe-kicker-input {
627
- flex: 0 0 auto; min-width: 120px;
628
- background: transparent; border: 1px dashed transparent;
629
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
630
- font-size: 11px; color: var(--color-text2);
631
- letter-spacing: 0.1em; text-transform: uppercase;
632
- padding: 2px 4px; border-radius: 3px; outline: none;
633
- }
634
- .nbe-kicker-input:focus { border-color: var(--color-border); background: var(--color-bg); color: var(--color-text1); }
635
- .nb-root.nb-view-mode .nbe-kicker-input { pointer-events: none; }
636
600
  .nbe-title {
637
601
  font-family: var(--font-serif, 'EB Garamond', Georgia, serif);
638
602
  font-size: 30px; font-weight: 500;
@@ -650,9 +614,13 @@ function injectLayoutStyles(): void {
650
614
  .nbe-handle { position: absolute; left: 0; top: 6px; }
651
615
  .nbe-del-abs {
652
616
  position: absolute; top: 4px; right: 4px;
653
- opacity: 0; transition: opacity 0.15s;
617
+ opacity: 0.65; transition: opacity 0.15s;
618
+ width: 26px; height: 26px;
619
+ font-size: 16px; line-height: 1;
620
+ display: inline-flex; align-items: center; justify-content: center;
621
+ padding: 0;
622
+ z-index: 2;
654
623
  }
655
- .nbe-cell:hover .nbe-del-abs { opacity: 0.5; }
656
624
  .nbe-del-abs:hover { opacity: 1 !important; }
657
625
 
658
626
  .nbe-prose {
@@ -711,7 +679,7 @@ function injectLayoutStyles(): void {
711
679
  }
712
680
  .nbe-cell-head {
713
681
  display: flex; align-items: center; gap: 8px;
714
- padding: 7px 12px;
682
+ padding: 7px 44px 7px 12px;
715
683
  border-bottom: 1px solid var(--color-border);
716
684
  font-family: var(--font-mono, 'IBM Plex Mono', monospace);
717
685
  font-size: 10px; color: var(--color-text2);
@@ -814,18 +782,6 @@ function injectLayoutStyles(): void {
814
782
  .nbe-toast.nbe-toast-in { opacity: 1; transform: translateX(-50%) translateY(0); }
815
783
  .nbe-toast.nbe-toast-error { color: var(--color-accent2); border-color: var(--color-accent2); }
816
784
 
817
- /* Live mode — discreet toggle in header (edit mode only) */
818
- .nbe-live-toggle {
819
- display: inline-flex; align-items: center; gap: 6px;
820
- font-family: var(--font-mono, 'IBM Plex Mono', monospace);
821
- font-size: 10.5px; color: var(--color-text2);
822
- letter-spacing: 0.06em; text-transform: uppercase;
823
- cursor: pointer; user-select: none;
824
- padding: 2px 7px; border: 1px solid var(--color-border); border-radius: 4px;
825
- }
826
- .nbe-live-toggle:hover { color: var(--color-text1); border-color: var(--color-border2); }
827
- .nbe-live-toggle input { margin: 0; cursor: pointer; }
828
-
829
785
  /* Title row + Live badge (view mode + autoRun) */
830
786
  .nbe-title-row { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; }
831
787
  .nbe-title-row .nbe-title { flex: 1; min-width: 0; }
@@ -1,7 +1,6 @@
1
1
  // @ts-nocheck
2
2
  // ---------------------------------------------------------------------------
3
3
  // Lightweight markdown renderer + allowlist sanitizer for notebook prose cells.
4
- // Also: renderMarkdownWithInjectButtons — used by recipe viewer modal.
5
4
  // No external dependencies.
6
5
  // ---------------------------------------------------------------------------
7
6
 
@@ -546,80 +545,3 @@ function updateEmptyState(host: HTMLElement): void {
546
545
  }
547
546
  }
548
547
 
549
- // ---------------------------------------------------------------------------
550
- // Renderer with inject buttons — used by recipe viewer modal.
551
- // Each fenced code block gets an "↳ inject" button next to it.
552
- // ---------------------------------------------------------------------------
553
-
554
- export interface InjectFenceEvent {
555
- lang: string;
556
- content: string;
557
- }
558
-
559
- /**
560
- * Render markdown into a container. Each fenced code block is rendered with
561
- * an "↳ inject" button; clicking it calls onInject({lang, content}).
562
- * Returns a cleanup function.
563
- */
564
- export function renderMarkdownWithInjectButtons(
565
- body: string,
566
- onInject: (e: InjectFenceEvent) => void,
567
- ): { root: HTMLElement; destroy: () => void } {
568
- const root = document.createElement('div');
569
- root.className = 'nb-md-render';
570
-
571
- const lines = (body || '').replace(/\r\n/g, '\n').split('\n');
572
- const buf: string[] = [];
573
- let i = 0;
574
- const cleanups: Array<() => void> = [];
575
-
576
- const flushProse = () => {
577
- if (buf.length) {
578
- const chunk = buf.join('\n');
579
- const p = document.createElement('div');
580
- p.innerHTML = renderProse(chunk);
581
- root.appendChild(p);
582
- buf.length = 0;
583
- }
584
- };
585
-
586
- while (i < lines.length) {
587
- const line = lines[i];
588
- if (/^```/.test(line)) {
589
- flushProse();
590
- const lang = line.replace(/^```/, '').trim().toLowerCase() || 'text';
591
- const code: string[] = [];
592
- i++;
593
- while (i < lines.length && !/^```/.test(lines[i])) {
594
- code.push(lines[i]);
595
- i++;
596
- }
597
- i++; // closing fence
598
- const content = code.join('\n');
599
-
600
- const block = document.createElement('div');
601
- block.className = 'nb-md-fence';
602
- block.innerHTML = `
603
- <div class="nb-md-fence-head">
604
- <span class="nb-md-fence-lang">${escapeHtml(lang)}</span>
605
- <button type="button" class="nb-md-fence-inject">↳ inject</button>
606
- </div>
607
- <pre class="hljs-pre"><code class="hljs language-${escapeHtml(lang)}">${highlightCode(content, lang)}</code></pre>
608
- `;
609
- const btn = block.querySelector('.nb-md-fence-inject') as HTMLButtonElement;
610
- const handler = () => onInject({ lang, content });
611
- btn.addEventListener('click', handler);
612
- cleanups.push(() => btn.removeEventListener('click', handler));
613
- root.appendChild(block);
614
- continue;
615
- }
616
- buf.push(line);
617
- i++;
618
- }
619
- flushProse();
620
-
621
- return {
622
- root,
623
- destroy: () => cleanups.forEach((f) => f()),
624
- };
625
- }
@@ -11,9 +11,6 @@ schema:
11
11
  mode:
12
12
  type: string
13
13
  enum: [edit, view]
14
- kicker:
15
- type: string
16
- description: Small uppercase label above the title (e.g. "analysis", "memo", "brief"). Editable inline. Defaults to "untitled".
17
14
  hideLiveToggle:
18
15
  type: boolean
19
16
  default: false
@@ -52,7 +49,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
52
49
  ```
53
50
  widget_display({name: "notebook", params: {
54
51
  title: "Q3 observations",
55
- kicker: "memo",
56
52
  cells: [
57
53
  {type: "md", content: "This memo covers the highlights of last quarter."},
58
54
  {type: "md", content: "We first look at revenue, then at churn."},
@@ -74,7 +70,6 @@ The distinguishing feature: prose paragraphs and code cells share a single order
74
70
  ## Notes
75
71
 
76
72
  - The serif font (EB Garamond, with Georgia fallback) applies only to prose content inside this widget — it signals "publication" the moment the user sees it.
77
- - The **kicker** above the title ("analysis", "memo", "internal") is editable inline — click to rename. Keep it short.
78
73
  - Prose cells are rendered via an HTML-sanitizing markdown pipeline: markdown syntax is resolved, unsafe tags are stripped (XSS closed), `<mark>` and other editorial tags are preserved.
79
74
  - The footer exposes a single `share` button.
80
75
  - Run / Stop controls are at the left of each code cell's header, same as the other notebook layouts.
@@ -112,7 +107,6 @@ An editorial piece earns its weight when the prose is anchored to real material.
112
107
  name: 'notebook',
113
108
  params: {
114
109
  title: '...',
115
- kicker: 'memo',
116
110
  cells: [...],
117
111
  servers: [{ name: 'tricoteuses', url: 'https://...', kind: 'data' }]
118
112
  }
@@ -26,6 +26,23 @@ const LANG_TO_TYPE: Record<string, CellType> = {
26
26
  * whenever such a fence is mapped so the user gets a hint. */
27
27
  const TS_LIKE_LANGS = new Set(['ts', 'typescript']);
28
28
 
29
+ /**
30
+ * Peel a leading `-- @meta {...}` (SQL) or `// @meta {...}` (JS) line from the
31
+ * cell content and return the parsed JSON args plus the remaining content.
32
+ * Round-trip with serializeCellMeta in share-handlers.ts.
33
+ */
34
+ export function peelMetaComment(content: string): { content: string; args?: Record<string, unknown> } {
35
+ const m = /^[ \t]*(?:--|\/\/)[ \t]*@meta[ \t]+(\{[\s\S]*?\})[ \t]*\r?\n?/.exec(content);
36
+ if (!m) return { content };
37
+ try {
38
+ const parsed = JSON.parse(m[1]);
39
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
40
+ return { content: content.slice(m[0].length), args: parsed as Record<string, unknown> };
41
+ }
42
+ } catch { /* invalid JSON → ignore, keep line in content */ }
43
+ return { content };
44
+ }
45
+
29
46
  export function fenceLangToCellType(lang: string): CellType | null {
30
47
  const key = (lang || '').toLowerCase().trim();
31
48
  if (TS_LIKE_LANGS.has(key)) {
@@ -45,7 +62,10 @@ export function fenceLangToCellType(lang: string): CellType | null {
45
62
  export function extractCellFromFence(lang: string, content: string): NotebookCell {
46
63
  const cellType = fenceLangToCellType(lang);
47
64
  if (cellType) {
48
- return { id: uid(), type: cellType, content: content.trim(), hideSource: false, hideResult: false };
65
+ const { content: stripped, args } = peelMetaComment(content.trim());
66
+ const cell: NotebookCell = { id: uid(), type: cellType, content: stripped, hideSource: false, hideResult: false };
67
+ if (args) cell.args = args;
68
+ return cell;
49
69
  }
50
70
  // Detect pseudo-code MCP tool calls like: query_sql({sql: "..."})
51
71
  // Only attempted when the fence language is unknown/text (cellType === null).