vibespot 1.7.7 → 1.8.0

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.
Files changed (42) hide show
  1. package/README.md +11 -0
  2. package/assets/whats-new.json +27 -0
  3. package/dist/index.js +482 -648
  4. package/dist/index.js.map +1 -1
  5. package/package.json +7 -4
  6. package/ui/chat.js +21 -8
  7. package/ui/dashboard.js +53 -12
  8. package/ui/docs/index.html +10 -1
  9. package/ui/email-preview.js +1 -5
  10. package/ui/escape-html.js +14 -0
  11. package/ui/field-editor.js +6 -12
  12. package/ui/field-save.js +82 -0
  13. package/ui/index.html +6 -3
  14. package/ui/inline-edit.js +116 -570
  15. package/ui/plan.js +19 -13
  16. package/ui/preview-agent.js +1050 -0
  17. package/ui/preview.js +248 -265
  18. package/ui/section-controls.js +16 -622
  19. package/ui/setup.js +73 -20
  20. package/ui/styles.css +177 -0
  21. package/ui/upload-panel.js +7 -8
  22. package/ui/whats-new.js +249 -0
  23. package/assets/readme/00-hero-banner.png +0 -0
  24. package/assets/readme/00-hero-banner.svg +0 -59
  25. package/assets/readme/01-vibe-coding-hero.png +0 -0
  26. package/assets/readme/02-checkpoints.png +0 -0
  27. package/assets/readme/02-plan-mode.png +0 -0
  28. package/assets/readme/03-figma-import.png +0 -0
  29. package/assets/readme/04-multi-page-sites.png +0 -0
  30. package/assets/readme/05-inline-wysiwyg.png +0 -0
  31. package/assets/readme/06-hubspot-upload.png +0 -0
  32. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  33. package/ui/docs/screenshots/checkpoint-card.png +0 -0
  34. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  35. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  36. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  37. package/ui/docs/screenshots/module-overview-slideout.png +0 -0
  38. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  39. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  40. package/ui/docs/screenshots/split-pane-view.png +0 -0
  41. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  42. package/ui/docs/screenshots/workspace-tabs.png +0 -0
package/ui/preview.js CHANGED
@@ -1,63 +1,140 @@
1
1
  /**
2
2
  * Preview management — iframe loading and module scrolling.
3
+ *
4
+ * VIB-1892: the live preview loads from a SEPARATE ORIGIN (see
5
+ * src/server/preview-origin.ts), so AI-generated page code is cross-origin to
6
+ * the app and can never touch this document or the app's /api routes. The
7
+ * parent never reaches into contentDocument; everything in-frame is done by
8
+ * the trusted preview agent over the narrow vs:* postMessage protocol
9
+ * (src/server/preview-protocol.ts). This file owns the parent end of that
10
+ * channel; inline-edit.js / section-controls.js register handlers on it.
3
11
  */
4
12
 
5
13
  const previewFrame = document.getElementById("preview-frame");
6
14
  const previewEmptyState = document.getElementById("preview-empty-state");
7
15
 
