@widgetic/creator 0.3.60 → 0.3.62

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.
@@ -4,6 +4,10 @@
4
4
  * Body drag is locked on the shape; move via the top drag bar so the iframe stays interactive.
5
5
  */
6
6
  import { onMount } from 'svelte';
7
+ import { Checked } from '@widgetic/design-system/icons';
8
+ import { Portal } from '@widgetic/design-system';
9
+ import HoverTooltip from './HoverTooltip.svelte';
10
+ import VersionDropdownRow from './VersionDropdownRow.svelte';
7
11
 
8
12
  interface VersionOption {
9
13
  id: string;
@@ -15,20 +19,32 @@
15
19
  dateLabel?: string;
16
20
  /** True when this option is the live published version. */
17
21
  isLive?: boolean;
22
+ /** True when this version has never been published. */
23
+ isDraft?: boolean;
18
24
  }
19
25
 
20
26
  interface Props {
21
27
  class?: string;
22
28
  style?: string;
23
29
  enabled?: boolean;
24
- /** Screen-space boxes for widget shapes currently on the canvas. */
30
+ /** Shared host: inactive units < active body (60) < controls (70) < active chrome (80). */
31
+ portalTarget?: string;
32
+ /** Bounds park chrome; the local dimensions/matrix transform the live body. */
25
33
  getRects?:
26
- | (() => Array<{ widgetId: string; left: number; top: number; width: number; height: number }>)
34
+ | (() => Array<Omit<ShapeRect, 'src'>>)
27
35
  | null;
28
36
  /** Resolve a live preview URL for a widget (CDN published or Worker preview). */
29
37
  resolveUrl?: ((widgetId: string) => string | null) | null;
38
+ /** Keep chrome visible while a historical build is pending or failed. */
39
+ resolvePreviewState?: ((widgetId: string) => { loading: boolean; error: string | null } | null) | null;
40
+ /** Retry the selected historical version; never refresh shared HEAD. */
41
+ onRetryPreview?: ((widgetId: string) => void) | null;
30
42
  /** Currently selected WidgetShape id (highlights chrome). */
31
43
  selectedWidgetId?: string | null;
44
+ primaryWidgetId?: string | null;
45
+ onSetPrimary?: ((widgetId: string) => void) | null;
46
+ /** Shared publish gate; explicit versions must not inherit another row's error. */
47
+ resolvePublishDisabled?: ((widgetId: string, version?: number | null) => boolean) | null;
32
48
  /** Translate shape by screen-pixel delta. */
33
49
  onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
34
50
  /** Fired once when a chrome drag ends (mark canvas dirty / history). */
@@ -45,30 +61,46 @@
45
61
  resolveVersionOptions?: ((widgetId: string) => Array<VersionOption> | null) | null;
46
62
  /** Currently selected version option id. */
47
63
  resolveVersionSelectedId?: ((widgetId: string) => string | null) | null;
48
- onVersionChange?: ((widgetId: string, versionId: string) => void) | null;
64
+ onVersionChange?: ((widgetId: string, versionId: string, buildPreview?: boolean) => void) | null;
49
65
  /** Promote a published version to Live (same as chat Set live). */
50
66
  onSetLive?: ((widgetId: string, version: number) => void) | null;
51
67
  /** Version currently being set live (disables that row's button). */
52
68
  settingLiveVersion?: number | null;
53
- /** Optional notify after local iframe reload (does not capture screenshots). */
54
- onRefresh?: ((widgetId: string) => void) | null;
69
+ /** Capture callback after an explicit reload has loaded and settled. */
70
+ onRefresh?: ((widgetId: string, iframe: HTMLIFrameElement, isCurrent: () => boolean) => void | Promise<void>) | null;
55
71
  /** Bump when widget names / publish state change so chrome re-renders. */
56
72
  titleRevision?: number;
57
73
  /** Whether this widget has unpublished codegen ahead of Live (show Publish). */
58
74
  resolveCanPublish?: ((widgetId: string) => boolean) | null;
75
+ /**
76
+ * Whether a specific Draft row can be published. Drafts carry their own
77
+ * commit, so they can be promoted without regenerating first — the main
78
+ * `resolveCanPublish` gate is about HEAD vs Live and would say "no".
79
+ */
80
+ resolveCanPublishDraft?: ((widgetId: string, version: number) => boolean) | null;
59
81
  /** Publish HEAD as a new live version (shape bar Publish button). */
60
82
  onPublish?: ((widgetId: string) => void) | null;
83
+ /** Publish a specific Draft version (built from that version's own commit). */
84
+ onPublishDraft?: ((widgetId: string, version: number) => void) | null;
61
85
  /** True while publish is in flight for this widget. */
62
86
  resolveIsPublishing?: ((widgetId: string) => boolean) | null;
87
+ /** Publish-disabled reason for a specific Draft row (null = enabled). */
88
+ resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
63
89
  }
