@widgetic/creator 0.3.65 → 0.3.66

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.
@@ -37,6 +37,8 @@ declare const CreatorApp: $$__sveltets_2_IsomorphicComponent<{
37
37
  style?: string;
38
38
  }, {
39
39
  [evt: string]: CustomEvent<any>;
40
- }, {}, {}, string>;
40
+ }, {}, {
41
+ onBack: CreatorAppProps["onBack"];
42
+ }, string>;
41
43
  type CreatorApp = InstanceType<typeof CreatorApp>;
42
44
  export default CreatorApp;
@@ -69,7 +69,9 @@
69
69
  | { type: 'save' };
70
70
 
71
71
  export let userSession: { jwt?: string; user?: { id?: string } } | null = null;
72
- export let hasValidToken: boolean = false;
72
+ // Passed by CreatorApp but never read here — export const keeps the
73
+ // init-set contract without the unused-reactive warning.
74
+ export const hasValidToken: boolean = false;
73
75
  export let selectedWidgetId: string | null = null;
74
76
  export let selectedWidget: WidgetSummary | null = null;
75
77
  export let isGeneratingCode: boolean = false;
@@ -81,11 +83,13 @@
81
83
  /** 'pending' = repo being created (show loader); 'failed' = show Create Repository; 'ready' = normal. */
82
84
  export let repositorySetupStatus: 'pending' | 'ready' | 'failed' = 'ready';
83
85
  export let generateCodeStatus: string | null = null;
84
- export let isCheckingActiveGeneration: boolean = false;
86
+ export const isCheckingActiveGeneration: boolean = false;
85
87
  export let lastCommitId: string | null = null;
86
88
  export let debugMode: boolean = false;
87
89
  export let forceMockReady: boolean = false;
88
- export let debugModeAvailable: boolean = false;
90
+ // Passed by CreatorApp but never read here — export const keeps the
91
+ // init-set contract without the unused-reactive warning.
92
+ export const debugModeAvailable: boolean = false;
89
93
 
90
94
  // Code gen / save / publish state — passed through to parent's slot content.
91
95
  // svelte-ignore export_let_unused — these are consumed by <svelte:fragment> slots in the parent.
@@ -767,6 +771,7 @@
767
771
  getResizeLimits?: () => { maxWidth: number; maxHeight: number };
768
772
  fitToContainer?: () => void;
769
773
  resetPreviewPosition?: () => void;
774
+ nudgeIframePaint?: () => void;
770
775
  } | undefined;
771
776
  let previewIframe: HTMLIFrameElement | null = null;
772
777
  let isLoadingDirectPreviewUrl = false;
@@ -806,6 +811,14 @@
806
811
  /** Parent calls this when a generation starts for THIS widget's panel. */
807
812
  export function markCodegenStarted(): void {
808
813
  codegenStartedAt = Date.now();
814
+ // A pinned published artifact — a version picked from the history dropdown —
815
+ // makes the Worker build event a no-op, so the panel went on showing that
816
+ // older version after the generation finished. The code being generated is
817
+ // what the user just asked to see, so the pin does not survive a generation.
818
+ pinnedPublishedPreviewUrl = null;
819
+ // The build event is judged against the previous generation's signature
820
+ // otherwise, and a Worker cache hit can reproduce it exactly.
821
+ lastWsPreviewSignature = null;
809
822
  }
810
823
 
