@webmcp-auto-ui/ui 2.5.37 → 2.5.39

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
  // Consumed by import-modals.ts and left-pane.ts.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { parseBody } from '@webmcp-auto-ui/sdk';
7
+ import { parseBody, parseWidgetDisplayCall } from '@webmcp-auto-ui/sdk';
8
8
  import { uid, defaultCellContent } from './shared.js';
9
9
  import type { NotebookCell, CellType } from './shared.js';
10
10
 
@@ -80,6 +80,21 @@ export function extractCellFromFence(lang: string, content: string): NotebookCel
80
80
  return { id: uid(), type: 'sql', content: sql.trim(), hideSource: false, hideResult: false };
81
81
  }
82
82
  }
83
+ // widget_display is a local WebMCP tool (autoui), not a remote MCP tool —
84
+ // route it to the JS sandbox `widget()` helper which captures and renders
85
+ // inline. callTool() would fail with "No MCP server exposes tool".
86
+ if (/^(?:[A-Za-z_]\w*_)?widget_display$/.test(name)) {
87
+ const parsed = parseWidgetDisplayCall(trimmed);
88
+ if (parsed) {
89
+ const paramsLiteral = JSON.stringify(parsed.params, null, 2);
90
+ return {
91
+ id: uid(),
92
+ type: 'js',
93
+ content: `return widget('${parsed.name}', ${paramsLiteral})`,
94
+ hideSource: false, hideResult: false,
95
+ };
96
+ }
97
+ }
83
98
  return {
84
99
  id: uid(),
85
100
  type: 'js',
@@ -4,7 +4,7 @@
4
4
  // 4 formats: JSON, Markdown, Hyperskill link (+ short), PNG snapshot.
5
5
  // ---------------------------------------------------------------------------
6
6
 
7
- import { encode, buildShortUrl } from '@webmcp-auto-ui/sdk';
7
+ import { encode, buildShortUrl, pickFence } from '@webmcp-auto-ui/sdk';
8
8
  import { canvasVanilla } from '@webmcp-auto-ui/sdk/canvas-vanilla';
9
9
  import type { NotebookState, NotebookCell } from './shared.js';
10
10
 
@@ -56,7 +56,9 @@ export function serializeToMarkdown(state: NotebookState): string {
56
56
  const metaLine = cell.args && Object.keys(cell.args).length > 0
57
57
  ? `${commentPrefix} @meta ${JSON.stringify(cell.args)}\n`
58
58
  : '';
59
- parts.push('```' + lang + varname, metaLine + cell.content.trim(), '```', '');
59
+ const body = metaLine + cell.content.trim();
60
+ const fence = pickFence(body);
61
+ parts.push(fence + lang + varname, body, fence, '');
60
62
  }
61
63
  }
62
64
  return parts.join('\n').trim() + '\n';
@@ -71,7 +73,8 @@ function buildFrontmatter(state: NotebookState): string {
71
73
  const title = (state.title || '').trim();
72
74
  const description = extractDescription(state);
73
75
  const servers = collectEnabledServers();
74
- if (!title && !description && servers.length === 0) return '';
76
+ const webmcpServers = collectEnabledWebmcpServers();
77
+ if (!title && !description && servers.length === 0 && webmcpServers.length === 0) return '';
75
78
 
76
79
  const lines: string[] = ['---'];
77
80
  if (title) lines.push(`title: ${yamlQuote(title)}`);
@@ -83,10 +86,28 @@ function buildFrontmatter(state: NotebookState): string {
83
86
  lines.push(` url: ${yamlQuote(s.url)}`);
84
87
  }
85
88
  }
89
+ if (webmcpServers.length > 0) {
90
+ // YAML flow-style for compactness (registry ids, no spaces).
91
+ lines.push(`webmcp_servers: [${webmcpServers.map(yamlQuote).join(', ')}]`);
92
+ }
86
93
  lines.push('---', '');
87
94
  return lines.join('\n');
88
95
  }
89
96
 
