@webmcp-auto-ui/ui 2.5.27 → 2.5.28

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 (82) hide show
  1. package/package.json +15 -3
  2. package/src/agent/DataServersPanel.svelte +164 -0
  3. package/src/agent/LLMSelector.svelte +11 -3
  4. package/src/agent/ModelCacheManager.svelte +359 -0
  5. package/src/index.ts +42 -30
  6. package/src/widgets/WidgetRenderer.svelte +114 -104
  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/compact.ts +823 -0
  12. package/src/widgets/notebook/document.ts +1065 -0
  13. package/src/widgets/notebook/editorial.ts +936 -0
  14. package/src/widgets/notebook/executors/.gitkeep +1 -0
  15. package/src/widgets/notebook/executors/index.ts +4 -0
  16. package/src/widgets/notebook/executors/js-worker.ts +269 -0
  17. package/src/widgets/notebook/executors/sql.ts +206 -0
  18. package/src/widgets/notebook/import-modals.ts +553 -0
  19. package/src/widgets/notebook/left-pane.ts +249 -0
  20. package/src/widgets/notebook/prose.ts +280 -0
  21. package/src/widgets/notebook/recipe-browser.ts +350 -0
  22. package/src/widgets/notebook/recipes/compact.md +124 -0
  23. package/src/widgets/notebook/recipes/document.md +139 -0
  24. package/src/widgets/notebook/recipes/editorial.md +120 -0
  25. package/src/widgets/notebook/recipes/workspace.md +119 -0
  26. package/src/widgets/notebook/resource-extractor.ts +162 -0
  27. package/src/widgets/notebook/share-handlers.ts +222 -0
  28. package/src/widgets/notebook/shared.ts +1592 -0
  29. package/src/widgets/notebook/workspace.ts +852 -0
  30. package/src/widgets/rich/cards.ts +181 -0
  31. package/src/widgets/rich/carousel.ts +319 -0
  32. package/src/widgets/rich/chart-rich.ts +386 -0
  33. package/src/widgets/rich/d3.ts +503 -0
  34. package/src/widgets/rich/data-table.ts +342 -0
  35. package/src/widgets/rich/gallery.ts +350 -0
  36. package/src/widgets/rich/grid-data.ts +173 -0
  37. package/src/widgets/rich/hemicycle.ts +313 -0
  38. package/src/widgets/rich/js-sandbox.ts +106 -0
  39. package/src/widgets/rich/json-viewer.ts +202 -0
  40. package/src/widgets/rich/log.ts +143 -0
  41. package/src/widgets/rich/map.ts +218 -0
  42. package/src/widgets/rich/profile.ts +256 -0
  43. package/src/widgets/rich/sankey.ts +262 -0
  44. package/src/widgets/rich/stat-card.ts +125 -0
  45. package/src/widgets/rich/timeline.ts +179 -0
  46. package/src/widgets/rich/trombinoscope.ts +246 -0
  47. package/src/widgets/simple/actions.ts +89 -0
  48. package/src/widgets/simple/alert.ts +100 -0
  49. package/src/widgets/simple/chart.ts +189 -0
  50. package/src/widgets/simple/code.ts +79 -0
  51. package/src/widgets/simple/kv.ts +68 -0
  52. package/src/widgets/simple/list.ts +89 -0
  53. package/src/widgets/simple/stat.ts +58 -0
  54. package/src/widgets/simple/tags.ts +125 -0
  55. package/src/widgets/simple/text.ts +198 -0
  56. package/src/widgets/SafeImage.svelte +0 -76
  57. package/src/widgets/rich/Cards.svelte +0 -39
  58. package/src/widgets/rich/Carousel.svelte +0 -88
  59. package/src/widgets/rich/Chart.svelte +0 -142
  60. package/src/widgets/rich/D3Widget.svelte +0 -378
  61. package/src/widgets/rich/DataTable.svelte +0 -62
  62. package/src/widgets/rich/Gallery.svelte +0 -94
  63. package/src/widgets/rich/GridData.svelte +0 -44
  64. package/src/widgets/rich/Hemicycle.svelte +0 -78
  65. package/src/widgets/rich/JsSandbox.svelte +0 -51
  66. package/src/widgets/rich/JsonViewer.svelte +0 -42
  67. package/src/widgets/rich/LogViewer.svelte +0 -24
  68. package/src/widgets/rich/MapView.svelte +0 -140
  69. package/src/widgets/rich/ProfileCard.svelte +0 -59
  70. package/src/widgets/rich/Sankey.svelte +0 -56
  71. package/src/widgets/rich/StatCard.svelte +0 -35
  72. package/src/widgets/rich/Timeline.svelte +0 -43
  73. package/src/widgets/rich/Trombinoscope.svelte +0 -48
  74. package/src/widgets/simple/ActionsBlock.svelte +0 -15
  75. package/src/widgets/simple/AlertBlock.svelte +0 -11
  76. package/src/widgets/simple/ChartBlock.svelte +0 -21
  77. package/src/widgets/simple/CodeBlock.svelte +0 -11
  78. package/src/widgets/simple/KVBlock.svelte +0 -16
  79. package/src/widgets/simple/ListBlock.svelte +0 -17
  80. package/src/widgets/simple/StatBlock.svelte +0 -14
  81. package/src/widgets/simple/TagsBlock.svelte +0 -15
  82. package/src/widgets/simple/TextBlock.svelte +0 -122