811
824
  /**
@@ -2046,7 +2059,8 @@
2046
2059
 
2047
2060
  function normalizeRepositoryId(source: WidgetSummary | null): string | null {
2048
2061
  if (!source) return null;
2049
- return source.repositoryId || source.repo_id || source.repoId || null;
2062
+ const candidate = source.repositoryId || source.repo_id || source.repoId;
2063
+ return typeof candidate === 'string' ? candidate : null;
2050
2064
  }
2051
2065
 
2052
2066
  // ═══════════════════════════════════════════════════════════════════════
@@ -3159,10 +3173,18 @@
3159
3173
 
3160
3174
  let lastWsPreviewSignature: string | null = null;
3161
3175
 
3162
- async function applyWorkerPreviewFromEvent(workerUrl: string, buildTimeMs?: number | null): Promise<void> {
3176
+ async function applyWorkerPreviewFromEvent(
3177
+ workerUrl: string,
3178
+ buildTimeMs?: number | null,
3179
+ eventTimestamp?: string | null,
3180
+ ): Promise<void> {
3163
3181
  if (pinnedPublishedPreviewUrl) return;
3164
3182
  const fetchUrl = toWorkerPreviewFetchUrl(workerUrl);
3165
- const signature = `${fetchUrl}:${buildTimeMs ?? 'na'}:${selectedWidgetId ?? ''}`;
3183
+ // The Worker serves every build of a widget at the same /preview URL, and
3184
+ // buildTimeMs repeats across builds (0 on a cache hit), so url + buildTimeMs
3185
+ // alone made a second generation look like a replay of the first and the
3186
+ // iframe kept the previous version. The per-event timestamp separates them.
3187
+ const signature = `${fetchUrl}:${buildTimeMs ?? 'na'}:${eventTimestamp ?? 'na'}:${selectedWidgetId ?? ''}`;
3166
3188
  if (signature === lastWsPreviewSignature) return;
3167
3189
  lastWsPreviewSignature = signature;
3168
3190
 
@@ -3189,6 +3211,7 @@
3189
3211
  void applyWorkerPreviewFromEvent(
3190
3212
  $dynamicWorkerPreviewStatus.previewUrl,
3191
3213
  $dynamicWorkerPreviewStatus.buildTimeMs,
3214
+ $dynamicWorkerPreviewStatus.timestamp,
3192
3215
  );
3193
3216
  }
3194
3217
 
@@ -3685,14 +3708,11 @@
3685
3708
  <Button onclick={() => { showPreviewModal = false; }} variant="outline" size="sm" class="close-preview-modal-btn shrink-0 w-6 h-6 p-[3px] cursor-pointer border border-black text-md bg-white text-black hover:bg-gray-100 rounded-lg">X</Button>
3686
3709
  </div>
3687
3710
  </div>
3688
- {/if}
3689
- <WidgetPreview
3690
- bind:this={widgetPreviewComponentRef}
3691
- mode="controlled"
3692
- enableResize={true}
3693
- resizeMode="concentric"
3694
- defaultStageAlign="center"
3695
- autoFitStage={showPreviewModal}
3711
+ {/if} <WidgetPreview
3712
+ bind:this={widgetPreviewComponentRef}
3713
+ mode="controlled"
3714
+ enableResize={true}
3715
+ autoFitStage={showPreviewModal}
3696
3716
  src={iframeSrc || getFallbackPreviewUrl()}
3697
3717
  displayUrl={directPreviewUrl || iframeSrc}
3698
3718
  bind:resizeWidth={previewResizeWidth}
@@ -201,6 +201,9 @@ declare const WidgetDetails: $$__sveltets_2_IsomorphicComponent<{
201
201
  'composition-editor': {};
202
202
  'embed-section': {};
203
203
  }, {
204
+ hasValidToken: boolean;
205
+ isCheckingActiveGeneration: boolean;
206
+ debugModeAvailable: boolean;
204
207
  beginCompositionHydration: () => void;
205
208
  showPublishedArtifact: (url: string | null) => void;
206
209
  finishCompositionHydration: () => void;
@@ -187,14 +187,38 @@
187
187
  * at the same URL (codegen rebuild). `sync()` only reloads when `src` changes,
188
188
  * so without a cache-bust token the overlay keeps showing pre-codegen content.
189
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.
190
+ *
191
+ * Reloads closer together than RELOAD_COALESCE_MS are collapsed into one, so
192
+ * the WS build event and the codegen `completed` handler do not flicker the
193
+ * overlay twice. The extra call is deferred rather than dropped: the later
194
+ * request is the authoritative one (codegen reloads after the build event, and
195
+ * a build-repair loop builds more than once), and dropping it left the overlay
196
+ * showing the version from before the generation.
191
197
  */
