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/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "vibespot",
3
- "version": "1.7.7",
4
- "description": "AI-powered HubSpot CMS landing page builder \u2014 vibe coding & React converter",
3
+ "version": "1.8.0",
4
+ "description": "AI-powered HubSpot CMS landing page builder vibe coding & React converter",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "vibespot": "bin/vibespot.mjs"
@@ -11,12 +11,15 @@
11
11
  "dist",
12
12
  "bin",
13
13
  "assets",
14
+ "!assets/readme/**",
14
15
  "ui",
16
+ "!ui/docs/screenshots/**",
15
17
  "starters"
16
18
  ],
17
19
  "scripts": {
18
20
  "dev": "tsx src/index.ts",
19
- "build": "tsup",
21
+ "build": "npm run whatsnew:gen && tsup",
22
+ "whatsnew:gen": "tsx scripts/gen-whats-new.ts",
20
23
  "typecheck": "tsc --noEmit",
21
24
  "test": "vitest run",
22
25
  "test:watch": "vitest",
@@ -27,7 +30,6 @@
27
30
  "prompts:seed": "tsx scripts/sync-prompts.ts --from-local",
28
31
  "prompts:push": "tsx scripts/sync-prompts.ts --push",
29
32
  "prepublishOnly": "npm run build",
30
- "contact-monitor": "tsx scripts/contact-monitor.ts",
31
33
  "docker:publish": "scripts/docker-publish.sh",
32
34
  "docker:publish:multi": "scripts/docker-publish.sh --multi-arch",
33
35
  "docker:smoke": "scripts/docker-publish.sh --smoke --dry-run"
@@ -55,6 +57,7 @@
55
57
  "@types/busboy": "^1.5.4",
56
58
  "@types/node": "^25.6.0",
57
59
  "@types/ws": "^8.18.1",
60
+ "jsdom": "^29.1.1",
58
61
  "tsup": "^8.4.0",
59
62
  "tsx": "^4.19.0",
60
63
  "typescript": "^6.0.3",
package/ui/chat.js CHANGED
@@ -12,6 +12,10 @@ let isStreaming = false;
12
12
  // which a new message barges in to cancel-and-replan (VIB-1880). False when
13
13
  // parked at a checkpoint gate or running single-call mode.
14
14
  let agenticRunning = false;
15
+ // True while a checkpoint gate card is parked awaiting the user's decision.
16
+ // finishStreaming() clears isStreaming at the gate, so this flag is what
17
+ // actually locks the composer (VIB-1898).
18
+ let checkpointGateActive = false;
15
19
  // Softened barge-in (VIB-1876): a message sent during an active build is QUEUED
16
20
  // by default and dispatched when the build finishes. The queued chip carries an
17
21
  // "Interrupt now" button that triggers the old cancel-and-replan barge-in.
@@ -750,6 +754,9 @@ function handleWsMessage(msg) {
750
754
  resetPipelineState();
751
755
  appendAssistantError(msg.message);
752
756
  setStatus("Error");
757
+ // The run this message was queued behind is over — dispatch it rather
758
+ // than stranding the chip forever (VIB-1898).
759
+ flushQueuedMessage();
753
760
  break;
754
761
 
755
762
  case "pong":
@@ -1785,6 +1792,10 @@ async function sendMessage(text, opts = {}) {
1785
1792
  const hasFiles = pendingFiles.length > 0;
1786
1793
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
1787
1794
  if (!text.trim() && !hasFiles) return;
1795
+ // A parked checkpoint gate must be resolved through its card. Only the send
1796
+ // buttons were locked before — Enter still reached here (the streaming flags
1797
+ // are reset at the gate) and started a second run mid-park (VIB-1898).
1798
+ if (checkpointGateActive) return;
1788
1799
  // Block sends while busy — EXCEPT during an active agentic build. There, a new
1789
1800
  // message is QUEUED by default and runs when the build finishes (VIB-1876);
1790
1801
  // an explicit Interrupt (opts.interrupt) triggers the cancel-and-replan
@@ -2078,6 +2089,9 @@ function interruptWithQueued() {
2078
2089
  // Dispatch the queued message once the current build has finished.
2079
2090
  function flushQueuedMessage() {
2080
2091
  if (!queuedMessage) return;
2092
+ // A parked gate blocks sendMessage — keep the message queued; the gate's
2093
+ // resolution path flushes again (VIB-1898).
2094
+ if (checkpointGateActive) return;
2081
2095
  const { text, opts } = queuedMessage;
2082
2096
  clearQueuedMessage();
2083
2097
  // Defer so the just-finished run's UI settles before the next one starts.
@@ -2122,6 +2136,7 @@ function supersedeCurrentRun() {
2122
2136
  }
2123
2137
  }
2124
2138
  removeCheckpointCard();
2139
+ checkpointGateActive = false;
2125
2140
  isStreaming = false;
2126
2141
  agenticRunning = false;
2127
2142
  streamingMsgEl = null;
@@ -2257,13 +2272,7 @@ function renderMarkdown(text) {
2257
2272
  // Helpers
2258
2273
  // ---------------------------------------------------------------------------
2259
2274
 
2260
- function escapeHtml(str) {
2261
- return str
2262
- .replace(/&/g, "&")
2263
- .replace(/</g, "&lt;")
2264
- .replace(/>/g, "&gt;")
2265
- .replace(/"/g, "&quot;");
2266
- }
2275
+ // escapeHtml comes from the shared ui/escape-html.js (VIB-1902).
2267
2276
 
2268
2277
  function scrollToBottom() {
2269
2278
  if (scrollScheduled) return;
@@ -2540,13 +2549,16 @@ function handleCheckpointRequested(msg) {
2540
2549
  messagesEl.appendChild(checkpointCardEl);
2541
2550
  scrollToBottom();
2542
2551
 
2543
- // Hold the send buttons until the gate is resolved.
2552
+ // Hold the composer until the gate is resolved — buttons AND the Enter path
2553
+ // through sendMessage (VIB-1898).
2554
+ checkpointGateActive = true;
2544
2555
  setSendDisabled(true);
2545
2556
  setStatus("Awaiting your call");
2546
2557
  }
2547
2558
 
2548
2559
  function handleCheckpointCancelled() {
2549
2560
  removeCheckpointCard();
2561
+ checkpointGateActive = false;
2550
2562
  setSendDisabled(false);
2551
2563
  appendSystemMessage("Cancelled — nothing was built.");
2552
2564
  flushQueuedMessage();
@@ -2554,6 +2566,7 @@ function handleCheckpointCancelled() {
2554
2566
 
2555
2567
  function resolveCheckpoint(action, note, extra) {
2556
2568
  if (!ws || ws.readyState !== WebSocket.OPEN) return;
2569
+ checkpointGateActive = false;
2557
2570
  // Leave the decision in the transcript (card → one-line record), not an empty
2558
2571
  // bubble (VIB-1876).
2559
2572
  finalizeCheckpointCard(action, note, extra);
package/ui/dashboard.js CHANGED
@@ -78,9 +78,16 @@ function hideDashboard() {
78
78
  // ---------------------------------------------------------------------------
79
79
 
80
80
  async function refreshDashboard() {
81
+ // Guard against a stale in-flight response rendering after the user
82
+ // switched to another theme (or left the dashboard) mid-fetch.
83
+ const themeAtStart = currentDashboardTheme;
84
+ const sessionAtStart = currentDashboardSessionId;
81
85
  try {
82
86
  const res = await fetch("/api/dashboard");
83
87
  const data = await res.json();
88
+ if (currentDashboardTheme !== themeAtStart || currentDashboardSessionId !== sessionAtStart) {
89
+ return;
90
+ }
84
91
  if (data.error) {
85
92
  console.warn("Dashboard load error:", data.error);
86
93
  return;
@@ -673,7 +680,9 @@ document.getElementById("dashboard-brand-assets")?.addEventListener("change", (e
673
680
  if (e.target.type !== "file") return;
674
681
  const card = e.target.closest(".brand-asset-card");
675
682
  if (!card || !e.target.files[0]) return;
676
- handleBrandFileSelected(card.dataset.asset, e.target.files[0]);
683
+ const file = e.target.files[0];
684
+ e.target.value = ""; // allow re-selecting the same file after an error
685
+ handleBrandFileSelected(card.dataset.asset, file);
677
686
  });
678
687
 
679
688
  // ---------------------------------------------------------------------------
@@ -785,27 +794,43 @@ function updateBrandPreview() {
785
794
  const logoImg = document.getElementById("brand-preview-logo");
786
795
  const logoPlaceholder = document.getElementById("brand-preview-logo-placeholder");
787
796
  if (logoImg && logoPlaceholder) {
788
- if (logoUrl) {
789
- logoImg.src = logoUrl;
790
- logoImg.hidden = false;
791
- logoPlaceholder.hidden = true;
797
+ const safeLogoUrl = logoUrl ? safeImageUrl(logoUrl) : null;
798
+ if (safeLogoUrl) {
799
+ // Attach handlers before setting src so a fast (cached) result can't
800
+ // race past them and wedge the preview in a stale state.
792
801
  logoImg.onerror = () => {
793
802
  logoImg.hidden = true;
803
+ logoImg.removeAttribute("src");
794
804
  logoPlaceholder.hidden = false;
795
805
  logoPlaceholder.textContent = "Bad URL";
796
806
  };
797
807
  logoImg.onload = () => {
808
+ logoImg.hidden = false;
809
+ logoPlaceholder.hidden = true;
798
810
  logoPlaceholder.textContent = "No logo";
799
811
  };
812
+ logoImg.src = safeLogoUrl;
813
+ logoImg.hidden = false;
814
+ logoPlaceholder.hidden = true;
800
815
  } else {
801
816
  logoImg.hidden = true;
802
817
  logoImg.removeAttribute("src");
803
818
  logoPlaceholder.hidden = false;
804
- logoPlaceholder.textContent = "No logo";
819
+ logoPlaceholder.textContent = logoUrl ? "Bad URL" : "No logo";
805
820
  }
806
821
  }
807
822
  }
808
823
 
824
+ // Only http(s) URLs (or paths that resolve to them) may reach img.src —
825
+ // blocks javascript:, data:, file: etc. from the free-text logo field.
826
+ function safeImageUrl(url) {
827
+ try {
828
+ const parsed = new URL(url, window.location.href);
829
+ if (parsed.protocol === "http:" || parsed.protocol === "https:") return parsed.href;
830
+ } catch { /* unparseable */ }
831
+ return null;
832
+ }
833
+
809
834
  for (const id of [
810
835
  "bk-hex-primary", "bk-hex-secondary", "bk-hex-accent",
811
836
  "bk-color-primary", "bk-color-secondary", "bk-color-accent",
@@ -1026,15 +1051,31 @@ async function uploadBrandAsset(type) {
1026
1051
  fileInput.click();
1027
1052
  }
1028
1053
 
1054
+ // Brand assets are prose fed into AI prompts — 1 MB of markdown is already
1055
+ // far beyond useful; anything bigger is a mis-picked file.
1056
+ const MAX_BRAND_ASSET_BYTES = 1024 * 1024;
1057
+
1029
1058
  async function handleBrandFileSelected(type, file) {
1030
- const content = await file.text();
1059
+ if (file.size > MAX_BRAND_ASSET_BYTES) {
1060
+ await vibeAlert(
1061
+ `File is too large (${(file.size / (1024 * 1024)).toFixed(1)} MB). Brand assets are text files up to 1 MB.`,
1062
+ "Error",
1063
+ );
1064
+ return;
1065
+ }
1066
+ if (!/\.(md|markdown|txt)$/i.test(file.name || "")) {
1067
+ await vibeAlert("Only .md and .txt files are supported.", "Error");
1068
+ return;
1069
+ }
1031
1070
 
1032
1071
  try {
1033
- const res = await fetch("/api/brand-assets", {
1034
- method: "POST",
1035
- headers: { "Content-Type": "application/json" },
1036
- body: JSON.stringify({ type, content }),
1037
- });
1072
+ // Multipart keeps the file out of a JSON string (no UTF-16/binary
1073
+ // mangling, no full-file JSON.stringify); the server enforces the same
1074
+ // size cap and rejects non-text content.
1075
+ const form = new FormData();
1076
+ form.append("type", type);
1077
+ form.append("file", file, file.name);
1078
+ const res = await fetch("/api/brand-assets", { method: "POST", body: form });
1038
1079
  const data = await res.json();
1039
1080
  if (data.error) {
1040
1081
  await vibeAlert(data.error, "Error");
@@ -372,9 +372,14 @@ vibespot</code></pre>
372
372
  <tr><td><code>langfusePublicKey</code></td><td>string</td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
373
373
  <tr><td><code>langfuseSecretKey</code></td><td>string</td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
374
374
  <tr><td><code>langfuseBaseUrl</code></td><td>string</td><td>Langfuse ingestion endpoint (default: <code>https://cloud.langfuse.com</code>)</td></tr>
375
+ <tr><td><code>lastSeenVersion</code></td><td>string</td><td>The version whose release notes you last dismissed in the <a href="#whats-new">What's new</a> dialog. Set automatically on dismiss; the dialog reappears when you upgrade to a newer version.</td></tr>
375
376
  </tbody>
376
377
  </table>
377
378
 
379
+ <h3 id="whats-new">What's new dialog</h3>
380
+ <p>The first time you open the builder after upgrading, a dialog shows the highlights of the new version with a link to the full changelog. Dismiss it &mdash; with the <strong>&times;</strong>, <strong>Got it</strong>, a click outside, or <kbd>Esc</kbd> &mdash; and it stays hidden until the next release. One card per version, never a repeat nag.</p>
381
+ <p>The highlights are pulled straight from <a href="https://github.com/borismichel/vibespot/blob/main/CHANGELOG.md"><code>CHANGELOG.md</code></a> at build time, so every release shows its own notes. Your dismissal is remembered per machine via <code>lastSeenVersion</code> in <code>~/.vibespot/config.json</code>.</p>
382
+
378
383
  <h3 id="env-vars">Environment Variables</h3>
379
384
  <p>API keys and server settings can be set as environment variables. The config file takes precedence &mdash; environment variables are used as fallbacks when no key is set in the config. In Docker deployments, env vars are the primary configuration method (see <a href="#docker-deployment">Docker Deployment</a>).</p>
380
385
  <table>
@@ -394,6 +399,10 @@ vibespot</code></pre>
394
399
  <tr><td><code>VIBESPOT_AGENTIC_MODE</code></td><td>Set <code>true</code> to enable the multi-stage agentic pipeline</td></tr>
395
400
  <tr><td><code>VIBESPOT_PORT</code></td><td>Override the HTTP server port (default: <code>4200</code>)</td></tr>
396
401
  <tr><td><code>VIBESPOT_NO_OPEN</code></td><td>Set <code>1</code> to suppress auto-opening the browser on startup</td></tr>
402
+ <tr><td><code>VIBESPOT_HOST</code></td><td>Bind address (default: <code>127.0.0.1</code>, loopback only). Set <code>0.0.0.0</code> to reach vibeSpot from another device &mdash; a non-loopback bind requires the access token printed at boot (see "Access from another device" in the README)</td></tr>
403
+ <tr><td><code>VIBESPOT_AUTH_TOKEN</code></td><td>Pin the shared-secret access token used on non-loopback binds (otherwise a fresh one is generated at boot and printed in the startup URL)</td></tr>
404
+ <tr><td><code>VIBESPOT_DISABLE_AUTH</code></td><td>Set <code>1</code> to disable the auth gate. Only for deployments behind their own authenticating proxy; on a non-loopback bind this additionally requires <code>VIBESPOT_TRUST_PROXY=1</code></td></tr>
405
+ <tr><td><code>VIBESPOT_TRUST_PROXY</code></td><td>Set <code>1</code> to acknowledge that an authenticating reverse proxy is the sole ingress, allowing <code>VIBESPOT_DISABLE_AUTH=1</code> on a non-loopback bind</td></tr>
397
406
  <tr><td><code>LANGFUSE_ENABLED</code></td><td>Set <code>true</code> to send <a href="#langfuse">Langfuse traces</a>. Required &mdash; keys alone don't enable it.</td></tr>
398
407
  <tr><td><code>LANGFUSE_PUBLIC_KEY</code></td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
399
408
  <tr><td><code>LANGFUSE_SECRET_KEY</code></td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
@@ -988,7 +997,7 @@ testimonial carousel, and a minimal footer with social links.</code></pre>
988
997
  <p>Each card also shows the <strong>estimated cost of the build it gates</strong>, computed from spend-so-far via the per-page <a href="#observability">cost tracker</a> &mdash; so you know what Approve will cost before you click it. (CLI engines report no usage, so the estimate is omitted for them.)</p>
989
998
  <div class="doc-callout doc-callout--tip">
990
999
  <div class="doc-callout__label">&#128268; Crash-safe by design</div>
991
- <p>A parked checkpoint isn't a paused, open promise &mdash; the pipeline <em>returns</em> at the gate and your resolution re-enters it via a resume token. If the server restarts while a gate is open, the run is simply dropped and you re-send your prompt, rather than leaving a hung generation behind.</p>
1000
+ <p>A parked checkpoint isn't a paused, open promise &mdash; the pipeline <em>returns</em> at the gate and your resolution re-enters it via a resume token. The resume state is persisted with the session, so an open gate even survives a server restart: reopen the theme and the parked card is still there &mdash; approve, steer, or skip it and the run continues where it left off.</p>
992
1001
  </div>
993
1002
 
994
1003
  <h3 id="checkpoints-oneshot">One-Shot Escape Hatch</h3>
@@ -38,11 +38,7 @@
38
38
  }
39
39
  }
40
40
 
41
- function escapeHtml(str) {
42
- var div = document.createElement("div");
43
- div.textContent = str;
44
- return div.innerHTML;
45
- }
41
+ // escapeHtml comes from the shared ui/escape-html.js (VIB-1902).
46
42
 
47
43
  function openEmailPreview() {
48
44
  overlay.classList.remove("hidden");
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Shared HTML escaper (VIB-1902). Loaded before every script that
3
+ * interpolates untrusted strings into innerHTML — chat.js, plan.js and
4
+ * email-preview.js previously each carried their own copy (the
5
+ * email-preview one didn't escape quotes, so it was unsafe in attribute
6
+ * context). One definition, attribute-safe, used everywhere.
7
+ */
8
+ function escapeHtml(str) {
9
+ return String(str)
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/"/g, "&quot;");
14
+ }
@@ -39,7 +39,9 @@ async function openFieldEditor(moduleName) {
39
39
  }
40
40
 
41
41
  function closeFieldEditor() {
42
- if (updateTimer) { clearTimeout(updateTimer); updateTimer = null; }
42
+ // Fire (not drop) any debounced edit still pending — closing the editor
43
+ // right after typing must not lose the change (VIB-1898).
44
+ flushPendingFieldSaves();
43
45
  currentEditModule = null;
44
46
  if (typeof showModuleListView === "function") {
45
47
  showModuleListView();
@@ -281,18 +283,10 @@ function createFieldInput(field, moduleName, fullPath) {
281
283
  // Update field and refresh preview
282
284
  // ---------------------------------------------------------------------------
283
285
 
284
- let updateTimer = null;
285
-
286
286
  function updateField(moduleName, fieldPath, value) {
287
- // Debounce updates
288
- clearTimeout(updateTimer);
289
- updateTimer = setTimeout(() => {
290
- fetch("/api/field", {
291
- method: "POST",
292
- headers: { "Content-Type": "application/json" },
293
- body: JSON.stringify({ moduleName, fieldPath, value }),
294
- }).then(() => refreshPreview());
295
- }, 300);
287
+ // Debounced per field via the shared save module (field-save.js) — editing
288
+ // a second field no longer cancels the first field's save (VIB-1898).
289
+ saveFieldDebounced(moduleName, fieldPath, value);
296
290
  }
297
291
 
298
292
  // ---------------------------------------------------------------------------
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Unified /api/field save path (VIB-1898).
3
+ *
4
+ * All three field-editing surfaces persist through this module — the field
5
+ * editor sidebar (field-editor.js), inline interact-mode edits
6
+ * (inline-edit.js) and the section-controls commits (section-controls.js).
7
+ * The debounce is keyed PER FIELD, so editing a second field never cancels
8
+ * the first field's pending save, and saves to the same field are chained so
9
+ * an earlier POST can't land after a later one.
10
+ */
11
+
12
+ /** key -> { timer, moduleName, fieldPath, value, opts } */
13
+ const fieldSavePending = new Map();
14
+ /** key -> tail of that field's in-flight POST chain */
15
+ const fieldSaveChains = new Map();
16
+ const FIELD_SAVE_DEBOUNCE_MS = 300;
17
+
18
+ function fieldSaveKey(moduleName, fieldPath) {
19
+ return `${moduleName}\u0000${fieldPath}`;
20
+ }
21
+
22
+ /**
23
+ * Save one field value now.
24
+ *
25
+ * @param {Object} [opts]
26
+ * @param {boolean} [opts.refresh=true] Refresh the preview after the POST.
27
+ * @returns {Promise<void>} resolves after the POST (and refresh) settle.
28
+ */
29
+ function saveField(moduleName, fieldPath, value, opts = {}) {
30
+ const key = fieldSaveKey(moduleName, fieldPath);
31
+ // An immediate save supersedes any older debounced value for the field.
32
+ const pending = fieldSavePending.get(key);
33
+ if (pending) {
34
+ clearTimeout(pending.timer);
35
+ fieldSavePending.delete(key);
36
+ }
37
+ const tail = fieldSaveChains.get(key) || Promise.resolve();
38
+ const post = tail
39
+ .then(() =>
40
+ fetch("/api/field", {
41
+ method: "POST",
42
+ headers: { "Content-Type": "application/json" },
43
+ body: JSON.stringify({ moduleName, fieldPath, value }),
44
+ })
45
+ )
46
+ .then(() => {
47
+ if (opts.refresh !== false) refreshPreview();
48
+ })
49
+ .catch(() => {
50
+ /* field save failed — preview keeps its optimistic state */
51
+ });
52
+ fieldSaveChains.set(key, post);
53
+ post.then(() => {
54
+ if (fieldSaveChains.get(key) === post) fieldSaveChains.delete(key);
55
+ });
56
+ return post;
57
+ }
58
+
59
+ /**
60
+ * Debounced save, keyed per field — pending edits to OTHER fields are
61
+ * untouched. Same opts as saveField, plus opts.debounceMs.
62
+ */
63
+ function saveFieldDebounced(moduleName, fieldPath, value, opts = {}) {
64
+ const key = fieldSaveKey(moduleName, fieldPath);
65
+ const prev = fieldSavePending.get(key);
66
+ if (prev) clearTimeout(prev.timer);
67
+ const entry = { moduleName, fieldPath, value, opts };
68
+ entry.timer = setTimeout(() => {
69
+ fieldSavePending.delete(key);
70
+ saveField(moduleName, fieldPath, value, opts);
71
+ }, opts.debounceMs ?? FIELD_SAVE_DEBOUNCE_MS);
72
+ fieldSavePending.set(key, entry);
73
+ }
74
+
75
+ /** Fire every pending debounced save immediately (e.g. the editor closes). */
76
+ function flushPendingFieldSaves() {
77
+ for (const [key, entry] of Array.from(fieldSavePending)) {
78
+ clearTimeout(entry.timer);
79
+ fieldSavePending.delete(key);
80
+ saveField(entry.moduleName, entry.fieldPath, entry.value, entry.opts);
81
+ }
82
+ }
package/ui/index.html CHANGED
@@ -889,7 +889,7 @@
889
889
  <button class="btn btn--danger btn--sm dashboard__module-preview-delete" id="dashboard-preview-delete" type="button">Delete module</button>
890
890
  <button class="dashboard__module-preview-close" id="dashboard-preview-close" type="button" aria-label="Close preview">&times;</button>
891
891
  </div>
892
- <iframe class="dashboard__module-preview-frame" id="dashboard-preview-frame" sandbox="allow-scripts allow-same-origin" title="Module preview"></iframe>
892
+ <iframe class="dashboard__module-preview-frame" id="dashboard-preview-frame" sandbox="allow-scripts" title="Module preview"></iframe>
893
893
  </div>
894
894
  </section>
895
895
 
@@ -952,7 +952,7 @@
952
952
  </div>
953
953
  <div class="email-preview-panel__notes" id="email-preview-notes"></div>
954
954
  <div class="email-preview-panel__body">
955
- <iframe class="email-preview-panel__frame" id="email-preview-frame" sandbox="allow-scripts allow-same-origin" title="Email client preview"></iframe>
955
+ <iframe class="email-preview-panel__frame" id="email-preview-frame" sandbox="allow-scripts" title="Email client preview"></iframe>
956
956
  </div>
957
957
  </div>
958
958
  </div>
@@ -997,8 +997,9 @@
997
997
  <script src="/vendor/marked.umd.js"></script>
998
998
  <script src="/vendor/codemirror-bundle.global.js"></script>
999
999
  <script src="/icons.js"></script>
1000
+ <script src="/escape-html.js"></script>
1000
1001
  <script src="/dialog.js"></script>
1001
- <script src="/setup.js?v=111"></script>
1002
+ <script src="/setup.js?v=112"></script>
1002
1003
  <script src="/dashboard.js"></script>
1003
1004
  <script src="/settings.js"></script>
1004
1005
  <script src="/upload-panel.js"></script>
@@ -1006,10 +1007,12 @@
1006
1007
  <script src="/chat.js?v=109"></script>
1007
1008
  <script src="/plan.js"></script>
1008
1009
  <script src="/preview.js"></script>
1010
+ <script src="/field-save.js"></script>
1009
1011
  <script src="/inline-edit.js"></script>
1010
1012
  <script src="/field-editor.js"></script>
1011
1013
  <script src="/code-editor.js"></script>
1012
1014
  <script src="/email-preview.js"></script>
1013
1015
  <script src="/section-controls.js"></script>
1016
+ <script src="/whats-new.js"></script>
1014
1017
  </body>
1015
1018
  </html>