64
90
 
65
91
  let {
66
92
  class: className = '',
67
93
  style = '',
68
94
  enabled = true,
95
+ portalTarget = 'floating-panels-overlay',
69
96
  getRects = null,
70
97
  resolveUrl = null,
98
+ resolvePreviewState = null,
99
+ onRetryPreview = null,
71
100
  selectedWidgetId = null,
101
+ primaryWidgetId = null,
102
+ onSetPrimary = null,
103
+ resolvePublishDisabled = null,
72
104
  onTranslate = null,
73
105
  onTranslateEnd = null,
74
106
  onSelectShape = null,
@@ -83,8 +115,12 @@
83
115
  onRefresh = null,
84
116
  titleRevision = 0,
85
117
  resolveCanPublish = null,
118
+ resolveCanPublishDraft = null,
86
119
  onPublish = null,
120
+ onPublishDraft = null,
87
121
  resolveIsPublishing = null,
122
+ /** Per-widget publish-gate reason for a Draft row (tooltip on Publish). */
123
+ resolvePublishDisabledReason = null,
88
124
  }: Props = $props();
89
125
 
90
126
  const DRAG_BAR_H = 30;
@@ -102,14 +138,39 @@
102
138
  top: number;
103
139
  width: number;
104
140
  height: number;
141
+ localWidth?: number;
142
+ localHeight?: number;
143
+ matrix?: number[];
144
+ /** Pointer is over the shape (Fabric mouse:over); keeps chrome visible. */
145
+ hovered?: boolean;
105
146
  src: string;
106
147
  };
107
148
 
108
149
  let overlays = $state<ShapeRect[]>([]);
150
+ // Pass the existing element to DS Portal: its string-target ancestor guard
151
+ // otherwise creates a second floating-panels-overlay on document.body.
152
+ let portalHost = $state<HTMLElement | null>(null);
109
153
  let draggingWidgetId = $state<string | null>(null);
110
154
  let resizingWidgetId = $state<string | null>(null);
111
155
  /** Per-widget cache-bust tokens for Refresh (iframe reload only). */
112
156
  let reloadTokens = $state<Record<string, number>>({});
157
+ /**
158
+ * Explicit refresh intent: widgetId → token. The parent capture callback
159
+ * (onRefresh) fires only after THAT reload's load event has settled, never
160
+ * for a stale frame, and exactly once per explicit Refresh click.
161
+ */
162
+ const pendingRefreshTokens = new Map<string, number>();
163
+ /** Settle timers for post-load captures so unmount/sync can cancel them. */
164
+ const refreshSettleTimers = new Map<string, ReturnType<typeof setTimeout>>();
165
+
166
+ function cancelPendingRefresh(widgetId: string) {
167
+ const timer = refreshSettleTimers.get(widgetId);
168
+ if (timer) {
169
+ clearTimeout(timer);
170
+ refreshSettleTimers.delete(widgetId);
171
+ }
172
+ pendingRefreshTokens.delete(widgetId);
173
+ }
113
174
  /** Which shape chrome version menu is open. */
114
175
  let openVersionMenuWidgetId = $state<string | null>(null);
115
176
  /** Hide Fabric placeholder flash — show loader until iframe fires load. */
@@ -117,6 +178,7 @@
117
178
 
118
179
  export function sync(): void {
119
180
  if (!enabled || !getRects || !resolveUrl) {
181
+ for (const widgetId of [...pendingRefreshTokens.keys()]) cancelPendingRefresh(widgetId);
120
182
  overlays = [];
121
183
  return;
122
184
  }
@@ -124,22 +186,24 @@
124
186
  const nextLoaded = { ...iframeLoadedByWidgetId };
125
187
  for (const rect of getRects() || []) {
126
188
  if (!rect?.widgetId) continue;
127
- const src = resolveUrl(rect.widgetId);
128
- if (!src) continue;
189
+ const src = resolveUrl(rect.widgetId) || '';
190
+ const previewState = resolvePreviewState?.(rect.widgetId);
191
+ if (!src && !previewState?.loading && !previewState?.error) continue;
129
192
  const prev = overlays.find((o) => o.widgetId === rect.widgetId);
130
193
  if (!prev || prev.src !== src) {
194
+ cancelPendingRefresh(rect.widgetId);
131
195
  nextLoaded[rect.widgetId] = false;
132
196
  }
133
- next.push({
134
- widgetId: rect.widgetId,
135
- left: rect.left,
136
- top: rect.top,
137
- width: rect.width,
138
- height: rect.height,
139
- src,
140
- });
197
+ next.push({ ...rect, src });
141
198
  }
142
199
  iframeLoadedByWidgetId = nextLoaded;
200
+ // Drop refresh intents for shapes that left the canvas — their iframes
201
+ // will never fire the load event the capture is waiting on.
202
+ for (const pendingWidgetId of [...pendingRefreshTokens.keys()]) {
203
+ if (!next.some((overlay) => overlay.widgetId === pendingWidgetId)) {
204
+ cancelPendingRefresh(pendingWidgetId);
205
+ }
206
+ }
143
207
  overlays = next;
144
208
  }
