@widgetic/creator 0.3.59 → 0.3.60

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.
@@ -3496,9 +3496,15 @@
3496
3496
  {/if}
3497
3497
 
3498
3498
  <div
3499
- class="widget-details-debug-buttons relative z-[1] ml-auto flex items-center shrink-0"
3499
+ class="widget-details-header-actions relative z-[1] ml-auto flex items-center gap-2 shrink-0"
3500
3500
  bind:this={widgetDetailsDebugButtonsEl}
3501
3501
  >
3502
+ <div class="widget-details-header-meta flex items-center gap-2 shrink-0 min-w-0">
3503
+ <slot name="header-meta" />
3504
+ <div class="widget-info-version-history-slot relative z-20 shrink-0">
3505
+ <slot name="version-history" />
3506
+ </div>
3507
+ </div>
3502
3508
  {#if SHOW_PANEL_PREVIEW_COLUMN}
3503
3509
  <HoverTooltip text={previewColumnCollapsed ? 'Open live preview' : 'Close live preview'}>
3504
3510
  <button
@@ -3533,7 +3539,7 @@
3533
3539
  </div>
3534
3540
 
3535
3541
  <!-- Widget Details Content: Two-column layout -->
3536
- <div class="widget-details-content-wrapper relative m-2.5 min-w-[460px] min-h-0 flex-1 overflow-hidden">
3542
+ <div class="widget-details-content-wrapper relative m-0 min-w-[460px] min-h-0 flex-1 overflow-hidden">
3537
3543
  <div class="widget-details-two-col flex h-full min-h-0 min-w-[460px] gap-0 overflow-hidden">
3538
3544
 
3539
3545
  <!-- ═══ LEFT COLUMN: Step Content (scrollable) ═══ -->
@@ -3541,43 +3547,13 @@
3541
3547
  class="widget-details-left-col flex flex-col min-w-[460px] min-h-0 overflow-hidden relative"
3542
3548
  style="width: {previewColumnCollapsed ? 100 : leftColPercent}%; flex: 0 0 {previewColumnCollapsed ? 100 : leftColPercent}%;"
3543
3549
  >
3544
- <div class="widget-details-left-border absolute inset-y-0 left-0 right-0 border border-gray-300 rounded-lg pointer-events-none z-10"></div>
3550
+ <div class="widget-details-left-border absolute inset-y-0 left-0 right-0 border border-gray-300 rounded-none pointer-events-none z-10"></div>
3545
3551
 
3546
3552
  {#if selectedWidgetId}
3547
3553
  <div class="widget-details-left-stack relative z-0 flex flex-col h-full min-h-0 min-w-[460px] overflow-hidden">
3548
- <!-- Sticky Widget Info Bar (always visible above scrollable content) -->
3549
- <div class="widget-info-sticky-bar relative flex items-center justify-between px-3 py-1.5 border-b border-gray-200 bg-gray-50 rounded-t-lg shrink-0 gap-2 min-w-0 z-20 overflow-visible">
3550
- <div class="widget-info-name flex items-center min-w-0 gap-1 text-sm text-gray-700">
3551
- <!-- svelte-ignore a11y-click-events-have-key-events -->
3552
- <!-- svelte-ignore a11y-no-static-element-interactions -->
3553
- <span
3554
- class="widget-id-label text-[11px] font-mono text-gray-500 cursor-pointer hover:text-gray-700 truncate"
3555
- title="Click to copy widget ID"
3556
- onclick={() => { navigator.clipboard.writeText(selectedWidgetId); showToast('success', 'Widget ID copied', { duration: 2000 }); }}
3557
- >Widget id: {selectedWidgetId}</span>
3558
- </div>
3559
- <div class="widget-info-meta flex items-center gap-2 shrink-0">
3560
- {#if saveStatus === 'saving'}
3561
- <span class="text-[10px] px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 animate-pulse">Saving...</span>
3562
- {:else if saveStatus === 'saved'}
3563
- <span class="text-[10px] px-1.5 py-0.5 rounded bg-green-100 text-green-700">Saved</span>
3564
- {:else if saveStatus === 'error'}
3565
- <span class="text-[10px] px-1.5 py-0.5 rounded bg-red-100 text-red-700">Save Error</span>
3566
- {/if}
3567
- {#if debugMode}
3568
- <span class="text-[10px] text-gray-500">
3569
- Commit: <span class="font-mono font-medium text-gray-600" title={lastCommitId || ''}>{lastCommitId ? lastCommitId.substring(0, 8) : '—'}</span>
3570
- </span>
3571
- {/if}
3572
- <div class="widget-info-version-history-slot relative z-20 shrink-0">
3573
- <slot name="version-history" />
3574
- </div>
3575
- </div>
3576
- </div>
3577
-
3578
3554
  <!-- Scrollable step content -->
3579
3555
  <div
3580
- class="widget-details-left-content flex flex-col flex-1 min-h-0 relative rounded-b-lg overflow-hidden overflow-x-hidden"
3556
+ class="widget-details-left-content flex flex-col flex-1 min-h-0 relative rounded-none overflow-hidden overflow-x-hidden"
3581
3557
  bind:this={contentContainerEl}
3582
3558
  >
3583
3559
  <div class="actions-panel widget-details-actions-panel relative flex flex-col flex-1 min-h-0">
@@ -191,6 +191,7 @@ declare const WidgetDetails: $$__sveltets_2_IsomorphicComponent<{
191
191
  } & {
192
192
  [evt: string]: CustomEvent<any>;
193
193
  }, {
194
+ 'header-meta': {};
194
195
  'version-history': {};
195
196
  'code-generation-models': {};
196
197
  'code-generation-status': {};
@@ -3,30 +3,98 @@
3
3
  * Live iframe + chrome over Fabric WidgetShapes (W3).
4
4
  * Body drag is locked on the shape; move via the top drag bar so the iframe stays interactive.
5
5
  */
6
- let className = '';
7
- export { className as class };
8
- export let style = '';
9
-
10
- export let enabled = true;
11
- /** Screen-space boxes for widget shapes currently on the canvas. */
12
- export let getRects:
13
- | (() => Array<{ widgetId: string; left: number; top: number; width: number; height: number }>)
14
- | null = null;
15
- /** Resolve a live preview URL for a widget (CDN published or Worker preview). */
16
- export let resolveUrl: ((widgetId: string) => string | null) | null = null;
17
- /** Currently selected WidgetShape id (highlights chrome). */
18
- export let selectedWidgetId: string | null = null;
19
- /** Translate shape by screen-pixel delta. */
20
- export let onTranslate:
21
- | ((widgetId: string, dxScreen: number, dyScreen: number) => void)
22
- | null = null;
23
- /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
24
- export let onSelectShape: ((widgetId: string) => void) | null = null;
25
- /** Open Widget Details / chat for this widget. */
26
- export let onOpenChat: ((widgetId: string) => void) | null = null;
27
-
28
- const DRAG_BAR_H = 28;
6
+ import { onMount } from 'svelte';
7
+
8
+ interface VersionOption {
9
+ id: string;
10
+ /** Full list label, e.g. "Version 9". */
11
+ label: string;
12
+ /** Compact trigger label, e.g. "v9". */
13
+ shortLabel?: string;
14
+ /** Published date/time shown in the list. */
15
+ dateLabel?: string;
16
+ /** True when this option is the live published version. */
17
+ isLive?: boolean;
18
+ }
19
+
20
+ interface Props {
21
+ class?: string;
22
+ style?: string;
23
+ enabled?: boolean;
24
+ /** Screen-space boxes for widget shapes currently on the canvas. */
25
+ getRects?:
26
+ | (() => Array<{ widgetId: string; left: number; top: number; width: number; height: number }>)
27
+ | null;
28
+ /** Resolve a live preview URL for a widget (CDN published or Worker preview). */
29
+ resolveUrl?: ((widgetId: string) => string | null) | null;
30
+ /** Currently selected WidgetShape id (highlights chrome). */
31
+ selectedWidgetId?: string | null;
32
+ /** Translate shape by screen-pixel delta. */
33
+ onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
34
+ /** Fired once when a chrome drag ends (mark canvas dirty / history). */
35
+ onTranslateEnd?: ((widgetId: string) => void) | null;
36
+ /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
37
+ onSelectShape?: ((widgetId: string) => void) | null;
38
+ /** Open Widget Details / chat for this widget. */
39
+ onOpenChat?: ((widgetId: string) => void) | null;
40
+ /** Resolve display title for the chrome bar. */
41
+ resolveTitle?: ((widgetId: string) => string | null) | null;
42
+ /** Current version label, e.g. "v3" or "Preview". */
43
+ resolveVersionLabel?: ((widgetId: string) => string | null) | null;
44
+ /** Available version options for the select (null/empty → show label only). */
45
+ resolveVersionOptions?: ((widgetId: string) => Array<VersionOption> | null) | null;
46
+ /** Currently selected version option id. */
47
+ resolveVersionSelectedId?: ((widgetId: string) => string | null) | null;
48
+ onVersionChange?: ((widgetId: string, versionId: string) => void) | null;
49
+ /** Promote a published version to Live (same as chat Set live). */
50
+ onSetLive?: ((widgetId: string, version: number) => void) | null;
51
+ /** Version currently being set live (disables that row's button). */
52
+ settingLiveVersion?: number | null;
53
+ /** Optional notify after local iframe reload (does not capture screenshots). */
54
+ onRefresh?: ((widgetId: string) => void) | null;
55
+ /** Bump when widget names / publish state change so chrome re-renders. */
56
+ titleRevision?: number;
57
+ /** Whether this widget has unpublished codegen ahead of Live (show Publish). */
58
+ resolveCanPublish?: ((widgetId: string) => boolean) | null;
59
+ /** Publish HEAD as a new live version (shape bar Publish button). */
60
+ onPublish?: ((widgetId: string) => void) | null;
61
+ /** True while publish is in flight for this widget. */
62
+ resolveIsPublishing?: ((widgetId: string) => boolean) | null;
63
+ }
64
+
65
+ let {
66
+ class: className = '',
67
+ style = '',
68
+ enabled = true,
69
+ getRects = null,
70
+ resolveUrl = null,
71
+ selectedWidgetId = null,
72
+ onTranslate = null,
73
+ onTranslateEnd = null,
74
+ onSelectShape = null,
75
+ onOpenChat = null,
76
+ resolveTitle = null,
77
+ resolveVersionLabel = null,
78
+ resolveVersionOptions = null,
79
+ resolveVersionSelectedId = null,
80
+ onVersionChange = null,
81
+ onSetLive = null,
82
+ settingLiveVersion = null,
83
+ onRefresh = null,
84
+ titleRevision = 0,
85
+ resolveCanPublish = null,
86
+ onPublish = null,
87
+ resolveIsPublishing = null,
88
+ }: Props = $props();
89
+
90
+ const DRAG_BAR_H = 30;
29
91
  const BAR_SHAPE_GAP = 4;
92
+ const SHOW_TITLE_MIN_WIDTH = 160;
93
+ /** Minimum chrome width when expanded so version + Live/Publish controls fit. */
94
+ const MIN_EXPANDED_CHROME_WIDTH = 220;
95
+ const IFRAME_INSET_PX = 2;
96
+ /** ~5 version rows before the menu scrolls (matches chat history panel). */
97
+ const VERSION_MENU_MAX_H = '12.5rem';
30
98
 
31
99
  type ShapeRect = {
32
100
  widgetId: string;
@@ -37,8 +105,15 @@
37
105
  src: string;
38
106
  };
39
107
 
40
- let overlays: ShapeRect[] = [];
41
- let draggingWidgetId: string | null = null;
108
+ let overlays = $state<ShapeRect[]>([]);
109
+ let draggingWidgetId = $state<string | null>(null);
110
+ let resizingWidgetId = $state<string | null>(null);
111
+ /** Per-widget cache-bust tokens for Refresh (iframe reload only). */
112
+ let reloadTokens = $state<Record<string, number>>({});
113
+ /** Which shape chrome version menu is open. */
114
+ let openVersionMenuWidgetId = $state<string | null>(null);
115
+ /** Hide Fabric placeholder flash — show loader until iframe fires load. */
116
+ let iframeLoadedByWidgetId = $state<Record<string, boolean>>({});
42
117
 
43
118
  export function sync(): void {
44
119
  if (!enabled || !getRects || !resolveUrl) {
@@ -46,10 +121,15 @@
46
121
  return;
47
122
  }
48
123
  const next: ShapeRect[] = [];
124
+ const nextLoaded = { ...iframeLoadedByWidgetId };
49
125
  for (const rect of getRects() || []) {
50
126
  if (!rect?.widgetId) continue;
51
127
  const src = resolveUrl(rect.widgetId);
52
128
  if (!src) continue;
129
+ const prev = overlays.find((o) => o.widgetId === rect.widgetId);
130
+ if (!prev || prev.src !== src) {
131
+ nextLoaded[rect.widgetId] = false;
132
+ }
53
133
  next.push({
54
134
  widgetId: rect.widgetId,
55
135
  left: rect.left,
@@ -59,20 +139,34 @@
59
139
  src,
60
140
  });
61
141
  }
142
+ iframeLoadedByWidgetId = nextLoaded;
62
143
  overlays = next;
63
144
  }
64
145
 
146
+ function buildIframeSrc(baseSrc: string, widgetId: string): string {
147
+ const token = reloadTokens[widgetId];
148
+ if (!token) return baseSrc;
149
+ try {
150
+ const url = new URL(baseSrc, typeof window !== 'undefined' ? window.location.href : 'http://localhost');
151
+ url.searchParams.set('_t', String(token));
152
+ return url.toString();
153
+ } catch (err) {
154
+ console.warn('[WidgetShapeLivePreviews] buildIframeSrc failed', err);
155
+ const cleaned = baseSrc.replace(/([?&])_t=\d+/g, '$1').replace(/[?&]$/, '');
156
+ const sep = cleaned.includes('?') ? '&' : '?';
157
+ return `${cleaned}${sep}_t=${token}`;
158
+ }
159
+ }
160
+
65
161
  function startShapeDrag(event: PointerEvent, widgetId: string) {
66
162
  const target = event.target as HTMLElement | null;
67
- if (target?.closest('button, a, input')) return;
163
+ if (target?.closest('button, a, input, select, label')) return;
68
164
  event.preventDefault();
69
165
  event.stopPropagation();
70
166
  onSelectShape?.(widgetId);
71
167
  draggingWidgetId = widgetId;
72
- const startX = event.clientX;
73
- const startY = event.clientY;
74
- let lastX = startX;
75
- let lastY = startY;
168
+ let lastX = event.clientX;
169
+ let lastY = event.clientY;
76
170
 
77
171
  function onMove(e: PointerEvent) {
78
172
  const dx = e.clientX - lastX;
@@ -90,6 +184,7 @@
90
184
  document.removeEventListener('pointercancel', endDrag);
91
185
  document.body.style.removeProperty('user-select');
92
186
  document.body.style.removeProperty('cursor');
187
+ onTranslateEnd?.(widgetId);
93
188
  console.log('[WidgetShapeLivePreviews] Drag end', widgetId.substring(0, 8));
94
189
  }
95
190
 
@@ -107,6 +202,102 @@
107
202
  onSelectShape?.(widgetId);
108
203
  onOpenChat?.(widgetId);
109
204
  }
205
+
206
+ function handleRefreshClick(event: MouseEvent, widgetId: string) {
207
+ event.preventDefault();
208
+ event.stopPropagation();
209
+ onSelectShape?.(widgetId);
210
+ iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: false };
211
+ reloadTokens = { ...reloadTokens, [widgetId]: Date.now() };
212
+ onRefresh?.(widgetId);
213
+ console.log('[WidgetShapeLivePreviews] Refresh iframe', widgetId.substring(0, 8));
214
+ }
215
+
216
+ function markIframeLoaded(widgetId: string) {
217
+ if (iframeLoadedByWidgetId[widgetId]) return;
218
+ iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: true };
219
+ }
220
+
221
+ function getTitle(widgetId: string): string {
222
+ return resolveTitle?.(widgetId)?.trim() || 'Widget';
223
+ }
224
+
225
+ function getVersionOptions(widgetId: string): VersionOption[] {
226
+ return resolveVersionOptions?.(widgetId) || [];
227
+ }
228
+
229
+ function resolveSelectedVersionId(widgetId: string, options: VersionOption[]): string {
230
+ const fromParent = resolveVersionSelectedId?.(widgetId)?.trim();
231
+ if (fromParent && options.some((o) => o.id === fromParent)) return fromParent;
232
+ const liveOpt = options.find((o) => o.isLive);
233
+ return liveOpt?.id || options[0]?.id || '';
234
+ }
235
+
236
+ function getSelectedOption(widgetId: string, options: VersionOption[]): VersionOption | null {
237
+ if (!options.length) return null;
238
+ const id = resolveSelectedVersionId(widgetId, options);
239
+ return options.find((o) => o.id === id) || options[0] || null;
240
+ }
241
+
242
+ function triggerLabelForOption(option: VersionOption | null, fallback: string): string {
243
+ if (!option) return fallback;
244
+ return (option.shortLabel || option.label || fallback).trim();
245
+ }
246
+
247
+ function toggleVersionMenu(event: MouseEvent, widgetId: string) {
248
+ event.preventDefault();
249
+ event.stopPropagation();
250
+ onSelectShape?.(widgetId);
251
+ openVersionMenuWidgetId = openVersionMenuWidgetId === widgetId ? null : widgetId;
252
+ }
253
+
254
+ function selectVersion(event: MouseEvent, widgetId: string, versionId: string) {
255
+ event.preventDefault();
256
+ event.stopPropagation();
257
+ openVersionMenuWidgetId = null;
258
+ if (!versionId) return;
259
+ onVersionChange?.(widgetId, versionId);
260
+ console.log('[WidgetShapeLivePreviews] Version change', widgetId.substring(0, 8), versionId);
261
+ }
262
+
263
+ function handleSetLiveClick(event: MouseEvent, widgetId: string, versionId: string) {
264
+ event.preventDefault();
265
+ event.stopPropagation();
266
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
267
+ if (!Number.isFinite(version)) return;
268
+ onSetLive?.(widgetId, version);
269
+ openVersionMenuWidgetId = null;
270
+ console.log('[WidgetShapeLivePreviews] Set live', widgetId.substring(0, 8), version);
271
+ }
272
+
273
+ function handleDocumentPointerDown(event: PointerEvent) {
274
+ if (!openVersionMenuWidgetId) return;
275
+ const target = event.target as HTMLElement | null;
276
+ if (target?.closest?.(`[data-version-menu-for="${openVersionMenuWidgetId}"]`)) return;
277
+ openVersionMenuWidgetId = null;
278
+ }
279
+
280
+ function handleDocumentKeydown(event: KeyboardEvent) {
281
+ if (event.key === 'Escape' && openVersionMenuWidgetId) {
282
+ openVersionMenuWidgetId = null;
283
+ }
284
+ }
285
+
286
+ onMount(() => {
287
+ document.addEventListener('pointerdown', handleDocumentPointerDown, true);
288
+ document.addEventListener('keydown', handleDocumentKeydown);
289
+ const onShapePointerBlock = (event: Event) => {
290
+ const detail = (event as CustomEvent<{ widgetId?: string | null; active?: boolean }>).detail;
291
+ if (!detail) return;
292
+ resizingWidgetId = detail.active && detail.widgetId ? detail.widgetId : null;
293
+ };
294
+ window.addEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
295
+ return () => {
296
+ document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
297
+ document.removeEventListener('keydown', handleDocumentKeydown);
298
+ window.removeEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
299
+ };
300
+ });
110
301
  </script>
111
302
 
112
303
  {#if enabled && overlays.length > 0}
@@ -114,57 +305,237 @@
114
305
  {#each overlays as overlay (overlay.widgetId)}
115
306
  {@const isSelected = selectedWidgetId === overlay.widgetId}
116
307
  {@const isDragging = draggingWidgetId === overlay.widgetId}
308
+ {@const isResizing = resizingWidgetId === overlay.widgetId}
309
+ {@const blockIframePointer = isDragging || isResizing}
117
310
  {@const chromeTop = overlay.top - DRAG_BAR_H - BAR_SHAPE_GAP}
311
+ {@const showExpandedChrome = isSelected || overlay.width >= SHOW_TITLE_MIN_WIDTH}
312
+ {@const chromeWidth = showExpandedChrome
313
+ ? Math.max(overlay.width, MIN_EXPANDED_CHROME_WIDTH)
314
+ : overlay.width}
315
+ {@const titleText = (titleRevision, getTitle(overlay.widgetId))}
316
+ {@const versionMenuOpen = openVersionMenuWidgetId === overlay.widgetId}
317
+ {@const canPublish = (titleRevision, !!resolveCanPublish?.(overlay.widgetId))}
318
+ {@const isPublishing = (titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
319
+ {@const versionOptions = (titleRevision, getVersionOptions(overlay.widgetId))}
320
+ {@const selectedVersion = getSelectedOption(overlay.widgetId, versionOptions)}
321
+ {@const versionTriggerText = triggerLabelForOption(
322
+ selectedVersion,
323
+ resolveVersionLabel?.(overlay.widgetId)?.trim() || '—',
324
+ )}
118
325
  <div
119
326
  class="widget-shape-live-chrome-ct fixed"
120
- style="left: {overlay.left}px; top: {chromeTop}px; width: {overlay.width}px; height: {DRAG_BAR_H + BAR_SHAPE_GAP + overlay.height}px; z-index: 1;"
327
+ style="left: {overlay.left}px; top: {chromeTop}px; width: {chromeWidth}px; height: {DRAG_BAR_H + BAR_SHAPE_GAP + overlay.height}px; z-index: 1;"
121
328
  >
122
329
  <!-- Drag bar sits between rotation control and the shape top stroke -->
123
330
  <!-- svelte-ignore a11y_no_static_element_interactions -->
124
331
  <div
125
- class="widget-shape-drag-bar pointer-events-auto absolute left-0 right-0 top-0 flex h-7 items-center gap-1 rounded-t-md border border-b-0 bg-white/95 px-1.5 shadow-sm select-none
332
+ class="widget-shape-drag-bar pointer-events-auto absolute left-0 right-0 top-0 flex items-center gap-1 rounded-t-md border border-b-0 bg-white/95 px-1.5 shadow-sm select-none
126
333
  {isSelected ? 'border-blue-400' : 'border-gray-300'}"
127
- style="height: {DRAG_BAR_H}px; cursor: grab;"
334
+ style="height: {DRAG_BAR_H}px; cursor: grab; min-width: {showExpandedChrome ? MIN_EXPANDED_CHROME_WIDTH : 0}px;"
128
335
  title="Drag widget"
129
336
  onpointerdown={(e) => startShapeDrag(e, overlay.widgetId)}
130
337
  >
131
338
  <span class="widget-shape-drag-grip shrink-0 text-xs leading-none text-gray-400" aria-hidden="true">⠿</span>
132
- {#if isSelected}
339
+
340
+ {#if showExpandedChrome}
341
+ <span
342
+ class="widget-shape-title-el min-w-0 flex-1 truncate text-[11px] font-medium text-gray-700"
343
+ title={titleText}
344
+ >{titleText}</span>
345
+
346
+ {#if versionOptions.length > 0}
347
+ <div
348
+ class="widget-shape-version-ct relative flex shrink-0 items-center gap-0.5"
349
+ data-version-menu-for={overlay.widgetId}
350
+ >
351
+ <button
352
+ type="button"
353
+ class="widget-shape-version-trigger flex min-w-[2.25rem] max-w-[4rem] cursor-pointer items-center gap-0.5 rounded border border-emerald-300 bg-emerald-50 px-1.5 py-0.5 text-[10px] font-semibold tabular-nums text-emerald-800 hover:bg-emerald-100"
354
+ title="Open version list — Live is what visitors see on your website"
355
+ aria-haspopup="listbox"
356
+ aria-expanded={versionMenuOpen}
357
+ onclick={(e) => toggleVersionMenu(e, overlay.widgetId)}
358
+ onpointerdown={(e) => e.stopPropagation()}
359
+ >
360
+ {versionTriggerText}
361
+ <span class="widget-shape-version-caret text-emerald-500" aria-hidden="true">{versionMenuOpen ? '▲' : '▼'}</span>
362
+ </button>
363
+ {#if canPublish}
364
+ <button
365
+ type="button"
366
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] rounded border border-emerald-600 bg-emerald-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
367
+ disabled={isPublishing}
368
+ title="Publish the latest generated code as the Live version"
369
+ onclick={(e) => {
370
+ e.stopPropagation();
371
+ onPublish?.(overlay.widgetId);
372
+ }}
373
+ onpointerdown={(e) => e.stopPropagation()}
374
+ >{isPublishing ? '...' : 'Publish'}</button>
375
+ {:else if selectedVersion?.isLive}
376
+ <span
377
+ class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
378
+ title="This version is Live"
379
+ >Live</span>
380
+ {:else if selectedVersion && onSetLive}
381
+ <button
382
+ type="button"
383
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] rounded border border-emerald-300 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
384
+ disabled={settingLiveVersion === Number(selectedVersion.id.slice(2))}
385
+ title="Publish this version so visitors see it on your website"
386
+ onclick={(e) => handleSetLiveClick(e, overlay.widgetId, selectedVersion.id)}
387
+ onpointerdown={(e) => e.stopPropagation()}
388
+ >{settingLiveVersion === Number(selectedVersion.id.slice(2)) ? '...' : 'Set live'}</button>
389
+ {/if}
390
+ {#if versionMenuOpen}
391
+ <div
392
+ class="widget-shape-version-menu absolute right-0 top-full z-30 mt-0.5 w-max max-w-[min(22rem,calc(100vw-16px))] overflow-y-auto overflow-x-hidden rounded-md border border-emerald-200 bg-white py-0.5 shadow-lg"
393
+ style="max-height: {VERSION_MENU_MAX_H}; scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
394
+ role="listbox"
395
+ >
396
+ {#each versionOptions as option (option.id)}
397
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
398
+ <div
399
+ role="option"
400
+ aria-selected={selectedVersion?.id === option.id}
401
+ tabindex="0"
402
+ class="widget-shape-version-option flex w-max max-w-full items-center gap-2 px-2 py-1.5 text-left text-[10px] cursor-pointer
403
+ {selectedVersion?.id === option.id ? 'bg-emerald-50 text-emerald-800' : 'text-gray-700 hover:bg-gray-50'}"
404
+ onclick={(e) => selectVersion(e, overlay.widgetId, option.id)}
405
+ onkeydown={(e) => {
406
+ if (e.key === 'Enter' || e.key === ' ') {
407
+ e.preventDefault();
408
+ selectVersion(e as unknown as MouseEvent, overlay.widgetId, option.id);
409
+ }
410
+ }}
411
+ onpointerdown={(e) => e.stopPropagation()}
412
+ >
413
+ <span class="widget-shape-version-option-label shrink-0 font-semibold tabular-nums">{option.shortLabel || option.label}</span>
414
+ <span class="widget-shape-version-option-date shrink-0 whitespace-nowrap tabular-nums text-slate-400" style="padding-right: 4px;">{option.dateLabel || '—'}</span>
415
+ {#if option.isLive}
416
+ <span class="widget-shape-version-live-badge shrink-0 inline-flex items-center justify-center min-w-[4.75rem] rounded bg-emerald-100 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-emerald-600">live</span>
417
+ {:else if onSetLive}
418
+ <button
419
+ type="button"
420
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] rounded border border-emerald-300 px-1.5 py-0.5 text-[9px] font-medium text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
421
+ disabled={settingLiveVersion === Number(option.id.slice(2))}
422
+ onclick={(e) => handleSetLiveClick(e, overlay.widgetId, option.id)}
423
+ onpointerdown={(e) => e.stopPropagation()}
424
+ >{settingLiveVersion === Number(option.id.slice(2)) ? '...' : 'Set live'}</button>
425
+ {/if}
426
+ </div>
427
+ {/each}
428
+ </div>
429
+ {/if}
430
+ </div>
431
+ {:else if versionTriggerText && versionTriggerText !== '—'}
432
+ <span
433
+ class="widget-shape-version-label shrink-0 rounded bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500"
434
+ title="Current version"
435
+ >{versionTriggerText}</span>
436
+ {/if}
437
+ {:else if isSelected}
133
438
  <span
134
439
  class="widget-shape-edit-mode-badge shrink-0 rounded-full bg-amber-200 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wide text-amber-900"
135
440
  >Edit mode</span>
136
441
  {/if}
137
- <div class="widget-shape-drag-bar-actions ml-auto flex items-center gap-0.5">
442
+
443
+ <div class="widget-shape-drag-bar-actions ml-auto flex shrink-0 items-center gap-0.5">
138
444
  <button
139
445
  type="button"
140
- class="widget-shape-open-chat-bt flex h-6 items-center gap-1 rounded px-1.5 text-[10px] font-medium text-gray-600 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
446
+ class="widget-shape-open-chat-btn flex h-6 w-6 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
141
447
  title="Open chat"
448
+ aria-label="Open chat"
142
449
  onclick={(e) => handleOpenChat(e, overlay.widgetId)}
143
450
  onpointerdown={(e) => e.stopPropagation()}
144
451
  >
145
- Chat
452
+ <svg
453
+ class="widget-shape-chat-icon h-4 w-4"
454
+ viewBox="0 0 24 24"
455
+ fill="none"
456
+ stroke="currentColor"
457
+ stroke-width="2"
458
+ stroke-linecap="round"
459
+ stroke-linejoin="round"
460
+ aria-hidden="true"
461
+ >
462
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
463
+ </svg>
464
+ <span class="sr-only">Chat</span>
465
+ </button>
466
+ <button
467
+ type="button"
468
+ class="widget-shape-refresh-btn flex h-6 w-6 items-center justify-center rounded text-gray-500 hover:bg-gray-100 hover:text-forest-green cursor-pointer"
469
+ title="Refresh preview"
470
+ aria-label="Refresh preview"
471
+ onclick={(e) => handleRefreshClick(e, overlay.widgetId)}
472
+ onpointerdown={(e) => e.stopPropagation()}
473
+ >
474
+ <svg class="widget-shape-refresh-icon h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
475
+ <polyline points="23 4 23 10 17 10"/>
476
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
477
+ </svg>
146
478
  </button>
147
479
  </div>
148
480
  </div>
149
481
 
150
482
  <div
151
- class="widget-shape-live-preview-frame pointer-events-none absolute left-0 right-0 overflow-visible
483
+ class="widget-shape-live-preview-frame pointer-events-none absolute left-0 overflow-visible
152
484
  {isSelected ? 'ring-2 ring-blue-400/80 rounded-sm' : ''}"
153
- style="top: {DRAG_BAR_H + BAR_SHAPE_GAP}px; height: {overlay.height}px;"
485
+ style="top: {DRAG_BAR_H + BAR_SHAPE_GAP}px; height: {overlay.height}px; width: {overlay.width}px;"
154
486
  >
155
487
  <!-- Inset so Fabric corner/rotate handles stay clickable around the edges -->
156
488
  <iframe
157
489
  class="widget-shape-live-preview-iframe pointer-events-auto absolute border-0 bg-white"
158
- class:pointer-events-none={isDragging}
159
- style="left: 8px; top: 8px; right: 8px; bottom: 8px; width: calc(100% - 16px); height: calc(100% - 16px);"
490
+ class:pointer-events-none={blockIframePointer}
491
+ class:opacity-0={!iframeLoadedByWidgetId[overlay.widgetId]}
492
+ style="left: {IFRAME_INSET_PX}px; top: {IFRAME_INSET_PX}px; right: {IFRAME_INSET_PX}px; bottom: {IFRAME_INSET_PX}px; width: calc(100% - {IFRAME_INSET_PX * 2}px); height: calc(100% - {IFRAME_INSET_PX * 2}px);"
160
493
  title="Live widget preview"
161
- src={overlay.src}
494
+ src={buildIframeSrc(overlay.src, overlay.widgetId)}
495
+ data-widget-id={overlay.widgetId}
162
496
  tabindex="-1"
163
- loading="lazy"
497
+ loading="eager"
164
498
  sandbox="allow-scripts allow-same-origin allow-forms"
499
+ onload={() => markIframeLoaded(overlay.widgetId)}
165
500
  ></iframe>
501
+ {#if !iframeLoadedByWidgetId[overlay.widgetId]}
502
+ <div
503
+ class="widget-shape-preview-loader pointer-events-none absolute inset-0 z-[1] flex items-center justify-center rounded-sm bg-white/90"
504
+ aria-hidden="true"
505
+ >
506
+ <div class="widget-shape-preview-spinner h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-emerald-600"></div>
507
+ </div>
508
+ {/if}
509
+ {#if blockIframePointer}
510
+ <!-- Transparent blue shield so drag/resize isn't stolen by the iframe -->
511
+ <div
512
+ class="widget-shape-iframe-pointer-shield pointer-events-auto absolute inset-0 z-[2] rounded-sm bg-blue-400/20"
513
+ aria-hidden="true"
514
+ ></div>
515
+ {/if}
166
516
  </div>
167
517
  </div>
168
518
  {/each}
169
519
  </div>
170
520
  {/if}
521
+
522
+ <style>
523
+ /* Suppress default Tab focus ring on chrome toolbar controls */
524
+ .widget-shape-drag-bar button:focus,
525
+ .widget-shape-drag-bar button:focus-visible {
526
+ outline: none;
527
+ box-shadow: none;
528
+ }
529
+ .widget-shape-version-menu::-webkit-scrollbar {
530
+ width: 8px;
531
+ height: 8px;
532
+ background: transparent;
533
+ }
534
+ .widget-shape-version-menu::-webkit-scrollbar-track {
535
+ background: transparent;
536
+ }
537
+ .widget-shape-version-menu::-webkit-scrollbar-thumb {
538
+ background: rgba(0, 0, 0, 0.2);
539
+ border-radius: 4px;
540
+ }
541
+ </style>