@@ -0,0 +1,256 @@
1
+ import { createSafeImage } from '../helpers/safe-image.js';
2
+
3
+ export interface ProfileField {
4
+ label: string;
5
+ value: string;
6
+ href?: string;
7
+ }
8
+
9
+ export interface ProfileStat {
10
+ label: string;
11
+ value: string;
12
+ }
13
+
14
+ export interface ProfileAction {
15
+ label: string;
16
+ href?: string;
17
+ variant?: 'primary' | 'secondary' | 'danger';
18
+ /** Identifier forwarded in `widget:interact` detail.payload when clicked. */
19
+ id?: string;
20
+ action?: string;
21
+ }
22
+
23
+ export interface ProfileSpec {
24
+ name?: string;
25
+ subtitle?: string;
26
+ avatar?: { src: string; alt?: string };
27
+ badge?: { text: string; variant?: 'default' | 'success' | 'warning' | 'error' };
28
+ fields?: ProfileField[];
29
+ stats?: ProfileStat[];
30
+ actions?: ProfileAction[];
31
+ }
32
+
33
+ const BADGE: Record<string, string> = {
34
+ default: 'bg-surface2 text-text2',
35
+ success: 'bg-teal/20 text-teal',
36
+ warning: 'bg-amber/20 text-amber',
37
+ error: 'bg-accent2/20 text-accent2',
38
+ };
39
+
40
+ const ACTION: Record<string, string> = {
41
+ primary: 'bg-accent text-white',
42
+ secondary: 'bg-surface2 text-text2',
43
+ danger: 'bg-accent2 text-white',
44
+ };
45
+
46
+ function computeInitials(name: string | undefined): string {
47
+ const src = (name ?? '?').trim();
48
+ if (!src) return '?';
49
+ const parts = src.split(/\s+/).slice(0, 2);
50
+ const out = parts.map((w) => w[0] ?? '').join('').toUpperCase();
51
+ return out || '?';
52
+ }
53
+
54
+ /**
55
+ * Vanilla renderer for the "profile" / ProfileCard widget.
56
+ *
57
+ * Mounts a profile card (avatar, name/subtitle/badge, fields, stats, actions)
58
+ * into `container`. Returns a cleanup function that removes listeners and
59
+ * empties the container.
60
+ *
61
+ * Contract:
62
+ * - Pure imperative DOM.
63
+ * - Emits CustomEvent('widget:interact', { detail: { action, payload }, bubbles: true })
64
+ * when a button action is clicked (the Svelte version invoked `a.onclick()`
65
+ * locally; here we both bubble an event AND call `onclick` if provided).
66
+ * - Tailwind classes and CSS vars preserved verbatim.
67
+ * - Avatar fallback: a dedicated initials bubble is rendered when the src is
68
+ * missing/invalid. For valid URLs, createSafeImage adds an error listener
69
+ * that self-replaces with a placeholder — we additionally swap to an
70
+ * initials bubble on load error to match the original behaviour.
71
+ */
72
+ export function render(container: HTMLElement, data: any): () => void {
73
+ const spec: Partial<ProfileSpec> = (data && typeof data === 'object') ? data : {};
74
+ const cleanups: Array<() => void> = [];
75
+
76
+ const root = document.createElement('div');
77
+ root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans max-w-full md:max-w-[480px]';
78
+
79
+ // --- Header (avatar + name/subtitle/badge) ---
80
+ const header = document.createElement('div');
81
+ header.className = 'flex flex-col sm:flex-row items-center sm:items-start mb-4 gap-3 sm:gap-4';
82
+
83
+ const initials = computeInitials(spec.name);
84
+ const avatarClasses = 'w-16 h-16 rounded-full object-cover border-2 border-border2 flex-shrink-0';
85
+ const fallbackClasses = 'w-16 h-16 rounded-full bg-accent text-white flex items-center justify-center text-xl font-bold flex-shrink-0';
86
+
87
+ const buildInitialsFallback = (): HTMLElement => {
88
+ const div = document.createElement('div');
89
+ div.className = fallbackClasses;
90
+ div.setAttribute('role', 'img');
91
+ div.setAttribute('aria-label', spec.name ?? 'Avatar');
92
+ div.textContent = initials;
93
+ return div;
94
+ };
95
+
96
+ const avatarSrc = spec.avatar?.src;
97
+ const avatarAlt = spec.avatar?.alt ?? spec.name ?? '';
98
+ const VALID_PREFIXES = ['http://', 'https://', 'data:', '/'];
99
+ const srcLooksValid =
100
+ typeof avatarSrc === 'string' &&
101
+ avatarSrc.length > 0 &&
102
+ VALID_PREFIXES.some((p) => avatarSrc.startsWith(p));
103
+
104
+ if (srcLooksValid) {
105
+ // Use createSafeImage; also listen for its error to swap in initials fallback.
106
+ const imgEl = createSafeImage({
107
+ src: avatarSrc!,
108
+ alt: avatarAlt,
109
+ className: avatarClasses,
110
+ fallbackText: initials,
111
+ });
112
+ header.appendChild(imgEl);
113
+
114
+ // If it's a real <img>, on error we swap for initials (prefer over placeholder).
115
+ if (imgEl.tagName === 'IMG') {
116
+ const onErr = () => {
117
+ const fb = buildInitialsFallback();
118
+ imgEl.replaceWith(fb);
119
+ };
120
+ imgEl.addEventListener('error', onErr, { once: true });
121
+ cleanups.push(() => imgEl.removeEventListener('error', onErr));
122
+ }
123
+ } else {
124
+ header.appendChild(buildInitialsFallback());
125
+ }
126
+
127
+ // Text block (name, subtitle, badge)
128
+ const textBlock = document.createElement('div');
129
+
130
+ const nameEl = document.createElement('h3');
131
+ nameEl.className = 'text-lg font-bold text-text1 m-0';
132
+ nameEl.textContent = spec.name ?? '';
133
+ textBlock.appendChild(nameEl);
134
+
135
+ if (spec.subtitle) {
136
+ const sub = document.createElement('div');
137
+ sub.className = 'text-sm text-text2 mt-0.5';
138
+ sub.textContent = spec.subtitle;
139
+ textBlock.appendChild(sub);
140
+ }
141
+
142
+ if (spec.badge) {
143
+ const badgeWrap = document.createElement('div');
144
+ badgeWrap.className = 'mt-1';
145
+ const span = document.createElement('span');
146
+ const variantCls = BADGE[spec.badge.variant ?? 'default'] ?? BADGE.default;
147
+ span.className = `text-xs font-semibold px-2 py-0.5 rounded-full ${variantCls}`;
148
+ span.textContent = spec.badge.text;
149
+ badgeWrap.appendChild(span);
150
+ textBlock.appendChild(badgeWrap);
151
+ }
152
+
153
+ header.appendChild(textBlock);
154
+ root.appendChild(header);
155
+
156
+ // --- Fields ---
157
+ if (Array.isArray(spec.fields) && spec.fields.length) {
158
+ const dl = document.createElement('dl');
159
+ dl.className = 'border-t border-border pt-3 m-0';
160
+ for (const f of spec.fields) {
161
+ const row = document.createElement('div');
162
+ row.className = 'flex gap-2 mb-1.5';
163
+
164
+ const dt = document.createElement('dt');
165
+ dt.className = 'text-xs text-text2 min-w-[80px] sm:min-w-[100px] font-mono';
166
+ dt.textContent = f.label;
167
+ row.appendChild(dt);
168
+
169
+ const dd = document.createElement('dd');
170
+ dd.className = 'text-sm text-text1 m-0';
171
+ if (f.href) {
172
+ const a = document.createElement('a');
173
+ a.href = f.href;
174
+ a.className = 'text-accent hover:underline';
175
+ a.textContent = f.value;
176
+ dd.appendChild(a);
177
+ } else {
178
+ dd.textContent = f.value;
179
+ }
180
+ row.appendChild(dd);
181
+ dl.appendChild(row);
182
+ }
183
+ root.appendChild(dl);
184
+ }
185
+
186
+ // --- Stats ---
187
+ if (Array.isArray(spec.stats) && spec.stats.length) {
188
+ const statsWrap = document.createElement('div');
189
+ statsWrap.className = 'flex flex-wrap border border-border rounded overflow-hidden mt-3';
190
+ for (const s of spec.stats) {
191
+ const cell = document.createElement('div');
192
+ cell.className = 'text-center px-4 py-2 border-r border-border last:border-r-0 flex-1 min-w-[80px]';
193
+ const vEl = document.createElement('div');
194
+ vEl.className = 'text-xl font-bold text-accent';
195
+ vEl.textContent = s.value;
196
+ const lEl = document.createElement('div');
197
+ lEl.className = 'text-xs text-text2';
198
+ lEl.textContent = s.label;
199
+ cell.appendChild(vEl);
200
+ cell.appendChild(lEl);
201
+ statsWrap.appendChild(cell);
202
+ }
203
+ root.appendChild(statsWrap);
204
+ }
205
+
206
+ // --- Actions ---
207
+ if (Array.isArray(spec.actions) && spec.actions.length) {
208
+ const actionsWrap = document.createElement('div');
209
+ actionsWrap.className = 'flex gap-2 mt-3 flex-wrap';
210
+
211
+ spec.actions.forEach((a, idx) => {
212
+ const variantCls = ACTION[a.variant ?? 'secondary'] ?? ACTION.secondary;
213
+ if (a.href) {
214
+ const link = document.createElement('a');
215
+ link.href = a.href;
216
+ link.className = `inline-flex items-center px-3.5 py-1.5 rounded text-xs font-semibold no-underline ${variantCls}`;
217
+ link.textContent = a.label;
218
+ actionsWrap.appendChild(link);
219
+ } else {
220
+ const btn = document.createElement('button');
221
+ btn.type = 'button';
222
+ btn.className = `inline-flex items-center px-3.5 py-1.5 rounded text-xs font-semibold border-0 cursor-pointer ${variantCls}`;
223
+ btn.textContent = a.label;
224
+ const handler = (ev: MouseEvent) => {
225
+ // Bubble a canonical widget:interact event.
226
+ container.dispatchEvent(new CustomEvent('widget:interact', {
227
+ detail: {
228
+ action: a.action ?? a.id ?? a.label,
229
+ payload: { index: idx, label: a.label },
230
+ },
231
+ bubbles: true,
232
+ }));
233
+ // Preserve original local onclick if provided.
234
+ if (typeof (a as any).onclick === 'function') {
235
+ try { (a as any).onclick(ev); } catch { /* swallow */ }
236
+ }
237
+ };
238
+ btn.addEventListener('click', handler);
239
+ cleanups.push(() => btn.removeEventListener('click', handler));
240
+ actionsWrap.appendChild(btn);
241
+ }
242
+ });
243
+
244
+ root.appendChild(actionsWrap);
245
+ }
246
+
247
+ container.appendChild(root);
248
+
249
+ return () => {
250
+ for (const fn of cleanups) {
251
+ try { fn(); } catch { /* swallow */ }
252
+ }
253
+ cleanups.length = 0;
254
+ container.innerHTML = '';
255
+ };
256
+ }
@@ -0,0 +1,262 @@
1
+ /**
2
+ * Sankey (simulated) — vanilla renderer.
3
+ *
4
+ * Contract:
5
+ * render(container, spec): () => void
6
+ *
7
+ * Emits on `container`:
8
+ * - CustomEvent 'widget:interact' { detail: { action: 'nodeclick', payload: node }, bubbles: true }
9
+ * - CustomEvent 'widget:interact' { detail: { action: 'linkclick', payload: link }, bubbles: true }
10
+ * - CustomEvent 'widget:node-dblclick' { detail: { nodeId, nodeData }, bubbles: true }
11
+ *
12
+ * Not a real d3-sankey layout — horizontal "bar" visualization sorted by value,
13
+ * matching the historical Svelte widget (Sankey.svelte).
14
+ */
15
+
16
+ export interface SankeyNode {
17
+ id: string;
18
+ label: string;
19
+ color?: string;
20
+ summary?: string;
21
+ }
22
+
23
+ export interface SankeyLink {
24
+ source: string;
25
+ target: string;
26
+ value: number;
27
+ label?: string;
28
+ }
29
+
30
+ export interface SankeySpec {
31
+ title?: string;
32
+ nodes?: SankeyNode[];
33
+ links?: SankeyLink[];
34
+ }
35
+
36
+ type Cleanup = () => void;
37
+
38
+ function emitInteract(
39
+ container: HTMLElement,
40
+ action: 'nodeclick' | 'linkclick',
41
+ payload: unknown,
42
+ ): void {
43
+ container.dispatchEvent(
44
+ new CustomEvent('widget:interact', {
45
+ detail: { action, payload },
46
+ bubbles: true,
47
+ }),
48
+ );
49
+ }
50
+
51
+ function emitNodeDblclick(container: HTMLElement, node: SankeyNode): void {
52
+ container.dispatchEvent(
53
+ new CustomEvent('widget:node-dblclick', {
54
+ detail: { nodeId: node.id, nodeData: node },
55
+ bubbles: true,
56
+ }),
57
+ );
58
+ }
59
+
60
+ export function render(container: HTMLElement, data: unknown): Cleanup {
61
+ const spec: Partial<SankeySpec> =
62
+ data && typeof data === 'object' ? (data as Partial<SankeySpec>) : {};
63
+
64
+ const nodes: SankeyNode[] = Array.isArray(spec.nodes) ? spec.nodes! : [];
65
+ const links: SankeyLink[] = Array.isArray(spec.links) ? spec.links! : [];
66
+
67
+ // Clear container.
68
+ container.innerHTML = '';
69
+
70
+ const root = document.createElement('div');
71
+ root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans';
72
+ container.appendChild(root);
73
+
74
+ if (spec.title) {
75
+ const h = document.createElement('h3');
76
+ h.className = 'text-sm font-semibold text-text1 mb-3';
77
+ h.textContent = String(spec.title);
78
+ root.appendChild(h);
79
+ }
80
+
81
+ // Empty state.
82
+ if (!nodes.length || !links.length) {
83
+ const p = document.createElement('p');
84
+ p.className = 'text-text2 text-sm';
85
+ p.textContent = 'No flow data';
86
+ root.appendChild(p);
87
+
88
+ const cleanupEmpty: Cleanup = () => {
89
+ container.innerHTML = '';
90
+ };
91
+ // PNG export — empty placeholder.
92
+ (container as unknown as { __exportPng?: () => Promise<Blob | null> }).__exportPng =
93
+ async () => null;
94
+ return cleanupEmpty;
95
+ }
96
+
97
+ const nodeMap = new Map<string, SankeyNode>(nodes.map((n) => [n.id, n]));
98
+ const maxVal = Math.max(...links.map((l) => l.value), 1);
99
+ const sorted = [...links].sort((a, b) => b.value - a.value);
100
+
101
+ // Counts line.
102
+ const counts = document.createElement('div');
103
+ counts.className = 'text-xs text-text2 mb-2 font-mono';
104
+ counts.textContent = `${nodes.length} nodes · ${links.length} flows`;
105
+ root.appendChild(counts);
106
+
107
+ const list = document.createElement('div');
108
+ list.className = 'flex flex-col gap-1.5';
109
+ root.appendChild(list);
110
+
111
+ // Track attached listeners for cleanup.
112
+ type Binding = { el: Element; type: string; fn: EventListener };
113
+ const bindings: Binding[] = [];
114
+ const on = (el: Element, type: string, fn: EventListener) => {
115
+ el.addEventListener(type, fn);
116
+ bindings.push({ el, type, fn });
117
+ };
118
+
119
+ for (const link of sorted) {
120
+ const src = nodeMap.get(link.source);
121
+ const tgt = nodeMap.get(link.target);
122
+ const sc = src?.color ?? 'var(--color-accent)';
123
+ const tc = tgt?.color ?? 'var(--color-teal)';
124
+ const pct = Math.round((link.value / maxVal) * 100);
125
+ const barH = Math.max(4, Math.round((link.value / maxVal) * 20));
126
+
127
+ const row = document.createElement('div');
128
+ row.className =
129
+ 'flex items-center gap-2 text-xs cursor-pointer hover:opacity-80 transition-opacity';
130
+ row.setAttribute('role', 'button');
131
+ row.setAttribute('tabindex', '0');
132
+
133
+ const onLinkActivate = () => emitInteract(container, 'linkclick', link);
134
+ on(row, 'click', onLinkActivate as EventListener);
135
+ on(row, 'keydown', ((e: Event) => {
136
+ const ke = e as KeyboardEvent;
137
+ if (ke.key === 'Enter' || ke.key === ' ') {
138
+ ke.preventDefault();
139
+ onLinkActivate();
140
+ }
141
+ }) as EventListener);
142
+
143
+ // Source label.
144
+ const srcSpan = document.createElement('span');
145
+ srcSpan.className = 'text-text2 min-w-[80px] truncate font-mono';
146
+ srcSpan.style.color = sc;
147
+ srcSpan.title = src?.summary ?? src?.label ?? link.source;
148
+ srcSpan.textContent = src?.label ?? link.source;
149
+ if (src) {
150
+ on(srcSpan, 'dblclick', ((e: Event) => {
151
+ e.stopPropagation();
152
+ emitNodeDblclick(container, src);
153
+ emitInteract(container, 'nodeclick', src);
154
+ }) as EventListener);
155
+ }
156
+ row.appendChild(srcSpan);
157
+
158
+ // Bar.
159
+ const barWrap = document.createElement('div');
160
+ barWrap.className = 'flex-1 bg-surface2 rounded-full overflow-hidden';
161
+ barWrap.style.height = `${barH}px`;
162
+ const bar = document.createElement('div');
163
+ bar.className = 'rounded-full h-full';
164
+ bar.style.width = `${pct}%`;
165
+ bar.style.background = `linear-gradient(to right, ${sc}, ${tc})`;
166
+ barWrap.appendChild(bar);
167
+ row.appendChild(barWrap);
168
+
169
+ // Target label.
170
+ const tgtSpan = document.createElement('span');
171
+ tgtSpan.className = 'text-text2 min-w-[80px] truncate text-right font-mono';
172
+ tgtSpan.style.color = tc;
173
+ tgtSpan.title = tgt?.summary ?? tgt?.label ?? link.target;
174
+ tgtSpan.textContent = tgt?.label ?? link.target;
175
+ if (tgt) {
176
+ on(tgtSpan, 'dblclick', ((e: Event) => {
177
+ e.stopPropagation();
178
+ emitNodeDblclick(container, tgt);
179
+ emitInteract(container, 'nodeclick', tgt);
180
+ }) as EventListener);
181
+ }
182
+ row.appendChild(tgtSpan);
183
+
184
+ // Value.
185
+ const val = document.createElement('span');
186
+ val.className = 'text-text2 min-w-[40px] text-right font-mono';
187
+ val.textContent = String(link.value);
188
+ row.appendChild(val);
189
+
190
+ list.appendChild(row);
191
+ }
192
+
193
+ // Optional PNG export — rasterize the root via html2canvas-less approach:
194
+ // use foreignObject inside an SVG, then draw to canvas.
195
+ (container as unknown as { __exportPng?: () => Promise<Blob | null> }).__exportPng =
196
+ async () => {
197
+ try {
198
+ const rect = root.getBoundingClientRect();
199
+ const w = Math.max(1, Math.ceil(rect.width));
200
+ const h = Math.max(1, Math.ceil(rect.height));
201
+ const xmlns = 'http://www.w3.org/2000/svg';
202
+ const svg = document.createElementNS(xmlns, 'svg');
203
+ svg.setAttribute('xmlns', xmlns);
204
+ svg.setAttribute('width', String(w));
205
+ svg.setAttribute('height', String(h));
206
+ const fo = document.createElementNS(xmlns, 'foreignObject');
207
+ fo.setAttribute('width', '100%');
208
+ fo.setAttribute('height', '100%');
209
+ const clone = root.cloneNode(true) as HTMLElement;
210
+ // Embed clone inside a namespaced div so the browser renders HTML.
211
+ const wrap = document.createElement('div');
212
+ wrap.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml');
213
+ wrap.appendChild(clone);
214
+ fo.appendChild(wrap);
215
+ svg.appendChild(fo);
216
+ const svgStr = new XMLSerializer().serializeToString(svg);
217
+ const blob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
218
+ const url = URL.createObjectURL(blob);
219
+ try {
220
+ const img = new Image();
221
+ img.decoding = 'sync';
222
+ await new Promise<void>((resolve, reject) => {
223
+ img.onload = () => resolve();
224
+ img.onerror = () => reject(new Error('svg load failed'));
225
+ img.src = url;
226
+ });
227
+ const scale = Math.min(2, (globalThis.devicePixelRatio as number) || 1);
228
+ const canvas = document.createElement('canvas');
229
+ canvas.width = w * scale;
230
+ canvas.height = h * scale;
231
+ const ctx = canvas.getContext('2d');
232
+ if (!ctx) return null;
233
+ ctx.scale(scale, scale);
234
+ ctx.drawImage(img, 0, 0);
235
+ return await new Promise<Blob | null>((resolve) =>
236
+ canvas.toBlob((b) => resolve(b), 'image/png'),
237
+ );
238
+ } finally {
239
+ URL.revokeObjectURL(url);
240
+ }
241
+ } catch {
242
+ return null;
243
+ }
244
+ };
245
+
246
+ const cleanup: Cleanup = () => {
247
+ for (const { el, type, fn } of bindings) {
248
+ el.removeEventListener(type, fn);
249
+ }
250
+ bindings.length = 0;
251
+ try {
252
+ delete (container as unknown as { __exportPng?: unknown }).__exportPng;
253
+ } catch {
254
+ /* noop */
255
+ }
256
+ container.innerHTML = '';
257
+ };
258
+
259
+ return cleanup;
260
+ }
261
+
262
+ export default { render };
@@ -0,0 +1,125 @@
1
+ export interface StatCardTrend {
2
+ direction: 'up' | 'down' | 'flat';
3
+ value?: string;
4
+ positive?: boolean;
5
+ }
6
+
7
+ export interface StatCardSpec {
8
+ label?: string;
9
+ value?: unknown;
10
+ unit?: string;
11
+ delta?: string;
12
+ trend?: 'up' | 'down' | 'flat' | StatCardTrend;
13
+ previousValue?: unknown;
14
+ variant?: 'default' | 'success' | 'warning' | 'error' | 'info';
15
+ }
16
+
17
+ const COLORS: Record<string, string> = {
18
+ default: 'var(--color-accent)',
19
+ success: 'var(--color-teal)',
20
+ warning: 'var(--color-amber)',
21
+ error: 'var(--color-accent2)',
22
+ info: '#3b82f6',
23
+ };
24
+
25
+ function computeAccent(spec: Partial<StatCardSpec>): string {
26
+ return COLORS[spec.variant ?? 'default'] ?? 'var(--color-accent)';
27
+ }
28
+
29
+ interface TrendInfo {
30
+ arrow: string;
31
+ val: string;
32
+ color: string;
33
+ }
34
+
35
+ function computeTrendInfo(spec: Partial<StatCardSpec>): TrendInfo | null {
36
+ const t = spec.trend;
37
+ if (!t) return null;
38
+ if (typeof t === 'string') {
39
+ const arrow = t === 'up' ? '↑' : t === 'down' ? '↓' : '→';
40
+ const color =
41
+ t === 'up'
42
+ ? 'var(--color-teal)'
43
+ : t === 'flat'
44
+ ? 'var(--color-text2)'
45
+ : 'var(--color-accent2)';
46
+ return { arrow, val: spec.delta ?? '', color };
47
+ }
48
+ const positive = t.positive ?? t.direction === 'up';
49
+ const arrow = t.direction === 'up' ? '↑' : t.direction === 'down' ? '↓' : '→';
50
+ const color = positive
51
+ ? 'var(--color-teal)'
52
+ : t.direction === 'flat'
53
+ ? 'var(--color-text2)'
54
+ : 'var(--color-accent2)';
55
+ return { arrow, val: t.value ?? '', color };
56
+ }
57
+
58
+ function escapeHtml(s: string): string {
59
+ return s
60
+ .replace(/&/g, '&amp;')
61
+ .replace(/</g, '&lt;')
62
+ .replace(/>/g, '&gt;')
63
+ .replace(/"/g, '&quot;')
64
+ .replace(/'/g, '&#39;');
65
+ }
66
+
67
+ function toDisplay(v: unknown): string {
68
+ if (v === null || v === undefined) return '';
69
+ return escapeHtml(String(v));
70
+ }
71
+
72
+ export function render(container: HTMLElement, data: any): () => void {
73
+ const spec: Partial<StatCardSpec> = data && typeof data === 'object' ? data : {};
74
+ const accent = computeAccent(spec);
75
+ const trend = computeTrendInfo(spec);
76
+
77
+ const label = toDisplay(spec.label ?? '');
78
+ const valueStr =
79
+ spec.value === null || spec.value === undefined ? '—' : toDisplay(spec.value);
80
+ const unit = spec.unit ? toDisplay(spec.unit) : '';
81
+ const prev =
82
+ spec.previousValue !== undefined ? toDisplay(spec.previousValue) : null;
83
+
84
+ const unitHtml = unit ? `<span class="text-base text-text2 ml-1">${unit}</span>` : '';
85
+
86
+ const trendHtml = trend
87
+ ? `<div class="flex items-center gap-1 justify-center mt-1">
88
+ <span class="text-base font-bold" style="color:${trend.color};" aria-hidden="true">${trend.arrow}</span>
89
+ ${trend.val ? `<span class="text-sm font-semibold" style="color:${trend.color};">${escapeHtml(trend.val)}</span>` : ''}
90
+ </div>`
91
+ : '';
92
+
93
+ const prevHtml =
94
+ prev !== null ? `<div class="text-xs text-text2 mt-1">prev: ${prev}</div>` : '';
95
+
96
+ container.innerHTML = `
97
+ <div class="bg-surface border border-border rounded-lg p-3 md:p-4 font-sans min-w-[140px] md:min-w-[160px]" style="border-top: 3px solid ${accent};" role="group" aria-label="${label || 'stat'}">
98
+ <div class="text-center py-2">
99
+ <div class="text-xs font-mono text-text2 uppercase tracking-widest mb-2">${label}</div>
100
+ <div class="text-3xl md:text-4xl font-bold leading-none" style="color:${accent};">
101
+ ${valueStr}${unitHtml}
102
+ </div>
103
+ ${trendHtml}
104
+ ${prevHtml}
105
+ </div>
106
+ </div>
107
+ `;
108
+
109
+ const root = container.firstElementChild as HTMLElement | null;
110
+ const onClick = (e: Event) => {
111
+ container.dispatchEvent(
112
+ new CustomEvent('widget:interact', {
113
+ detail: { action: 'select', payload: { label: spec.label, value: spec.value } },
114
+ bubbles: true,
115
+ })
116
+ );
117
+ e.stopPropagation();
118
+ };
119
+ if (root) root.addEventListener('click', onClick);
120
+
121
+ return () => {
122
+ if (root) root.removeEventListener('click', onClick);
123
+ container.innerHTML = '';
124
+ };
125
+ }