@widgetic/creator 0.3.64 → 0.3.65

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.
@@ -70,6 +70,8 @@
70
70
  import { uploadChatFileToWidgeticApi } from './utils/widgeticChatUpload.js';
71
71
  import { persistWidgetThumbnail } from './utils/persistWidgetThumbnail.js';
72
72
  import { nativeScrollbarPadding } from './utils/nativeScrollbarPadding.js';
73
+ import { pinScrollToBottom } from './utils/pinScrollToBottom.js';
74
+ import { installFreezeWatchdog } from './utils/freezeWatchdog.js';
73
75
  import { streamOperationUpdates } from './utils/operationStream.js';
74
76
  import { generateEmbedCode, EMBED_FORMATS, EMBED_FORMAT_LABELS, type EmbedFormat } from './utils/embedCode.js';
75
77
 
@@ -518,8 +520,40 @@
518
520
  const canvasModule = await canvasModulePromise;
519
521
  WidgeticCanvas = canvasModule.Canvas;
520
522
  }
523
+ installLongTaskFreezeReporter();
524
+ installFreezeWatchdog();
521
525
  });
522
526
 
527
+ /**
528
+ * Freeze attribution: while a code generation is tracked, any main-thread
529
+ * block ≥ LONG_TASK_FREEZE_MS is reported with its most likely culprit (the
530
+ * last status line + open panels). This gives a concrete answer to "why does
531
+ * the site freeze during generation" instead of a silent stuck progress bar.
532
+ */
533
+ const LONG_TASK_FREEZE_MS = 250;
534
+ let freezeObserver: PerformanceObserver | null = null;
535
+ function installLongTaskFreezeReporter(): void {
536
+ if (typeof window === 'undefined' || !('PerformanceObserver' in window)) return;
537
+ if (freezeObserver) return;
538
+ try {
539
+ freezeObserver = new PerformanceObserver((list) => {
540
+ if (!Object.keys(generatingWidgetIds).some((id) => generatingWidgetIds[id])) return;
541
+ for (const entry of list.getEntries()) {
542
+ if (entry.duration < LONG_TASK_FREEZE_MS) continue;
543
+ console.warn(
544
+ `[FreezeReport] Main thread blocked ${Math.round(entry.duration)}ms during generation.`
545
+ + ` Status: "${generateCodeStatus ?? '—'}".`
546
+ + ` Open panels: ${openWidgetPanelIds.length}.`
547
+ + ` Start: ${Math.round(entry.startTime)}ms after nav.`,
548
+ );
549
+ }
550
+ });
551
+ freezeObserver.observe({ entryTypes: ['longtask'] });
552
+ } catch (err) {
553
+ console.warn('[FreezeReport] longtask observer unavailable', err);
554
+ }
555
+ }
556
+
523
557
  // Trigger WidgetDetails load as soon as a panel is opened or requested
