@webmcp-auto-ui/ui 2.5.27 → 2.5.29

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.
Files changed (76) hide show
  1. package/package.json +18 -5
  2. package/src/agent/LLMSelector.svelte +11 -3
  3. package/src/agent/ModelCacheManager.svelte +359 -0
  4. package/src/index.ts +42 -30
  5. package/src/theme/scale.ts +128 -0
  6. package/src/widgets/WidgetRenderer.svelte +144 -107
  7. package/src/widgets/export-widget.ts +28 -1
  8. package/src/widgets/helpers/safe-image.ts +78 -0
  9. package/src/widgets/notebook/.gitkeep +0 -0
  10. package/src/widgets/notebook/chart-renderer.ts +63 -0
  11. package/src/widgets/notebook/executors/.gitkeep +1 -0
  12. package/src/widgets/notebook/executors/index.ts +4 -0
  13. package/src/widgets/notebook/executors/js-worker.ts +269 -0
  14. package/src/widgets/notebook/executors/sql.ts +206 -0
  15. package/src/widgets/notebook/import-modals.ts +560 -0
  16. package/src/widgets/notebook/left-pane.ts +256 -0
  17. package/src/widgets/notebook/notebook.ts +930 -0
  18. package/src/widgets/notebook/prose.ts +615 -0
  19. package/src/widgets/notebook/recipe-browser.ts +350 -0
  20. package/src/widgets/notebook/recipes/notebook.md +124 -0
  21. package/src/widgets/notebook/resource-extractor.ts +162 -0
  22. package/src/widgets/notebook/share-handlers.ts +222 -0
  23. package/src/widgets/notebook/shared.ts +1633 -0
  24. package/src/widgets/rich/cards.ts +181 -0
  25. package/src/widgets/rich/carousel.ts +319 -0
  26. package/src/widgets/rich/chart-rich.ts +386 -0
  27. package/src/widgets/rich/d3.ts +503 -0
  28. package/src/widgets/rich/data-table.ts +342 -0
  29. package/src/widgets/rich/gallery.ts +350 -0
  30. package/src/widgets/rich/grid-data.ts +173 -0
  31. package/src/widgets/rich/hemicycle.ts +313 -0
  32. package/src/widgets/rich/js-sandbox.ts +122 -0
  33. package/src/widgets/rich/json-viewer.ts +202 -0
  34. package/src/widgets/rich/log.ts +143 -0
  35. package/src/widgets/rich/map.ts +218 -0
  36. package/src/widgets/rich/profile.ts +256 -0
  37. package/src/widgets/rich/sankey.ts +257 -0
  38. package/src/widgets/rich/stat-card.ts +125 -0
  39. package/src/widgets/rich/timeline.ts +179 -0
  40. package/src/widgets/rich/trombinoscope.ts +246 -0
  41. package/src/widgets/simple/actions.ts +89 -0
  42. package/src/widgets/simple/alert.ts +100 -0
  43. package/src/widgets/simple/chart.ts +189 -0
  44. package/src/widgets/simple/code.ts +79 -0
  45. package/src/widgets/simple/kv.ts +68 -0
  46. package/src/widgets/simple/list.ts +89 -0
  47. package/src/widgets/simple/stat.ts +58 -0
  48. package/src/widgets/simple/tags.ts +125 -0
  49. package/src/widgets/simple/text.ts +198 -0
  50. package/src/widgets/SafeImage.svelte +0 -76
  51. package/src/widgets/rich/Cards.svelte +0 -39
  52. package/src/widgets/rich/Carousel.svelte +0 -88
  53. package/src/widgets/rich/Chart.svelte +0 -142
  54. package/src/widgets/rich/D3Widget.svelte +0 -378
  55. package/src/widgets/rich/DataTable.svelte +0 -62
  56. package/src/widgets/rich/Gallery.svelte +0 -94
  57. package/src/widgets/rich/GridData.svelte +0 -44
  58. package/src/widgets/rich/Hemicycle.svelte +0 -78
  59. package/src/widgets/rich/JsSandbox.svelte +0 -51
  60. package/src/widgets/rich/JsonViewer.svelte +0 -42
  61. package/src/widgets/rich/LogViewer.svelte +0 -24
  62. package/src/widgets/rich/MapView.svelte +0 -140
  63. package/src/widgets/rich/ProfileCard.svelte +0 -59
  64. package/src/widgets/rich/Sankey.svelte +0 -56
  65. package/src/widgets/rich/StatCard.svelte +0 -35
  66. package/src/widgets/rich/Timeline.svelte +0 -43
  67. package/src/widgets/rich/Trombinoscope.svelte +0 -48
  68. package/src/widgets/simple/ActionsBlock.svelte +0 -15
  69. package/src/widgets/simple/AlertBlock.svelte +0 -11
  70. package/src/widgets/simple/ChartBlock.svelte +0 -21
  71. package/src/widgets/simple/CodeBlock.svelte +0 -11
  72. package/src/widgets/simple/KVBlock.svelte +0 -16
  73. package/src/widgets/simple/ListBlock.svelte +0 -17
  74. package/src/widgets/simple/StatBlock.svelte +0 -14
  75. package/src/widgets/simple/TagsBlock.svelte +0 -15
  76. package/src/widgets/simple/TextBlock.svelte +0 -122
