@webmcp-auto-ui/ui 2.5.31 → 2.5.33

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 (80) hide show
  1. package/package.json +15 -2
  2. package/src/agent/DiagnosticModal.svelte +126 -50
  3. package/src/agent/EphemeralBubble.svelte +13 -3
  4. package/src/agent/MCPserversList.svelte +147 -0
  5. package/src/agent/McpConnector.svelte +10 -1
  6. package/src/agent/RecipeBrowser.svelte +384 -0
  7. package/src/agent/RemoteMCPserversDemo.svelte +5 -121
  8. package/src/agent/ToolBrowser.svelte +133 -0
  9. package/src/agent/WebMCPserversList.svelte +2 -0
  10. package/src/agent/useAgentLoop.svelte.ts +396 -0
  11. package/src/base/chat-inline.svelte +64 -0
  12. package/src/base/dialog-content.svelte +3 -1
  13. package/src/components/HeaderControls.svelte +78 -0
  14. package/src/index.ts +13 -35
  15. package/src/stores/canvas.svelte.ts +0 -6
  16. package/src/widgets/SafeImage.svelte +67 -0
  17. package/src/widgets/WidgetRenderer.svelte +153 -78
  18. package/src/widgets/notebook/executors/index.ts +0 -1
  19. package/src/widgets/notebook/executors/sql.ts +32 -182
  20. package/src/widgets/notebook/import-modal-api.ts +237 -0
  21. package/src/widgets/notebook/import-modal.svelte +738 -0
  22. package/src/widgets/notebook/left-pane.ts +1 -1
  23. package/src/widgets/notebook/notebook.svelte +75 -0
  24. package/src/widgets/notebook/notebook.ts +38 -73
  25. package/src/widgets/notebook/prose.ts +6 -3
  26. package/src/widgets/notebook/shared.ts +68 -49
  27. package/src/widgets/rich/cards.svelte +74 -0
  28. package/src/widgets/rich/carousel.svelte +126 -0
  29. package/src/widgets/rich/chart-rich.svelte +221 -0
  30. package/src/widgets/rich/chat-input.svelte +52 -0
  31. package/src/widgets/rich/data-table.svelte +132 -0
  32. package/src/widgets/rich/gallery.svelte +115 -0
  33. package/src/widgets/rich/grid-data.svelte +85 -0
  34. package/src/widgets/rich/hemicycle.svelte +95 -0
  35. package/src/widgets/rich/js-sandbox.svelte +67 -0
  36. package/src/widgets/rich/json-viewer.svelte +82 -0
  37. package/src/widgets/rich/log.svelte +62 -0
  38. package/src/widgets/rich/profile.svelte +91 -0
  39. package/src/widgets/rich/sankey.svelte +73 -0
  40. package/src/widgets/rich/stat-card.svelte +60 -0
  41. package/src/widgets/rich/timeline.svelte +95 -0
  42. package/src/widgets/rich/trombinoscope.svelte +87 -0
  43. package/src/widgets/simple/actions.svelte +36 -0
  44. package/src/widgets/simple/alert.svelte +52 -0
  45. package/src/widgets/simple/chart.svelte +38 -0
  46. package/src/widgets/simple/code.svelte +30 -0
  47. package/src/widgets/simple/kv.svelte +31 -0
  48. package/src/widgets/simple/list.svelte +35 -0
  49. package/src/widgets/simple/stat.svelte +36 -0
  50. package/src/widgets/simple/tags.svelte +34 -0
  51. package/src/widgets/simple/text.svelte +130 -0
  52. package/src/widgets/helpers/safe-image.ts +0 -78
  53. package/src/widgets/notebook/import-modals.ts +0 -560
  54. package/src/widgets/notebook/recipe-browser.ts +0 -350
  55. package/src/widgets/rich/cards.ts +0 -181
  56. package/src/widgets/rich/carousel.ts +0 -319
  57. package/src/widgets/rich/chart-rich.ts +0 -386
  58. package/src/widgets/rich/d3.ts +0 -503
  59. package/src/widgets/rich/data-table.ts +0 -342
  60. package/src/widgets/rich/gallery.ts +0 -350
  61. package/src/widgets/rich/grid-data.ts +0 -173
  62. package/src/widgets/rich/hemicycle.ts +0 -313
  63. package/src/widgets/rich/js-sandbox.ts +0 -122
  64. package/src/widgets/rich/json-viewer.ts +0 -202
  65. package/src/widgets/rich/log.ts +0 -143
  66. package/src/widgets/rich/map.ts +0 -218
  67. package/src/widgets/rich/profile.ts +0 -256
  68. package/src/widgets/rich/sankey.ts +0 -257
  69. package/src/widgets/rich/stat-card.ts +0 -125
  70. package/src/widgets/rich/timeline.ts +0 -179
  71. package/src/widgets/rich/trombinoscope.ts +0 -246
  72. package/src/widgets/simple/actions.ts +0 -89
  73. package/src/widgets/simple/alert.ts +0 -100
  74. package/src/widgets/simple/chart.ts +0 -189
  75. package/src/widgets/simple/code.ts +0 -79
  76. package/src/widgets/simple/kv.ts +0 -68
  77. package/src/widgets/simple/list.ts +0 -89
  78. package/src/widgets/simple/stat.ts +0 -58
  79. package/src/widgets/simple/tags.ts +0 -125
  80. package/src/widgets/simple/text.ts +0 -198
