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/inline-edit.js CHANGED
@@ -1,483 +1,16 @@
1
1
  /**
2
- * Unified interact mode — merged Select + Edit into one contextual model.
2
+ * Unified interact mode — parent end (VIB-1892).
3
3
  *
4
- * Editable elements (text, images, links) get inline editing.
5
- * Module-level containers prefill the chat input for referencing.
4
+ * The hover/edit UI itself lives in the cross-origin preview frame and is run
5
+ * by the trusted agent (ui/preview-agent.js). This file owns the toolbar
6
+ * toggle, tells the agent which mode is active (vs:set-mode via preview.js),
7
+ * and maps the agent's committed edits (vs:edit-commit / vs:select-module)
8
+ * onto module fields via /api/field. The frame can only ever request the
9
+ * field-edit writes handled here — nothing else crosses the boundary.
6
10
  */
7
11
 
8
12
  const interactBtn = document.getElementById("interact-mode-toggle");
9
13
  let interactModeActive = false;
10
- let interactHandlers = null;
11
-
12
- function ensureInteractStyles(doc) {
13
- if (doc.getElementById("vibespot-interact-css")) return;
14
- const style = doc.createElement("style");
15
- style.id = "vibespot-interact-css";
16
- style.textContent = `
17
- html.vibespot-interact-mode { cursor: default; }
18
- .vibespot-editable-hover {
19
- outline: 2px dashed rgba(59, 130, 246, 0.6) !important;
20
- outline-offset: 2px !important;
21
- cursor: text !important;
22
- }
23
- .vibespot-editable-hover[data-edit-type="image"] {
24
- cursor: pointer !important;
25
- }
26
- .vibespot-editable-hover[data-edit-type="select"] {
27
- outline-color: #e8613a !important;
28
- outline-style: solid !important;
29
- background-color: rgba(232, 97, 58, 0.08) !important;
30
- cursor: crosshair !important;
31
- }
32
- .vibespot-editing {
33
- outline: 2px solid rgba(59, 130, 246, 0.9) !important;
34
- outline-offset: 2px !important;
35
- background-color: rgba(59, 130, 246, 0.04) !important;
36
- min-height: 1em;
37
- }
38
- .vibespot-edit-label {
39
- position: fixed;
40
- z-index: 2147483647;
41
- pointer-events: none;
42
- font: 500 11px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
43
- color: #fff;
44
- padding: 2px 7px;
45
- border-radius: 3px;
46
- box-shadow: 0 2px 6px rgba(0,0,0,0.2);
47
- white-space: nowrap;
48
- }
49
- .vibespot-edit-label--edit {
50
- background: #3b82f6;
51
- }
52
- .vibespot-edit-label--select {
53
- background: #e8613a;
54
- }
55
- .vibespot-image-edit-input {
56
- position: fixed;
57
- z-index: 2147483647;
58
- font: 13px/1.4 -apple-system, BlinkMacSystemFont, sans-serif;
59
- padding: 6px 10px;
60
- border: 2px solid #3b82f6;
61
- border-radius: 6px;
62
- background: #1c1917;
63
- color: #fff;
64
- width: 320px;
65
- outline: none;
66
- }
67
- .vibespot-link-edit-popup {
68
- position: fixed;
69
- z-index: 2147483647;
70
- background: #1c1917;
71
- border: 1px solid rgba(255,255,255,0.15);
72
- border-radius: 8px;
73
- padding: 10px;
74
- box-shadow: 0 4px 16px rgba(0,0,0,0.4);
75
- display: flex;
76
- flex-direction: column;
77
- gap: 6px;
78
- }
79
- .vibespot-link-edit-popup label {
80
- font: 500 11px/1.4 -apple-system, sans-serif;
81
- color: rgba(255,255,255,0.6);
82
- }
83
- .vibespot-link-edit-popup input {
84
- font: 13px/1.4 -apple-system, sans-serif;
85
- padding: 5px 8px;
86
- border: 1px solid rgba(255,255,255,0.2);
87
- border-radius: 4px;
88
- background: #0c0a09;
89
- color: #fff;
90
- outline: none;
91
- width: 260px;
92
- }
93
- .vibespot-link-edit-popup input:focus { border-color: #3b82f6; }
94
- .vibespot-link-edit-popup .vibespot-link-edit-actions {
95
- display: flex; gap: 6px; justify-content: flex-end; margin-top: 4px;
96
- }
97
- .vibespot-link-edit-popup button {
98
- font: 500 12px/1 -apple-system, sans-serif;
99
- padding: 5px 12px;
100
- border: none; border-radius: 4px;
101
- cursor: pointer;
102
- }
103
- .vibespot-link-edit-popup .vibespot-btn-save {
104
- background: #3b82f6; color: #fff;
105
- }
106
- .vibespot-link-edit-popup .vibespot-btn-cancel {
107
- background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.7);
108
- }
109
- `;
110
- doc.head.appendChild(style);
111
- }
112
-
113
- // Determine if element is directly editable (text/image/link) or module-level
114
- function getInteractInfo(el) {
115
- if (!el) return null;
116
- const tag = (el.tagName || "").toLowerCase();
117
-
118
- // Check for explicit field annotations first
119
- const annotated = el.closest("[data-vs-field]");
120
- if (annotated) {
121
- const vsType = annotated.getAttribute("data-vs-type");
122
- if (vsType === "image") return { type: "image", el: annotated, action: "edit" };
123
- if (vsType === "link") return { type: "link", el: annotated, action: "edit" };
124
- return { type: "text", el: annotated, action: "edit" };
125
- }
126
- const linkAnnotated = el.closest("[data-vs-link]");
127
- if (linkAnnotated) return { type: "link", el: linkAnnotated, action: "edit" };
128
-
129
- // Heuristic detection for editable elements
130
- if (tag === "img") return { type: "image", el, action: "edit" };
131
- if (tag === "a" || (el.closest && el.closest("a"))) {
132
- const anchor = tag === "a" ? el : el.closest("a");
133
- return { type: "link", el: anchor, action: "edit" };
134
- }
135
- if (tag.match(/^h[1-6]$/) || tag === "p" || tag === "span" || tag === "li" || tag === "td" || tag === "th") {
136
- if (el.children.length === 0 || (el.children.length === 1 && el.children[0].tagName === "BR")) {
137
- return { type: "text", el, action: "edit" };
138
- }
139
- if (el.textContent && el.textContent.trim().length > 0 && el.childElementCount <= 2) {
140
- return { type: "text", el, action: "edit" };
141
- }
142
- }
143
- if (tag === "button") return { type: "text", el, action: "edit" };
144
-
145
- // Module-level container → select mode (prefill chat)
146
- const moduleEl = el.closest("[data-module]");
147
- if (moduleEl) return { type: "select", el: moduleEl, action: "select" };
148
-
149
- return null;
150
- }
151
-
152
- function describeElement(el) {
153
- if (!el) return "";
154
- const moduleEl = el.closest("[data-module]");
155
- const moduleName = moduleEl ? moduleEl.getAttribute("data-module") : null;
156
- const tag = (el.tagName || "").toLowerCase();
157
- let kind = tag;
158
- if (tag.match(/^h[1-6]$/)) kind = "headline";
159
- else if (tag === "p") kind = "paragraph";
160
- else if (tag === "a") kind = "link";
161
- else if (tag === "button") kind = "button";
162
- else if (tag === "img") kind = "image";
163
- else if (tag === "ul" || tag === "ol") kind = "list";
164
- else if (tag === "li") kind = "list item";
165
-
166
- if (moduleName && moduleEl === el) return moduleName;
167
- if (moduleName) return `${moduleName} > ${kind}`;
168
- return kind;
169
- }
170
-
171
- function buildChatPrefill(el) {
172
- if (!el) return "";
173
- const moduleEl = el.closest("[data-module]");
174
- const moduleName = moduleEl ? moduleEl.getAttribute("data-module") : null;
175
- const tag = (el.tagName || "").toLowerCase();
176
- const text = (el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80);
177
-
178
- let elementPart;
179
- if (tag.match(/^h[1-6]$/)) elementPart = "the headline";
180
- else if (tag === "p") elementPart = "the paragraph";
181
- else if (tag === "a") elementPart = "the link";
182
- else if (tag === "button") elementPart = "the button";
183
- else if (tag === "img") elementPart = "the image";
184
- else elementPart = `the ${tag}`;
185
-
186
- if (moduleEl === el && moduleName) {
187
- return `In the ${moduleName} module, `;
188
- }
189
- if (moduleName) {
190
- const quote = text ? ` ("${text}${text.length === 80 ? "…" : ""}")` : "";
191
- return `In the ${moduleName} module, ${elementPart}${quote} `;
192
- }
193
- return `${elementPart} `;
194
- }
195
-
196
- function attachInteractHandlers() {
197
- let doc;
198
- try {
199
- doc = previewFrame.contentDocument || previewFrame.contentWindow?.document;
200
- } catch { return; }
201
- if (!doc || !doc.body) return;
202
-
203
- ensureInteractStyles(doc);
204
- doc.documentElement.classList.add("vibespot-interact-mode");
205
-
206
- let hoveredEl = null;
207
- let labelEl = null;
208
- let activeEditor = null;
209
-
210
- const cleanup = () => {
211
- if (hoveredEl) {
212
- hoveredEl.classList.remove("vibespot-editable-hover", "vibespot-select-hover");
213
- hoveredEl.removeAttribute("data-edit-type");
214
- hoveredEl = null;
215
- }
216
- if (labelEl && labelEl.parentNode) {
217
- labelEl.parentNode.removeChild(labelEl);
218
- }
219
- labelEl = null;
220
- };
221
-
222
- const onMouseOver = (e) => {
223
- if (activeEditor) return;
224
- const info = getInteractInfo(e.target);
225
- if (!info) {
226
- cleanup();
227
- return;
228
- }
229
- if (hoveredEl === info.el) return;
230
- cleanup();
231
- hoveredEl = info.el;
232
- hoveredEl.classList.add("vibespot-editable-hover");
233
- hoveredEl.setAttribute("data-edit-type", info.action === "select" ? "select" : info.type);
234
-
235
- if (!labelEl) {
236
- labelEl = doc.createElement("div");
237
- labelEl.className = "vibespot-edit-label";
238
- doc.body.appendChild(labelEl);
239
- }
240
-
241
- if (info.action === "select") {
242
- labelEl.className = "vibespot-edit-label vibespot-edit-label--select";
243
- labelEl.textContent = describeElement(info.el);
244
- } else {
245
- labelEl.className = "vibespot-edit-label vibespot-edit-label--edit";
246
- const typeLabel = info.type === "image" ? "Click to edit image" : info.type === "link" ? "Click to edit link" : "Click to edit";
247
- labelEl.textContent = typeLabel;
248
- }
249
- const rect = info.el.getBoundingClientRect();
250
- labelEl.style.top = Math.max(4, rect.top - 20) + "px";
251
- labelEl.style.left = Math.max(4, rect.left) + "px";
252
- };
253
-
254
- const onMouseOut = (e) => {
255
- if (activeEditor) return;
256
- if (!e.relatedTarget) cleanup();
257
- };
258
-
259
- const closeActiveEditor = () => {
260
- if (!activeEditor) return;
261
- if (activeEditor.cleanup) activeEditor.cleanup();
262
- activeEditor = null;
263
- };
264
-
265
- const onClick = (e) => {
266
- e.preventDefault();
267
- e.stopPropagation();
268
-
269
- const info = getInteractInfo(e.target);
270
- if (!info) return;
271
-
272
- closeActiveEditor();
273
- cleanup();
274
-
275
- if (info.action === "select") {
276
- const prefill = buildChatPrefill(info.el);
277
- if (typeof window.prefillChatInput === "function") {
278
- window.prefillChatInput(prefill);
279
- }
280
- return;
281
- }
282
-
283
- const moduleEl = info.el.closest("[data-module]");
284
- if (!moduleEl) return;
285
- const moduleName = moduleEl.getAttribute("data-module");
286
-
287
- if (info.type === "text") {
288
- startTextEdit(doc, info.el, moduleName);
289
- } else if (info.type === "image") {
290
- startImageEdit(doc, info.el, moduleName);
291
- } else if (info.type === "link") {
292
- startLinkEdit(doc, info.el, moduleName);
293
- }
294
- };
295
-
296
- function startTextEdit(doc, el, moduleName) {
297
- const originalText = el.textContent;
298
- el.setAttribute("data-original-text", originalText.trim());
299
- el.setAttribute("contenteditable", "true");
300
- el.classList.add("vibespot-editing");
301
- el.focus();
302
-
303
- const range = doc.createRange();
304
- range.selectNodeContents(el);
305
- const sel = doc.defaultView.getSelection();
306
- sel.removeAllRanges();
307
- sel.addRange(range);
308
-
309
- const save = () => {
310
- el.removeAttribute("contenteditable");
311
- el.classList.remove("vibespot-editing");
312
- const newText = el.textContent.trim();
313
- if (newText !== originalText.trim()) {
314
- saveTextChange(moduleName, el, newText);
315
- }
316
- activeEditor = null;
317
- };
318
-
319
- el.addEventListener("blur", save, { once: true });
320
- el.addEventListener("keydown", (e) => {
321
- if (e.key === "Enter" && !e.shiftKey) {
322
- e.preventDefault();
323
- el.blur();
324
- }
325
- if (e.key === "Escape") {
326
- el.textContent = originalText;
327
- el.blur();
328
- }
329
- });
330
-
331
- activeEditor = {
332
- cleanup: () => {
333
- el.removeAttribute("contenteditable");
334
- el.classList.remove("vibespot-editing");
335
- },
336
- };
337
- }
338
-
339
- function startImageEdit(doc, imgEl, moduleName) {
340
- const rect = imgEl.getBoundingClientRect();
341
- const input = doc.createElement("input");
342
- input.className = "vibespot-image-edit-input";
343
- input.type = "text";
344
- input.placeholder = "Enter image URL...";
345
- input.value = imgEl.src || "";
346
- input.style.top = (rect.bottom + 4) + "px";
347
- input.style.left = Math.max(4, rect.left) + "px";
348
- doc.body.appendChild(input);
349
- input.focus();
350
- input.select();
351
-
352
- const save = () => {
353
- const newSrc = input.value.trim();
354
- if (input.parentNode) input.parentNode.removeChild(input);
355
- if (newSrc && newSrc !== imgEl.src) {
356
- imgEl.src = newSrc;
357
- saveImageChange(moduleName, imgEl, newSrc);
358
- }
359
- activeEditor = null;
360
- };
361
-
362
- input.addEventListener("blur", save, { once: true });
363
- input.addEventListener("keydown", (e) => {
364
- if (e.key === "Enter") { e.preventDefault(); input.blur(); }
365
- if (e.key === "Escape") {
366
- if (input.parentNode) input.parentNode.removeChild(input);
367
- activeEditor = null;
368
- }
369
- });
370
-
371
- activeEditor = {
372
- cleanup: () => { if (input.parentNode) input.parentNode.removeChild(input); },
373
- };
374
- }
375
-
376
- function startLinkEdit(doc, anchorEl, moduleName) {
377
- const origText = (anchorEl.textContent || "").trim();
378
- const origHref = anchorEl.href || "";
379
- const rect = anchorEl.getBoundingClientRect();
380
-
381
- const popup = doc.createElement("div");
382
- popup.className = "vibespot-link-edit-popup";
383
- popup.style.top = (rect.bottom + 6) + "px";
384
- popup.style.left = Math.max(4, rect.left) + "px";
385
-
386
- const textLabel = doc.createElement("label");
387
- textLabel.textContent = "Link text";
388
- const textInput = doc.createElement("input");
389
- textInput.type = "text";
390
- textInput.className = "vibespot-link-text";
391
- textInput.value = origText;
392
-
393
- const urlLabel = doc.createElement("label");
394
- urlLabel.textContent = "URL";
395
- const urlInput = doc.createElement("input");
396
- urlInput.type = "text";
397
- urlInput.className = "vibespot-link-url";
398
- urlInput.value = origHref;
399
-
400
- const actions = doc.createElement("div");
401
- actions.className = "vibespot-link-edit-actions";
402
- const cancelBtn = doc.createElement("button");
403
- cancelBtn.className = "vibespot-btn-cancel";
404
- cancelBtn.textContent = "Cancel";
405
- const saveBtn = doc.createElement("button");
406
- saveBtn.className = "vibespot-btn-save";
407
- saveBtn.textContent = "Save";
408
- actions.appendChild(cancelBtn);
409
- actions.appendChild(saveBtn);
410
-
411
- popup.appendChild(textLabel);
412
- popup.appendChild(textInput);
413
- popup.appendChild(urlLabel);
414
- popup.appendChild(urlInput);
415
- popup.appendChild(actions);
416
- doc.body.appendChild(popup);
417
- textInput.focus();
418
-
419
- const close = () => {
420
- if (popup.parentNode) popup.parentNode.removeChild(popup);
421
- activeEditor = null;
422
- };
423
-
424
- cancelBtn.addEventListener("click", close);
425
- saveBtn.addEventListener("click", () => {
426
- const newText = textInput.value.trim();
427
- const newUrl = urlInput.value.trim();
428
- if (newText) anchorEl.textContent = newText;
429
- if (newUrl) anchorEl.href = newUrl;
430
- if (newText !== origText || newUrl !== origHref) {
431
- saveLinkChange(moduleName, anchorEl, newText, newUrl);
432
- }
433
- close();
434
- });
435
-
436
- popup.addEventListener("keydown", (e) => {
437
- if (e.key === "Escape") close();
438
- if (e.key === "Enter") {
439
- e.preventDefault();
440
- saveBtn.click();
441
- }
442
- });
443
-
444
- activeEditor = { cleanup: close };
445
- }
446
-
447
- const onKeyDown = (e) => {
448
- if (e.key === "Escape" && !activeEditor) deactivateInteractMode();
449
- };
450
-
451
- doc.addEventListener("mouseover", onMouseOver, true);
452
- doc.addEventListener("mouseout", onMouseOut, true);
453
- doc.addEventListener("click", onClick, true);
454
- doc.addEventListener("keydown", onKeyDown, true);
455
-
456
- interactHandlers = {
457
- doc,
458
- onMouseOver,
459
- onMouseOut,
460
- onClick,
461
- onKeyDown,
462
- cleanup,
463
- closeActiveEditor,
464
- };
465
- }
466
-
467
- function detachInteractHandlers() {
468
- if (!interactHandlers) return;
469
- const { doc, onMouseOver, onMouseOut, onClick, onKeyDown, cleanup, closeActiveEditor } = interactHandlers;
470
- try {
471
- closeActiveEditor();
472
- cleanup();
473
- doc.documentElement.classList.remove("vibespot-interact-mode");
474
- doc.removeEventListener("mouseover", onMouseOver, true);
475
- doc.removeEventListener("mouseout", onMouseOut, true);
476
- doc.removeEventListener("click", onClick, true);
477
- doc.removeEventListener("keydown", onKeyDown, true);
478
- } catch { /* cross-origin */ }
479
- interactHandlers = null;
480
- }
481
14
 