524
558
  $: if (openWidgetPanelIds.length > 0 && !WidgetDetails) {
525
559
  void loadWidgetDetailsModule();
@@ -797,6 +831,9 @@
797
831
  let codeGenerationPrompt = DEFAULT_PROMPT;
798
832
  let isGeneratingCode = false;
799
833
  let generatingWidgetIds: Record<string, boolean> = {};
834
+ function isAnyWidgetGenerating(): boolean {
835
+ return isGeneratingCode || Object.keys(generatingWidgetIds).some((id) => generatingWidgetIds[id]);
836
+ }
800
837
  const widgetAbortControllers = new Map<string, AbortController>();
801
838
  let currentGenerationOperationId: string | null = null;
802
839
  let generateCodeStatus: string | null = null;
@@ -1578,6 +1615,9 @@
1578
1615
  iframe: HTMLIFrameElement,
1579
1616
  timeoutMs = 6000,
1580
1617
  ): Promise<{ data: string | null; error: string | null }> {
1618
+ if (isAnyWidgetGenerating()) {
1619
+ return Promise.resolve({ data: null, error: 'generation in progress' });
1620
+ }
1581
1621
  return new Promise((resolve) => {
1582
1622
  if (!iframe.contentWindow) {
1583
1623
  resolve({ data: null, error: 'no-content-window' });
@@ -1657,19 +1697,20 @@
1657
1697
 
1658
1698
  /** Explicit Refresh only: capture the loaded frame without opening a panel or publishing. */
1659
1699
  async function handleWidgetShapeRefresh(widgetId, iframe, isCurrentFrame) {
1700
+ if (isAnyWidgetGenerating()) return;
1660
1701
  const capturedCanvasId = getEffectiveCanvasId();
1661
1702
  const capturedCanvas = canvasRef;
1662
1703
  const capturedSource = shapePreviewSourceByWidgetId[widgetId] || 'live';
1663
1704
  // Frame/intent identity owns this capture, not focused-panel HEAD hydration
1664
1705
  // or another widget's global generation operation.
1665
1706
  const isCurrent = () => isCurrentFrame() && !!getWidgetById(widgetId)
1666
- && !isDraftConvertId(widgetId) && !generatingWidgetIds[widgetId]
1707
+ && !isDraftConvertId(widgetId) && !generatingWidgetIds[widgetId] && !isAnyWidgetGenerating()
1667
1708
  && capturedCanvasId === getEffectiveCanvasId() && capturedCanvas === canvasRef
1668
1709
  && capturedSource === (shapePreviewSourceByWidgetId[widgetId] || 'live')
1669
1710
  && resolveLiveWidgetShapeIframe(widgetId) === iframe;
1670
1711
  if (!isCurrent()) return;
1671
1712
  try {
1672
- const screenshot = await handleChatScreenshot(widgetId, iframe);
1713
+ const screenshot = await handleChatScreenshot(widgetId, iframe, true);
1673
1714
  if (!screenshot || !isCurrent()) return;
1674
1715
  // Persist before Fabric/autosave side effects can resync the live overlay.
1675
1716
  await persistWidgetListThumbnail(widgetId, screenshot, isCurrent);
@@ -1683,13 +1724,34 @@
1683
1724
  }
1684
1725
 
1685
1726
  /** Screenshot: capture the live WidgetShape iframe (CDN or Worker), not Fabric wireframe. */
1686
- async function handleChatScreenshot(widgetId?: string | null, requiredIframe: HTMLIFrameElement | null = null): Promise<string | null> {
1727
+ async function handleChatScreenshot(widgetId?: string | null, requiredIframe: HTMLIFrameElement | null = null, quiet = false): Promise<string | null> {
1687
1728
  const targetId = widgetId || focusedPanelWidgetId || selectedWidgetId;
1688
1729
  if (!targetId) {
1689
1730
  console.warn('[Chat→Screenshot] No widget id');
1690
1731
  return null;
1691
1732
  }
1733
+ // Never run background / refresh screenshot captures while code generation is running —
1734
+ // html2canvas inside any same-origin iframe freezes the browser main thread.
1735
+ if ((quiet || requiredIframe != null) && isAnyWidgetGenerating()) {
1736
+ console.log('[Chat→Screenshot] Skip capture — generation in progress');
1737
+ return null;
1738
+ }
1739
+ // Same single-flight as handleRefreshScreenshot: both paths run html2canvas
1740
+ // inside preview iframes that share the page main thread. Overlapping
1741
+ // captures during generation were a major freeze source.
1742
+ if (screenshotCaptureInFlight.has(targetId)) {
1743
+ console.log('[Chat→Screenshot] Capture already in flight — skipping', targetId.substring(0, 8));
1744
+ return null;
1745
+ }
1746
+ screenshotCaptureInFlight.add(targetId);
1747
+ try {
1748
+ return await performChatScreenshot(targetId, requiredIframe, quiet);
1749
+ } finally {
1750
+ screenshotCaptureInFlight.delete(targetId);
1751
+ }
1752
+ }
1692
1753
 
1754
+ async function performChatScreenshot(targetId: string, requiredIframe: HTMLIFrameElement | null, quiet = false): Promise<string | null> {
1693
1755
  const liveIframe = requiredIframe ?? resolveLiveWidgetShapeIframe(targetId);
1694
1756
  if (requiredIframe && (requiredIframe !== resolveLiveWidgetShapeIframe(targetId) || !requiredIframe.isConnected)) return null;
1695
1757
  const liveSrc = liveIframe
@@ -1712,6 +1774,8 @@
1712
1774
 
1713
1775
  // Explicit refresh must not persist a different version or a stale Fabric image.
1714
1776
  if (requiredIframe) return null;
1777
+ // Quiet thumbnail sync must NEVER construct a temporary hidden iframe and compile worker preview.
1778
+ if (quiet) return null;
1715
1779
 
1716
1780
  // 2) Hidden Worker /preview (same capture script; wait for load)
1717
1781
  const fromWorker = await captureViaHiddenWorkerPreview(targetId);
@@ -1737,7 +1801,6 @@
1737
1801
  });
1738
1802
  return null;
1739
1803
  }
1740
-
1741
1804
  function normalizeConversationRecords(json: any): any[] {
1742
1805
  const raw = json?.data ?? json?.conversations ?? json;
1743
1806
  const list = Array.isArray(raw)
@@ -7619,12 +7682,12 @@
7619
7682
  canvasShapePreviewSyncTimers.clear();
7620
7683
  }
7621
7684
  function scheduleCanvasShapePreviewSync(widgetId: string, delayMs = 1800): void {
7622
- if (!widgetId) return;
7685
+ if (!widgetId || isAnyWidgetGenerating()) return;
7623
7686
  const existing = canvasShapePreviewSyncTimers.get(widgetId);
7624
7687
  if (existing) clearTimeout(existing);
7625
7688
  const timer = setTimeout(() => {
7626
7689
  canvasShapePreviewSyncTimers.delete(widgetId);
7627
- if (generatingWidgetIds[widgetId]) {
7690
+ if (isAnyWidgetGenerating() || generatingWidgetIds[widgetId]) {
7628
7691
  // console.log('[CreatorApp] Skip shape preview sync — codegen in progress:', widgetId.substring(0, 8));
7629
7692
  return;
7630
7693
  }
@@ -7643,6 +7706,24 @@
7643
7706
  const quiet = !!options.quiet;
7644
7707
  // console.log('[handleRefreshScreenshot] Capturing screenshot via postMessage for widget:', widgetId, quiet ? '(quiet)' : '');
7645
7708
 
7709
+ // Never run quiet background screenshot captures while ANY code generation is running —
7710
+ // html2canvas execution inside any preview iframe blocks the single shared main thread.
7711
+ if (quiet && isAnyWidgetGenerating()) {
7712
+ console.log('[handleRefreshScreenshot] Skip quiet capture — codegen in progress', widgetId?.substring(0, 8));
7713
+ return;
7714
+ }
7715
+
7716
+ // Single-flight per widget: overlapping captures stack html2canvas runs in
7717
+ // the preview iframe — same-origin iframes share this page's main thread,
7718
+ // and stacked captures blocked the whole site during generation (no
7719
+ // [FreezeReport] because the long task is attributed to the iframe).
7720
+ if (screenshotCaptureInFlight.has(widgetId)) {
7721
+ console.log('[handleRefreshScreenshot] Capture already in flight — skipping', widgetId?.substring(0, 8));
7722
+ return;
7723
+ }
7724
+ screenshotCaptureInFlight.add(widgetId);
7725
+
7726
+ try {
7646
7727
  if (isConvertOrDraftPanelOpen() && !isDraftConvertId(widgetId)) {
7647
7728
  // console.log('[handleRefreshScreenshot] Skip iframe capture — convert/draft panel is open:', widgetId.substring(0, 8));
7648
7729
  return;
@@ -7673,7 +7754,7 @@
7673
7754
  if (!shapeThumbnailCaptureInFlight.has(widgetId)) {
7674
7755
  shapeThumbnailCaptureInFlight.add(widgetId);
7675
7756
  try {
7676
- const freshCapture = await handleChatScreenshot(widgetId);
7757
+ const freshCapture = await handleChatScreenshot(widgetId, null, quiet);
7677
7758
  if (freshCapture) {
7678
7759
  canvasRef?.updateWidgetImage(widgetId, freshCapture);
7679
7760
  const freshJson = canvasRef?.saveCanvas?.();
@@ -7713,8 +7794,7 @@
7713
7794
  showToast('info', 'Capturing widget screenshot...', { duration: 3000 });
7714
7795
  }
7715
7796
 
7716
- try {
7717
- const result = await new Promise<{ data: string | null; error: string | null }>((resolve) => {
7797
+ const result = await new Promise<{ data: string | null; error: string | null }>((resolve) => {
7718
7798
  const timeout = setTimeout(() => {
7719
7799
  window.removeEventListener('message', handler);
7720
7800
  resolve({ data: null, error: 'timeout' });
@@ -7765,6 +7845,8 @@
7765
7845
  if (!quiet) {
7766
7846
  showToast('error', 'Screenshot capture failed', { duration: 3000 });
7767
7847
  }
7848
+ } finally {
7849
+ screenshotCaptureInFlight.delete(widgetId);
7768
7850
  }
7769
7851
  }
7770
7852
 
@@ -7971,6 +8053,7 @@ Requirements:
7971
8053
 
7972
8054
  const panelChat = getChatMethodsForWidget(generationWidgetId);
7973
8055
  const panelSnap = getPanelState(generationWidgetId);
8056
+ const hadCodeBeforeGeneration = !!(panelSnap?.hasGeneratedCode || panelSnap?.lastCommitId);
7974
8057
 
7975
8058
  // Per-widget abort: only abort if the SAME widget already has a running generation
7976
8059
  const existingController = widgetAbortControllers.get(generationWidgetId);
@@ -8351,18 +8434,25 @@ Requirements:
8351
8434
  // Refresh version history after every generation so the new commit shows
8352
8435
  // up in the version menu immediately. Publishing is deliberately not
8353
8436
  // automatic — the user previews and presses Publish themselves.
8437
+ // Deferred past the preview remount: running this synchronously against
8438
+ // `dynamic_worker_preview_ready` stacked three heavy re-renders on the
8439
+ // main thread in the same 800ms window — the "frozen at 80%" report.
8354
8440
  if (generationWidgetId) {
8355
8441
  const { [generationWidgetId]: _resolved, ...restResolved } = shapeHistoryResolvedByWidgetId;
8356
8442
  shapeHistoryResolvedByWidgetId = restResolved;
8357
- void loadPublishHistoryForPanel(generationWidgetId, { silent: true }).then(() => {
8358
- syncWidgetShapeLivePreviews();
8359
- if (isFocusedPanel) panelDisplayRevision += 1;
8360
- });
8443
+ setTimeout(() => {
8444
+ void loadPublishHistoryForPanel(generationWidgetId, { silent: true }).then(() => {
8445
+ syncWidgetShapeLivePreviews();
8446
+ if (isFocusedPanel) panelDisplayRevision += 1;
8447
+ });
8448
+ }, 1200);
8361
8449
  }
8362
8450
 
8363
8451
  // Always reconcile GitLab HEAD after codegen (metadata.commitId can be missing/stale).
8452
+ // Deferred — same window as the schema/history reloads above.
8364
8453
  if (generationWidgetId) {
8365
- void (async () => {
8454
+ setTimeout(() => {
8455
+ void (async () => {
8366
8456
  try {
8367
8457
  const fetchedHead = await fetchRepoHeadCommitSha(generationWidgetId);
8368
8458
  // Prefer the codegen commit from the operation. GitLab HEAD can still
@@ -8404,11 +8494,15 @@ Requirements:
8404
8494
  } catch (err) {
8405
8495
  console.warn('[Version] Failed to reconcile HEAD after codegen:', err);
8406
8496
  }
8407
- })();
8497
+ })();
8498
+ }, 1000);
8408
8499
  }
8409
8500
  if (generationWidgetId) {
8410
8501
  // console.log('[CompositionEditor] Reloading schemas after codegen', generationWidgetId.substring(0, 8));
8411
- void loadWidgetSchemas(generationWidgetId, { force: true });
8502
+ // Deferred with the publish-history reload — see comment above.
8503
+ setTimeout(() => {
8504
+ void loadWidgetSchemas(generationWidgetId, { force: true });
8505
+ }, 1600);
8412
8506
  }
8413
8507
 
8414
8508
  const completedRepoId = resolveRepositoryIdForWidget(generationWidgetId);
@@ -8506,6 +8600,9 @@ Requirements:
8506
8600
  await activePanelRef?.refreshPreview?.({ withRetry: true, skipIfAlreadyShowing: true });
8507
8601
  }
8508
8602
  activePanelRef?.setPreviewBuildError(null);
8603
+ // TODO(tomorrow): Dupa o generare noua, sincronizarea versiunii in dropdown si istoric se desincronizeaza.
8604
+ // Trebuie armonizata selectia automata a noii versiuni draft (vN) create in istoric,
8605
+ // actualizat trigger-ul pe canvas si in chat la noul numar de versiune si pastrata coerenta intre preview si dropdown.
8509
8606
  // Show Worker preview on the canvas shape (not the previous Live CDN artifact).
8510
8607
  shapePreviewSourceByWidgetId = {
8511
8608
  ...shapePreviewSourceByWidgetId,
@@ -8531,10 +8628,13 @@ Requirements:
8531
8628
  panelChat?.markWidgetHeadAtLatestCommit?.();
8532
8629
  }, 2500);
8533
8630
  setTimeout(() => {
8534
- void (async () => {
8535
- await handleRefreshScreenshot(generationWidgetId, { quiet: true });
8536
- // List thumb only from live iframe capture — never Fabric shape icon.
8537
- })();
8631
+ // Only capture screenshot after the very first generation or at publish
8632
+ // Subsequent generations skip this heavy operation to keep the main thread smooth.
8633
+ if (!hadCodeBeforeGeneration) {
8634
+ void (async () => {
8635
+ await handleRefreshScreenshot(generationWidgetId, { quiet: true });
8636
+ })();
8637
+ }
8538
8638
  }, 3000);
8539
8639
  // Do NOT auto-publish — user previews first, then clicks Publish / Set live.
8540
8640
  } else {
@@ -8944,6 +9044,7 @@ Requirements:
8944
9044
  widgetPublishStatus,
8945
9045
  codeGenerationStatus,
8946
9046
  agentHarnessOutput,
9047
+ dynamicWorkerPreviewStatus,
8947
9048
  setAuthToken as setWsAuthToken,
8948
9049
  setWebsocketApiUrl
8949
9050
  } from './stores/websocketStore.js';
@@ -9330,6 +9431,9 @@ Requirements:
9330
9431
 
9331
9432
  function pushCodegenProgressToUi(op: OperationStatus, widgetId: string | null | undefined) {
9332
9433
  const line = formatCodegenProgressStatus(op);
9434
+ // Feed the freeze watchdog the latest status so a [FreezeReport] names the
9435
+ // stage the UI was showing when the main thread stopped responding.
9436
+ (window as any).__widgeticFreezeWatchdog?.setStatus?.(line);
9333
9437
  // SSE progress frames can arrive many times per second while the LLM
9334
9438
  // streams. Each one re-renders every open panel — coalesce them.
9335
9439
  if (widgetId) {
@@ -9920,6 +10024,26 @@ Requirements:
9920
10024
 
9921
10025
  function getPanelEditPreviewUrl(widgetId: string): string | null {
9922
10026
  const ps = getPanelDisplayState(widgetId);
10027
+ const source = shapePreviewSourceByWidgetId[widgetId];
10028
+ if (source && source.startsWith('v:')) {
10029
+ const version = parseInt(source.slice(2), 10);
10030
+ if (Number.isFinite(version)) {
10031
+ const record = getShapePublishHistory(widgetId).find(
10032
+ (row) => Number(row.version) === version,
10033
+ );
10034
+ const isPublished = !!(record?.publishedAt ?? record?.published_at);
10035
+ const artifact =
10036
+ record?.artifactUrl ||
10037
+ record?.artifact_url ||
10038
+ (version === ps.lastPublishedVersion ? ps.selectedWidgetJsPath : null);
10039
+ if (isPublished && artifact) {
10040
+ const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
10041
+ if (url) return url;
10042
+ }
10043
+ const previewState = resolveWidgetShapeVersionPreviewState(widgetId);
10044
+ if (previewState?.url) return previewState.url;
10045
+ }
10046
+ }
9923
10047
  const widget = getWidgetById(widgetId);
9924
10048
  const version = ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(widget);
9925
10049
  if (version == null) return null;
@@ -10022,6 +10146,15 @@ Requirements:
10022
10146
  return fromPanel?.length ? fromPanel : [];
10023
10147
  }
10024
10148
 
10149
+ /** Newest unpublished version — Worker `/preview` (repo HEAD) shows this version's code. */
10150
+ function getNewestDraftVersionId(widgetId: string): number | null {
10151
+ const draftVersions = getShapePublishHistory(widgetId)
10152
+ .filter((row) => !(row.publishedAt ?? row.published_at))
10153
+ .map((row) => Number(row.version))
10154
+ .filter((v) => Number.isFinite(v));
10155
+ return draftVersions.length > 0 ? Math.max(...draftVersions) : null;
10156
+ }
10157
+
10025
10158
  function resolveWidgetShapeLivePreviewUrl(widgetId: string): string | null {
10026
10159
  if (!widgetId) return null;
10027
10160
  const source = shapePreviewSourceByWidgetId[widgetId] || 'live';
@@ -10049,6 +10182,11 @@ Requirements:
10049
10182
  const url = resolvePublishedWidgetUrl(widgetId, version, artifact);
10050
10183
  if (url) return url;
10051
10184
  }
10185
+ // The newest draft IS repo HEAD — Worker `/preview/{widgetId}` already serves this code!
10186
+ const newestDraft = getNewestDraftVersionId(widgetId);
10187
+ if (!isPublished && newestDraft != null && version === newestDraft && workerUrl) {
10188
+ return workerUrl;
10189
+ }
10052
10190
  return resolveWidgetShapeVersionPreviewState(widgetId)?.url ?? null;
10053
10191
  }
10054
10192
  return null;
@@ -10074,6 +10212,13 @@ Requirements:
10074
10212
  if (!Number.isFinite(version)) return null;
10075
10213
  return `v${version}`;
10076
10214
  }
10215
+ // 'preview' = Worker HEAD = newest generated (draft) version. Showing the
10216
+ // Live version here hid every fresh generation behind the old artifact.
10217
+ if (source === 'preview') {
10218
+ const newestDraft = getNewestDraftVersionId(widgetId);
10219
+ if (newestDraft != null) return `v${newestDraft}`;
10220
+ return null;
10221
+ }
10077
10222
  return liveVersion != null ? `v${liveVersion}` : null;
10078
10223
  }
10079
10224
 
@@ -10160,6 +10305,13 @@ Requirements:
10160
10305
  function resolveWidgetShapeVersionSelectedId(widgetId: string): string | null {
10161
10306
  const source = shapePreviewSourceByWidgetId[widgetId];
10162
10307
  if (source?.startsWith('v:')) return source;
10308
+ if (source === 'preview') {
10309
+ // Worker HEAD preview = the newest draft — keep the dropdown selection
10310
+ // on it, otherwise the picker kept pointing at the stale Live version.
10311
+ const newestDraft = getNewestDraftVersionId(widgetId);
10312
+ if (newestDraft != null) return `v:${newestDraft}`;
10313
+ return null;
10314
+ }
10163
10315
  const ps = getPanelDisplayState(widgetId);
10164
10316
  const liveVersion =
10165
10317
  ps.lastPublishedVersion ?? resolvePublishedVersionFromWidget(getWidgetById(widgetId));
@@ -10191,9 +10343,12 @@ Requirements:
10191
10343
  // Published artifacts need no build. Drafts must never substitute HEAD.
10192
10344
  if (versionId.startsWith('v:')) {
10193
10345
  const version = Number(versionId.slice(2));
10194
- if (resolveWidgetShapeLivePreviewUrl(widgetId)) {
10346
+ const directUrl = resolveWidgetShapeLivePreviewUrl(widgetId);
10347
+ if (directUrl) {
10195
10348
  const record = getShapePublishHistory(widgetId).find((row) => Number(row.version) === version);
10196
10349
  console.log('[VersionPreview] Selection', { widgetId, version, commitSha: record?.commitSha ?? record?.commit_sha ?? null, source: 'published' });
10350
+ const detailsRef = getWidgetDetailsRef(widgetId);
10351
+ detailsRef?.showPublishedArtifact?.(directUrl);
10197
10352
  } else {
10198
10353
  void ensureWidgetShapeVersionPreview(widgetId, version);
10199
10354
  }
@@ -10244,6 +10399,8 @@ Requirements:
10244
10399
  panelDisplayRevision += 1;
10245
10400
  shapeVersionPreviewErrorsByKey = { ...shapeVersionPreviewErrorsByKey, [`${widgetId}:${versionId}`]: null };
10246
10401
  console.log('[VersionPreview] Selection', { widgetId, version, commitSha });
10402
+ const detailsRef = getWidgetDetailsRef(widgetId);
10403
+ detailsRef?.showPublishedArtifact?.(previewUrl);
10247
10404
  } catch (error: unknown) {
10248
10405
  if (
10249
10406
  shapePreviewSourceByWidgetId[widgetId] !== versionId
@@ -11729,6 +11886,17 @@ $: if ($agentHarnessOutput?.timestamp) {
11729
11886
  }
11730
11887
  }
11731
11888
 
11889
+ // Worker preview ready via WebSocket event — reload canvas shape preview immediately
11890
+ $: if ($dynamicWorkerPreviewStatus.widgetId && $dynamicWorkerPreviewStatus.previewUrl) {
11891
+ const wsReadyWidgetId = $dynamicWorkerPreviewStatus.widgetId;
11892
+ shapePreviewSourceByWidgetId = {
11893
+ ...shapePreviewSourceByWidgetId,
11894
+ [wsReadyWidgetId]: 'preview',
11895
+ };
11896
+ syncWidgetShapeLivePreviews();
11897
+ widgetShapeLivePreviewsRef?.requestReload?.(wsReadyWidgetId);
11898
+ }
11899
+
11732
11900
  // ===================== Publish History =====================
11733
11901
 
11734
11902
  /**
@@ -11893,7 +12061,9 @@ function togglePublishHistory(triggerEl?: HTMLElement | null) {
11893
12061
  const cluster = triggerEl.closest('.wc-version-history-ct') as HTMLElement | null;
11894
12062
  const rect = (cluster ?? triggerEl).getBoundingClientRect();
11895
12063
  liveHistoryPanelTop = Math.round(rect.bottom + 4);
11896
- liveHistoryPanelRight = Math.round(window.innerWidth - rect.right);
12064
+ // Rows inset their action button 12px from the panel edge (panel p-1 4px + row px-2 8px).
12065
+ // Shift right by 12px so the per-row action buttons line up under the header action button.
12066
+ liveHistoryPanelRight = Math.round(window.innerWidth - rect.right) - 12;
11897
12067
  }
11898
12068
  loadPublishHistory();
11899
12069
  }
@@ -12385,6 +12555,14 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12385
12555
  */
12386
12556
  const shapeThumbnailCaptureInFlight = new Set<string>();
12387
12557
 
12558
+ /**
12559
+ * Whole-function single-flight guard for handleRefreshScreenshot.
12560
+ * Stacked html2canvas captures run inside the preview iframe, which shares
12561
+ * the page main thread — a capture storm froze the entire site with no
12562
+ * parent-page [FreezeReport] (long tasks attributed to the iframe).
12563
+ */
12564
+ const screenshotCaptureInFlight = new Set<string>();
12565
+
12388
12566
  /**
12389
12567
  * Called when a Widget Image shape is clicked on the canvas.
12390
12568
  * Selects the linked widget and opens WidgetDetails.
@@ -12463,7 +12641,6 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
12463
12641
  notifyHostWidgetName(widget);
12464
12642
  }
12465
12643
  await openWidgetDetailsPanel(widgetId);
12466
- scheduleCanvasShapePreviewSync(widgetId);
12467
12644
  }
12468
12645
 
12469
12646
  /**
@@ -14152,6 +14329,9 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14152
14329
  onTranslateEnd={(widgetId) => {
14153
14330
  canvasRef?.commitWidgetShapeTranslate?.(widgetId);
14154
14331
  }}
14332
+ onOverlayDragChange={(active) => {
14333
+ canvasRef?.setWidgetShapeOverlayDragActive?.(active);
14334
+ }}
14155
14335
  onSelectShape={(widgetId) => {
14156
14336
  canvasRef?.selectWidgetShapeById?.(widgetId);
14157
14337
  canvasSelectedWidgetShapeId = widgetId;
@@ -14161,6 +14341,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14161
14341
  void openWidgetDetailsPanel(widgetId);
14162
14342
  }}
14163
14343
  onRefresh={handleWidgetShapeRefresh}
14344
+ isGenerating={isAnyWidgetGenerating()}
14164
14345
  />
14165
14346
 
14166
14347
  <!-- ═══ PANEL 2: Widgets Panel — debug + showWidgetsList (site passes false) ═══ -->
@@ -14564,8 +14745,6 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14564
14745
  if (isPanelFocused && previewWidgetId === panelWidgetId) {
14565
14746
  chatMethods?.clearStalePreviewBuildFailures?.();
14566
14747
  }
14567
- // Keep canvas WidgetShape thumbnail aligned with the live Worker preview.
14568
- scheduleCanvasShapePreviewSync(previewWidgetId);
14569
14748
  if (detail?.step === 'create' || ps.widgetDetailsStep === 'create') {
14570
14749
  applyPanelCompositionToPreview(previewWidgetId);
14571
14750
  }
@@ -14800,10 +14979,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14800
14979
  {@const shapeSource = shapePreviewSourceByWidgetId[panelWidgetId] || defaultVersionSource}
14801
14980
  {@const viewingVersion = shapeSource && shapeSource.startsWith('v:')
14802
14981
  ? parseInt(shapeSource.slice(2), 10)
14803
- // A 'preview' source (Worker HEAD, unpublished) is not a published
14804
- // version — falling back to panelPublishedVersion produced `vnull`
14805
- // right after generation. The newest draft IS this HEAD's version.
14806
- : (newestDraft ? newestDraft.version : panelPublishedVersion)}
14982
+ : shapeSource === 'preview'
14983
+ // A 'preview' source (Worker HEAD, unpublished) is not a published
14984
+ // version — falling back to panelPublishedVersion produced `vnull`
14985
+ // right after generation AND showed the stale Live version when a
14986
+ // draft existed. The newest draft IS this HEAD's version.
14987
+ ? (newestDraft ? newestDraft.version : panelPublishedVersion)
14988
+ : panelPublishedVersion}
14807
14989
  {@const viewingIsLive = Number(viewingVersion) === Number(panelPublishedVersion)}
14808
14990
  <!--
14809
14991
  A version is a Draft until it is published. The history is one
@@ -14841,13 +15023,13 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14841
15023
  }}
14842
15024
  disabled={isPublishDisabledForPanel(panelWidgetId) || (isPublishing && publishingWidgetId === panelWidgetId)}
14843
15025
  title={getPublishDisabledReasonForPanel(panelWidgetId) ?? 'Publish the latest generated code as the Live version'}
14844
- class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15026
+ class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14845
15027
  >
14846
15028
  {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14847
15029
  </button>
14848
15030
  {:else if viewingIsLive}
14849
15031
  <span
14850
- class="wc-version-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-600 opacity-80"
15032
+ class="wc-version-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-600 opacity-80"
14851
15033
  title="Live — generate new code to Publish a new version"
14852
15034
  >Live</span>
14853
15035
  {:else if viewingIsDraft}
@@ -14863,7 +15045,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14863
15045
  }}
14864
15046
  disabled={!canPublishDraftVersionForPanel(panelWidgetId, viewingVersion) || getPublishGateForPanel(panelWidgetId, Number(viewingVersion)).disabled}
14865
15047
  title="Build this draft version and make it the Live version"
14866
- class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15048
+ class="wc-version-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-600 bg-emerald-600 text-white hover:bg-emerald-700 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14867
15049
  >
14868
15050
  {isPublishing && publishingWidgetId === panelWidgetId ? '...' : 'Publish'}
14869
15051
  </button>
@@ -14880,7 +15062,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14880
15062
  }}
14881
15063
  disabled={isSettingLiveVersion === Number(viewingVersion)}
14882
15064
  title="Publish this version so visitors see it on your website"
14883
- class="wc-version-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-300 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
15065
+ class="wc-version-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded border border-emerald-300 text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
14884
15066
  >
14885
15067
  {isSettingLiveVersion === Number(viewingVersion) ? '...' : 'Set live'}
14886
15068
  </button>
@@ -14893,15 +15075,20 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14893
15075
  aria-label="Close version history"
14894
15076
  onclick={() => { isHistoryOpen = false; }}
14895
15077
  ></button>
15078
+ {@const hasChatScroll = ps.publishHistory.length > 5}
14896
15079
  <div
14897
- class="wc-version-history-panel flex flex-col gap-1 rounded-md border border-emerald-200 bg-white shadow-lg p-1.5 z-[9999] w-max"
14898
- style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px; max-width: min(22rem, calc(100vw - 16px));"
15080
+ class="wc-version-history-panel rounded-md border border-emerald-200 bg-white shadow-lg p-1 z-[9999] w-max"
15081
+ style="position: fixed; top: {liveHistoryPanelTop}px; right: {liveHistoryPanelRight}px;"
14899
15082
  >
14900
15083
  {#if isLoadingHistory}
14901
15084
  <p class="text-xs text-emerald-500 animate-pulse text-center py-2">Loading history...</p>
14902
15085
  {:else if ps.publishHistory.length === 0}
14903
15086
  <p class="text-xs text-emerald-400 text-center py-2">No history records yet. Generate or publish to start tracking.</p>
14904
15087
  {:else}
15088
+ <div
15089
+ class="wc-version-history-scroll flex flex-col gap-1 overflow-x-hidden {hasChatScroll ? 'overflow-y-auto' : 'overflow-y-visible'}"
15090
+ style="{hasChatScroll ? 'max-height: 185px; margin-right: -4px; padding-right: 4px;' : 'max-height: none;'} scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
15091
+ >
14905
15092
  {#each ps.publishHistory as record (record.id)}
14906
15093
  {@const rowVersion = Number(record.version)}
14907
15094
  {@const rowIsDraft = !(record.publishedAt ?? record.published_at)}
@@ -14922,6 +15109,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14922
15109
  publishDisabledReason={publishGate.disabled ? publishGate.reason : null}
14923
15110
  setLiveDisabled={isSettingLiveVersion === record.version}
14924
15111
  onselect={() => {
15112
+ isHistoryOpen = false;
14925
15113
  handleWidgetShapeVersionChange(panelWidgetId, `v:${record.version}`);
14926
15114
  }}
14927
15115
  onpublish={() => {
@@ -14933,6 +15121,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
14933
15121
  }}
14934
15122
  />
14935
15123
  {/each}
15124
+ </div>
14936
15125
  {/if}
14937
15126
  </div>
14938
15127
  </Portal>
@@ -15021,6 +15210,7 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
15021
15210
  class="generation-scroll-ct flex flex-col h-full min-h-0 overflow-y-auto border-t border-gray-200"
15022
15211
  style="padding: 0;"
15023
15212
  use:nativeScrollbarPadding={0}
15213
+ use:pinScrollToBottom
15024
15214
  onmousedown={() => { if (!isPanelFocused) focusWidgetPanel(panelWidgetId); }}
15025
15215
  >
15026
15216
  {#key panelInstanceKey}
@@ -15821,21 +16011,24 @@ $: if (codeGenerationPrompt && selectedWidgetId && promptLoadedForWidgetId === s
15821
16011
  outline: none;
15822
16012
  box-shadow: none;
15823
16013
  }
15824
- .wc-version-history-panel {
16014
+ .wc-version-history-scroll {
15825
16015
  scrollbar-width: thin;
15826
16016
  scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
15827
16017
  }
15828
- .wc-version-history-panel::-webkit-scrollbar {
15829
- width: 8px;
16018
+ .wc-version-history-scroll::-webkit-scrollbar {
16019
+ width: 4px;
15830
16020
  background: transparent;
15831
16021
  }
15832
- .wc-version-history-panel::-webkit-scrollbar-track {
16022
+ .wc-version-history-scroll::-webkit-scrollbar-track {
15833
16023
  background: transparent;
15834
16024
  }
15835
- .wc-version-history-panel::-webkit-scrollbar-thumb {
16025
+ .wc-version-history-scroll::-webkit-scrollbar-thumb {
15836
16026
  background: rgba(0, 0, 0, 0.2);
15837
16027
  border-radius: 4px;
15838
16028
  }
16029
+ .wc-version-history-scroll::-webkit-scrollbar-thumb:hover {
16030
+ background: rgba(0, 0, 0, 0.35);
16031
+ }
15839
16032
  .generation-scroll-ct {
15840
16033
  scrollbar-width: thin;
15841
16034
  scrollbar-color: rgba(0, 0, 0, 0.22) transparent;
@@ -42,14 +42,16 @@
42
42
  // One fixed width per column — identical on every row and in both dropdowns.
43
43
  // Explicit PIXEL widths that hug the content (vN, 7-char SHA, dd/mm/yyyy
44
44
  // hh:mm) — no rem sizing, no floating side padding.
45
- const COL_VERSION = 'w-[16px] justify-end';
45
+ const COL_VERSION = 'w-[20px] justify-end';
46
46
  const COL_COMMIT = 'w-[44px] justify-end';
47
47
  const COL_DATE = 'w-[88px]';
48
- const COL_ACTION = 'w-[60px]';
48
+ // Same width as the header Publish/Live button (min-w-[4.75rem] / w-[4.75rem]) so every
49
+ // action button lines up in one column across rows, header and overlay.
50
+ const COL_ACTION = 'w-[4.75rem]';
49
51
  </script>
50
52
 
51
53
  <div
52
- class="version-dropdown-row flex items-center gap-2 rounded px-2 py-1.5 w-max text-[10px] cursor-pointer {selected ? 'bg-emerald-50 text-emerald-800' : 'text-gray-700 hover:bg-gray-50'}"
54
+ class="version-dropdown-row flex items-center justify-between gap-2 rounded px-2 py-1.5 w-full text-[10px] cursor-pointer {selected ? 'bg-emerald-50 text-emerald-800' : 'text-gray-700 hover:bg-gray-50'}"
53
55
  role="option"
54
56
  aria-selected={selected}
55
57
  tabindex="0"
@@ -49,6 +49,8 @@
49
49
  onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
50
50
  /** Fired once when a chrome drag ends (mark canvas dirty / history). */
51
51
  onTranslateEnd?: ((widgetId: string) => void) | null;
52
+ /** Tell the canvas a chrome drag started/ended (suppresses hover-select). */
53
+ onOverlayDragChange?: ((active: boolean) => void) | null;
52
54
  /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
53
55
  onSelectShape?: ((widgetId: string) => void) | null;
54
56
  /** Open Widget Details / chat for this widget. */
@@ -86,6 +88,8 @@
86
88
  resolveIsPublishing?: ((widgetId: string) => boolean) | null;
87
89
  /** Publish-disabled reason for a specific Draft row (null = enabled). */
88
90
  resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
91
+ /** Whether code generation is running (suppresses background captures / reloads). */
92
+ isGenerating?: boolean;
89
93
  }
90
94
 
91
95
  let {
@@ -103,6 +107,7 @@
103
107
  resolvePublishDisabled = null,
104
108
  onTranslate = null,
105
109
  onTranslateEnd = null,
110
+ onOverlayDragChange = null,
106
111
  onSelectShape = null,
107
112
  onOpenChat = null,
108
113
  resolveTitle = null,
@@ -121,6 +126,7 @@
121
126
  resolveIsPublishing = null,
122
127
  /** Per-widget publish-gate reason for a Draft row (tooltip on Publish). */
123
128
  resolvePublishDisabledReason = null,
129
+ isGenerating = false,
124
130
  }: Props = $props();
125
131
 
126
132
  const DRAG_BAR_H = 30;
@@ -130,7 +136,11 @@
130
136
  const MIN_EXPANDED_CHROME_WIDTH = 220;
131
137
  const IFRAME_INSET_PX = 2;
132
138
  /** ~5 version rows before the menu scrolls (matches chat history panel). */
133
- const VERSION_MENU_MAX_H = '12.5rem';
139
+ /** Row height ≈2.1rem (py-1.5 + text) + 0.6rem vertical padding for the menu box. */
140
+ const VERSION_MENU_ROW_REM = 2.1;
141
+ const VERSION_MENU_PAD_REM = 0.6;
142
+ /** Menu shows at most 5 rows before scrolling. */
143
+ const VERSION_MENU_MAX_ROWS = 5;
134
144
 
135
145
  type ShapeRect = {
136
146
  widgetId: string;
@@ -177,15 +187,27 @@
177
187
  * at the same URL (codegen rebuild). `sync()` only reloads when `src` changes,
178
188
  * so without a cache-bust token the overlay keeps showing pre-codegen content.
179
189
  * Bumps the reload token → `{#key}` remounts the iframe with a fresh `_t`.
190
+ * Debounced by 1200ms to avoid double-refresh flicker when WS and completed SSE both fire.
180
191
  */
192
+ let lastReloadAtByWidgetId: Record<string, number> = {};
181
193
  export function requestReload(widgetId: string): void {
182
- const token = Math.max(Date.now(), (reloadTokens[widgetId] || 0) + 1);
194
+ const now = Date.now();
195
+ if (now - (lastReloadAtByWidgetId[widgetId] || 0) < 1200) {
196
+ return;
197
+ }
198
+ lastReloadAtByWidgetId[widgetId] = now;
199
+ cancelPendingRefresh(widgetId);
200
+ const token = Math.max(now, (reloadTokens[widgetId] || 0) + 1);
201
+ pendingRefreshTokens.set(widgetId, token);
183
202
  reloadTokens = { ...reloadTokens, [widgetId]: token };
184
203
  iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: false };
204
+ sync();
185
205
  }
186
206
 
187
207
  /** Which shape chrome version menu is open. */
188
208
  let openVersionMenuWidgetId = $state<string | null>(null);
209
+ /** Local manual version selection override per widget (ensures trigger updates immediately). */
210
+ let localSelectedVersionByWidgetId = $state<Record<string, string>>({});
189
211
  /** Hide Fabric placeholder flash — show loader until iframe fires load. */
190
212
  let iframeLoadedByWidgetId = $state<Record<string, boolean>>({});
191
213
 
@@ -263,11 +285,13 @@
263
285
  document.body.style.removeProperty('user-select');
264
286
  document.body.style.removeProperty('cursor');
265
287
  onTranslateEnd?.(widgetId);
288
+ onOverlayDragChange?.(false);
266
289
  console.log('[WidgetShapeLivePreviews] Drag end', widgetId.substring(0, 8));
267
290
  }
268
291
 
269
292
  document.body.style.userSelect = 'none';
270
293
  document.body.style.cursor = 'grabbing';
294
+ onOverlayDragChange?.(true);
271
295
  document.addEventListener('pointermove', onMove);
272
296
  document.addEventListener('pointerup', endDrag);
273
297
  document.addEventListener('pointercancel', endDrag);
@@ -303,6 +327,11 @@
303
327
  // An old load event must not mark the replacement loaded or claim its timer.
304
328
  if (!matchesFrame()) return;
305
329
  iframeLoadedByWidgetId = { ...iframeLoadedByWidgetId, [widgetId]: true };
330
+ // Suppress auto-refresh / screenshot capture when code generation is running.
331
+ if (isGenerating) {
332
+ cancelPendingRefresh(widgetId);
333
+ return;
334
+ }
306
335
  const token = pendingRefreshTokens.get(widgetId);
307
336
  if (!token || refreshSettleTimers.has(widgetId)) return;
308
337
  const isCurrent = () => matchesFrame()
@@ -332,6 +361,8 @@
332
361
  }
333
362
 
334
363
  function resolveSelectedVersionId(widgetId: string, options: VersionOption[]): string {
364
+ const local = localSelectedVersionByWidgetId[widgetId];
365
+ if (local && options.some((o) => o.id === local)) return local;
335
366
  const fromParent = resolveVersionSelectedId?.(widgetId)?.trim();
336
367
  if (fromParent && options.some((o) => o.id === fromParent)) return fromParent;
337
368
  const liveOpt = options.find((o) => o.isLive);
@@ -404,6 +435,7 @@
404
435
  function selectVersionFromMenu(widgetId: string, versionId: string) {
405
436
  openVersionMenuWidgetId = null;
406
437
  if (!versionId) return;
438
+ localSelectedVersionByWidgetId = { ...localSelectedVersionByWidgetId, [widgetId]: versionId };
407
439
  onVersionChange?.(widgetId, versionId);
408
440
  }
409
441
 
@@ -438,8 +470,15 @@
438
470
  */
439
471
  function trackIframeSelection(iframe, widgetId) {
440
472
  let focusTimer;
473
+ let pointerIsDown = false;
474
+ const onWindowPointerDown = () => { pointerIsDown = true; };
475
+ const onWindowPointerUp = () => { pointerIsDown = false; };
441
476
  function select() {
442
477
  if (!enabled || draggingWidgetId || resizingWidgetId || !iframe.isConnected) return;
478
+ // A drag in progress anywhere (Fabric shape drag or overlay chrome drag)
479
+ // must not let hover over this iframe steal the selection — the pointer
480
+ // merely passes over other shapes while carrying the dragged one.
481
+ if (pointerIsDown) return;
443
482
  // Keep the menu owner selected while its dropdown is being used.
444
483
  if (openVersionMenuWidgetId && openVersionMenuWidgetId !== widgetId) return;
445
484
  if (selectedWidgetId !== widgetId) onSelectShape?.(widgetId);
@@ -450,6 +489,8 @@
450
489
  if (document.activeElement === iframe) select();
451
490
  }, 0);
452
491
  }
492
+ window.addEventListener('pointerdown', onWindowPointerDown, true);
493
+ window.addEventListener('pointerup', onWindowPointerUp, true);
453
494
  iframe.addEventListener('pointerenter', select);
454
495
  iframe.addEventListener('focus', select);
455
496
  window.addEventListener('blur', checkFocus);
@@ -457,6 +498,8 @@
457
498
  update(nextWidgetId) { widgetId = nextWidgetId; },
458
499
  destroy() {
459
500
  clearTimeout(focusTimer);
501
+ window.removeEventListener('pointerdown', onWindowPointerDown, true);
502
+ window.removeEventListener('pointerup', onWindowPointerUp, true);
460
503
  iframe.removeEventListener('pointerenter', select);
461
504
  iframe.removeEventListener('focus', select);
462
505
  window.removeEventListener('blur', checkFocus);
@@ -604,7 +647,7 @@
604
647
  >
605
648
  <button
606
649
  type="button"
607
- 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"
650
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
608
651
  disabled={isPublishing || selectedPreviewBlocked || !!resolvePublishDisabled?.(overlay.widgetId, selectedVersion ? Number(selectedVersion.id.slice(2)) : null)}
609
652
  onclick={(e) => {
610
653
  e.stopPropagation();
@@ -620,7 +663,7 @@
620
663
  class="inline-flex shrink-0"
621
664
  >
622
665
  <span
623
- 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"
666
+ class="widget-shape-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded bg-emerald-100 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-600 opacity-80"
624
667
  >Live</span>
625
668
  </HoverTooltip>
626
669
  {:else if selectedVersion && onSetLive && !isDraftVersion(selectedVersion)}
@@ -631,7 +674,7 @@
631
674
  >
632
675
  <button
633
676
  type="button"
634
- 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"
677
+ class="widget-shape-set-live-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-300 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-emerald-700 hover:bg-emerald-100 disabled:opacity-50 cursor-pointer"
635
678
  disabled={settingLiveVersion === Number(selectedVersion.id.slice(2))}
636
679
  onclick={(e) => handleSetLiveClick(e, overlay.widgetId, selectedVersion.id)}
637
680
  onpointerdown={(e) => e.stopPropagation()}
@@ -646,7 +689,7 @@
646
689
  >
647
690
  <button
648
691
  type="button"
649
- 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"
692
+ class="widget-shape-publish-btn shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded border border-emerald-600 bg-emerald-600 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-white hover:bg-emerald-700 disabled:opacity-50 cursor-pointer"
650
693
  disabled={isPublishing || selectedPreviewBlocked || !resolveCanPublishDraft?.(overlay.widgetId, Number(selectedVersion.id.slice(2))) || !!resolvePublishDisabled?.(overlay.widgetId, Number(selectedVersion.id.slice(2)))}
651
694
  onclick={(e) => handlePublishDraftClick(e, overlay.widgetId, selectedVersion.id)}
652
695
  onpointerdown={(e) => e.stopPropagation()}
@@ -659,39 +702,45 @@
659
702
  class="inline-flex shrink-0"
660
703
  >
661
704
  <span
662
- 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"
705
+ class="widget-shape-draft-badge shrink-0 inline-flex items-center justify-center min-w-[4.75rem] w-[4.75rem] rounded bg-amber-100 px-1 py-0.5 text-[10px] font-semibold uppercase tracking-wide text-amber-700"
663
706
  >Draft</span>
664
707
  </HoverTooltip>
665
708
  {/if}
666
709
  {/if}
667
710
  {#if versionMenuOpen}
711
+ {@const hasScroll = versionOptions.length > 5}
668
712
  <div
669
- 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"
670
- style="max-height: {VERSION_MENU_MAX_H}; scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
713
+ class="widget-shape-version-menu absolute top-full z-30 mt-0.5 w-max rounded-md border border-emerald-200 bg-white p-1 shadow-lg"
714
+ style="right: -12px;"
671
715
  role="listbox"
672
716
  >
673
- {#each versionOptions as option (option.id)}
674
- {@const optionVersion = Number(option.id.slice(2))}
675
- {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
676
- {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
677
- || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
678
- || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
679
- {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
680
- <VersionDropdownRow
681
- versionLabel={option.shortLabel || option.label}
682
- dateLabel={option.dateLabel}
683
- isLive={option.isLive === true}
684
- isDraft={isDraftVersion(option)}
685
- selected={selectedVersion?.id === option.id}
686
- busy={optionBusy}
687
- publishDisabled={optionPublishDisabled}
688
- publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
689
- setLiveDisabled={settingLiveVersion === optionVersion}
690
- onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
691
- onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
692
- onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
693
- />
694
- {/each}
717
+ <div
718
+ class="widget-shape-version-scroll flex flex-col gap-1 overflow-x-hidden {hasScroll ? 'overflow-y-auto' : 'overflow-y-visible'}"
719
+ style="{hasScroll ? 'max-height: 185px; margin-right: -4px; padding-right: 4px;' : 'max-height: none;'} scrollbar-width: thin; scrollbar-color: rgba(0,0,0,0.2) transparent;"
720
+ >
721
+ {#each versionOptions as option (option.id)}
722
+ {@const optionVersion = Number(option.id.slice(2))}
723
+ {@const optionBusy = (void titleRevision, !!resolveIsPublishing?.(overlay.widgetId))}
724
+ {@const optionPublishDisabled = !resolveCanPublishDraft?.(overlay.widgetId, optionVersion)
725
+ || !!resolvePublishDisabled?.(overlay.widgetId, optionVersion)
726
+ || (selectedVersion?.id === option.id && selectedPreviewBlocked)}
727
+ {@const optionPublishReason = (void titleRevision, resolvePublishDisabledReason?.(overlay.widgetId, optionVersion))}
728
+ <VersionDropdownRow
729
+ versionLabel={option.shortLabel || option.label}
730
+ dateLabel={option.dateLabel}
731
+ isLive={option.isLive === true}
732
+ isDraft={isDraftVersion(option)}
733
+ selected={selectedVersion?.id === option.id}
734
+ busy={optionBusy}
735
+ publishDisabled={optionPublishDisabled}
736
+ publishDisabledReason={optionPublishReason || 'Build this draft version and make it the Live version'}
737
+ setLiveDisabled={settingLiveVersion === optionVersion}
738
+ onselect={() => selectVersionFromMenu(overlay.widgetId, option.id)}
739
+ onpublish={() => handlePublishDraftClickFromMenu(overlay.widgetId, option.id)}
740
+ onsetlive={() => handleSetLiveClickFromMenu(overlay.widgetId, option.id)}
741
+ />
742
+ {/each}
743
+ </div>
695
744
  </div>
696
745
  {/if}
697
746
  </div>
@@ -812,16 +861,19 @@
812
861
  {/if}
813
862
 
814
863
  <style>
815
- .widget-shape-version-menu::-webkit-scrollbar {
816
- width: 8px;
817
- height: 8px;
864
+ .widget-shape-version-scroll::-webkit-scrollbar {
865
+ width: 4px;
866
+ height: 4px;
818
867
  background: transparent;
819
868
  }
820
- .widget-shape-version-menu::-webkit-scrollbar-track {
869
+ .widget-shape-version-scroll::-webkit-scrollbar-track {
821
870
  background: transparent;
822
871
  }
823
- .widget-shape-version-menu::-webkit-scrollbar-thumb {
872
+ .widget-shape-version-scroll::-webkit-scrollbar-thumb {
824
873
  background: rgba(0, 0, 0, 0.2);
825
874
  border-radius: 4px;
826
875
  }
876
+ .widget-shape-version-scroll::-webkit-scrollbar-thumb:hover {
877
+ background: rgba(0, 0, 0, 0.35);
878
+ }
827
879
  </style>
@@ -51,6 +51,8 @@ interface Props {
51
51
  onTranslate?: ((widgetId: string, dxScreen: number, dyScreen: number) => void) | null;
52
52
  /** Fired once when a chrome drag ends (mark canvas dirty / history). */
53
53
  onTranslateEnd?: ((widgetId: string) => void) | null;
54
+ /** Tell the canvas a chrome drag started/ended (suppresses hover-select). */
55
+ onOverlayDragChange?: ((active: boolean) => void) | null;
54
56
  /** Select the Fabric WidgetShape (e.g. when pressing the drag bar). */
55
57
  onSelectShape?: ((widgetId: string) => void) | null;
56
58
  /** Open Widget Details / chat for this widget. */
@@ -88,6 +90,8 @@ interface Props {
88
90
  resolveIsPublishing?: ((widgetId: string) => boolean) | null;
89
91
  /** Publish-disabled reason for a specific Draft row (null = enabled). */
90
92
  resolvePublishDisabledReason?: ((widgetId: string, version: number) => string | null) | null;
93
+ /** Whether code generation is running (suppresses background captures / reloads). */
94
+ isGenerating?: boolean;
91
95
  }
92
96
  declare const WidgetShapeLivePreviews: import("svelte").Component<Props, {
93
97
  requestReload: (widgetId: string) => void;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Main-thread responsiveness watchdog.
3
+ *
4
+ * PerformanceObserver('longtask') only fires AFTER a blocking task completes —
5
+ * an infinite reactive loop, or heavy work running inside a same-origin iframe
6
+ * (html2canvas screenshot captures in the widget preview share the parent's main
7
+ * thread), is never attributed to the page. The result was a frozen site with no
8
+ * [FreezeReport] in the console.
9
+ *
10
+ * This watchdog flips the model: a Web Worker (own thread, unaffected by the
11
+ * blocked main thread) pings every 500ms and the main thread answers. When pongs
12
+ * stop arriving, the worker logs a [FreezeReport] straight to the page console
13
+ * (worker console output shows in DevTools) with the block duration and the last
14
+ * codegen status the main thread reported. It also reports recovery, so each
15
+ * freeze episode produces exactly two lines: block start + recovery.
16
+ */
17
+ export interface FreezeWatchdog {
18
+ /** Main thread reports the current codegen status; included in freeze reports. */
19
+ setStatus(status: string): void;
20
+ dispose(): void;
21
+ }
22
+ export declare function installFreezeWatchdog(): FreezeWatchdog | null;
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Main-thread responsiveness watchdog.
3
+ *
4
+ * PerformanceObserver('longtask') only fires AFTER a blocking task completes —
5
+ * an infinite reactive loop, or heavy work running inside a same-origin iframe
6
+ * (html2canvas screenshot captures in the widget preview share the parent's main
7
+ * thread), is never attributed to the page. The result was a frozen site with no
8
+ * [FreezeReport] in the console.
9
+ *
10
+ * This watchdog flips the model: a Web Worker (own thread, unaffected by the
11
+ * blocked main thread) pings every 500ms and the main thread answers. When pongs
12
+ * stop arriving, the worker logs a [FreezeReport] straight to the page console
13
+ * (worker console output shows in DevTools) with the block duration and the last
14
+ * codegen status the main thread reported. It also reports recovery, so each
15
+ * freeze episode produces exactly two lines: block start + recovery.
16
+ */
17
+ const WATCHDOG_SOURCE = `
18
+ let lastPongAt = performance.now();
19
+ let freezeStartedAt = null;
20
+ let reported = false;
21
+ let lastStatus = '';
22
+ const PING_MS = 500;
23
+ const STALL_MS = 4000;
24
+ setInterval(() => {
25
+ postMessage({ type: 'ping' });
26
+ const now = performance.now();
27
+ if (now - lastPongAt >= STALL_MS) {
28
+ if (!freezeStartedAt) freezeStartedAt = now - STALL_MS;
29
+ if (!reported) {
30
+ reported = true;
31
+ console.warn('[FreezeReport] Main thread unresponsive ' + Math.round(now - freezeStartedAt) +
32
+ 'ms. Last status: "' + (lastStatus || '—') + '"');
33
+ }
34
+ } else if (freezeStartedAt) {
35
+ console.warn('[FreezeReport] Main thread recovered after ' + Math.round(now - freezeStartedAt) +
36
+ 'ms. Last status: "' + (lastStatus || '—') + '"');
37
+ freezeStartedAt = null;
38
+ reported = false;
39
+ }
40
+ }, PING_MS);
41
+ onmessage = (e) => {
42
+ if (e.data && e.data.type === 'pong') {
43
+ lastPongAt = performance.now();
44
+ if (e.data.status) lastStatus = e.data.status;
45
+ }
46
+ };
47
+ `;
48
+ export function installFreezeWatchdog() {
49
+ if (typeof window === 'undefined' || typeof Worker === 'undefined' || typeof Blob === 'undefined') {
50
+ return null;
51
+ }
52
+ const existing = window.__widgeticFreezeWatchdog;
53
+ if (existing)
54
+ return existing;
55
+ const blob = new Blob([WATCHDOG_SOURCE], { type: 'text/javascript' });
56
+ const url = URL.createObjectURL(blob);
57
+ const worker = new Worker(url);
58
+ URL.revokeObjectURL(url);
59
+ let lastStatus = '';
60
+ worker.onmessage = (event) => {
61
+ if (event.data?.type === 'ping') {
62
+ worker.postMessage({ type: 'pong', status: lastStatus });
63
+ }
64
+ };
65
+ const watchdog = {
66
+ setStatus(status) {
67
+ lastStatus = status;
68
+ },
69
+ dispose() {
70
+ worker.terminate();
71
+ delete window.__widgeticFreezeWatchdog;
72
+ },
73
+ };
74
+ window.__widgeticFreezeWatchdog = watchdog;
75
+ return watchdog;
76
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Keeps a scrollable container pinned to its bottom edge until the user scrolls
3
+ * up. Opening the widget-details panel on a small screen lays the panel out
4
+ * after mount, which can leave the outer scroll area (status bar + chat +
5
+ * footer) resting above its bottom — clipping the chat input. The observer
6
+ * re-pins to bottom whenever the content height or the box changes, so the
7
+ * input is always visible on open. Any manual upward scroll un-pins until the
8
+ * user returns to the bottom, preserving normal reading behavior.
9
+ */
10
+ export declare function pinScrollToBottom(node: HTMLElement): {
11
+ destroy(): void;
12
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Keeps a scrollable container pinned to its bottom edge until the user scrolls
3
+ * up. Opening the widget-details panel on a small screen lays the panel out
4
+ * after mount, which can leave the outer scroll area (status bar + chat +
5
+ * footer) resting above its bottom — clipping the chat input. The observer
6
+ * re-pins to bottom whenever the content height or the box changes, so the
7
+ * input is always visible on open. Any manual upward scroll un-pins until the
8
+ * user returns to the bottom, preserving normal reading behavior.
9
+ */
10
+ export function pinScrollToBottom(node) {
11
+ let pinned = true;
12
+ let rafId = null;
13
+ const scrollToBottom = () => {
14
+ if (!pinned)
15
+ return;
16
+ // rAF so the layout pass has settled before reading scrollHeight.
17
+ rafId = requestAnimationFrame(() => {
18
+ node.scrollTo({ top: node.scrollHeight, behavior: 'instant' });
19
+ });
20
+ };
21
+ const onScroll = () => {
22
+ const atBottom = node.scrollTop + node.clientHeight >= node.scrollHeight - 8;
23
+ pinned = atBottom;
24
+ };
25
+ const observer = new ResizeObserver(scrollToBottom);
26
+ observer.observe(node);
27
+ // Content growing also needs re-pinning; observe children changes.
28
+ const contentObserver = new MutationObserver(scrollToBottom);
29
+ contentObserver.observe(node, { childList: true, subtree: true, characterData: true });
30
+ node.addEventListener('scroll', onScroll, { passive: true });
31
+ // Initial pin after the first layout pass.
32
+ const initialRaf = requestAnimationFrame(scrollToBottom);
33
+ return {
34
+ destroy() {
35
+ if (rafId !== null)
36
+ cancelAnimationFrame(rafId);
37
+ cancelAnimationFrame(initialRaf);
38
+ observer.disconnect();
39
+ contentObserver.disconnect();
40
+ node.removeEventListener('scroll', onScroll);
41
+ },
42
+ };
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.64",
3
+ "version": "0.3.65",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",