@accelerated-agency/visual-editor 0.2.4 → 0.2.6

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.
package/dist/vite.cjs CHANGED
@@ -627,7 +627,7 @@ select.pr-inp{cursor:pointer;background:#fff}
627
627
  </div>
628
628
  <!-- btn-close: hidden visually, kept for JS event listener -->
629
629
  <button id="btn-close" style="display:none" title="Close editor"></button>
630
- <button class="tb-sim-btn" id="btn-simulate"><i class="bi bi-lightning-charge-fill"></i> Simulate</button>
630
+ <button class="tb-sim-btn" id="btn-simulate" onclick="simulateExperiment()"><i class="bi bi-lightning-charge-fill"></i> Simulate</button>
631
631
  <button class="tb-fin-btn" id="btn-save">Finalize</button>
632
632
  </div>
633
633
 
@@ -850,6 +850,7 @@ select.pr-inp{cursor:pointer;background:#fff}
850
850
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/inputs.js"></script>
851
851
  <!-- components.js defines shared colour-class arrays used by bootstrap5/widgets components -->
852
852
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components.js"></script>
853
+ <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-html.js"></script>
853
854
  <script>
854
855
  /* Safety stub: if components.js didn't define these, create empty arrays so
855
856
  components-bootstrap5.js doesn't throw ReferenceError on load. */
@@ -863,6 +864,18 @@ if (typeof colorSelectOptions === 'undefined') window.colorSelectOptions =
863
864
  if (typeof textColorSelectOptions=== 'undefined') window.textColorSelectOptions= [];
864
865
  if (typeof borderSelectOptions === 'undefined') window.borderSelectOptions = [];
865
866
  if (typeof sizeSelectOptions === 'undefined') window.sizeSelectOptions = [];
867
+ if (window.Vvveb && window.Vvveb.Components) {
868
+ if (!window.Vvveb.ComponentsGroup) window.Vvveb.ComponentsGroup = {};
869
+ if (!window.Vvveb.ComponentsGroup['Bootstrap 5']) window.Vvveb.ComponentsGroup['Bootstrap 5'] = [];
870
+ try {
871
+ var baseExists =
872
+ window.Vvveb.Components._components &&
873
+ Object.prototype.hasOwnProperty.call(window.Vvveb.Components._components, '_base');
874
+ if (!baseExists && typeof window.Vvveb.Components.add === 'function') {
875
+ window.Vvveb.Components.add('_base', { name: 'Base', properties: [] });
876
+ }
877
+ } catch(_) {}
878
+ }
866
879
  </script>
867
880
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-bootstrap5.js"></script>
868
881
  <script src="https://cdn.jsdelivr.net/gh/givanz/VvvebJs@master/libs/builder/components-widgets.js"></script>
@@ -901,6 +914,52 @@ function send(type, payload) {
901
914
  window.parent.postMessage({ channel: CHANNEL, type: type, payload: payload || {} }, '*');
902
915
  }
903
916
 
917
+ function generatePreviewUrlString(args) {
918
+ var baseUrl = (args && args.url) || '';
919
+ var test = (args && args.test) || {};
920
+ var variation = (args && args.variation) || {};
921
+ if (!baseUrl) return '';
922
+ var testId = test.iid || test.experimentId || test._id || '';
923
+ var variationId = variation.iid || variation._id || '';
924
+ var cId = String(testId || '') + '_' + String(variationId || '');
925
+ var hasQueryParams = String(baseUrl).indexOf('?') >= 0;
926
+ return (
927
+ baseUrl +
928
+ (hasQueryParams ? '&' : '?') +
929
+ 'codebase_debug=true&cId=' +
930
+ encodeURIComponent(cId)
931
+ );
932
+ }
933
+
934
+ function simulateExperiment() {
935
+ var test = experimentData || {};
936
+ var activeVariation = null;
937
+ if (Array.isArray(variations) && variations.length) {
938
+ activeVariation =
939
+ variations.find(function(v) { return v && v._id === activeVarId; }) ||
940
+ variations[0];
941
+ }
942
+ var targetUrl =
943
+ (Array.isArray(test.urltargeting) && test.urltargeting[0]) ||
944
+ test.pageUrl ||
945
+ (test.metadata_1 && test.metadata_1.editor_url) ||
946
+ '';
947
+ var url = generatePreviewUrlString({
948
+ url: targetUrl,
949
+ test: test,
950
+ variation: activeVariation || {},
951
+ });
952
+ if (!url) {
953
+ console.warn('[V2] simulateExperiment: missing target URL');
954
+ return;
955
+ }
956
+ try {
957
+ window.open(url, '_blank');
958
+ } catch(err) {
959
+ console.warn('[V2] simulateExperiment:', err);
960
+ }
961
+ }
962
+
904
963
  window.addEventListener('message', function(e) {
905
964
  if (!e.data || e.data.channel !== CHANNEL) return;
906
965
  switch (e.data.type) {
@@ -914,6 +973,8 @@ var experimentData = null;
914
973
  var variations = [];
915
974
  var activeVarId = null;
916
975
  var varHtmlCache = {};
976
+ /** Per-variation chain rows from structural actions (insert/duplicate/delete/reorder/hide), merged on Finalize. */
977
+ var sessionStructuralChainRowsByVarId = {};
917
978
  /** Last iframe proxy URL we navigated to \u2014 used to skip redundant reloads when parent re-sends load-experiment */
918
979
  var lastLoadedProxyUrl = '';
919
980
  /** API changeset rows (excluding __vvveb_body__) reapplied until selectors match late-hydrated DOM */
@@ -927,6 +988,8 @@ var iframeContentNavGen = 0;
927
988
  var iframeContentApplyTimer = null;
928
989
  var iframeEarlyGranularPrimedForGen = null;
929
990
  var iframeEarlySyncPrimedForGen = null;
991
+ /** insert/reorder entries are applied from early granular + full apply \u2014 skip exact duplicates per iframe nav */
992
+ var appliedStructuralChangesetKeys = {};
930
993
  var isDirty = false;
931
994
  var vvvebReady = false;
932
995
  var currentMode = 'editor';
@@ -946,6 +1009,15 @@ var selectionResizeBound = false;
946
1009
  var clickAttachDoc = null;
947
1010
  var changeObserver = null;
948
1011
  var changeObserverDoc = null;
1012
+ /** Incremented while applying changesets / selection chrome so MutationObserver does not mark dirty */
1013
+ var suppressIframeMutationDirty = 0;
1014
+ /** Throttled reconcile timer to keep DOM aligned with saved changesets. */
1015
+ var consistencyReconcileTimer = null;
1016
+ /** Background watchdog for late client-side re-renders that wipe applied changesets. */
1017
+ var consistencyWatchTimer = null;
1018
+ var consistencyWatchDoc = null;
1019
+ var consistencyWatchTicks = 0;
1020
+ var CONSISTENCY_WATCH_MAX_TICKS = 160;
949
1021
  /** { doc, onRS } \u2014 iframe document readystate until complete */
950
1022
  var iframeDocLoadingListeners = null;
951
1023
  // Each entry: {selector, label, cssProp, value, targetEl}
@@ -953,12 +1025,89 @@ var iframeDocLoadingListeners = null;
953
1025
  var stateChanges = [];
954
1026
  /** Pre-apply DOM snapshots for granular + body changesets (used when removing History rows) */
955
1027
  var appliedChangesetSnapshots = {};
1028
+ /** Canonical JSON fingerprints of persisted changesets per variation (last load / finalize) */
1029
+ var baselineChangesetsByVarId = {};
1030
+
1031
+ // \u2500\u2500 Dirty tracking (compare DB baseline + session stateChanges vs current export) \u2500\u2500
1032
+ function beginSuppressIframeMutationDirty() {
1033
+ suppressIframeMutationDirty += 1;
1034
+ }
1035
+
1036
+ function endSuppressIframeMutationDirty() {
1037
+ suppressIframeMutationDirty = Math.max(0, suppressIframeMutationDirty - 1);
1038
+ }
1039
+
1040
+ /** Stable stringify of a variation's changesets field (string or array from API). */
1041
+ function fingerprintChangesetsField(raw) {
1042
+ if (raw == null) return '[]';
1043
+ if (Array.isArray(raw)) {
1044
+ try {
1045
+ return JSON.stringify(raw);
1046
+ } catch(_) {
1047
+ return '[]';
1048
+ }
1049
+ }
1050
+ if (typeof raw !== 'string') return '[]';
1051
+ var s = raw.trim();
1052
+ if (!s) return '[]';
1053
+ try {
1054
+ var p = JSON.parse(s);
1055
+ return JSON.stringify(Array.isArray(p) ? p : []);
1056
+ } catch(_) {
1057
+ return '[]';
1058
+ }
1059
+ }
1060
+
1061
+ function captureBaselineFromVariations(list) {
1062
+ baselineChangesetsByVarId = {};
1063
+ if (!list || !list.length) return;
1064
+ for (var i = 0; i < list.length; i++) {
1065
+ var v = list[i];
1066
+ if (!v || !v._id) continue;
1067
+ baselineChangesetsByVarId[v._id] = fingerprintChangesetsField(v.changesets);
1068
+ }
1069
+ }
1070
+
1071
+ /** Fingerprint of what Finalize would send for this variation (matches buildPersistedChainSetsForVariation). */
1072
+ function persistedExportFingerprintForVariation(v) {
1073
+ if (!v || !v._id) return '[]';
1074
+ try {
1075
+ var rows = buildPersistedChainSetsForVariation(v);
1076
+ return JSON.stringify(rows || []);
1077
+ } catch(_) {
1078
+ return '[]';
1079
+ }
1080
+ }
1081
+
1082
+ function setEditorDirty(dirty) {
1083
+ var was = isDirty;
1084
+ isDirty = !!dirty;
1085
+ var dot = document.getElementById('dirty-dot');
1086
+ if (dot) dot.classList.toggle('on', isDirty);
1087
+ if (isDirty && !was) send('mutations-changed', {});
1088
+ if (!isDirty && was) send('editor-dirty', { dirty: false });
1089
+ if (!isDirty) {
1090
+ savedAt = Date.now();
1091
+ updateSaveTime();
1092
+ }
1093
+ }
956
1094
 
957
- // \u2500\u2500 Dirty tracking \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
958
- function markDirty() {
959
- isDirty = true;
960
- document.getElementById('dirty-dot').classList.add('on');
961
- send('mutations-changed', {});
1095
+ function recomputeEditorDirty() {
1096
+ var d = stateChanges.length > 0;
1097
+ if (!d && variations && variations.length) {
1098
+ for (var i = 0; i < variations.length; i++) {
1099
+ var v = variations[i];
1100
+ var vid = v._id;
1101
+ var cur = persistedExportFingerprintForVariation(v);
1102
+ var base = baselineChangesetsByVarId[vid];
1103
+ if (base == null) base = '[]';
1104
+ if (cur !== base) {
1105
+ d = true;
1106
+ break;
1107
+ }
1108
+ }
1109
+ }
1110
+ setEditorDirty(d);
962
1111
  }
963
1112
  var savedAt = null;
964
1113
  function updateSaveTime() {
@@ -968,12 +1117,6 @@ function updateSaveTime() {
968
1117
  el.textContent = s < 60 ? s + 's ago' : Math.floor(s / 60) + 'm ago';
969
1118
  }
970
1119
  setInterval(updateSaveTime, 10000);
971
- function markClean() {
972
- isDirty = false;
973
- savedAt = Date.now();
974
- document.getElementById('dirty-dot').classList.remove('on');
975
- updateSaveTime();
976
- }
977
1120
 
978
1121
  // \u2500\u2500 Mode toggle \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
979
1122
  function setMode(mode) {
@@ -1150,6 +1293,7 @@ function logChange(selector, inputId, value, targetEl, originalValue) {
1150
1293
  if (idx >= 0) { stateChanges[idx] = entry; } else { stateChanges.push(entry); }
1151
1294
  }
1152
1295
  if (currentMainTab === 'states') renderStatesTab();
1296
+ recomputeEditorDirty();
1153
1297
  }
1154
1298
 
1155
1299
  function renderStatesTab() {
@@ -1183,7 +1327,7 @@ function renderStatesTab() {
1183
1327
 
1184
1328
  // Resolve a live DOM element for a state-change entry.
1185
1329
  // Tries the stored direct reference first; if it's detached or missing,
1186
- // falls back to querySelector(selector) inside the iframe document.
1330
+ // falls back to querySelector (with .vve-* class stripped) inside the iframe document.
1187
1331
  function resolveChangeEl(change) {
1188
1332
  try {
1189
1333
  var iframeDoc = document.getElementById('iframeId').contentDocument;
@@ -1193,7 +1337,7 @@ function resolveChangeEl(change) {
1193
1337
  return change.targetEl;
1194
1338
  }
1195
1339
  // Fallback: re-query by the stored CSS selector
1196
- return iframeDoc.querySelector(change.selector) || null;
1340
+ return querySelectorResolved(iframeDoc, change.selector);
1197
1341
  } catch (e) {
1198
1342
  console.warn('[V2] resolveChangeEl:', e);
1199
1343
  return null;
@@ -1256,7 +1400,7 @@ function removeStateChange(idx) {
1256
1400
  syncDesignInput(change);
1257
1401
  stateChanges.splice(idx, 1);
1258
1402
  renderStatesTab();
1259
- markDirty();
1403
+ recomputeEditorDirty();
1260
1404
  }
1261
1405
 
1262
1406
  function clearAllStates() {
@@ -1266,7 +1410,7 @@ function clearAllStates() {
1266
1410
  });
1267
1411
  stateChanges = [];
1268
1412
  renderStatesTab();
1269
- markDirty();
1413
+ recomputeEditorDirty();
1270
1414
  }
1271
1415
 
1272
1416
  // \u2500\u2500 History tab (saved changesets from DB for active variation) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -1291,10 +1435,11 @@ function persistActiveVariationChangesets(arr) {
1291
1435
 
1292
1436
  function entrySnapshotKey(entry) {
1293
1437
  if (!entry || !entry.selector) return '';
1438
+ var selKey = sanitizeSelectorForMatch(entry.selector) || entry.selector;
1294
1439
  return (
1295
- entry.selector +
1440
+ selKey +
1296
1441
  '\0' +
1297
- (entry.type || '') +
1442
+ normalizeChangesetType(entry) +
1298
1443
  '\0' +
1299
1444
  String(entry.property || '') +
1300
1445
  '\0' +
@@ -1312,7 +1457,7 @@ function captureChangesetSnapshotBeforeApply(entry, el, iframeDoc) {
1312
1457
  if (!entry || !el || entry.selector === '__vvveb_body__') return;
1313
1458
  var k = entrySnapshotKey(entry);
1314
1459
  if (appliedChangesetSnapshots[k]) return;
1315
- switch (entry.type) {
1460
+ switch (normalizeChangesetType(entry)) {
1316
1461
  case 'content':
1317
1462
  if (entry.html != null) {
1318
1463
  appliedChangesetSnapshots[k] = { kind: 'innerHTML', v: el.innerHTML };
@@ -1380,10 +1525,7 @@ function revertChangesetEntryOnDom(entry) {
1380
1525
  if (!iframeDoc) return false;
1381
1526
  var k = entrySnapshotKey(entry);
1382
1527
  var snap = appliedChangesetSnapshots[k];
1383
- var el = null;
1384
- try {
1385
- el = iframeDoc.querySelector(entry.selector);
1386
- } catch(_) {}
1528
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1387
1529
  if (!snap || !el) {
1388
1530
  softReloadEditorIframe();
1389
1531
  delete appliedChangesetSnapshots[k];
@@ -1410,7 +1552,7 @@ function revertChangesetEntryOnDom(entry) {
1410
1552
  function historyEntryTypeLabel(entry) {
1411
1553
  if (!entry) return 'Change';
1412
1554
  if (entry.selector === '__vvveb_body__') return 'Full page HTML';
1413
- var t = (entry.type || '').toLowerCase();
1555
+ var t = normalizeChangesetType(entry);
1414
1556
  if (t === 'content') return entry.html != null ? 'Inner HTML' : 'Text / content';
1415
1557
  if (t === 'style') return 'Style: ' + (entry.property || '');
1416
1558
  if (t === 'attribute') return 'Attribute: ' + (entry.attribute || '');
@@ -1424,7 +1566,8 @@ function historyEntryValuePreview(entry) {
1424
1566
  if (entry.selector === '__vvveb_body__') return '(body snapshot)';
1425
1567
  if (entry.html != null) return String(entry.html).slice(0, 120);
1426
1568
  if (entry.value != null) return String(entry.value).slice(0, 120);
1427
- if (entry.type === 'style' || entry.type === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1569
+ var nt = normalizeChangesetType(entry);
1570
+ if (nt === 'style' || nt === 'attribute') return String(entry.value != null ? entry.value : '').slice(0, 120);
1428
1571
  return '';
1429
1572
  }
1430
1573
 
@@ -1458,6 +1601,9 @@ function renderHistoryTab() {
1458
1601
  var val = historyEntryValuePreview(item.entry);
1459
1602
  html +=
1460
1603
  '<div class="state-item">' +
1604
+ '<span class="state-item-idx" style="opacity:0.55;font-size:10px;min-width:2.2em;display:inline-block" title="Order in saved changesets">#' +
1605
+ item.idx +
1606
+ '</span>' +
1461
1607
  '<span class="state-item-label">' +
1462
1608
  esc(lab) +
1463
1609
  '</span>' +
@@ -1466,7 +1612,9 @@ function renderHistoryTab() {
1466
1612
  '">' +
1467
1613
  esc(val) +
1468
1614
  '</span>' +
1469
- '<button type="button" class="state-remove" title="Remove from saved changesets" onclick="removeHistoryChangeset(' +
1615
+ '<button type="button" class="state-remove" title="Remove this saved row (#' +
1616
+ item.idx +
1617
+ ')" onclick="removeHistoryChangeset(' +
1470
1618
  item.idx +
1471
1619
  ')">&#x2715;</button>' +
1472
1620
  '</div>';
@@ -1476,6 +1624,16 @@ function renderHistoryTab() {
1476
1624
  container.innerHTML = html;
1477
1625
  }
1478
1626
 
1627
+ function changesetListHasStructural(arr) {
1628
+ if (!arr || !arr.length) return false;
1629
+ for (var i = 0; i < arr.length; i++) {
1630
+ var e = arr[i];
1631
+ var t = normalizeChangesetType(e);
1632
+ if (e && (t === 'insert' || t === 'reorder')) return true;
1633
+ }
1634
+ return false;
1635
+ }
1636
+
1479
1637
  function removeHistoryChangeset(idx) {
1480
1638
  var v = getActiveVariationForHistory();
1481
1639
  if (!v) return;
@@ -1488,18 +1646,31 @@ function removeHistoryChangeset(idx) {
1488
1646
  try {
1489
1647
  delete varHtmlCache[activeVarId];
1490
1648
  } catch(_) {}
1491
- if (!didReload) {
1649
+ // Re-applying remaining rows on top of current DOM duplicates insert/reorder nodes; reload when any
1650
+ // structural row remains or was removed (revert may already have started a reload for insert/body).
1651
+ var removedType = normalizeChangesetType(removed);
1652
+ var needsStructuralReload =
1653
+ !didReload &&
1654
+ (removedType === 'insert' ||
1655
+ removedType === 'reorder' ||
1656
+ changesetListHasStructural(arr));
1657
+ if (didReload) {
1658
+ /* revertChangesetEntryOnDom already kicked off iframe reload */
1659
+ } else if (needsStructuralReload) {
1660
+ softReloadEditorIframe();
1661
+ } else {
1492
1662
  try {
1663
+ appliedStructuralChangesetKeys = {};
1493
1664
  applyActiveVariationHtml();
1494
1665
  registerPendingGranularChangesets(
1495
1666
  arr,
1496
1667
  document.getElementById('iframeId').contentDocument,
1497
1668
  );
1669
+ saveCurrentVariationHtml();
1498
1670
  } catch(_) {}
1499
- saveCurrentVariationHtml();
1500
1671
  }
1501
1672
  if (currentMainTab === 'history') renderHistoryTab();
1502
- markDirty();
1673
+ recomputeEditorDirty();
1503
1674
  scheduleDomTreeRefresh();
1504
1675
  }
1505
1676
 
@@ -1509,15 +1680,82 @@ function clearAllHistoryChangesets() {
1509
1680
  if (!parseVariationChangesets(v).length) return;
1510
1681
  persistActiveVariationChangesets([]);
1511
1682
  appliedChangesetSnapshots = {};
1683
+ appliedStructuralChangesetKeys = {};
1512
1684
  try {
1513
1685
  delete varHtmlCache[activeVarId];
1514
1686
  } catch(_) {}
1515
1687
  if (currentMainTab === 'history') renderHistoryTab();
1516
- markDirty();
1688
+ recomputeEditorDirty();
1517
1689
  scheduleDomTreeRefresh();
1518
1690
  softReloadEditorIframe();
1519
1691
  }
1520
1692
 
1693
+ // \u2500\u2500 Persisted active variation (survives iframe / full page reload) \u2500\u2500\u2500\u2500\u2500\u2500\u2500
1694
+ /** All Visual Editor iframe keys in localStorage use this prefix \u2014 cleared on close. */
1695
+ var VVE_LOCAL_STORAGE_PREFIX = 'vve:';
1696
+
1697
+ function clearVisualEditorLocalStorage() {
1698
+ try {
1699
+ for (var i = localStorage.length - 1; i >= 0; i--) {
1700
+ var k = localStorage.key(i);
1701
+ if (k && k.indexOf(VVE_LOCAL_STORAGE_PREFIX) === 0) {
1702
+ localStorage.removeItem(k);
1703
+ }
1704
+ }
1705
+ } catch(_) {}
1706
+ }
1707
+
1708
+ function activeVariationStorageKeyFromPayload(data) {
1709
+ return (
1710
+ VVE_LOCAL_STORAGE_PREFIX +
1711
+ 'activeVar:' +
1712
+ String((data && data.experimentId) || '') +
1713
+ ':' +
1714
+ String((data && data.pageUrl) || '')
1715
+ );
1716
+ }
1717
+
1718
+ function readPersistedActiveVariationId(data) {
1719
+ try {
1720
+ var sk = activeVariationStorageKeyFromPayload(data);
1721
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return null;
1722
+ return localStorage.getItem(sk);
1723
+ } catch(_) {
1724
+ return null;
1725
+ }
1726
+ }
1727
+
1728
+ function writePersistedActiveVariationId(varId) {
1729
+ try {
1730
+ if (!experimentData || !experimentData.experimentId) return;
1731
+ var sk =
1732
+ VVE_LOCAL_STORAGE_PREFIX +
1733
+ 'activeVar:' +
1734
+ String(experimentData.experimentId || '') +
1735
+ ':' +
1736
+ String(experimentData.pageUrl || '');
1737
+ if (sk === VVE_LOCAL_STORAGE_PREFIX + 'activeVar::') return;
1738
+ if (varId) localStorage.setItem(sk, String(varId));
1739
+ } catch(_) {}
1740
+ }
1741
+
1742
+ /**
1743
+ * @param allowPrevMemory when true, keep in-session activeVarId if still valid (skip-reload path).
1744
+ */
1745
+ function pickActiveVariationIdForLoad(data, variationsArr, prevMemoryId, allowPrevMemory) {
1746
+ var baseline = variationsArr.find(function(v) { return v.baseline; });
1747
+ var fallback = (baseline || variationsArr[0] || {})._id || null;
1748
+ if (!variationsArr.length) return null;
1749
+ if (allowPrevMemory && prevMemoryId && variationsArr.some(function(v) { return v._id === prevMemoryId; })) {
1750
+ return prevMemoryId;
1751
+ }
1752
+ var stored = readPersistedActiveVariationId(data);
1753
+ if (stored && variationsArr.some(function(v) { return v._id === stored; })) {
1754
+ return stored;
1755
+ }
1756
+ return fallback;
1757
+ }
1758
+
1521
1759
  // \u2500\u2500 Experiment loading \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1522
1760
  function handleLoadExperiment(data) {
1523
1761
  clearPendingGranularChangesets();
@@ -1545,10 +1783,8 @@ function handleLoadExperiment(data) {
1545
1783
  experimentData = data;
1546
1784
  variations = Array.isArray(data.variations) ? data.variations : [];
1547
1785
  var prevActive = activeVarId;
1548
- var baseline = variations.find(function(v) { return v.baseline; });
1549
- var fallback = (baseline || variations[0] || {})._id || null;
1550
- activeVarId =
1551
- prevActive && variations.some(function(v) { return v._id === prevActive; }) ? prevActive : fallback;
1786
+ activeVarId = pickActiveVariationIdForLoad(data, variations, prevActive, true);
1787
+ writePersistedActiveVariationId(activeVarId);
1552
1788
  renderVariationTabs();
1553
1789
  var urlBarSkip = document.getElementById('url-bar');
1554
1790
  urlBarSkip.textContent = pageUrl;
@@ -1562,24 +1798,30 @@ function handleLoadExperiment(data) {
1562
1798
  if (ifrSkip && ifrSkip.contentDocument) attachIframeLoadingUntilComplete(ifrSkip);
1563
1799
  } catch(_) {}
1564
1800
  if (currentMainTab === 'history') renderHistoryTab();
1801
+ captureBaselineFromVariations(variations);
1802
+ recomputeEditorDirty();
1565
1803
  return;
1566
1804
  }
1567
1805
 
1568
1806
  if (!experimentData || prevKey !== nextKey) {
1569
1807
  varHtmlCache = {};
1808
+ sessionStructuralChainRowsByVarId = {};
1570
1809
  appliedChangesetSnapshots = {};
1810
+ appliedStructuralChangesetKeys = {};
1571
1811
  }
1572
1812
  experimentData = data;
1573
1813
  variations = Array.isArray(data.variations) ? data.variations : [];
1574
- // New document load: start on baseline so the first paint matches the live page.
1575
- var baseline = variations.find(function(v) { return v.baseline; });
1576
- activeVarId = (baseline || variations[0] || {})._id || null;
1814
+ var sameExpPage = prevKey === nextKey;
1815
+ activeVarId = pickActiveVariationIdForLoad(data, variations, activeVarId, sameExpPage);
1816
+ writePersistedActiveVariationId(activeVarId);
1577
1817
  renderVariationTabs();
1578
1818
 
1579
1819
  var urlBar = document.getElementById('url-bar');
1580
1820
  urlBar.textContent = pageUrl;
1581
1821
  urlBar.title = pageUrl;
1582
1822
  if (currentMainTab === 'history') renderHistoryTab();
1823
+ captureBaselineFromVariations(variations);
1824
+ recomputeEditorDirty();
1583
1825
  loadPage(proxyUrl);
1584
1826
  }
1585
1827
 
@@ -1679,9 +1921,7 @@ function granularAnySelectorMatches(doc, cs) {
1679
1921
  if (!doc || !cs || !cs.length) return false;
1680
1922
  var g = filterGranularChangesetEntries(cs);
1681
1923
  for (var i = 0; i < g.length; i++) {
1682
- try {
1683
- if (doc.querySelector(g[i].selector)) return true;
1684
- } catch(_) {}
1924
+ if (querySelectorResolved(doc, g[i].selector)) return true;
1685
1925
  }
1686
1926
  return false;
1687
1927
  }
@@ -1702,7 +1942,7 @@ function appendIframeReloadBust(url) {
1702
1942
  // applying variation changesets there is then wiped when the real navigation commits.
1703
1943
  function iframeDocMatchesNavigatedSrc(iframe, doc) {
1704
1944
  if (!iframe || !doc) return false;
1705
- var src = iframe.src || iframe.getAttribute('src') || '';
1945
+ var src = iframe.getAttribute('src') || iframe.src || '';
1706
1946
  if (!src || src === 'about:blank') return false;
1707
1947
  var loc = '';
1708
1948
  try {
@@ -1711,12 +1951,28 @@ function iframeDocMatchesNavigatedSrc(iframe, doc) {
1711
1951
  return false;
1712
1952
  }
1713
1953
  if (!loc || loc === 'about:blank') return false;
1714
- var rmSrc = src.match(/__ve_reload=([0-9]+)/);
1715
- if (rmSrc) return loc.indexOf('__ve_reload=' + rmSrc[1]) !== -1;
1716
1954
  try {
1717
1955
  var base = window.location.href;
1718
1956
  var su = new URL(src, base);
1957
+ if (su.searchParams && su.searchParams.has('__ve_reload')) {
1958
+ su.searchParams.delete('__ve_reload');
1959
+ }
1719
1960
  var du = new URL(loc, base);
1961
+ if (du.searchParams && du.searchParams.has('__ve_reload')) {
1962
+ du.searchParams.delete('__ve_reload');
1963
+ }
1964
+ // Same-origin proxy that keeps document address aligned with iframe src
1965
+ if (su.origin === du.origin && su.pathname + su.search === du.pathname + du.search) {
1966
+ return true;
1967
+ }
1968
+ // conversion-proxy: iframe src stays on our app, but doc.URL is usually the target site after redirects
1969
+ var p = su.pathname || '';
1970
+ var isRootProxyPath = p === '/api/conversion-proxy' || p.indexOf('/api/conversion-proxy/') === 0;
1971
+ var isNestedMalformedProxy = !isRootProxyPath && p.indexOf('api/conversion-proxy') !== -1;
1972
+ if (isNestedMalformedProxy) return false;
1973
+ if (isRootProxyPath || String(su.href).indexOf('conversion-proxy') !== -1) {
1974
+ return doc === iframe.contentDocument;
1975
+ }
1720
1976
  return su.pathname + su.search === du.pathname + du.search;
1721
1977
  } catch(_) {
1722
1978
  return false;
@@ -1745,10 +2001,73 @@ function clearPendingGranularChangesets() {
1745
2001
  }
1746
2002
  }
1747
2003
 
2004
+ function stopConsistencyWatchdog() {
2005
+ if (consistencyWatchTimer) {
2006
+ clearInterval(consistencyWatchTimer);
2007
+ consistencyWatchTimer = null;
2008
+ }
2009
+ consistencyWatchDoc = null;
2010
+ consistencyWatchTicks = 0;
2011
+ }
2012
+
2013
+ function runConsistencyReconcile() {
2014
+ if (!activeVarId) return;
2015
+ var iframe = document.getElementById('iframeId');
2016
+ var doc = iframe && iframe.contentDocument;
2017
+ if (!doc || !doc.body) return;
2018
+ var variation = variations.find(function(v) { return v._id === activeVarId; });
2019
+ var cs = parseVariationChangesets(variation);
2020
+ if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2021
+ var granular = filterGranularChangesetEntries(cs);
2022
+ var unresolved = countUnresolvedGranularSelectors(doc, granular);
2023
+ if (unresolved > 0 || changesetListHasStructural(cs)) {
2024
+ reapplyActiveVariationGranular(doc);
2025
+ registerPendingGranularChangesets(cs, doc);
2026
+ }
2027
+ }
2028
+
2029
+ function scheduleConsistencyReconcile() {
2030
+ if (consistencyReconcileTimer) return;
2031
+ consistencyReconcileTimer = setTimeout(function() {
2032
+ consistencyReconcileTimer = null;
2033
+ runConsistencyReconcile();
2034
+ }, 80);
2035
+ }
2036
+
2037
+ function startConsistencyWatchdog(doc) {
2038
+ if (!doc || !doc.body) return;
2039
+ if (consistencyWatchDoc === doc && consistencyWatchTimer) return;
2040
+ stopConsistencyWatchdog();
2041
+ consistencyWatchDoc = doc;
2042
+ consistencyWatchTicks = 0;
2043
+ consistencyWatchTimer = setInterval(function() {
2044
+ if (consistencyWatchDoc !== doc) {
2045
+ stopConsistencyWatchdog();
2046
+ return;
2047
+ }
2048
+ var iframe = document.getElementById('iframeId');
2049
+ if (!iframe || iframe.contentDocument !== doc || !doc.body) {
2050
+ stopConsistencyWatchdog();
2051
+ return;
2052
+ }
2053
+ consistencyWatchTicks += 1;
2054
+ scheduleConsistencyReconcile();
2055
+ if (consistencyWatchTicks >= CONSISTENCY_WATCH_MAX_TICKS) {
2056
+ stopConsistencyWatchdog();
2057
+ }
2058
+ }, 400);
2059
+ }
2060
+
1748
2061
  function resetIframeBindings() {
1749
2062
  detachIframeLoadingListeners();
1750
2063
  stopIframeContentApplyWatcher();
2064
+ stopConsistencyWatchdog();
2065
+ if (consistencyReconcileTimer) {
2066
+ clearTimeout(consistencyReconcileTimer);
2067
+ consistencyReconcileTimer = null;
2068
+ }
1751
2069
  appliedChangesetSnapshots = {};
2070
+ appliedStructuralChangesetKeys = {};
1752
2071
  clickAttachDoc = null;
1753
2072
  dragAttachDoc = null;
1754
2073
  changeObserverDoc = null;
@@ -1819,13 +2138,19 @@ function switchVariation(varId) {
1819
2138
  saveCurrentVariationHtml();
1820
2139
  clearPendingGranularChangesets();
1821
2140
  activeVarId = varId;
2141
+ writePersistedActiveVariationId(varId);
1822
2142
  renderVariationTabs();
1823
2143
  deselectElement();
1824
2144
  try {
1825
2145
  var iframe = document.getElementById('iframeId');
1826
2146
  var saved = varHtmlCache[varId];
1827
2147
  if (saved) {
1828
- iframe.contentDocument.body.innerHTML = saved;
2148
+ beginSuppressIframeMutationDirty();
2149
+ try {
2150
+ iframe.contentDocument.body.innerHTML = saved;
2151
+ } finally {
2152
+ endSuppressIframeMutationDirty();
2153
+ }
1829
2154
  detachIframeLoadingListeners();
1830
2155
  setIframePageLoadingUi(false);
1831
2156
  syncIframeInteractions('switch-variation-cache');
@@ -1848,6 +2173,7 @@ function switchVariation(varId) {
1848
2173
  }
1849
2174
  } catch(_) {}
1850
2175
  if (currentMainTab === 'history') renderHistoryTab();
2176
+ recomputeEditorDirty();
1851
2177
  }
1852
2178
 
1853
2179
  function saveCurrentVariationHtml() {
@@ -1886,31 +2212,123 @@ function parseVariationChangesets(variation) {
1886
2212
  }
1887
2213
  }
1888
2214
 
2215
+ /** Lowercase entry.type so persisted / API rows match switches (e.g. Insert vs insert). */
2216
+ function normalizeChangesetType(entry) {
2217
+ return String(entry && entry.type != null ? entry.type : '').toLowerCase();
2218
+ }
2219
+
1889
2220
  function filterGranularChangesetEntries(cs) {
1890
2221
  if (!cs || !cs.length) return [];
1891
2222
  var out = [];
1892
2223
  for (var i = 0; i < cs.length; i++) {
1893
2224
  var e = cs[i];
1894
- if (e && e.selector && e.selector !== '__vvveb_body__') out.push(e);
2225
+ if (!e || !e.selector || e.selector === '__vvveb_body__') continue;
2226
+ out.push(e);
2227
+ }
2228
+ return out;
2229
+ }
2230
+
2231
+ /** Dedup key for merging chain-set rows (overlay wins over base). */
2232
+ function chainSetDedupKey(entry) {
2233
+ if (!entry || !entry.selector) return '';
2234
+ var t = normalizeChangesetType(entry);
2235
+ if (t === 'style') return entry.selector + '|s|' + String(entry.property || '');
2236
+ if (t === 'content') return entry.selector + '|c|' + (entry.html != null ? 'h' : 't');
2237
+ if (t === 'attribute') return entry.selector + '|a|' + String(entry.attribute || '');
2238
+ if (t === 'insert') return entry.selector + '|i|' + String(entry.action || '') + '|' + String(entry.html || '').slice(0, 120);
2239
+ if (t === 'remove') return entry.selector + '|r|';
2240
+ if (t === 'reorder') {
2241
+ return entry.selector + '|ro|' + String(entry.targetSelector || '') + '|' + String(entry.action || '');
2242
+ }
2243
+ try {
2244
+ return entry.selector + '|' + t + '|' + JSON.stringify(entry);
2245
+ } catch(_) {
2246
+ return entry.selector + '|' + t;
2247
+ }
2248
+ }
2249
+
2250
+ function mergeGranularChainSets(baseList, overlayList) {
2251
+ var map = {};
2252
+ var order = [];
2253
+ function ingest(arr) {
2254
+ if (!arr || !arr.length) return;
2255
+ for (var i = 0; i < arr.length; i++) {
2256
+ var e = arr[i];
2257
+ if (!e || !e.selector) continue;
2258
+ var k = chainSetDedupKey(e);
2259
+ if (!map[k]) order.push(k);
2260
+ map[k] = e;
2261
+ }
2262
+ }
2263
+ ingest(baseList);
2264
+ ingest(overlayList);
2265
+ var out = [];
2266
+ for (var j = 0; j < order.length; j++) {
2267
+ out.push(map[order[j]]);
1895
2268
  }
1896
2269
  return out;
1897
2270
  }
1898
2271
 
2272
+ function appendSessionStructuralChainRow(varId, row) {
2273
+ if (!varId || !row) return;
2274
+ if (!sessionStructuralChainRowsByVarId[varId]) sessionStructuralChainRowsByVarId[varId] = [];
2275
+ sessionStructuralChainRowsByVarId[varId].push(row);
2276
+ }
2277
+
2278
+ /** One States-tab row -> Conversion.io chain-set shape (matches applyChangesetEntry). */
2279
+ function stateChangeToChainSet(c) {
2280
+ if (!c || !c.selector) return null;
2281
+ if (c.cssProp) {
2282
+ return { selector: c.selector, type: 'style', property: c.cssProp, value: c.value };
2283
+ }
2284
+ switch (c.inputId) {
2285
+ case 'pp-text':
2286
+ return { selector: c.selector, type: 'content', value: c.value };
2287
+ case 'pp-html':
2288
+ return { selector: c.selector, type: 'content', html: c.value };
2289
+ case 'pp-cls':
2290
+ return { selector: c.selector, type: 'attribute', attribute: 'class', value: c.value };
2291
+ case 'pp-id':
2292
+ return { selector: c.selector, type: 'attribute', attribute: 'id', value: c.value };
2293
+ case 'pp-href':
2294
+ return { selector: c.selector, type: 'attribute', attribute: 'href', value: c.value };
2295
+ case 'pp-target':
2296
+ return { selector: c.selector, type: 'attribute', attribute: 'target', value: c.value };
2297
+ case 'pp-src':
2298
+ return { selector: c.selector, type: 'attribute', attribute: 'src', value: c.value };
2299
+ case 'pp-alt':
2300
+ return { selector: c.selector, type: 'attribute', attribute: 'alt', value: c.value };
2301
+ case 'pp-ph':
2302
+ return { selector: c.selector, type: 'attribute', attribute: 'placeholder', value: c.value };
2303
+ case 'pp-css':
2304
+ return { selector: c.selector, type: 'attribute', attribute: 'style', value: c.value };
2305
+ case 'pp-mob-css':
2306
+ return { selector: c.selector, type: 'attribute', attribute: 'data-mobile-css', value: c.value };
2307
+ case 'pp-tab-css':
2308
+ return { selector: c.selector, type: 'attribute', attribute: 'data-tablet-css', value: c.value };
2309
+ default:
2310
+ return null;
2311
+ }
2312
+ }
2313
+
1899
2314
  function countUnresolvedGranularSelectors(iframeDoc, entries) {
1900
2315
  if (!iframeDoc || !entries || !entries.length) return 0;
1901
2316
  var n = 0;
1902
2317
  for (var i = 0; i < entries.length; i++) {
1903
- var el = null;
1904
- try { el = iframeDoc.querySelector(entries[i].selector); } catch(_) {}
1905
- if (!el) n++;
2318
+ if (!querySelectorResolved(iframeDoc, entries[i].selector)) n++;
1906
2319
  }
1907
2320
  return n;
1908
2321
  }
1909
2322
 
1910
2323
  function applyGranularChangesetEntries(iframeDoc, entries) {
1911
2324
  if (!iframeDoc || !entries || !entries.length) return;
1912
- for (var i = 0; i < entries.length; i++) {
1913
- applyChangesetEntry(entries[i], iframeDoc);
2325
+ beginSuppressIframeMutationDirty();
2326
+ try {
2327
+ for (var i = 0; i < entries.length; i++) {
2328
+ applyChangesetEntry(entries[i], iframeDoc);
2329
+ }
2330
+ } finally {
2331
+ endSuppressIframeMutationDirty();
1914
2332
  }
1915
2333
  }
1916
2334
 
@@ -1965,6 +2383,30 @@ function flushPendingGranularChangesets() {
1965
2383
  scheduleGranularChangesetReapply();
1966
2384
  }
1967
2385
 
2386
+ /** Stable key for structural changesets (insert/reorder) to avoid double-apply across early + full paint. */
2387
+ function structuralChangesetDedupKey(entry) {
2388
+ var nt = normalizeChangesetType(entry);
2389
+ if (!entry || (nt !== 'insert' && nt !== 'reorder')) return '';
2390
+ var vid = activeVarId || '';
2391
+ try {
2392
+ return (
2393
+ vid +
2394
+ '\0' +
2395
+ nt +
2396
+ '\0' +
2397
+ entry.selector +
2398
+ '\0' +
2399
+ String(entry.action || '') +
2400
+ '\0' +
2401
+ String(entry.html != null ? entry.html : '').slice(0, 240) +
2402
+ '\0' +
2403
+ String(entry.targetSelector || '')
2404
+ );
2405
+ } catch(_) {
2406
+ return vid + '\0' + nt + '\0' + entry.selector;
2407
+ }
2408
+ }
2409
+
1968
2410
  /**
1969
2411
  * Apply a single changeset entry inside the editor iframe.
1970
2412
  * Backend format: { selector, type: 'content'|'style'|'attribute'|'insert'|'remove', ... }
@@ -1984,21 +2426,34 @@ function applyChangesetEntry(entry, iframeDoc) {
1984
2426
  return;
1985
2427
  }
1986
2428
 
2429
+ var structuralDedupeKey = structuralChangesetDedupKey(entry);
2430
+ if (structuralDedupeKey && appliedStructuralChangesetKeys[structuralDedupeKey]) return;
2431
+
1987
2432
  // \u2500\u2500 Standard granular changeset \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
1988
- var el = null;
1989
- try { el = iframeDoc.querySelector(entry.selector); } catch(_) {}
2433
+ var el = querySelectorResolved(iframeDoc, entry.selector);
1990
2434
  if (!el) return;
1991
2435
 
1992
2436
  captureChangesetSnapshotBeforeApply(entry, el, iframeDoc);
1993
2437
 
1994
- switch (entry.type) {
2438
+ switch (normalizeChangesetType(entry)) {
1995
2439
  case 'content':
1996
2440
  if (entry.html != null) el.innerHTML = entry.html;
1997
2441
  else if (entry.value != null) el.textContent = entry.value;
1998
2442
  break;
1999
2443
  case 'style':
2000
- if (entry.property && entry.value != null) {
2001
- el.style[camelize(entry.property)] = entry.value;
2444
+ if (entry.property) {
2445
+ var propKebab = entry.property;
2446
+ var cam = camelize(propKebab);
2447
+ if (entry.value == null || entry.value === '') {
2448
+ try { el.style.removeProperty(propKebab); } catch(_) {}
2449
+ try { if (cam in el.style) el.style[cam] = ''; } catch(__) {}
2450
+ } else {
2451
+ try {
2452
+ el.style.setProperty(propKebab, entry.value, 'important');
2453
+ } catch(_) {
2454
+ el.style[cam] = entry.value;
2455
+ }
2456
+ }
2002
2457
  }
2003
2458
  break;
2004
2459
  case 'attribute':
@@ -2009,11 +2464,23 @@ function applyChangesetEntry(entry, iframeDoc) {
2009
2464
  case 'insert': {
2010
2465
  var pos = entry.action === 'before' ? 'beforebegin' : 'afterend';
2011
2466
  if (entry.html) el.insertAdjacentHTML(pos, entry.html);
2467
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2012
2468
  break;
2013
2469
  }
2014
2470
  case 'remove':
2015
2471
  el.style.display = 'none';
2016
2472
  break;
2473
+ case 'reorder': {
2474
+ var target = entry.targetSelector ? querySelectorResolved(iframeDoc, entry.targetSelector) : null;
2475
+ if (!target || !el.parentNode || !target.parentNode) break;
2476
+ if (entry.action === 'before') {
2477
+ target.parentNode.insertBefore(el, target);
2478
+ } else {
2479
+ target.parentNode.insertBefore(el, target.nextSibling);
2480
+ }
2481
+ if (structuralDedupeKey) appliedStructuralChangesetKeys[structuralDedupeKey] = true;
2482
+ break;
2483
+ }
2017
2484
  default: break;
2018
2485
  }
2019
2486
  }
@@ -2028,19 +2495,24 @@ function applyActiveVariationHtml() {
2028
2495
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2029
2496
  var cs = parseVariationChangesets(variation);
2030
2497
 
2031
- // If we have an in-session HTML snapshot, use it (user edited in this session)
2032
- var saved = varHtmlCache[activeVarId];
2033
- if (saved) {
2034
- try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2035
- return;
2036
- }
2498
+ beginSuppressIframeMutationDirty();
2499
+ try {
2500
+ // If we have an in-session HTML snapshot, use it (user edited in this session)
2501
+ var saved = varHtmlCache[activeVarId];
2502
+ if (saved) {
2503
+ try { iframeDoc.body.innerHTML = saved; } catch(_) {}
2504
+ return;
2505
+ }
2037
2506
 
2038
- if (!cs.length) return;
2039
- for (var i = 0; i < cs.length; i++) {
2040
- applyChangesetEntry(cs[i], iframeDoc);
2507
+ if (!cs.length) return;
2508
+ for (var i = 0; i < cs.length; i++) {
2509
+ applyChangesetEntry(cs[i], iframeDoc);
2510
+ }
2511
+ // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2512
+ registerPendingGranularChangesets(cs, iframeDoc);
2513
+ } finally {
2514
+ endSuppressIframeMutationDirty();
2041
2515
  }
2042
- // Selectors often miss on first paint (client-rendered sections). Retry via timer + mutations.
2043
- registerPendingGranularChangesets(cs, iframeDoc);
2044
2516
  }
2045
2517
 
2046
2518
  function changesetsHaveBodySnapshot(cs) {
@@ -2051,6 +2523,23 @@ function changesetsHaveBodySnapshot(cs) {
2051
2523
  return false;
2052
2524
  }
2053
2525
 
2526
+ /** Rows to persist for this variation on Finalize (same chain-set model as EditorShell \u2014 never __vvveb_body__). */
2527
+ function buildPersistedChainSetsForVariation(v) {
2528
+ if (!v || !v._id) return [];
2529
+ var parsed = parseVariationChangesets(v);
2530
+ var base = filterGranularChangesetEntries(parsed);
2531
+ var sessionExtra = sessionStructuralChainRowsByVarId[v._id] || [];
2532
+ if (v._id !== activeVarId) {
2533
+ return mergeGranularChainSets(base, sessionExtra);
2534
+ }
2535
+ var overlay = [];
2536
+ for (var si = 0; si < stateChanges.length; si++) {
2537
+ var row = stateChangeToChainSet(stateChanges[si]);
2538
+ if (row) overlay.push(row);
2539
+ }
2540
+ return mergeGranularChainSets(mergeGranularChainSets(base, sessionExtra), overlay);
2541
+ }
2542
+
2054
2543
  /**
2055
2544
  * While document.readyState === 'loading', apply only granular changesets (no __vvveb_body__
2056
2545
  * replacement) so the first painted nodes can receive edits before iframe/window load completes.
@@ -2061,10 +2550,15 @@ function applyVariationGranularOnly(iframeDoc) {
2061
2550
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2062
2551
  var cs = parseVariationChangesets(variation);
2063
2552
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2064
- for (var i = 0; i < cs.length; i++) {
2065
- applyChangesetEntry(cs[i], iframeDoc);
2553
+ beginSuppressIframeMutationDirty();
2554
+ try {
2555
+ for (var i = 0; i < cs.length; i++) {
2556
+ applyChangesetEntry(cs[i], iframeDoc);
2557
+ }
2558
+ registerPendingGranularChangesets(cs, iframeDoc);
2559
+ } finally {
2560
+ endSuppressIframeMutationDirty();
2066
2561
  }
2067
- registerPendingGranularChangesets(cs, iframeDoc);
2068
2562
  }
2069
2563
 
2070
2564
  /** Re-try granular entries without resetting pending registration (use between poll ticks). */
@@ -2074,8 +2568,13 @@ function reapplyActiveVariationGranular(iframeDoc) {
2074
2568
  var variation = variations.find(function(v) { return v._id === activeVarId; });
2075
2569
  var cs = parseVariationChangesets(variation);
2076
2570
  if (!cs.length || changesetsHaveBodySnapshot(cs)) return;
2077
- for (var i = 0; i < cs.length; i++) {
2078
- applyChangesetEntry(cs[i], iframeDoc);
2571
+ beginSuppressIframeMutationDirty();
2572
+ try {
2573
+ for (var i = 0; i < cs.length; i++) {
2574
+ applyChangesetEntry(cs[i], iframeDoc);
2575
+ }
2576
+ } finally {
2577
+ endSuppressIframeMutationDirty();
2079
2578
  }
2080
2579
  }
2081
2580
 
@@ -2137,9 +2636,14 @@ function startIframeContentApplyWatcher(navGen) {
2137
2636
 
2138
2637
  // \u2500\u2500 Element selection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2139
2638
  function selectElement(el) {
2140
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2141
- selectedEl = el;
2142
- try { el.classList.add('vve-selected'); } catch(_) {}
2639
+ beginSuppressIframeMutationDirty();
2640
+ try {
2641
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} }
2642
+ selectedEl = el;
2643
+ try { el.classList.add('vve-selected'); } catch(_) {}
2644
+ } finally {
2645
+ endSuppressIframeMutationDirty();
2646
+ }
2143
2647
  document.getElementById('bc-path').textContent = buildSelector(el);
2144
2648
  document.getElementById('bc-path').style.color = 'var(--accent-txt)';
2145
2649
  document.getElementById('no-sel').style.display = 'none';
@@ -2154,7 +2658,12 @@ function selectElement(el) {
2154
2658
 
2155
2659
  function deselectElement() {
2156
2660
  setDragHandleActive(false);
2157
- if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2661
+ beginSuppressIframeMutationDirty();
2662
+ try {
2663
+ if (selectedEl) { try { selectedEl.classList.remove('vve-selected'); } catch(_) {} selectedEl = null; }
2664
+ } finally {
2665
+ endSuppressIframeMutationDirty();
2666
+ }
2158
2667
  document.getElementById('no-sel').style.display = '';
2159
2668
  document.getElementById('el-info').style.display = 'none';
2160
2669
  document.getElementById('rp-accordion').style.display = 'none';
@@ -2178,18 +2687,27 @@ function injectIframeSelectionStyles(doc) {
2178
2687
  'html.vve-drag-armed .vve-selected{cursor:grab!important;}' +
2179
2688
  '.vve-dragging{opacity:0.92!important;outline:2px dashed #f59e0b!important;' +
2180
2689
  'outline-offset:2px!important;cursor:grabbing!important;box-shadow:none!important;}';
2181
- doc.head.appendChild(st);
2690
+ beginSuppressIframeMutationDirty();
2691
+ try {
2692
+ doc.head.appendChild(st);
2693
+ } finally {
2694
+ endSuppressIframeMutationDirty();
2695
+ }
2182
2696
  }
2183
2697
 
2184
2698
  function setDragHandleActive(on) {
2185
2699
  dragHandleActive = !!on;
2186
2700
  var b = document.getElementById('sf-drag');
2187
2701
  if (b) b.classList.toggle('active', dragHandleActive);
2702
+ beginSuppressIframeMutationDirty();
2188
2703
  try {
2189
2704
  var iframe = document.getElementById('iframeId');
2190
2705
  var d = iframe && iframe.contentDocument && iframe.contentDocument.documentElement;
2191
2706
  if (d) d.classList.toggle('vve-drag-armed', dragHandleActive);
2192
- } catch(_) {}
2707
+ } catch(_) {
2708
+ } finally {
2709
+ endSuppressIframeMutationDirty();
2710
+ }
2193
2711
  }
2194
2712
 
2195
2713
  function positionSelectionToolbar() {
@@ -2259,34 +2777,72 @@ function selectElementFromTree(el) {
2259
2777
 
2260
2778
  function duplicateSelectedEl() {
2261
2779
  if (!selectedEl || !selectedEl.parentNode) return;
2780
+ var anchorSel = buildSelector(selectedEl);
2262
2781
  var clone = selectedEl.cloneNode(true);
2263
2782
  clone.classList.remove('vve-selected');
2264
2783
  var all = clone.querySelectorAll ? clone.querySelectorAll('.vve-selected') : [];
2265
2784
  for (var i = 0; i < all.length; i++) all[i].classList.remove('vve-selected');
2266
2785
  clone.style.visibility = '';
2267
2786
  if (clone.removeAttribute) clone.removeAttribute('data-vve-hidden');
2787
+ stripDataVveInstanceSubtree(clone);
2788
+ try {
2789
+ if (clone.id) clone.removeAttribute('id');
2790
+ } catch(_) {}
2791
+ try {
2792
+ clone.setAttribute('data-vve-instance', generateVveInstanceId());
2793
+ } catch(_) {}
2794
+ if (activeVarId) {
2795
+ appendSessionStructuralChainRow(activeVarId, {
2796
+ selector: anchorSel,
2797
+ type: 'insert',
2798
+ action: 'after',
2799
+ html: clone.outerHTML,
2800
+ });
2801
+ }
2268
2802
  selectedEl.parentNode.insertBefore(clone, selectedEl.nextSibling);
2269
- markDirty();
2803
+ saveCurrentVariationHtml();
2804
+ recomputeEditorDirty();
2270
2805
  scheduleDomTreeRefresh();
2271
2806
  selectElement(clone);
2272
2807
  }
2273
2808
 
2274
2809
  function toggleHideSelectedEl() {
2275
2810
  if (!selectedEl) return;
2811
+ var hidSel = buildSelector(selectedEl);
2276
2812
  if (selectedEl.getAttribute('data-vve-hidden') === '1') {
2277
2813
  selectedEl.style.visibility = '';
2278
2814
  selectedEl.removeAttribute('data-vve-hidden');
2815
+ if (activeVarId) {
2816
+ appendSessionStructuralChainRow(activeVarId, {
2817
+ selector: hidSel,
2818
+ type: 'style',
2819
+ property: 'visibility',
2820
+ value: '',
2821
+ });
2822
+ }
2279
2823
  } else {
2280
2824
  selectedEl.style.visibility = 'hidden';
2281
2825
  selectedEl.setAttribute('data-vve-hidden', '1');
2826
+ if (activeVarId) {
2827
+ appendSessionStructuralChainRow(activeVarId, {
2828
+ selector: hidSel,
2829
+ type: 'style',
2830
+ property: 'visibility',
2831
+ value: 'hidden',
2832
+ });
2833
+ }
2282
2834
  }
2283
- markDirty();
2835
+ saveCurrentVariationHtml();
2836
+ recomputeEditorDirty();
2284
2837
  }
2285
2838
 
2286
2839
  function deleteSelectedEl() {
2287
2840
  if (!selectedEl || !selectedEl.parentNode) return;
2841
+ var delSel = buildSelector(selectedEl);
2288
2842
  selectedEl.remove();
2289
- markDirty();
2843
+ if (activeVarId) appendSessionStructuralChainRow(activeVarId, { selector: delSel, type: 'remove' });
2844
+ saveCurrentVariationHtml();
2845
+ recomputeEditorDirty();
2290
2846
  deselectElement();
2291
2847
  scheduleDomTreeRefresh();
2292
2848
  }
@@ -2578,14 +3134,12 @@ function renderImageSection(el) {
2578
3134
  var prev = document.querySelector('.img-preview');
2579
3135
  if (prev) prev.src = srcInp.value;
2580
3136
  logChange(sel, 'pp-src', srcInp.value, el, orig);
2581
- markDirty();
2582
3137
  });
2583
3138
  var altInp = document.getElementById('pp-img-alt');
2584
3139
  if (altInp) altInp.addEventListener('input', function() {
2585
3140
  var orig = getOriginalValue('pp-alt', el);
2586
3141
  el.setAttribute('alt', altInp.value);
2587
3142
  logChange(sel, 'pp-alt', altInp.value, el, orig);
2588
- markDirty();
2589
3143
  });
2590
3144
 
2591
3145
  // Wire srcset entry inputs
@@ -2594,7 +3148,8 @@ function renderImageSection(el) {
2594
3148
  var dInp = document.getElementById('pp-se-desc-'+i);
2595
3149
  function flushSrcset() {
2596
3150
  el.setAttribute('srcset', buildSrcset(_srcsetEntries));
2597
- markDirty();
3151
+ saveCurrentVariationHtml();
3152
+ recomputeEditorDirty();
2598
3153
  }
2599
3154
  if (uInp) uInp.addEventListener('input', function(){ _srcsetEntries[i].url = uInp.value; flushSrcset(); });
2600
3155
  if (dInp) dInp.addEventListener('input', function(){ _srcsetEntries[i].descriptor = dInp.value; flushSrcset(); });
@@ -2612,7 +3167,8 @@ function renderImageSection(el) {
2612
3167
  }
2613
3168
  function flushSizes() {
2614
3169
  el.setAttribute('sizes', buildSizes(_sizesEntries));
2615
- markDirty();
3170
+ saveCurrentVariationHtml();
3171
+ recomputeEditorDirty();
2616
3172
  }
2617
3173
  if (opInp) opInp.addEventListener('change', function(){ buildCondition(); flushSizes(); });
2618
3174
  if (valInp) valInp.addEventListener('input', function(){ buildCondition(); flushSizes(); });
@@ -2626,7 +3182,12 @@ function addSrcsetEntry() {
2626
3182
  }
2627
3183
  function removeSrcsetEntry(i) {
2628
3184
  _srcsetEntries.splice(i, 1);
2629
- if (_imageEl) { _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries)); renderImageSection(_imageEl); markDirty(); }
3185
+ if (_imageEl) {
3186
+ _imageEl.setAttribute('srcset', buildSrcset(_srcsetEntries));
3187
+ renderImageSection(_imageEl);
3188
+ saveCurrentVariationHtml();
3189
+ recomputeEditorDirty();
3190
+ }
2630
3191
  }
2631
3192
  function addSizesEntry() {
2632
3193
  _sizesEntries.push({condition:'max-width: 760px', value:'760px'});
@@ -2634,7 +3195,12 @@ function addSizesEntry() {
2634
3195
  }
2635
3196
  function removeSizesEntry(i) {
2636
3197
  _sizesEntries.splice(i, 1);
2637
- if (_imageEl) { _imageEl.setAttribute('sizes', buildSizes(_sizesEntries)); renderImageSection(_imageEl); markDirty(); }
3198
+ if (_imageEl) {
3199
+ _imageEl.setAttribute('sizes', buildSizes(_sizesEntries));
3200
+ renderImageSection(_imageEl);
3201
+ saveCurrentVariationHtml();
3202
+ recomputeEditorDirty();
3203
+ }
2638
3204
  }
2639
3205
 
2640
3206
  // \u2500\u2500 Right panel rendering (accordion) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
@@ -2839,20 +3405,51 @@ function renderRightPanel(el) {
2839
3405
  var orig = getOriginalValue(b[0], el);
2840
3406
  b[1](inp.value);
2841
3407
  logChange(sel, b[0], inp.value, el, orig);
2842
- markDirty();
2843
3408
  });
2844
3409
  });
2845
3410
  }
2846
3411
 
2847
3412
  // \u2500\u2500 Selector helper \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3413
+ function generateVveInstanceId() {
3414
+ return 'v' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 10);
3415
+ }
3416
+
3417
+ /** Editor-assigned clone marker so duplicated subtrees do not share the same CSS path as the original. */
3418
+ function stripDataVveInstanceSubtree(root) {
3419
+ if (!root || root.nodeType !== 1) return;
3420
+ try {
3421
+ root.removeAttribute('data-vve-instance');
3422
+ } catch(_) {}
3423
+ var sub = root.querySelectorAll ? root.querySelectorAll('[data-vve-instance]') : [];
3424
+ for (var i = 0; i < sub.length; i++) {
3425
+ try {
3426
+ sub[i].removeAttribute('data-vve-instance');
3427
+ } catch(_) {}
3428
+ }
3429
+ }
3430
+
2848
3431
  function buildSelector(el) {
2849
3432
  if (!el) return '';
3433
+ var doc = el.ownerDocument || document;
3434
+ var inst = el.getAttribute && el.getAttribute('data-vve-instance');
3435
+ if (inst && String(inst).trim()) {
3436
+ var safe = String(inst).split('"').join('\\"');
3437
+ var attrSel = '[data-vve-instance="' + safe + '"]';
3438
+ try {
3439
+ if (doc.querySelectorAll(attrSel).length === 1) return attrSel;
3440
+ } catch(_) {}
3441
+ }
2850
3442
  if (el.id) return '#' + el.id;
2851
3443
  var parts = [], node = el, depth = 0;
2852
3444
  while (node && node.nodeType === 1 && depth < 5) {
2853
3445
  if (node.id) { parts.unshift('#' + node.id); break; }
2854
3446
  var p = node.tagName.toLowerCase();
2855
- if (node.classList && node.classList.length) p += '.' + Array.from(node.classList).slice(0,2).join('.');
3447
+ if (node.classList && node.classList.length) {
3448
+ var clsArr = Array.from(node.classList).filter(function(c) {
3449
+ return c.indexOf('vve-') !== 0;
3450
+ });
3451
+ if (clsArr.length) p += '.' + clsArr.slice(0, 2).join('.');
3452
+ }
2856
3453
  var idx = 1, sib = node.previousElementSibling;
2857
3454
  while (sib) { if (sib.tagName === node.tagName) idx++; sib = sib.previousElementSibling; }
2858
3455
  if (idx > 1) p += ':nth-of-type(' + idx + ')';
@@ -2863,6 +3460,71 @@ function buildSelector(el) {
2863
3460
  return parts.join(' > ');
2864
3461
  }
2865
3462
 
3463
+ /**
3464
+ * Strip editor-only .vve-* class tokens from a selector string (fixes DB rows saved while an element was selected).
3465
+ */
3466
+ function sanitizeSelectorForMatch(sel) {
3467
+ if (!sel || typeof sel !== 'string') return '';
3468
+ var s0 = sel.replace(/.vve-[a-zA-Z0-9_-]+/gi, '');
3469
+ var parts = s0.split(/s*>s*/).map(function(seg) {
3470
+ var t = seg.replace(/.+/g, '.').replace(/.$/, '');
3471
+ return t.trim();
3472
+ });
3473
+ return parts.filter(Boolean).join(' > ');
3474
+ }
3475
+
3476
+ /** Drop the rightmost :nth-of-type(n) (hydration / layout often shifts sibling indices). */
3477
+ function stripRightmostNthOfType(sel) {
3478
+ if (!sel || typeof sel !== 'string') return null;
3479
+ var idx = sel.lastIndexOf(':nth-of-type(');
3480
+ if (idx === -1) return null;
3481
+ var j = idx + ':nth-of-type('.length;
3482
+ while (j < sel.length && sel.charCodeAt(j) >= 48 && sel.charCodeAt(j) <= 57) j++;
3483
+ if (j >= sel.length || sel.charAt(j) !== ')') return null;
3484
+ return (sel.slice(0, idx) + sel.slice(j + 1))
3485
+ .replace(/s{2,}/g, ' ')
3486
+ .replace(/s*>s*>/g, ' >')
3487
+ .trim();
3488
+ }
3489
+
3490
+ function querySelectorResolved(iframeDoc, selector) {
3491
+ if (!iframeDoc || !selector) return null;
3492
+ var seen = {};
3493
+ function tryOne(s) {
3494
+ if (!s || seen[s]) return null;
3495
+ seen[s] = true;
3496
+ try {
3497
+ return iframeDoc.querySelector(s) || null;
3498
+ } catch(_) {
3499
+ return null;
3500
+ }
3501
+ }
3502
+ function walkRelax(base) {
3503
+ if (!base) return null;
3504
+ var el = tryOne(base);
3505
+ if (el) return el;
3506
+ var cur = base;
3507
+ for (var g = 0; g < 28; g++) {
3508
+ var nxt = stripRightmostNthOfType(cur);
3509
+ if (!nxt || nxt === cur) break;
3510
+ cur = nxt;
3511
+ el = tryOne(cur);
3512
+ if (el) return el;
3513
+ }
3514
+ return null;
3515
+ }
3516
+ var alt = sanitizeSelectorForMatch(selector);
3517
+ // Prefer sanitized + nth relax FIRST: raw selectors often still contain .vve-* from
3518
+ // save-time selection; those only match after clicking (we re-add vve-selected).
3519
+ var el = walkRelax(alt || selector);
3520
+ if (el) return el;
3521
+ if (alt !== selector) {
3522
+ el = walkRelax(selector);
3523
+ if (el) return el;
3524
+ }
3525
+ return null;
3526
+ }
3527
+
2866
3528
  // \u2500\u2500 Iframe interaction \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
2867
3529
  function repositionDragSibling(dragEl, clientY) {
2868
3530
  var p = dragEl.parentElement;
@@ -2888,6 +3550,27 @@ function repositionDragSibling(dragEl, clientY) {
2888
3550
  }
2889
3551
  }
2890
3552
 
3553
+ function recordReorderAfterDrag(movedEl) {
3554
+ if (!activeVarId || !movedEl || !movedEl.parentElement) return;
3555
+ var prev = movedEl.previousElementSibling;
3556
+ var next = movedEl.nextElementSibling;
3557
+ if (prev) {
3558
+ appendSessionStructuralChainRow(activeVarId, {
3559
+ selector: buildSelector(movedEl),
3560
+ type: 'reorder',
3561
+ targetSelector: buildSelector(prev),
3562
+ action: 'after',
3563
+ });
3564
+ } else if (next) {
3565
+ appendSessionStructuralChainRow(activeVarId, {
3566
+ selector: buildSelector(movedEl),
3567
+ type: 'reorder',
3568
+ targetSelector: buildSelector(next),
3569
+ action: 'before',
3570
+ });
3571
+ }
3572
+ }
3573
+
2891
3574
  function attachDragReposition() {
2892
3575
  try {
2893
3576
  var iframe = document.getElementById('iframeId');
@@ -2941,7 +3624,9 @@ function attachDragReposition() {
2941
3624
  } catch(_) {}
2942
3625
  suppressClickUntil = Date.now() + 200;
2943
3626
  setDragHandleActive(false);
2944
- markDirty();
3627
+ if (activeVarId) recordReorderAfterDrag(selectedEl);
3628
+ saveCurrentVariationHtml();
3629
+ recomputeEditorDirty();
2945
3630
  updateSelectionToolbar();
2946
3631
  scheduleDomTreeRefresh();
2947
3632
  }
@@ -2994,11 +3679,31 @@ function attachChangeObserver() {
2994
3679
  changeObserver = null;
2995
3680
  changeObserverDoc = null;
2996
3681
  }
2997
- changeObserver = new MutationObserver(function() {
2998
- markDirty();
2999
- // Debounced full rebuild of Elements panel as the live DOM grows / changes
3682
+ changeObserver = new MutationObserver(function(mutations) {
3683
+ // Dirty state is derived from changesets baseline + stateChanges (not raw DOM mutations).
3684
+ var bodyReplaced = false;
3685
+ for (var mi = 0; mi < mutations.length; mi++) {
3686
+ var m = mutations[mi];
3687
+ if (
3688
+ m &&
3689
+ m.type === 'childList' &&
3690
+ m.target === doc.body &&
3691
+ m.addedNodes &&
3692
+ m.removedNodes &&
3693
+ m.addedNodes.length > 0 &&
3694
+ m.removedNodes.length > 0
3695
+ ) {
3696
+ bodyReplaced = true;
3697
+ break;
3698
+ }
3699
+ }
3700
+ if (bodyReplaced) {
3701
+ // Page JS replaced body children; allow structural rows (insert/reorder) to apply again.
3702
+ appliedStructuralChangesetKeys = {};
3703
+ }
3000
3704
  scheduleDomTreeRefresh();
3001
3705
  scheduleGranularChangesetReapply();
3706
+ scheduleConsistencyReconcile();
3002
3707
  });
3003
3708
  changeObserver.observe(doc.body, {
3004
3709
  childList: true, subtree: true, attributes: true, characterData: true
@@ -3028,10 +3733,13 @@ function syncIframeInteractions(reason) {
3028
3733
  attachClickHandler();
3029
3734
  attachDragReposition();
3030
3735
  attachChangeObserver();
3736
+ startConsistencyWatchdog(doc);
3737
+ scheduleConsistencyReconcile();
3031
3738
  bindSelectionToolbarScroll();
3032
3739
  var inp = document.getElementById('comp-search');
3033
3740
  renderDomTree(inp ? inp.value : '');
3034
3741
  updateSelectionToolbar();
3742
+ recomputeEditorDirty();
3035
3743
  } catch(_) {}
3036
3744
  }
3037
3745
 
@@ -3083,8 +3791,11 @@ function insertHtml(html) {
3083
3791
  console.warn('[V2] insertHtml: iframe document not ready');
3084
3792
  return;
3085
3793
  }
3794
+ var htmlStr = String(html).trim();
3795
+ var anchorSel =
3796
+ selectedEl && selectedEl !== doc.body && selectedEl.parentNode ? buildSelector(selectedEl) : 'body';
3086
3797
  var t = doc.createElement('template');
3087
- t.innerHTML = String(html).trim();
3798
+ t.innerHTML = htmlStr;
3088
3799
  var frag = doc.createDocumentFragment();
3089
3800
  var firstEl = null;
3090
3801
  while (t.content.firstChild) {
@@ -3100,7 +3811,16 @@ function insertHtml(html) {
3100
3811
  doc.body.appendChild(frag);
3101
3812
  }
3102
3813
  if (firstEl) selectElement(firstEl);
3103
- markDirty();
3814
+ if (activeVarId) {
3815
+ appendSessionStructuralChainRow(activeVarId, {
3816
+ selector: anchorSel,
3817
+ type: 'insert',
3818
+ action: 'after',
3819
+ html: htmlStr,
3820
+ });
3821
+ }
3822
+ saveCurrentVariationHtml();
3823
+ recomputeEditorDirty();
3104
3824
  scheduleDomTreeRefresh();
3105
3825
  } catch(err) { console.warn('[V2] insertHtml:', err); }
3106
3826
  }
@@ -3192,15 +3912,37 @@ document.getElementById('btn-close').addEventListener('click', handleClose);
3192
3912
  function handleSave() {
3193
3913
  saveCurrentVariationHtml();
3194
3914
  var updatedVariations = variations.map(function(v) {
3195
- var saved = varHtmlCache[v._id];
3196
- if (!saved) return Object.assign({}, v);
3197
- return Object.assign({}, v, { changesets: JSON.stringify([{ selector: '__vvveb_body__', html: saved, mutations: [] }]) });
3915
+ var prevParsed = parseVariationChangesets(v);
3916
+ var granularPrev = filterGranularChangesetEntries(prevParsed);
3917
+ var bodyOnlyLegacy = changesetsHaveBodySnapshot(prevParsed) && granularPrev.length === 0;
3918
+
3919
+ var rows = buildPersistedChainSetsForVariation(v);
3920
+ if (bodyOnlyLegacy && rows.length === 0) {
3921
+ return Object.assign({}, v);
3922
+ }
3923
+ var json = '[]';
3924
+ try {
3925
+ json = JSON.stringify(rows || []);
3926
+ } catch(_) {
3927
+ json = '[]';
3928
+ }
3929
+ return Object.assign({}, v, { changesets: json });
3198
3930
  });
3931
+ variations = updatedVariations;
3932
+ varHtmlCache = {};
3933
+ sessionStructuralChainRowsByVarId = {};
3934
+ stateChanges = [];
3935
+ if (currentMainTab === 'states') renderStatesTab();
3936
+ captureBaselineFromVariations(variations);
3937
+ if (experimentData && Array.isArray(experimentData.variations)) {
3938
+ experimentData.variations = updatedVariations;
3939
+ }
3199
3940
  send('save-experiment', { experimentId: experimentData ? experimentData.experimentId : null, variations: updatedVariations });
3200
- markClean();
3941
+ setEditorDirty(false);
3201
3942
  }
3202
3943
 
3203
3944
  function handleClose() {
3945
+ clearVisualEditorLocalStorage();
3204
3946
  // Unsaved-changes UX lives in the parent (PlatformVisualEditorV2); avoid double confirm here.
3205
3947
  send('close-editor', {});
3206
3948
  }
@@ -3208,8 +3950,22 @@ function handleClose() {
3208
3950
  // \u2500\u2500 Keyboard shortcuts \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
3209
3951
  document.addEventListener('keydown', function(e) {
3210
3952
  var meta = e.metaKey || e.ctrlKey;
3211
- if (meta && !e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.undo(); }
3212
- if (meta && e.shiftKey && e.key === 'z') { e.preventDefault(); if (typeof Vvveb !== 'undefined' && Vvveb.Undo) Vvveb.Undo.redo(); }
3953
+ if (meta && !e.shiftKey && e.key === 'z') {
3954
+ e.preventDefault();
3955
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3956
+ Vvveb.Undo.undo();
3957
+ saveCurrentVariationHtml();
3958
+ recomputeEditorDirty();
3959
+ }
3960
+ }
3961
+ if (meta && e.shiftKey && e.key === 'z') {
3962
+ e.preventDefault();
3963
+ if (typeof Vvveb !== 'undefined' && Vvveb.Undo) {
3964
+ Vvveb.Undo.redo();
3965
+ saveCurrentVariationHtml();
3966
+ recomputeEditorDirty();
3967
+ }
3968
+ }
3213
3969
  if (meta && e.key === 's') { e.preventDefault(); handleSave(); }
3214
3970
  if (e.key === 'Escape') {
3215
3971
  var openTips = document.querySelectorAll('.ve-pl-tip.is-tip-open');