145
209
 
@@ -159,6 +223,7 @@
159
223
  }
160
224
 
161
225
  function startShapeDrag(event: PointerEvent, widgetId: string) {
226
+ if (event.button !== 0 || draggingWidgetId || resizingWidgetId) return;
162
227
  const target = event.target as HTMLElement | null;
163
228
  if (target?.closest('button, a, input, select, label')) return;
164
229
  event.preventDefault();
@@ -207,15 +272,42 @@
207
272
  event.preventDefault();
208
273
  event.stopPropagation();
209
274
  onSelectShape?.(widgetId);
275
+ cancelPendingRefresh(widgetId);
276
+ const token = Math.max(Date.now(), (reloadTokens[widgetId] || 0) + 1);
277
+ pendingRefreshTokens.set(widgetId, token);
210
278
  iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: false };
211
- reloadTokens = { ...reloadTokens, [widgetId]: Date.now() };
212
- onRefresh?.(widgetId);
279
+ reloadTokens = { ...reloadTokens, [widgetId]: token };
213
280
  console.log('[WidgetShapeLivePreviews] Refresh iframe', widgetId.substring(0, 8));
214
281
  }
215
282
 
216
- function markIframeLoaded(widgetId: string) {
217
- if (iframeLoadedByWidgetId[widgetId]) return;
283
+ function markIframeLoaded(widgetId: string, iframe: EventTarget | null) {
284
+ if (!(iframe instanceof HTMLIFrameElement)) return;
285
+ const loadedSource = iframe.src;
286
+ const matchesFrame = () => enabled && iframe.isConnected
287
+ && iframe.dataset.widgetId === widgetId && iframe.src === loadedSource
288
+ && overlays.some((overlay) => overlay.widgetId === widgetId
289
+ && new URL(buildIframeSrc(overlay.src, widgetId), window.location.href).href === loadedSource);
290
+ // An old load event must not mark the replacement loaded or claim its timer.
291
+ if (!matchesFrame()) return;
218
292
  iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: true };
293
+ const token = pendingRefreshTokens.get(widgetId);
294
+ if (!token || refreshSettleTimers.has(widgetId)) return;
295
+ const isCurrent = () => matchesFrame()
296
+ && pendingRefreshTokens.get(widgetId) === token && reloadTokens[widgetId] === token;
297
+ // Match the existing screenshot settling delay, but only after this frame's load.
298
+ refreshSettleTimers.set(widgetId, setTimeout(async () => {
299
+ // Retain the intent and timer marker throughout capture: cancellation must
300
+ // invalidate in-flight work, and duplicate load events must not recapture.
301
+ try {
302
+ if (!isCurrent()) return;
303
+ await onRefresh?.(widgetId, iframe, isCurrent);
304
+ } catch (error) {
305
+ console.warn('[WidgetShapeLivePreviews] Refresh capture failed', widgetId, error);
306
+ } finally {
307
+ // An older completion must never consume a newer Refresh click.
308
+ if (pendingRefreshTokens.get(widgetId) === token) cancelPendingRefresh(widgetId);
309
+ }
310
+ }, 800));
219
311
  }
220
312
 
221
313
  function getTitle(widgetId: string): string {
@@ -233,6 +325,17 @@
233
325
  return liveOpt?.id || options[0]?.id || '';
234
326
  }
235
327
 
328
+ /**
329
+ * True for a version that has not been published yet.
330
+ *
331
+ * The history is a single sequence of versions; a Draft is one that has no
332
+ * `publishedAt`. It must never offer "Set live" — there is no artifact to
333
+ * promote, and publishing goes through the normal Publish flow.
334
+ */
335
+ function isDraftVersion(option: { id: string; isDraft?: boolean }): boolean {
336
+ return option.isDraft === true;
337
+ }
338
+
236
339
  function getSelectedOption(widgetId: string, options: VersionOption[]): VersionOption | null {
237
340
  if (!options.length) return null;
238
341
  const id = resolveSelectedVersionId(widgetId, options);
@@ -257,7 +360,7 @@
257
360
  openVersionMenuWidgetId = null;
258
361
  if (!versionId) return;
259
362
  onVersionChange?.(widgetId, versionId);
260
- console.log('[WidgetShapeLivePreviews] Version change', widgetId.substring(0, 8), versionId);
363
+ // The parent logs the selected version and resolved commit once the build completes.
261
364
  }