8
- // Highlights to apply once the iframe finishes loading after the next refresh.
16
+ // ---------------------------------------------------------------------------
17
+ // Preview-origin channel (parent end)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ const VS_PROTOCOL_V = 1;
21
+ /** Preview -> parent verbs the parent will dispatch. Locked to
22
+ * src/server/preview-protocol.ts by test/preview-protocol-parity.test.ts. */
23
+ const VS_PREVIEW_TO_PARENT = new Set([
24
+ "vs:ready",
25
+ "vs:empty-state",
26
+ "vs:select-module",
27
+ "vs:edit-commit",
28
+ "vs:field-commit",
29
+ "vs:request-mode",
30
+ ]);
31
+
32
+ /** { origin, token } once /api/preview-origin resolves; null while loading. */
33
+ let previewOriginInfo = null;
34
+ let previewOriginPromise = null;
35
+ /** True between the agent's vs:ready and the next navigation. */
36
+ let previewAgentReady = false;
37
+ /** Bumped per navigation to force a fresh document load. */
38
+ let previewLoadSeq = 0;
39
+ /** Current interaction mode, re-sent to the agent on every (re)load. */
40
+ let currentPreviewMode = "view";
41
+
42
+ const previewMessageHandlers = new Map();
43
+
44
+ /** Register a parent-side handler for a preview->parent verb. */
45
+ function onPreviewMessage(type, handler) {
46
+ previewMessageHandlers.set(type, handler);
47
+ }
48
+
49
+ /** Send a command to the in-frame agent (drops silently until ready). */
50
+ function sendPreviewCommand(type, payload) {
51
+ if (!previewOriginInfo || !previewAgentReady || !previewFrame.contentWindow) return;
52
+ previewFrame.contentWindow.postMessage(
53
+ { v: VS_PROTOCOL_V, token: previewOriginInfo.token, type, payload },
54
+ previewOriginInfo.origin
55
+ );
56
+ }
57
+
58
+ /** Track + broadcast the interaction mode (called by inline-edit.js). */
59
+ function setPreviewMode(mode) {
60
+ currentPreviewMode = mode === "interact" || mode === "section" ? mode : "view";
61
+ sendPreviewCommand("vs:set-mode", { mode: currentPreviewMode });
62
+ }
63
+
64
+ function fetchPreviewOriginInfo() {
65
+ if (previewOriginPromise) return previewOriginPromise;
66
+ previewOriginPromise = fetch("/api/preview-origin")
67
+ .then((res) => res.json())
68
+ .then((data) => {
69
+ if (data && typeof data.origin === "string" && typeof data.token === "string") {
70
+ previewOriginInfo = { origin: data.origin.replace(/\/$/, ""), token: data.token };
71
+ } else {
72
+ console.error("Preview origin unavailable — live preview disabled.");
73
+ }
74
+ return previewOriginInfo;
75
+ })
76
+ .catch((err) => {
77
+ console.error("Failed to resolve preview origin:", err);
78
+ previewOriginPromise = null; // allow a retry on the next refresh
79
+ return null;
80
+ });
81
+ return previewOriginPromise;
82
+ }
83
+
84
+ window.addEventListener("message", (event) => {
85
+ // Gate 1: the message must come from the preview origin AND from our frame.
86
+ if (!previewOriginInfo) return;
87
+ if (event.origin !== previewOriginInfo.origin) return;
88
+ if (event.source !== previewFrame.contentWindow) return;
89
+ // Gate 2: envelope — version, token, direction-scoped verb allow-list.
90
+ const env = event.data;
91
+ if (!env || typeof env !== "object") return;
92
+ if (env.v !== VS_PROTOCOL_V) return;
93
+ if (typeof env.token !== "string" || env.token !== previewOriginInfo.token) return;
94
+ if (!VS_PREVIEW_TO_PARENT.has(env.type)) return;
95
+
96
+ if (env.type === "vs:ready") {
97
+ previewAgentReady = true;
98
+ sendPreviewCommand("vs:init", { mode: currentPreviewMode });
99
+ flushPendingHighlights();
100
+ return;
101
+ }
102
+ const handler = previewMessageHandlers.get(env.type);
103
+ if (handler) handler(env.payload || {});
104
+ });
105
+
106
+ // ---------------------------------------------------------------------------
107
+ // Empty state + change highlighting
108
+ // ---------------------------------------------------------------------------
109
+
110
+ // Highlights to apply once the agent reports ready after the next refresh.
9
111
  let pendingChangedModules = null;
10
112
  let pendingNewModules = null;
11
113
 
12
114
  /**
13
- * Show or hide the preview empty state. Called when generation starts or when
14
- * the iframe finishes loading and we can detect whether any modules rendered.
115
+ * Show or hide the preview empty state. Driven by the agent's vs:empty-state
116
+ * report after each load (the parent cannot inspect the cross-origin doc).
15
117
  */
16
118
  function setPreviewEmptyState(show) {
17
119
  if (!previewEmptyState) return;
18
120
  previewEmptyState.setAttribute("aria-hidden", show ? "false" : "true");
19
121
  }
