vibespot 1.6.5 → 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 (40) hide show
  1. package/README.md +24 -3
  2. package/assets/whats-new.json +27 -0
  3. package/dist/index.js +579 -681
  4. package/dist/index.js.map +1 -1
  5. package/package.json +8 -5
  6. package/ui/chat.js +671 -9
  7. package/ui/dashboard.js +53 -12
  8. package/ui/docs/index.html +57 -2
  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 +10 -4
  14. package/ui/inline-edit.js +116 -570
  15. package/ui/plan.js +22 -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 +424 -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-plan-mode.png +0 -0
  27. package/assets/readme/03-figma-import.png +0 -0
  28. package/assets/readme/04-multi-page-sites.png +0 -0
  29. package/assets/readme/05-inline-wysiwyg.png +0 -0
  30. package/assets/readme/06-hubspot-upload.png +0 -0
  31. package/ui/docs/screenshots/brand-kit-preview.png +0 -0
  32. package/ui/docs/screenshots/content-type-dropdown.png +0 -0
  33. package/ui/docs/screenshots/editor-full-layout.png +0 -0
  34. package/ui/docs/screenshots/inline-wysiwyg-editing.png +0 -0
  35. package/ui/docs/screenshots/module-overview-slideout.png +0 -0
  36. package/ui/docs/screenshots/multi-page-tree.png +0 -0
  37. package/ui/docs/screenshots/onboarding-walkthrough.png +0 -0
  38. package/ui/docs/screenshots/split-pane-view.png +0 -0
  39. package/ui/docs/screenshots/visual-controls-toolbar.png +0 -0
  40. package/ui/docs/screenshots/workspace-tabs.png +0 -0
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");
@@ -86,6 +86,10 @@
86
86
  <a class="doc-nav__link doc-nav__link--sub" href="#intent-types">Intents</a>
87
87
  <a class="doc-nav__link doc-nav__link--sub" href="#prompt-tips">Prompt Tips</a>
88
88
  <a class="doc-nav__link doc-nav__link--sub" href="#brand-assets">Brand Assets</a>
89
+ <a class="doc-nav__link" href="#checkpoints">Checkpoints</a>
90
+ <a class="doc-nav__link doc-nav__link--sub" href="#checkpoints-seams">The Three Seams</a>
91
+ <a class="doc-nav__link doc-nav__link--sub" href="#checkpoints-card">The Checkpoint Card</a>
92
+ <a class="doc-nav__link doc-nav__link--sub" href="#checkpoints-oneshot">One-Shot Escape Hatch</a>
89
93
  <a class="doc-nav__link" href="#plan-mode">Plan Mode</a>
90
94
  <a class="doc-nav__link doc-nav__link--sub" href="#plan-mode-when">When to Use</a>
91
95
  <a class="doc-nav__link doc-nav__link--sub" href="#plan-mode-flow">The Flow</a>
@@ -368,9 +372,14 @@ vibespot</code></pre>
368
372
  <tr><td><code>langfusePublicKey</code></td><td>string</td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
369
373
  <tr><td><code>langfuseSecretKey</code></td><td>string</td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
370
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>
371
376
  </tbody>
372
377
  </table>
373
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
+
374
383
  <h3 id="env-vars">Environment Variables</h3>
375
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>
376
385
  <table>
@@ -390,6 +399,10 @@ vibespot</code></pre>
390
399
  <tr><td><code>VIBESPOT_AGENTIC_MODE</code></td><td>Set <code>true</code> to enable the multi-stage agentic pipeline</td></tr>
391
400
  <tr><td><code>VIBESPOT_PORT</code></td><td>Override the HTTP server port (default: <code>4200</code>)</td></tr>
392
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>
393
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>
394
407
  <tr><td><code>LANGFUSE_PUBLIC_KEY</code></td><td>Langfuse public key (<code>pk-lf-</code>)</td></tr>
395
408
  <tr><td><code>LANGFUSE_SECRET_KEY</code></td><td>Langfuse secret key (<code>sk-lf-</code>)</td></tr>