482
15
  function activateInteractMode() {
483
16
  if (interactModeActive) return;
@@ -485,7 +18,7 @@ function activateInteractMode() {
485
18
  if (interactBtn) interactBtn.setAttribute("aria-pressed", "true");
486
19
  const previewContainer = document.getElementById("preview-container");
487
20
  if (previewContainer) previewContainer.classList.add("preview--interact-mode");
488
- attachInteractHandlers();
21
+ setPreviewMode("interact");
489
22
  }
490
23
 
491
24
  function deactivateInteractMode() {
@@ -494,7 +27,7 @@ function deactivateInteractMode() {
494
27
  if (interactBtn) interactBtn.setAttribute("aria-pressed", "false");
495
28
  const previewContainer = document.getElementById("preview-container");
496
29
  if (previewContainer) previewContainer.classList.remove("preview--interact-mode");
497
- detachInteractHandlers();
30
+ setPreviewMode("view");
498
31
  }
499
32
 
500
33
  function setInteractModeDisabled(disabled) {
@@ -515,18 +48,45 @@ if (interactBtn) {
515
48
  });
516
49
  }
517
50
 
518
- if (typeof previewFrame !== "undefined" && previewFrame) {
519
- previewFrame.addEventListener("load", () => {
520
- if (interactModeActive) attachInteractHandlers();
521
- });
522
- }
523
-
524
51
  // Backward-compat aliases
525
52
  window.setEditModeDisabled = setInteractModeDisabled;
526
53
  window.deactivateEditMode = deactivateInteractMode;
527
54
  window.setSelectModeDisabled = setInteractModeDisabled;
528
55
  window.deactivateSelectMode = deactivateInteractMode;
529
56
 
57
+ // ---------------------------------------------------------------------------
58
+ // Agent messages: module select -> chat prefill; edit commits -> field saves
59
+ // ---------------------------------------------------------------------------
60
+
61
+ // The agent may *request* dropping back to view mode (Esc in-frame with no
62
+ // editor open). Only the exit request is honoured — a compromised frame can
63
+ // lower its privileges, never raise them — and the toolbar flip re-issues
64
+ // vs:set-mode, so the parent stays authoritative over the mode.
65
+ onPreviewMessage("vs:request-mode", (p) => {
66
+ if (!p || p.mode !== "view") return;
67
+ deactivateInteractMode();
68
+ });
69
+
70
+ onPreviewMessage("vs:select-module", (p) => {
71
+ if (typeof p.prefill !== "string") return;
72
+ if (typeof window.prefillChatInput === "function") {
73
+ // Preview content is AI-authored — cap the prefill so a hostile page
74
+ // can't stuff the composer.
75
+ window.prefillChatInput(p.prefill.slice(0, 400));
76
+ }
77
+ });
78
+
79
+ onPreviewMessage("vs:edit-commit", (p) => {
80
+ if (!p || typeof p.moduleName !== "string" || !p.moduleName) return;
81
+ if (p.kind === "text" && typeof p.value === "string") {
82
+ saveTextChange(p.moduleName, p);
83
+ } else if (p.kind === "image" && typeof p.value === "string") {
84
+ saveImageChange(p.moduleName, p);
85
+ } else if (p.kind === "link") {
86
+ saveLinkChange(p.moduleName, p);
87
+ }
88
+ });
89
+
530
90
  // ---------------------------------------------------------------------------
531
91
  // Save helpers — find matching field in fields.json and update via /api/field
532
92
  // ---------------------------------------------------------------------------
@@ -560,40 +120,55 @@ function findFieldByText(fields, originalText, tag, prefix = "") {
560
120
  return null;
561
121
  }
562
122
 
563
- function findImageField(fields, imgSrc, prefix = "") {
123
+ function collectFieldsOfType(fields, type, prefix = "", out = []) {
564
124
  for (const field of fields) {
565
125
  const path = prefix ? `${prefix}.${field.name}` : field.name;
566
126
  if (field.type === "group" && field.children) {
567
- const found = findImageField(field.children, imgSrc, path);
568
- if (found) return found;
569
- }
570
- if (field.type === "image" && field.default?.src) {
571
- return { path, field };
127
+ collectFieldsOfType(field.children, type, path, out);
128
+ continue;
572
129
  }
130
+ if (field.type === type) out.push({ path, field });
573
131
  }
574
- return null;
132
+ return out;
575
133
  }
576
134
 
577
- function findLinkField(fields, href, prefix = "") {
578
- for (const field of fields) {
579
- const path = prefix ? `${prefix}.${field.name}` : field.name;
580
- if (field.type === "group" && field.children) {
581
- const found = findLinkField(field.children, href, path);
582
- if (found) return found;
583
- }
584
- if (field.type === "link") return { path, field };
585
- }
586
- return null;
135
+ // The agent reports the DOM's absolutized URL (possibly with a preview access
136
+ // token in the query) while field defaults are often relative — compare
137
+ // without query/hash and accept a suffix match either way.
138
+ function urlLikeMatches(a, b) {
139
+ a = String(a || "").split(/[?#]/)[0];
140
+ b = String(b || "").split(/[?#]/)[0];
141
+ if (!a || !b) return false;
142
+ return a === b || a.endsWith(b) || b.endsWith(a);
587
143
  }
588
144
 
589
- async function saveTextChange(moduleName, el, newText) {
590
- const fieldPath = el.getAttribute("data-vs-field");
591
- if (fieldPath) {
592
- await fetch("/api/field", {
593
- method: "POST",
594
- headers: { "Content-Type": "application/json" },
595
- body: JSON.stringify({ moduleName, fieldPath, value: newText }),
596
- });
145
+ /** Match the edited image by its pre-edit src so editing the 2nd image can't
146
+ * overwrite the 1st (VIB-1898). Falls back to a lone image field only. */
147
+ function findImageField(fields, imgSrc) {
148
+ const candidates = collectFieldsOfType(fields, "image");
149
+ const bySrc = candidates.find((c) => urlLikeMatches(c.field.default?.src || "", imgSrc || ""));
150
+ if (bySrc) return bySrc;
151
+ return candidates.length === 1 ? candidates[0] : null;
152
+ }
153
+
154
+ /** Same per-href matching for link fields (VIB-1898). */
155
+ function findLinkField(fields, href) {
156
+ const candidates = collectFieldsOfType(fields, "link");
157
+ const byHref = candidates.find((c) => urlLikeMatches(c.field.default?.url?.href || "", href || ""));
158
+ if (byHref) return byHref;
159
+ return candidates.length === 1 ? candidates[0] : null;
160
+ }
161
+
162
+ function postField(moduleName, fieldPath, value) {
163
+ // Unified save path (field-save.js); the commit handlers refresh the
164
+ // preview once per commit rather than once per POST.
165
+ return saveField(moduleName, fieldPath, value, { refresh: false });
166
+ }
167
+
168
+ /** p: { fieldPath?, originalText, tag, value } from vs:edit-commit. */
169
+ async function saveTextChange(moduleName, p) {
170
+ if (typeof p.fieldPath === "string" && p.fieldPath) {
171
+ await postField(moduleName, p.fieldPath, p.value);
597
172
  refreshPreview();
598
173
  return;
599
174
  }
@@ -601,30 +176,20 @@ async function saveTextChange(moduleName, el, newText) {
601
176
  const data = await findModuleFields(moduleName);
602
177
  if (!data) { refreshPreview(); return; }
603
178
 
604
- const originalText = el.getAttribute("data-original-text") || newText;
605
- const match = findFieldByText(data.fields, originalText, el.tagName.toLowerCase());
606
-
179
+ const originalText = typeof p.originalText === "string" && p.originalText ? p.originalText : p.value;
180
+ const match = findFieldByText(data.fields, originalText, p.tag || "");
607
181
  if (match) {
608
- await fetch("/api/field", {
609
- method: "POST",
610
- headers: { "Content-Type": "application/json" },
611
- body: JSON.stringify({ moduleName, fieldPath: match.path, value: newText }),
612
- });
182
+ await postField(moduleName, match.path, p.value);
613
183
  }
614
184
  refreshPreview();
615
185
  }
616
186
 
617
- async function saveImageChange(moduleName, imgEl, newSrc) {
618
- const fieldPath = imgEl.getAttribute("data-vs-field");
619
- if (fieldPath) {
620
- await fetch("/api/field", {
621
- method: "POST",
622
- headers: { "Content-Type": "application/json" },
623
- body: JSON.stringify({
624
- moduleName,
625
- fieldPath,
626
- value: { src: newSrc, alt: imgEl.getAttribute("alt") || "" },
627
- }),
187
+ /** p: { fieldPath?, alt?, oldSrc?, value } from vs:edit-commit. */
188
+ async function saveImageChange(moduleName, p) {
189
+ if (typeof p.fieldPath === "string" && p.fieldPath) {
190
+ await postField(moduleName, p.fieldPath, {
191
+ src: p.value,
192
+ alt: typeof p.alt === "string" ? p.alt : "",
628
193
  });
629
194
  refreshPreview();
630
195
  return;
@@ -633,43 +198,33 @@ async function saveImageChange(moduleName, imgEl, newSrc) {
633
198
  const data = await findModuleFields(moduleName);
634
199
  if (!data) { refreshPreview(); return; }
635
200
 
636
- const match = findImageField(data.fields, imgEl.src);
201
+ const match = findImageField(data.fields, p.oldSrc || "");
637
202
  if (match) {
638
- await fetch("/api/field", {
639
- method: "POST",
640
- headers: { "Content-Type": "application/json" },
641
- body: JSON.stringify({
642
- moduleName,
643
- fieldPath: match.path,
644
- value: { src: newSrc, alt: match.field.default?.alt || "" },
645
- }),
203
+ await postField(moduleName, match.path, {
204
+ src: p.value,
205
+ alt: match.field.default?.alt || "",
646
206
  });
647
207
  }
648
208
  refreshPreview();
649
209
  }
650
210
 
651
- async function saveLinkChange(moduleName, anchorEl, newText, newUrl) {
652
- const linkField = anchorEl.getAttribute("data-vs-link");
653
- const textField = anchorEl.getAttribute("data-vs-field");
211
+ /** p: { linkField?, textField?, origText?, origHref?, newText?, newUrl? }. */
212
+ async function saveLinkChange(moduleName, p) {
213
+ const newText = typeof p.newText === "string" ? p.newText : "";
214
+ const newUrl = typeof p.newUrl === "string" ? p.newUrl : "";
215
+ const linkField = typeof p.linkField === "string" ? p.linkField : "";
216
+ const textField = typeof p.textField === "string" ? p.textField : "";
654
217
 
655
218
  if (linkField || textField) {
656
219
  if (linkField && newUrl) {
657
- await fetch("/api/field", {
658
- method: "POST",
659
- headers: { "Content-Type": "application/json" },
660
- body: JSON.stringify({
661
- moduleName,
662
- fieldPath: linkField,
663
- value: { url: { href: newUrl, type: "EXTERNAL" }, open_in_new_tab: false, no_follow: false },
664
- }),
220
+ await postField(moduleName, linkField, {
221
+ url: { href: newUrl, type: "EXTERNAL" },
222
+ open_in_new_tab: false,
223
+ no_follow: false,
665
224
  });
666
225
  }
667
226
  if (textField && newText) {
668
- await fetch("/api/field", {
669
- method: "POST",
670
- headers: { "Content-Type": "application/json" },
671
- body: JSON.stringify({ moduleName, fieldPath: textField, value: newText }),
672
- });
227
+ await postField(moduleName, textField, newText);
673
228
  }
674
229
  refreshPreview();
675
230
  return;
@@ -678,31 +233,22 @@ async function saveLinkChange(moduleName, anchorEl, newText, newUrl) {
678
233
  const data = await findModuleFields(moduleName);
679
234
  if (!data) { refreshPreview(); return; }
680
235
 
681
- const match = findLinkField(data.fields, anchorEl.href);
236
+ // Only write the URL when the user actually changed it — a text-only edit
237
+ // must not blank the stored href (VIB-1898).
238
+ const match = newUrl && newUrl !== (p.origHref || "") ? findLinkField(data.fields, p.origHref || "") : null;
682
239
  if (match) {
683
- await fetch("/api/field", {
684
- method: "POST",
685
- headers: { "Content-Type": "application/json" },
686
- body: JSON.stringify({
687
- moduleName,
688
- fieldPath: match.path,
689
- value: {
690
- url: { href: newUrl, type: "EXTERNAL" },
691
- open_in_new_tab: match.field.default?.open_in_new_tab ?? false,
692
- no_follow: match.field.default?.no_follow ?? false,
693
- },
694
- }),
240
+ await postField(moduleName, match.path, {
241
+ url: { href: newUrl, type: "EXTERNAL" },
242
+ open_in_new_tab: match.field.default?.open_in_new_tab ?? false,
243
+ no_follow: match.field.default?.no_follow ?? false,
695
244
  });
696
245
  }
697
246
 
698
247
  if (newText) {
699
- const textMatch = findFieldByText(data.fields, anchorEl.textContent, "a");
248
+ // Match the pre-edit text so the right field default is found.
249
+ const textMatch = findFieldByText(data.fields, p.origText || newText, "a");
700
250
  if (textMatch) {
701
- await fetch("/api/field", {
702
- method: "POST",
703
- headers: { "Content-Type": "application/json" },
704
- body: JSON.stringify({ moduleName, fieldPath: textMatch.path, value: newText }),
705
- });
251
+ await postField(moduleName, textMatch.path, newText);
706
252
  }
707
253
  }
708
254