@widgetic/creator 0.3.64 → 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.
@@ -0,0 +1,161 @@
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
+ * 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.
24
+ */
25
+ const WATCHDOG_SOURCE = `
26
+ let lastPongAt = performance.now();
27
+ let freezeStartedAt = null;
28
+ let reported = false;
29
+ let lastStatus = '';
30
+ let breadcrumbs = [];
31
+ const PING_MS = 500;
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
+
41
+ setInterval(() => {
42
+ postMessage({ type: 'ping' });
43
+ const now = performance.now();
44
+ if (now - lastPongAt >= STALL_MS) {
45
+ if (!freezeStartedAt) freezeStartedAt = now - STALL_MS;
46
+ if (!reported) {
47
+ reported = true;
48
+ console.warn('[FreezeReport] Main thread unresponsive ' + Math.round(now - freezeStartedAt) +
49
+ 'ms. Last status: "' + (lastStatus || '—') + '"');
50
+ }
51
+ } else if (freezeStartedAt) {
52
+ console.warn('[FreezeReport] Main thread recovered after ' + Math.round(now - freezeStartedAt) +
53
+ 'ms. Last status: "' + (lastStatus || '—') + '". Blocking work: ' + describeBreadcrumbs());
54
+ freezeStartedAt = null;
55
+ reported = false;
56
+ }
57
+ breadcrumbs = [];
58
+ }, PING_MS);
59
+ onmessage = (e) => {
60
+ if (e.data && e.data.type === 'pong') {
61
+ lastPongAt = performance.now();
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
+ }
66
+ }
67
+ };
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
+ }
118
+ export function installFreezeWatchdog() {
119
+ if (typeof window === 'undefined' || typeof Worker === 'undefined' || typeof Blob === 'undefined') {
120
+ return null;
121
+ }
122
+ const existing = window.__widgeticFreezeWatchdog;
123
+ if (existing)
124
+ return existing;
125
+ const blob = new Blob([WATCHDOG_SOURCE], { type: 'text/javascript' });
126
+ const url = URL.createObjectURL(blob);
127
+ const worker = new Worker(url);
128
+ URL.revokeObjectURL(url);
129
+ let lastStatus = '';
130
+ let pendingBreadcrumbs = [];
131
+ worker.onmessage = (event) => {
132
+ if (event.data?.type === 'ping') {
133
+ worker.postMessage({ type: 'pong', status: lastStatus, breadcrumbs: pendingBreadcrumbs });
134
+ pendingBreadcrumbs = [];
135
+ }
136
+ };
137
+ let stopLongTaskObserver = null;
138
+ const watchdog = {
139
+ setStatus(status) {
140
+ lastStatus = status;
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
+ },
152
+ dispose() {
153
+ stopLongTaskObserver?.();
154
+ worker.terminate();
155
+ delete window.__widgeticFreezeWatchdog;
156
+ },
157
+ };
158
+ window.__widgeticFreezeWatchdog = watchdog;
159
+ stopLongTaskObserver = observeLongTasks(watchdog);
160
+ return watchdog;
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
  }
@@ -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.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",