262
365
 
263
366
  function handleSetLiveClick(event: MouseEvent, widgetId: string, versionId: string) {
@@ -270,6 +373,84 @@
270
373
  console.log('[WidgetShapeLivePreviews] Set live', widgetId.substring(0, 8), version);
271
374
  }
272
375
 
376
+ /** Publish a Draft row: select/preview that version first, then promote exactly it. */
377
+ function handlePublishDraftClick(event: MouseEvent, widgetId: string, versionId: string) {
378
+ event.preventDefault();
379
+ event.stopPropagation();
380
+ openVersionMenuWidgetId = null;
381
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
382
+ if (!Number.isFinite(version)) return;
383
+ if (!resolveCanPublishDraft?.(widgetId, version) || resolvePublishDisabled?.(widgetId, version)) return;
384
+ // Select exactly this draft, without launching a competing preview build.
385
+ onVersionChange?.(widgetId, versionId, false);
386
+ onPublishDraft?.(widgetId, version);
387
+ console.log('[WidgetShapeLivePreviews] Publish draft', widgetId.substring(0, 8), version);
388
+ }
389
+
390
+ /** Row-select from the shared VersionDropdownRow (no event to stop). */
391
+ function selectVersionFromMenu(widgetId: string, versionId: string) {
392
+ openVersionMenuWidgetId = null;
393
+ if (!versionId) return;
394
+ onVersionChange?.(widgetId, versionId);
395
+ }
396
+
397
+ /** Publish-Draft from the shared VersionDropdownRow; guards mirror handlePublishDraftClick. */
398
+ function handlePublishDraftClickFromMenu(widgetId: string, versionId: string) {
399
+ openVersionMenuWidgetId = null;
400
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
401
+ if (!Number.isFinite(version)) return;
402
+ if (!onPublishDraft) return;
403
+ if (!resolveCanPublishDraft?.(widgetId, version) || resolvePublishDisabled?.(widgetId, version)) return;
404
+ // Select exactly this draft, without launching a competing preview build.
405
+ onVersionChange?.(widgetId, versionId, false);
406
+ onPublishDraft?.(widgetId, version);
407
+ console.log('[WidgetShapeLivePreviews] Publish draft', widgetId.substring(0, 8), version);
408
+ }
409
+
410
+ /** Set-live from the shared VersionDropdownRow. */
411
+ function handleSetLiveClickFromMenu(widgetId: string, versionId: string) {
412
+ const version = versionId.startsWith('v:') ? parseInt(versionId.slice(2), 10) : NaN;
413
+ if (!Number.isFinite(version)) return;
414
+ onSetLive?.(widgetId, version);
415
+ openVersionMenuWidgetId = null;
416
+ console.log('[WidgetShapeLivePreviews] Set live', widgetId.substring(0, 8), version);
417
+ }
418
+
419
+ /**
420
+ * Parent-side iframe selection works across origins without reading child DOM.
421
+ * pointerenter selects on hover and never cancels the child's click. Browsers
422
+ * focus the iframe element in the parent document when its child gains focus;
423
+ * window blur is deferred one task so activeElement has already been updated.
424
+ * No focus() call or transparent click catcher: rating/audio keep the gesture.
425
+ */
426
+ function trackIframeSelection(iframe, widgetId) {
427
+ let focusTimer;
428
+ function select() {
429
+ if (!enabled || draggingWidgetId || resizingWidgetId || !iframe.isConnected) return;
430
+ // Keep the menu owner selected while its dropdown is being used.
431
+ if (openVersionMenuWidgetId && openVersionMenuWidgetId !== widgetId) return;
432
+ if (selectedWidgetId !== widgetId) onSelectShape?.(widgetId);
433
+ }
434
+ function checkFocus() {
435
+ clearTimeout(focusTimer);
436
+ focusTimer = setTimeout(() => {
437
+ if (document.activeElement === iframe) select();
438
+ }, 0);
439
+ }
440
+ iframe.addEventListener('pointerenter', select);
441
+ iframe.addEventListener('focus', select);
442
+ window.addEventListener('blur', checkFocus);
443
+ return {
444
+ update(nextWidgetId) { widgetId = nextWidgetId; },
445
+ destroy() {
446
+ clearTimeout(focusTimer);
447
+ iframe.removeEventListener('pointerenter', select);
448
+ iframe.removeEventListener('focus', select);
449
+ window.removeEventListener('blur', checkFocus);
450
+ },
451
+ };
452
+ }
453
+
273
454
  function handleDocumentPointerDown(event: PointerEvent) {
274
455
  if (!openVersionMenuWidgetId) return;
275
456
  const target = event.target as HTMLElement | null;
@@ -284,6 +465,15 @@
284
465
  }
