@webmcp-auto-ui/ui 2.5.5 → 2.5.7

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webmcp-auto-ui/ui",
3
- "version": "2.5.5",
3
+ "version": "2.5.7",
4
4
  "description": "Svelte 5 UI components — primitives, widgets, window manager",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "type": "module",
@@ -16,10 +16,19 @@
16
16
  totalCachedGB: number;
17
17
  isWasm: boolean;
18
18
  };
19
+ maxContextTokens?: number;
19
20
  visible?: boolean;
20
21
  }
21
22
 
22
- let { metrics, visible = true }: Props = $props();
23
+ let { metrics, maxContextTokens, visible = true }: Props = $props();
24
+
25
+ const ctxRatio = $derived(
26
+ maxContextTokens && maxContextTokens > 0 && metrics.lastInputTokens > 0
27
+ ? metrics.lastInputTokens / maxContextTokens
28
+ : 0
29
+ );
30
+
31
+ const ctxWarn = $derived(ctxRatio > 0.8);
23
32
 
24
33
  // Rate limits (Anthropic tier)
25
34
  const LIMITS = { reqPerMin: 1000, inPerMin: 450_000, outPerMin: 90_000 };
@@ -65,16 +74,20 @@
65
74
  in:fly={{ y: -8, duration: 200, opacity: 0 }}
66
75
  out:fly={{ y: -8, duration: 150, opacity: 0 }}
67
76
  >