@@ -954,12 +967,49 @@ testimonial carousel, and a minimal footer with social links.</code></pre>
954
967
  </ul>
955
968
  </div>
956
969
 
970
+ <!-- ============================================================
971
+ Section: Checkpoints
972
+ ============================================================ -->
973
+ <div class="doc-section" id="checkpoints">
974
+ <h2 id="checkpoints-heading">Checkpoints <a href="#checkpoints" class="doc-anchor">#</a></h2>
975
+ <p class="doc-subtitle">The default generation flow. Instead of running all four pipeline stages in one blocking shot, vibeSpot pauses at cheap seams and shows you a review card <em>before</em> spending tokens on the expensive parallel module build &mdash; so you steer the look and the skeleton while changes are still free.</p>
976
+
977
+ <p>The <a href="#agentic-pipeline">agentic pipeline</a> has a natural cost cliff: the design system (Stage 2a) and the module plan (Stage 2b) are a handful of cheap calls, but the module build (Stage 3) fans out one generation per module in parallel &mdash; the bulk of the spend and the wait. Checkpoints park the pipeline at the seams before that cliff. You see what the AI decided, adjust it, and only then pay for the build. Each gate is rendered from data the pipeline already produced, so showing the card costs nothing extra.</p>
978
+
979
+ <img class="doc-screenshot" style="max-width:360px" src="screenshots/checkpoint-card.png" alt="Design checkpoint card in the chat panel: a DESIGN CHECKPOINT badge, a rendered hero preview, the palette swatches and heading/body type specimen, the estimated build cost, and Approve / Steer / Skip / Cancel buttons">
980
+
981
+ <h3 id="checkpoints-seams">The Three Seams</h3>
982
+ <p>For a from-scratch page, checkpoints fire in order: <strong>brand intake &rarr; design &rarr; structure &rarr; build</strong>. Modifications to an existing page, email generation, and plan-approved runs build straight through without gating.</p>
983
+ <ol class="doc-steps">
984
+ <li><strong>Brand intake.</strong> When you ask for a new page and the theme has no style system yet, vibeSpot asks first instead of guessing: <strong>bring your brand</strong> (paste colors, drop in CSS/code, point at an existing theme or a live URL, describe your voice) or <strong>let it choose</strong>. What you provide seeds the <code>:root</code> design tokens that feed the design checkpoint. Skipped when the theme already has a style system.</li>
985
+ <li><strong>Design checkpoint.</strong> After the design-system stage, the pipeline parks and shows a <strong>design card</strong>: palette swatches, a heading/body type specimen, and one representative hero rendered straight from the design tokens. No extra model call &mdash; it's a deterministic preview of the tokens the build will use.</li>
986
+ <li><strong>Structure checkpoint.</strong> After the module planner, the pipeline parks again and shows the planned modules as an <strong>editable outline</strong>. Reorder rows (&#9650;/&#9660;), cut them (&#10005;), rename inline, or add a section &mdash; all client-side, no model call. The build honors exactly the outline you keep: a reorder or rename preserves that module's planned brief; an added row gets a sensible default brief.</li>
987
+ </ol>
988
+
989
+ <h3 id="checkpoints-card">The Checkpoint Card</h3>
990
+ <p>Every checkpoint card offers the same four actions, plus a cost estimate:</p>
991
+ <ul>
992
+ <li><strong>Approve</strong> &mdash; accept what's shown and continue to the next seam (or the build, at the last gate).</li>
993
+ <li><strong>Steer</strong> &mdash; type a note and vibeSpot re-runs <em>only</em> that stage with your guidance, then re-previews. Cheap to iterate, because you're redoing one stage, not the whole page.</li>
994
+ <li><strong>Skip</strong> &mdash; build now and suppress any remaining gates for this run. Skipping at the design gate suppresses the structure gate too.</li>
995
+ <li><strong>Cancel</strong> &mdash; drop the run. Nothing is written.</li>
996
+ </ul>
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>
998
+ <div class="doc-callout doc-callout--tip">
999
+ <div class="doc-callout__label">&#128268; Crash-safe by design</div>
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>
1001
+ </div>
1002
+
1003
+ <h3 id="checkpoints-oneshot">One-Shot Escape Hatch</h3>
1004
+ <p>Checkpoints are <strong>on by default</strong> for from-scratch page generation. When you already know what you want and don't need to review, the <strong>one-shot</strong> button next to send runs the whole pipeline in one go &mdash; the previous one-call behaviour, no gates. It's a per-message choice: tap one-shot for this prompt, or hit send to go through the checkpoints.</p>
1005
+ </div>
1006
+
957
1007
  <!-- ============================================================
958
1008
  Section: Plan Mode
959
1009
  ============================================================ -->
960
1010
  <div class="doc-section" id="plan-mode">
961
1011
  <h2 id="plan-mode-heading">Plan Mode <a href="#plan-mode" class="doc-anchor">#</a></h2>
962
- <p class="doc-subtitle">A deliberation phase that runs <em>before</em> any code is generated. Plan mode turns vague briefs into well-scoped plans through guided dialogue, then runs the agentic pipeline against the approved plan as a high-fidelity design brief.</p>
1012
+ <p class="doc-subtitle">A deliberation phase that runs <em>before</em> any code is generated. Plan mode turns vague briefs into well-scoped plans through guided dialogue, then runs the agentic pipeline against the approved plan as a high-fidelity design brief. It is the heaviest variant of the <a href="#checkpoints">checkpoint</a> flow &mdash; a full written plan you approve, rather than a single review card.</p>
963
1013
 
964
1014
  <p>Without plan mode, vibeSpot generates immediately on every chat message &mdash; fast, but unforgiving on vague prompts. The AI guesses at audience, content, and structure, and you find out what it guessed only after a full generation cycle. Plan mode flips the order: the AI asks targeted questions first, surfaces what's missing, and only generates after you've reviewed and approved a written plan. The plan is markdown, persisted to <code>{theme}/.vibespot/plan.md</code>, and reviewable in a dedicated tab in the right pane.</p>
965
1015
 
@@ -1010,6 +1060,11 @@ testimonial carousel, and a minimal footer with social links.</code></pre>
1010
1060
  </ul>
1011
1061
  <p>The plan persists across vibeSpot restarts. Open the same theme weeks later, toggle plan mode on, and your in-progress plan is right where you left it.</p>
1012
1062
 
1063
+ <div class="doc-callout doc-callout--info">
1064
+ <div class="doc-callout__label">&#128279; One primitive under the hood</div>
1065
+ <p>Plan approval now runs on the same park-and-re-enter machinery as the design and structure <a href="#checkpoints">checkpoints</a>. <strong>Approve plan</strong> and <strong>Discard &amp; start over</strong> resolve through the shared <code>checkpoint_resolve</code> path (approve &rarr; build, cancel &rarr; discard), so plan mode, the design gate, and the structure gate are three faces of one gating model rather than separate code paths. A plan-approved run builds straight through &mdash; it doesn't re-gate at the design or structure seams, because the plan already covered that deliberation.</p>
1066
+ </div>
1067
+
1013
1068
  <div class="doc-callout doc-callout--tip">
1014
1069
  <div class="doc-callout__label">&#10024; Plan mode + brand assets</div>
1015
1070
  <p>If your theme has a styleguide, brand voice, or product context loaded, the plan-mode AI uses them automatically. The plan will reference the right colors and tone without you re-stating them. After approval, the agentic pipeline gets the plan <em>and</em> the brand assets &mdash; double the alignment.</p>
@@ -1500,7 +1555,7 @@ docker cp vibespot:/home/vibespot/vibespot-themes ./backup-themes</code></pre>
1500
1555
  </ul>
1501
1556
  <div class="doc-callout doc-callout--tip">
1502
1557
  <div class="doc-callout__label">&#9888;&#65039; Single instance &amp; auth</div>
1503
- <p>vibeSpot keeps active sessions in memory &mdash; run a single instance (no horizontal scaling without sticky sessions). It runs as a non-root user and has <strong>no built-in authentication</strong>. For internet-facing deployments, put an authenticating proxy (OAuth2 Proxy, Authelia, Cloudflare Access) in front. Persistence is filesystem-only on the named volumes &mdash; there is no database to run.</p>
1558
+ <p>vibeSpot keeps active sessions in memory &mdash; run a single instance (no horizontal scaling without sticky sessions). It runs as a non-root user and has <strong>no built-in authentication</strong>. For internet-facing deployments, enable the bundled <strong>Azure Entra SSO gate</strong> (the <code>docker-compose.auth.yml</code> overlay) or put your own authenticating proxy (Authelia, Cloudflare Access) in front. Persistence is filesystem-only on the named volumes &mdash; there is no database to run.</p>
1504
1559
  </div>
1505
1560
  </div>
1506
1561
 
@@ -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
@@ -542,7 +542,10 @@
542
542
  <button class="chat__input-icon" id="btn-starter-templates" type="button" title="Templates" aria-label="Templates">
543
543
  <svg class="icon icon--sm" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>
544
544
  </button>
545
- <button class="chat__send" id="chat-send" type="button" title="Send (Enter)" aria-label="Send message">
545
+ <button class="chat__send chat__send--oneshot" id="chat-send-oneshot" type="button" title="One-shot — skip the design checkpoint and build the whole page in one go" aria-label="One-shot (skip checkpoints)">
546
+ <svg class="icon icon--sm" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M13 2L4.5 12.5h6L9 22l8.5-10.5h-6L13 2z"/></svg>
547
+ </button>
548
+ <button class="chat__send" id="chat-send" type="button" title="Send (Enter) — pauses at the design checkpoint" aria-label="Send message">
546
549
  <svg class="icon icon--md" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M3 12L21 4L13 22L11 14L3 12Z"/></svg>
547
550
  </button>
548
551
  </div>
@@ -886,7 +889,7 @@
886
889
  <button class="btn btn--danger btn--sm dashboard__module-preview-delete" id="dashboard-preview-delete" type="button">Delete module</button>
887
890
  <button class="dashboard__module-preview-close" id="dashboard-preview-close" type="button" aria-label="Close preview">&times;</button>
888
891
  </div>
889
- <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>
890
893
  </div>
891
894
  </section>
892
895
 
@@ -949,7 +952,7 @@
949
952
  </div>
950
953
  <div class="email-preview-panel__notes" id="email-preview-notes"></div>
951
954
  <div class="email-preview-panel__body">
952
- <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>
953
956
  </div>
954
957
  </div>
955
958
  </div>
@@ -994,8 +997,9 @@
994
997
  <script src="/vendor/marked.umd.js"></script>
995
998
  <script src="/vendor/codemirror-bundle.global.js"></script>
996
999
  <script src="/icons.js"></script>
1000
+ <script src="/escape-html.js"></script>
997
1001
  <script src="/dialog.js"></script>
998
- <script src="/setup.js?v=111"></script>
1002
+ <script src="/setup.js?v=112"></script>
999
1003
  <script src="/dashboard.js"></script>
1000
1004
  <script src="/settings.js"></script>
1001
1005
  <script src="/upload-panel.js"></script>
@@ -1003,10 +1007,12 @@
1003
1007
  <script src="/chat.js?v=109"></script>
1004
1008
  <script src="/plan.js"></script>
1005
1009
  <script src="/preview.js"></script>
1010
+ <script src="/field-save.js"></script>
1006
1011
  <script src="/inline-edit.js"></script>
1007
1012
  <script src="/field-editor.js"></script>
1008
1013
  <script src="/code-editor.js"></script>
1009
1014
  <script src="/email-preview.js"></script>
1010
1015
  <script src="/section-controls.js"></script>
1016
+ <script src="/whats-new.js"></script>
1011
1017
  </body>
1012
1018
  </html>