285
466
 
286
467
  onMount(() => {
468
+ const existingHost = document.getElementsByClassName(portalTarget.replace(/^#/, ''))[0]
469
+ || document.getElementById(portalTarget.replace(/^#/, ''));
470
+ portalHost = existingHost instanceof HTMLElement ? existingHost : null;
471
+ // A child-only HMR remount does not trigger Fabric after:render. Bootstrap
472
+ // once after parent bindings/layout settle; subsequent updates stay event-driven.
473
+ const initialSyncFrame = requestAnimationFrame(() => {
474
+ try { sync(); }
475
+ catch (error) { console.warn('[WidgetShapeLivePreviews] Initial sync failed', error); }
476
+ });
287
477
  document.addEventListener('pointerdown', handleDocumentPointerDown, true);
288
478
  document.addEventListener('keydown', handleDocumentKeydown);
289
479
  const onShapePointerBlock = (event: Event) => {
@@ -293,6 +483,8 @@
293
483
  };
294
484
  window.addEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
295
485
  return () => {
486
+ cancelAnimationFrame(initialSyncFrame);
487
+ for (const widgetId of [...pendingRefreshTokens.keys()]) cancelPendingRefresh(widgetId);
296
488
  document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
297
489
  document.removeEventListener('keydown', handleDocumentKeydown);
298
490
  window.removeEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
@@ -300,7 +492,8 @@
300
492
  });
301
493
  </script>
302
494
 
303
- {#if enabled && overlays.length > 0}
495
+ {#if enabled && overlays.length > 0 && portalHost}
496
+ <Portal target={portalHost} class="widget-shape-units-portal" style="display: contents;">
304
497
  <div class="widget-shape-live-previews-ct pointer-events-none {className}" {style}>
305
498
  {#each overlays as overlay (overlay.widgetId)}
306
499
  {@const isSelected = selectedWidgetId === overlay.widgetId}
@@ -308,29 +501,44 @@
308
501
  {@const isResizing = resizingWidgetId === overlay.widgetId}
309
502
  {@const blockIframePointer = isDragging || isResizing}
310
503
  {@const chromeTop = overlay.top - DRAG_BAR_H - BAR_SHAPE_GAP}
504
+ {@const bodyWidth = overlay.localWidth ?? overlay.width}
505
+ {@const bodyHeight = overlay.localHeight ?? overlay.height}
506
+ {@const bodyMatrix = overlay.matrix ?? [1, 0, 0, 1, overlay.left, overlay.top]}
311
507
  {@const showExpandedChrome = isSelected || overlay.width >= SHOW_TITLE_MIN_WIDTH}
312
508
  {@const chromeWidth = showExpandedChrome
313
509
  ? Math.max(overlay.width, MIN_EXPANDED_CHROME_WIDTH)
314
510
  : overlay.width}
315
- {@const titleText = (titleRevision, getTitle(overlay.widgetId))}
511
+ {@const titleText = (void titleRevision, getTitle(overlay.widgetId))}
316
512
  {@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))}
513
+ {@const canPublish = (void titleRevision, !!resolveCanPublish?.(overlay.widgetId))}
514
+ {@const isPublishing = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
515
+ {@const versionOptions = (void titleRevision, getVersionOptions(overlay.widgetId))}
320
516
  {@const selectedVersion = getSelectedOption(overlay.widgetId, versionOptions)}
321
517
  {@const versionTriggerText = triggerLabelForOption(
322
518
  selectedVersion,
323
519
  resolveVersionLabel?.(overlay.widgetId)?.trim() || '—',
324
520
  )}
521
+ <!-- Inactive widgets are whole stacking units, ordered by Fabric's array.
522
+ Active wrapper is transparent to stacking: body 60 < controls 70 < bar 80. -->
523
+ {@const chromeZIndex = isSelected ? 80 : 2}
524
+ {@const isPrimary = primaryWidgetId === overlay.widgetId}
525
+ {@const previewState = (void titleRevision, resolvePreviewState?.(overlay.widgetId))}
526
+ {@const selectedPreviewBlocked = !!(previewState?.loading || previewState?.error) || !iframeLoadedByWidgetId[overlay.widgetId]}
527
+ <!-- Chrome shows on hover or selection; an open menu pins it while the pointer is inside the menu. -->
528
+ {@const chromeVisible = isSelected || overlay.hovered || versionMenuOpen}
529
+ <div class="widget-shape-unit-ct pointer-events-none" data-widget-id={overlay.widgetId}
530
+ style={isSelected ? 'display: contents;' : 'position: absolute; inset: 0; z-index: 1; pointer-events: none;'}>
325
531
  <div
326
- class="widget-shape-live-chrome-ct fixed"
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;"
532
+ class="widget-shape-live-chrome-ct fixed pointer-events-none"
533
+ data-widget-id={overlay.widgetId}
534
+ class:opacity-0={!chromeVisible}
535
+ style="left: {overlay.left}px; top: {chromeTop}px; width: {chromeWidth}px; height: {DRAG_BAR_H}px; z-index: {chromeZIndex}; pointer-events: none; transition: opacity 120ms ease;"
328
536
  >
329
- <!-- Drag bar sits between rotation control and the shape top stroke -->
537
+ <!-- Attached horizontal chrome wins hits where its own rotation stem overlaps. -->
330
538
  <!-- svelte-ignore a11y_no_static_element_interactions -->
331
539
  <div
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
333
- {isSelected ? 'border-blue-400' : 'border-gray-300'}"
540
+ class="widget-shape-drag-bar pointer-events-auto absolute left-0 right-0 top-0 z-10 flex items-center gap-1 rounded-t-md border border-b-0 px-1.5 shadow-sm select-none [&_button:focus]:outline-none [&_button:focus]:shadow-none [&_button:focus-visible]:outline-none [&_button:focus-visible]:shadow-none
541
+ {isPrimary ? 'bg-red-100' : 'bg-white'} {isSelected ? 'border-blue-400' : 'border-gray-300'}"
334
542
  style="height: {DRAG_BAR_H}px; cursor: grab; min-width: {showExpandedChrome ? MIN_EXPANDED_CHROME_WIDTH : 0}px;"
335
543
  title="Drag widget"
336
544
  onpointerdown={(e) => startShapeDrag(e, overlay.widgetId)}
@@ -343,49 +551,105 @@
343
551
  title={titleText}
344
552
  >{titleText}</span>
345
553
 
554
+ <HoverTooltip text={isPrimary ? 'Primary widget' : 'Set primary'} class="inline-flex shrink-0">
555
+ <button
556
+ type="button"
557
+ class="widget-shape-set-primary-btn flex h-6 w-6 shrink-0 items-center justify-center rounded text-red-700 enabled:hover:bg-red-200 enabled:hover:text-red-800 disabled:text-red-700/40 disabled:pointer-events-none disabled:cursor-default cursor-pointer"
558
+ aria-label={isPrimary ? 'Primary widget' : 'Set primary'}
559
+ disabled={isPrimary || !onSetPrimary}
560
+ onpointerdown={(event) => event.stopPropagation()}
561
+ onclick={(event) => { event.stopPropagation(); onSetPrimary?.(overlay.widgetId); }}
562
+ ><Checked class="widget-shape-primary-icon h-4 w-4" /></button>
563
+ </HoverTooltip>
346
564
  {#if versionOptions.length > 0}
347
565
  <div
348
566
  class="widget-shape-version-ct relative flex shrink-0 items-center gap-0.5"
349
567
  data-version-menu-for={overlay.widgetId}
350
568
  >
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()}
569
+ <HoverTooltip
570
+ text="Switch version"
571
+ maxWidth={120}
572
+ class="inline-flex shrink-0"
359
573
  >
360
- {versionTriggerText}
361
- <span class="widget-shape-version-caret text-emerald-500" aria-hidden="true">{versionMenuOpen ? '▲' : '▼'}</span>
362
- </button>
363
- {#if canPublish}
364
574
  <button
365
575
  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
- }}
576
+ 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"
577
+ aria-haspopup="listbox"
578
+ aria-expanded={versionMenuOpen}
579
+ onclick={(e) => toggleVersionMenu(e, overlay.widgetId)}
373
580
  onpointerdown={(e) => e.stopPropagation()}
374
- >{isPublishing ? '...' : 'Publish'}</button>
581
+ >
582
+ {versionTriggerText}
583
+ <span class="widget-shape-version-caret text-emerald-500" aria-hidden="true">{versionMenuOpen ? '▲' : '▼'}</span>
584
+ </button>
585
+ </HoverTooltip>
586
+ {#if canPublish && !selectedVersion}
587
+ <HoverTooltip
588
+ text="Publish latest"
589
+ maxWidth={120}
590
+ class="inline-flex shrink-0"
591
+ >
592
+ <button
593
+ type="button"
594
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
595
+ disabled={isPublishing || selectedPreviewBlocked || !!resolvePublishDisabled?.(overlay.widgetId, selectedVersion ? Number(selectedVersion.id.slice(2)) : null)}
596
+ onclick={(e) => {
597
+ e.stopPropagation();
598
+ onPublish?.(overlay.widgetId);
599
+ }}
600
+ onpointerdown={(e) => e.stopPropagation()}
601
+ >{isPublishing ? '...' : 'Publish'}</button>
602
+ </HoverTooltip>
375
603
  {: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>
604
+ <HoverTooltip
605
+ text="Currently live"
606
+ maxWidth={120}
607
+ class="inline-flex shrink-0"
608
+ >
609
+ <span
610
+ class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-emerald-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
611
+ >Live</span>
612
+ </HoverTooltip>
613
+ {:else if selectedVersion && onSetLive && !isDraftVersion(selectedVersion)}
614
+ <HoverTooltip
615
+ text="Make this version live"
616
+ maxWidth={130}
617
+ class="inline-flex shrink-0"
618
+ >
619
+ <button
620
+ type="button"
621
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-300 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
622
+ disabled={settingLiveVersion === Number(selectedVersion.id.slice(2))}
623
+ onclick={(e) => handleSetLiveClick(e, overlay.widgetId, selectedVersion.id)}
624
+ onpointerdown={(e) => e.stopPropagation()}
625
+ >{settingLiveVersion === Number(selectedVersion.id.slice(2)) ? '...' : 'Set live'}</button>
626
+ </HoverTooltip>
627
+ {:else if selectedVersion && isDraftVersion(selectedVersion)}
628
+ {#if onPublishDraft}
629
+ <HoverTooltip
630
+ text="Publish this draft"
631
+ maxWidth={130}
632
+ class="inline-flex shrink-0"
633
+ >
634
+ <button
635
+ type="button"
636
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
637
+ disabled={isPublishing || selectedPreviewBlocked || !resolveCanPublishDraft?.(overlay.widgetId, Number(selectedVersion.id.slice(2))) || !!resolvePublishDisabled?.(overlay.widgetId, Number(selectedVersion.id.slice(2)))}
638
+ onclick={(e) => handlePublishDraftClick(e, overlay.widgetId, selectedVersion.id)}
639
+ onpointerdown={(e) => e.stopPropagation()}
640
+ >{isPublishing ? '...' : 'Publish'}</button>
641
+ </HoverTooltip>
642
+ {:else}
643
+ <HoverTooltip
644
+ text="No commit to publish"
645
+ maxWidth={130}
646
+ class="inline-flex shrink-0"
647
+ >
648
+ <span
649
+ class="widget-shape-draft-badge shrink-0 inline-flex items-center justify-center min-w-[3.75rem] rounded bg-amber-100 px-1 py-0.5 text-[8px] font-semibold uppercase tracking-wide text-amber-700"
650
+ >Draft</span>
651
+ </HoverTooltip>
652
+ {/if}
389
653
  {/if}
390
654
  {#if versionMenuOpen}
391
655
  <div
@@ -394,36 +658,26 @@
394
658
  role="listbox"
395
659
  >
396
660
  {#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>
661
+ {@const optionVersion = Number(option.id.slice(2))}
662
+ {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
663
+ {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
664
+ || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
665
+ || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
666
+ {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
667
+ <VersionDropdownRow
668
+ versionLabel={option.shortLabel || option.label}
669
+ dateLabel={option.dateLabel}
670
+ isLive={option.isLive === true}
671
+ isDraft={isDraftVersion(option)}
672
+ selected={selectedVersion?.id === option.id}
673
+ busy={optionBusy}
674
+ publishDisabled={optionPublishDisabled}
675
+ publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
676
+ setLiveDisabled={settingLiveVersion === optionVersion}
677
+ onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
678
+ onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
679
+ onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
680
+ />
427
681
  {/each}
428
682
  </div>
429
683
  {/if}
@@ -441,10 +695,25 @@
441
695
  {/if}
442
696
 
443
697
  <div class="widget-shape-drag-bar-actions ml-auto flex shrink-0 items-center gap-0.5">
698
+ <HoverTooltip text="Refresh preview" class="inline-flex shrink-0">
699
+ <button
700
+ type="button"
701
+ 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"
702
+ aria-label="Refresh preview"
703
+ onclick={(e) => handleRefreshClick(e, overlay.widgetId)}
704
+ onpointerdown={(e) => e.stopPropagation()}
705
+ >
706
+ <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">
707
+ <polyline points="23 4 23 10 17 10"/>
708
+ <path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
709
+ </svg>
710
+ <span class="sr-only">Refresh</span>
711
+ </button>
712
+ </HoverTooltip>
713
+ <HoverTooltip text="Open chat" maxWidth={100} class="inline-flex shrink-0">
444
714
  <button
445
715
  type="button"
446
716
  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"
447
- title="Open chat"
448
717
  aria-label="Open chat"
449
718
  onclick={(e) => handleOpenChat(e, overlay.widgetId)}
450
719
  onpointerdown={(e) => e.stopPropagation()}
@@ -463,32 +732,27 @@
463
732
  </svg>
464
733
  <span class="sr-only">Chat</span>
465
734
  </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>
478
- </button>
735
+ </HoverTooltip>
479
736
  </div>
480
737
  </div>
481
738
 
739
+ </div>
740
+
482
741
  <div
483
- class="widget-shape-live-preview-frame pointer-events-none absolute left-0 overflow-visible
484
- {isSelected ? 'ring-2 ring-blue-400/80 rounded-sm' : ''}"
485
- style="top: {DRAG_BAR_H + BAR_SHAPE_GAP}px; height: {overlay.height}px; width: {overlay.width}px;"
742
+ class="widget-shape-live-preview-frame pointer-events-none fixed left-0 top-0 origin-top-left overflow-visible"
743
+ data-widget-id={overlay.widgetId}
744
+ style="height: {bodyHeight}px; width: {bodyWidth}px; transform: matrix({bodyMatrix.join(',')}); z-index: {isSelected ? 60 : 1};"
486
745
  >
487
- <!-- Inset so Fabric corner/rotate handles stay clickable around the edges -->
746
+ <!-- A new reload gets its own window so old load/screenshot replies cannot win. -->
747
+ {#if overlay.src}
748
+ {#key buildIframeSrc(overlay.src, overlay.widgetId)}
749
+ <!-- Inset so Fabric corner/rotate handles stay clickable around the edges.
750
+ First click inside the widget selects the shape (ring + chrome) via
751
+ focus, while the iframe keeps receiving the pointer events themselves. -->
488
752
  <iframe
489
753
  class="widget-shape-live-preview-iframe pointer-events-auto absolute border-0 bg-white"
490
- class:pointer-events-none={blockIframePointer}
491
- class:opacity-0={!iframeLoadedByWidgetId[overlay.widgetId]}
754
+ class:pointer-events-none={blockIframePointer || selectedPreviewBlocked}
755
+ class:opacity-0={selectedPreviewBlocked}
492
756
  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);"
493
757
  title="Live widget preview"
494
758
  src={buildIframeSrc(overlay.src, overlay.widgetId)}
@@ -496,14 +760,28 @@
496
760
  tabindex="-1"
497
761
  loading="eager"
498
762
  sandbox="allow-scripts allow-same-origin allow-forms"
499
- onload={() => markIframeLoaded(overlay.widgetId)}
763
+ use:trackIframeSelection={overlay.widgetId}
764
+ onload={(event) => markIframeLoaded(overlay.widgetId, event.currentTarget)}
500
765
  ></iframe>
501
- {#if !iframeLoadedByWidgetId[overlay.widgetId]}
766
+ {/key}
767
+ {/if}
768
+ {#if previewState?.error && !previewState.loading}
769
+ <div class="widget-shape-preview-error pointer-events-auto absolute inset-0 flex flex-col items-center justify-center gap-2 overflow-auto bg-white p-4 text-center text-xs text-red-600 [scrollbar-color:rgba(0,0,0,0.2)_transparent]" role="alert">
770
+ <span>{previewState.error}</span>
771
+ {#if onRetryPreview}
772
+ <button type="button" class="widget-shape-preview-retry pointer-events-auto cursor-pointer rounded border border-gray-300 bg-white px-3 py-1.5 text-gray-700 hover:bg-gray-100 focus:outline-none focus-visible:outline-none focus-visible:bg-gray-100"
773
+ onpointerdown={(event) => event.stopPropagation()}
774
+ onclick={(event) => { event.stopPropagation(); onRetryPreview?.(overlay.widgetId); }}>Retry</button>
775
+ {/if}
776
+ </div>
777
+ {:else if selectedPreviewBlocked}
502
778
  <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"
779
+ class="widget-shape-preview-loader pointer-events-none absolute inset-0 z-[1] flex flex-col items-center justify-center gap-2 rounded-sm bg-white"
780
+ role="status"
781
+ aria-live="polite"
505
782
  >
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>
783
+ <div class="widget-shape-preview-spinner h-6 w-6 animate-spin rounded-full border-2 border-gray-300 border-t-emerald-600" aria-hidden="true"></div>
784
+ <span class="widget-shape-preview-loading-label text-xs text-gray-600">{previewState?.loading ? 'Building...' : 'Loading...'}</span>
507
785
  </div>
508
786
  {/if}
509
787
  {#if blockIframePointer}
@@ -517,15 +795,10 @@
517
795
  </div>
518
796
  {/each}
519
797
  </div>
798
+ </Portal>
520
799
  {/if}
521
800
 
522
801
  <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
802
  .widget-shape-version-menu::-webkit-scrollbar {
530
803
  width: 8px;
531
804
  height: 8px;