20
122
 
21
- /**
22
- * Inspect iframe contents post-load and toggle the empty state accordingly.
23
- * Empty state stays visible if the rendered preview has no module content.
24
- */
25
- function syncEmptyStateFromFrame() {
26
- if (!previewEmptyState) return;
27
- try {
28
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
29
- if (!doc || !doc.body) {
30
- setPreviewEmptyState(true);
31
- return;
32
- }
33
- const hasModules = doc.querySelector("[data-module]") !== null;
34
- setPreviewEmptyState(!hasModules);
35
- } catch {
36
- // cross-origin — assume content is present
37
- setPreviewEmptyState(false);
38
- }
39
- }
123
+ onPreviewMessage("vs:empty-state", (p) => {
124
+ setPreviewEmptyState(!p.hasModules);
125
+ });
40
126
 
41
- previewFrame.addEventListener("load", () => {
42
- syncEmptyStateFromFrame();
127
+ function flushPendingHighlights() {
43
128
  if (!pendingChangedModules && !pendingNewModules) return;
44
129
  const changed = pendingChangedModules;
45
130
  const fresh = pendingNewModules;
46
131
  pendingChangedModules = null;
47
132
  pendingNewModules = null;
48
- try {
49
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
50
- if (!doc || !doc.body) return;
51
- ensureChangeHighlightStyles(doc);
52
- if (fresh && fresh.length) animateNewModules(doc, fresh);
53
- if (changed && changed.length) highlightChangedModules(doc, changed, fresh || []);
54
- } catch {
55
- // cross-origin — skip
56
- }
57
- });
133
+ sendPreviewCommand("vs:highlight-changed", { changed: changed || [], fresh: fresh || [] });
134
+ }
58
135
 