97
+ /**
98
+ * Read canvas.enabledServerIds — the registry ids (e.g. 'autoui', 'd3',
99
+ * 'observable-plot') of bundled WebMCP servers active in this notebook.
100
+ * The viewer re-instantiates them from @webmcp-auto-ui/servers on load.
101
+ */
102
+ function collectEnabledWebmcpServers(): string[] {
103
+ try {
104
+ const ids = (canvasVanilla as { enabledServerIds?: string[] }).enabledServerIds ?? [];
105
+ return ids.filter((id): id is string => typeof id === 'string' && id.length > 0);
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
90
111
  function extractDescription(state: NotebookState): string {
91
112
  for (const cell of state.cells) {
92
113
  if (cell.type !== 'md') continue;
@@ -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
 
@@ -46,6 +47,8 @@ export interface NotebookCell {
46
47
  varname?: string; // named output (compact)
47
48
  hideSource?: boolean;
48
49
  hideResult?: boolean;
50
+ /** view-mode: collapse the console logs panel under the head bar. Default true in view mode. */
51
+ hideLogs?: boolean;
49
52
  runState?: RunState;
50
53
  lastMs?: number;
51
54
  status?: 'fresh' | 'stale';
@@ -71,6 +74,16 @@ export interface NotebookState {
71
74
  * a RuntimeOverlay consumed at render. Default false (frozen snapshots).
72
75
  */
73
76
  autoRun?: boolean;
77
+ /** Bundled WebMCP server instances active in this notebook. Populated from
78
+ * `data.webmcpServers` at mount; used by the widget picker (+widget) and as
79
+ * fallback for `mountWidget` when a custom-element tag isn't defined. */
80
+ webmcpServers?: import('@webmcp-auto-ui/core').WebMcpServer[];
81
+ /** Host-supplied LLM proxy endpoint. The notebook agent posts to this URL.
82
+ * Defaults to '/api/chat' when absent — flex sets '/flex/api/chat' to
83
+ * match its base path; notebook-viewer keeps the default. */
84
+ chatApiBase?: string;
85
+ /** Suppress the published-slug chip in the header (notebook-viewer use). */
86
+ hidePublishBadge?: boolean;
74
87
  }
75
88
 
76
89
  export interface HistoryEntry {
@@ -189,6 +202,9 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
189
202
  autoRun: initial?.autoRun ?? false,
190
203
  publishedSlug: initial?.publishedSlug,
191
204
  publishedToken: initial?.publishedToken,
205
+ webmcpServers: initial?.webmcpServers,
206
+ chatApiBase: initial?.chatApiBase,
207
+ hidePublishBadge: initial?.hidePublishBadge,
192
208
  };
193
209
  }
194
210
 
@@ -308,17 +324,18 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
308
324
  onTick?.(overlay);
309
325
 
310
326
  const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
327
+ const sharedSignal = signal ?? new AbortController().signal;
311
328
 
312
- for (const cell of state.cells) {
313
- if (signal?.aborted) break;
314
- if (!isReRunnable(cell)) { summary.frozen++; continue; }
329
+ const tasks = state.cells.map(async (cell) => {
330
+ if (sharedSignal.aborted) return;
331
+ if (!isReRunnable(cell)) { summary.frozen++; return; }
315
332
 
316
333
  overlay.status.set(cell.id, 'running');
317
334
  onCellChange?.(cell.id);
318
335
  onTick?.(overlay);
319
336
 
320
337
  try {
321
- const result = await runner(cell, signal ?? new AbortController().signal);
338
+ const result = await runner(cell, sharedSignal);
322
339
  if (result.ok) {
323
340
  overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
324
341
  overlay.status.set(cell.id, 'fresh');
@@ -334,7 +351,9 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
334
351
  }
335
352
  onCellChange?.(cell.id);
336
353
  onTick?.(overlay);
337
- }
354
+ });
355
+
356
+ await Promise.all(tasks);
338
357
 
339
358
  overlay.finishedAt = Date.now();
340
359
  onTick?.(overlay);
@@ -683,7 +702,8 @@ export interface DataServerDescriptor {
683
702
  /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
684
703
  serverName?: string;
685
704
  url?: string;
686
- kind?: string;
705
+ /** 'data' = remote MCP via canvas.dataServers, 'webmcp' = bundled JS server. */
706
+ kind?: 'data' | 'webmcp' | string;
687
707
  tools?: DataServerTool[];
688
708
  recipes?: DataServerRecipe[];
689
709
  }
@@ -734,6 +754,30 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
734
754
  }));
735
755
  }
736
756
 
757
+ /**
758
+ * Project bundled WebMCP server instances into the same descriptor shape used
759
+ * by the left pane, so they render alongside remote data servers in a separate
760
+ * "Widgets" group. Recipe bodies are inlined (no get_recipe roundtrip needed).
761
+ */
762
+ export function collectWebmcpServers(
763
+ servers: import('@webmcp-auto-ui/core').WebMcpServer[] | undefined,
764
+ ): DataServerDescriptor[] {
765
+ if (!servers?.length) return [];
766
+ return servers.map((s) => {
767
+ const layer = (() => { try { return s.layer(); } catch { return { recipes: [], tools: [] }; } })();
768
+ return {
769
+ name: s.name,
770
+ label: s.name,
771
+ kind: 'webmcp' as const,
772
+ recipes: (layer.recipes ?? []).map((r: any) => ({ name: r.name, description: r.description, body: r.body })),
773
+ // Tools of bundled WebMCP servers (search_recipes, widget_display, canvas, …)
774
+ // are agent-facing primitives, not callable from a JS cell via callTool().
775
+ // Hide them from the Resources pane to avoid generating broken cells.
776
+ tools: [],
777
+ };
778
+ });
779
+ }
780
+
737
781
 
738
782
  // ---------------------------------------------------------------------------
739
783
  // Publish controls (button + optional badge + optional footer)
@@ -765,16 +809,25 @@ function publishUrlFor(slug: string): string {
765
809
  return `${NB_PUBLISH_HOST}/p/${slug}`;
766
810
  }
767
811
 
812
+ function publishMode(state: NotebookState): 'publish' | 'save' | 'fork' {
813
+ if (!state.publishedSlug) return 'publish';
814
+ return state.publishedToken ? 'save' : 'fork';
815
+ }
816
+
768
817
  function publishBtnLabel(state: NotebookState): string {
769
- return state.publishedSlug ? '💾 save' : '📤 publish';
818
+ const m = publishMode(state);
819
+ return m === 'save' ? '💾 save' : m === 'fork' ? '🍴 fork' : '📤 publish';
770
820
  }
771
821
 
772
822
  function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
773
823
  const { btn, badge, footer } = controls;
774
824
  btn.textContent = publishBtnLabel(state);
775
- btn.dataset.state = state.publishedSlug ? 'published' : 'draft';
776
- if (state.publishedSlug) {
777
- btn.title = `Save changes to ${publishUrlFor(state.publishedSlug)}`;
825
+ const mode = publishMode(state);
826
+ btn.dataset.state = mode === 'publish' ? 'draft' : mode === 'fork' ? 'fork' : 'published';
827
+ if (mode === 'save') {
828
+ btn.title = `Save changes to ${publishUrlFor(state.publishedSlug!)}`;
829
+ } else if (mode === 'fork') {
830
+ btn.title = 'Fork this notebook — publish a copy you own';
778
831
  } else {
779
832
  btn.title = 'Publish this notebook';
780
833
  }
@@ -790,8 +843,18 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
790
843
  }
791
844
  if (footer) {
792
845
  if (state.publishedSlug) {
793
- const url = publishUrlFor(state.publishedSlug);
794
- footer.innerHTML = `Published at <a href="${escapeHtml(url)}" target="_blank" rel="noopener">${escapeHtml(url)}</a>`;
846
+ const publicUrl = publishUrlFor(state.publishedSlug);
847
+ if (state.publishedToken) {
848
+ // Author view — show the author URL (with ?t=) so the author keeps their
849
+ // edit access even after the clipboard is overwritten. The public URL is
850
+ // exposed via a copy button next to it for sharing.
851
+ const authorUrl = `${publicUrl}?t=${encodeURIComponent(state.publishedToken)}`;
852
+ footer.innerHTML =
853
+ `Published at <a href="${escapeHtml(authorUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>` +
854
+ ` <button type="button" class="nb-copy-public-link" title="Copy public link (no edit token)" data-public-url="${escapeHtml(publicUrl)}">copy public link</button>`;
855
+ } else {
856
+ footer.innerHTML = `Published at <a href="${escapeHtml(publicUrl)}" target="_blank" rel="noopener">${escapeHtml(publicUrl)}</a>`;
857
+ }
795
858
  footer.style.display = '';
796
859
  } else {
797
860
  footer.innerHTML = '';
@@ -847,20 +910,24 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
847
910
 
848
911
  const onClick = async () => {
849
912
  const prevLabel = btn.textContent ?? '';
913
+ const mode = publishMode(state);
850
914
  btn.disabled = true;
851
- btn.textContent = state.publishedSlug ? '… saving' : '… publishing';
915
+ btn.textContent = mode === 'save' ? '… saving' : mode === 'fork' ? 'forking' : '… publishing';
852
916
  try {
853
917
  // HyperSkill standalone markdown — frontmatter (title/description/servers)
854
918
  // + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
855
919
  const markdown = serializeToMarkdown(state);
920
+ // Fork = publish a fresh copy (no slug/token) — the server allocates a new
921
+ // slug and token, and the visitor becomes the author of the copy.
922
+ const payload: Record<string, unknown> = { markdown };
923
+ if (mode === 'save') {
924
+ payload.slug = state.publishedSlug;
925
+ payload.token = state.publishedToken;
926
+ }
856
927
  const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
857
928
  method: 'POST',
858
929
  headers: { 'content-type': 'application/json' },
859
- body: JSON.stringify({
860
- markdown,
861
- slug: state.publishedSlug,
862
- token: state.publishedToken,
863
- }),
930
+ body: JSON.stringify(payload),
864
931
  });
865
932
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
866
933
  const reply: any = await res.json();
@@ -873,15 +940,20 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
873
940
  // URL). Public visitors share the bare URL without ?t.
874
941
  const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
875
942
  try { await navigator.clipboard?.writeText?.(authorUrl); } catch { /* ignore */ }
943
+ // On fork, also rewrite the browser URL so the visitor sees the new slug
944
+ // and so a refresh keeps them as author (token persisted in localStorage).
945
+ if (mode === 'fork' && typeof window !== 'undefined') {
946
+ try {
947
+ window.localStorage.setItem(`nb-token-${reply.slug}`, String(reply.token));
948
+ } catch {}
949
+ try { window.history.replaceState({}, '', baseUrl); } catch {}
950
+ }
876
951
  const updated = Boolean(reply.updated);
877
- toast(
878
- updated
879
- ? `saved · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
880
- : `published · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`
881
- );
952
+ const action = mode === 'fork' ? 'forked' : updated ? 'saved' : 'published';
953
+ toast(`${action} · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`);
882
954
  opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
883
955
  } catch (err: any) {
884
- toast(`publish failed · ${String(err?.message ?? err)}`, true);
956
+ toast(`${mode} failed · ${String(err?.message ?? err)}`, true);
885
957
  btn.textContent = prevLabel;
886
958
  } finally {
887
959
  btn.disabled = false;
@@ -891,8 +963,26 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
891
963
 
892
964
  btn.addEventListener('click', onClick);
893
965
 
966
+ // Delegated handler for the "copy public link" button rendered in the footer
967
+ // when the author has a token. We can't bind directly because the footer
968
+ // innerHTML is rebuilt by refreshPublishControls.
969
+ const onFooterClick = (e: Event) => {
970
+ const target = e.target as HTMLElement | null;
971
+ const copyBtn = target?.closest('.nb-copy-public-link') as HTMLElement | null;
972
+ if (!copyBtn) return;
973
+ e.preventDefault();
974
+ const url = copyBtn.dataset.publicUrl ?? '';
975
+ if (!url) return;
976
+ void (async () => {
977
+ try { await navigator.clipboard?.writeText?.(url); toast(`public link copied · ${url.replace(/^https?:\/\//, '')}`); }
978
+ catch { toast('copy failed', true); }
979
+ })();
980
+ };
981
+ if (footer) footer.addEventListener('click', onFooterClick);
982
+
894
983
  return () => {
895
984
  btn.removeEventListener('click', onClick);
985
+ if (footer) footer.removeEventListener('click', onFooterClick);
896
986
  btn.parentNode?.removeChild(btn);
897
987
  badge?.parentNode?.removeChild(badge);
898
988
  footer?.parentNode?.removeChild(footer);
@@ -1160,9 +1250,79 @@ const NOTEBOOK_STYLES = `
1160
1250
 
1161
1251
  .nb-root.nb-view-mode .nb-drag-handle,
1162
1252
  .nb-root.nb-view-mode .nb-icon-btn.nb-danger,
1163
- .nb-root.nb-view-mode .nb-toggle-src,
1164
- .nb-root.nb-view-mode .nb-toggle-res,
1165
- .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
+ }
1166
1326
  /* Run controls (.nb-ctl-pill) remain active in view mode — users can execute cells
1167
1327
  * even when the notebook is read-only; only editing the source is locked. */
1168
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));
@@ -23,15 +23,36 @@
23
23
  let sortCol = $state<string|null>(null);
24
24
  let sortAsc = $state(true);
25
25
 
26
- const rows = $derived<Record<string,unknown>[]>(
27
- Array.isArray(data?.rows) && (data!.rows as unknown[]).length
28
- ? data!.rows as Record<string,unknown>[]
29
- : []
30
- );
26
+ // ── Tolerance 1: alias `headers` → `columns` ─────────────────────────────
27
+ // Some callers pass `headers` (array of strings or column objects) instead of
28
+ // `columns`. Normalise so downstream logic only sees `columns`.
29
+ const resolvedColumnDefs = $derived.by<DataTableColumn[] | null>(() => {
30
+ const src = (data as DataTableData & { headers?: unknown }).headers ?? data?.columns;
31
+ if (!Array.isArray(src) || !src.length) return null;
32
+ // headers can be plain strings ["A","B"] or full column objects [{key,label}]
33
+ return (src as unknown[]).map((h) =>
34
+ typeof h === 'string' ? { key: h, label: h } : h as DataTableColumn
35
+ );
36
+ });
37
+
38
+ // ── Tolerance 2: rows as array-of-arrays ─────────────────────────────────
39
+ // If the first row is an array, map each row to an object using `resolvedColumnDefs`
40
+ // (falling back to positional keys "0","1",… when no column defs are available).
41
+ const rows = $derived.by<Record<string,unknown>[]>(() => {
42
+ if (!Array.isArray(data?.rows) || !(data!.rows as unknown[]).length) return [];
43
+ const raw = data!.rows as unknown[];
44
+ if (!Array.isArray(raw[0])) return raw as Record<string,unknown>[];
45
+ // array-of-arrays: convert to array-of-objects
46
+ const keys = resolvedColumnDefs?.map(c => c.key)
47
+ ?? (raw[0] as unknown[]).map((_, i) => String(i));
48
+ return (raw as unknown[][]).map(arr =>
49
+ Object.fromEntries(keys.map((k, i) => [k, arr[i]]))
50
+ );
51
+ });
31
52
 
32
53
  const columns = $derived<DataTableColumn[]>(
33
- Array.isArray(data?.columns) && (data!.columns as unknown[]).length
34
- ? data!.columns as DataTableColumn[]
54
+ resolvedColumnDefs && resolvedColumnDefs.length
55
+ ? resolvedColumnDefs
35
56
  : rows.length > 0
36
57
  ? Object.keys(rows[0] as object).map(k => ({ key: k, label: k }))
37
58
  : []