198
+ const RELOAD_COALESCE_MS = 1200;
192
199
  let lastReloadAtByWidgetId: Record<string, number> = {};
200
+ const coalescedReloadTimers = new Map<string, ReturnType<typeof setTimeout>>();
201
+
193
202
  export function requestReload(widgetId: string): void {
194
203
  const now = Date.now();
195
- if (now - (lastReloadAtByWidgetId[widgetId] || 0) < 1200) {
204
+ const sinceLast = now - (lastReloadAtByWidgetId[widgetId] || 0);
205
+ if (sinceLast < RELOAD_COALESCE_MS) {
206
+ if (!coalescedReloadTimers.has(widgetId)) {
207
+ coalescedReloadTimers.set(
208
+ widgetId,
209
+ setTimeout(() => {
210
+ coalescedReloadTimers.delete(widgetId);
211
+ requestReload(widgetId);
212
+ }, RELOAD_COALESCE_MS - sinceLast),
213
+ );
214
+ }
196
215
  return;
197
216
  }
217
+ const coalesced = coalescedReloadTimers.get(widgetId);
218
+ if (coalesced) {
219
+ clearTimeout(coalesced);
220
+ coalescedReloadTimers.delete(widgetId);
221
+ }
198
222
  lastReloadAtByWidgetId[widgetId] = now;
199
223
  cancelPendingRefresh(widgetId);
200
224
  const token = Math.max(now, (reloadTokens[widgetId] || 0) + 1);
@@ -541,6 +565,8 @@
541
565
  return () => {
542
566
  cancelAnimationFrame(initialSyncFrame);
543
567
  for (const widgetId of [...pendingRefreshTokens.keys()]) cancelPendingRefresh(widgetId);
568
+ for (const timer of coalescedReloadTimers.values()) clearTimeout(timer);
569
+ coalescedReloadTimers.clear();
544
570
  document.removeEventListener('pointerdown', handleDocumentPointerDown, true);
545
571
  document.removeEventListener('keydown', handleDocumentKeydown);
546
572
  window.removeEventListener('widgetic:widget-shape-pointer-block', onShapePointerBlock);
@@ -13,10 +13,26 @@
13
13
  * (worker console output shows in DevTools) with the block duration and the last
14
14
  * codegen status the main thread reported. It also reports recovery, so each
15
15
  * freeze episode produces exactly two lines: block start + recovery.
16
+ *
17
+ * The duration alone never said *what* blocked, which is why the freeze survived
18
+ * several rounds of fixes. So the main thread also keeps breadcrumbs — long tasks
19
+ * seen by PerformanceObserver (with their iframe attribution, since a widget
20
+ * preview blocking the shared main thread looks identical to the page blocking
21
+ * itself) and explicit `measureSync` spans — and ships the ones it has not
22
+ * reported yet on the next pong. The pong that lands on recovery therefore
23
+ * carries the task that caused the freeze, named and measured.
16
24
  */
17
25
  export interface FreezeWatchdog {
18
26
  /** Main thread reports the current codegen status; included in freeze reports. */
19
27
  setStatus(status: string): void;
28
+ /** Record a synchronous span so a freeze report can name it. */
29
+ addBreadcrumb(label: string, duration: number, source?: string): void;
20
30
  dispose(): void;
21
31
  }
32
+ /**
33
+ * Times a synchronous step and hands it to the watchdog when it blocks long
34
+ * enough to matter. Cheap enough to leave on any suspect path permanently: two
35
+ * `performance.now()` reads and, for ordinary fast calls, nothing else.
36
+ */
37
+ export declare function measureSync<T>(label: string, run: () => T): T;
22
38
  export declare function installFreezeWatchdog(): FreezeWatchdog | null;
@@ -13,14 +13,31 @@
13
13
  * (worker console output shows in DevTools) with the block duration and the last
14
14
  * codegen status the main thread reported. It also reports recovery, so each
15
15
  * freeze episode produces exactly two lines: block start + recovery.
16
+ *
17
+ * The duration alone never said *what* blocked, which is why the freeze survived
18
+ * several rounds of fixes. So the main thread also keeps breadcrumbs — long tasks
19
+ * seen by PerformanceObserver (with their iframe attribution, since a widget
20
+ * preview blocking the shared main thread looks identical to the page blocking
21
+ * itself) and explicit `measureSync` spans — and ships the ones it has not
22
+ * reported yet on the next pong. The pong that lands on recovery therefore
23
+ * carries the task that caused the freeze, named and measured.
16
24
  */
17
25
  const WATCHDOG_SOURCE = `
18
26
  let lastPongAt = performance.now();
19
27
  let freezeStartedAt = null;
20
28
  let reported = false;
21
29
  let lastStatus = '';
30
+ let breadcrumbs = [];
22
31
  const PING_MS = 500;
23
32
  const STALL_MS = 4000;
33
+
34
+ function describeBreadcrumbs() {
35
+ if (breadcrumbs.length === 0) return 'no instrumented task over 200ms — the block is somewhere not yet measured';
36
+ return breadcrumbs
37
+ .map((b) => b.label + ' ' + Math.round(b.duration) + 'ms' + (b.source ? ' [' + b.source + ']' : ''))
38
+ .join(', ');
39
+ }
40
+
24
41
  setInterval(() => {
25
42
  postMessage({ type: 'ping' });
26
43
  const now = performance.now();
@@ -33,18 +50,71 @@ setInterval(() => {
33
50
  }
34
51
  } else if (freezeStartedAt) {
35
52
  console.warn('[FreezeReport] Main thread recovered after ' + Math.round(now - freezeStartedAt) +
36
- 'ms. Last status: "' + (lastStatus || '—') + '"');
53
+ 'ms. Last status: "' + (lastStatus || '—') + '". Blocking work: ' + describeBreadcrumbs());
37
54
  freezeStartedAt = null;
38
55
  reported = false;
39
56
  }
57
+ breadcrumbs = [];
40
58
  }, PING_MS);
41
59
  onmessage = (e) => {
42
60
  if (e.data && e.data.type === 'pong') {
43
61
  lastPongAt = performance.now();
44
62
  if (e.data.status) lastStatus = e.data.status;
63
+ if (e.data.breadcrumbs && e.data.breadcrumbs.length) {
64
+ breadcrumbs = breadcrumbs.concat(e.data.breadcrumbs).slice(-12);
65
+ }
45
66
  }
46
67
  };
47
68
  `;
69
+ /** Long tasks and spans below this are ordinary work, not a freeze. */
70
+ const BREADCRUMB_MIN_MS = 200;
71
+ /**
72
+ * Times a synchronous step and hands it to the watchdog when it blocks long
73
+ * enough to matter. Cheap enough to leave on any suspect path permanently: two
74
+ * `performance.now()` reads and, for ordinary fast calls, nothing else.
75
+ */
76
+ export function measureSync(label, run) {
77
+ if (typeof performance === 'undefined')
78
+ return run();
79
+ const startedAt = performance.now();
80
+ try {
81
+ return run();
82
+ }
83
+ finally {
84
+ const duration = performance.now() - startedAt;
85
+ if (duration >= BREADCRUMB_MIN_MS) {
86
+ const watchdog = globalThis.__widgeticFreezeWatchdog;
87
+ watchdog?.addBreadcrumb(label, duration);
88
+ console.warn(`[MainThreadBlock] ${label} took ${Math.round(duration)}ms`);
89
+ }
90
+ }
91
+ }
92
+ /** Long tasks the page did not measure itself, including ones inside an iframe. */
93
+ function observeLongTasks(watchdog) {
94
+ if (typeof PerformanceObserver === 'undefined')
95
+ return null;
96
+ try {
97
+ const observer = new PerformanceObserver((list) => {
98
+ for (const entry of list.getEntries()) {
99
+ if (entry.duration < BREADCRUMB_MIN_MS)
100
+ continue;
101
+ // `containerSrc` is the one signal that separates the page blocking
102
+ // itself from a widget preview blocking the main thread it shares.
103
+ const attribution = entry.attribution?.[0];
104
+ const source = attribution?.containerSrc
105
+ || attribution?.containerName
106
+ || attribution?.containerType
107
+ || undefined;
108
+ watchdog.addBreadcrumb('longtask', entry.duration, source);
109
+ }
110
+ });
111
+ observer.observe({ entryTypes: ['longtask'] });
112
+ return () => observer.disconnect();
113
+ }
114
+ catch {
115
+ return null;
116
+ }
117
+ }
48
118
  export function installFreezeWatchdog() {
49
119
  if (typeof window === 'undefined' || typeof Worker === 'undefined' || typeof Blob === 'undefined') {
50
120
  return null;
@@ -57,20 +127,35 @@ export function installFreezeWatchdog() {
57
127
  const worker = new Worker(url);
58
128
  URL.revokeObjectURL(url);
59
129
  let lastStatus = '';
130
+ let pendingBreadcrumbs = [];
60
131
  worker.onmessage = (event) => {
61
132
  if (event.data?.type === 'ping') {
62
- worker.postMessage({ type: 'pong', status: lastStatus });
133
+ worker.postMessage({ type: 'pong', status: lastStatus, breadcrumbs: pendingBreadcrumbs });
134
+ pendingBreadcrumbs = [];
63
135
  }
64
136
  };
137
+ let stopLongTaskObserver = null;
65
138
  const watchdog = {
66
139
  setStatus(status) {
67
140
  lastStatus = status;
68
141
  },
142
+ addBreadcrumb(label, duration, source) {
143
+ if (duration < BREADCRUMB_MIN_MS)
144
+ return;
145
+ pendingBreadcrumbs.push({ label, duration, source });
146
+ // Keep only the worst offenders: a freeze is one big task, not fifty.
147
+ if (pendingBreadcrumbs.length > 12) {
148
+ pendingBreadcrumbs.sort((a, b) => b.duration - a.duration);
149
+ pendingBreadcrumbs = pendingBreadcrumbs.slice(0, 12);
150
+ }
151
+ },
69
152
  dispose() {
153
+ stopLongTaskObserver?.();
70
154
  worker.terminate();
71
155
  delete window.__widgeticFreezeWatchdog;
72
156
  },
73
157
  };
74
158
  window.__widgeticFreezeWatchdog = watchdog;
159
+ stopLongTaskObserver = observeLongTasks(watchdog);
75
160
  return watchdog;
76
161
  }
@@ -1,5 +1,16 @@
1
1
  import type { WidgetsApi } from '@widgetic/api-sdk';
2
2
  /**
3
- * Upload a convert/preview screenshot to widgets.thumbnail_url via the API gateway.
3
+ * Upload a convert/preview screenshot to widgets.thumbnail_url via the API
4
+ * gateway.
5
+ *
6
+ * This used to be a hand-rolled fetch: the gateway route
7
+ * (`POST /widgets/{id}/thumbnail`) postdated the last SDK generation, so the
8
+ * module read `basePath` and the bearer token back out of the `WidgetsApi`
9
+ * instance's `Configuration` and hit the endpoint itself, unwrapping the
10
+ * response against four guessed shapes. The spec now carries the operation
11
+ * with a typed 200 (`UploadWidgetThumbnail200Response.data.thumbnailUrl`), the
12
+ * SDK was regenerated, and the fetch is retired — the typed method targets the
13
+ * same host the client was constructed with, which is what the Configuration
14
+ * read-back was approximating.
4
15
  */
5
16
  export declare function persistWidgetThumbnail(widgetsClient: WidgetsApi | null | undefined, widgetId: string | null | undefined, imageDataUrl: string | null | undefined): Promise<string | null>;
@@ -1,5 +1,16 @@
1
1
  /**
2
- * Upload a convert/preview screenshot to widgets.thumbnail_url via the API gateway.
2
+ * Upload a convert/preview screenshot to widgets.thumbnail_url via the API
3
+ * gateway.
4
+ *
5
+ * This used to be a hand-rolled fetch: the gateway route
6
+ * (`POST /widgets/{id}/thumbnail`) postdated the last SDK generation, so the
7
+ * module read `basePath` and the bearer token back out of the `WidgetsApi`
8
+ * instance's `Configuration` and hit the endpoint itself, unwrapping the
9
+ * response against four guessed shapes. The spec now carries the operation
10
+ * with a typed 200 (`UploadWidgetThumbnail200Response.data.thumbnailUrl`), the
11
+ * SDK was regenerated, and the fetch is retired — the typed method targets the
12
+ * same host the client was constructed with, which is what the Configuration
13
+ * read-back was approximating.
3
14
  */
4
15
  export async function persistWidgetThumbnail(widgetsClient, widgetId, imageDataUrl) {
5
16
  if (!widgetsClient || !widgetId || typeof imageDataUrl !== 'string')
@@ -7,15 +18,11 @@ export async function persistWidgetThumbnail(widgetsClient, widgetId, imageDataU
7
18
  if (!imageDataUrl.startsWith('data:image/'))
8
19
  return null;
9
20
  try {
10
- const response = await widgetsClient.uploadWidgetThumbnailRaw({
21
+ const response = await widgetsClient.uploadWidgetThumbnail({
11
22
  id: widgetId,
12
23
  uploadWidgetThumbnailRequest: { imageDataUrl },
13
24
  });
14
- const json = await response.raw.json();
15
- const thumbnailUrl = json?.data?.thumbnailUrl ||
16
- json?.data?.thumbnail_url ||
17
- json?.thumbnailUrl ||
18
- null;
25
+ const thumbnailUrl = response.data?.thumbnailUrl ?? null;
19
26
  if (thumbnailUrl) {
20
27
  console.log('[persistWidgetThumbnail] stored', widgetId.slice(0, 8), thumbnailUrl.slice(0, 80));
21
28
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@widgetic/creator",
3
- "version": "0.3.65",
3
+ "version": "0.3.66",
4
4
  "type": "module",
5
5
  "peerDependencies": {
6
6
  "@sveltejs/kit": "^2.20.0",
@@ -45,6 +45,7 @@
45
45
  "dev-sv": "VITE_APP_VERSION=$(node -p \"require('./package.json').version\") VITE_BUILD_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo local) NODE_ENV=development vite dev --port 5174",
46
46
  "lps": "volta run npm link --save @widgetic/design-system && volta run npm link --save @widgetic/api-sdk && volta run npm link --save @widgetic/canvas",
47
47
  "llinks": "ls -la node_modules/@widgetic",
48
+ "ldt": "volta run npm link --save @widgetic/dev-tools",
48
49
  "clean-npm-cache": "npm cache clean --force",
49
50
  "clean-svelte-cache": "rm -rf .svelte-kit && npx svelte-kit sync",
50
51
  "clean-vite-cache": "rm -rf node_modules/.vite",
@@ -67,12 +68,13 @@
67
68
  "@sveltejs/vite-plugin-svelte": "^5.0.3",
68
69
  "@tailwindcss/postcss": "^4.1.4",
69
70
  "@tailwindcss/typography": "^0.5.15",
70
- "@types/eslint": "^9.6.0",
71
+ "@types/node": "26.4.0",
71
72
  "@widgetic/api-sdk": "^1.0.10",
72
73
  "@widgetic/cache-layer": "^0.1.2",
73
74
  "@widgetic/canvas": "^0.5.4",
74
75
  "@widgetic/chat": "^0.1.4",
75
76
  "@widgetic/design-system": "^0.5.8",
77
+ "@widgetic/dev-tools": "^0.2.0",
76
78
  "@widgetic/editor": "^4.0.1",
77
79
  "@widgetic/file-browser": "^0.5.17",
78
80
  "autoprefixer": "^10.4.20",