@webmcp-auto-ui/ui 2.5.37 → 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.
@@ -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 {
@@ -189,6 +200,9 @@ export function createState(initial?: Partial<NotebookState>): NotebookState {
189
200
  autoRun: initial?.autoRun ?? false,
190
201
  publishedSlug: initial?.publishedSlug,
191
202
  publishedToken: initial?.publishedToken,
203
+ webmcpServers: initial?.webmcpServers,
204
+ chatApiBase: initial?.chatApiBase,
205
+ hidePublishBadge: initial?.hidePublishBadge,
192
206
  };
193
207
  }
194
208
 
@@ -308,17 +322,18 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
308
322
  onTick?.(overlay);
309
323
 
310
324
  const summary: AutoRefreshSummary = { rerun: 0, frozen: 0, stale: 0, failed: 0 };
325
+ const sharedSignal = signal ?? new AbortController().signal;
311
326
 
312
- for (const cell of state.cells) {
313
- if (signal?.aborted) break;
314
- 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; }
315
330
 
316
331
  overlay.status.set(cell.id, 'running');
317
332
  onCellChange?.(cell.id);
318
333
  onTick?.(overlay);
319
334
 
320
335
  try {
321
- const result = await runner(cell, signal ?? new AbortController().signal);
336
+ const result = await runner(cell, sharedSignal);
322
337
  if (result.ok) {
323
338
  overlay.outputs.set(cell.id, { result, refreshedAt: Date.now() });
324
339
  overlay.status.set(cell.id, 'fresh');
@@ -334,7 +349,9 @@ export async function runAutoRefresh(opts: AutoRefreshOptions): Promise<AutoRefr
334
349
  }
335
350
  onCellChange?.(cell.id);
336
351
  onTick?.(overlay);
337
- }
352
+ });
353
+
354
+ await Promise.all(tasks);
338
355
 
339
356
  overlay.finishedAt = Date.now();
340
357
  onTick?.(overlay);
@@ -683,7 +700,8 @@ export interface DataServerDescriptor {
683
700
  /** Real server name from MCP handshake (initResult.serverInfo.name, aliased). */
684
701
  serverName?: string;
685
702
  url?: string;
686
- kind?: string;
703
+ /** 'data' = remote MCP via canvas.dataServers, 'webmcp' = bundled JS server. */
704
+ kind?: 'data' | 'webmcp' | string;
687
705
  tools?: DataServerTool[];
688
706
  recipes?: DataServerRecipe[];
689
707
  }
@@ -734,6 +752,30 @@ export function collectDataServers(data: Record<string, unknown>): DataServerDes
734
752
  }));
735
753
  }
736
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
+
737
779
 
738
780
  // ---------------------------------------------------------------------------
739
781
  // Publish controls (button + optional badge + optional footer)
@@ -765,16 +807,25 @@ function publishUrlFor(slug: string): string {
765
807
  return `${NB_PUBLISH_HOST}/p/${slug}`;
766
808
  }
767
809
 
810
+ function publishMode(state: NotebookState): 'publish' | 'save' | 'fork' {
811
+ if (!state.publishedSlug) return 'publish';
812
+ return state.publishedToken ? 'save' : 'fork';
813
+ }
814
+
768
815
  function publishBtnLabel(state: NotebookState): string {
769
- return state.publishedSlug ? '💾 save' : '📤 publish';
816
+ const m = publishMode(state);
817
+ return m === 'save' ? '💾 save' : m === 'fork' ? '🍴 fork' : '📤 publish';
770
818
  }
771
819
 
772
820
  function refreshPublishControls(state: NotebookState, controls: PublishControlsHandles): void {
773
821
  const { btn, badge, footer } = controls;
774
822
  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)}`;
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';
778
829
  } else {
779
830
  btn.title = 'Publish this notebook';
780
831
  }
@@ -790,8 +841,18 @@ function refreshPublishControls(state: NotebookState, controls: PublishControlsH
790
841
  }
791
842
  if (footer) {
792
843
  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>`;
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
+ }
795
856
  footer.style.display = '';
796
857
  } else {
797
858
  footer.innerHTML = '';
@@ -847,20 +908,24 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
847
908
 
848
909
  const onClick = async () => {
849
910
  const prevLabel = btn.textContent ?? '';
911
+ const mode = publishMode(state);
850
912
  btn.disabled = true;
851
- btn.textContent = state.publishedSlug ? '… saving' : '… publishing';
913
+ btn.textContent = mode === 'save' ? '… saving' : mode === 'fork' ? 'forking' : '… publishing';
852
914
  try {
853
915
  // HyperSkill standalone markdown — frontmatter (title/description/servers)
854
916
  // + body with fenced cells. Re-parsable via parseFrontmatter + parseBody.
855
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
+ }
856
925
  const res = await fetch(`${NB_PUBLISH_HOST}/api/publish`, {
857
926
  method: 'POST',
858
927
  headers: { 'content-type': 'application/json' },
859
- body: JSON.stringify({
860
- markdown,
861
- slug: state.publishedSlug,
862
- token: state.publishedToken,
863
- }),
928
+ body: JSON.stringify(payload),
864
929
  });
865
930
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
866
931
  const reply: any = await res.json();
@@ -873,15 +938,20 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
873
938
  // URL). Public visitors share the bare URL without ?t.
874
939
  const authorUrl = `${baseUrl}?t=${encodeURIComponent(String(reply.token))}`;
875
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
+ }
876
949
  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
- );
950
+ const action = mode === 'fork' ? 'forked' : updated ? 'saved' : 'published';
951
+ toast(`${action} · ${baseUrl.replace(/^https?:\/\//, '')} (author link copied)`);
882
952
  opts.onPublished?.({ slug: String(reply.slug), url: baseUrl, updated });
883
953
  } catch (err: any) {
884
- toast(`publish failed · ${String(err?.message ?? err)}`, true);
954
+ toast(`${mode} failed · ${String(err?.message ?? err)}`, true);
885
955
  btn.textContent = prevLabel;
886
956
  } finally {
887
957
  btn.disabled = false;
@@ -891,8 +961,26 @@ export function createPublishControls(state: NotebookState, opts: PublishControl
891
961
 
892
962
  btn.addEventListener('click', onClick);
893
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
+
894
981
  return () => {
895
982
  btn.removeEventListener('click', onClick);
983
+ if (footer) footer.removeEventListener('click', onFooterClick);
896
984
  btn.parentNode?.removeChild(btn);
897
985
  badge?.parentNode?.removeChild(badge);
898
986
  footer?.parentNode?.removeChild(footer);
@@ -1162,7 +1250,79 @@ const NOTEBOOK_STYLES = `
1162
1250
  .nb-root.nb-view-mode .nb-icon-btn.nb-danger,
1163
1251
  .nb-root.nb-view-mode .nb-toggle-src,
1164
1252
  .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
  : []