77
+ {#if maxContextTokens && maxContextTokens > 0 && metrics.lastInputTokens > 0}
78
+ <span class:warn={ctxWarn}>Ctx {fmt(metrics.lastInputTokens)}/{fmt(maxContextTokens)}</span>
79
+ {/if}
68
80
  {#if !metrics.isWasm && metrics.totalCacheReadTokens > 0}
69
81
  <span class="cached-active">Cached {fmt(metrics.totalCacheReadTokens)}</span>
70
82
  {/if}
83
+ <span class="sep">|</span>
71
84
  {#if metrics.isWasm}
72
- <span>Req {metrics.requestsPerMin}/min</span>
73
85
  <span>Out {fmt(metrics.outputTokensPerMin)}/min</span>
86
+ <span>Req {metrics.requestsPerMin}/min</span>
74
87
  {:else}
75
- <span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
76
88
  <span class:warn={metrics.inputTokensPerMin > LIMITS.inPerMin * 0.8}>In {fmt(metrics.inputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.inPerMin)} max)</span>
77
89
  <span class:warn={metrics.outputTokensPerMin > LIMITS.outPerMin * 0.8}>Out {fmt(metrics.outputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.outPerMin)} max)</span>
90
+ <span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
78
91
  {/if}
79
92
  </div>
80
93
  {/if}
package/src/index.ts CHANGED
@@ -46,6 +46,11 @@ export { default as Carousel } from './widgets/rich/Carousel.svelte';
46
46
  // Safe image (URL validation + error fallback)
47
47
  export { default as SafeImage } from './widgets/SafeImage.svelte';
48
48
 
49
+ // Widget export utility
50
+ export { exportWidget, getExportFormats, exportWidgetAs } from './widgets/export-widget.js';
51
+ export type { ExportFormat } from './widgets/export-widget.js';
52
+ export { default as ExportModal } from './widgets/ExportModal.svelte';
53
+
49
54
  // Dispatcher
50
55
  export { default as BlockRenderer } from './widgets/BlockRenderer.svelte';
51
56
  export { default as WidgetRenderer } from './widgets/WidgetRenderer.svelte';
@@ -0,0 +1,117 @@
1
+ <script lang="ts">
2
+ import { getExportFormats, exportWidgetAs, type ExportFormat } from './export-widget.js';
3
+
4
+ interface Props {
5
+ open: boolean;
6
+ type: string;
7
+ data: Record<string, unknown>;
8
+ containerEl?: HTMLElement;
9
+ onclose?: () => void;
10
+ }
11
+
12
+ let { open = $bindable(false), type, data, containerEl, onclose }: Props = $props();
13
+
14
+ const formats = $derived(open ? getExportFormats(type, containerEl) : []);
15
+
16
+ function doExport(fmt: ExportFormat) {
17
+ exportWidgetAs(fmt.id, type, data, containerEl);
18
+ open = false;
19
+ onclose?.();
20
+ }
21
+
22
+ function handleBackdrop() {
23
+ open = false;
24
+ onclose?.();
25
+ }
26
+
27
+ function handleKeydown(e: KeyboardEvent) {
28
+ if (e.key === 'Escape') {
29
+ open = false;
30
+ onclose?.();
31
+ }
32
+ }
33
+ </script>
34
+
35
+ {#if open}
36
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
37
+ <div class="export-modal-backdrop" onclick={handleBackdrop} onkeydown={handleKeydown}>
38
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
39
+ <!-- svelte-ignore a11y_click_events_have_key_events -->
40
+ <div class="export-modal" onclick={(e) => e.stopPropagation()}>
41
+ <div class="export-modal-title">Exporter — {type}</div>
42
+ <div class="export-modal-list">
43
+ {#each formats as fmt}
44
+ <button class="export-modal-btn" onclick={() => doExport(fmt)}>
45
+ <span class="export-modal-icon">{fmt.icon}</span>
46
+ <span class="export-modal-label">{fmt.label}</span>
47
+ </button>
48
+ {/each}
49
+ </div>
50
+ </div>
51
+ </div>
52
+ {/if}
53
+
54
+ <style>
55
+ .export-modal-backdrop {
56
+ position: fixed;
57
+ inset: 0;
58
+ z-index: 9999;
59
+ display: flex;
60
+ align-items: center;
61
+ justify-content: center;
62
+ background: rgba(0, 0, 0, 0.35);
63
+ }
64
+ .export-modal {
65
+ background: var(--color-surface, #1a1a2e);
66
+ border: 1px solid var(--color-border, #333);
67
+ border-radius: 8px;
68
+ padding: 12px;
69
+ max-width: 250px;
70
+ width: 100%;
71
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
72
+ font-family: var(--font-mono, ui-monospace, monospace);
73
+ }
74
+ .export-modal-title {
75
+ font-size: 11px;
76
+ color: var(--color-text2, #888);
77
+ margin-bottom: 8px;
78
+ padding-bottom: 6px;
79
+ border-bottom: 1px solid var(--color-border, #333);
80
+ text-transform: uppercase;
81
+ letter-spacing: 0.05em;
82
+ }
83
+ .export-modal-list {
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: 4px;
87
+ }
88
+ .export-modal-btn {
89
+ display: flex;
90
+ align-items: center;
91
+ gap: 8px;
92
+ width: 100%;
93
+ padding: 8px 10px;
94
+ border: 1px solid transparent;
95
+ border-radius: 6px;
96
+ background: transparent;
97
+ color: var(--color-text1, #eee);
98
+ font-family: inherit;
99
+ font-size: 13px;
100
+ cursor: pointer;
101
+ transition: background 0.15s, border-color 0.15s;
102
+ }
103
+ .export-modal-btn:hover {
104
+ background: var(--color-surface2, #2a2a3e);
105
+ border-color: var(--color-border, #444);
106
+ }
107
+ .export-modal-icon {
108
+ font-size: 16px;
109
+ flex-shrink: 0;
110
+ width: 20px;
111
+ text-align: center;
112
+ }
113
+ .export-modal-label {
114
+ flex: 1;
115
+ text-align: left;
116
+ }
117
+ </style>
@@ -71,5 +71,6 @@
71
71
  {loading}
72
72
  onerror={onError}
73
73
  onload={onLoad}
74
+ referrerpolicy="no-referrer"
74
75
  />
75
76
  {/if}
@@ -168,6 +168,10 @@
168
168
  const mc = getMC();
169
169
  if (!mc) return;
170
170
 
171
+ // Unregister first in case widget was re-mounted with same busId
172
+ const toolNames = [`widget_${busId}_get`, `widget_${busId}_update`, `widget_${busId}_remove`];
173
+ toolNames.forEach(n => { try { mc.unregisterTool(n); } catch { /* ok */ } });
174
+
171
175
  mc.registerTool({
172
176
  name: `widget_${busId}_get`,
173
177
  description: `Get current data of ${type} widget (id: ${busId}).`,
@@ -211,7 +215,7 @@
211
215
  </script>
212
216
 
213
217
  {#if isVanillaRenderer}
214
- <div bind:this={vanillaContainer}></div>
218
+ <div bind:this={vanillaContainer} class="vanilla-container w-full h-full overflow-auto p-2"></div>
215
219
  {:else if customRenderer}
216
220
  <svelte:component this={customRenderer as Component<any>} {data} {id} />
217
221
  {:else if nativeEntry}
@@ -219,3 +223,24 @@
219
223
  {:else}
220
224
  <div class="p-3 font-mono text-xs text-text2">[{type}]</div>
221
225
  {/if}
226
+
227
+ <style>
228
+ .vanilla-container :global(svg) {
229
+ width: 100%;
230
+ height: auto;
231
+ max-height: 100%;
232
+ display: block;
233
+ }
234
+ .vanilla-container :global(canvas) {
235
+ width: 100%;
236
+ height: auto;
237
+ max-height: 100%;
238
+ display: block;
239
+ }
240
+ .vanilla-container :global(img) {
241
+ width: 100%;
242
+ height: auto;
243
+ max-height: 100%;
244
+ object-fit: contain;
245
+ }
246
+ </style>
@@ -0,0 +1,345 @@
1
+ /**
2
+ * export-widget.ts — contextual export utility for widgets
3
+ * Determines the best export format based on widget type and triggers a file download.
4
+ */
5
+
6
+ // ── helpers ──────────────────────────────────────────────────────────────────
7
+
8
+ function timestamp(): string {
9
+ return new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
10
+ }
11
+
12
+ function filename(type: string, ext: string): string {
13
+ return `${type}_${timestamp()}.${ext}`;
14
+ }
15
+
16
+ /**
17
+ * Download a file. Accepts either a data: URL or plain text content.
18
+ */
19
+ function downloadFile(content: string, name: string, mimeType = 'text/plain'): void {
20
+ const a = document.createElement('a');
21
+ if (content.startsWith('data:')) {
22
+ a.href = content;
23
+ } else {
24
+ const blob = new Blob([content], { type: mimeType });
25
+ a.href = URL.createObjectURL(blob);
26
+ }
27
+ a.download = name;
28
+ a.click();
29
+ }
30
+
31
+ // ── CSV ───────────────────────────────────────────────────────────────────────
32
+
33
+ /** Escape a cell value for CSV (RFC 4180). */
34
+ function csvCell(value: unknown): string {
35
+ const str = value === null || value === undefined ? '' : String(value);
36
+ if (str.includes('"') || str.includes(',') || str.includes('\n') || str.includes('\r')) {
37
+ return `"${str.replace(/"/g, '""')}"`;
38
+ }
39
+ return str;
40
+ }
41
+
42
+ function csvRow(cells: unknown[]): string {
43
+ return cells.map(csvCell).join(',');
44
+ }
45
+
46
+ function exportCsv(type: string, data: Record<string, unknown>): void {
47
+ let lines: string[] = [];
48
+
49
+ if (type === 'data-table') {
50
+ const columns = (data.columns as string[] | undefined) ?? [];
51
+ const rows = (data.rows as (Record<string, unknown> | unknown[])[]) ?? [];
52
+ lines.push(csvRow(columns));
53
+ for (const row of rows) {
54
+ if (Array.isArray(row)) {
55
+ lines.push(csvRow(row));
56
+ } else {
57
+ lines.push(csvRow(columns.map((c) => (row as Record<string, unknown>)[c])));
58
+ }
59
+ }
60
+ } else if (type === 'grid-data') {
61
+ const headers = (data.headers as string[] | undefined) ?? [];
62
+ const rows = (data.rows as unknown[][] | undefined) ?? [];
63
+ lines.push(csvRow(headers));
64
+ for (const row of rows) lines.push(csvRow(row));
65
+ } else if (type === 'kv') {
66
+ lines.push(csvRow(['key', 'value']));
67
+ const items = (data.items as { key: unknown; value: unknown }[] | undefined) ?? [];
68
+ for (const item of items) lines.push(csvRow([item.key, item.value]));
69
+ } else if (type === 'list') {
70
+ lines.push(csvRow(['value']));
71
+ const items = (data.items as unknown[] | undefined) ?? [];
72
+ for (const item of items) {
73
+ if (typeof item === 'object' && item !== null && 'label' in item) {
74
+ lines.push(csvRow([(item as { label: unknown }).label]));
75
+ } else {
76
+ lines.push(csvRow([item]));
77
+ }
78
+ }
79
+ }
80
+
81
+ downloadFile(lines.join('\r\n'), filename(type, 'csv'), 'text/csv;charset=utf-8');
82
+ }
83
+
84
+ // ── PNG via SVG ───────────────────────────────────────────────────────────────
85
+
86
+ function svgToPng(svgEl: SVGElement, name: string): void {
87
+ // Inline computed styles on the SVG to improve fidelity when rendered off-DOM
88
+ const svgData = new XMLSerializer().serializeToString(svgEl);
89
+ const canvas = document.createElement('canvas');
90
+ const ctx = canvas.getContext('2d')!;
91
+ const img = new Image();
92
+ const blob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
93
+ const url = URL.createObjectURL(blob);
94
+ img.onload = () => {
95
+ canvas.width = img.naturalWidth || svgEl.clientWidth || 800;
96
+ canvas.height = img.naturalHeight || svgEl.clientHeight || 600;
97
+ ctx.drawImage(img, 0, 0);
98
+ const pngUrl = canvas.toDataURL('image/png');
99
+ downloadFile(pngUrl, name);
100
+ URL.revokeObjectURL(url);
101
+ };
102
+ img.onerror = () => {
103
+ // Fallback: try canvas.toDataURL directly if available
104
+ URL.revokeObjectURL(url);
105
+ };
106
+ img.src = url;
107
+ }
108
+
109
+ function canvasToPng(canvasEl: HTMLCanvasElement, name: string): void {
110
+ const url = canvasEl.toDataURL('image/png');
111
+ downloadFile(url, name);
112
+ }
113
+
114
+ function exportPng(type: string, containerEl: HTMLElement): void {
115
+ const name = filename(type, 'png');
116
+
117
+ // Prefer canvas (chart.js renders to <canvas>)
118
+ const canvasEl = containerEl.querySelector('canvas');
119
+ if (canvasEl) {
120
+ canvasToPng(canvasEl, name);
121
+ return;
122
+ }
123
+
124
+ // Fall back to SVG
125
+ const svgEl = containerEl.querySelector('svg');
126
+ if (svgEl) {
127
+ svgToPng(svgEl as SVGElement, name);
128
+ return;
129
+ }
130
+
131
+ console.warn('[exportWidget] No <canvas> or <svg> found in container for type:', type);
132
+ }
133
+
134
+ // ── Markdown ──────────────────────────────────────────────────────────────────
135
+
136
+ function exportMarkdown(type: string, data: Record<string, unknown>): void {
137
+ let content = '';
138
+
139
+ if (type === 'text') {
140
+ content = String((data.content ?? data.text) ?? '');
141
+ } else if (type === 'code') {
142
+ const lang = String(data.language ?? '');
143
+ const code = String(data.code ?? data.content ?? '');
144
+ content = `\`\`\`${lang}\n${code}\n\`\`\``;
145
+ } else if (type === 'log') {
146
+ const entries = (data.entries as unknown[] | undefined) ?? [];
147
+ content = entries.map((e) => (typeof e === 'object' ? JSON.stringify(e) : String(e))).join('\n');
148
+ }
149
+
150
+ downloadFile(content, filename(type, 'md'), 'text/markdown;charset=utf-8');
151
+ }
152
+
153
+ // ── JSON ──────────────────────────────────────────────────────────────────────
154
+
155
+ function exportJson(type: string, data: Record<string, unknown>): void {
156
+ const content = JSON.stringify(data, null, 2);
157
+ downloadFile(content, filename(type, 'json'), 'application/json');
158
+ }
159
+
160
+ // ── HTML (js-sandbox) ─────────────────────────────────────────────────────────
161
+
162
+ function exportHtml(type: string, data: Record<string, unknown>, containerEl?: HTMLElement): void {
163
+ // Try to grab the iframe srcdoc/src first
164
+ const iframeEl = containerEl?.querySelector('iframe');
165
+ let content = '';
166
+ if (iframeEl) {
167
+ try {
168
+ content = iframeEl.contentDocument?.documentElement?.outerHTML ?? '';
169
+ } catch {
170
+ // cross-origin — fall back to data
171
+ }
172
+ }
173
+ if (!content) {
174
+ content = String(data.html ?? data.source ?? data.content ?? '');
175
+ }
176
+ downloadFile(content, filename(type, 'html'), 'text/html;charset=utf-8');
177
+ }
178
+
179
+ // ── Gallery / Carousel (JSON of image URLs) ──────────────────────────────────
180
+
181
+ function exportImageUrls(type: string, data: Record<string, unknown>): void {
182
+ const items = (data.items as unknown[] | undefined) ?? (data.images as unknown[] | undefined) ?? [];
183
+ const urls = items.map((item) => {
184
+ if (typeof item === 'string') return item;
185
+ if (typeof item === 'object' && item !== null) {
186
+ return (item as Record<string, unknown>).url ?? (item as Record<string, unknown>).src ?? item;
187
+ }
188
+ return item;
189
+ });
190
+ const content = JSON.stringify({ type, urls }, null, 2);
191
+ downloadFile(content, filename(type, 'json'), 'application/json');
192
+ }
193
+
194
+ // ── Export Format types ───────────────────────────────────────────────────────
195
+
196
+ export interface ExportFormat {
197
+ id: string; // 'csv', 'png', 'json', 'md', 'html'
198
+ label: string; // 'CSV', 'PNG', 'JSON', 'Markdown', 'HTML'
199
+ icon: string; // emoji or unicode
200
+ }
201
+
202
+ const CSV_FMT: ExportFormat = { id: 'csv', label: 'CSV', icon: '📊' };
203
+ const PNG_FMT: ExportFormat = { id: 'png', label: 'PNG', icon: '📷' };
204
+ const JSON_FMT: ExportFormat = { id: 'json', label: 'JSON', icon: '📋' };
205
+ const MD_FMT: ExportFormat = { id: 'md', label: 'Markdown', icon: '📝' };
206
+ const HTML_FMT: ExportFormat = { id: 'html', label: 'HTML', icon: '🌐' };
207
+
208
+ /**
209
+ * Return the list of export formats available for a given widget type.
210
+ */
211
+ export function getExportFormats(type: string, containerEl?: HTMLElement): ExportFormat[] {
212
+ switch (type) {
213
+ case 'data-table':
214
+ case 'grid-data':
215
+ case 'kv':
216
+ case 'list':
217
+ return [CSV_FMT, JSON_FMT];
218
+
219
+ case 'chart':
220
+ case 'chart-rich':
221
+ case 'sankey':
222
+ case 'd3':
223
+ case 'hemicycle':
224
+ case 'map':
225
+ return [PNG_FMT, JSON_FMT];
226
+
227
+ case 'text':
228
+ case 'code':
229
+ case 'log':
230
+ return [MD_FMT, JSON_FMT];
231
+
232
+ case 'js-sandbox':
233
+ return [HTML_FMT, JSON_FMT];
234
+
235
+ case 'gallery':
236
+ case 'carousel':
237
+ return [JSON_FMT];
238
+
239
+ default:
240
+ return [JSON_FMT];
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Export a widget in a specific format chosen by the user.
246
+ */
247
+ export function exportWidgetAs(
248
+ format: string,
249
+ type: string,
250
+ data: Record<string, unknown>,
251
+ containerEl?: HTMLElement
252
+ ): void {
253
+ switch (format) {
254
+ case 'csv':
255
+ exportCsv(type, data);
256
+ break;
257
+ case 'png':
258
+ if (containerEl) exportPng(type, containerEl);
259
+ else console.warn('[exportWidgetAs] containerEl required for PNG export');
260
+ break;
261
+ case 'md':
262
+ exportMarkdown(type, data);
263
+ break;
264
+ case 'html':
265
+ exportHtml(type, data, containerEl);
266
+ break;
267
+ case 'json':
268
+ default:
269
+ exportJson(type, data);
270
+ break;
271
+ }
272
+ }
273
+
274
+ // ── Public API ────────────────────────────────────────────────────────────────
275
+
276
+ /**
277
+ * Export a widget's data in the most appropriate format for its type.
278
+ * @deprecated Use getExportFormats() + exportWidgetAs() for format selection modal.
279
+ *
280
+ * @param type Widget type identifier (e.g. "data-table", "chart", "text")
281
+ * @param data The widget's data object
282
+ * @param containerEl Optional DOM container — required for PNG exports
283
+ */
284
+ export function exportWidget(
285
+ type: string,
286
+ data: Record<string, unknown>,
287
+ containerEl?: HTMLElement
288
+ ): void {
289
+ switch (type) {
290
+ // ── CSV ──
291
+ case 'data-table':
292
+ case 'grid-data':
293
+ case 'kv':
294
+ case 'list':
295
+ exportCsv(type, data);
296
+ break;
297
+
298
+ // ── PNG ──
299
+ case 'chart':
300
+ case 'chart-rich':
301
+ case 'sankey':
302
+ case 'd3':
303
+ case 'hemicycle':
304
+ case 'map':
305
+ if (containerEl) {
306
+ exportPng(type, containerEl);
307
+ } else {
308
+ console.warn('[exportWidget] containerEl required for PNG export of type:', type);
309
+ }
310
+ break;
311
+
312
+ // ── Markdown ──
313
+ case 'text':
314
+ case 'code':
315
+ case 'log':
316
+ exportMarkdown(type, data);
317
+ break;
318
+
319
+ // ── HTML ──
320
+ case 'js-sandbox':
321
+ exportHtml(type, data, containerEl);
322
+ break;
323
+
324
+ // ── Image URL lists ──
325
+ case 'gallery':
326
+ case 'carousel':
327
+ exportImageUrls(type, data);
328
+ break;
329
+
330
+ // ── JSON (everything else) ──
331
+ case 'json-viewer':
332
+ case 'profile':
333
+ case 'stat-card':
334
+ case 'stat':
335
+ case 'tags':
336
+ case 'alert':
337
+ case 'actions':
338
+ case 'cards':
339
+ case 'timeline':
340
+ case 'trombinoscope':
341
+ default:
342
+ exportJson(type, data);
343
+ break;
344
+ }
345
+ }
@@ -20,7 +20,7 @@
20
20
  <!-- svelte-ignore a11y_no_static_element_interactions -->
21
21
  <!-- svelte-ignore a11y_interactive_supports_focus -->
22
22
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
23
- <div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined} onclick={()=>oncardclick?.(card)}>
23
+ <div class="bg-surface2 border border-border rounded-lg overflow-hidden hover:border-border2 transition-all {oncardclick?'cursor-pointer':''}" role={oncardclick?"button":undefined} tabindex={oncardclick?0:undefined} title={oncardclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oncardclick?.(card)}>
24
24
  {#if card.image}<SafeImage src={card.image} alt={card.title} class="w-full h-32 object-cover" hideOnError />{/if}
25
25
  <div class="p-3">
26
26
  <div class="font-semibold text-sm text-text1 leading-tight">{card.title}</div>
@@ -3,7 +3,11 @@
3
3
  export interface ChartSpec { title?: string; type?: 'bar'|'line'|'area'|'pie'|'donut'; labels?: string[]; data?: ChartDataset[]; legend?: boolean; xAxis?: {label?:string}; yAxis?: {label?:string}; }
4
4
  interface Props { spec: Partial<ChartSpec>; }
5
5
  let { spec }: Props = $props();
6
- const PAL=['#7c6dfa','#3ecfb2','#f0a050','#fa6d7c','#3b82f6','#a855f7','#14b8a6','#f97316'];
6
+ const PAL=[
7
+ '#6366f1','#f59e0b','#10b981','#ef4444','#8b5cf6',
8
+ '#06b6d4','#f97316','#ec4899','#14b8a6','#a855f7',
9
+ '#eab308','#3b82f6','#22c55e','#e11d48','#0ea5e9',
10
+ ];
7
11
  const fmt=new Intl.NumberFormat('fr-FR');
8
12
  const datasets=$derived<ChartDataset[]>(Array.isArray(spec.data)?spec.data:[]);
9
13
  const labels=$derived<string[]>(Array.isArray(spec.labels)?spec.labels:[]);
@@ -14,6 +18,10 @@
14
18
  const xLabels=$derived(labels.length>0?labels:(datasets[0]?.values??[]).map((_:number,i:number)=>String(i+1)));
15
19
  const showLegend=$derived(spec.legend!==false&&datasets.length>1);
16
20
  function col(ds:ChartDataset,i:number){return ds.color??PAL[i%PAL.length];}
21
+ /** For single-series bar charts, color each bar distinctly by x-index */
22
+ const isCategoricalBar=$derived(type==='bar'&&datasets.length===1);
23
+ const isCategoricalLine=$derived((type==='line'||type==='area')&&datasets.length===1);
24
+ function barCol(ds:ChartDataset,di:number,xi:number){return isCategoricalBar?PAL[xi%PAL.length]:col(ds,di);}
17
25
  // Pie
18
26
  const pieTotal=$derived.by<number>(()=>{const ds=datasets[0];if(!ds)return 1;return ds.values.reduce((a,b)=>a+b,0)||1;});
19
27
  interface Slice{label:string;value:number;color:string;startAngle:number;endAngle:number;pct:number}
@@ -71,7 +79,7 @@
71
79
  {@const v=ds.values[xi]??0}
72
80
  {@const pct=Math.round(v/maxVal*100)}
73
81
  <div class="flex-1 rounded-t transition-all hover:opacity-80 cursor-default"
74
- style="height:{pct}%;background:{col(ds,di)};"
82
+ style="height:{pct}%;background:{barCol(ds,di,xi)};"
75
83
  title="{ds.label??''} {lbl}: {fmt.format(v)}">
76
84
  </div>
77
85
  {/each}
@@ -90,6 +98,11 @@
90
98
  <polygon points="{pad},{H-pad} {pts.join(' ')} {pad+((ds.values.length-1)*((W-pad*2)/(ds.values.length-1||1)))},{H-pad}" fill={col(ds,di)} opacity="0.15"/>
91
99
  {/if}
92
100
  <polyline points={pts.join(' ')} fill="none" stroke={col(ds,di)} stroke-width="2" stroke-linejoin="round"/>
101
+ {#each ds.values as v, i}
102
+ {@const cx = pad + i * ((W - pad * 2) / (ds.values.length - 1 || 1))}
103
+ {@const cy = H - pad - (v / maxVal) * (H - pad * 2)}
104
+ <circle cx={cx} cy={cy} r="4" fill={isCategoricalLine ? PAL[i % PAL.length] : col(ds, di)} stroke="var(--color-surface, white)" stroke-width="1.5" />
105
+ {/each}
93
106
  {/each}
94
107
  </svg>
95
108
  <div class="flex gap-1">
@@ -97,7 +110,25 @@
97
110
  </div>
98
111
  {/if}
99
112
  </div>
100
- {#if showLegend}
113
+ {#if isCategoricalLine && xLabels.length>1}
114
+ <div class="flex gap-3 flex-wrap mt-2">
115
+ {#each xLabels as lbl, xi}
116
+ <div class="flex items-center gap-1 text-xs">
117
+ <div class="w-2.5 h-2.5 rounded-full flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
118
+ <span class="text-text2">{lbl}</span>
119
+ </div>
120
+ {/each}
121
+ </div>
122
+ {:else if isCategoricalBar && xLabels.length>1}
123
+ <div class="flex gap-3 flex-wrap mt-2">
124
+ {#each xLabels as lbl,xi}
125
+ <div class="flex items-center gap-1 text-xs">
126
+ <div class="w-2.5 h-2.5 rounded-sm flex-shrink-0" style="background:{PAL[xi%PAL.length]};"></div>
127
+ <span class="text-text2">{lbl}</span>
128
+ </div>
129
+ {/each}
130
+ </div>
131
+ {:else if showLegend}
101
132
  <div class="flex gap-3 flex-wrap mt-2">
102
133
  {#each datasets as ds,i}
103
134
  <div class="flex items-center gap-1 text-xs">
@@ -37,7 +37,8 @@
37
37
  <tbody>
38
38
  {#each displayed as row, i}
39
39
  <tr class="hover:bg-surface2 transition-colors {spec.striped!==false&&i%2===1?'bg-white/[0.02]':''} {onrowclick?'cursor-pointer':''}"
40
- onclick={()=>onrowclick?.(row)}>
40
+ title={onrowclick?"Double-cliquez pour interagir":undefined}
41
+ ondblclick={()=>onrowclick?.(row)}>
41
42
  {#each columns as col}
42
43
  {@const val=row[col.key]}
43
44
  <td class="border-b border-border text-text1 overflow-hidden text-ellipsis whitespace-nowrap max-w-[280px] {compact?'px-2 py-1 text-xs':'px-3 py-2 text-sm'} {col.align==='center'?'text-center':col.align==='right'?'text-right':''}">
@@ -16,6 +16,8 @@
16
16
 
17
17
  function open(i: number) {
18
18
  lightboxIdx = i;
19
+ }
20
+ function interact(i: number) {
19
21
  onimageclick?.(images[i], i);
20
22
  }
21
23
  function close() { lightboxIdx = null; }
@@ -35,11 +37,22 @@
35
37
  {#if spec.title}<h3 class="text-sm font-semibold text-text1 mb-3">{spec.title}</h3>{/if}
36
38
  {#if images.length === 0}
37
39
  <p class="text-text2 text-sm">{spec.emptyMessage ?? 'Aucune image'}</p>
40
+ {:else if images.length === 1}
41
+ {@const img = images[0]}
42
+ <button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group w-full"
43
+ onclick={() => open(0)}>
44
+ <SafeImage src={img.src} alt={img.alt ?? ''} class="w-full max-h-[400px] object-contain rounded-lg" loading="eager" />
45
+ {#if img.caption || img.alt}
46
+ <div class="mt-2 text-center text-xs text-text2">{img.caption ?? img.alt}</div>
47
+ {/if}
48
+ </button>
38
49
  {:else}
39
50
  <div class="grid gap-2 responsive-gallery" style="--gallery-cols: repeat({spec.columns ?? 3}, minmax(0, 1fr));">
40
51
  {#each images as img, i}
41
52
  <button class="relative overflow-hidden rounded-lg border border-border hover:border-border2 transition-all cursor-pointer bg-transparent p-0 group"
42
- onclick={() => open(i)}>
53
+ title={onimageclick?"Double-cliquez pour interagir":undefined}
54
+ onclick={() => open(i)}
55
+ ondblclick={() => interact(i)}>
43
56
  <SafeImage src={img.src} alt={img.alt ?? ''} class="w-full h-32 sm:h-40 object-cover transition-transform group-hover:scale-105" loading="lazy" />
44
57
  {#if img.caption}
45
58
  <div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/60 to-transparent px-2 py-1.5">
@@ -30,9 +30,10 @@
30
30
  {#each rows as row, ri}
31
31
  <tr class="hover:bg-surface2">
32
32
  {#each (Array.isArray(row)?row:[]) as cell, ci}
33
- <td class="px-3 text-text2 border-b border-r border-border cursor-pointer hover:bg-surface2"
33
+ <td class="px-3 text-text2 border-b border-r border-border {oncellclick?'cursor-pointer':''} hover:bg-surface2"
34
34
  style="height:{cellH}px;{bg(ri,ci)?`background:${bg(ri,ci)};`:''}"
35
- onclick={()=>oncellclick?.(ri,ci,cell)}>{dv(cell)}</td>
35
+ title={oncellclick?"Double-cliquez pour interagir":undefined}
36
+ ondblclick={()=>oncellclick?.(ri,ci,cell)}>{dv(cell)}</td>
36
37
  {/each}
37
38
  </tr>
38
39
  {/each}
@@ -51,7 +51,9 @@
51
51
  class={s.gid?'cursor-pointer':''}
52
52
  onmouseenter={()=>{const g=groups.find(g=>g.id===s.gid);if(g)tooltip={label:g.label,seats:g.seats};}}
53
53
  onmouseleave={()=>tooltip=null}
54
- onclick={()=>{const g=groups.find(g=>g.id===s.gid);if(g)ongroupclick?.(g);}}/>
54
+ ondblclick={()=>{const g=groups.find(g=>g.id===s.gid);if(g)ongroupclick?.(g);}}>
55
+ {#if s.gid}<title>Double-cliquez pour interagir</title>{/if}
56
+ </circle>
55
57
  {/each}
56
58
  <text x={cx} y={cy+18} text-anchor="middle" font-size="11" fill="var(--color-text2)" font-family="system-ui">{total} sièges</text>
57
59
  </svg>
@@ -65,7 +67,7 @@
65
67
  {#each legend as g}
66
68
  <!-- svelte-ignore a11y_click_events_have_key_events -->
67
69
  <!-- svelte-ignore a11y_no_static_element_interactions -->
68
- <div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0" onclick={()=>ongroupclick?.(g)} onkeydown={(e)=>{if(e.key==="Enter")ongroupclick?.(g)}}>
70
+ <div class="flex items-center gap-1.5 text-xs cursor-pointer hover:opacity-80" role="button" tabindex="0" title="Double-cliquez pour interagir" ondblclick={()=>ongroupclick?.(g)} onkeydown={(e)=>{if(e.key==="Enter")ongroupclick?.(g)}}>
69
71
  <div class="w-3 h-3 rounded-full flex-shrink-0" style="background:{g.color};"></div>
70
72
  <span class="text-text2">{g.label}</span>
71
73
  <span class="text-text2">{g.seats}</span>
@@ -18,7 +18,7 @@
18
18
  <!-- svelte-ignore a11y_no_static_element_interactions -->
19
19
  <!-- svelte-ignore a11y_interactive_supports_focus -->
20
20
  <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
21
- <div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined} onclick={()=>oneventclick?.(event)} onkeydown={(e)=>{if(e.key==='Enter')oneventclick?.(event)}}>
21
+ <div class="flex gap-4 relative {!isLast?'pb-5':''} {oneventclick?'cursor-pointer':''}" role={oneventclick?"button":undefined} tabindex={oneventclick?0:undefined} title={oneventclick?"Double-cliquez pour interagir":undefined} ondblclick={()=>oneventclick?.(event)} onkeydown={(e)=>{if(e.key==='Enter')oneventclick?.(event)}}>
22
22
  <div class="flex flex-col items-center flex-shrink-0">
23
23
  <div class="w-3 h-3 rounded-full flex-shrink-0 mt-0.5" style="background:{dotColor};{event.status==='active'?`box-shadow:0 0 0 3px ${dotColor}33;`:''}"></div>
24
24
  {#if !isLast}<div class="w-0.5 flex-1 bg-border mt-1"></div>{/if}
@@ -9,7 +9,7 @@
9
9
  {#if data.title}<div class="text-[10px] font-mono text-text2 mb-4 uppercase tracking-widest">{data.title}</div>{/if}
10
10
  <div class="flex items-end gap-1.5 h-32">
11
11
  {#each bars as [, val]}
12
- <div class="flex-1 rounded-t bg-accent/80 hover:bg-accent transition-all"
12
+ <div class="flex-1 rounded-t bg-accent opacity-80 hover:opacity-100 transition-all"
13
13
  style="height: max(2px, {Math.round(val / max * 100)}%)"></div>
14
14
  {/each}
15
15
  </div>
@@ -10,7 +10,8 @@
10
10
  <!-- svelte-ignore a11y_click_events_have_key_events -->
11
11
  <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
12
12
  <li class="text-sm text-text1 bg-surface2 rounded px-3 py-2 border-l-2 border-accent {onitemclick ? 'cursor-pointer hover:bg-surface2/80' : ''}"
13
- onclick={() => onitemclick?.(item, i)}>{item}</li>
13
+ title={onitemclick?"Double-cliquez pour interagir":undefined}
14
+ ondblclick={() => onitemclick?.(item, i)}>{item}</li>
14
15
  {/each}
15
16
  </ul>
16
17
  </div>
@@ -2,5 +2,121 @@
2
2
  export interface TextBlockData { content?: string; }
3
3
  interface Props { data: Partial<TextBlockData>; }
4
4
  let { data }: Props = $props();
5
+
6
+ /** Minimal markdown → HTML renderer (no deps, naturally XSS-safe: only produces known tags) */
7
+ function renderMarkdown(src: string): string {
8
+ if (!src) return '';
9
+
10
+ // Escape HTML entities first (XSS protection)
11
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
12
+
13
+ const lines = src.split('\n');
14
+ const out: string[] = [];
15
+ let inCode = false;
16
+ let codeLines: string[] = [];
17
+ let inUl = false;
18
+ let inOl = false;
19
+
20
+ const closeList = () => {
21
+ if (inUl) { out.push('</ul>'); inUl = false; }
22
+ if (inOl) { out.push('</ol>'); inOl = false; }
23
+ };
24
+
25
+ /** Inline formatting: bold, italic, code, links */
26
+ const inline = (s: string): string => {
27
+ return esc(s)
28
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
29
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
30
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>')
31
+ .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
32
+ };
33
+
34
+ for (const line of lines) {
35
+ // Fenced code block toggle
36
+ if (line.trimStart().startsWith('```')) {
37
+ if (!inCode) {
38
+ closeList();
39
+ inCode = true;
40
+ codeLines = [];
41
+ } else {
42
+ out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
43
+ inCode = false;
44
+ }
45
+ continue;
46
+ }
47
+ if (inCode) { codeLines.push(line); continue; }
48
+
49
+ const trimmed = line.trim();
50
+
51
+ // Empty line → close lists, push break
52
+ if (!trimmed) { closeList(); out.push(''); continue; }
53
+
54
+ // Headers
55
+ const hMatch = trimmed.match(/^(#{1,6})\s+(.+)$/);
56
+ if (hMatch) {
57
+ closeList();
58
+ const level = hMatch[1].length;
59
+ out.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
60
+ continue;
61
+ }
62
+
63
+ // Unordered list
64
+ if (/^[-*+]\s+/.test(trimmed)) {
65
+ if (inOl) { out.push('</ol>'); inOl = false; }
66
+ if (!inUl) { out.push('<ul>'); inUl = true; }
67
+ out.push(`<li>${inline(trimmed.replace(/^[-*+]\s+/, ''))}</li>`);
68
+ continue;
69
+ }
70
+
71
+ // Ordered list
72
+ const olMatch = trimmed.match(/^(\d+)\.\s+(.+)$/);
73
+ if (olMatch) {
74
+ if (inUl) { out.push('</ul>'); inUl = false; }
75
+ if (!inOl) { out.push('<ol>'); inOl = true; }
76
+ out.push(`<li>${inline(olMatch[2])}</li>`);
77
+ continue;
78
+ }
79
+
80
+ // Horizontal rule
81
+ if (/^[-*_]{3,}$/.test(trimmed)) {
82
+ closeList();
83
+ out.push('<hr>');
84
+ continue;
85
+ }
86
+
87
+ // Regular paragraph line
88
+ closeList();
89
+ out.push(`<p>${inline(trimmed)}</p>`);
90
+ }
91
+
92
+ // Close any open blocks
93
+ if (inCode) out.push(`<pre><code>${esc(codeLines.join('\n'))}</code></pre>`);
94
+ closeList();
95
+
96
+ return out.join('\n');
97
+ }
98
+
99
+ let rendered = $derived(renderMarkdown(data.content ?? ''));
5
100
  </script>
6
- <div class="p-4 md:p-5 text-sm text-text2 leading-relaxed">{data.content ?? ''}</div>
101
+ <div class="tb-md p-4 md:p-5 text-sm leading-relaxed">{@html rendered}</div>
102
+
103
+ <style>
104
+ .tb-md { color: var(--color-text2); }
105
+ .tb-md :global(h1) { font-size: 1.5em; font-weight: 700; color: var(--color-text1); margin: 0.8em 0 0.4em; }
106
+ .tb-md :global(h2) { font-size: 1.25em; font-weight: 600; color: var(--color-text1); margin: 0.7em 0 0.35em; }
107
+ .tb-md :global(h3) { font-size: 1.1em; font-weight: 600; color: var(--color-text1); margin: 0.6em 0 0.3em; }
108
+ .tb-md :global(h4), .tb-md :global(h5), .tb-md :global(h6) { font-size: 1em; font-weight: 600; color: var(--color-text1); margin: 0.5em 0 0.25em; }
109
+ .tb-md :global(p) { margin: 0.4em 0; }
110
+ .tb-md :global(strong) { font-weight: 600; color: var(--color-text1); }
111
+ .tb-md :global(em) { font-style: italic; }
112
+ .tb-md :global(a) { color: var(--color-accent); text-decoration: underline; text-underline-offset: 2px; }
113
+ .tb-md :global(a:hover) { opacity: 0.8; }
114
+ .tb-md :global(ul), .tb-md :global(ol) { margin: 0.4em 0; padding-left: 1.5em; }
115
+ .tb-md :global(ul) { list-style: disc; }
116
+ .tb-md :global(ol) { list-style: decimal; }
117
+ .tb-md :global(li) { margin: 0.15em 0; }
118
+ .tb-md :global(code) { font-family: 'IBM Plex Mono', ui-monospace, monospace; font-size: 0.9em; background: var(--color-surface2); padding: 0.15em 0.35em; border-radius: 4px; }
119
+ .tb-md :global(pre) { background: var(--color-surface2); border-radius: 6px; padding: 0.75em 1em; margin: 0.5em 0; overflow-x: auto; }
120
+ .tb-md :global(pre code) { background: none; padding: 0; font-size: 0.85em; }
121
+ .tb-md :global(hr) { border: none; border-top: 1px solid var(--color-surface2); margin: 0.8em 0; }
122
+ </style>
@@ -81,6 +81,7 @@
81
81
  .flex-grid {
82
82
  display: grid;
83
83
  grid-template-columns: repeat(auto-fill, minmax(var(--min-w), 1fr));
84
+ grid-auto-rows: 280px;
84
85
  gap: var(--gap);
85
86
  padding: var(--gap);
86
87
  }