59
136
  /**
60
- * Refresh the preview iframe by reloading from /preview endpoint.
137
+ * Refresh the preview iframe by (re)navigating it to the preview origin.
61
138
  *
62
139
  * @param {Object} [opts]
63
140
  * @param {string[]} [opts.changedModules] Module names that were just regenerated.
@@ -68,121 +145,44 @@ function refreshPreview(opts) {
68
145
  pendingChangedModules = opts.changedModules || null;
69
146
  pendingNewModules = opts.newModules || null;
70
147
  }
71
- // Use srcdoc approach: fetch preview HTML and set as srcdoc
72
- // This avoids cache issues and allows the iframe to update smoothly
73
- fetch("/preview")
74
- .then((res) => res.text())
75
- .then((html) => {
76
- previewFrame.srcdoc = html;
77
- })
78
- .catch((err) => {
79
- console.error("Preview refresh failed:", err);
80
- });
81
- }
82
-
83
- // ---------------------------------------------------------------------------
84
- // Change highlighting — outline glow on regenerated modules and slide-in for
85
- // brand-new modules. Styles are injected into the sandboxed preview iframe.
86
- // ---------------------------------------------------------------------------
87
-
88
- function ensureChangeHighlightStyles(doc) {
89
- if (doc.getElementById("vibespot-change-highlight-css")) return;
90
- const style = doc.createElement("style");
91
- style.id = "vibespot-change-highlight-css";
92
- style.textContent = `
93
- @keyframes vibespot-change-glow {
94
- 0% { outline-color: rgba(232, 97, 58, 0.85); box-shadow: 0 0 0 6px rgba(232, 97, 58, 0.18); }
95
- 70% { outline-color: rgba(232, 97, 58, 0.55); box-shadow: 0 0 0 4px rgba(232, 97, 58, 0.10); }
96
- 100% { outline-color: rgba(232, 97, 58, 0); box-shadow: 0 0 0 0 rgba(232, 97, 58, 0); }
97
- }
98
- .vibespot-module--changed {
99
- outline: 2px solid rgba(232, 97, 58, 0.85);
100
- outline-offset: 4px;
101
- border-radius: 2px;
102
- animation: vibespot-change-glow 2s ease-out forwards;
103
- }
104
- @keyframes vibespot-module-slide-in {
105
- 0% { opacity: 0; transform: translateY(24px); }
106
- 100% { opacity: 1; transform: translateY(0); }
107
- }
108
- .vibespot-module--new {
109
- animation: vibespot-module-slide-in 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) both;
110
- }
111
- @media (prefers-reduced-motion: reduce) {
112
- .vibespot-module--changed,
113
- .vibespot-module--new { animation: none; }
114
- .vibespot-module--changed { outline-color: transparent; }
148
+ // The origin lookup is async only the newest requested navigation may
149
+ // touch the frame, or a slow resolve clobbers a later refresh or the
150
+ // generating screen (VIB-1898).
151
+ previewLoadSeq += 1;
152
+ const nav = previewLoadSeq;
153
+ fetchPreviewOriginInfo().then((info) => {
154
+ if (nav !== previewLoadSeq) return; // superseded while resolving
155
+ if (!info) {
156
+ // The preview origin never resolved (stale cached client hitting a
157
+ // moved route, the origin process not running, or the port blocked).
158
+ // Surface a self-diagnosing message instead of a silent blank frame.
159
+ showPreviewUnavailable();
160
+ return;
115
161
  }
116
- `;
117
- doc.head.appendChild(style);
118
- }
119
-
120
- function highlightChangedModules(doc, moduleNames, newModuleNames) {
121
- // Skip modules that are already getting the slide-in animation — the slide-in
122
- // is a stronger signal on its own.
123
- const newSet = new Set(newModuleNames);
124
- for (const name of moduleNames) {
125
- if (newSet.has(name)) continue;
126
- const el = doc.querySelector(`[data-module="${cssEscape(name)}"]`);
127
- if (!el) continue;
128
- el.classList.remove("vibespot-module--changed");
129
- // Force a reflow so the animation restarts if the class was just removed.
130
- void el.offsetWidth;
131
- el.classList.add("vibespot-module--changed");
132
- setTimeout(() => {
133
- el.classList.remove("vibespot-module--changed");
134
- }, 2200);
135
- }
136
- }
137
-
138
- function animateNewModules(doc, moduleNames) {
139
- for (const name of moduleNames) {
140
- const el = doc.querySelector(`[data-module="${cssEscape(name)}"]`);
141
- if (!el) continue;
142
- el.classList.add("vibespot-module--new");
143
- setTimeout(() => {
144
- el.classList.remove("vibespot-module--new");
145
- }, 700);
146
- }
147
- }
148
-
149
- function cssEscape(value) {
150
- if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
151
- return CSS.escape(value);
152
- }
153
- return String(value).replace(/["\\]/g, "\\$&");
162
+ previewAgentReady = false;
163
+ // The `r` param defeats bfcache/no-op navigations; `t` is the access token.
164
+ previewFrame.removeAttribute("srcdoc");
165
+ previewFrame.src = `${info.origin}/?t=${encodeURIComponent(info.token)}&r=${nav}`;
166
+ });
154
167
  }
155
168
 
156
169
  /**
157
170
  * Scroll the preview iframe to a specific module by name.
158
171
  */
159
172
  function scrollPreviewToModule(moduleName) {
160
- try {
161
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
162
- const el = doc.querySelector(`[data-module="${moduleName}"]`);
163
- if (el) {
164
- el.scrollIntoView({ behavior: "smooth", block: "start" });
165
-
166
- // Brief highlight effect
167
- el.style.outline = "2px solid #e8613a";
168
- el.style.outlineOffset = "4px";
169
- el.style.transition = "outline-color 0.5s ease";
170
- setTimeout(() => {
171
- el.style.outlineColor = "transparent";
172
- setTimeout(() => { el.style.outline = ""; el.style.outlineOffset = ""; }, 500);
173
- }, 1500);
174
- }
175
- } catch {
176
- // Cross-origin issues — fall back to simple reload
177
- }
173
+ sendPreviewCommand("vs:scroll-to", { module: moduleName });
178
174
  }
179
175
 