@@ -0,0 +1,128 @@
1
+ /**
2
+ * UI scale — binary 1× / 1.5× toggle, labeled as "2×" in the UI for affordance.
3
+ *
4
+ * Implementation: CSS `zoom` on `<html>`, driven by a custom property set
5
+ * imperatively. This rescales everything uniformly (fonts, icons, borders,
6
+ * modals, widgets) while keeping layout reflow (contrary to `transform: scale`).
7
+ *
8
+ * Persistence: `localStorage['webmcp-ui-scale']` — "x1" | "x2".
9
+ */
10
+
11
+ export type UIScale = 1 | 1.5;
12
+ export type UIScaleKey = 'x1' | 'x2';
13
+
14
+ const STORAGE_KEY = 'webmcp-ui-scale';
15
+ const STYLE_ID = 'webmcp-ui-scale-style';
16
+
17
+ function injectStyle(): void {
18
+ if (typeof document === 'undefined') return;
19
+ if (document.getElementById(STYLE_ID)) return;
20
+ const style = document.createElement('style');
21
+ style.id = STYLE_ID;
22
+ // When scaled:
23
+ // - zoom the root so everything grows uniformly
24
+ // - compensate layouts sized in 100vh/100vw (viewport units stay tied to the
25
+ // physical viewport regardless of zoom, so a `h-screen` container rendered
26
+ // at 1.5× would overflow by 50%). Force these containers to the logical
27
+ // equivalent so they fit after scaling.
28
+ // - keep html/body overflow:hidden (matches the unscaled baseline) so
29
+ // floating bars with flex-shrink-0 stay pinned to the viewport edge
30
+ // instead of drifting mid-screen when the body scrolls.
31
+ style.textContent = `
32
+ html { zoom: var(--ui-scale, 1); }
33
+ html[data-ui-scale="x2"] {
34
+ width: calc(100vw / 1.5);
35
+ height: calc(100vh / 1.5);
36
+ overflow: hidden;
37
+ }
38
+ html[data-ui-scale="x2"] body {
39
+ width: 100%;
40
+ height: 100%;
41
+ overflow: hidden;
42
+ }
43
+ html[data-ui-scale="x2"] .h-screen,
44
+ html[data-ui-scale="x2"] [class*="h-screen"] {
45
+ height: calc(100vh / 1.5);
46
+ max-height: calc(100vh / 1.5);
47
+ }
48
+ html[data-ui-scale="x2"] .w-screen,
49
+ html[data-ui-scale="x2"] [class*="w-screen"] {
50
+ width: calc(100vw / 1.5);
51
+ max-width: calc(100vw / 1.5);
52
+ }
53
+ `;
54
+ document.head.appendChild(style);
55
+ }
56
+
57
+ function applyScale(scale: UIScale): void {
58
+ if (typeof document === 'undefined') return;
59
+ injectStyle();
60
+ const root = document.documentElement;
61
+ root.style.setProperty('--ui-scale', String(scale));
62
+ root.dataset.uiScale = scale === 1 ? 'x1' : 'x2';
63
+ try {
64
+ window.dispatchEvent(new CustomEvent('webmcp:ui-scale-change', { detail: { scale } }));
65
+ } catch { /* ignore */ }
66
+ // Canvas-based widgets (cytoscape, d3, vega-embed, leaflet, custom SVG) size
67
+ // their rendering surface at mount via getBoundingClientRect and don't react
68
+ // to CSS-only zoom changes. Fire a resize event after the browser has applied
69
+ // the zoom (next RAF) so those libs redraw against the new container size.
70
+ // A second tick after ~200ms catches widgets that listen via debounced
71
+ // ResizeObserver + widgets that rebuild on a longer cycle.
72
+ try {
73
+ requestAnimationFrame(() => {
74
+ window.dispatchEvent(new Event('resize'));
75
+ setTimeout(() => window.dispatchEvent(new Event('resize')), 200);
76
+ });
77
+ } catch { /* ignore */ }
78
+ }
79
+
80
+ function readStored(): UIScale {
81
+ if (typeof localStorage === 'undefined') return 1;
82
+ try {
83
+ const raw = localStorage.getItem(STORAGE_KEY);
84
+ if (raw === 'x2') return 1.5;
85
+ return 1;
86
+ } catch { return 1; }
87
+ }
88
+
89
+ function writeStored(scale: UIScale): void {
90
+ if (typeof localStorage === 'undefined') return;
91
+ try { localStorage.setItem(STORAGE_KEY, scale === 1 ? 'x1' : 'x2'); } catch { /* ignore */ }
92
+ }
93
+
94
+ /** Current scale value. Safe to call before init. */
95
+ export function getUIScale(): UIScale {
96
+ if (typeof document === 'undefined') return readStored();
97
+ const key = document.documentElement.dataset.uiScale;
98
+ if (key === 'x2') return 1.5;
99
+ if (key === 'x1') return 1;
100
+ return readStored();
101
+ }
102
+
103
+ /** True when the UI is currently displayed at 1.5× (label "2×" in the UI). */
104
+ export function isUIScaled(): boolean {
105
+ return getUIScale() !== 1;
106
+ }
107
+
108
+ /** Set the scale explicitly. Persists + applies. */
109
+ export function setUIScale(scale: UIScale): void {
110
+ writeStored(scale);
111
+ applyScale(scale);
112
+ }
113
+
114
+ /** Toggle between 1× and 1.5×. Returns the new scale. */
115
+ export function toggleUIScale(): UIScale {
116
+ const next: UIScale = getUIScale() === 1 ? 1.5 : 1;
117
+ setUIScale(next);
118
+ return next;
119
+ }
120
+
121
+ /**
122
+ * Read persisted scale and apply it. Call once at app boot (e.g. in a root
123
+ * +layout onMount) to restore the user's preference before first paint.
124
+ * Safe to call multiple times.
125
+ */
126
+ export function initUIScale(): void {
127
+ applyScale(readStored());
128
+ }
@@ -1,96 +1,95 @@
1
1
  <script lang="ts">
