@webmcp-auto-ui/ui 2.5.36 → 2.5.38

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.
@@ -4,7 +4,7 @@
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';
7
+ import { serializeToMarkdown } from './share-handlers.js';
8
8
 
9
9
  export const NB_PUBLISH_HOST: string = (() => {
10
10
  try {
@@ -25,6 +25,7 @@ export type CellResult =
25
25
  | { ok: true; kind: 'table'; rows: Record<string, unknown>[]; columns: string[]; rowCount: number; truncated?: boolean; durationMs: number; logs?: string[] }
26
26
  | { ok: true; kind: 'value'; value: unknown; durationMs: number; logs?: string[] }
27
27
  | { ok: true; kind: 'chart'; spec: unknown; durationMs: number; logs?: string[] }
28
+ | { ok: true; kind: 'widget'; widgets: Array<{ name: string; params: Record<string, unknown> }>; durationMs: number; logs?: string[] }
28
29
  | { ok: true; kind: 'empty'; durationMs: number; logs?: string[] }
29
30
  | { ok: false; error: string; errorKind?: 'syntax' | 'runtime' | 'timeout' | 'schema'; durationMs: number; logs?: string[] };
30
31
 
@@ -71,6 +72,16 @@ export interface NotebookState {
71
72
  * a RuntimeOverlay consumed at render. Default false (frozen snapshots).
72
73
  */
73
74
  autoRun?: boolean;
75
+ /** Bundled WebMCP server instances active in this notebook. Populated from
76
+ * `data.webmcpServers` at mount; used by the widget picker (+widget) and as
77
+ * fallback for `mountWidget` when a custom-element tag isn't defined. */
78
+ webmcpServers?: import('@webmcp-auto-ui/core').WebMcpServer[];
79
+ /** Host-supplied LLM proxy endpoint. The notebook agent posts to this URL.
80
+ * Defaults to '/api/chat' when absent — flex sets '/flex/api/chat' to
81
+ * match its base path; notebook-viewer keeps the default. */
82
+ chatApiBase?: string;
83
+ /** Suppress the published-slug chip in the header (notebook-viewer use). */
84
+ hidePublishBadge?: boolean;
74
85
  }
75
86
 
76
87
  export interface HistoryEntry {
@@ -187,6 +198,11 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
187
198
  executors: initial?.executors ?? {},
188
199
  lastEditAt: initial?.lastEditAt ?? Date.now(),
189
200
  autoRun: initial?.autoRun ?? false,
201
+ publishedSlug: initial?.publishedSlug,
202
+ publishedToken: initial?.publishedToken,
203
+ webmcpServers: initial?.webmcpServers,
204
+ chatApiBase: initial?.chatApiBase,
205
+ hidePublishBadge: initial?.hidePublishBadge,
190
206
  };
191
207
  }
192
208
 
@@ -260,9 +276,9 @@ export function cellRuntimeStatus(cell: NotebookCell, overlay: RuntimeOverlay |
260
276
  return 'idle';
261
277
  }
262
278
 
263
- /** Live-mode whitelist. Only SQL cells are re-executable publicly. */
279
+ /** Live-mode whitelist. SQL and JS cells are re-executable; markdown stays frozen. */
264
280
  export function isReRunnable(cell: NotebookCell): boolean {
265
- return cell.type === 'sql';
281
+ return cell.type === 'sql' || cell.type === 'js';
266
282
  }
267
283
 
268
284
  /**
@@ -306,17 +322,18 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
306
322
  onTick?.(overlay);
307
323
 
308
324
  const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
325
+ const sharedSignal = signal ?? new AbortController().signal;
309
326
 
310
- for (const cell of state.cells) {
311
- if (signal?.aborted) break;
312
- if (!isReRunnable(cell)) { summary.frozen++; continue; }
327
+ const tasks = state.cells.map(async (cell) => {
328
+ if (sharedSignal.aborted) return;
329
+ if (!isReRunnable(cell)) { summary.frozen++; return; }
313
330
 
314
331
  overlay.status.set(cell.id, 'running');
315
332
  onCellChange?.(cell.id);
316
333
  onTick?.(overlay);
317
334
 
318
335
  try {
319
- const result = await runner(cell, signal ?? new AbortController().signal);
336
+ const result = await runner(cell, sharedSignal);
320
337
  if (result.ok) {
321
338
  overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
322
339
  overlay.status.set(cell.id, 'fresh');
@@ -332,7 +349,9 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
332
349
  }
333
350
  onCellChange?.(cell.id);
334
351
  onTick?.(overlay);
335
- }
352
+ });
353
+
354
+ await Promise.all(tasks);
336
355
 
337
356
  overlay.finishedAt = Date.now();
338
357
  onTick?.(overlay);
@@ -348,22 +367,17 @@ export function lastRefreshedAt(overlay: RuntimeOverlay | null | undefined): num
348
367
  }
349
368
 
350
369
  /**
351
- * Build a CellRunner backed by a MultiMcpBridge. Discovers a SQL-capable tool
352
- * on the connected servers (matching `*_query_sql` then `query|run|execute`),
353
- * calls it with `{ sql: cell.content }`, parses content-array into a table.
370
+ * Build a CellRunner that issues SQL via `callTool`. Discovers a SQL-capable
371
+ * tool on the connected servers (matching `*_query_sql` then
372
+ * `query|run|execute`), calls it with `{ sql: cell.content }`, parses the
373
+ * content-array into a table.
354
374
  *
355
- * Throws if no server is reachable / no SQL tool found. Callers are expected
356
- * to surface this as a 'stale' status, not crash.
375
+ * Returns a runtime error result if no server is reachable / no SQL tool found.
357
376
  */
358
- export function createBridgeSqlRunner(bridge: {
359
- hasServer?: (name: string) => boolean;
360
- connectedServers?: () => string[];
361
- multiClient: {
362
- listTools?: (url: string) => Promise<{ name: string }[]>;
363
- getToolsForUrl?: (url: string) => { name: string }[];
364
- };
365
- callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>;
366
- }, getServerDescriptors: () => DataServerDescriptor[]): CellRunner {
377
+ export function createBridgeSqlRunner(
378
+ callTool: (serverName: string, toolName: string, args: unknown) => Promise<unknown>,
379
+ getServerDescriptors: () => DataServerDescriptor[],
380
+ ): CellRunner {
367
381
  const PATTERN_PRIMARY = /^.*query_sql$/i;
368
382
  const PATTERN_FALLBACK = /^(query|run|execute)(_sql)?$/i;
369
383
 
@@ -423,20 +437,18 @@ export function createBridgeSqlRunner(bridge: {
423
437
  if (!hit) {
424
438
  return { ok: false, error: 'No SQL tool exposed by reachable servers', errorKind: 'schema', durationMs: 0 };
425
439
  }
426
- const raw = await bridge.callTool(hit.serverName, hit.toolName, { sql: cell.content });
440
+ const raw = await callTool(hit.serverName, hit.toolName, { sql: cell.content });
427
441
  return parseResult(raw, startedAt);
428
442
  };
429
443
  }
430
444
 
431
445
  /**
432
- * High-level bootstrap: auto-connect declared servers, wait for handshake, build
433
- * a bridge-backed runner, fire runAutoRefresh. Safe to call from any layout at
434
- * mount time when `state.autoRun && state.mode === 'view'`. Returns a cleanup.
446
+ * High-level bootstrap: auto-connect declared servers, wait for handshake,
447
+ * build a SQL runner, fire runAutoRefresh. Safe to call from any layout at
448
+ * mount time when `state.autoRun && state.mode === 'view'`. Returns cleanup.
435
449
  *
436
- * Reuses the global singleton bridge on `globalThis.__multiMcp` (installed via
437
- * `installMultiMcpBridge` from @webmcp-auto-ui/core) never creates a parallel
438
- * bridge. If no singleton exists yet we install it here, and only the installer
439
- * is allowed to stop it on cleanup.
450
+ * Drives connection state through the canvas store; the store's internal
451
+ * sync owns the McpMultiClient and reconciles handshakes on every mutation.
440
452
  */
441
453
  export interface BootstrapLiveRefreshOptions {
442
454
  state: NotebookState;
@@ -447,36 +459,54 @@ export interface BootstrapLiveRefreshOptions {
447
459
  timeoutMs?: number;
448
460
  }
449
461
 
462
+ interface CanvasLike {
463
+ dataServers: { name: string; url: string; enabled?: boolean; connected?: boolean }[];
464
+ callTool: (name: string, toolName: string, args: unknown) => Promise<unknown>;
465
+ }
466
+
467
+ async function waitForEnabledServers(canvas: CanvasLike, timeoutMs: number): Promise<void> {
468
+ const deadline = Date.now() + Math.max(0, timeoutMs);
469
+ while (Date.now() < deadline) {
470
+ const enabled = (canvas.dataServers ?? []).filter((s) => s.enabled !== false);
471
+ if (enabled.length === 0) return;
472
+ if (enabled.every((s) => s.connected)) return;
473
+ await new Promise((r) => setTimeout(r, 100));
474
+ }
475
+ }
476
+
450
477
  export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => void {
451
478
  const { state, data, overlay, onCellChange, onTick, timeoutMs } = opts;
452
479
  const ac = new AbortController();
453
- let weCreatedBridge = false;
454
- let bridgeRef: any = null;
455
480
 
456
481
  void (async () => {
457
482
  try {
458
483
  autoConnectFrontmatterServers(data);
459
- const canvas: any = (globalThis as { __canvasVanilla?: unknown; canvasVanilla?: unknown })
460
- .__canvasVanilla ?? (globalThis as { canvasVanilla?: unknown }).canvasVanilla;
484
+ const canvas = ((globalThis as { __canvasVanilla?: CanvasLike; canvasVanilla?: CanvasLike })
485
+ .__canvasVanilla ?? (globalThis as { canvasVanilla?: CanvasLike }).canvasVanilla) as CanvasLike | undefined;
461
486
  if (!canvas) {
462
487
  overlay.error = 'No canvas available';
463
488
  overlay.finishedAt = Date.now();
464
489
  onTick?.(overlay);
465
490
  return;
466
491
  }
467
- const existing = (globalThis as any).__multiMcp;
468
- const bridge = existing ?? installMultiMcpBridge({ getCanvas: () => canvas });
469
- weCreatedBridge = !existing;
470
- bridgeRef = bridge;
471
- // `installMultiMcpBridge` already starts the bridge; a pre-existing
472
- // singleton is assumed to be running.
473
- await bridge.waitForEnabledServers(timeoutMs ?? 5000);
474
-
475
- const runner = createBridgeSqlRunner(bridge, () => {
476
- // filter collectDataServers to only connected ones
477
- const all = collectDataServers(data);
478
- return all.filter((s) => bridge.hasServer(s.name));
479
- });
492
+ await waitForEnabledServers(canvas, timeoutMs ?? 5000);
493
+
494
+ const sqlRunner = createBridgeSqlRunner(
495
+ canvas.callTool.bind(canvas),
496
+ () => collectDataServers(data).filter((s) => canvas.dataServers.find((d) => d.name === s.name)?.connected),
497
+ );
498
+
499
+ const runner: CellRunner = async (cell, signal) => {
500
+ if (cell.type === 'sql') return sqlRunner(cell, signal);
501
+ if (cell.type === 'js') {
502
+ const exec = state.executors?.js;
503
+ if (!exec) {
504
+ return { ok: false, error: 'No JS executor registered', errorKind: 'runtime', durationMs: 0 };
505
+ }
506
+ return exec({ cell, state, scope: state.scope, signal });
507
+ }
508
+ return { ok: false, error: `Cell type '${cell.type}' is not re-runnable`, errorKind: 'runtime', durationMs: 0 };
509
+ };
480
510
 
481
511
  await runAutoRefresh({ state, overlay, runner, onCellChange, onTick, signal: ac.signal });
482
512
  } catch (err) {
@@ -486,16 +516,7 @@ export function bootstrapLiveRefresh(opts: BootstrapLiveRefreshOptions): () => v
486
516
  }
487
517
  })();
488
518
 
489
- return () => {
490
- ac.abort();
491
- if (weCreatedBridge && bridgeRef && typeof bridgeRef.stop === 'function') {
492
- try { bridgeRef.stop(); } catch { /* ignore */ }
493
- try {
494
- const g: any = globalThis as any;
495
- if (g.__multiMcp === bridgeRef) g.__multiMcp = undefined;
496
- } catch { /* ignore */ }
497
- }
498
- };
519
+ return () => { ac.abort(); };
499
520
  }
500
521
 
501
522
  export function registerExecutor(state: NotebookState, type: CellType, fn: CellExecutor): void {
@@ -672,9 +693,15 @@ export interface DataServerRecipe {
672
693
  }
673
694
 
674
695
  export interface DataServerDescriptor {
696
+ /** Canvas key (= registry id, e.g. 'wikipedia'). Stable identity used for routing. */
675
697
  name: string;
698
+ /** Display label (registry label, or URL host for manual entries). */
699
+ label?: string;
700
+ /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
701
+ serverName?: string;
676
702
  url?: string;
677
- kind?: string;
703
+ /** 'data' = remote MCP via canvas.dataServers, 'webmcp' = bundled JS server. */
704
+ kind?: 'data' | 'webmcp' | string;
678
705
  tools?: DataServerTool[];
679
706
  recipes?: DataServerRecipe[];
680
707
  }
@@ -704,6 +731,8 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
704
731
  .filter((s) => !isUiServer(String(s?.name ?? ''), s?.kind))
705
732
  .map((s) => ({
706
733
  name: String(s.name),
734
+ label: typeof s.label === 'string' ? s.label : undefined,
735
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
707
736
  url: s.url ? String(s.url) : undefined,
708
737
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
709
738
  tools: Array.isArray(s.tools) ? s.tools : [],
@@ -715,12 +744,38 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
715
744
  .filter((s) => s?.name && !isUiServer(String(s.name), s?.kind))
716
745
  .map((s) => ({
717
746
  name: String(s.name),
747
+ label: typeof s.label === 'string' ? s.label : undefined,
748
+ serverName: typeof s.serverName === 'string' ? s.serverName : undefined,
718
749
  url: s.url ? String(s.url) : undefined,
719
750
  recipes: Array.isArray(s.recipes) ? s.recipes : [],
720
751
  tools: Array.isArray(s.tools) ? s.tools : [],
721
752
  }));
722
753
  }
723
754
 
755
+ /**
756
+ * Project bundled WebMCP server instances into the same descriptor shape used
757
+ * by the left pane, so they render alongside remote data servers in a separate
758
+ * "Widgets" group. Recipe bodies are inlined (no get_recipe roundtrip needed).
759
+ */
760
+ export function collectWebmcpServers(
761
+ servers: import('@webmcp-auto-ui/core').WebMcpServer[] | undefined,
762
+ ): DataServerDescriptor[] {
763
+ if (!servers?.length) return [];
764
+ return servers.map((s) => {
765
+ const layer = (() => { try { return s.layer(); } catch { return { recipes: [], tools: [] }; } })();
766
+ return {
767
+ name: s.name,
768
+ label: s.name,
769
+ kind: 'webmcp' as const,
770
+ recipes: (layer.recipes ?? []).map((r: any) => ({ name: r.name, description: r.description, body: r.body })),
771
+ // Tools of bundled WebMCP servers (search_recipes, widget_display, canvas, …)
772
+ // are agent-facing primitives, not callable from a JS cell via callTool().
773
+ // Hide them from the Resources pane to avoid generating broken cells.
774
+ tools: [],
775
+ };
776
+ });
777
+ }
778
+
724
779
 
725
780
  // ---------------------------------------------------------------------------
726
781
  // Publish controls (button + optional badge + optional footer)
@@ -752,16 +807,25 @@ function publishUrlFor(slug: string): string {
752
807
  return `${NB_PUBLISH_HOST}/p/${slug}`;
753
808
  }
754
809
 
810
+ function publishMode(state: NotebookState): 'publish' | 'save' | 'fork' {
811
+ if (!state.publishedSlug) return 'publish';
812
+ return state.publishedToken ? 'save' : 'fork';
813
+ }
814
+
755
815
  function publishBtnLabel(state: NotebookState): string {
756
- return state.publishedSlug ? '🔄 update' : '📤 publish';
816
+ const m = publishMode(state);
817
+ return m === 'save' ? '💾 save' : m === 'fork' ? '🍴 fork' : '📤 publish';
757
818
  }
758
819
 
759
820
  function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
760
821
  const { btn, badge, footer } = controls;
761
822
  btn.textContent = publishBtnLabel(state);
762
- btn.dataset.state = state.publishedSlug ? 'published' : 'draft';
763
- if (state.publishedSlug) {
764
- btn.title = `Update ${publishUrlFor(state.publishedSlug)}`;
823
+ const mode = publishMode(state);
824
+ btn.dataset.state = mode === 'publish' ? 'draft' : mode === 'fork' ? 'fork' : 'published';
825
+ if (mode === 'save') {
826
+ btn.title = `Save changes to ${publishUrlFor(state.publishedSlug!)}`;
827
+ } else if (mode === 'fork') {
828
+ btn.title = 'Fork this notebook — publish a copy you own';
765
829
  } else {
766
830
  btn.title = 'Publish this notebook';
767
831
  }
@@ -777,8 +841,18 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
777
841
  }
778
842
  if (footer) {
779
843
  if (state.publishedSlug) {
780
- const url = publishUrlFor(state.publishedSlug);
781
- footer.innerHTML = `Published at <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`;
844
+ const publicUrl = publishUrlFor(state.publishedSlug);
845
+ if (state.publishedToken) {
846
+ // Author view — show the author URL (with ?t=) so the author keeps their
847
+ // edit access even after the clipboard is overwritten. The public URL is
848
+ // exposed via a copy button next to it for sharing.
849
+ const authorUrl = `${publicUrl}?t=${encodeURIComponent(state.publishedToken)}`;
850
+ footer.innerHTML =
851
+ `Published at <a href="${escapeHtml(authorUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>` +
852
+ ` <button type="button" class="nb-copy-public-link" title="Copy public link (no edit token)" data-public-url="${escapeHtml(publicUrl)}">copy public link</button>`;
853
+ } else {
854
+ footer.innerHTML = `Published at <a href="${escapeHtml(publicUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>`;
855
+ }
782
856
  footer.style.display = '';
783
857
  } else {
784
858
  footer.innerHTML = '';
@@ -834,42 +908,50 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
834
908
 
835
909
  const onClick = async () => {
836
910
  const prevLabel = btn.textContent ?? '';
911
+ const mode = publishMode(state);
837
912
  btn.disabled = true;
838
- btn.textContent = state.publishedSlug ? '… updating' : '… publishing';
913
+ btn.textContent = mode === 'save' ? '… saving' : mode === 'fork' ? 'forking' : '… publishing';
839
914
  try {
840
- const minimal = opts.serializeState
841
- ? opts.serializeState(state)
842
- : {
843
- id: state.id,
844
- title: state.title,
845
- mode: state.mode,
846
- cells: state.cells,
847
- };
915
+ // HyperSkill standalone markdown — frontmatter (title/description/servers)
916
+ // + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
917
+ const markdown = serializeToMarkdown(state);
918
+ // Fork = publish a fresh copy (no slug/token) — the server allocates a new
919
+ // slug and token, and the visitor becomes the author of the copy.
920
+ const payload: Record<string, unknown> = { markdown };
921
+ if (mode === 'save') {
922
+ payload.slug = state.publishedSlug;
923
+ payload.token = state.publishedToken;
924
+ }
848
925
  const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
849
926
  method: 'POST',
850
927
  headers: { 'content-type': 'application/json' },
851
- body: JSON.stringify({
852
- state: minimal,
853
- slug: state.publishedSlug,
854
- token: state.publishedToken,
855
- }),
928
+ body: JSON.stringify(payload),
856
929
  });
857
930
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
858
931
  const reply: any = await res.json();
859
932
  state.publishedSlug = reply.slug;
860
933
  state.publishedToken = reply.token;
861
934
  state.lastEditAt = Date.now();
862
- const url: string = reply.url ?? publishUrlFor(String(reply.slug));
863
- try { await navigator.clipboard?.writeText?.(url); } catch { /* ignore */ }
935
+ const baseUrl: string = reply.url ?? publishUrlFor(String(reply.slug));
936
+ // Author URL embeds the token via `?t=` so the viewer can hydrate it
937
+ // (one-shot — the viewer extracts it into localStorage and cleans the
938
+ // URL). Public visitors share the bare URL without ?t.
939
+ const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
940
+ try { await navigator.clipboard?.writeText?.(authorUrl); } catch { /* ignore */ }
941
+ // On fork, also rewrite the browser URL so the visitor sees the new slug
942
+ // and so a refresh keeps them as author (token persisted in localStorage).
943
+ if (mode === 'fork' && typeof window !== 'undefined') {
944
+ try {
945
+ window.localStorage.setItem(`nb-token-${reply.slug}`, String(reply.token));
946
+ } catch {}
947
+ try { window.history.replaceState({}, '', baseUrl); } catch {}
948
+ }
864
949
  const updated = Boolean(reply.updated);
865
- toast(
866
- updated
867
- ? `updated · ${url.replace(/^https?:\/\//, '')} (copied)`
868
- : `published · ${url.replace(/^https?:\/\//, '')} (copied)`
869
- );
870
- opts.onPublished?.({ slug: String(reply.slug), url, updated });
950
+ const action = mode === 'fork' ? 'forked' : updated ? 'saved' : 'published';
951
+ toast(`${action} · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`);
952
+ opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
871
953
  } catch (err: any) {
872
- toast(`publish failed · ${String(err?.message ?? err)}`, true);
954
+ toast(`${mode} failed · ${String(err?.message ?? err)}`, true);
873
955
  btn.textContent = prevLabel;
874
956
  } finally {
875
957
  btn.disabled = false;
@@ -879,8 +961,26 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
879
961
 
880
962
  btn.addEventListener('click', onClick);
881
963
 
964
+ // Delegated handler for the "copy public link" button rendered in the footer
965
+ // when the author has a token. We can't bind directly because the footer
966
+ // innerHTML is rebuilt by refreshPublishControls.
967
+ const onFooterClick = (e: Event) => {
968
+ const target = e.target as HTMLElement | null;
969
+ const copyBtn = target?.closest('.nb-copy-public-link') as HTMLElement | null;
970
+ if (!copyBtn) return;
971
+ e.preventDefault();
972
+ const url = copyBtn.dataset.publicUrl ?? '';
973
+ if (!url) return;
974
+ void (async () => {
975
+ try { await navigator.clipboard?.writeText?.(url); toast(`public link copied · ${url.replace(/^https?:\/\//, '')}`); }
976
+ catch { toast('copy failed', true); }
977
+ })();
978
+ };
979
+ if (footer) footer.addEventListener('click', onFooterClick);
980
+
882
981
  return () => {
883
982
  btn.removeEventListener('click', onClick);
983
+ if (footer) footer.removeEventListener('click', onFooterClick);
884
984
  btn.parentNode?.removeChild(btn);
885
985
  badge?.parentNode?.removeChild(badge);
886
986
  footer?.parentNode?.removeChild(footer);
@@ -1150,7 +1250,79 @@ const NOTEBOOK_STYLES = `
1150
1250
  .nb-root.nb-view-mode .nb-icon-btn.nb-danger,
1151
1251
  .nb-root.nb-view-mode .nb-toggle-src,
1152
1252
  .nb-root.nb-view-mode .nb-toggle-res,
1153
- .nb-root.nb-view-mode .nb-add-cell { display: none !important; }
1253
+ .nb-root.nb-view-mode .nb-add-cell,
1254
+ .nb-root.nb-view-mode .nbe-cell-actionbar { display: none !important; }
1255
+
1256
+ /* Per-cell action bar — slim row of insert/agent shortcuts shown under each cell in edit mode. */
1257
+ .nbe-cell-actionbar {
1258
+ display: flex; gap: 4px; align-items: center;
1259
+ margin: 4px 0 14px 0; padding-left: 28px;
1260
+ opacity: 0.55; transition: opacity 120ms ease;
1261
+ }
1262
+ .nbe-cell-actionbar:hover { opacity: 1; }
1263
+ .nb-cellbar-btn {
1264
+ font-size: 11px; padding: 2px 8px; line-height: 1.4;
1265
+ background: transparent; border: 1px dashed var(--color-border, #c8c2b4);
1266
+ color: var(--color-text2, #645d4f); border-radius: 6px; cursor: pointer;
1267
+ }
1268
+ .nb-cellbar-btn:hover { background: var(--color-bg2, #f4efdf); color: var(--color-text1, #1c1a16); border-style: solid; }
1269
+
1270
+ /* Widget picker overlay (+widget action) */
1271
+ .nbe-picker-overlay {
1272
+ position: fixed; inset: 0; background: rgba(20, 18, 14, 0.55);
1273
+ display: flex; align-items: flex-start; justify-content: center;
1274
+ padding-top: 80px; z-index: 9999;
1275
+ font-family: var(--font-sans, system-ui);
1276
+ }
1277
+ .nbe-picker {
1278
+ background: var(--color-bg1, #faf6e6); color: var(--color-text1, #1c1a16);
1279
+ border-radius: 12px; box-shadow: 0 24px 64px rgba(0,0,0,0.18);
1280
+ width: min(620px, calc(100vw - 32px)); max-height: 70vh;
1281
+ display: flex; flex-direction: column; overflow: hidden;
1282
+ }
1283
+ .nbe-picker > header {
1284
+ display: flex; gap: 8px; align-items: center;
1285
+ padding: 10px 12px; border-bottom: 1px solid var(--color-border, #c8c2b4);
1286
+ }
1287
+ .nbe-picker-q {
1288
+ flex: 1; font-family: inherit; font-size: 14px;
1289
+ padding: 6px 10px; border: 1px solid var(--color-border, #c8c2b4);
1290
+ background: var(--color-bg0, #ffffff); border-radius: 6px;
1291
+ }
1292
+ .nbe-picker-close {
1293
+ background: transparent; border: none; cursor: pointer;
1294
+ font-size: 16px; color: var(--color-text2, #645d4f); padding: 4px 8px;
1295
+ }
1296
+ .nbe-picker-list {
1297
+ list-style: none; margin: 0; padding: 4px 0;
1298
+ overflow-y: auto; flex: 1;
1299
+ }
1300
+ .nbe-picker-list li {
1301
+ display: grid; grid-template-columns: minmax(120px, auto) auto 1fr;
1302
+ gap: 12px; align-items: baseline;
1303
+ padding: 8px 14px; cursor: pointer;
1304
+ border-bottom: 1px solid var(--color-bg2, #f0eada);
1305
+ }
1306
+ .nbe-picker-list li:hover { background: var(--color-bg2, #f4efdf); }
1307
+ .nbe-picker-name { font-family: var(--font-mono, monospace); font-size: 13px; color: var(--color-text1, #1c1a16); }
1308
+ .nbe-picker-server { font-size: 11px; color: var(--color-accent, #1d6f5f); text-transform: lowercase; }
1309
+ .nbe-picker-desc { font-size: 12px; color: var(--color-text2, #645d4f); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1310
+
1311
+ /* Agent bar (+agent) */
1312
+ .nbe-agent-bar {
1313
+ margin: 0 0 16px 28px;
1314
+ padding: 8px 10px;
1315
+ background: var(--color-bg2, #f4efdf);
1316
+ border-radius: 8px;
1317
+ border: 1px solid var(--color-border, #c8c2b4);
1318
+ display: flex; flex-direction: column; gap: 6px;
1319
+ }
1320
+ .nbe-agent-bar auto-chat-input { display: block; }
1321
+ .nbe-agent-status {
1322
+ font-family: var(--font-mono, monospace); font-size: 11px;
1323
+ color: var(--color-text2, #645d4f);
1324
+ padding: 0 4px;
1325
+ }
1154
1326
  /* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
1155
1327
  * even when the notebook is read-only; only editing the source is locked. */
1156
1328
  .nb-root.nb-view-mode textarea,
@@ -15,6 +15,7 @@
15
15
  export interface CardsData {
16
16
  title?: string;
17
17
  cards?: CardItem[];
18
+ items?: CardItem[];
18
19
  minCardWidth?: string;
19
20
  gap?: string;
20
21
  emptyMessage?: string;
@@ -28,7 +29,8 @@
28
29
  let { data = {}, oncardclick }: Props = $props();
29
30
 
30
31
  const cards = $derived.by<CardItem[]>(() => {
31
- if (Array.isArray(data?.cards) && (data!.cards as unknown[]).length) return data!.cards as CardItem[];
32
+ const list = data?.cards ?? data?.items;
33
+ if (Array.isArray(list) && (list as unknown[]).length) return list as CardItem[];
32
34
  return [];
33
35
  });
34
36
  </script>
@@ -41,20 +41,86 @@
41
41
  return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
42
42
  }
43
43
 
44
+ /**
45
+ * Normalize a single series item:
46
+ * - `values: number[]` → kept as-is
47
+ * - `value: number` → wrapped as `[value]` (tolerance #1)
48
+ */
49
+ function normalizeItem(o: Record<string, unknown>): ChartDataset | null {
50
+ // Prefer explicit `values` array; fall back to scalar `value`
51
+ let values: number[];
52
+ if (Array.isArray(o.values)) {
53
+ values = coerceValues(o.values);
54
+ } else if (typeof o.value === 'number' && Number.isFinite(o.value)) {
55
+ values = [o.value];
56
+ } else if (typeof o.value === 'string') {
57
+ const n = Number(o.value);
58
+ values = Number.isFinite(n) ? [n] : [];
59
+ } else {
60
+ values = [];
61
+ }
62
+ if (!values.length) return null;
63
+ return {
64
+ label: isPrimitiveLabel(o.label) ? String(o.label) : undefined,
65
+ values,
66
+ color: typeof o.color === 'string' ? o.color : undefined,
67
+ };
68
+ }
69
+
70
+ /** Detect categorical shorthand: [{label?, value}, ...] — one point per item, single series. */
71
+ function isCategoricalArray(raw: unknown): boolean {
72
+ if (!Array.isArray(raw) || raw.length === 0) return false;
73
+ return raw.every(item =>
74
+ item && typeof item === 'object'
75
+ && !Array.isArray((item as Record<string, unknown>).values)
76
+ && (item as Record<string, unknown>).value !== undefined
77
+ );
78
+ }
79
+
44
80
  function normalizeDatasets(raw: unknown): ChartDataset[] {
45
81
  if (!Array.isArray(raw)) return [];
82
+ // Categorical shorthand: collapse N items into ONE series of N points.
83
+ // Without this, recipes that send `data: [{label, value}, ...]` produce N
84
+ // datasets of 1 point each, all rendering at x=0 (all dots stack vertically).
85
+ if (isCategoricalArray(raw)) {
86
+ const values = raw.map(item => {
87
+ const v = (item as Record<string, unknown>).value;
88
+ const n = typeof v === 'number' ? v : Number(v as string);
89
+ return Number.isFinite(n) ? n : 0;
90
+ });
91
+ return [{ values }];
92
+ }
46
93
  return raw.flatMap(item => {
47
94
  if (!item || typeof item !== 'object') return [];
48
- const o = item as Record<string, unknown>;
49
- // Validate values field
50
- const values = coerceValues(o.values);
51
- if (!values.length) return [];
52
- return [{ label: isPrimitiveLabel(o.label) ? String(o.label) : undefined, values, color: typeof o.color === 'string' ? o.color : undefined }];
95
+ const result = normalizeItem(item as Record<string, unknown>);
96
+ return result ? [result] : [];
53
97
  });
54
98
  }
55
99
 
56
- const datasets = $derived<ChartDataset[]>(normalizeDatasets(data?.datasets));
57
- const labels = $derived<string[]>(Array.isArray(data?.labels) ? (data!.labels! as unknown[]).filter(isPrimitiveLabel).map(String) : []);
100
+ function extractCategoricalLabels(raw: unknown): string[] {
101
+ if (!isCategoricalArray(raw)) return [];
102
+ return (raw as unknown[]).map(item => {
103
+ const l = (item as Record<string, unknown>).label;
104
+ return typeof l === 'string' || typeof l === 'number' ? String(l) : '';
105
+ });
106
+ }
107
+
108
+ /**
109
+ * Resolve datasets from `data` prop, tolerating two shapes:
110
+ * - `{ datasets: [...] }` — canonical
111
+ * - `{ data: [...] }` — agent shorthand (schema uses `data` key)
112
+ */
113
+ const rawArray = $derived(
114
+ Array.isArray((data as Record<string, unknown> | undefined)?.['datasets'])
115
+ ? (data as Record<string, unknown>)['datasets']
116
+ : (data as Record<string, unknown> | undefined)?.['data'],
117
+ );
118
+ const datasets = $derived<ChartDataset[]>(normalizeDatasets(rawArray));
119
+ const labels = $derived<string[]>(
120
+ Array.isArray(data?.labels)
121
+ ? (data!.labels! as unknown[]).filter(isPrimitiveLabel).map(String)
122
+ : extractCategoricalLabels(rawArray),
123
+ );
58
124
  const type = $derived(data?.type ?? 'bar');
59
125
  const isPie = $derived(type === 'pie' || type === 'donut');
60
126
  const allVals = $derived(datasets.flatMap(d => d.values));