180
176
  /**
181
177
  * Show the generating preview — spinner + rotating fun messages.
182
- * Called when AI generation starts to entertain the user while waiting.
178
+ * Trusted static content (no AI code), so srcdoc is fine here.
183
179
  */
184
180
  function showGeneratingPreview() {
185
181
  setPreviewEmptyState(false);
182
+ previewAgentReady = false;
183
+ // Invalidate any refresh navigation still resolving its origin lookup so it
184
+ // can't replace this screen after we show it (VIB-1898).
185
+ previewLoadSeq += 1;
186
186
  const html = `<!DOCTYPE html>
187
187
  <html lang="en">
188
188
  <head>
@@ -259,161 +259,144 @@ function showGeneratingPreview() {
259
259
  <\/script>
260
260
  </body>
261
261
  </html>`;
262
+ previewFrame.removeAttribute("src");
262
263
  previewFrame.srcdoc = html;
263
264
  }
264
265
 
265
- // ---------------------------------------------------------------------------
266
- // "Working on it" overlay for modules being regenerated
267
- // ---------------------------------------------------------------------------
268
-
269
- const WORKING_MESSAGES = [
270
- "Rethinking this section\u2026",
271
- "Rewriting the code\u2026",
272
- "Giving this a fresh coat of paint\u2026",
273
- "Making it better\u2026",
274
- "Tweaking the layout\u2026",
275
- "Polishing the details\u2026",
276
- "Almost there\u2026",
277
- "Updating the design\u2026",
278
- "Improving the copy\u2026",
279
- "Refining the structure\u2026",
280
- ];
281
-
282
266
  /**
283
- * Inject the overlay CSS into the preview iframe (once).
267
+ * Show a visible in-frame fallback when the preview origin can't be resolved
268
+ * (`/api/preview-origin` returned `{origin:null}` or the fetch failed). Without
269
+ * this the frame just stays blank and the failure is invisible — the classic
270
+ * symptom of a stale cached `ui/preview.js` running against an upgraded server,
271
+ * or the preview origin process not being reachable (VIB-1937). Trusted static
272
+ * content (no AI code), so srcdoc is fine here.
284
273
  */