2
- import { type Component, onMount, onDestroy } from 'svelte';
2
+ import { type Component, onMount, onDestroy, untrack } from 'svelte';
3
3
  import type { WebMcpServer } from '@webmcp-auto-ui/core';
4
4
  import { bus } from '../messaging/bus.svelte.js';
5
- // Simple widgets
6
- import StatBlock from './simple/StatBlock.svelte';
7
- import KVBlock from './simple/KVBlock.svelte';
8
- import ListBlock from './simple/ListBlock.svelte';
9
- import ChartBlock from './simple/ChartBlock.svelte';
10
- import AlertBlock from './simple/AlertBlock.svelte';
11
- import CodeBlock from './simple/CodeBlock.svelte';
12
- import TextBlock from './simple/TextBlock.svelte';
13
- import ActionsBlock from './simple/ActionsBlock.svelte';
14
- import TagsBlock from './simple/TagsBlock.svelte';
15
- // Rich widgets
16
- import StatCard from './rich/StatCard.svelte';
17
- import DataTable from './rich/DataTable.svelte';
18
- import Timeline from './rich/Timeline.svelte';
19
- import ProfileCard from './rich/ProfileCard.svelte';
20
- import Trombinoscope from './rich/Trombinoscope.svelte';
21
- import JsonViewer from './rich/JsonViewer.svelte';
22
- import Hemicycle from './rich/Hemicycle.svelte';
23
- import Chart from './rich/Chart.svelte';
24
- import Cards from './rich/Cards.svelte';
25
- import GridData from './rich/GridData.svelte';
26
- import Sankey from './rich/Sankey.svelte';
27
- import MapView from './rich/MapView.svelte';
28
- import D3Widget from './rich/D3Widget.svelte';
29
- import JsSandbox from './rich/JsSandbox.svelte';
30
- import LogViewer from './rich/LogViewer.svelte';
31
- import Gallery from './rich/Gallery.svelte';
32
- import Carousel from './rich/Carousel.svelte';
33
-
34
- type Emit = (action: string, payload: unknown) => void;
35
-
36
- /** Native widget entry: component + prop builder */
37
- type NativeEntry = {
38
- component: Component<any>;
39
- props: (data: Record<string, unknown>, emit: Emit) => Record<string, unknown>;
40
- };
41
5
 
42
- /** Simple helper: passes { data } */
43
- const d = (c: Component<any>): NativeEntry => ({
44
- component: c,
45
- props: (data) => ({ data }),
46
- });
6
+ // Monotonic counter for busId generation avoids collisions on sub-ms remounts.
7
+ let busIdCounter = 0;
8
+ function makeBusId(widgetType: string): string {
9
+ if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
10
+ return `block_${widgetType}_${crypto.randomUUID()}`;
11
+ }
12
+ return `block_${widgetType}_${Date.now()}-${++busIdCounter}`;
13
+ }
47
14
 