@@ -1,350 +0,0 @@
1
- // @ts-nocheck
2
- // ---------------------------------------------------------------------------
3
- // recipe-browser — vanilla widget renderer
4
- // Interactive browser: search, filter by kind/tags, layout toggle, preview, pick/download.
5
- // ---------------------------------------------------------------------------
6
-
7
- // NOTE: this widget lives in @webmcp-auto-ui/ui but imports runtime helpers from
8
- // @webmcp-auto-ui/agent, creating an ESM cycle ui <-> agent. The cycle is safe
9
- // because these imports are only used inside functions (no top-level eval).
10
- import { filterRecipes, sortRecipes, recipeToDownloadBlob, recipeToMarkdown } from '@webmcp-auto-ui/agent';
11
- import type { Recipe } from '@webmcp-auto-ui/agent';
12
-
13
- export interface RecipeBrowserData {
14
- recipes: Recipe[];
15
- filters?: { tags?: string[]; kind?: 'webmcp' | 'mcp' | 'all'; q?: string };
16
- layout?: 'list' | 'grid';
17
- onPick?: (recipe: Recipe) => void;
18
- }
19
-
20
- const STYLE_ID = 'rb-recipe-browser-styles';
21
-
22
- function injectStyles(): void {
23
- if (document.getElementById(STYLE_ID)) return;
24
- const s = document.createElement('style');
25
- s.id = STYLE_ID;
26
- s.textContent = `
27
- .recipe-browser-root {
28
- display: flex; flex-direction: column; gap: 10px;
29
- font-family: var(--font-sans, system-ui, sans-serif);
30
- color: var(--color-text1, #111);
31
- background: var(--color-surface, #fff);
32
- border: 1px solid var(--color-border, #e4e4e7);
33
- border-radius: 10px;
34
- padding: 14px;
35
- min-height: 360px;
36
- box-sizing: border-box;
37
- }
38
- .recipe-browser-root * { box-sizing: border-box; }
39
- .rb-head {
40
- display: flex; flex-wrap: wrap; gap: 8px; align-items: center;
41
- padding-bottom: 10px;
42
- border-bottom: 1px solid var(--color-border, #e4e4e7);
43
- }
44
- .rb-search {
45
- flex: 1 1 200px; min-width: 160px;
46
- padding: 7px 10px; font-size: 13px;
47
- border: 1px solid var(--color-border, #e4e4e7);
48
- border-radius: 6px;
49
- background: var(--color-surface, #fff);
50
- color: var(--color-text1, #111);
51
- outline: none;
52
- }
53
- .rb-search:focus { border-color: var(--color-accent, #6a55ff); }
54
- .rb-select {
55
- padding: 7px 10px; font-size: 12px;
56
- border: 1px solid var(--color-border, #e4e4e7);
57
- border-radius: 6px;
58
- background: var(--color-surface, #fff);
59
- color: var(--color-text1, #111);
60
- }
61
- .rb-layout-toggle { display: inline-flex; border: 1px solid var(--color-border, #e4e4e7); border-radius: 6px; overflow: hidden; }
62
- .rb-layout-btn {
63
- background: var(--color-surface, #fff); color: var(--color-text2, #666);
64
- border: 0; padding: 7px 10px; font-size: 12px; cursor: pointer;
65
- }
66
- .rb-layout-btn.rb-on { background: var(--color-accent, #6a55ff); color: #fff; }
67
- .rb-body {
68
- display: grid; grid-template-columns: minmax(220px, 320px) 1fr;
69
- gap: 12px; min-height: 280px;
70
- }
71
- .rb-body[data-layout="grid"] { grid-template-columns: 1fr; }
72
- .rb-body[data-layout="grid"] .rb-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 8px; max-height: none; }
73
- .rb-list {
74
- display: flex; flex-direction: column; gap: 6px;
75
- max-height: 420px; overflow-y: auto;
76
- padding-right: 4px;
77
- }
78
- .rb-item {
79
- padding: 9px 11px; border-radius: 8px;
80
- background: var(--color-surface2, #f4f4f5);
81
- border: 1px solid transparent;
82
- cursor: pointer; transition: background .12s, border-color .12s;
83
- }
84
- .rb-item:hover { background: var(--color-surface3, #eeeef0); }
85
- .rb-item.rb-selected { border-color: var(--color-accent, #6a55ff); background: var(--color-surface3, #eeeef0); }
86
- .rb-item-name { font-weight: 600; font-size: 13px; margin-bottom: 3px; }
87
- .rb-item-desc { font-size: 11.5px; color: var(--color-text2, #666); margin-bottom: 6px; line-height: 1.35; }
88
- .rb-item-foot { display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
89
- .rb-chip {
90
- font-size: 10.5px; font-family: var(--font-mono, monospace);
91
- padding: 1px 6px; border-radius: 999px;
92
- background: var(--color-surface, #fff);
93
- border: 1px solid var(--color-border, #e4e4e7);
94
- color: var(--color-text2, #666);
95
- }
96
- .rb-chip.rb-srv { background: var(--color-accent, #6a55ff); color: #fff; border-color: transparent; }
97
- .rb-preview {
98
- display: flex; flex-direction: column; min-height: 260px;
99
- border: 1px solid var(--color-border, #e4e4e7);
100
- border-radius: 8px; overflow: hidden;
101
- background: var(--color-surface, #fff);
102
- }
103
- .rb-preview-head {
104
- display: flex; align-items: center; gap: 8px;
105
- padding: 9px 12px;
106
- border-bottom: 1px solid var(--color-border, #e4e4e7);
107
- background: var(--color-surface2, #f4f4f5);
108
- }
109
- .rb-preview-title { flex: 1; font-weight: 600; font-size: 13px; }
110
- .rb-preview-body {
111
- flex: 1; padding: 12px; overflow-y: auto;
112
- font-size: 12.5px; line-height: 1.5;
113
- max-height: 420px;
114
- }
115
- .rb-preview-body pre {
116
- background: var(--color-surface2, #f4f4f5);
117
- padding: 10px; border-radius: 6px;
118
- overflow-x: auto;
119
- font-family: var(--font-mono, monospace);
120
- font-size: 11.5px;
121
- white-space: pre-wrap;
122
- margin: 0;
123
- }
124
- .rb-btn {
125
- background: var(--color-surface2, #f4f4f5);
126
- border: 1px solid var(--color-border, #e4e4e7);
127
- border-radius: 6px; padding: 6px 12px; font-size: 12px;
128
- color: var(--color-text1, #111);
129
- cursor: pointer;
130
- }
131
- .rb-btn:hover { background: var(--color-surface3, #eeeef0); }
132
- .rb-btn-primary { background: var(--color-accent, #6a55ff); color: #fff; border-color: transparent; }
133
- .rb-btn-primary:hover { filter: brightness(1.08); }
134
- .rb-empty { padding: 24px; text-align: center; color: var(--color-text2, #666); font-size: 12px; }
135
- .rb-placeholder { color: var(--color-text2, #666); font-size: 12px; padding: 24px; text-align: center; }
136
- `;
137
- document.head.appendChild(s);
138
- }
139
-
140
- function escapeHtml(s: unknown): string {
141
- return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
142
- }
143
-
144
- function detectKind(r: Recipe): 'webmcp' | 'mcp' {
145
- // Heuristic: if frontmatter 'servers' present and non-empty, treat as MCP-connected.
146
- const srv = (r as any).server ?? (r as any).serverName;
147
- if (typeof srv === 'string' && srv && srv !== 'webmcp') return 'mcp';
148
- return 'webmcp';
149
- }
150
-
151
- function recipeTags(r: Recipe): string[] {
152
- const out: string[] = [];
153
- if (Array.isArray(r.servers)) out.push(...r.servers);
154
- if (Array.isArray(r.components_used)) out.push(...r.components_used.slice(0, 3));
155
- return out;
156
- }
157
-
158
- function serverLabel(r: Recipe): string {
159
- const srv = (r as any).server ?? (r as any).serverName;
160
- if (typeof srv === 'string' && srv) return srv;
161
- if (Array.isArray(r.servers) && r.servers.length) return r.servers[0];
162
- return 'webmcp';
163
- }
164
-
165
- function simpleMarkdown(body: string): string {
166
- // Minimal safe rendering: escape + preserve paragraphs + wrap fences in <pre>.
167
- const escaped = escapeHtml(body);
168
- // Code fences
169
- const withFences = escaped.replace(/```([a-zA-Z0-9_-]*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
170
- return `<pre><code>${code}</code></pre>`;
171
- });
172
- // Paragraphs (double newlines)
173
- const paras = withFences.split(/\n{2,}/).map((p) => {
174
- if (p.startsWith('<pre>')) return p;
175
- return `<p>${p.replace(/\n/g, '<br/>')}</p>`;
176
- });
177
- return paras.join('\n');
178
- }
179
-
180
- export function render(container: HTMLElement, data: RecipeBrowserData): () => void {
181
- injectStyles();
182
-
183
- const all: Recipe[] = Array.isArray(data?.recipes) ? data.recipes : [];
184
- if (!all.length) {
185
- container.innerHTML = '<div class="recipe-browser-root"><div class="rb-empty">No recipes available.</div></div>';
186
- return () => { container.innerHTML = ''; };
187
- }
188
-
189
- const initialFilters = data?.filters ?? {};
190
- let q = initialFilters.q ?? '';
191
- let kind: 'all' | 'webmcp' | 'mcp' = initialFilters.kind ?? 'all';
192
- let layout: 'list' | 'grid' = data?.layout === 'grid' ? 'grid' : 'list';
193
- let selectedId: string | null = null;
194
-
195
- const root = document.createElement('div');
196
- root.className = 'recipe-browser-root';
197
- root.innerHTML = `
198
- <div class="rb-head">
199
- <input type="search" class="rb-search" placeholder="Search recipes..." value="${escapeHtml(q)}" />
200
- <select class="rb-select" data-role="kind">
201
- <option value="all"${kind === 'all' ? ' selected' : ''}>All kinds</option>
202
- <option value="webmcp"${kind === 'webmcp' ? ' selected' : ''}>WebMCP</option>
203
- <option value="mcp"${kind === 'mcp' ? ' selected' : ''}>MCP</option>
204
- </select>
205
- <div class="rb-layout-toggle">
206
- <button type="button" class="rb-layout-btn${layout === 'list' ? ' rb-on' : ''}" data-layout="list">list</button>
207
- <button type="button" class="rb-layout-btn${layout === 'grid' ? ' rb-on' : ''}" data-layout="grid">grid</button>
208
- </div>
209
- </div>
210
- <div class="rb-body" data-layout="${layout}">
211
- <div class="rb-list" data-role="list"></div>
212
- <div class="rb-preview" data-role="preview">
213
- <div class="rb-placeholder">Select a recipe to preview.</div>
214
- </div>
215
- </div>
216
- `;
217
- container.innerHTML = '';
218
- container.appendChild(root);
219
-
220
- const searchEl = root.querySelector('.rb-search') as HTMLInputElement;
221
- const kindEl = root.querySelector('[data-role="kind"]') as HTMLSelectElement;
222
- const bodyEl = root.querySelector('.rb-body') as HTMLElement;
223
- const listEl = root.querySelector('[data-role="list"]') as HTMLElement;
224
- const previewEl = root.querySelector('[data-role="preview"]') as HTMLElement;
225
- const layoutBtns = Array.from(root.querySelectorAll('.rb-layout-btn')) as HTMLButtonElement[];
226
-
227
- const listeners: Array<{ el: EventTarget; type: string; fn: EventListener }> = [];
228
- function on<T extends EventTarget>(el: T, type: string, fn: EventListener) {
229
- el.addEventListener(type, fn);
230
- listeners.push({ el, type, fn });
231
- }
232
-
233
- function applyFilters(): Recipe[] {
234
- let filtered = all.slice();
235
- if (kind !== 'all') filtered = filtered.filter((r) => detectKind(r) === kind);
236
- // Tag filter (optional): keep only recipes whose tags intersect data.filters.tags
237
- const wantTags = initialFilters.tags;
238
- if (Array.isArray(wantTags) && wantTags.length) {
239
- const wanted = new Set(wantTags.map((t) => String(t).toLowerCase()));
240
- filtered = filtered.filter((r) => recipeTags(r).some((t) => wanted.has(String(t).toLowerCase())));
241
- }
242
- filtered = filterRecipes(filtered, q);
243
- return sortRecipes(filtered);
244
- }
245
-
246
- function renderList() {
247
- const items = applyFilters();
248
- listEl.innerHTML = '';
249
- if (!items.length) {
250
- const empty = document.createElement('div');
251
- empty.className = 'rb-empty';
252
- empty.textContent = 'No matches.';
253
- listEl.appendChild(empty);
254
- return;
255
- }
256
- for (const r of items) {
257
- const id = r.id ?? r.name ?? '';
258
- const node = document.createElement('div');
259
- node.className = 'rb-item' + (id === selectedId ? ' rb-selected' : '');
260
- const tags = recipeTags(r).slice(0, 4);
261
- node.innerHTML = `
262
- <div class="rb-item-name">${escapeHtml(r.name || 'Untitled')}</div>
263
- <div class="rb-item-desc">${escapeHtml(r.description || 'No description')}</div>
264
- <div class="rb-item-foot">
265
- <span class="rb-chip rb-srv">${escapeHtml(serverLabel(r))}</span>
266
- ${tags.map((t) => `<span class="rb-chip">${escapeHtml(t)}</span>`).join('')}
267
- </div>
268
- `;
269
- on(node, 'click', () => {
270
- selectedId = id;
271
- renderList();
272
- renderPreview(r);
273
- });
274
- listEl.appendChild(node);
275
- }
276
- }
277
-
278
- function renderPreview(r: Recipe) {
279
- const md = typeof r.body === 'string' && r.body.trim()
280
- ? r.body
281
- : (() => { try { return recipeToMarkdown(r as any); } catch { return ''; } })();
282
- previewEl.innerHTML = `
283
- <div class="rb-preview-head">
284
- <span class="rb-preview-title">${escapeHtml(r.name || 'Untitled')}</span>
285
- <button type="button" class="rb-btn" data-act="download">Download .md</button>
286
- <button type="button" class="rb-btn rb-btn-primary" data-act="pick">Pick</button>
287
- </div>
288
- <div class="rb-preview-body">${md ? simpleMarkdown(md) : '<div class="rb-placeholder">No body.</div>'}</div>
289
- `;
290
-
291
- const pickBtn = previewEl.querySelector('[data-act="pick"]') as HTMLButtonElement | null;
292
- const dlBtn = previewEl.querySelector('[data-act="download"]') as HTMLButtonElement | null;
293
-
294
- if (pickBtn) {
295
- on(pickBtn, 'click', () => {
296
- if (typeof data.onPick === 'function') {
297
- data.onPick(r);
298
- } else {
299
- container.dispatchEvent(new CustomEvent('widget:interact', {
300
- bubbles: true,
301
- detail: { action: 'pick', payload: r },
302
- }));
303
- }
304
- });
305
- }
306
- if (dlBtn) {
307
- on(dlBtn, 'click', () => {
308
- try {
309
- const { blob, filename } = recipeToDownloadBlob(r as any);
310
- triggerDownload(blob, filename);
311
- } catch {
312
- // fallback
313
- const body = typeof r.body === 'string' ? r.body : '';
314
- const blob = new Blob([body], { type: 'text/markdown' });
315
- const filename = (r.name || r.id || 'recipe').replace(/[^a-z0-9_.-]/gi, '-').toLowerCase() + '.md';
316
- triggerDownload(blob, filename);
317
- }
318
- });
319
- }
320
- }
321
-
322
- function triggerDownload(blob: Blob, filename: string) {
323
- const url = URL.createObjectURL(blob);
324
- const a = document.createElement('a');
325
- a.href = url;
326
- a.download = filename;
327
- document.body.appendChild(a);
328
- a.click();
329
- a.remove();
330
- setTimeout(() => URL.revokeObjectURL(url), 500);
331
- }
332
-
333
- on(searchEl, 'input', () => { q = searchEl.value; renderList(); });
334
- on(kindEl, 'change', () => { kind = (kindEl.value as any); renderList(); });
335
- for (const btn of layoutBtns) {
336
- on(btn, 'click', () => {
337
- layout = (btn.dataset.layout as 'list' | 'grid') || 'list';
338
- bodyEl.setAttribute('data-layout', layout);
339
- layoutBtns.forEach((b) => b.classList.toggle('rb-on', b === btn));
340
- });
341
- }
342
-
343
- renderList();
344
-
345
- return () => {
346
- for (const { el, type, fn } of listeners) el.removeEventListener(type, fn);
347
- listeners.length = 0;
348
- container.innerHTML = '';
349
- };
350
- }
@@ -1,181 +0,0 @@
1
- /**
2
- * Cards (vanilla) — grid of cards with optional image, subtitle, description, tags.
3
- * Mirrors Cards.svelte. Interactive mode: cards are clickable (dblclick + Enter/Space).
4
- *
5
- * Contract:
6
- * render(container, data) -> cleanup()
7
- * Event:
8
- * 'widget:interact' { action: 'cardclick', payload: card }
9
- */
10
-
11
- import { createSafeImage } from '../helpers/safe-image.js';
12
-
13
- export interface CardItem {
14
- title: string;
15
- description?: string;
16
- subtitle?: string;
17
- image?: string;
18
- tags?: string[];
19
- href?: string;
20
- }
21
-
22
- export interface CardsSpec {
23
- title?: string;
24
- cards?: CardItem[];
25
- minCardWidth?: string;
26
- gap?: string;
27
- emptyMessage?: string;
28
- interactive?: boolean;
29
- }
30
-
31
- interface CardsPayload {
32
- spec?: Partial<CardsSpec>;
33
- data?: unknown;
34
- }
35
-
36
- function escapeHtml(s: string): string {
37
- return s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
38
- }
39
-
40
- function resolveCards(spec: Partial<CardsSpec>, data: unknown): CardItem[] {
41
- if (Array.isArray(spec.cards) && spec.cards.length) return spec.cards as CardItem[];
42
- if (Array.isArray(data)) {
43
- return (data as Record<string, unknown>[]).map((d) => ({
44
- title: String(d.title ?? d.name ?? d.label ?? JSON.stringify(d)),
45
- description: typeof d.description === 'string' ? (d.description as string) : undefined,
46
- subtitle: typeof d.subtitle === 'string' ? (d.subtitle as string) : undefined,
47
- }));
48
- }
49
- return [];
50
- }
51
-
52
- export function render(container: HTMLElement, data: any): () => void {
53
- const payload: CardsPayload = (data && typeof data === 'object' ? data : {}) as CardsPayload;
54
- const spec: Partial<CardsSpec> = payload.spec ?? {};
55
- const inner = payload.data;
56
- const interactive = spec.interactive === true;
57
- const cards = resolveCards(spec, inner);
58
-
59
- // Root
60
- const root = document.createElement('div');
61
- root.className = 'bg-surface border border-border rounded-lg p-3 md:p-4 font-sans';
62
-
63
- if (spec.title) {
64
- const h3 = document.createElement('h3');
65
- h3.className = 'text-sm font-semibold text-text1 mb-3';
66
- h3.textContent = spec.title;
67
- root.appendChild(h3);
68
- }
69
-
70
- // Tracked listeners for cleanup
71
- const listeners: Array<{ el: HTMLElement; type: string; handler: EventListener }> = [];
72
- const addL = (el: HTMLElement, type: string, handler: EventListener) => {
73
- el.addEventListener(type, handler);
74
- listeners.push({ el, type, handler });
75
- };
76
-
77
- if (cards.length === 0) {
78
- const p = document.createElement('p');
79
- p.className = 'text-text2 text-sm';
80
- p.textContent = spec.emptyMessage ?? 'No items';
81
- root.appendChild(p);
82
- } else {
83
- const grid = document.createElement('div');
84
- grid.className = 'grid';
85
- const minW = spec.minCardWidth ?? '180px';
86
- const gap = spec.gap ?? '1rem';
87
- grid.setAttribute('style', `grid-template-columns: repeat(auto-fill, minmax(${minW}, 1fr)); gap: ${gap};`);
88
-
89
- for (const card of cards) {
90
- const cardEl = document.createElement('div');
91
- cardEl.className =
92
- 'bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all' +
93
- (interactive ? ' cursor-pointer' : '');
94
- if (interactive) {
95
- cardEl.setAttribute('role', 'button');
96
- cardEl.setAttribute('tabindex', '0');
97
- cardEl.setAttribute('title', 'Double-cliquez pour interagir');
98
-
99
- const dispatch = () => {
100
- container.dispatchEvent(
101
- new CustomEvent('widget:interact', {
102
- detail: { action: 'cardclick', payload: card },
103
- bubbles: true,
104
- }),
105
- );
106
- };
107
-
108
- addL(cardEl, 'dblclick', () => dispatch());
109
- addL(cardEl, 'keydown', (ev: Event) => {
110
- const ke = ev as KeyboardEvent;
111
- if (ke.key === 'Enter' || ke.key === ' ') {
112
- ke.preventDefault();
113
- dispatch();
114
- }
115
- });
116
- }
117
-
118
- if (card.image) {
119
- const img = createSafeImage({
120
- src: card.image,
121
- alt: card.title,
122
- className: 'w-full h-32 object-cover',
123
- hideOnError: true,
124
- });
125
- cardEl.appendChild(img);
126
- }
127
-
128
- const body = document.createElement('div');
129
- body.className = 'p-3';
130
-
131
- const titleEl = document.createElement('div');
132
- titleEl.className = 'font-semibold text-sm text-text1 leading-tight';
133
- titleEl.textContent = card.title;
134
- body.appendChild(titleEl);
135
-
136
- if (card.subtitle) {
137
- const sub = document.createElement('div');
138
- sub.className = 'text-xs text-text2 mt-0.5';
139
- sub.textContent = card.subtitle;
140
- body.appendChild(sub);
141
- }
142
-
143
- if (card.description) {
144
- const desc = document.createElement('div');
145
- desc.className = 'text-xs text-text2 mt-1.5 leading-relaxed';
146
- desc.textContent = card.description;
147
- body.appendChild(desc);
148
- }
149
-
150
- if (Array.isArray(card.tags) && card.tags.length) {
151
- const tagsWrap = document.createElement('div');
152
- tagsWrap.className = 'flex gap-1 flex-wrap mt-2';
153
- for (const tag of card.tags) {
154
- const span = document.createElement('span');
155
- span.className = 'text-[10px] font-mono px-1.5 py-0.5 rounded bg-surface2 text-text2';
156
- span.textContent = String(tag);
157
- tagsWrap.appendChild(span);
158
- }
159
- body.appendChild(tagsWrap);
160
- }
161
-
162
- cardEl.appendChild(body);
163
- grid.appendChild(cardEl);
164
- }
165
-
166
- root.appendChild(grid);
167
- }
168
-
169
- container.appendChild(root);
170
-
171
- return () => {
172
- for (const { el, type, handler } of listeners) {
173
- el.removeEventListener(type, handler);
174
- }
175
- listeners.length = 0;
176
- container.innerHTML = '';
177
- };
178
- }
179
-
180
- // Silence unused warning on some toolchains
181
- void escapeHtml;