285
- function ensureOverlayStyles(doc) {
286
- if (doc.getElementById("vibespot-working-css")) return;
287
- const style = doc.createElement("style");
288
- style.id = "vibespot-working-css";
289
- style.textContent = `
290
- .vibespot-module--working { position: relative; }
291
- .vibespot-module--working > *:not(.vibespot-working-overlay) {
292
- filter: blur(3px) saturate(0.4);
293
- opacity: 0.5;
294
- transition: filter 0.4s ease, opacity 0.4s ease;
295
- pointer-events: none;
296
- }
297
- .vibespot-working-overlay {
298
- position: absolute; inset: 0;
299
- display: flex; flex-direction: column;
300
- align-items: center; justify-content: center;
301
- z-index: 9999;
302
- pointer-events: none;
303
- }
304
- .vibespot-working-overlay__spinner {
305
- width: 36px; height: 36px;
306
- border: 3px solid rgba(232,97,58,0.15);
307
- border-top-color: rgba(232,97,58,0.8);
308
- border-radius: 50%;
309
- animation: vw-spin 0.8s linear infinite;
310
- margin-bottom: 12px;
311
- }
312
- .vibespot-working-overlay__text {
313
- font-family: system-ui, -apple-system, sans-serif;
314
- font-size: 14px; font-weight: 500;
315
- color: rgba(255,255,255,0.7);
316
- text-align: center;
317
- max-width: 280px;
318
- transition: opacity 0.5s ease;
319
- }
320
- .vibespot-working-overlay__text.fade { opacity: 0; }
321
- @keyframes vw-spin { to { transform: rotate(360deg); } }
322
- `;
323
- doc.head.appendChild(style);
274
+ function showPreviewUnavailable() {
275
+ previewAgentReady = false;
276
+ // Hide the opaque empty-state overlay — it defaults to visible (aria-hidden
277
+ // false, solid background) and otherwise paints on top of the fallback card
278
+ // rendered into the iframe below, masking it (VIB-1938).
279
+ setPreviewEmptyState(false);
280
+ // Compute the expected preview port (app port + 2) so the hint is concrete.
281
+ const appPort = Number(
282
+ window.location.port || (window.location.protocol === "https:" ? 443 : 80)
283
+ );
284
+ const previewPort = Number.isFinite(appPort) ? appPort + 2 : null;
285
+ const portHint = previewPort
286
+ ? `the preview port (<code>${previewPort}</code>, i.e. app port + 2)`
287
+ : "the preview port (app port + 2)";
288
+ const html = `<!DOCTYPE html>
289
+ <html lang="en">
290
+ <head>
291
+ <meta charset="utf-8">
292
+ <meta name="viewport" content="width=device-width, initial-scale=1">
293
+ <style>
294
+ * { margin: 0; padding: 0; box-sizing: border-box; }
295
+ body {
296
+ min-height: 100vh;
297
+ display: flex;
298
+ align-items: center;
299
+ justify-content: center;
300
+ font-family: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
301
+ background: #0c0a09;
302
+ color: rgba(255,255,255,0.62);
303
+ padding: 2rem;
304
+ }
305
+ .unavail {
306
+ text-align: center;
307
+ max-width: 460px;
308
+ }
309
+ .unavail .icon {
310
+ font-size: 2.2rem;
311
+ margin-bottom: 1rem;
312
+ opacity: 0.7;
313
+ }
314
+ .unavail h1 {
315
+ font-size: 1.15rem;
316
+ font-weight: 600;
317
+ color: rgba(255,255,255,0.9);
318
+ margin-bottom: 0.75rem;
319
+ }
320
+ .unavail p {
321
+ font-size: 0.95rem;
322
+ line-height: 1.6;
323
+ margin-bottom: 0.6rem;
324
+ }
325
+ .unavail ul {
326
+ text-align: left;
327
+ display: inline-block;
328
+ margin: 0.4rem auto 0;
329
+ padding-left: 1.1rem;
330
+ font-size: 0.9rem;
331
+ line-height: 1.7;
332
+ }
333
+ .unavail code {
334
+ font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
335
+ font-size: 0.85em;
336
+ background: rgba(255,255,255,0.08);
337
+ padding: 0.1em 0.4em;
338
+ border-radius: 4px;
339
+ color: rgba(232, 97, 58, 0.95);
340
+ }
341
+ .unavail kbd {
342
+ font-family: "SF Mono", ui-monospace, Menlo, Consolas, monospace;
343
+ font-size: 0.8em;
344
+ background: rgba(255,255,255,0.1);
345
+ border: 1px solid rgba(255,255,255,0.15);
346
+ border-bottom-width: 2px;
347
+ padding: 0.1em 0.4em;
348
+ border-radius: 4px;
349
+ white-space: nowrap;
350
+ }
351
+ </style>
352
+ </head>
353
+ <body>
354
+ <div class="unavail">
355
+ <div class="icon">&#9888;</div>
356
+ <h1>Live preview unavailable</h1>
357
+ <p>The editor couldn't reach the preview server, so the page can't render here yet.</p>
358
+ <p>Two things usually fix it:</p>
359
+ <ul>
360
+ <li>Hard-refresh this page (<kbd>Cmd/Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>R</kbd>) to load the latest editor after an upgrade.</li>
361
+ <li>Make sure ${portHint} is free and reachable.</li>
362
+ </ul>
363
+ </div>
364
+ </body>
365
+ </html>`;
366
+ previewFrame.removeAttribute("src");
367
+ previewFrame.srcdoc = html;
324
368
  }
325
369
 
326
- /** Active carousel intervals keyed by module name */
327
- const workingIntervals = new Map();
370
+ // ---------------------------------------------------------------------------
371
+ // "Working on it" overlays — forwarded to the in-frame agent
372
+ // ---------------------------------------------------------------------------
328
373
 
329
374
  /**
330
- * Mark modules as "being worked on" — adds blur overlay with rotating messages.
375
+ * Mark modules as "being worked on" — the agent adds the blur overlay with
376
+ * rotating messages in-frame.
331
377
  */
332
378
  function markModulesWorking(moduleNames) {
333
- try {
334
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
335
- if (!doc || !doc.body) return;
336
- ensureOverlayStyles(doc);
337
-
338
- for (const name of moduleNames) {
339
- const el = doc.querySelector(`[data-module="${name}"]`);
340
- if (!el || el.classList.contains("vibespot-module--working")) continue;
341
-
342
- el.classList.add("vibespot-module--working");
343
-
344
- const overlay = doc.createElement("div");
345
- overlay.className = "vibespot-working-overlay";
346
- overlay.innerHTML = `
347
- <div class="vibespot-working-overlay__spinner"></div>
348
- <div class="vibespot-working-overlay__text"></div>
349
- `;
350
- el.appendChild(overlay);
351
-
352
- // Scroll to first working module
353
- if (name === moduleNames[0]) {
354
- el.scrollIntoView({ behavior: "smooth", block: "center" });
355
- }
356
-
357
- // Start message carousel
358
- const textEl = overlay.querySelector(".vibespot-working-overlay__text");
359
- let idx = Math.floor(Math.random() * WORKING_MESSAGES.length);
360
- textEl.textContent = WORKING_MESSAGES[idx];
361
-
362
- const interval = setInterval(() => {
363
- textEl.classList.add("fade");
364
- setTimeout(() => {
365
- idx = (idx + 1) % WORKING_MESSAGES.length;
366
- textEl.textContent = WORKING_MESSAGES[idx];
367
- textEl.classList.remove("fade");
368
- }, 500);
369
- }, 3500);
370
- workingIntervals.set(name, interval);
371
- }
372
- } catch { /* cross-origin */ }
379
+ sendPreviewCommand("vs:mark-working", { modules: moduleNames });
373
380
  }
374
381
 
375
382
  /**
376
383
  * Clear working overlay from a specific module (when it completes).
377
384
  */
378
385
  function clearModuleWorking(moduleName) {
379
- const interval = workingIntervals.get(moduleName);
380
- if (interval) { clearInterval(interval); workingIntervals.delete(moduleName); }
381
-
382
- try {
383
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
384
- if (!doc) return;
385
- const el = doc.querySelector(`[data-module="${moduleName}"]`);
386
- if (!el) return;
387
- el.classList.remove("vibespot-module--working");
388
- const overlay = el.querySelector(".vibespot-working-overlay");
389
- if (overlay) overlay.remove();
390
- } catch { /* cross-origin */ }
386
+ sendPreviewCommand("vs:clear-working", { modules: [moduleName] });
391
387
  }
392
388
 
393
389
  /**
394
390
  * Clear all working overlays.
395
391
  */
396
392
  function clearAllModulesWorking() {
397
- for (const [name, interval] of workingIntervals) {
398
- clearInterval(interval);
399
- }
400
- workingIntervals.clear();
401
-
402
- try {
403
- const doc = previewFrame.contentDocument || previewFrame.contentWindow.document;
404
- if (!doc) return;
405
- doc.querySelectorAll(".vibespot-module--working").forEach((el) => {
406
- el.classList.remove("vibespot-module--working");
407
- const overlay = el.querySelector(".vibespot-working-overlay");
408
- if (overlay) overlay.remove();
409
- });
410
- } catch { /* cross-origin */ }
393
+ sendPreviewCommand("vs:clear-working", {});
411
394
  }
412
395
 
413
396
  // Preview refresh is triggered by setup.js after a session is created.
414
- // Do NOT auto-refresh here.
415
-
416
- // Select/edit mode has been unified into interact mode (inline-edit.js)
397
+ // Do NOT auto-refresh here — but resolve the preview origin early so the
398
+ // first refresh doesn't pay the round-trip.
399
+ fetchPreviewOriginInfo();
417
400
 
418
401
  // ---------------------------------------------------------------------------
419
402
  // HubL validity badge — aggregates per-module checks reported by chat.js into