48
- /** Spec helper: passes { spec } */
49
- const s = (c: Component<any>): NativeEntry => ({
50
- component: c,
51
- props: (data) => ({ spec: data }),
52
- });
15
+ // Safe clone for data passed to vanilla renderers — strips Svelte 5 $state
16
+ // proxies without crashing on BigInt / circular refs / undefined values.
17
+ function safeClone<T>(value: T): T {
18
+ if (value === null || typeof value !== 'object') return value;
19
+ try {
20
+ return structuredClone(value);
21
+ } catch (err) {
22
+ console.warn('[WidgetRenderer] structuredClone failed, falling back to shallow copy', err);
23
+ return { ...(value as Record<string, unknown>) } as T;
24
+ }
25
+ }
53
26
 
54
- /** Spec + events helper */
55
- const se = (
56
- c: Component<any>,
57
- events: (emit: Emit) => Record<string, unknown>,
58
- ): NativeEntry => ({
59
- component: c,
60
- props: (data, emit) => ({ spec: data, ...events(emit) }),
61
- });
27
+ // ── Native vanilla renderers ─────────────────────────────────────────────
28
+ // Simple widgets
29
+ import { render as renderStat } from './simple/stat.js';
30
+ import { render as renderKv } from './simple/kv.js';
31
+ import { render as renderList } from './simple/list.js';
32
+ import { render as renderChart } from './simple/chart.js';
33
+ import { render as renderAlert } from './simple/alert.js';
34
+ import { render as renderCode } from './simple/code.js';
35
+ import { render as renderText } from './simple/text.js';
36
+ import { render as renderActions } from './simple/actions.js';
37
+ import { render as renderTags } from './simple/tags.js';
38
+ // Rich widgets
39
+ import { render as renderStatCard } from './rich/stat-card.js';
40
+ import { render as renderProfile } from './rich/profile.js';
41
+ import { render as renderJsonViewer } from './rich/json-viewer.js';
42
+ import { render as renderChartRich } from './rich/chart-rich.js';
43
+ import { render as renderSankey } from './rich/sankey.js';
44
+ import { render as renderHemicycle } from './rich/hemicycle.js';
45
+ import { render as renderDataTable } from './rich/data-table.js';
46
+ import { render as renderTimeline } from './rich/timeline.js';
47
+ import { render as renderTrombinoscope } from './rich/trombinoscope.js';
48
+ import { render as renderCards } from './rich/cards.js';
49
+ import { render as renderGridData } from './rich/grid-data.js';
50
+ import { render as renderMap } from './rich/map.js';
51
+ import { render as renderD3 } from './rich/d3.js';
52
+ import { render as renderJsSandbox } from './rich/js-sandbox.js';
53
+ import { render as renderLog } from './rich/log.js';
54
+ import { render as renderGallery } from './rich/gallery.js';
55
+ import { render as renderCarousel } from './rich/carousel.js';
56
+
57
+ /** A vanilla renderer: returns cleanup or Promise thereof. */
58
+ type VanillaRenderer = (
59
+ container: HTMLElement,
60
+ data: Record<string, unknown>,
61
+ ) => void | (() => void) | Promise<void | (() => void)>;
62
62
 
