PageSourceSearch

https://surfacecc.com/

html surfacecc.com collected 2026-10-03 20:08:16 UTC 81,547 bytes, 2,413 lines download raw bytes

1<!doctype html>
2<html lang="en">
3  <head>
4    <meta charset="UTF-8" />
4<script data-name="termly-code">
5(function () {
6    var button = null;
7
8    function createPreferencesButton() {
9        var btn = document.createElement("button");
10        btn.type = "button";
11        btn.id = "consent-banner-prefs-button";
12        btn.className = "termly-display-preferences";
13        btn.setAttribute("aria-label", "Cookie preferences");
14        btn.title = "Cookie preferences";
15        if (document.body) {
16            document.body.appendChild(btn);
17        } else {
18            document.addEventListener("DOMContentLoaded", function () {
19                document.body.appendChild(btn);
20            });
21        }
22        return btn;
23    }
24
25    function showPreferencesButton() {
26        if (!button) {
27            button = createPreferencesButton();
28        }
29        if (button.style.display !== "block") {
30            button.style.display = "block";
31        }
32    }
33
34    window.onTermlyLoaded = function () {
35        window.Termly.on("consent", function (data) {
36            if (data && data.uuid) {
37                showPreferencesButton();
38            }
39        });
40    };
41}());
42</script>
42<script src="https://app.termly.io/resource-blocker/84a3dc1d-21b9-4a65-9a8a-064af3ef5844?autoBlock=on" data-name="termly-code" onload="window.onTermlyLoaded && window.onTermlyLoaded();"></script>
42
43    <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
44    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
45    <link rel="preconnect" href="https://fonts.googleapis.com" />
46    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
47    <link href="https://fonts.googleapis.com/css2?family=Baloo+2:wght@500;700;800&display=swap" rel="stylesheet" />
48    <title>SurfAce Country Club</title>
49    
49<script type="module" crossorigin src="/assets/index-CAGVz00w.js"></script>
49
50    <link rel="stylesheet" crossorigin href="/assets/index-Bs03FsZT.css">
51  <style data-name="termly-code">
52button#consent-banner-prefs-button {
53    display: none;
54    background-color: white;
55    background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 40 44' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Ccircle cx='20' cy='22' r='11' stroke='%233C3C3C' stroke-width='2'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M27.5042 16.7507C27.5042 17.7164 26.7197 18.5 25.7514 18.5C24.7845 18.5 24 17.7164 24 16.7507C24 15.7836 24.7845 15 25.7514 15C26.7197 15 27.5042 15.7836 27.5042 16.7507Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M28.503 23.25C28.503 23.9405 27.9428 24.5 27.2515 24.5C26.5602 24.5 26 23.9405 26 23.25C26 22.5611 26.5602 22 27.2515 22C27.9428 22 28.503 22.5611 28.503 23.25Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M22 21.5C22 22.3286 21.3286 23 20.5 23C19.6714 23 19 22.3286 19 21.5C19 20.6733 19.6714 20 20.5 20C21.3286 20 22 20.6733 22 21.5Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M26 28C26 28.5524 25.5524 29 25 29C24.4476 29 24 28.5524 24 28C24 27.4489 24.4476 27 25 27C25.5524 27 26 27.4489 26 28Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M20 14C20 14.5524 19.5524 15 19 15C18.4476 15 18 14.5524 18 14C18 13.4489 18.4476 13 19 13C19.5524 13 20 13.4489 20 14Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M21 28.5C21 29.8809 19.8809 31 18.5 31C17.1191 31 16 29.8809 16 28.5C16 27.1222 17.1191 26 18.5 26C19.8809 26 21 27.1222 21 28.5Z' fill='%233C3C3C'/%3E%3Cpath fill-rule='evenodd' clip-rule='evenodd' d='M16 21C16 22.1059 15.1041 23 14 23C12.8941 23 12 22.1059 12 21C12 19.8959 12.8941 19 14 19C15.1041 19 16 19.8959 16 21Z' fill='%233C3C3C'/%3E%3Cpath d='M21 1L23.6464 3.64645C23.8417 3.84171 23.8417 4.1583 23.6464 4.35356L21 7' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M1 22.75C1 12.3947 9.39466 4 19.75 4C20.9718 4 22.1664 4.11687 23.3231 4.34005' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M19 42.75L16.3536 40.1035C16.1583 39.9083 16.1583 39.5917 16.3536 39.3964L19 36.75' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3Cpath d='M39 21C39 31.3553 30.6053 39.75 20.25 39.75C19.0282 39.75 17.8336 39.6331 16.6769 39.41' stroke='%233C3C3C' stroke-width='2' stroke-linecap='round'/%3E%3C/svg%3E");
56    background-size: 44px;
57    background-repeat: no-repeat;
58    background-position: center;
59    border: none;
60    box-sizing: border-box;
61    cursor: pointer;
62    padding: 9px 9px 7px 7px;
63    outline: none;
64    width: 64px;
65    height: 64px;
66    border-top-right-radius: 50%;
67    box-shadow: 0px 0px 6.86px 0px #0000001A;
68    position: fixed;
69    bottom: 0;
70    left: 0;
71    z-index: 9999999;
72    transform: scale(0.7);
73    transform-origin: bottom left;
74    transition: all .3s ease;
75}
76button#consent-banner-prefs-button:hover {
77    transform: scale(1);
78}
79button#consent-banner-prefs-button.termly-display-preferences--disabled {
80    opacity: 0.4;
81    cursor: not-allowed;
82}
83</style></head>
84  <body>
85    <div id="root"></div>
86    
86<script>(function () {
87  if (window.parent === window) return;
88
89  // Broadcasts the iframe's path to the parent so the editor's top-bar reflects
90  // in-iframe navigation. Assumes BrowserRouter — adding `hashchange` would
91  // only fire dedupe-skipped events for in-page anchors.
92  (function () {
93    var lastBroadcastPath = null;
94    function currentPath() {
95      return window.location.pathname || '/';
96    }
97    function scrollToTopIfPathChanged(previousPath) {
98      if (currentPath() === previousPath) return;
99      try {
100        window.scrollTo({ top: 0, left: 0, behavior: 'auto' });
101      } catch (_err) {
102        try {
103          window.scrollTo(0, 0);
104        } catch (_fallbackErr) {
105          // Ignore scroll failures so customer routing cannot break.
106        }
107      }
108    }
109    function broadcastPath() {
110      var path = currentPath();
111      if (path === lastBroadcastPath) return;
112      // Guard: this runs from inside patched pushState/replaceState — a throw
113      // here would break the customer's SPA router.
114      try {
115        window.parent.postMessage(
116          { type: 'wsb-page-navigate', path: path },
117          '*',
118        );
119        lastBroadcastPath = path;
120      } catch (_err) {
121        // Leave lastBroadcastPath untouched so the next nav retries.
122      }
123    }
124    // Patch history methods so SPA push/replaceState are observable.
125    var originalPush = window.history.pushState;
126    var originalReplace = window.history.replaceState;
127    window.history.pushState = function () {
128      var previousPath = currentPath();
129      var result = originalPush.apply(this, arguments);
130      scrollToTopIfPathChanged(previousPath);
131      broadcastPath();
132      return result;
133    };
134    window.history.replaceState = function () {
135      var previousPath = currentPath();
136      var result = originalReplace.apply(this, arguments);
137      scrollToTopIfPathChanged(previousPath);
138      broadcastPath();
139      return result;
140    };
141    window.addEventListener('popstate', function () {
142      scrollToTopIfPathChanged(lastBroadcastPath);
143      broadcastPath();
144    });
145    // Initial broadcast — deferred so the parent's listener is attached.
146    setTimeout(broadcastPath, 0);
147  })();
148
149  var EDIT_EVENTS = {
150    MOVE_COMPONENT: 'MOVE_COMPONENT',
151    DELETE_COMPONENT: 'DELETE_COMPONENT',
152    STYLE_OBJECT_OVERRIDES: 'STYLE_OBJECT_OVERRIDES',
153    SET_IMAGE_UPLOAD_TARGET: 'SET_IMAGE_UPLOAD_TARGET',
154    UPDATE_IMAGE_UPLOAD_TARGET: 'UPDATE_IMAGE_UPLOAD_TARGET',
155    CLEAR_IMAGE_UPLOAD_TARGET: 'CLEAR_IMAGE_UPLOAD_TARGET',
156    APPLY_TEXT_CONTENT: 'APPLY_TEXT_CONTENT',
157    PROP_OBJECT_OVERRIDES: 'PROP_OBJECT_OVERRIDES',
158
159    // Routing event back to the UI
160    TEXT_CONTENT_CHANGED: 'TEXT_CONTENT_CHANGED',
161  };
162  var overlay = document.createElement('div');
163  overlay.id = 'wsb-hover-overlay';
164  Object.assign(overlay.style, {
165    position: 'fixed',
166    pointerEvents: 'none',
167    zIndex: '2147483647',
168    border: '1px solid #0066AA',
169    transition: 'all 0.1s ease-out',
170    display: 'none',
171  });
172  document.body.appendChild(overlay);
173
174  var selectedOverlay = document.createElement('div');
175  selectedOverlay.id = 'wsb-selected-overlay';
176  Object.assign(selectedOverlay.style, {
177    position: 'fixed',
178    pointerEvents: 'none',
179    zIndex: '2147483646',
180    border: '2px solid #0066AA',
181    backgroundColor: '#0066AA1A',
182    display: 'none',
183  });
184  document.body.appendChild(selectedOverlay);
185
186  var state = {
187    enabled: false,
188    hoveredEl: null,
189    selectedEl: null,
190    uploadTarget: null,
191    quotaExceeded: false,
192    isPostCard: false,
193    isServiceCard: false,
194    // True when the current selection is a duplicated-id ("dynamic-static")
195    // element — items rendered from a .map() over a hardcoded array (e.g. FAQ
196    // entries). Selection is allowed for visual feedback, but manual text /
197    // style edits are blocked; the parent must hide manual controls.
198    isManualEditDisabled: false,
199    editingEl: null,
200    editingOriginalContent: null,
201    pickerAfterEl: null,
202    pickerBeforeEl: null,
203    pickerOpen: false,
204    blogPostMode: false,
205  };
206  var savedRange = null;
207  var savedEditingEl = null;
208  var storedServices = [];
209
210  var ui = {
211    overlay: overlay,
212    selectedOverlay: selectedOverlay,
213    overlayNodes: [overlay],
214    selectedOverlayNodes: [selectedOverlay],
215  };
216
217  function positionOverlay(el, ov) {
218    var r = el.getBoundingClientRect();
219    Object.assign(ov.style, {
220      top: r.top + 'px',
221      left: r.left + 'px',
222      width: r.width + 'px',
223      height: r.height + 'px',
224      display: 'block',
225    });
226    return r;
227  }
228
229  function hideOverlayGroup(overlays) {
230    for (var i = 0; i < overlays.length; i++) {
231      overlays[i].style.display = 'none';
232    }
233  }
234
235  function ensureOverlayNode(overlays, template, index) {
236    if (overlays[index]) return overlays[index];
237    var clone = template.cloneNode(false);
238    if (clone.id) clone.id = clone.id + '-clone-' + index;
239    clone.style.display = 'none';
240    document.body.appendChild(clone);
241    overlays[index] = clone;
242    return clone;
243  }
244
245  function positionOverlayGroupById(wsbId, overlays, template, primaryEl) {
246    if (!wsbId) {
247      hideOverlayGroup(overlays);
248      return null;
249    }
250    var matches = findElementsByWsbId(wsbId);
251    if (!matches.length) {
252      hideOverlayGroup(overlays);
253      return null;
254    }
255    var firstRect = null;
256    var primaryRect = null;
257    for (var i = 0; i < matches.length; i++) {
258      var ov = ensureOverlayNode(overlays, template, i);
259      var r = positionOverlay(matches[i], ov);
260      if (!firstRect) firstRect = r;
261      if (primaryEl && matches[i] === primaryEl) primaryRect = r;
262    }
263    for (var j = matches.length; j < overlays.length; j++) {
264      overlays[j].style.display = 'none';
265    }
266    return primaryRect || firstRect;
267  }
268
269  function positionSingleOverlay(el, overlays, template) {
270    if (!el || !el.isConnected) {
271      hideOverlayGroup(overlays);
272      return null;
273    }
274    var ov = ensureOverlayNode(overlays, template, 0);
275    var r = positionOverlay(el, ov);
276    for (var i = 1; i < overlays.length; i++) {
277      overlays[i].style.display = 'none';
278    }
279    return r || el.getBoundingClientRect();
280  }
281
282  function positionOverlayGroupForElement(el, overlays, template) {
283    if (!el || !el.isConnected) {
284      hideOverlayGroup(overlays);
285      return null;
286    }
287    var callId = el.getAttribute('data-wsb-call-id');
288    if (callId) {
289      return positionSingleOverlay(el, overlays, template);
290    }
291    var wsbId = el.getAttribute('data-wsb-id');
292    if (!wsbId) {
293      return positionSingleOverlay(el, overlays, template);
294    }
295    return (
296      positionOverlayGroupById(wsbId, overlays, template, el) ||
297      el.getBoundingClientRect()
298    );
299  }
300
301  function wsbLine(id) {
302    if (!id) return -1;
303    var parts = id.split(':');
304    return parseInt(parts[parts.length - 2], 10);
305  }
306  function wsbFile(id) {
307    if (!id) return '';
308    var parts = id.split(':');
309    parts.pop();
310    parts.pop();
311    return parts.join(':');
312  }
313  function findElementsByWsbId(id) {
314    if (!id) return [];
315    var allEls = document.querySelectorAll('[data-wsb-id]');
316    var results = [];
317    for (var i = 0; i < allEls.length; i++) {
318      if (allEls[i].getAttribute('data-wsb-id') === id) results.push(allEls[i]);
319    }
320    return results;
321  }
322
323  function getNearestAttributeValue(startNode, attributeName, disallowedValues = []) {
324    const disallowed = new Set(disallowedValues);
325
326    let cursor =
327      startNode && startNode.nodeType === Node.ELEMENT_NODE
328        ? startNode
329        : startNode?.parentElement || null;
330
331    while (cursor) {
332      const value = cursor.getAttribute(attributeName);
333
334      if (value && !disallowed.has(value)) {
335        return value;
336      }
337
338      cursor = cursor.parentElement;
339    }
340
341    return null;
342  }
343
344  function extractUrlFromCssImage(value) {
345    if (!value || value === 'none') return '';
346    var match = String(value).match(/^url\((['"]?)(.*?)\1\)$/);
347    return match ? match[2] : value;
348  }
349
350  function findElementByCallId(id) {
351    var elements = document.querySelectorAll('[data-wsb-call-id]');
352    var target = null;
353    elements.forEach((e) => {
354      if (e.getAttribute('data-wsb-call-id') === id) target = e;
355    });
356    return target;
357  }
358  function findElementsByAttribute(attr, id) {
359    if (!attr || !id) return [];
360    var allEls = document.querySelectorAll('[' + attr + ']');
361    var results = [];
362    for (var i = 0; i < allEls.length; i++) {
363      if (allEls[i].getAttribute(attr) === id) results.push(allEls[i]);
364    }
365    return results;
366  }
367
368  // Find the source-code id used by existing editor messages and edit handlers.
369  function findElementId(el) {
370    if (!el || el === document || el.nodeType !== 1) return null;
371    return el.getAttribute("data-wsb-id") || el.getAttribute("data-wsb-call-id");
372  }
373
374  function findClosestUniquelyIdentifiableElement(startElement) {
375    // Identity priority is local to this helper because "selectability" is a
376    // DOM concern, not a source-edit concern. Product cards are repeated from
377    // the same JSX line, so their data-wsb-id is often duplicated; the rendered
378    // product id is the identity that makes the card selectable as a product.
379    // Component call ids and DOM wsb ids remain the source-code identities used
380    // for normal element editing.
381    var uniqueIdentifiers = [
382      'data-wc-product-id',
383      'data-ce-product-id',
384      'data-wsb-call-id',
385      'data-wsb-id',
386    ];
387
388    var parent = startElement;
389
390    // Keep the walk shallow so a click inside an unidentifiable subtree does
391    // not accidentally select a distant page wrapper. Five levels covers the
392    // common card/text/icon nesting in generated pages.
393    var searchDepth = 5;
394
395    while (parent && searchDepth > 0) {
396      if (parent === document) return null;
397      if (parent.nodeType !== 1) {
398        searchDepth = searchDepth - 1;
399        parent = parent.parentNode;
400        continue;
401      }
402
403      var ids = {};
404      var uniqueBy = null;
405
406      for (var i = 0; i < uniqueIdentifiers.length; i++) {
407        var attr = uniqueIdentifiers[i];
408        var value = parent.getAttribute(attr);
409        if (!value) continue;
410
411        ids[attr] = value;
412        if (!uniqueBy && findElementsByAttribute(attr, value).length === 1) {
413          uniqueBy = { attr: attr, value: value };
414        }
415      }
416
417      if (uniqueBy) {
418        return { element: parent, ids: ids, uniqueBy: uniqueBy };
419      }
420      searchDepth = searchDepth - 1;
421      parent = parent.parentNode;
422    }
423    return null;
424  }
425
426  // Avoids a full document scan for ids that may appear multiple times —
427  // narrows to siblings first, then descendants, then falls back to the
428  // structurally nearest match across the whole document.
429  function findClosestElementByWsbId(id, anchorEl) {
430    if (!id || !anchorEl) return null;
431
432    var parent = anchorEl.parentNode;
433    if (parent) {
434      var children = parent.children;
435      for (var i = 0; i < children.length; i++) {
436        if (children[i].getAttribute('data-wsb-id') === id) return children[i];
437      }
438    }
439
440    var descendant = anchorEl.querySelector("[data-wsb-id='" + id + "']");
441    if (descendant) return descendant;
442
443    // Multiple matches exist — pick the one sharing the deepest branch with anchorEl
444    var matches = findElementsByWsbId(id);
445    if (!matches.length) return null;
446    if (matches.length === 1) return matches[0];
447
448    var best = null,
449      bestDepth = -1;
450    for (var m = 0; m < matches.length; m++) {
451      var node = matches[m];
452      var depth = 0;
453      var cursor = node;
454      while (cursor) {
455        if (cursor === anchorEl || cursor.contains(anchorEl)) {
456          depth += 1000;
457          break;
458        }
459        cursor = cursor.parentNode;
460        depth++;
461      }
462      if (depth > bestDepth) {
463        bestDepth = depth;
464        best = node;
465      }
466    }
467    return best;
468  }
469
470  function createEditEventHandlers(ctx) {
471    function getSelectedElements() {
472      if (!ctx.state.selectedEl) return [];
473      var selectedId = ctx.state.selectedEl.getAttribute('data-wsb-id');
474      if (!selectedId) return [ctx.state.selectedEl];
475      var matches = ctx.findElementsByWsbId(selectedId);
476      return matches.length ? matches : [ctx.state.selectedEl];
477    }
478
479    return {
480      // Swaps the DOM positions of two same-parent components by wsb-id.
481      // Intentionally independent of the current selection so the editor
482      // can replay it for undo/redo/discard after the toolbar has closed.
483      // A swap is its own inverse, which is what makes that replay possible.
484      MOVE_COMPONENT: function ({ idA, idB }) {
485        var matchesA = ctx.findElementsByWsbId(idA);
486        var matchesB = ctx.findElementsByWsbId(idB);
487        if (matchesA.length !== 1 || matchesB.length !== 1) return;
488        var elA = matchesA[0];
489        var elB = matchesB[0];
490        if (!elA.parentNode || elA.parentNode !== elB.parentNode) return;
491
492        var marker = document.createComment('wsb-swap');
493        elA.parentNode.insertBefore(marker, elA);
494        elB.parentNode.insertBefore(elA, elB);
495        marker.parentNode.insertBefore(elB, marker);
496        marker.parentNode.removeChild(marker);
497
498        var selected = ctx.state.selectedEl;
499        if (selected === elA || selected === elB) {
500          ctx.positionOverlayGroupForElement(
501            selected,
502            ctx.ui.selectedOverlayNodes,
503            ctx.ui.selectedOverlay,
504          );
505        }
506      },
507      DELETE_COMPONENT: function () {
508        var target = ctx.state.selectedEl;
509        if (!target || !target.parentNode) return;
510
511        if (
512          ctx.state.editingEl &&
513          (ctx.state.editingEl === target ||
514            target.contains(ctx.state.editingEl))
515        ) {
516          ctx.state.editingEl = null;
517        }
518        if (
519          ctx.state.selectedEl &&
520          (ctx.state.selectedEl === target ||
521            target.contains(ctx.state.selectedEl))
522        ) {
523          ctx.state.selectedEl = null;
524          ctx.state.isManualEditDisabled = false;
525          ctx.hideOverlayGroup(ctx.ui.selectedOverlayNodes);
526        }
527        if (
528          ctx.state.hoveredEl &&
529          (ctx.state.hoveredEl === target ||
530            target.contains(ctx.state.hoveredEl))
531        ) {
532          ctx.state.hoveredEl = null;
533          ctx.hideOverlayGroup(ctx.ui.overlayNodes);
534        }
535
536        target.parentNode.removeChild(target);
537      },
538      STYLE_OBJECT_OVERRIDES: function ({ selectedId, loopId, update }) {
539        var targets = selectedId
540          ? ctx.findElementsByWsbId(selectedId)
541          : getSelectedElements();
542        if (!targets.length || !update || typeof update !== 'object') return;
543        if (loopId) {
544          targets = targets.filter(
545            (target) =>
546              getLoopId(target) === loopId,
547          );
548        }
549        if (!targets.length) return;
550
551        // Text styling props — when set at block level, strip from child spans so parent wins
552        var TEXT_STYLE_PROPS = {
553          textDecoration: 1,
554          fontWeight: 1,
555          fontStyle: 1,
556          color: 1,
557          fontSize: 1,
558          textTransform: 1,
559        };
560
561        for (var ti = 0; ti < targets.length; ti++) {
562          var target = targets[ti];
563          var keys = Object.keys(update);
564          for (var i = 0; i < keys.length; i++) {
565            var styleProp = keys[i];
566            var value = update[styleProp];
567            if (!styleProp) continue;
568
569            if (
570              styleProp.indexOf('--') === 0 ||
571              styleProp.indexOf('-') !== -1
572            ) {
573              if (value == null || value === '') {
574                target.style.removeProperty(styleProp);
575              } else {
576                target.style.setProperty(styleProp, value);
577              }
578            } else if (value == null || value === '') {
579              target.style[styleProp] = '';
580            } else {
581              target.style[styleProp] = value;
582            }
583
584            // For text style props, strip matching inline style from child spans so block style wins
585            if (TEXT_STYLE_PROPS[styleProp]) {
586              var childSpans = target.querySelectorAll('span');
587              for (var si = 0; si < childSpans.length; si++) {
588                childSpans[si].style[styleProp] = '';
589              }
590            }
591          }
592        }
593        ctx.updatePositions();
594      },
595
596      PROP_OBJECT_OVERRIDES: function ({ selectedId, loopId, update }) {
597        var targets = selectedId
598          ? ctx.findElementsByWsbId(selectedId)
599          : getSelectedElements();
600
601        if (!targets.length || !update || typeof update !== 'object') return;
602
603        const target = loopId ? targets.find((t) => getLoopId(t) === loopId) : targets[0];
604
605        if (!target) return;
606
607        var keys = Object.keys(update);
608        for (var i = 0; i < keys.length; i++) {
609          var prop = keys[i];
610          var value = update[prop];
611
612          if (!prop || prop === "children") continue;
613
614          target.setAttribute(prop, value);
615        }
616
617        ctx.updatePositions();
618      },
619
620      SET_IMAGE_UPLOAD_TARGET: function ({
621        selectedId,
622        hasBgImage,
623        loopId,
624        imgChildId,
625      }) {
626        let target = null;
627        var lookupId = hasBgImage ? selectedId : imgChildId || selectedId;
628        var targets = lookupId
629          ? ctx.findElementsByWsbId(lookupId)
630          : getSelectedElements();
631
632        if (!targets.length) return;
633
634        if (loopId) {
635          target = targets.find((t) => getNearestAttributeValue(t, "data-wsb-loop-id", ["-1"]) === loopId);
636        } else {
637          target = targets[0];
638        }
639
640        state.uploadTarget = { element: target, hasBgImage };
641        ctx.updatePositions();
642      },
643      UPDATE_IMAGE_UPLOAD_TARGET: function ({ url }) {
644        if (!state.uploadTarget) return;
645        const { element, hasBgImage } = state.uploadTarget;
646
647        if (hasBgImage) {
648          element.style.backgroundImage = 'url(' + url + ')';
649        } else {
650          element.setAttribute('src', url);
651          element.src = url;
652        }
653        ctx.updatePositions();
654      },
655      CLEAR_IMAGE_UPLOAD_TARGET: function () {
656        state.uploadTarget = null;
657        ctx.updatePositions();
658      },
659
660      APPLY_TEXT_CONTENT: function ({ wsbId, callId, loopId, content }) {
661        if (typeof content !== "string") return;
662
663        var targets = [];
664
665        if (callId) {
666          var element = ctx.findElementByCallId(callId);
667          if (element) targets.push(element);
668        } else if (wsbId) {
669          targets = ctx.findElementsByWsbId(wsbId);
670        }
671
672        if (loopId) {
673          targets = targets.filter(function (target) {
674            return (
675              getNearestAttributeValue(
676                target,
677                "data-wsb-loop-id",
678                ["-1"]
679              ) === loopId
680            );
681          });
682        }
683
684        var contentProperty = hasSupportedInlineMarkup(content)
685          ? "innerHTML"
686          : "textContent";
687
688        for (var i = 0; i < targets.length; i++) {
689          targets[i][contentProperty] = content;
690        }
691
692        ctx.updatePositions();
693      },
694
695      TEXT_CONTENT_CHANGED: function ({
696        selectedId,
697        content,
698        previousContent,
699      }) {
700        if (!selectedId || typeof content !== 'string') return;
701        window.parent.postMessage(
702          {
703            type: EDIT_EVENTS.TEXT_CONTENT_CHANGED,
704            data: {
705              selectedId: selectedId,
706              content: content,
707              previousContent:
708                typeof previousContent === 'string' ? previousContent : null,
709            },
710          },
711          '*',
712        );
713      },
714    };
715  }
716
717  var editEventHandlers = createEditEventHandlers({
718    state: state,
719    ui: ui,
720    findElementsByWsbId: findElementsByWsbId,
721    findElementByCallId: findElementByCallId,
722    findClosestElementByWsbId: findClosestElementByWsbId,
723    positionOverlayGroupById: positionOverlayGroupById,
724    positionOverlayGroupForElement: positionOverlayGroupForElement,
725    hideOverlayGroup: hideOverlayGroup,
726    updatePositions: updatePositions,
727  });
728
729  function getTagName(el) {
730    if (!el) return '';
731    if (typeof el.localName === 'string' && el.localName)
732      return el.localName.toLowerCase();
733    if (typeof el.tagName === 'string' && el.tagName)
734      return el.tagName.toLowerCase();
735    return '';
736  }
737
738  var EDITOR_ATTR_PREFIX = 'data-wsb-';
739
740  // Attribute-tolerant so the stamped `<br data-wsb-id="…">` still counts as
741  // markup. Mirrors INLINE_MARKUP_RE on the server (project.service.ts).
742  var SUPPORTED_INLINE_MARKUP_RE =
743    /<(?:span|strong|em|b|i|u|s|mark|code|a|br)(?:\s[^>]*)?\/?>/i;
744  function hasSupportedInlineMarkup(content) {
745    return SUPPORTED_INLINE_MARKUP_RE.test(content);
746  }
747
748  // Serializes an editable element's content for persistence: strips the
749  // editor's `data-wsb-*`/contentEditable scaffolding and unwraps any tag the
750  // editor can't round-trip (e.g. a browser-injected <div>) to its text, so
751  // saved content only ever holds supported inline markup.
752  function serializeInlineContent(el) {
753    var clone = el.cloneNode(true);
754    var nodes = clone.querySelectorAll('*');
755    for (var i = 0; i < nodes.length; i++) {
756      var node = nodes[i];
757      var attrNames = node.getAttributeNames ? node.getAttributeNames() : [];
758      for (var a = 0; a < attrNames.length; a++) {
759        if (attrNames[a].indexOf(EDITOR_ATTR_PREFIX) === 0) {
760          node.removeAttribute(attrNames[a]);
761        }
762      }
763      node.removeAttribute('contenteditable');
764      if (getTagName(node) !== 'br' && !INLINE_TAGS[getTagName(node)]) {
765        var parent = node.parentNode;
766        if (parent) {
767          while (node.firstChild) parent.insertBefore(node.firstChild, node);
768          parent.removeChild(node);
769        }
770      }
771    }
772    return hasSupportedInlineMarkup(clone.innerHTML)
773      ? clone.innerHTML
774      : clone.textContent || '';
775  }
776
777  var SECTION_TAGS = {
778    section: 1,
779    header: 1,
780    footer: 1,
781    main: 1,
782    article: 1,
783    aside: 1,
784    nav: 1,
785  };
786  var MIN_SECTION_HEIGHT = 100;
787  function isSectionLike(el) {
788    var tag = getTagName(el);
789    if (SECTION_TAGS[tag]) return true;
790    if (tag === 'div' && el.offsetHeight >= MIN_SECTION_HEIGHT) return true;
791    return false;
792  }
793
794  const getSectionId = (el) => el?.getAttribute?.('data-wsb-id') || null;
795
796  const getSectionFile = (el) => {
797    const id = getSectionId(el);
798    return id ? wsbFile(id) : null;
799  };
800
801  const MIN_NESTED_SECTION_HEIGHT = 48;
802
803  // Out-of-flow elements (hero background layers, overlays) paint on top of
804  // their siblings instead of stacking below them, so reordering them can
805  // never produce a visible change — they must not count as sections.
806  const isOutOfFlow = (el) => {
807    const position = window.getComputedStyle(el).position;
808    return position === 'absolute' || position === 'fixed';
809  };
810
811  const isSectionShapedSibling = (el) => {
812    if (SECTION_TAGS[getTagName(el)]) return true;
813    return (
814      getTagName(el) === 'div' &&
815      !isOutOfFlow(el) &&
816      el.offsetHeight >= MIN_NESTED_SECTION_HEIGHT
817    );
818  };
819
820  const parentHasSemanticSectionChildren = (el) => {
821    const ourFile = getSectionFile(el);
822    if (!el?.parentElement || !ourFile) return false;
823    for (const child of el.parentElement.children) {
824      if (SECTION_TAGS[getTagName(child)] && getSectionFile(child) === ourFile) {
825        return true;
826      }
827    }
828    return false;
829  };
830
831  const hasGroupedSectionSiblings = (el) => {
832    if (
833      !el?.parentElement ||
834      getTagName(el) !== 'div' ||
835      isOutOfFlow(el) ||
836      el.offsetHeight < MIN_NESTED_SECTION_HEIGHT ||
837      parentHasSemanticSectionChildren(el)
838    ) {
839      return false;
840    }
841    const ourFile = getSectionFile(el);
842    if (!ourFile) return false;
843    for (const sib of el.parentElement.children) {
844      if (sib === el || getSectionFile(sib) !== ourFile) continue;
845      if (isSectionShapedSibling(sib)) return true;
846    }
847    return false;
848  };
849
850  // A bare div only counts as a section when it stacks among other
851  // section-shaped siblings from the same file. A lone tall div is layout
852  // (a hero's content wrapper), and treating it as a section hijacks
853  // section actions from the real <section> above it: move up/down would
854  // swap the hero's internal layers — a visual no-op (WSB2-387).
855  const isSectionCandidate = (el) =>
856    SECTION_TAGS[getTagName(el)] ? true : hasGroupedSectionSiblings(el);
857
858  const isPageSectionContainer = (el) => {
859    if (getTagName(el) !== 'main') return false;
860    const descendants = el.querySelectorAll('[data-wsb-id]');
861    for (const descendant of descendants) {
862      if (
863        descendant !== el &&
864        !isFixedSectionTag(descendant) &&
865        isSectionCandidate(descendant)
866      ) {
867        return true;
868      }
869    }
870    return false;
871  };
872
873  const FIXED_SECTION_TAGS = new Set(['header', 'nav', 'footer']);
874  const isFixedSectionTag = (el) =>
875    !!el && FIXED_SECTION_TAGS.has(getTagName(el));
876  const isFixedSectionInList = (list, index) => {
877    if (index < 0 || index >= list.length) return true;
878    if (isFixedSectionTag(list[index])) return true;
879    return false;
880  };
881
882  // True when `container` is itself a wrapper sitting among section-candidate
883  // siblings of the same file — i.e. it is one of several route-level blocks,
884  // not the route content root. Sections nested inside such a wrapper are
885  // sub-route content and must not move independently. The route content root
886  // (e.g. the sole stamped child of <main>) has no candidate siblings, so its
887  // sections stay movable.
888  const containerHasSectionSiblings = (container) => {
889    const parent = container && container.parentElement;
890    if (!parent) return false;
891    const ourFile = getSectionFile(container);
892    if (!ourFile) return false;
893    for (const sib of parent.children) {
894      if (sib === container) continue;
895      if (isSectionCandidate(sib) && getSectionFile(sib) === ourFile)
896        return true;
897    }
898    return false;
899  };
900
901  // Reordering is limited to page-level sections. Nested/grouped candidates
902  // can still receive clicks, but movement should resolve to their owning
903  // top-level section so cards/columns cannot move independently.
904  const resolveSectionContext = (el) => {
905    const contexts = [];
906    for (let cur = el; cur; cur = cur.parentElement) {
907      if (!cur.parentElement || !isSectionCandidate(cur)) continue;
908      const ourFile = getSectionFile(cur);
909      if (!ourFile) continue;
910      const siblings = [];
911      let idx = -1;
912      for (const sib of cur.parentElement.children) {
913        if (!isSectionCandidate(sib) || getSectionFile(sib) !== ourFile)
914          continue;
915        if (sib === cur) idx = siblings.length;
916        siblings.push(sib);
917      }
918      if (idx === -1) continue;
919      contexts.push({ section: cur, list: siblings, index: idx });
920    }
921    if (!contexts.length) return null;
922    const selectedContext = contexts[0];
923    const owningSectionContext = contexts
924      .slice(1)
925      .find((context) => !isPageSectionContainer(context.section));
926    // A nested wrapper owner (a plain <div> grouping the selection) does not
927    // produce its own context, so detect it structurally: the selection's
928    // container sits among route-level section siblings.
929    const nestedInsideRouteWrapper =
930      !isPageSectionContainer(selectedContext.section) &&
931      containerHasSectionSiblings(selectedContext.section.parentElement);
932    if (owningSectionContext || nestedInsideRouteWrapper) {
933      return {
934        section: selectedContext.section,
935        list: [selectedContext.section],
936        index: 0,
937      };
938    }
939    for (let i = contexts.length - 1; i >= 0; i -= 1) {
940      if (isPageSectionContainer(contexts[i].section)) continue;
941      if (contexts[i].list.length > 1) return contexts[i];
942    }
943    return contexts[0];
944  };
945
946  function findSections() {
947    var allEls = document.querySelectorAll('[data-wsb-id]');
948    if (!allEls.length) return { root: null, sections: [] };
949    var first = allEls[0];
950    var firstId = first.getAttribute('data-wsb-id');
951    var firstFile = wsbFile(firstId);
952    var root = null,
953      topEl = first,
954      p = first.parentElement;
955    while (p) {
956      var pid = p.getAttribute('data-wsb-id');
957      if (pid && wsbFile(pid) === firstFile) {
958        root = p;
959      }
960      p = p.parentElement;
961    }
962    if (!root) {
963      root = first;
964    }
965    var rootLine = wsbLine(root.getAttribute('data-wsb-id'));
966    var rootFile = wsbFile(root.getAttribute('data-wsb-id'));
967    var sections = [];
968    var ch = root.children;
969    for (var ci = 0; ci < ch.length; ci++) {
970      var cid = ch[ci].getAttribute('data-wsb-id');
971      if (
972        cid &&
973        wsbFile(cid) === rootFile &&
974        wsbLine(cid) > rootLine &&
975        isSectionLike(ch[ci])
976      )
977        sections.push(ch[ci]);
978    }
979    return { root: root, sections: sections };
980  }
981
982  var INLINE_TAGS = {
983    span: 1,
984    strong: 1,
985    em: 1,
986    b: 1,
987    i: 1,
988    u: 1,
989    s: 1,
990    mark: 1,
991    code: 1,
992    a: 1,
993  };
994  function isTextEditable(el) {
995    if (!el.textContent.trim()) return false;
996    for (var i = 0; i < el.childNodes.length; i++) {
997      var child = el.childNodes[i];
998      if (child.nodeType === 3) continue; // text node — ok
999      if (child.nodeType === 1 && getTagName(child) === 'br') continue; // void, contributes no content
1000      if (
1001        child.nodeType === 1 &&
1002        INLINE_TAGS[getTagName(child)] &&
1003        !child.querySelector(
1004          'div,section,article,header,footer,nav,aside,main,figure,ul,ol,table,h1,h2,h3,h4,h5,h6,img',
1005        )
1006      )
1007        continue; // inline element — ok
1008      return false;
1009    }
1010    return el.childNodes.length > 0;
1011  }
1012  function isTextOnly(el) {
1013    return isTextEditable(el);
1014  }
1015
1016  var TEXT_SELECTION_TAGS = {
1017    h1: 1,
1018    h2: 1,
1019    h3: 1,
1020    h4: 1,
1021    h5: 1,
1022    h6: 1,
1023    p: 1,
1024    span: 1,
1025    a: 1,
1026    label: 1,
1027    li: 1,
1028    dt: 1,
1029    dd: 1,
1030    blockquote: 1,
1031    caption: 1,
1032    figcaption: 1,
1033    td: 1,
1034    th: 1,
1035    button: 1,
1036  };
1037  var IMAGE_SELECTION_TAGS = {
1038    img: 1,
1039    picture: 1,
1040    svg: 1,
1041    use: 1,
1042    canvas: 1,
1043    video: 1,
1044  };
1045
1046  function inferSelectionKind(el, isProductCard, isPostCard, isServiceCard) {
1047    var tag = getTagName(el);
1048    if (isProductCard) return 'product';
1049    if (isPostCard) return 'post';
1050    if (isServiceCard) return 'service';
1051    if (TEXT_SELECTION_TAGS[tag]) return 'text';
1052    if (IMAGE_SELECTION_TAGS[tag]) return 'image';
1053    if (isTextEditable(el)) return 'text';
1054    return 'section';
1055  }
1056
1057  function stopEditing() {
1058    if (!state.editingEl) return;
1059    var el = state.editingEl;
1060    var originalContent = state.editingOriginalContent;
1061    state.editingEl = null;
1062    state.editingOriginalContent = null;
1063    el.contentEditable = 'false';
1064    el.style.outline = '';
1065    el.style.minWidth = '';
1066    // If a range is still pending, the wsb-apply-range-style handler will persist after applying
1067    if (savedRange) return;
1068    var wsbId = el.getAttribute('data-wsb-id');
1069    var callId = el.getAttribute('data-wsb-call-id');
1070    var targetId = callId || wsbId;
1071    if (targetId) {
1072      var content = serializeInlineContent(el);
1073      var hasInlineMarkup = hasSupportedInlineMarkup(content);
1074
1075
1076      if (content !== originalContent) {
1077        editEventHandlers[EDIT_EVENTS.TEXT_CONTENT_CHANGED]({
1078          selectedId: targetId,
1079          callId: callId || null,
1080          content: content,
1081          previousContent: originalContent,
1082        });
1083      }
1084    }
1085  }
1086  // Get the closest element that has been tagged either with data-wsb-call-id or data-wsb-id
1087  function getClosestStampedElement(e) {
1088    var node = e.target;
1089    while (node && node !== document) {
1090      if (node.nodeType === 1) {
1091        if (
1092          node.hasAttribute('data-wsb-call-id') ||
1093          node.hasAttribute('data-wsb-id')
1094        ) {
1095          return node;
1096        }
1097      }
1098      node = node.parentNode;
1099    }
1100    return null;
1101  }
1102
1103
1104  function getLoopId(el){
1105    return getNearestAttributeValue(el, "data-wsb-loop-id", ["-1"]);
1106  }
1107
1108  // Product card root detection — used by both hover and click handlers.
1109  // Matches elements whose class list contains a product card root class,
1110  // but NOT sub-elements like product-card__img, product-card__body, etc.
1111  function isProductCardRoot(node) {
1112    if (!node || typeof node.className !== 'string') return false;
1113    var classes = node.className.split(/\s+/);
1114    for (var ci = 0; ci < classes.length; ci++) {
1115      var c = classes[ci];
1116      // Match: product-card, product-item, product-tile, productCard, etc.
1117      // Reject: product-card__img, product-card__body, product-card__footer (BEM children)
1118      if (/^product[-_]?(card|item|tile|box|wrap)?$/i.test(c)) return true;
1119    }
1120    return false;
1121  }
1122
1123  // Backend product id on a card root: the WooCommerce numeric id
1124  // (data-wc-product-id) or the commerce-engine uuid (data-ce-product-id).
1125  // The editor parent validates the value per provider. Returns null when
1126  // neither attribute is present.
1127  function getProductId(node) {
1128    if (!node || node.nodeType !== 1) return null;
1129    return (
1130      node.getAttribute('data-wc-product-id') ||
1131      node.getAttribute('data-ce-product-id')
1132    );
1133  }
1134
1135  // Selector matching a product-card root by its backend-id attribute, for
1136  // either commerce provider (WooCommerce or commerce-engine).
1137  var PRODUCT_ID_SELECTOR = '[data-wc-product-id],[data-ce-product-id]';
1138
1139  const findBlogContentArea = (clickedEl) => {
1140    // Bail only if the click is inside page-level chrome: a <header> or <footer>
1141    // that has no <article> ancestor. Post templates often use <header> inside
1142    // <article> for the hero/title area — those should still be promoted.
1143    let cur = clickedEl;
1144    while (cur && cur !== document.body) {
1145      const t = (cur.tagName || '').toLowerCase();
1146      if ((t === 'header' || t === 'footer') && !cur.closest('article'))
1147        return null;
1148      cur = cur.parentElement;
1149    }
1150
1151    // Try <main> first (query globally — some themes don't wrap <main> around the click target).
1152    // Prefer its first direct <article> child so we get the post component wsb-id
1153    // (e.g. PostDetail.jsx) rather than the layout wrapper (e.g. App.jsx).
1154    // Sidebar <article> elements are nested deeper inside the post article, so they
1155    // won't be returned as direct children of <main>.
1156    const mainEl = document.querySelector('main');
1157    if (mainEl) {
1158      for (const child of mainEl.children) {
1159        if ((child.tagName || '').toLowerCase() === 'article') return child;
1160      }
1161      return mainEl;
1162    }
1163
1164    // No <main> (e.g. classic WordPress themes) — use the first <article> that
1165    // is not inside a <header>, <footer>, or <nav>.
1166    const CHROME_TAGS = new Set(['header', 'footer', 'nav']);
1167    for (const article of document.querySelectorAll('article')) {
1168      let ancestor = article.parentElement;
1169      let inChrome = false;
1170      while (ancestor && ancestor !== document.body) {
1171        if (CHROME_TAGS.has((ancestor.tagName || '').toLowerCase())) {
1172          inChrome = true;
1173          break;
1174        }
1175        ancestor = ancestor.parentElement;
1176      }
1177      if (!inChrome) return article;
1178    }
1179
1180    // Final fallback: first direct body child that is not header/footer/nav/script/style
1181    const SKIP_TAGS = new Set(['header', 'footer', 'nav', 'script', 'style']);
1182    for (const child of document.body.children) {
1183      if (!SKIP_TAGS.has((child.tagName || '').toLowerCase())) return child;
1184    }
1185    return null;
1186  };
1187
1188  /**
1189   * Returns true when `href` looks like an internal single-post URL.
1190   *
1191   * A qualifying href must:
1192   * - be a relative path (no scheme, `//`, `#`, `mailto:`, or `tel:`)
1193   * - differ from the current page path (so a "Blog" nav link on /blog doesn't count)
1194   * - contain at least two non-empty path segments after stripping query/hash
1195   *   (e.g. `/blog/my-post` qualifies; `/blog` does not)
1196   *
1197   * @param {string} href - The raw href attribute value from an anchor element.
1198   * @param {string} curPath - `window.location.pathname` of the current page.
1199   * @returns {boolean}
1200   */
1201  const isInternalPostHref = (href, curPath) => {
1202    if (!href || /^(https?:|\/\/|#|mailto:|tel:)/.test(href)) return false;
1203    const p = href.split('?')[0].split('#')[0].replace(/\/$/, '') || '/';
1204    if (p === (curPath.replace(/\/$/, '') || '/')) return false;
1205    return p.replace(/^\//, '').split('/').filter(Boolean).length >= 2;
1206  };
1207
1208  /**
1209   * Collects all unique internal post hrefs found within `el`.
1210   *
1211   * Includes `el` itself if it is an `<a>`, then queries all descendant
1212   * `<a href>` elements. Deduplicates via a Set and filters each href through
1213   * `isInternalPostHref` so navigation links and external URLs are excluded.
1214   *
1215   * @param {Element} el - The element to search.
1216   * @param {string} curPath - `window.location.pathname` of the current page.
1217   * @returns {string[]} Deduplicated array of qualifying post hrefs.
1218   */
1219  const collectPostHrefs = (el, curPath) => {
1220    const seen = new Set();
1221    const anchors = [
1222      ...(getTagName(el) === 'a' ? [el] : []),
1223      ...el.querySelectorAll('a[href]'),
1224    ];
1225    const out = [];
1226    for (const anchor of anchors) {
1227      const h = anchor.getAttribute('href') || '';
1228      if (h && isInternalPostHref(h, curPath) && !seen.has(h)) {
1229        seen.add(h);
1230        out.push(h);
1231      }
1232    }
1233    return out;
1234  };
1235
1236  /**
1237   * Heuristic that decides whether `el` looks like a self-contained post card.
1238   *
1239   * Scores one point each for the presence of an `<img>`, a heading
1240   * (`h1`–`h6`), and a `<p>` anywhere in the subtree. An element scores
1241   * true when it reaches at least 2 out of 3, so a card with just an image
1242   * and a heading (no excerpt paragraph) still qualifies, but a bare heading
1243   * alone does not.
1244   *
1245   * @param {Element} el - The element to test.
1246   * @returns {boolean}
1247   */
1248  const looksLikePostCard = (el) =>
1249    !!el.querySelector('img') +
1250    !!el.querySelector('h1,h2,h3,h4,h5,h6') +
1251    !!el.querySelector('p') >=
1252    2;
1253
1254  /**
1255   * Walks up the DOM from `startEl` to find the root element of a blog post
1256   * card, promoting any click on a card's internals to the card root.
1257   *
1258   * Algorithm (up to 10 ancestors, stops at `document.body`):
1259   * 1. Only considers elements that carry a `data-wsb-id` or
1260   *    `data-wsb-call-id` attribute (i.e. stamped by the compiler).
1261   * 2. Collects internal post hrefs for each candidate via `collectPostHrefs`.
1262   * 3. An element with exactly one qualifying href that also passes
1263   *    `looksLikePostCard` becomes the initial `best`.
1264   * 4. Wider ancestors that link to the same single href extend `best`
1265   *    upward — unless a direct child count check reveals two or more sibling
1266   *    post cards inside that ancestor (which would mean it is a list wrapper,
1267   *    not the card itself).
1268   * 5. Stops early when hrefs diverge (different post) or when a zero-href
1269   *    element is reached after a candidate has already been found.
1270   *
1271   * @param {Element} startEl - The element that was clicked or hovered.
1272   * @returns {{ root: Element, href: string } | null} The post card root and
1273   *   its post href, or `null` if no post card structure was detected.
1274   */
1275  const findPostCardRoot = (startEl) => {
1276    const curPath = window.location.pathname;
1277    let best = null,
1278      bestHref = null,
1279      el = startEl;
1280    for (
1281      let i = 0;
1282      i < 10 && el && el !== document.body;
1283      i++, el = el.parentElement
1284    ) {
1285      if (
1286        !el.getAttribute('data-wsb-id') &&
1287        !el.getAttribute('data-wsb-call-id')
1288      )
1289        continue;
1290      const hrefs = collectPostHrefs(el, curPath);
1291      if (hrefs.length === 0) {
1292        if (best) break;
1293      } else if (hrefs.length === 1) {
1294        if (!bestHref) {
1295          if (looksLikePostCard(el)) {
1296            best = el;
1297            bestHref = hrefs[0];
1298          }
1299        } else if (hrefs[0] === bestHref) {
1300          // Don't extend best to a container that holds multiple sibling post cards
1301          const cardCount = Array.from(el.children).filter(
1302            looksLikePostCard,
1303          ).length;
1304          if (cardCount >= 2) break;
1305          best = el;
1306        } else break;
1307      } else break;
1308    }
1309    return best ? { root: best, href: bestHref } : null;
1310  };
1311
1312  /**
1313   * Returns true when `node` contains exactly one stored Shore service name —
1314   * the structural signature of a service card root.
1315   */
1316  const isServiceCardRoot = (node) => {
1317    if (!node || !storedServices.length) return false;
1318    const text = (node.textContent || '').toLowerCase();
1319    let matchCount = 0;
1320    for (const svc of storedServices) {
1321      if (text.includes(svc.name.toLowerCase())) matchCount++;
1322      if (matchCount > 1) return false;
1323    }
1324    return matchCount === 1;
1325  };
1326
1327  /**
1328   * Walks up from `startEl` looking for a node that contains exactly one
1329   * stored Shore service name. Returns the root element and its serviceId,
1330   * or `null` if no service card structure is detected.
1331   *
1332   * Does not steal elements already identified as blog post cards.
1333   *
1334   * @returns {{ root: Element, serviceId: string } | null}
1335   */
1336  const findServiceCardRoot = (startEl) => {
1337    if (!storedServices.length) return null;
1338    let best = null;
1339    let bestServiceId = null;
1340    let el = startEl;
1341    for (let i = 0; i < 10 && el && el !== document.body; i++, el = el.parentElement) {
1342      if (!el.getAttribute('data-wsb-id') && !el.getAttribute('data-wsb-call-id')) continue;
1343      const text = (el.textContent || '').toLowerCase();
1344      let matchCount = 0;
1345      let matchedId = null;
1346      for (const svc of storedServices) {
1347        if (text.includes(svc.name.toLowerCase())) {
1348          matchCount++;
1349          if (matchCount === 1) matchedId = svc.serviceId;
1350        }
1351      }
1352      if (matchCount === 1) {
1353        best = el;
1354        bestServiceId = matchedId;
1355      } else if (matchCount > 1) {
1356        break;
1357      }
1358    }
1359    // Don't steal blog post cards that happen to mention a service name.
1360    if (best && findPostCardRoot(best)) return null;
1361    return best ? { root: best, serviceId: bestServiceId } : null;
1362  };
1363
1364  document.addEventListener(
1365    'mousemove',
1366    function (e) {
1367      if (!state.enabled || state.editingEl) return;
1368      if (state.selectedEl) return;
1369      var el = document.elementFromPoint(e.clientX, e.clientY);
1370      if (!el) {
1371        hideOverlayGroup(ui.overlayNodes);
1372        state.hoveredEl = null;
1373        return;
1374      }
1375
1376      // Same ordering as the click handler: promote first, fall back to the
1377      // nearest stamped ancestor. Hover never enters edit mode, so we don't
1378      // apply the uniqueness gate — the overlay is purely visual feedback
1379      // that an element is selectable.
1380      var hoverAnchor = null;
1381      var cur = el;
1382      while (cur && cur !== document) {
1383        if (
1384          cur.nodeType === 1 &&
1385          (cur.hasAttribute('data-wsb-call-id') ||
1386            cur.hasAttribute('data-wsb-id'))
1387        ) {
1388          hoverAnchor = cur;
1389          break;
1390        }
1391        cur = cur.parentNode;
1392      }
1393
1394      var target = null;
1395      // Product card promotion on hover — highlight the whole card, not child elements
1396      var hoverCardRoot = el.closest && el.closest(PRODUCT_ID_SELECTOR);
1397      if (!hoverCardRoot) {
1398        var hCheck = el;
1399        for (var _hpc = 0; _hpc < 8 && hCheck; _hpc++) {
1400          if (
1401            hCheck.nodeType === 1 &&
1402            isProductCardRoot(hCheck) &&
1403            (hCheck.getAttribute('data-wsb-id') ||
1404              hCheck.getAttribute('data-wsb-call-id'))
1405          ) {
1406            hoverCardRoot = hCheck;
1407            break;
1408          }
1409          hCheck = hCheck.parentElement;
1410        }
1411      }
1412      var isHoverProduct = !!(
1413        hoverCardRoot &&
1414        (hoverCardRoot.getAttribute('data-wsb-id') ||
1415          hoverCardRoot.getAttribute('data-wsb-call-id') ||
1416          getProductId(hoverCardRoot))
1417      );
1418      if (isHoverProduct) {
1419        target = hoverCardRoot;
1420      }
1421      var isHoverPost = false;
1422      if (!isHoverProduct) {
1423        var hoverPostResult = findPostCardRoot(el);
1424        if (hoverPostResult) {
1425          target = hoverPostResult.root;
1426          isHoverPost = true;
1427        }
1428      }
1429      var isHoverService = false;
1430      if (!isHoverProduct && !isHoverPost) {
1431        var hoverServiceResult = findServiceCardRoot(el);
1432        if (hoverServiceResult) {
1433          target = hoverServiceResult.root;
1434          isHoverService = true;
1435        }
1436      }
1437      if (!target) {
1438        target = hoverAnchor;
1439      }
1440      if (!target) {
1441        hideOverlayGroup(ui.overlayNodes);
1442        state.hoveredEl = null;
1443        return;
1444      }
1445      if (target === state.hoveredEl) return;
1446      state.hoveredEl = target;
1447      var r;
1448      if (isHoverProduct || isHoverPost || isHoverService) {
1449        r = positionSingleOverlay(target, ui.overlayNodes, ui.overlay);
1450      } else {
1451        r = positionOverlayGroupForElement(target, ui.overlayNodes, ui.overlay);
1452      }
1453    },
1454    true,
1455  );
1456
1457  function getPickerBoundaryRect() {
1458    var bnd;
1459    if (state.pickerAfterEl && state.pickerBeforeEl) {
1460      bnd =
1461        (state.pickerAfterEl.getBoundingClientRect().bottom +
1462          state.pickerBeforeEl.getBoundingClientRect().top) /
1463        2;
1464    } else if (state.pickerAfterEl) {
1465      bnd = state.pickerAfterEl.getBoundingClientRect().bottom;
1466    } else if (state.pickerBeforeEl) {
1467      bnd = state.pickerBeforeEl.getBoundingClientRect().top;
1468    } else {
1469      return null;
1470    }
1471    return { top: bnd, left: 0, width: window.innerWidth, height: 0 };
1472  }
1473
1474  document.addEventListener(
1475    'click',
1476    function (e) {
1477      if (!state.enabled) return;
1478      if (state.editingEl && !state.editingEl.contains(e.target)) {
1479        stopEditing();
1480      }
1481
1482      // Anchor first, promote second, gate last.
1483      //
1484      // Dynamic-DATA cards (products / posts / services / blog content) are
1485      // rendered from a .map() and therefore share their `data-wsb-id` /
1486      // `data-wsb-call-id` with siblings. The uniqueness gate would reject
1487      // them, so the data-driven promotion helpers run BEFORE the gate. If
1488      // promotion matches, we trust those helpers' identity (wc-product-id,
1489      // post href, service id).
1490      //
1491      // Otherwise, we fall back to the closest stamped ancestor. If that
1492      // anchor is uniquely identifiable in the document, it's a real unique
1493      // element and manual editing is allowed. If it is duplicated, we still
1494      // surface a selection (so the user sees feedback) but mark it as
1495      // dynamic-static — the parent hides text/style/upload/delete controls
1496      // and only the AI "Ask for changes" entry remains.
1497      var anchor = getClosestStampedElement(e);
1498
1499      if (
1500        state.editingEl &&
1501        (anchor === state.editingEl || state.editingEl.contains(e.target))
1502      ) {
1503        e.preventDefault();
1504        e.stopPropagation();
1505        return;
1506      }
1507
1508      // Prevent all regular event propagations
1509      e.preventDefault();
1510      e.stopPropagation();
1511
1512      // Product card promotion — if clicking inside a product card, select the card root instead.
1513      var cardRoot = e.target.closest && e.target.closest(PRODUCT_ID_SELECTOR);
1514      if (!cardRoot) {
1515        // Check e.target itself and ancestors for product card root class
1516        var check = e.target;
1517        for (var _pc = 0; _pc < 8 && check; _pc++) {
1518          if (
1519            check.nodeType === 1 &&
1520            isProductCardRoot(check) &&
1521            (check.getAttribute('data-wsb-id') ||
1522              check.getAttribute('data-wsb-call-id'))
1523          ) {
1524            cardRoot = check;
1525            break;
1526          }
1527          check = check.parentElement;
1528        }
1529      }
1530      var isProductCard = !!(
1531        cardRoot &&
1532        (cardRoot.getAttribute('data-wsb-id') ||
1533          cardRoot.getAttribute('data-wsb-call-id') ||
1534          getProductId(cardRoot))
1535      );
1536
1537      var el = null;
1538      if (isProductCard) {
1539        el = cardRoot;
1540      }
1541
1542      // Blog post mode — promote any click to the page content area (excluding header/footer)
1543      var isBlogContent = false;
1544      if (!isProductCard && state.blogPostMode) {
1545        var blogAnchor = anchor || e.target;
1546        var contentArea = findBlogContentArea(blogAnchor);
1547        if (contentArea) {
1548          el = contentArea;
1549          isBlogContent = true;
1550        }
1551      }
1552      var isPostCard = false;
1553      var clickPostHref = null;
1554      if (!isProductCard && !isBlogContent) {
1555        var clickPostResult = findPostCardRoot(e.target);
1556        if (clickPostResult) {
1557          el = clickPostResult.root;
1558          clickPostHref = clickPostResult.href;
1559          isPostCard = true;
1560        }
1561      }
1562      var isServiceCard = false;
1563      var clickServiceResult = null;
1564      if (!isProductCard && !isPostCard && !isBlogContent) {
1565        clickServiceResult = findServiceCardRoot(e.target);
1566        if (clickServiceResult) {
1567          el = clickServiceResult.root;
1568          isServiceCard = true;
1569        }
1570      }
1571
1572      var isDynamicData =
1573        isProductCard || isPostCard || isServiceCard || isBlogContent;
1574
1575
1576      if (!isDynamicData) {
1577        if (!anchor) {
1578          // Clicked on blank / unstamped area — deselect and notify parent.
1579          if (state.selectedEl) {
1580            state.selectedEl = null;
1581            hideOverlayGroup(ui.selectedOverlayNodes);
1582            window.parent.postMessage({ type: 'wsb-deselect' }, '*');
1583          }
1584          return;
1585        }
1586        el = anchor;
1587      }
1588      state.selectedEl = el;
1589      state.isPostCard = isPostCard;
1590      state.isServiceCard = isServiceCard;
1591      resizeObserver.disconnect();
1592      resizeObserver.observe(el);
1593      // (multiple cards share the same data-wsb-id since they come from the same JSX line)
1594      var r;
1595      if (isProductCard || isPostCard || isServiceCard) {
1596        r = positionSingleOverlay(
1597          el,
1598          ui.selectedOverlayNodes,
1599          ui.selectedOverlay,
1600        );
1601      } else {
1602        r = positionOverlayGroupForElement(
1603          el,
1604          ui.selectedOverlayNodes,
1605          ui.selectedOverlay,
1606        );
1607      }
1608      hideOverlayGroup(ui.overlayNodes);
1609      let prevSibId = null,
1610        nextSibId = null,
1611        sectionId = null;
1612      let sectionEl = null;
1613      const sectionCtx = resolveSectionContext(el);
1614      if (sectionCtx) {
1615        const selectedIsPlainChildContainer =
1616          sectionCtx.section !== el &&
1617          getTagName(el) === 'div' &&
1618          !isOutOfFlow(el) &&
1619          !isSectionCandidate(el);
1620        if (selectedIsPlainChildContainer) {
1621          sectionEl = el;
1622          sectionId = getSectionId(el);
1623        } else {
1624          sectionEl = sectionCtx.section;
1625          sectionId = getSectionId(sectionEl);
1626          const { list, index } = sectionCtx;
1627          if (!isFixedSectionInList(list, index)) {
1628            if (!isFixedSectionInList(list, index - 1)) {
1629              prevSibId = getSectionId(list[index - 1]);
1630            }
1631            if (!isFixedSectionInList(list, index + 1)) {
1632              nextSibId = getSectionId(list[index + 1]);
1633            }
1634          }
1635        }
1636      }
1637      var imgChildId = null;
1638      var hasBgImage = false;
1639      var oldUrl = '';
1640      if (getTagName(el) !== 'img') {
1641        var imgChild = el.querySelector('img[data-wsb-id]');
1642        if (!imgChild && el.parentElement) {
1643          imgChild = el.parentElement.querySelector('img[data-wsb-id]');
1644        }
1645        if (imgChild) {
1646          imgChildId = imgChild.getAttribute('data-wsb-id');
1647          oldUrl = imgChild.getAttribute('src') || imgChild.src || '';
1648        }
1649        var bgImg = el.style.backgroundImage;
1650        if (bgImg && bgImg !== 'none' && bgImg.indexOf('url') !== -1) {
1651          hasBgImage = true;
1652          oldUrl = extractUrlFromCssImage(bgImg);
1653        }
1654      } else {
1655        oldUrl = el.getAttribute('src') || el.src || '';
1656      }
1657      // WooCommerce product data extraction — scrape product info directly from the card DOM
1658      var wcProductId = null;
1659      var wcProductData = null;
1660      if (isProductCard) {
1661        // el is already promoted to the card root
1662        wcProductId =
1663          getProductId(el) ||
1664          el.getAttribute('data-id') ||
1665          null;
1666        var _nameEl = el.querySelector(
1667          "h1,h2,h3,h4,[class*='name'],[class*='title']:not([class*='section'])",
1668        );
1669        var _priceEl = el.querySelector("[class*='price']");
1670        var _catEl = el.querySelector("[class*='category']");
1671        var _descEl = el.querySelector("[class*='desc']");
1672        var _imgEl = el.querySelector('img');
1673        wcProductData = {
1674          name: _nameEl ? (_nameEl.textContent || '').trim() : '',
1675          price: _priceEl ? (_priceEl.textContent || '').trim() : '',
1676          category: _catEl ? (_catEl.textContent || '').trim() : '',
1677          description: _descEl ? (_descEl.textContent || '').trim() : '',
1678          image: _imgEl ? _imgEl.getAttribute('src') || '' : '',
1679        };
1680      }
1681      var postData = isPostCard ? { href: clickPostHref || '' } : null;
1682      var serviceData = isServiceCard && clickServiceResult ? { serviceId: clickServiceResult.serviceId } : null;
1683      var selectionKind = inferSelectionKind(el, isProductCard, isPostCard, isServiceCard);
1684
1685      if (!sectionId && selectionKind === 'section') {
1686        sectionEl = el;
1687        sectionId = getSectionId(el);
1688      }
1689      var cs = window.getComputedStyle(el);
1690      var elementId = findElementId(el);
1691
1692      window.parent.postMessage(
1693        {
1694          type: 'wsb-select',
1695          id: elementId,
1696          tag: getTagName(el),
1697          loopId: getLoopId(el) ?? null,
1698          selectionKind: selectionKind,
1699          isBlogContent: isBlogContent,
1700          pathname: window.location.pathname,
1701          rect: { top: r.top, left: r.left, width: r.width, height: r.height },
1702          sectionId: sectionId,
1703          sectionHtml: sectionEl ? sectionEl.outerHTML : null,
1704          prevSiblingId: prevSibId,
1705          nextSiblingId: nextSibId,
1706          imgChildId: imgChildId,
1707          hasBgImage: hasBgImage,
1708          oldUrl: oldUrl,
1709          wcProductId: wcProductId,
1710          wcProductData: wcProductData,
1711          postData: postData,
1712          serviceData: serviceData,
1713          computedStyles: {
1714            fontSize: cs.fontSize,
1715            fontWeight: cs.fontWeight,
1716            fontStyle: cs.fontStyle,
1717            textDecorationLine: cs.textDecorationLine,
1718            textDecoration: cs.textDecoration,
1719            textAlign: cs.textAlign,
1720            textTransform: cs.textTransform,
1721            color: cs.color,
1722            backgroundColor: cs.backgroundColor,
1723            opacity: cs.opacity,
1724            marginLeft: cs.marginLeft,
1725            marginRight: cs.marginRight,
1726            marginTop: cs.marginTop,
1727            marginBottom: cs.marginBottom,
1728            paddingLeft: cs.paddingLeft,
1729            paddingRight: cs.paddingRight,
1730            paddingTop: cs.paddingTop,
1731            paddingBottom: cs.paddingBottom,
1732          },
1733          inlineStyles: {
1734            fontSize: el.style.fontSize,
1735            fontWeight: el.style.fontWeight,
1736            fontStyle: el.style.fontStyle,
1737            textDecoration: el.style.textDecoration,
1738            textAlign: el.style.textAlign,
1739            textTransform: el.style.textTransform,
1740            color: el.style.color,
1741            background: el.style.background,
1742            opacity: el.style.opacity,
1743            marginLeft: el.style.marginLeft,
1744            marginRight: el.style.marginRight,
1745            marginTop: el.style.marginTop,
1746            marginBottom: el.style.marginBottom,
1747            paddingLeft: el.style.paddingLeft,
1748            paddingRight: el.style.paddingRight,
1749            paddingTop: el.style.paddingTop,
1750            paddingBottom: el.style.paddingBottom,
1751          },
1752        },
1753        '*',
1754      );
1755    },
1756    true,
1757  );
1758
1759  document.addEventListener(
1760    'dblclick',
1761    function (e) {
1762      // Only on double click operations do we want to edit text
1763      if (!state.enabled) return;
1764      // Dynamic-static items cannot be text-edited per-instance — their
1765      // duplicated wsb-id would silently fan changes out to siblings.
1766      var el = state.selectedEl;
1767      if (!el || !el.contains(e.target) || !isTextOnly(el)) return;
1768
1769
1770      const isLoopedElement = (getLoopId(el) ?? null) !== null;
1771
1772      if (isLoopedElement && state.quotaExceeded) return;
1773
1774      e.preventDefault();
1775      e.stopPropagation();
1776      state.editingEl = el;
1777      // Serialize the baseline the same way stopEditing does, so the undo
1778      // snapshot and the new value are directly comparable.
1779      state.editingOriginalContent = serializeInlineContent(el);
1780      el.contentEditable = 'true';
1781      el.style.minWidth = '1em';
1782      el.focus();
1783      var range = document.createRange();
1784      range.selectNodeContents(el);
1785      var sel = window.getSelection();
1786      sel.removeAllRanges();
1787      sel.addRange(range);
1788    },
1789    true,
1790  );
1791
1792  document.addEventListener(
1793    'keydown',
1794    function (e) {
1795      if (!state.editingEl) return;
1796
1797      if (e.key === 'Escape') {
1798        e.preventDefault();
1799        state.editingEl.blur();
1800        return;
1801      }
1802
1803      if (e.key === 'Enter') {
1804        if (e.shiftKey) {
1805          e.preventDefault();
1806
1807          var inserted = false;
1808          try {
1809            inserted = document.execCommand('insertLineBreak');
1810          } catch (_err) {
1811            inserted = false;
1812          }
1813
1814          if (!inserted) {
1815            var sel = window.getSelection();
1816            if (!sel || sel.rangeCount === 0) return;
1817            var range = sel.getRangeAt(0);
1818            range.deleteContents();
1819            var br = document.createElement('br');
1820            range.insertNode(br);
1821            range.setStartAfter(br);
1822            range.setEndAfter(br);
1823            sel.removeAllRanges();
1824            sel.addRange(range);
1825          }
1826          return;
1827        }
1828
1829        e.preventDefault();
1830        state.editingEl.blur();
1831      }
1832    },
1833    true,
1834  );
1835
1836  document.addEventListener(
1837    'focusout',
1838    function (e) {
1839      if (state.editingEl && e.target === state.editingEl) stopEditing();
1840    },
1841    true,
1842  );
1843
1844  document.addEventListener(
1845    'mouseup',
1846    function () {
1847      if (!state.editingEl) return;
1848      var sel = window.getSelection();
1849      if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
1850        if (savedRange) {
1851          savedRange = null;
1852          window.parent.postMessage({ type: 'wsb-range-deselect' }, '*');
1853        }
1854        return;
1855      }
1856      var range = sel.getRangeAt(0);
1857      if (!state.editingEl.contains(range.commonAncestorContainer)) {
1858        savedRange = null;
1859        window.parent.postMessage({ type: 'wsb-range-deselect' }, '*');
1860        return;
1861      }
1862      savedRange = range.cloneRange();
1863      savedEditingEl = state.editingEl;
1864      window.parent.postMessage({ type: 'wsb-range-select' }, '*');
1865    },
1866    true,
1867  );
1868
1869  document.addEventListener('selectionchange', function () {
1870    if (!state.editingEl || !savedRange) return;
1871    // If the iframe no longer has focus the selection collapsed because the user
1872    // clicked on the parent toolbar — keep savedRange so the style can still apply.
1873    if (!document.hasFocus()) return;
1874    var sel = window.getSelection();
1875    if (!sel || sel.isCollapsed) {
1876      savedRange = null;
1877      window.parent.postMessage({ type: 'wsb-range-deselect' }, '*');
1878    }
1879  });
1880
1881  function updatePositions() {
1882    if (state.hoveredEl && state.hoveredEl.isConnected && !state.selectedEl) {
1883      if (
1884        getProductId(state.hoveredEl) ||
1885        isProductCardRoot(state.hoveredEl) ||
1886        isServiceCardRoot(state.hoveredEl)
1887      ) {
1888        positionSingleOverlay(state.hoveredEl, ui.overlayNodes, ui.overlay);
1889      } else {
1890        positionOverlayGroupForElement(
1891          state.hoveredEl,
1892          ui.overlayNodes,
1893          ui.overlay,
1894        );
1895      }
1896    }
1897    if (state.uploadTarget && state.uploadTarget.element.isConnected) {
1898      // Send rect updates on scroll
1899      r = state.uploadTarget.element.getBoundingClientRect();
1900      window.parent.postMessage(
1901        {
1902          type: 'wsb-target-image-rect-update',
1903          rect: { top: r.top, left: r.left, width: r.width, height: r.height },
1904        },
1905        '*',
1906      );
1907    }
1908    if (state.selectedEl && state.selectedEl.isConnected) {
1909      var r;
1910      if (
1911        getProductId(state.selectedEl) ||
1912        isProductCardRoot(state.selectedEl) ||
1913        state.isPostCard ||
1914        state.isServiceCard
1915      ) {
1916        r = positionSingleOverlay(
1917          state.selectedEl,
1918          ui.selectedOverlayNodes,
1919          ui.selectedOverlay,
1920        );
1921      } else {
1922        r = positionOverlayGroupForElement(
1923          state.selectedEl,
1924          ui.selectedOverlayNodes,
1925          ui.selectedOverlay,
1926        );
1927      }
1928      window.parent.postMessage(
1929        {
1930          type: 'wsb-rect-update',
1931          rect: { top: r.top, left: r.left, width: r.width, height: r.height },
1932        },
1933        '*',
1934      );
1935    }
1936    if (state.pickerOpen) {
1937      var pr = getPickerBoundaryRect();
1938      if (pr) {
1939        window.parent.postMessage(
1940          {
1941            type: 'wsb-add-component-rect-update',
1942            rect: pr,
1943          },
1944          '*',
1945        );
1946      }
1947    }
1948  }
1949  window.addEventListener(
1950    'scroll',
1951    function () {
1952      updatePositions();
1953      window.parent.postMessage(
1954        { type: 'wsb-scroll', scrollY: window.scrollY },
1955        '*',
1956      );
1957    },
1958    true,
1959  );
1960  window.addEventListener('resize', updatePositions);
1961
1962  var resizeObserver = new ResizeObserver(function () {
1963    if (state.selectedEl && state.selectedEl.isConnected) updatePositions();
1964  });
1965
1966  function hideAll() {
1967    stopEditing();
1968    resizeObserver.disconnect();
1969    state.selectedEl = null;
1970    state.hoveredEl = null;
1971    state.isPostCard = false;
1972    state.isServiceCard = false;
1973    state.isManualEditDisabled = false;
1974    hideOverlayGroup(ui.overlayNodes);
1975    hideOverlayGroup(ui.selectedOverlayNodes);
1976    state.pickerOpen = false;
1977    state.pickerAfterEl = null;
1978    state.pickerBeforeEl = null;
1979    savedRange = null;
1980    savedEditingEl = null;
1981  }
1982
1983  // Like hideAll(), but keeps the selected element so the editor
1984  // can continue to function after temporary disabling.
1985  function disableWithoutClearingSelection() {
1986    stopEditing();
1987    resizeObserver.disconnect();
1988    state.hoveredEl = null;
1989    hideOverlayGroup(ui.overlayNodes);
1990    hideOverlayGroup(ui.selectedOverlayNodes);
1991    state.pickerOpen = false;
1992    state.pickerAfterEl = null;
1993    state.pickerBeforeEl = null;
1994    savedRange = null;
1995    savedEditingEl = null;
1996  }
1997
1998  window.addEventListener('message', function (e) {
1999    if (!e.data) return;
2000    if (e.data.type === 'wsb-set-services') {
2001      storedServices = Array.isArray(e.data.services) ? e.data.services : [];
2002    }
2003    if (e.data.type === 'wsb-enable') {
2004      state.enabled = true;
2005      document.body.classList.add('wsb-active');
2006      updatePositions();
2007      window.parent.postMessage(
2008        { type: 'wsb-pathname', pathname: window.location.pathname },
2009        '*',
2010      );
2011    }
2012    if (e.data.type === 'wsb-quota-exceeded'){
2013        state.quotaExceeded = Boolean(e.data.data);
2014    }
2015    if (e.data.type === 'wsb-disable') {
2016      state.enabled = false;
2017      document.body.classList.remove('wsb-active');
2018      disableWithoutClearingSelection();
2019    }
2020    if (e.data.type === 'wsb-deselect') {
2021      hideAll();
2022    }
2023    if (e.data.type === 'wsb-set-mode') {
2024      state.blogPostMode = !!e.data.blogPost;
2025    }
2026    if (e.data.type === 'wsb-picker-closed') {
2027      state.pickerOpen = false;
2028      state.pickerAfterEl = null;
2029      state.pickerBeforeEl = null;
2030    }
2031    if (e.data.type === 'wsb-scroll-to') {
2032      window.scrollTo(0, e.data.scrollY);
2033    }
2034    if (e.data.type === 'wsb-get-scroll') {
2035      window.parent.postMessage(
2036        { type: 'wsb-scroll', scrollY: window.scrollY },
2037        '*',
2038      );
2039    }
2040
2041    if (
2042      e.data.type === 'wsb-apply-range-style' &&
2043      savedRange &&
2044      e.data.update
2045    ) {
2046      var span = document.createElement('span');
2047      var update = e.data.update;
2048      var props = Object.keys(update);
2049      for (var pi = 0; pi < props.length; pi++) {
2050        var p = props[pi];
2051        if (update[p]) span.style[p] = update[p];
2052      }
2053      var previousContent = savedEditingEl
2054        ? serializeInlineContent(savedEditingEl)
2055        : null;
2056      try {
2057        savedRange.surroundContents(span);
2058      } catch (_err) {
2059        // Selection spans multiple nodes — extract and rewrap
2060        var frag = savedRange.extractContents();
2061        span.appendChild(frag);
2062        savedRange.insertNode(span);
2063      }
2064      // Persist the updated HTML to the file
2065      if (savedEditingEl) {
2066        var wsbId = savedEditingEl.getAttribute('data-wsb-id');
2067        if (wsbId) {
2068          editEventHandlers[EDIT_EVENTS.TEXT_CONTENT_CHANGED]({
2069            selectedId: wsbId,
2070            content: serializeInlineContent(savedEditingEl),
2071            previousContent: previousContent,
2072          });
2073        }
2074      }
2075      // Clear selection UI after applying
2076      var sel = window.getSelection();
2077      if (sel) sel.removeAllRanges();
2078      savedRange = null;
2079      savedEditingEl = null;
2080      window.parent.postMessage({ type: 'wsb-range-deselect' }, '*');
2081    }
2082
2083    var editHandler = editEventHandlers[e.data.type];
2084    if (typeof editHandler === 'function') {
2085      editHandler(e.data.data || e.data);
2086    }
2087  });
2088
2089  function getProductsUrl(data) {
2090    var wpHost = data && (data.wpHost || data.wpUrl);
2091    if (typeof wpHost === 'string') {
2092      wpHost = wpHost.trim().replace(/\/+$/, '');
2093      if (wpHost) {
2094        return wpHost + '/wp-json/wc/store/v1/products?per_page=100';
2095      }
2096    }
2097
2098    return '/wp-json/wc/store/v1/products?per_page=100';
2099  }
2100
2101  // Handle product fetch request from editor. Prefer project wpHost; keep the
2102  // relative fallback for local same-origin WordPress setups.
2103  window.addEventListener('message', function (e) {
2104    if (e.data && e.data.type === 'wsb-fetch-products') {
2105      fetch(getProductsUrl(e.data))
2106        .then(function (r) {
2107          return r.ok ? r.json() : [];
2108        })
2109        .then(function (products) {
2110          var simplified = [];
2111          for (var i = 0; i < products.length; i++) {
2112            simplified.push({ id: products[i].id, name: products[i].name });
2113          }
2114          window.parent.postMessage(
2115            { type: 'wsb-products-response', products: simplified },
2116            '*',
2117          );
2118        })
2119        .catch(function () {
2120          window.parent.postMessage(
2121            { type: 'wsb-products-response', products: [] },
2122            '*',
2123          );
2124        });
2125    }
2126  });
2127
2128  // Forward Vite compile/runtime errors to the parent editor so onboarding only
2129  // reveals the homepage after the iframe has rendered a healthy app.
2130  function postViteStatus(healthy, message) {
2131    window.parent.postMessage(
2132      {
2133        type: healthy ? 'wsb-vite-ready' : 'wsb-vite-error',
2134        message: message || null,
2135      },
2136      '*',
2137    );
2138  }
2139
2140  function forwardHmrError(message) {
2141    window.parent.postMessage({ type: 'wsb-hmr-error', message: message }, '*');
2142    postViteStatus(false, message);
2143  }
2144
2145  var HMR_ERROR_PATTERNS = [
2146    'does not provide an export named',
2147    'Failed to fetch dynamically imported module',
2148    'Failed to load module script',
2149  ];
2150
2151  function isHmrError(message) {
2152    if (!message) return false;
2153    for (var i = 0; i < HMR_ERROR_PATTERNS.length; i++) {
2154      if (message.indexOf(HMR_ERROR_PATTERNS[i]) !== -1) return true;
2155    }
2156    return false;
2157  }
2158
2159  window.addEventListener('error', function (e) {
2160    if (isHmrError(e.message)) forwardHmrError(e.message);
2161  });
2162
2163  window.addEventListener('unhandledrejection', function (e) {
2164    var msg = e.reason && (e.reason.message || String(e.reason));
2165    if (isHmrError(msg)) forwardHmrError(msg);
2166  });
2167
2168  var viteStatusTimer = null;
2169  var lastViteStatusKey = null;
2170
2171  function getViteErrorOverlayMessage() {
2172    var overlay = document.querySelector('vite-error-overlay');
2173    if (!overlay) return null;
2174    var text = overlay.textContent || '';
2175    return text.trim() || 'Vite error overlay is visible';
2176  }
2177
2178  function reportViteOverlayStatus() {
2179    var message = getViteErrorOverlayMessage();
2180    var statusKey = message ? 'error:' + message : 'ready';
2181    if (statusKey === lastViteStatusKey) return;
2182    lastViteStatusKey = statusKey;
2183    postViteStatus(!message, message);
2184  }
2185
2186  function scheduleViteOverlayStatus(delayMs) {
2187    if (viteStatusTimer !== null) window.clearTimeout(viteStatusTimer);
2188    viteStatusTimer = window.setTimeout(function () {
2189      viteStatusTimer = null;
2190      reportViteOverlayStatus();
2191    }, delayMs);
2192  }
2193
2194  try {
2195    var viteOverlayObserver = new MutationObserver(function () {
2196      scheduleViteOverlayStatus(100);
2197    });
2198    viteOverlayObserver.observe(document.documentElement, {
2199      childList: true,
2200      subtree: true,
2201      characterData: true,
2202    });
2203  } catch (_err) {
2204    // MutationObserver is best-effort; load/error listeners still cover the
2205    // normal browser path.
2206  }
2207
2208  window.addEventListener('load', function () {
2209    scheduleViteOverlayStatus(500);
2210  });
2211  scheduleViteOverlayStatus(1000);
2212
2213  var style = document.createElement('style');
2214  style.textContent = `
2215    body.wsb-active [data-wsb-id]:not([contenteditable='true']) {
2216      cursor: pointer !important;
2217    }
2218    [contenteditable='true'] {
2219      outline: none;
2220    }
2221    body.wsb-active *,
2222    body.wsb-active *::before,
2223    body.wsb-active *::after {
2224      transition: none !important;
2225      animation-play-state: paused !important;
2226      animation-delay: -9999s !important;
2227      animation-fill-mode: forwards !important;
2228    }
2229    body.wsb-active *:hover,
2230    body.wsb-active *:hover::before,
2231    body.wsb-active *:hover::after {
2232      transition: none !important;
2233      animation-play-state: paused !important;
2234      animation-delay: -9999s !important;
2235      animation-fill-mode: forwards !important;
2236      transform: none !important;
2237      filter: none !important;
2238      backdrop-filter: none !important;
2239      box-shadow: none !important;
2240      text-shadow: none !important;
2241    }
2242  `;
2243  document.head.appendChild(style);
2244
2245  /**
2246   * Auto-scroll: scroll to a `data-wsb-focus-now`-stamped section after
2247   * the parent posts `wsb-ai-response-complete`; MO handles late renders.
2248   */
2249  var FOCUS_ATTR = 'data-wsb-focus-now';
2250  var FOCUS_SELECTOR = '[' + FOCUS_ATTR + ']';
2251  var SCROLL_PADDING_PX = 20;
2252  var HEADER_TOP_THRESHOLD_PX = 5;
2253  var SCROLL_END_FALLBACK_MS = 1500;
2254  // Tear-down budget so a missing marker doesn't leak the observer.
2255  const FOCUS_OBSERVER_TIMEOUT_MS = 10000;
2256
2257  var scrolledFocusEls = new WeakSet();
2258  var focusFlowRunning = false;
2259  let focusObserver = null;
2260  let focusObserverTimer = null;
2261
2262  function getHeaderOffset() {
2263    var candidates = document.querySelectorAll(
2264      'header, nav, [role="banner"], [data-wsb-header]',
2265    );
2266    var maxBottom = 0;
2267    for (var i = 0; i < candidates.length; i++) {
2268      var el = candidates[i];
2269      if (el.hasAttribute(FOCUS_ATTR)) continue;
2270      var cs = window.getComputedStyle(el);
2271      if (cs.position !== 'fixed' && cs.position !== 'sticky') continue;
2272      var rect = el.getBoundingClientRect();
2273      if (rect.top > HEADER_TOP_THRESHOLD_PX || rect.height <= 0) continue;
2274      if (rect.bottom > maxBottom) maxBottom = rect.bottom;
2275    }
2276    return maxBottom;
2277  }
2278
2279  // Prefers the native `scrollend` event (Chromium 114+, Firefox 109+) and
2280  // falls back to a timer so the caller always progresses on older engines.
2281  function waitForScrollEnd() {
2282    return new Promise(function (resolve) {
2283      var done = false;
2284      function finish() {
2285        if (done) return;
2286        done = true;
2287        window.removeEventListener('scrollend', finish);
2288        clearTimeout(timer);
2289        resolve();
2290      }
2291      window.addEventListener('scrollend', finish, { once: true });
2292      var timer = setTimeout(finish, SCROLL_END_FALLBACK_MS);
2293    });
2294  }
2295
2296  function stripFocusAttrsFromDom() {
2297    var stuck = document.querySelectorAll(FOCUS_SELECTOR);
2298    for (var i = 0; i < stuck.length; i++) {
2299      stuck[i].removeAttribute(FOCUS_ATTR);
2300    }
2301  }
2302
2303  function pickFocusTarget() {
2304    // Last marker-bearing element in document order wins: multiple markers
2305    // may coexist if the previous turn's server sweep hadn't finished, and
2306    // freshly-inserted sections are appended last.
2307    var candidates = document.querySelectorAll(FOCUS_SELECTOR);
2308    for (var i = candidates.length - 1; i >= 0; i--) {
2309      if (!scrolledFocusEls.has(candidates[i])) return candidates[i];
2310    }
2311    return null;
2312  }
2313
2314  function runFocusFlow(el) {
2315    if (!el || scrolledFocusEls.has(el) || focusFlowRunning) return;
2316    // Mark up-front so re-entrant mutations (React reconciliation flicker)
2317    // don't queue a second scroll for the same node.
2318    scrolledFocusEls.add(el);
2319    focusFlowRunning = true;
2320
2321    requestAnimationFrame(function () {
2322      if (!el.isConnected) {
2323        focusFlowRunning = false;
2324        return;
2325      }
2326
2327      var rect = el.getBoundingClientRect();
2328      var targetY =
2329        rect.top + window.scrollY - getHeaderOffset() - SCROLL_PADDING_PX;
2330      if (targetY < 0) targetY = 0;
2331      window.scrollTo({ top: targetY, behavior: 'smooth' });
2332
2333      waitForScrollEnd().then(function () {
2334        focusFlowRunning = false;
2335        // Strip from the DOM ourselves so the preview is visually clean
2336        // even if the server-side source sweep lags or fails.
2337        stripFocusAttrsFromDom();
2338        // The server discovers affected files itself — we never send an
2339        // identifier so a compromised iframe cannot name a file to edit.
2340        window.parent.postMessage({ type: 'wsb-clear-focus-now' }, '*');
2341      });
2342    });
2343  }
2344
2345  /** Disconnect the focus observer and cancel its safety timer. Idempotent. */
2346  const teardownFocusObserver = () => {
2347    if (focusObserver) {
2348      focusObserver.disconnect();
2349      focusObserver = null;
2350    }
2351    if (focusObserverTimer) {
2352      clearTimeout(focusObserverTimer);
2353      focusObserverTimer = null;
2354    }
2355  };
2356
2357  /**
2358   * Find the latest marker and start the scroll. Posts `wsb-focus-hold`
2359   * first so the parent overlay holds through the scroll.
2360   * @returns {boolean} `true` if a target was found.
2361   */
2362  const tryFocusScrollOnce = () => {
2363    const target = pickFocusTarget();
2364    if (!target) return false;
2365    teardownFocusObserver();
2366    window.parent.postMessage({ type: 'wsb-focus-hold' }, '*');
2367    runFocusFlow(target);
2368    return true;
2369  };
2370
2371  /**
2372   * Search for a focus marker. Try synchronously first (marker may
2373   * already be in the DOM); fall back to a MutationObserver bounded
2374   * by `FOCUS_OBSERVER_TIMEOUT_MS`.
2375   */
2376  const startFocusSearch = () => {
2377    teardownFocusObserver();
2378    if (tryFocusScrollOnce()) return;
2379
2380    focusObserver = new MutationObserver(() => {
2381      tryFocusScrollOnce();
2382    });
2383    focusObserver.observe(document.body, {
2384      childList: true,
2385      subtree: true,
2386      attributeFilter: [FOCUS_ATTR],
2387    });
2388    focusObserverTimer = setTimeout(
2389      teardownFocusObserver,
2390      FOCUS_OBSERVER_TIMEOUT_MS,
2391    );
2392  };
2393
2394  /**
2395   * AI-turn lifecycle from the parent editor:
2396   * - `wsb-ai-response-start` resets per-turn dedupe.
2397   * - `wsb-ai-response-complete` triggers the marker search.
2398   * Foreign sources are dropped via the source check.
2399   */
2400  window.addEventListener('message', (e) => {
2401    if (e.source !== window.parent) return;
2402    if (!e.data) return;
2403    if (e.data.type === 'wsb-ai-response-start') {
2404      scrolledFocusEls = new WeakSet();
2405      teardownFocusObserver();
2406    } else if (e.data.type === 'wsb-ai-response-complete') {
2407      startFocusSearch();
2408    }
2409  });
2410})();
2411</script>
2411
2412  </body>
2413</html>

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.