@webmcp-auto-ui/ui 2.5.6 → 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.6",
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",
@@ -80,13 +80,14 @@
80
80
  {#if !metrics.isWasm && metrics.totalCacheReadTokens > 0}
81
81
  <span class="cached-active">Cached {fmt(metrics.totalCacheReadTokens)}</span>
82
82
  {/if}
83
+ <span class="sep">|</span>
83
84
  {#if metrics.isWasm}
84
- <span>Req {metrics.requestsPerMin}/min</span>
85
85
  <span>Out {fmt(metrics.outputTokensPerMin)}/min</span>
86
+ <span>Req {metrics.requestsPerMin}/min</span>
86
87
  {:else}
87
- <span class:warn={metrics.requestsPerMin > LIMITS.reqPerMin * 0.8}>Req {metrics.requestsPerMin}/min</span> <span class="limit">({fmt(LIMITS.reqPerMin)} max)</span>
88
88
  <span class:warn={metrics.inputTokensPerMin > LIMITS.inPerMin * 0.8}>In {fmt(metrics.inputTokensPerMin)}/min</span> <span class="limit">({fmt(LIMITS.inPerMin)} max)</span>
89
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>
90
91
  {/if}
91
92
  </div>
92
93
  {/if}
package/src/index.ts CHANGED
@@ -47,7 +47,9 @@ export { default as Carousel } from './widgets/rich/Carousel.svelte';
47
47
  export { default as SafeImage } from './widgets/SafeImage.svelte';
48
48
 
49
49
  // Widget export utility
50
- export { exportWidget } from './widgets/export-widget.js';
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';
51
53
 
52
54
  // Dispatcher
53
55
  export { default as BlockRenderer } from './widgets/BlockRenderer.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>
@@ -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} class="w-full h-full overflow-hidden"></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>
@@ -191,10 +191,91 @@ function exportImageUrls(type: string, data: Record<string, unknown>): void {
191
191
  downloadFile(content, filename(type, 'json'), 'application/json');
192
192
  }
193
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
+
194
274
  // ── Public API ────────────────────────────────────────────────────────────────
195
275
 
196
276
  /**
197
277
  * Export a widget's data in the most appropriate format for its type.
278
+ * @deprecated Use getExportFormats() + exportWidgetAs() for format selection modal.
198
279
  *
199
280
  * @param type Widget type identifier (e.g. "data-table", "chart", "text")
200
281
  * @param data The widget's data object
@@ -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}
@@ -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>