63
- /** Static map of all native widget types → component + props */
64
- const NATIVE_MAP: Record<string, NativeEntry> = {
65
- // Simple widgets (data prop)
66
- 'stat': d(StatBlock),
67
- 'kv': d(KVBlock),
68
- 'list': { component: ListBlock, props: (data, emit) => ({ data, onitemclick: (item: unknown, index: unknown) => emit('itemclick', { item, index }) }) },
69
- 'chart': d(ChartBlock),
70
- 'alert': d(AlertBlock),
71
- 'code': d(CodeBlock),
72
- 'text': d(TextBlock),
73
- 'actions': d(ActionsBlock),
74
- 'tags': d(TagsBlock),
75
- // Rich widgets (spec prop)
76
- 'stat-card': s(StatCard),
77
- 'profile': s(ProfileCard),
78
- 'json-viewer': s(JsonViewer),
79
- 'chart-rich': s(Chart),
80
- 'sankey': s(Sankey),
81
- 'map': s(MapView),
82
- 'd3': s(D3Widget),
83
- 'js-sandbox': s(JsSandbox),
84
- 'log': s(LogViewer),
85
- // Rich widgets (spec prop + event callbacks)
86
- 'data-table': se(DataTable, (emit) => ({ onrowclick: (row: unknown) => emit('rowclick', row) })),
87
- 'timeline': se(Timeline, (emit) => ({ oneventclick: (e: unknown) => emit('eventclick', e) })),
88
- 'trombinoscope': se(Trombinoscope, (emit) => ({ onpersonclick: (p: unknown) => emit('personclick', p) })),
89
- 'hemicycle': se(Hemicycle, (emit) => ({ ongroupclick: (g: unknown) => emit('groupclick', g) })),
90
- 'cards': se(Cards, (emit) => ({ oncardclick: (c: unknown) => emit('cardclick', c) })),
91
- 'grid-data': se(GridData, (emit) => ({ oncellclick: (r: unknown, c: unknown, v: unknown) => emit('cellclick', { row: r, col: c, value: v }) })),
92
- 'gallery': se(Gallery, (emit) => ({ onimageclick: (img: unknown, i: unknown) => emit('imageclick', { image: img, index: i }) })),
93
- 'carousel': se(Carousel, (emit) => ({ onslidechange: (slide: unknown, i: unknown) => emit('slidechange', { slide, index: i }) })),
63
+ /** Static map of all native widget types → vanilla renderer */
64
+ const NATIVE_VANILLA_MAP: Record<string, VanillaRenderer> = {
65
+ // Simple
66
+ 'stat': renderStat,
67
+ 'kv': renderKv,
68
+ 'list': renderList,
69
+ 'chart': renderChart,
70
+ 'alert': renderAlert,
71
+ 'code': renderCode,
72
+ 'text': renderText,
73
+ 'actions': renderActions,
74
+ 'tags': renderTags,
75
+ // Rich
76
+ 'stat-card': renderStatCard,
77
+ 'profile': renderProfile,
78
+ 'json-viewer': renderJsonViewer,
79
+ 'chart-rich': renderChartRich,
80
+ 'sankey': renderSankey,
81
+ 'hemicycle': renderHemicycle,
82
+ 'data-table': renderDataTable,
83
+ 'timeline': renderTimeline,
84
+ 'trombinoscope': renderTrombinoscope,
85
+ 'cards': renderCards,
86
+ 'grid-data': renderGridData,
87
+ 'map': renderMap,
88
+ 'd3': renderD3 as unknown as VanillaRenderer,
89
+ 'js-sandbox': renderJsSandbox as unknown as VanillaRenderer,
90
+ 'log': renderLog,
91
+ 'gallery': renderGallery,
92
+ 'carousel': renderCarousel,
94
93
  };
95
94
 
96
95
  interface Props {
@@ -103,7 +102,7 @@
103
102
  let { id, type, data, servers, oninteract }: Props = $props();
104
103
 
105
104
  // Auto-register on the FONC message bus
106
- const busId = id ?? `block_${type}_${Date.now().toString(36)}`;
105
+ const busId = id ?? makeBusId(type);
107
106
  const unregisterBus = bus.register(busId, type, ['data-update', 'interact', '*'], (msg) => {
108
107
  if (msg.channel === 'data-update' && msg.payload && typeof msg.payload === 'object') {
109
108
  oninteract?.(type, 'bus-update', msg.payload);
@@ -116,7 +115,7 @@
116
115
  bus.broadcast(busId, 'interact', { type, action, payload });
117
116
  }
118
117
 
119
- // ── Renderer resolution: servers > native > fallback ────────────
118
+ // ── Renderer resolution: servers > native vanilla > fallback ────────────
120
119
 
121
120
  // Look up a custom widget entry from connected WebMCP servers
122
121
  const customWidgetEntry = $derived.by(() => {
@@ -129,36 +128,52 @@
129
128
  });
130
129
 
131
130
  const customRenderer = $derived(customWidgetEntry?.renderer ?? null);
131
+ const isCustomVanilla = $derived(customWidgetEntry?.vanilla === true);
132
+
133
+ const nativeVanillaRenderer = $derived<VanillaRenderer | undefined>(
134
+ customRenderer ? undefined : NATIVE_VANILLA_MAP[type],
135
+ );
132
136
 
133
- /** True when the custom renderer is a vanilla function (not a Svelte component) */
134
- const isVanillaRenderer = $derived(customWidgetEntry?.vanilla === true);
137
+ /** True when a vanilla renderer (custom or native) should be used */
138
+ const useVanilla = $derived(isCustomVanilla || !!nativeVanillaRenderer);
135
139
 
136
- const nativeEntry: NativeEntry | undefined = $derived(
137
- customRenderer ? undefined : NATIVE_MAP[type],
140
+ /** The effective vanilla renderer to invoke */
141
+ const vanillaRenderer = $derived<VanillaRenderer | null>(
142
+ isCustomVanilla
143
+ ? (customRenderer as VanillaRenderer)
144
+ : (nativeVanillaRenderer ?? null),
138
145
  );
139
146
 
140
- // Deep-clone data to strip Svelte 5 $state proxies — only needed for vanilla
141
- // renderers whose third-party libs (Cytoscape, Plotly, etc.) use
142
- // Object.defineProperty which conflicts with Svelte 5 proxies.
143
- // Native Svelte components handle proxied data fine and benefit from
144
- // fine-grained reactivity, so they receive raw `data` directly.
145
- const plainData: Record<string, unknown> = $derived(JSON.parse(JSON.stringify(data)));
147
+ // Deep-clone data to strip Svelte 5 $state proxies — vanilla renderers + third-party
148
+ // libs (D3, Leaflet, etc.) rely on Object.defineProperty which conflicts with proxies.
149
+ const plainData: Record<string, unknown> = $derived(safeClone(data) as Record<string, unknown>);
146
150
 
147
151
  // ── Vanilla renderer container + lifecycle ────────────
148
152
  let vanillaContainer: HTMLElement | undefined = $state(undefined);
149
153
 
154
+ // Mount effect — re-runs only when the widget identity changes (type /
155
+ // renderer / container). Data changes are handled separately to avoid a
156
+ // flickering full remount on every agent update.
150
157
  $effect(() => {
151
- if (!isVanillaRenderer || !vanillaContainer) return;
152
- // Clear previous content
153
- vanillaContainer.innerHTML = '';
158
+ // Touch only the mount deps; `plainData` is intentionally read via untrack
159
+ // so data updates don't retrigger this effect.
160
+ if (!useVanilla || !vanillaRenderer || !vanillaContainer) return;
161
+ const container = vanillaContainer;
162
+ const renderer = vanillaRenderer;
163
+ container.innerHTML = '';
164
+
165
+ const onInteract = (ev: Event) => {
166
+ const ce = ev as CustomEvent<{ action?: string; payload?: unknown }>;
167
+ const action = ce.detail?.action ?? 'interact';
168
+ emit(action, ce.detail?.payload);
169
+ };
170
+ container.addEventListener('widget:interact', onInteract);
154
171
 
155
172
  let cleanup: (() => void) | void;
156
173
  let cancelled = false;
157
174
 
158
175
  try {
159
- const result = (customRenderer as (container: HTMLElement, data: Record<string, unknown>) => void | (() => void) | Promise<void | (() => void)>)(
160
- vanillaContainer, plainData,
161
- );
176
+ const result = renderer(container, untrack(() => plainData));
162
177
  if (result && typeof (result as Promise<unknown>).then === 'function') {
163
178
  (result as Promise<void | (() => void)>).then(
164
179
  (c) => { if (!cancelled) cleanup = c ?? undefined; },
@@ -172,12 +187,36 @@
172
187
 
173
188
  return () => {
174
189
  cancelled = true;
190
+ container.removeEventListener('widget:interact', onInteract);
175
191
  if (typeof cleanup === 'function') {
176
192
  try { cleanup(); } catch (err) { console.error('[WidgetRenderer] cleanup failed:', err); }
177
193
  }
178
194
  };
179
195
  });
180
196
 
197
+ // Data-update effect — only triggers when plainData changes. Renderers that
198
+ // support in-place updates should listen for `widget:data-update` and call
199
+ // preventDefault() on the event; doing so signals they've handled the new
200
+ // data themselves so we don't need to remount. Renderers that don't listen
201
+ // fall back to a full remount here (innerHTML cleared + mount effect re-run).
202
+ let firstDataCycle = true;
203
+ $effect(() => {
204
+ const data = plainData;
205
+ if (!vanillaContainer || !useVanilla) return;
206
+ if (firstDataCycle) { firstDataCycle = false; return; }
207
+ const ev = new CustomEvent('widget:data-update', { detail: data, cancelable: true });
208
+ const handled = !vanillaContainer.dispatchEvent(ev);
209
+ if (handled || !vanillaRenderer) return;
210
+ // Not handled — fall back to remount by clearing + calling renderer again.
211
+ const container = vanillaContainer;
212
+ container.innerHTML = '';
213
+ try {
214
+ vanillaRenderer(container, data);
215
+ } catch (err) {
216
+ console.error('[WidgetRenderer] fallback remount failed:', err);
217
+ }
218
+ });
219
+
181
220
  // ── Auto-register WebMCP tools when modelContext is available ────────────
182
221
  type ModelContext = {
183
222
  registerTool: (t: unknown) => void;
@@ -238,12 +277,10 @@
238
277
  });
239
278
  </script>
240
279
 
241
- {#if isVanillaRenderer}
280
+ {#if useVanilla}
242
281
  <div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
243
282
  {:else if customRenderer}
244
283
  <svelte:component this={customRenderer as Component<any>} {data} {id} />
245
- {:else if nativeEntry}
246
- <svelte:component this={nativeEntry.component} {...nativeEntry.props(data, emit)} />
247
284
  {:else}
248
285
  <div class="p-3 font-mono text-xs text-text2">[{type}]</div>
249
286
  {/if}
@@ -5,7 +5,15 @@
5
5
 
6
6
  import { toPng } from 'html-to-image';
7
7
 
8
- const TARGET_PNG_WIDTH = 2048;
8
+ const TARGET_PNG_WIDTH = 4096;
9
+
10
+ export type WidgetExportHook = (scale: number) => Promise<Blob>;
11
+
12
+ declare global {
13
+ interface HTMLElement {
14
+ __exportPng?: WidgetExportHook;
15
+ }
16
+ }
9
17
 
10
18
  // ── helpers ──────────────────────────────────────────────────────────────────
11
19
 
@@ -95,6 +103,25 @@ async function exportPng(type: string, containerEl: HTMLElement): Promise<void>
95
103
  const scrollH = Math.max(containerEl.scrollHeight, containerEl.clientHeight, 1);
96
104
  const pixelRatio = TARGET_PNG_WIDTH / scrollW;
97
105
 
106
+ // Canvas-based widgets (cytoscape, etc.) expose a native exporter via __exportPng.
107
+ // html-to-image would only recapture the already-rasterised on-screen pixels,
108
+ // so we route to the widget's own resolution-independent exporter when available.
109
+ if (typeof containerEl.__exportPng === 'function') {
110
+ const scale = Math.max(2, Math.ceil(TARGET_PNG_WIDTH / scrollW));
111
+ try {
112
+ const blob = await containerEl.__exportPng(scale);
113
+ const url = URL.createObjectURL(blob);
114
+ const a = document.createElement('a');
115
+ a.href = url;
116
+ a.download = name;
117
+ a.click();
118
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
119
+ return;
120
+ } catch (err) {
121
+ console.error('[exportWidget] native __exportPng failed, falling back to html-to-image', err);
122
+ }
123
+ }
124
+
98
125
  const bg = (typeof document !== 'undefined'
99
126
  ? getComputedStyle(document.documentElement).getPropertyValue('--color-surface').trim()
100
127
  : '') || '#ffffff';
@@ -0,0 +1,78 @@
1
+ /**
2
+ * SafeImage (vanilla) — URL validation, error fallback, placeholder rendering.
3
+ * Replaces SafeImage.svelte. Used by Profile/Cards/Carousel/Gallery/Trombinoscope renderers.
4
+ */
5
+
6
+ export interface SafeImageOptions {
7
+ src: string | undefined | null;
8
+ alt?: string;
9
+ className?: string;
10
+ style?: string;
11
+ loading?: 'lazy' | 'eager';
12
+ fallbackText?: string;
13
+ hideOnError?: boolean;
14
+ }
15
+
16
+ const VALID_PREFIXES = ['http://', 'https://', 'data:', '/'];
17
+
18
+ function isValidSrc(src: unknown): src is string {
19
+ return typeof src === 'string' && src.length > 0 && VALID_PREFIXES.some((p) => (src as string).startsWith(p));
20
+ }
21
+
22
+ function escapeHtml(s: string): string {
23
+ return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
24
+ }
25
+
26
+ function buildPlaceholder(label: string, opts: SafeImageOptions): HTMLElement {
27
+ const div = document.createElement('div');
28
+ div.className = `flex items-center justify-center bg-surface2 text-text2 text-xs ${opts.className ?? ''}`.trim();
29
+ if (opts.style) div.setAttribute('style', opts.style);
30
+ div.setAttribute('role', 'img');
31
+ div.setAttribute('aria-label', label);
32
+ div.innerHTML =
33
+ '<svg class="w-5 h-5 opacity-40 mr-1" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
34
+ '<rect x="3" y="3" width="18" height="18" rx="2"/>' +
35
+ '<circle cx="8.5" cy="8.5" r="1.5"/>' +
36
+ '<path d="M21 15l-5-5L5 21"/></svg>' +
37
+ `<span class="truncate max-w-[80%]">${escapeHtml(label)}</span>`;
38
+ return div;
39
+ }
40
+
41
+ function buildHidden(): HTMLElement {
42
+ const el = document.createElement('div');
43
+ el.style.display = 'none';
44
+ el.setAttribute('aria-hidden', 'true');
45
+ return el;
46
+ }
47
+
48
+ /**
49
+ * Returns an HTMLElement for the given image source.
50
+ * - Invalid/missing URL → placeholder (or hidden element if hideOnError).
51
+ * - Valid URL → <img> that self-replaces with placeholder on load error.
52
+ */
53
+ export function createSafeImage(opts: SafeImageOptions): HTMLElement {
54
+ const label = opts.fallbackText ?? opts.alt ?? 'Image';
55
+
56
+ if (!isValidSrc(opts.src)) {
57
+ return opts.hideOnError ? buildHidden() : buildPlaceholder(label, opts);
58
+ }
59
+
60
+ const img = document.createElement('img');
61
+ img.src = opts.src;
62
+ img.alt = opts.alt ?? '';
63
+ if (opts.className) img.className = opts.className;
64
+ if (opts.style) img.setAttribute('style', opts.style);
65
+ img.loading = opts.loading ?? 'lazy';
66
+ img.referrerPolicy = 'no-referrer';
67
+
68
+ img.addEventListener(
69
+ 'error',
70
+ () => {
71
+ const replacement = opts.hideOnError ? buildHidden() : buildPlaceholder(label, opts);
72
+ img.replaceWith(replacement);
73
+ },
74
+ { once: true },
75
+ );
76
+
77
+ return img;
78
+ }
File without changes
@@ -0,0 +1,63 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Chart renderer — Vega-Lite via lazy dynamic import of vega-embed.
3
+ //
4
+ // Design notes:
5
+ // - vega-embed is imported dynamically so it does not bloat the initial
6
+ // bundle. The first chart render pays a one-time cost; subsequent charts
7
+ // share the cached module.
8
+ // - On failure (missing dep, invalid spec, CSP denies eval, etc.) we fall
9
+ // back to a readable <pre>JSON</pre> preview of the spec so the user
10
+ // still sees *something* rather than an empty panel.
11
+ // - vega-embed is declared as an optional peer dependency in package.json;
12
+ // apps that want interactive charts install it, others degrade gracefully.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ export interface ChartRenderer {
16
+ mount(container: HTMLElement, spec: unknown): void | Promise<void>;
17
+ destroy(): void;
18
+ }
19
+
20
+ let cachedEmbed: Promise<any> | null = null;
21
+
22
+ function loadEmbed(): Promise<any> {
23
+ if (!cachedEmbed) {
24
+ // @ts-ignore — vega-embed is an optional peer dep; resolved at runtime
25
+ cachedEmbed = import('vega-embed').then((m: any) => m?.default ?? m);
26
+ }
27
+ return cachedEmbed;
28
+ }
29
+
30
+ function escapeHtml(s: string): string {
31
+ return String(s ?? '').replace(/[&<>"']/g, (c) =>
32
+ ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
33
+ }
34
+
35
+ function renderFallback(container: HTMLElement, spec: unknown, reason?: string): void {
36
+ let preview: string;
37
+ try { preview = JSON.stringify(spec, null, 2); } catch { preview = String(spec); }
38
+ const note = reason ? `<div class="nb-chart-fallback-note">${escapeHtml(reason)}</div>` : '';
39
+ container.innerHTML = `${note}<pre class="nb-chart-fallback">${escapeHtml(preview)}</pre>`;
40
+ }
41
+
42
+ /**
43
+ * Render a Vega / Vega-Lite spec into the given container. Resolves once the
44
+ * chart is mounted (or falls back to a JSON preview on error). Never throws.
45
+ */
46
+ export async function renderChart(container: HTMLElement, spec: unknown): Promise<void> {
47
+ if (!container) return;
48
+ if (!spec || typeof spec !== 'object') {
49
+ renderFallback(container, spec, 'Invalid chart spec');
50
+ return;
51
+ }
52
+ try {
53
+ const embed = await loadEmbed();
54
+ if (typeof embed !== 'function') {
55
+ renderFallback(container, spec, 'vega-embed unavailable');
56
+ return;
57
+ }
58
+ container.innerHTML = '';
59
+ await embed(container, spec as any, { actions: false, renderer: 'canvas' });
60
+ } catch (err: any) {
61
+ renderFallback(container, spec, `chart render failed: ${String(err?.message ?? err)}`);
62
+ }
63
+ }
@@ -0,0 +1 @@
1
+ for Agent F (sql.ts, js-worker.ts, worker-runtime.ts)
@@ -0,0 +1,4 @@
1
+ export { createSqlExecutor } from './sql.js';
2
+ export type { SqlExecutorOptions } from './sql.js';
3
+ export { createJsExecutor } from './js-worker.js';
4
+ export type { JsExecutorOptions } from './js-worker.js';