PageSourceSearch

https://ab.silberthal.de/visual-editor.js

js silberthal.de collected 2026-10-03 21:19:24 UTC 76,740 bytes, 564 lines download raw bytes

1window.abdrivenEditorCss="/* The visual editor is a dark sidebar docked to the customer's own page, inside a shadow root, so it carries its own styles.\n   Dense and aligned like a design tool's inspector: 30px controls, a two-column label/control grid, collapsible sections,\n   Inter, and gold only for what has been changed. */\n:host{\n  --bg:#181817;--card:#211f1e;--fill:#292927;--fill-hover:#343432;--line:#2f2f2d;--edge:#3d3d3a;--track:#0f0f0e;\n  --ink:#f0f0ec;--muted:#a3a39c;--faint:#8a8a84;--gold:#d9a23e;--gold-soft:rgba(217,162,62,.16);--danger:#e0776a;\n}\n/* The frame itself has every style reset (see visual-editor.js), so the type and colours start here. */\n*{box-sizing:border-box;scrollbar-color:#454542 transparent;scrollbar-width:thin}\n.shell{font:13px/1.5 Inter,-apple-system,BlinkMacSystemFont,\"Segoe UI\",sans-serif;color:var(--ink);color-scheme:dark;font-synthesis:none;text-align:left;-webkit-font-smoothing:antialiased;position:relative;height:100%;display:flex;flex-direction:column;background:var(--bg);border-left:1px solid var(--line);box-shadow:-12px 0 40px rgba(0,0,0,.28)}\n\n/* Header and toolbar: one compact block. Only the body scrolls. */\nheader{flex:none;display:flex;align-items:center;gap:6px;padding:10px 8px 8px 12px}\n.mark{flex:none;display:flex;gap:3px;align-items:center;justify-content:center;width:26px;height:26px;margin-right:6px;border-radius:7px;background:var(--ink)}\n.mark i{width:3px;height:13px;border-radius:2px;background:var(--bg);transform:skewY(-25deg)}\n.mark i:nth-child(2){height:9px;align-self:flex-start;margin-top:6px}\n.title{min-width:0;flex:1;display:flex;align-items:center;gap:8px}\nh2{margin:0;font-size:14px;line-height:1.3;letter-spacing:-.2px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.chip{flex:none;font-size:11px;line-height:16px;padding:1px 7px;border-radius:3px;background:var(--gold-soft);color:#f1d9a3;font-weight:500}\n.bar{flex:none;display:flex;align-items:center;gap:4px;padding:0 10px 10px 8px;border-bottom:1px solid var(--line)}\n.grow{flex:1}\n#body{flex:1;min-height:0;overflow:auto;padding:0 12px 24px}\n/* Above the buttons, one quiet line: whether everything is saved, and how long this editing session lasts. */\n.status{flex:none;display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 12px;border-top:1px solid var(--line);font-size:11.5px;line-height:1.4;color:var(--faint)}\n#sessionExpiry{text-align:right}\n#sessionExpiry a{color:var(--ink)}\nfooter{flex:none;display:flex;align-items:center;gap:8px;padding:10px 12px;border-top:1px solid var(--line);background:var(--bg)}\n#state{flex:none;color:var(--muted);display:flex;align-items:center;gap:7px}\n#state::before{content:'';width:7px;height:7px;border-radius:50%;background:#5fae7a}\n#state.unsaved{color:var(--ink)}\n#state.unsaved::before{background:var(--gold)}\nfooter .primary{min-width:104px}\n\n/* Messages float above the footer, so they never push the panel around. */\n#notice{position:absolute;left:12px;right:12px;bottom:98px;z-index:5;margin:0;padding:10px 12px;border-radius:6px;background:#2f2717;color:#f1d9a3;font-size:12px;line-height:1.5;overflow-wrap:anywhere;box-shadow:0 8px 24px rgba(0,0,0,.4),inset 0 0 0 1px rgba(217,162,62,.35)}\n\n/* Nothing selected yet. */\n.empty{display:grid;justify-items:center;gap:5px;text-align:center;margin-top:14px;padding:28px 20px;border-radius:6px;background:var(--card);color:var(--muted)}\n.empty svg{color:var(--ink);margin-bottom:6px}\n.empty b{color:var(--ink);font-size:13px;font-weight:600}\n.empty span{font-size:12px;line-height:1.6}\n\n/* The selected element stays in view while the settings scroll beneath it. */\nfieldset{border:0;margin:0;padding:0;min-width:0}\nfieldset:disabled{opacity:.6;pointer-events:none}\n.sticky{position:sticky;top:0;z-index:3;margin:0 -12px;padding:10px 12px 8px;background:var(--bg);border-bottom:1px solid var(--line)}\n.target{display:flex;align-items:center;gap:6px}\n.pill{flex:none;font:600 11px/16px ui-monospace,\"Cascadia Mono\",Consolas,monospace;background:var(--ink);color:var(--bg);padding:1px 7px;border-radius:3px}\n.what{flex:1;min-width:0;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.crumbs{display:flex;align-items:center;flex-wrap:nowrap;gap:2px;margin-top:4px;font:11px/18px ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--muted);overflow:hidden;white-space:nowrap}\n.crumbs b{color:var(--ink);font-weight:600}\n.crumbs .sep{opacity:.6;padding:0 1px}\n.crumb{padding:0 3px;font:inherit;font-weight:400;color:var(--muted);background:transparent;line-height:18px;min-width:0;max-width:130px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:0 1 auto}\n.crumbs b{flex:none}\n.crumb:hover:not(:disabled){color:var(--ink);background:var(--fill)}\n\n/* Collapsible sections. */\n.group{border-top:1px solid var(--line)}\n.sticky + .group{border-top:0}\nsummary{display:flex;align-items:center;gap:8px;min-height:38px;cursor:pointer;list-style:none;font-size:12px;font-weight:600;color:var(--ink);user-select:none}\nsummary::-webkit-details-marker{display:none}\n.chev{flex:none;width:14px;
1height:14px;position:relative}\n.chev::before{content:'';position:absolute;left:3px;top:2px;width:6px;height:6px;border:solid var(--muted);border-width:0 1.6px 1.6px 0;transform:rotate(-45deg);transition:transform .15s}\ndetails[open] > summary .chev::before{transform:rotate(45deg);top:1px}\nsummary:hover .chev::before{border-color:var(--ink)}\n.cnt{margin-left:auto;font-style:normal;font-size:11px;font-weight:500;color:var(--muted)}\n.cnt:not(:empty)::before{content:'';display:inline-block;width:6px;height:6px;margin-right:6px;border-radius:50%;background:var(--gold)}\n#changes .cnt::before,[data-sec=changes] .cnt::before{display:none}\n.sec{padding:0 0 14px}\n\n/* Settings: a label on the left, the control on the right. A gold dot marks what differs from the page. */\n.row{display:grid;grid-template-columns:96px minmax(0,1fr);align-items:center;gap:6px;min-height:34px;margin-bottom:2px}\n.lab{display:flex;align-items:center;gap:2px;min-width:0;font-size:12px;color:var(--muted)}\n.lab span:last-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.row.changed .lab,.stack.changed .lab{color:var(--ink)}\n.dot{flex:none;width:14px;height:14px;padding:0;position:relative;background:transparent;visibility:hidden}\n.dot::before{content:'';position:absolute;left:4px;top:4px;width:6px;height:6px;border-radius:50%;background:var(--gold)}\nbutton.dot:hover:not(:disabled){background:var(--gold-soft);border-radius:50%}\n.changed > .lab .dot,.changed > .head .dot,.changed > .tag .dot,.tag.changed .dot{visibility:visible}\n.ctl{display:flex;align-items:center;gap:6px;min-width:0}\n.stack{margin:2px 0 8px}\n.head{display:flex;align-items:center;min-height:26px}\n.hint{font-size:12px;color:var(--muted);margin:4px 0 8px;line-height:1.6}\n.quiet{width:100%;margin-top:6px;height:30px;padding:0 10px;text-align:left;color:var(--muted);font-weight:500;background:var(--card)}\n.quiet:hover:not(:disabled){color:var(--ink)}\n\n/* Controls: all 30px tall, all the same type. */\ninput,textarea,select{\n  font-size:12.5px;line-height:18px;font-family:inherit;display:block;width:100%;min-width:0;height:30px;padding:0 9px;border:1px solid var(--edge);border-radius:3px;\n  background:var(--card);color:var(--ink);outline:0;\n}\ntextarea{height:auto;resize:vertical;min-height:60px;padding:7px 9px}\ninput.mono{font-family:ui-monospace,\"Cascadia Mono\",Consolas,monospace;font-size:11.5px}\ninput::placeholder,textarea::placeholder{color:var(--faint)}\ninput:hover:not(:disabled),textarea:hover:not(:disabled){border-color:#55554f}\ninput:focus,textarea:focus{border-color:var(--gold);background:#1b1a19}\ninput:disabled,textarea:disabled,select:disabled{cursor:not-allowed}\n/* A colour swatch; transparent colours show a checkerboard. */\ninput[type=color].swatch{flex:none;width:30px;padding:3px;cursor:pointer;background:conic-gradient(#3a3a37 25%,#4a4a46 0 50%,#3a3a37 0 75%,#4a4a46 0) 0 0/8px 8px}\ninput[type=color]::-webkit-color-swatch-wrapper{padding:0}\ninput[type=color]::-webkit-color-swatch{border:0;border-radius:2px}\n/* A switch for yes/no settings. */\ninput.switch{-webkit-appearance:none;appearance:none;flex:none;width:34px;height:20px;padding:0;border:0;border-radius:10px;background:var(--fill-hover);position:relative;cursor:pointer;transition:background .18s}\ninput.switch::after{content:'';position:absolute;top:3px;left:3px;width:14px;height:14px;border-radius:50%;background:#c9c9c2;transition:transform .18s,background .18s}\ninput.switch:checked{background:var(--gold)}\ninput.switch:checked::after{transform:translateX(14px);background:#181817}\n/* Dropdowns: a soft fill and a chevron, no outline, like every other dropdown in the product. */\nselect{\n  -webkit-appearance:none;appearance:none;padding-right:30px;cursor:pointer;border-color:transparent;background-color:var(--fill);white-space:nowrap;text-overflow:ellipsis;\n  background-image:url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none' stroke='%23f0f0ec' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1.5 1.5 6 6l4.5-4.5'/%3E%3C/svg%3E\");\n  background-repeat:no-repeat;background-position:right 10px center;background-size:10px 7px;\n}\nselect:hover:not(:disabled){background-color:var(--fill-hover)}\n\nbutton{font-size:12.5px;font-weight:600;line-height:18px;font-family:inherit;border:0;border-radius:3px;background:var(--fill);color:var(--ink);padding:6px 12px;cursor:pointer}\nbutton:hover:not(:disabled){background:var(--fill-hover)}\nbutton:disabled{opacity:.4;cursor:default}\n.icon{flex:none;width:30px;height:30px;padding:0;display:grid;place-items:center;font-size:16px;line-height:1;color:var(--muted);background:transparent;text-decoration:none;border-radius:3px}\n.icon:hover:not(:disabled){background:var(--fill);color:var(--ink)}\n.primary{height:34px;background:var(--ink);color:var(--bg)}\n.primary:hover:not(:disabled){background:#fff}\n\n/* Two-way switches in the toolbar. */\n.seg{display:flex;gap:2px;padding:2px;background:var(--track);border-radius:5px}\n.seg button{height:26px;padding:0 9px;background:transparent;color:var(--muted);font-size:12px;font-weight:500;border-radius:3px}\n.seg button:hover:not(:disabled){color:var(--ink);background:transparent}\n.seg button.active{background:var(--fill-hover);color:var(--ink);font-weight:600}\n#viewOriginal.active{background:var(--gold);color:#181817}\n\n/* Alignment as icons. */\n.icons{display:flex;gap:2px;padding:2px;background:var(--track);border-radius:5px}\n.ic{width:34px;height:26px;padding:0;display:grid;place-items:center;background:transparent;color:var(--muted);border-radius:3px}\n.ic:hover:not(:disabled){color:var(--ink);background:transparent}\n.ic.on{background:var(--fill-hover);color:var(--ink)}\n.ic.on.inherited{color:var(--muted)}\n\n/* Margin and padding as nested boxes, like a browser's box-model diagram. */\n.box{position:relative;border-radius:6px;padding:20px 8px 6px;background:#1d1d1c;box-shadow:inset 0 0 0 1px var(--edge)}\n.box.inner{margin:2px 30px;background:#242423}\n.tag{position:absolute;top:3px;left:6px;display:flex;align-items:center;font-size:9.5px;font-weight:600;letter-spacing:.9px;text-transform:uppercase;color:var(--muted)}\n.tag .dot{width:12px}\n.tag .dot::before{left:3px}\n.c{display:flex;justify-content:center}\n.bxrow{display:flex;align-items:center;justify-content:space-between}\n.core{flex:1;height:14px;margin:0 4px;border-radius:3px;background:var(--track)}\ninput.bx{width:38px;height:24px;padding:0;text-align:center;font-size:12px;background:transparent;border-color:transparent}\ninput.bx:hover:not(:disabled){border-color:var(--edge)}\ninput.bx:focus{background:var(--bg)}\n\n/* The list of changes, one row per element. */\nol{padding:0;margin:0;list-style:none;display:grid;gap:4px}\nli{display:flex;align-items:stretch;gap:2px}\nli.none{color:var(--muted);font-size:12px;padding:2px 0 4px}\n.change{flex:1;min-width:0;display:grid;gap:4px;text-align:left;padding:8px 10px;background:var(--card);font-weight:400}\n.change:hover:not(:disabled){background:var(--fill)}\n.change.on{box-shadow:inset 2px 0 0 var(--gold)}\n.change b{font-weight:600;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.tags{display:flex;flex-wrap:wrap;gap:4px}\n.tags i{display:inline-flex;align-items:center;gap:5px;font-style:normal;font-size:11px;line-height:16px;padding:1px 6px;border-radius:3px;background:var(--fill-hover);color:#d8d8d2;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.sw{flex:none;width:9px;height:9px;border-radius:2px;box-shadow:inset 0 0 0 1px rgba(255,255,255,.35)}\nli > .icon{height:auto;width:28px;align-self:stretch;background:var(--card)}\nli > .icon:hover:not(:disabled){background:#3a2522;color:var(--danger)}\n\n.back{display:block;text-align:center;margin:14px 0 0;color:var(--ink)}\nbutton:focus-visible,a:focus-visible,input:focus-visible,textarea:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid var(--gold);outline-offset:1px}\n[hidden]{display:none!important}\n\n/* Collapsed: a slim rail that only offers to reopen the editor. */\n:h
1ost([data-collapsed]) .shell > :not(header){display:none}\n:host([data-collapsed]) header{padding:12px 0;flex-direction:column;justify-content:flex-start}\n:host([data-collapsed]) .title,:host([data-collapsed]) header a{display:none}\n:host([data-collapsed]) .mark{margin:0}\n\n/* On a phone the editor is a sheet along the bottom of the screen. */\n@media(max-width:719px){\n  .shell{border-left:0;border-top:1px solid var(--line);border-radius:14px 14px 0 0;box-shadow:0 -12px 40px rgba(0,0,0,.3)}\n  :host([data-collapsed]) header{flex-direction:row;padding:10px 14px}\n  :host([data-collapsed]) .title{display:flex}\n  .row{grid-template-columns:88px minmax(0,1fr)}\n  #notice{bottom:58px}\n}\n\n/* Chromium's customisable select: the open list is a dark flat card, like the dropdowns in the dashboard. */\n@supports (appearance:base-select){\n  select,::picker(select){-webkit-appearance:base-select;appearance:base-select}\n  select{background-image:none;display:flex;align-items:center;justify-content:space-between}\n  select::picker-icon{content:'';flex:none;width:7px;height:7px;margin-left:8px;border:solid var(--ink);border-width:0 1.8px 1.8px 0;transform:translateY(-2px) rotate(45deg)}\n  select:open::picker-icon{transform:translateY(2px) rotate(225deg)}\n  ::picker(select){padding:4px;margin-block:4px;border:1px solid var(--edge);border-radius:3px;background:#1f1f1e;box-shadow:0 14px 40px rgba(0,0,0,.45);outline:0}\n  select option{display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:2px;font-size:13px;color:var(--ink);cursor:pointer}\n  select option:hover,select option:focus-visible{background:var(--fill-hover);outline:0}\n  select option:checked{background:var(--gold-soft);color:#f1d9a3;font-weight:600}\n  select option::checkmark{color:var(--gold);order:1;margin-left:auto}\n}\n@media(prefers-reduced-motion:reduce){*{transition:none!important}}\n\n/* A change made on another page shows that page; choosing it goes there, and the editor comes along. */\n#changelist .where{display:block;margin:1px 0 3px;font:11px/1.4 ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--faint);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n#changelist li.elsewhere .change b{color:var(--muted);font-weight:500}\n/* A page the experiment does not run on: nothing to edit here. */\n.empty.outside{border:1px solid rgba(217,162,62,.35);background:var(--gold-soft)}\n.empty.outside b{color:#f1d9a3}\n\n/* Placing a section: the choice of position next to the selected element. */\n.empty.placing{border:1px solid rgba(217,162,62,.45);background:var(--gold-soft)}\n.empty.placing b{color:#f1d9a3}\n.place-choices{display:grid;gap:6px;width:100%;margin:10px 0 4px}\n.place-choices button{width:100%;padding:9px 10px;border-radius:6px;background:var(--gold);color:#1b1a18;font-weight:600}\n.place-choices button:hover{background:#e6b457}\n\n/* A placed section: its settings and blocks, where it sits, and images to upload or choose. */\n.sub{margin:12px 0 6px;font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--faint)}\n.seg.wide{width:100%}\n.seg.wide button{flex:1}\n.hint code{font:11px/1.5 ui-monospace,\"Cascadia Mono\",Consolas,monospace;color:var(--ink);word-break:break-all}\n.quiet.danger{color:var(--danger)}\n.quiet.danger:hover:not(:disabled){color:#f09a8f}\n.blk{margin:0 0 10px;padding:8px 10px 4px;border:1px solid var(--line);border-radius:6px;background:var(--card)}\n.blkhead{display:flex;align-items:center;gap:2px;margin-bottom:4px}\n.blkhead b{flex:1;min-width:0;font-size:12px;font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}\n.addblk select{flex:1;min-width:0}\n.imgpick{display:flex;gap:8px;align-items:flex-start;margin-bottom:6px}\n.thumb{flex:none;width:52px;height:52px;border-radius:5px;overflow:hidden;background:var(--track) linear-gradient(135deg,transparent 46%,var(--edge) 47%,var(--edge) 53%,transparent 54%);border:1px solid var(--line)}\n.thumb img{display:block;width:100%;height:100%;object-fit:cover}\n.imgctl{display:grid;gap:6px;flex:1;min-width:0}\n.imgrow{display:flex;gap:6px}\n.upload{position:relative;display:inline-flex;align-items:center;justify-content:center;flex:1;height:28px;padding:0 10px;border-radius:3px;background:var(--fill);color:var(--ink);font-size:12px;font-weight:600;cursor:pointer;overflow:hidden}\n.upload:hover{background:var(--fill-hover)}\n.upload input{position:absolute;inset:0;opacity:0;cursor:pointer}\n.upload.busy{opacity:.55;pointer-events:none}\n";
2// The visual editor: a dark sidebar docked to the right of the customer's own page. Click anything on the page and its
3// settings appear in the sidebar, ready to edit. Edits show on the page as they are made and are kept as changes; nothing
4// needs applying. It runs inside a shadow root so the site's CSS cannot touch it.
5(async function () {
6  if (document.querySelector('#split-editor-host')) return;
7  const script = document.currentScript, base_url = script.dataset.base, site = script.dataset.site;
8  const sdk = window.abdriven || window.SplitTest, visual = window.abdrivenVisual || window.SplitVisual;
9  let token = script.dataset.preview || new URLSearchParams(location.search).get('split_preview'); delete script.dataset.preview;
10  const placeId = script.dataset.place || ''; delete script.dataset.place;
11  const lifetime = new AbortController();
12  let info = null;
13  const listen = (target, type, callback, options = {}) => target.addEventListener(type, callback, { ...(typeof options === 'boolean' ? { capture: options } : options), signal: lifetime.signal });
14  const html = document.documentElement;
15  const margins = ['margin-right', 'margin-bottom'].map(name => [name, html.style.getPropertyValue(name), html.style.getPropertyPriority(name)]);
16  // The editor's frame is an element of its own kind with every page style reset and its own settings marked important: a shop
17  // theme's rules for divs (Shopify's Dawn hides every empty div, and this one only has a shadow root) cannot hide or reshape it.
18  const host = document.createElement('abdriven-editor');
19  const pin = (el, css) => { el.style.cssText = 'all:initial !important;' + css.split(';
19').filter(Boolean).map(d => d + ' !important').join(';'); };
20  function restoreFrame() { lifetime.abort(); host.remove(); makeRoom(0); for (const [name, value, priority] of margins) { if (value) html.style.setProperty(name,value,priority); else html.style.removeProperty(name); } }
21  host.id = 'split-editor-host';
22  document.body.append(host);
23  // Cookie banners, cart drawers, chat and trust badges often claim the highest z-index as well (a German shop easily has four).
24  // The browser's top layer sits above all of them, so the editor goes there where the browser supports it.
25  const onTop = () => { try { if (host.showPopover && host.isConnected && !host.matches(':popover-open')) { host.popover = 'manual'; host.showPopover(); } } catch { /* the z-index remains */ } };
26  // Fixed bars the width of the window (a header, a notice) would run under the editor: while it is open they end where it
27  // starts. What they had is put back when it closes.
28  const squeezed = new Map();
29  function makeRoom(width) {
30    for (const [el, [value, priority]] of squeezed) { if (value) el.style.setProperty('max-width', value, priority); else el.style.removeProperty('max-width'); }
31    squeezed.clear();
32    if (!width) return;
33    for (const el of document.body.querySelectorAll('*')) {
34      if (el === host || el.id === 'split-preview-status' || el.tagName.startsWith('ABDRIVEN-')) continue;
35      const box = el.getBoundingClientRect();
36      if (box.width < innerWidth * 0.5 || box.right <= innerWidth - width || getComputedStyle(el).position !== 'fixed') continue;
37      squeezed.set(el, [el.style.getPropertyValue('max-width'), el.style.getPropertyPriority('max-width')]);
38      el.style.setProperty('max-width', `calc(100% - ${width}px)`, 'important');
39    }
40  }
41  const root = host.attachShadow({ mode: 'open' });
42  // The server puts the stylesheet in front of this script; applied as a constructed sheet, neither a tracking gateway that
43  // forwards only scripts nor the shop's content security policy can keep it away. The link is for older browsers.
44  let styled = false;
45  if (typeof window.abdrivenEditorCss === 'string' && 'adoptedStyleSheets' in root) { try { const own = new CSSStyleSheet(); own.replaceSync(window.abdrivenEditorCss); root.adoptedStyleSheets = [own]; styled = true; } catch { /* fall back to the link */ } }
46  if (!styled) { const sheet = document.createElement('link'); sheet.rel = 'stylesheet'; sheet.href = base_url + '/visual-editor.css'; root.append(sheet); }
47  const shell = document.createElement('div'); shell.className = 'shell'; root.append(shell);
48
49  const newId = () => 'op_' + Array.from(crypto.getRandomValues(new Uint8Array(4)), b => b.toString(16).padStart(2, '0')).join('');
50  const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
51  const $ = selector => root.querySelector(selector);
52  // Rendering and uploading for sections are newer than some tracking gateways; when the website's gateway does not forward
53  // them yet (it then refuses the request), they go to abdriven directly.
54  async function api(path, data, origin = base_url) {
55    const direct = ['/render', '/asset'].includes(path) && info?.dashboard && origin !== info.dashboard ? info.dashboard : '';
56    let r;
57    try { r = await fetch(`${origin}/sdk/editor${path}?site=${encodeURIComponent(site)}&preview=${encodeURIComponent(token)}`, { method: data ? 'POST' : 'GET', credentials: 'omit', headers: data ? { 'Content-Type': 'application/json' } : {}, body: data ? JSON.stringify(data) : undefined }); }
58    catch { if (direct) return api(path, data, direct); throw Error('abdriven could not be reached. Check the connection and try again.'); }
59    if (direct && r.status === 404 && !/json/.test(r.headers.get('content-type') || '')) return api(path, data, direct);
60    const out = await r.json().catch(() => ({ error: `abdriven could not be reached (${r.status}). Try again in a moment.` })); if (!r.ok) throw Object.assign(Error(out.error), { details: out.details }); return out;
61  }
62  const BRAND = '<span class="mark"><i></i><i></i><i></i></span>';
63  const ICON = {
64    undo: '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M7 4 3.5 7.5 7 11"/><path d="M4 7.5h7.5a4.5 4.5 0 0 1 0 9H8"/></svg>',
65    redo: '<svg viewBox="0 0 20 20" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M13 4l3.5 3.5L13 11"/><path d="M16 7.5H8.5a4.5 4.5 0 0 0 0 9H12"/></svg>',
66    left: '<path d="M3 5h14M3 9h9M3 13h14M3 17h9"/>', center: '<path d="M3 5h14M6 9h8M3 13h14M6 17h8"/>', right: '<path d="M3 5h14M8 9h9M3 13h14M8 17h9"/>', justify: '<path d="M3 5h14M3 9h14M3 13h14M3 17h14"/>',
67  };
68
69  let ready = false;
70  const narrow = () => innerWidth < 720;
71  // ---- Docking: the page gives up the width (or, on a phone, the height) the editor uses ----
72  function layout() {
73    const collapsed = host.hasAttribute('data-collapsed');
74    const w = collapsed ? 56 : 340, h = collapsed ? 56 : Math.round(innerHeight * 0.6);
75    pin(host, 'display:block;position:fixed;z-index:2147483647;' + (narrow() ? `left:0;right:0;bottom:0;height:${h}px` : `top:0;right:0;bottom:0;width:${w}px`));
76    html.style.setProperty('margin-right', narrow() ? '' : w + 'px', 'important');
77    html.style.setProperty('margin-bottom', narrow() ? h + 'px' : '', 'important');
78    onTop(); makeRoom(narrow() ? 0 : w);
79    const banner = document.querySelector('#split-preview-status');
80    if (banner) { banner.style.right = (narrow() ? 12 : w + 12) + 'px'; banner.style.bottom = (narrow() ? h + 12 : 12) + 'px'; }
81    requestAnimationFrame(() => { if (ready) refreshBoxes(); });
82  }
83  layout();
84  listen(window, 'resize', layout);
85
86  // The session and any unsaved edits are kept in this tab (sessionStorage), so the editor follows the person to other pages
87  // of the website. Closing the editor, or a session that no longer works, clears both.
88  const sessionKey = `split:${site}:editor`, draftKey = `split:${site}:editor-draft`;
89  const keep = (key, data) => { try { if (data === null) sessionStorage.removeItem(key); else sessionStorage.setItem(key, JSON.stringify(data)); } catch { /* blocked storage: the editor then stays on this page */ } };
90  const kept = key => { try { return JSON.parse(sessionStorage.getItem(key) || 'null'); } catch { return null; } };
91  // The way back to abdriven. The snippet may load through the website's own tracking subdomain, which only forwards the SDK,
92  // so the dashboard's address comes from the server.
93  try { info = await api(''); } catch (e) { keep(sessionKey, null); keep(draftKey, null); shell.innerHTML = `<header>${BRAND}<div class="title"><h2>Editor unavailable</h2></div></header><div id="body"><p class="hint">${esc(e.message)}</p><a class="back" href="${esc(e.details?.dashboard || base_url)}/app">Return to abdriven</a><button id="closeUnavailable">Close editor</button></div>`; listen($('#closeUnavailable'), 'click', restoreFrame); return; }
94  const dashboard = info.dashboard || base_url, testLink = `${dashboard}/app#experiment/${info.experiment_id}`;
95
96  // ---- State ----
97  keep(sessionKey, { token, expires: info.expires });
98  let ops = structuredClone(info.visual), saved = JSON.stringify(ops), undoStack = [], redoStack = [], lastKey = null, lastAt = 0;
99  // Unsaved edits from another page of this session come along, unless the draft was saved elsewhere in the meantime.
100  const draft = kept(draftKey);
101  if (draft?.experiment_id === info.experiment_id && draft.variant === info.variant && draft.base === saved && Array.isArray(draft.ops)) ops = draft.ops; else keep(draftKey, null);
102  const remember = () => keep(draftKey, saved !== JSON.stringify(ops) ? { experiment_id: info.experiment_id, variant: info.variant, base: saved, ops } : null);
103  // Each change belongs to the page it was made on (the snippet decides which pages that covers). On a page the experiment
104  // does not run on, visitors would never see a change, so editing waits until the person browses to one of its pages.
105  const here = o => (sdk._visualHere ? sdk._visualHere(o) : true);
106  const outside = sdk._pageInTest ? !sdk._pageInTest() : false;
107  const describeRules = () => (info.page_rules || []).map(r => r.match_type === 'all' ? 'every page' : r.match_type === 'prefix' ? `pages starting with ${r.value}` : r.value).join(', ') || 'its pages';
108  let selected = null, selector = '', base = null, picking = !outside, compare = false, saving = false, linkKind = 'url', chain = [];
109  // A test started from a section: the editor opens ready to place it, so only the position is left to choose.
110  let placing = placeId ? info.sections.find(s => s.id === placeId) || null : null;
111  const openSection = new Map();
112  // A placed section being edited: its change id, the values shown in the panel and its schema (which fields it has).
113  let sectionEdit = null, renderTimer = 0, renderRun = 0, renderNow = null;
114  const schemas = new Map();
115  // What a placed section keeps: its rendered result and, for a library section, where it came from and the values it has.
116  const placedSection = s => s.kind === 'liquid' ? { name: s.name, kind: 'liquid', html: s.html || '', css: s.css || '', ...(s.id ? { source: { id: s.id, settings: s.settings || {}
116, blocks: s.blocks || [] } } : {}) } : s;
117  const sectionNode = id => document.querySelector(`[data-split-op="${CSS.escape(id)}"]`);
118  const SIDES = ['top', 'right', 'bottom', 'left'];
119  const STYLE_LABEL = { color: 'Text color', 'background-color': 'Background', 'font-size': 'Font size', padding: 'Padding', margin: 'Margin', 'border-radius': 'Corner radius', 'max-width': 'Max width', 'text-align': 'Alignment' };
120  const SIZED = ['font-size', 'border-radius', 'max-width'];
121  const LINK_KINDS = { url: ['Web address', 'https://example.com'], path: ['Page on this site', '/pricing'], section: ['Section on this page', ''], email: ['Email address', '[email protected]'], phone: ['Phone number', '+1 555 0100'] };
122  const find = (type, property) => ops.find(o => o.selector === selector && o.type === type && (property === undefined || o.property === property) && here(o));
123  const value = f => root.querySelector(`[data-f="${f}"]`)?.value ?? '';
124
125  // ---- Outlines drawn over the page: a dashed one under the pointer, a solid one on the selection ----
126  // They are protected from the theme's styles in the same way as the editor itself.
127  function outline(solid) {
128    const box = document.createElement('abdriven-outline'), label = document.createElement('abdriven-outline-label');
129    const look = `position:fixed;pointer-events:none;z-index:2147483645;box-sizing:border-box;border:${solid ? '2px solid' : '1.5px dashed'} #d9a23e;background:rgba(217,162,62,${solid ? '.12' : '.07'});border-radius:4px`;
130    const show = (visible, b = { left: 0, top: 0, width: 0, height: 0 }) => pin(box, `${look};display:${visible ? 'block' : 'none'};left:${b.left}px;top:${b.top}px;width:${b.width}px;height:${b.height}px`);
131    const tag = above => pin(label, `display:block;position:absolute;left:-2px;${above ? 'bottom:100%;margin:0 0 5px' : 'top:100%;margin:5px 0 0'};white-space:nowrap;font:600 11px/1 Inter,-apple-system,"Segoe UI",system-ui,sans-serif;color:#f0f0ec;background:#161615;padding:5px 8px;border-radius:4px;max-width:320px;overflow:hidden;text-overflow:ellipsis`);
132    show(false); tag(true);
133    if (solid) box.append(label);
134    document.body.append(box); return { box, label, show, tag, hide: () => show(false) };
135  }
136  const hover = outline(false), chosen = outline(true);
137  function place({ show, label, tag }, el) {
138    const b = el.getBoundingClientRect();
139    show(true, b);
140    label.textContent = el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (b.width && b.height ? '  ' + Math.round(b.width) + '×' + Math.round(b.height) : '');
141    tag(b.top > 34);
142  }
143  function refreshBoxes() {
144    if (sectionEdit && !selected?.isConnected) selected = sectionNode(sectionEdit.opId) || selected;
145    if (selected?.isConnected && !compare) place(chosen, selected); else chosen.hide();
146  }
147  listen(window, 'scroll', () => requestAnimationFrame(refreshBoxes), true);
148  // Bring an element into the part of the page the editor is not covering.
149  function reveal(el) {
150    const r = el.getBoundingClientRect(), covered = narrow() ? host.getBoundingClientRect().top : innerHeight;
151    if (r.top < 0 || r.bottom > covered - 8) window.scrollBy({ top: r.top - Math.max(24, (covered - r.height) / 3), behavior: 'smooth' });
152  }
153
154  // ---- Helpers for values ----
155  const snap = el => {
156    const cs = getComputedStyle(el), css = {};
157    for (const p of Object.keys(STYLE_LABEL)) css[p] = cs.getPropertyValue(p);
158    for (const p of ['margin', 'padding']) for (const s of SIDES) css[`${p}-${s}`] = cs.getPropertyValue(`${p}-${s}`);
159    return { text: el.children.length ? null : el.textContent, href: el.getAttribute('href') || '', blank: el.target === '_blank', src: el.currentSrc || el.src || '', alt: el.alt || '', css };
160  };
161  // What an element is on the page with none of the visual edits, however many have been made.
162  function baseline(el) {
163    let out; try { sdk._visualApply([]); out = snap(el); } finally { try { sdk._visualApply(compare ? [] : ops); } catch { /* the page keeps its last state */ } }
164    return out;
165  }
166  const toHex = css => { try { const c = document.createElement('canvas').getContext('2d'); c.fillStyle = '#000000'; c.fillStyle = css; return c.fillStyle.startsWith('#') ? c.fillStyle : '#000000'; } catch { return '#000000'; } };
167  const pageIds = () => [...document.querySelectorAll('[id]')].filter(e => !e.closest('#split-editor-host') && e.id && !/\s/.test(e.id)).map(e => e.id).slice(0, 200);
168  const kindOf = href => /^mailto:/i.test(href) ? 'email' : /^tel:/i.test(href) ? 'phone' : /^https?:/i.test(href) ? 'url' : href.startsWith('#') ? 'section' : 'path';
169  const hrefField = href => linkKind === 'section' ? (href.startsWith('#') ? href.slice(1) : '') : linkKind === kindOf(href) ? href.replace(/^(mailto|tel):/i, '') : '';
170  function composeHref(kind, raw) {
171    raw = raw.trim(); if (!raw) return '';
172    if (kind === 'email') return 'mailto:' + raw.replace(/^mailto:/i, '');
173    if (kind === 'phone') return 'tel:' + raw.replace(/^tel:/i, '').replace(/\s+/g, '');
174    if (kind === 'section') return '#' + raw.replace(/^#/, '');
175    if (kind === 'path') return /^[/#?.]/.test(raw) ? raw : '/' + raw;
176    return /^https?:/i.test(raw) ? raw : 'https://' + raw;
177  }
178  // Theme templates indent their text; unless the element keeps white space as written, it is shown and compared tidied.
179  const tidy = (el, t) => t === null || /^(pre|pre-wrap|pre-line|break-spaces)$/.test(getComputedStyle(el).whiteSpace) ? t : t.replace(/\s+/g, ' ').trim();
180  // Computed sizes often have many decimals (12.895px); the fields show them to one decimal.
181  const short = v =>
181 String(v || '').replace(/(\d+\.\d)\d+/g, '$1');
182  // Saved values are shown as they are (they are written back); only the page's own values in the placeholders are rounded.
183  const bare = v => String(v || '').replace(/px$/, ''), plain = v => short(bare(v));
184  const unit = v => /^-?\d+(\.\d+)?$/.test(v) ? v + 'px' : v;
185  // "10px 20px" -> the four sides, so the box widget can show what an existing change says.
186  function sidesOf(v) { const t = (v || '').trim().split(/\s+/).filter(Boolean); if (!t.length) return ['', '', '', '']; const [a, b = a, c = a, d = b] = t; return [a, b, c, d]; }
187
188  // ---- The panel ----
189  function render() {
190    shell.innerHTML = `<header>${BRAND}<div class="title"><h2>${esc(info.name)}</h2><span class="chip">Variation ${esc(info.variant)}</span></div><a class="icon" href="${esc(testLink)}" aria-label="Return to this experiment" title="Open abdriven">↗</a><button id="collapse" class="icon" aria-label="Collapse editor" title="Collapse">»</button></header>
191<div class="bar"><button id="undo" class="icon" aria-label="Undo" title="Undo (Ctrl+Z)">
191${ICON.undo}</button><button id="redo" class="icon" aria-label="Redo" title="Redo (Ctrl+Shift+Z)">${ICON.redo}</button><span class="grow"></span><div class="seg" role="group" aria-label="Pointer mode"><button id="pick" class="${picking ? 'active' : ''}" title="Click elements on the page to edit them">Edit</button><button id="browse" class="${picking ? '' : 'active'}" title="Use the page normally">Browse</button></div><div class="seg" role="group" aria-label="Page view"><button id="viewEdited" class="active" title="Show the page with your visual edits">Edited</button><button id="viewOriginal" title="Show the page without your visual edits">Original</button></div></div>
192<div id="body"><div id="inspector"></div><details class="group" data-sec="changes" open><summary><span class="chev"></span><span>Changes</span><em id="count" class="cnt"></em></summary><div class="sec"><ol id="changelist"></ol></div></details></div>
193<p id="notice" role="status" hidden></p>
194<div class="status"><span id="state"></span><span id="sessionExpiry"></span></div><footer><button id="exitEditor">Close editor</button><span class="grow"></span><button id="save">Save draft</button><button id="saveClose" class="primary">Save & close</button></footer>`;
195    inspect(); list(); state();
196  }
197  const notify = text => { const n = $('#notice'); if (!n) return; n.textContent = text; n.hidden = !text; };
198  const dirty = () => saved !== JSON.stringify(ops);
199  function closeEditor(back = false) { saved = JSON.stringify(ops); keep(sessionKey, null); keep(draftKey, null); window.abdriven.revoke(); hover.box.remove(); chosen.box.remove(); ready = false; restoreFrame(); if (back) location.assign(testLink); }
200  function state() {
201    $('#undo').disabled = !undoStack.length || compare; $('#redo').disabled = !redoStack.length || compare;
202    const expired = Date.now() >= info.expires;
203    $('#saveClose').disabled = saving || expired;
204    $('#sessionExpiry').innerHTML = expired ? `Session expired. <a href="${esc(testLink)}">Open a new editor session in abdriven</a>` : `Session until ${new Date(info.expires).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}`;
205    $('#save').disabled = saving || expired || !dirty(); $('#save').textContent = saving ? 'Saving…' : 'Save draft';
206    const s = $('#state'); s.textContent = dirty() ? 'Unsaved changes' : 'All changes saved'; s.classList.toggle('unsaved', dirty());
207  }
208  const describe = o => ({ text: 'Text', link: 'Link → ' + o.href, image: 'Image', style: `${STYLE_LABEL[o.property] || o.property}: ${o.value}`, hide: 'Hidden', move: 'Moved', section: 'Section: ' + (o.section?.name || '') }[o.type] || o.type);
209  // Changes are grouped by element; those of another page are listed with that page, and choosing one goes there.
210  const keyOf = o => JSON.stringify([here(o) ? '' : o.page || '', o.selector, ...(o.type === 'section' ? [o.id] : [])]);
211  function list() {
212    const groups = new Map(); for (const o of ops) { const key = keyOf(o); if (!groups.has(key)) groups.set(key, []); groups.get(key).push(o); }
213    $('#changelist').innerHTML = [...groups].map(([key, group]) => {
214      const sel = group[0].selector, page = here(group[0]) ? '' : group[0].page;
215      let el = null; if (!page) { try { el = document.querySelector(sel); } catch { /* an invalid selector cannot be shown */ } }
216      const placed = group[0].type === 'section' ? group[0] : null;
217      const title = placed ? placed.section?.name || 'Section' : page ? sel.split('>').pop().trim() : (el?.textContent.trim().replace(/\s+/g, ' ').slice(0, 34)) || el?.tagName.toLowerCase() || sel;
218      const tags = group.map(o => `<i>${o.type === 'style' && /color/.test(o.property) ? `<span class="sw" data-sw="${esc(o.value)}"></span>` : ''}${esc(placed ? 'Section' : describe(o))}</i>`).join('');
219      const target = page ? `data-page="${esc(page)}" title="Go to ${esc(page)} to edit"` : placed ? `data-op="${esc(placed.id)}" title="Edit this section"` : `data-select="${esc(sel)}" title="${esc(sel)}"`;
220      const on = placed ? sectionEdit?.opId === placed.id : !sectionEdit && sel === selector && selected;
221      return `<li${page ? ' class="elsewhere"' : ''}><button class="change${!page && on ? ' on' : ''}" ${target}><b>${esc(title)}</b>${page ? `<span class="where">${esc(page)}</span>` : ''}<span class="tags">${tags}</span></button><button class="icon" data-remove="${esc(key)}" aria-label="Remove the changes to this element" title="Remove these changes">×</button></li>`;
222    }).join('') || '<li class="none">No changes yet. Click something on the page.</li>';
223    root.querySelectorAll('[data-sw]').forEach(n => { n.style.background = n.dataset.sw; }); // set here, not inline, so a strict CSP cannot block it
224    $('#count').textContent = groups.size ? `${groups.size} element${groups.size === 1 ? '' : 's'} · ${ops.length} edit${ops.length === 1 ? '' : 's'}` : '';
225  }
226
227  // ---- The inspector: the settings of whatever is selected ----
228  const dot = key => key ? `<button class="dot" data-reset="${key}" title="Changed. Click to undo this change" aria-label="Undo this change"></button>` : '<span class="dot"></span>';
229  const row = (key, label, control) => `<div class="row" data-row="${key || ''}"><span class="lab">${dot(key)}<span>${label}</span></span><div class="ctl">${control}</div></div>`;
230  const stack = (key, label, control) => `<div class="stack" data-row="${key}"><div class="head"><span class="lab">${dot(key)}<span>${label}</span></span></div>${control}</div>`;
231  const section = (id, title, body) => `<details class="group" data-sec="${id}" ${(openSection.has(id) ? openSection.get(id) : id !== 'advanced') ? 'open' : ''}><summary><span class="chev"></span><span>${title}</span><em class="cnt"></em></summary><div class="sec">${body}</div></details>`;
232  const styleField = (p, kind = 'text') => {
233    const v = find('style', p)?.value || '', ph = short(base.css[p] || '');
234    return kind === 'color'
235      ? `<input type="color" class="swatch" data-f="swatch:${p}" value="${toHex(v || ph)}" aria-label="${STYLE_LABEL[p]} picker"><input data-f="style:${p}" value="${esc(v)}" placeholder="${esc(ph)}" autocomplete="off" spellcheck="false">`
236      : `<input data-f="style:${p}" value="${esc(v)}" placeholder="${esc(ph)}" autocomplete="off" spellcheck="false">`;
237  };
238  function boxWidget() {
239    const side = (prop, s, i) => `<input class="bx" data-f="side:${prop}:${s}" value="${esc(bare(sidesOf(find('style', prop)?.value)[i]))}" placeholder="${esc(plain(base.css[`${prop}-${s}`]))}" aria-label="${prop} ${s}" autocomplete="off" spellcheck="false">`;
240    const tag = (key, label) => `<span class="tag" data-row="${key}">${dot(key)}${label}</span>`;
241    return `<div class="box outer">${tag('style:margin', 'Margin')}<div class="c">${side('margin', 'top', 0)}</div><div class="bxrow">${side('margin', 'left', 3)}<div class="box inner">${tag('style:padding', 'Padding')}<div class="c">${side('padding', 'top', 0)}</div><div class="bxrow">${side('padding', 'left', 3)}<span class="core"></span>${side('padding', 'right', 1)}</div><div class="c">${side('padding', 'bottom', 2)}</div></div>${side('margin', 'right', 1)}</div><div class="c">${side('margin', 'bottom', 2)}</div></div>`;
242  }
243  function alignControl() {
244    const set = find('style', 'text-align')?.value, now = set || ({ start: 'left', end: 'right' }[base.css['text-align']] || base.css['text-align']);
245    return `<div class="icons" role="group" aria-label="Alignment">${['left', 'center', 'right', 'justify'].map(a => `<button type="button" class="ic${a === now ? ' on' : ''}${a === now && !set ? ' inherited' : ''}" data-align="${a}" aria-pressed="${a === now}" title="Align ${a}"><svg viewBox="0 0 20 20" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round">${ICON[a]}</svg></button>`).join('')}</div>`;
246  }
247  const crumbLabel = e => e.tagName.toLowerCase() + (e.id ? '#' + e.id : '');
248  // The fields of a placed section, built from its schema like the dialog in abdriven: texts, colours, choices and images.
249  const IMAGE_TYPES = 'image/png,image/jpeg,image/gif,image/webp';
250  function sectionField(setting, value, path) {
251    const label = esc(setting.label || setting.id || ''), f = `data-sf="${esc(path)}"`;
252    if (setting.type === 'header') return `<p class="sub">${esc(setting.content || '')}</p>`;
253    if (setting.type === 'paragraph') return `<p class="hint">${esc(setting.content || '')}</p>`;
254    if (!setting.id) return '';
255    if (setting.type === 'checkbox') return row('', label, `<input type="checkbox" class="switch" ${f} aria-label="${label}" ${value ? 'checked' : ''}>`);
256    if (['select', 'radio'].includes(setting.type)) return row('', label, `<select ${f}>${(setting.options || []).map(o => `<option value="${esc(o.value)}" ${String(o.value) === String(value) ? 'selected' : ''}>${esc(o.label || o.value)}</option>`).join('')}</select>`);
257    if (['range', 'number'].includes(setting.type)) return row('', label + (setting.unit ? ` (${esc(setting.unit)})` : ''), `<input type="number" ${f} value="${esc(value ?? '')}"${setting.min !== undefined ? ` min="${esc(setting.min)}"` : ''}${setting.max !== undefined ? ` max="${esc(setting.max)}"` : ''}${setting.step !== undefined ? ` step="${esc(setting.step)}"` : ''}>`);
258    if (['color', 'color_background'].includes(setting.type)) return row('', label, `<input type="color" class="swatch" data-sfc="${esc(path)}" value="${toHex(value || '#000000')}" aria-label="${label} picker"><input ${f} value="${esc(value ?? '')}" autocomplete="off" spellcheck="false">`);
259    if (['textarea', 'richtext', 'html', 'inline_richtext', 'liquid'].includes(setting.type)) return stack('', label, `<textarea ${f} rows="3">${esc(value ?? '')}</textarea>`);
260    if (setting.type === 'image_picker') return stack('', label, `<div class="imgpick"><span class="thumb">${value ? `<img src="${esc(value)}" alt="">` : ''}</span><div class="imgctl">`
261      + (info.assets.length ? `<select data-sfa="${esc(path)}
261" aria-label="Choose one of your images"><option value="">Your images…</option>${info.assets.map(a => `<option value="${esc(dashboard)}/assets/${a.id}">${esc(a.name)}</option>`).join('')}</select>` : '')
262      + `<div class="imgrow"><label class="upload">Upload<input type="file" accept="${IMAGE_TYPES}" data-sfu="${esc(path)}"></label>${value ? `<button type="button" class="upload" data-sfclear="${esc(path)}">Remove</button>` : ''}</div></div></div><input type="url" ${f} value="${esc(value ?? '')}" placeholder="https://…" aria-label="${label} address" autocomplete="off" spellcheck="false">`);
263    if (['text', 'url', 'video_url'].includes(setting.type)) return stack('', label, `<input ${setting.type === 'text' ? '' : 'type="url" '}${f} value="${esc(value ?? '')}" autocomplete="off" spellcheck="false">`);
264    return `<p class="hint">${label}: set in the Shopify theme.</p>`;
265  }
266  function sectionPanel() {
267    const op = ops.find(o => o.id === sectionEdit.opId), s = sectionEdit, schema = s.schema;
268    const head = `<div class="sticky"><div class="target"><span class="pill">section</span><span class="what">${esc(op.section?.name || 'Section')}</span><button class="icon" data-act="clear" title="Deselect (Esc)" aria-label="Deselect">
268×</button></div></div>`;
269    const where = section('position', 'Position', `<div class="seg wide" role="group" aria-label="Position">${[['beforebegin', 'Above'], ['afterend', 'Below'], ['beforeend', 'Inside']].map(([v, l]) => `<button type="button" data-spos="${v}" class="${op.position === v ? 'active' : ''}" aria-pressed="${op.position === v}">${l}</button>`).join('')}</div><p class="hint">Relative to <code>${esc(op.selector)}</code>.</p><button class="quiet" data-act="section-anchor">Select that element</button><button class="quiet danger" data-act="remove-section">Remove this section</button>`);
270    let body;
271    if (!op.section?.source) body = '<p class="hint">This section was placed before sections could be edited here. Remove it and place it again from the section library to change its texts and images.</p>';
272    else if (s.error) body = `<p class="hint">${esc(s.error)}</p>`;
273    else if (!schema) body = '<p class="hint">Loading the section’s settings…</p>';
274    else {
275      const blockDef = type => schema.blocks.find(b => b.type === type) || { settings: [] };
276      const blocks = s.blocks.map((b, i) => `<div class="blk"><div class="blkhead"><b>${esc(blockDef(b.type).name || b.type)} ${i + 1}</b><button type="button" class="icon" data-blk="move:${i}:-1" ${i ? '' : 'disabled'} aria-label="Move up" title="Move up">↑</button><button type="button" class="icon" data-blk="move:${i}:1" ${i < s.blocks.length - 1 ? '' : 'disabled'} aria-label="Move down" title="Move down">↓</button><button type="button" class="icon" data-blk="remove:${i}" aria-label="Remove" title="Remove">×</button></div>${(blockDef(b.type).settings || []).map(x => sectionField(x, b.settings?.[x.id], `blocks.${i}.${x.id}`)).join('')}</div>`).join('');
277      const add = schema.blocks.length && s.blocks.length < (schema.max_blocks || 50) ? `<div class="ctl addblk"><select data-f="blockType" aria-label="Block type">${schema.blocks.map(b => `<option value="${esc(b.type)}">${esc(b.name || b.type)}</option>`).join('')}</select><button type="button" data-blk="add">Add</button></div>` : '';
278      body = section('content', 'Content', schema.settings.map(x => sectionField(x, s.settings[x.id], `settings.${x.id}`)).join('') || '<p class="hint">This section has no settings.</p>')
279        + (schema.blocks.length ? section('blocks', `Blocks`, blocks + add) : '') + `<p class="hint" id="sectionWarnings">${esc((s.warnings || []).join(' '))}</p>`;
280    }
281    return `<fieldset id="fs" ${compare ? 'disabled' : ''}>${head}${body}${where}</fieldset>`;
282  }
283  function inspect() {
284    const box = $('#inspector'); if (!box) return;
285    if (sectionEdit && ops.some(o => o.id === sectionEdit.opId)) { box.innerHTML = sectionPanel(); root.querySelectorAll('.thumb img').forEach(img => img.addEventListener('error', () => img.remove(), { once: true })); return; }
286    if (sectionEdit) sectionEdit = null;
287    if (placing && !outside) {
288      box.innerHTML = selected
289        ? `<div class="empty placing"><b>Where should “${esc(placing.name)}” go?</b><span>Next to the highlighted element. Click another element to choose a different spot.</span><div class="place-choices"><button data-place="beforebegin">Above it</button><button data-place="afterend">Below it</button><button data-place="beforeend">Inside it, at the end</button></div><button class="quiet" data-act="cancel-place">Cancel placing</button></div>`
290        : `<div class="empty placing"><b>Place “${esc(placing.name)}”</b><span>Click the element on the page the section should go next to.</span><button class="quiet" data-act="cancel-place">Cancel placing</button></div>`;
291      return;
292    }
293    if (!selected && outside) { box.innerHTML = `<div class="empty outside"><b>This page is not part of the test</b><span>Visitors see the variation on ${esc(describeRules())}. Browse to one of those pages to edit it; the editor comes along.</span></div>`; return; }
294    if (!selected) { box.innerHTML = `<div class="empty"><svg viewBox="0 0 24 24" width="28" height="28" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="m5 3 14 8-6 2-2 6z"/></svg><b>Click anything on the page</b><span>Words, links, images, colours and spacing can all be changed here. Edits show instantly.</span></div>`; return; }
295    const el = selected, tag = el.tagName.toLowerCase(), isLink = tag === 'a', isImg = tag === 'img';
296    const opText = find('text'), opLink = find('link'), opImg = f
296ind('image'), opMove = find('move');
297    chain = []; for (let e = el; e && e !== document.body && e !== html; e = e.parentElement) chain.unshift(e);
298    const shown = chain.length > 4 ? chain.slice(-4) : chain, crumbs = (chain.length > 4 ? '<span class="sep">…</span>' : '') + shown.map((e, i) => `${i || chain.length > 4 ? '<span class="sep">›</span>' : ''}${e === el ? `<b>${esc(crumbLabel(e))}</b>` : `<button class="crumb" data-crumb="${chain.indexOf(e)}" title="${esc(crumbLabel(e))}">${esc(crumbLabel(e))}</button>`}`).join('');
299    const live = (opText ? opText.value : base.text) || '', name = live.trim().replace(/\s+/g, ' ').slice(0, 38) || el.getAttribute('aria-label') || el.id || '';
300    let content = '';
301    if (base.text !== null && !isImg) content += stack('text', isLink ? 'Link text' : 'Text', `<textarea data-f="text" rows="2">${esc(opText ? opText.value : base.shown)}</textarea>`);
302    else if (!isImg && !isLink) content += '<p class="hint">This element holds other elements. Click the words inside it to change them.</p>';
303    if (isLink) {
304      const href = opLink?.href ?? base.href, dest = linkKind === 'section'
305        ? `<select data-f="href"><option value="">Choose a section</option>${pageIds().map(id => `<option value="${esc(id)}" ${'#' + id === href ? 'selected' : ''}>${esc(id)}</option>`).join('')}</select>`
306        : `<input data-f="href" value="${esc(hrefField(href))}" placeholder="${esc(LINK_KINDS[linkKind][1])}" autocomplete="off" spellcheck="false">`;
307      content += row('', 'Link to', `<select data-f="linkkind">${Object.entries(LINK_KINDS).map(([k, [n]]) => `<option value="${k}" ${k === linkKind ? 'selected' : ''}>${n}</option>`).join('')}</select>`)
308        + row('link', 'Address', dest)
309        + row('', 'New tab', `<input type="checkbox" class="switch" data-f="blank" aria-label="Open in a new tab" ${(opLink?.blank ?? base.blank) ? 'checked' : ''}>`);
310    } else if (!isImg && el.closest('a')) content += '<button class="quiet" data-act="parent-link">This is inside a link. Edit the link →</button>';
311    if (isImg) content += row('', 'Library', `<select data-f="asset"><option value="">Choose an image</option>${info.assets.map(a => `<option value="${esc(dashboard)}/assets/${a.id}">${esc(a.name)}</option>`).join('')}</select>`)
312      + row('image', 'Image URL', `<input data-f="url" type="url" value="${esc(opImg ? opImg.url : base.src)}" placeholder="https://...">`)
313      + row('', 'Description', `<input data-f="alt" value="${esc(opImg ? opImg.alt : base.alt)}">`);
314    box.innerHTML = `<fieldset id="fs" ${compare ? 'disabled' : ''}>
315<div class="sticky"><div class="target"><span class="pill">${esc(tag)}</span><span class="what">${esc(name)}</span><button class="icon" data-act="parent" title="Select the parent element" aria-label="Select the parent element">↑</button><button class="icon" data-act="clear" title="Deselect (Esc)" aria-label="Deselect">
315×</button></div><nav class="crumbs" aria-label="Element path">${crumbs}</nav></div>
316${content ? section('content', 'Content', content) : ''}
317${section('type', 'Typography', row('style:color', 'Text color', styleField('color', 'color')) + row('style:font-size', 'Font size', styleField('font-size')) + row('style:text-align', 'Alignment', alignControl()))}
318${section('spacing', 'Spacing', boxWidget())}
319${section('look', 'Appearance', row('style:background-color', 'Background', styleField('background-color', 'color')) + row('style:border-radius', 'Corner radius', styleField('border-radius')) + row('style:max-width', 'Max width', styleField('max-width')) + row('hide', 'Hide element', `<input type="checkbox" class="switch" data-f="hide" aria-label="Hide this element" ${find('hide') ? 'checked' : ''}>`))}
320${section('advanced', 'Advanced', `${row('', 'Selector', `<input class="mono" data-f="selector" value="${esc(selector)}" aria-label="CSS selector" spellcheck="false" autocomplete="off">`)}
321${row('move', 'Move to', `<input data-f="moveTarget" value="${esc(opMove?.target || '')}" placeholder="#other-element" autocomplete="off" spellcheck="false">`)}
322${row('', 'Position', `<select data-f="movePos"><option value="before" ${opMove?.position === 'before' ? 'selected' : ''}>Before it</option><option value="after" ${opMove?.position === 'after' ? 'selected' : ''}>After it</option></select>`)}
323${info.sections.length ? `${row('', 'Insert', `<select data-f="section"><option value="">Choose a saved section</option>${info.sections.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('')}</select>`)}${row('', 'Place', `<select data-f="sectionPos"><option value="afterend">After this element</option><option value="beforebegin">Before this element</option><option value="beforeend">Inside, at the end</option></select>`)}<button class="quiet" data-act="insert-section">Insert section</button>` : '<p class="hint">Create reusable sections under Section library in abdriven to insert them here.</p>'}`)}
324</fieldset>`;
325    mark();
326  }
327  // Rows that differ from the page carry a gold dot (click it to undo that change), and their section shows how many.
328  const isChanged = key => { const [kind, prop] = key.split(':'); return kind === 'style' ? Boolean(find('style', prop)) : Boolean(find(kind)); };
329  function mark() {
330    root.querySelectorAll('[data-row]').forEach(r => { const key = r.dataset.row; if (key) r.classList.toggle('changed', isChanged(key)); });
331    root.querySelectorAll('details.group[data-sec]').forEach(d => { const n = d.querySelectorAll('.changed').length, c = d.querySelector(':scope > summary .cnt'); if (c && d.dataset.sec !== 'changes') c.textContent = n || ''; });
332  }
333
334  // ---- Changes: every edit becomes (or updates, or removes) one change, and shows on the page straight away ----
335  function commit(next, key) {
336    if (next.length > 50) { notify('Maximum 50 visual changes.'); return false; }
337    try { sdk._visualApply(next); }
338    catch (error) { notify(error.message); try { sdk._visualApply(ops); } catch { /* the page is left as it was */ } return false; }
339    const now = Date.now();
340    if (!(key && key === lastKey && now - lastAt < 1200)) { undoStack.push(structuredClone(ops)); if (undoStack.length > 100) undoStack.shift(); redoStack = []; }
341    lastKey = key || null; lastAt = now;
342    ops = structuredClone(next); remember(); notify(''); list(); state(); mark(); requestAnimationFrame(refreshBoxes); return true;
343  }
344  function upsert(type, property, op, key) {
345    const match = o => o.selector === selector && o.type === type && (property === undefined || o.property === property) && here(o), prev = ops.find(match);
346    if (!op) return prev ? commit(ops.filter(o => !match(o)), key) : true;
347    if (prev) return commit(ops.map(o => match(o) ? { ...op, id: prev.id, ...(prev.page ? { page: prev.page } : {}), ...(prev.note ? { note: prev.note } : {}) } : o), key);
348    return commit([...ops, { id: newId(), ...op, page: location.pathname }], key);
349  }
350  function setStyle(property, raw) {
351    let v = raw.trim(); if (SIZED.includes(property)) v = unit(v);
352    if (v && (!/^[\w\s#.,()%+\/-]+$/.test(v) || /(?:url|expression|var)\s*\(/i.test(v))) { notify('Use a simple c
352olour, size or alignment.'); return; }
353    upsert('style', property, v ? { type: 'style', selector, property, value: v } : null, 'style' + property + selector);
354  }
355  // Four boxes become one shorthand value. Boxes left empty keep what the page already has.
356  function setBox(property) {
357    const vals = SIDES.map(s => value(`side:${property}:${s}`).trim());
358    if (vals.every(v => !v)) return setStyle(property, '');
359    const [t, r, b, l] = vals.map((v, i) => unit(v || base.css[`${property}-${SIDES[i]}`] || '0px'));
360    setStyle(property, t === r && r === b && b === l ? t : t === b && r === l ? `${t} ${r}` : `${t} ${r} ${b} ${l}`);
361  }
362  function edit(f, target) {
363    if (!selected) return;
364    if (f === 'text') { const what = $('.what'); if (what) what.textContent = target.value.trim().replace(/\s+/g, ' ').slice(0, 38); return upsert('text', undefined, target.value === base.shown || target.value === base.text ? null : { type: 'text', selector, value: target.value }, 'text' + selector); }
365    if (f === 'href' || f === 'blank') {
366      const raw = value('href'), blank = root.querySelector('[data-f="blank"]').checked, untouched = base.href && raw.trim() === hrefField(base.href);
367      const href = untouched ? base.href : composeHref(linkKind, raw);
368      return upsert('link', undefined, !href || (href === base.href && blank === base.blank) ? null : { type: 'link', selector, href, ...(blank !== base.blank ? { blank } : {}) }, 'link' + selector);
369    }
370    if (f === 'url' || f === 'alt') {
371      const url = value('url').trim(), alt = value('alt');
372      if (url && !/^https?:\/\//i.test(url)) return;
373      return upsert('image', undefined, !url || (url === base.src && alt === base.alt) ? null : { type: 'image', selector, url, alt }, 'image' + selector);
374    }
375    if (f === 'asset') { if (target.value) { root.querySelector('[data-f="url"]').value = target.value; edit('url'); } return; }
376    if (f.startsWith('side:')) return setBox(f.split(':')[1]);
377    if (f.startsWith('swatch:')) { const p = f.slice(7); root.querySelector(`[data-f="style:${p}"]`).value = target.value; return setStyle(p, target.value); }
378    if (f.startsWith('style:')) { const p = f.slice(6); const swatch = root.querySelector(`[data-f="swatch:${p}"]`); if (swatch && /^#[0-9a-f]{6}$/i.test(target.value.trim())) swatch.value = target.value.trim(); return setStyle(p, target.value); }
379    if (f === 'hide') return upsert('hide', undefined, target.checked ? { type: 'hide', selector } : null, 'hide' + selector);
380    if (f === 'moveTarget' || f === 'movePos') { const to = value('moveTarget').trim(); return upsert('move', undefined, to ? { type: 'move', selector, target: to, position: value('movePos') } : null, 'move' + selector); }
381  }
382  function selectSection(opId) {
383    const op = ops.find(o => o.id === opId && o.type === 'section'); if (!op) return;
384    if (sectionEdit?.opId !== opId) sectionEdit = { opId, settings: structuredClone(op.section.source?.settings || {}), blocks: structuredClone(op.section.source?.blocks || []), schema: schemas.get(op.section.source?.id) || null, error: '' };
385    const node = sectionNode(opId); selected = node; selector = ''; base = null;
386    notify(''); inspect(); list(); if (node) { place(chosen, node); reveal(node); } else chosen.hide();
387    if (op.section.source && !sectionEdit.schema) loadSchema(op);
388  }
389  async function loadSchema(op) {
390    try {
391      const out = await api('/render', { id: op.section.source.id, settings: op.section.source.settings, blocks: op.section.source.blocks });
392      schemas.set(op.section.source.id, out.schema);
393      if (sectionEdit?.opId === op.id) { sectionEdit.schema = out.schema; sectionEdit.warnings = out.warnings; }
394    } catch (error) { if (sectionEdit?.opId === op.id) sectionEdit.error = error.message; }
395    if (sectionEdit?.opId === op.id) inspect();
396  }
397  // Edited values are rendered by abdriven (the same Liquid and cleaning as in the section library) and replace the section
398  // on the page. Quick successive edits become one step in the undo history.
399  function rerender(delay = 350) {
400    const edit = sectionEdit; if (!edit) return;
401    clearTimeout(renderTimer);
402    const run = async () => {
403      renderTimer = 0; renderNow = null;
404      const turn = ++renderRun, op = ops.find(o => o.id === edit.opId); if (!op?.section.source) return;
405      try {
406        const out = await api('/render', { id: op.section.source.id, settings: edit.settings, blocks: edit.blocks });
407        if (turn !== renderRun || !ops.some(o => o.id === edit.opId)) return;
408        commit(ops.map(o => o.id === edit.opId ? { ...o, section: { name: o.section.name, kind: 'liquid', html: out.html, css: out.css, source: out.source } } : o), 'section' + edit.opId);
409        edit.warnings = out.warnings; const w = $('#sectionWarnings'); if (w) w.textContent = out.warnings.join(' ');
410      } catch (error) { if (turn === renderRun) notify(error.message); }
411    };
412    renderNow = run; renderTimer = setTimeout(run, delay);
413  }
414  // Before saving, an edit still waiting to be rendered is rendered first.
415  async function flushRender() { if (renderTimer && renderNow) { clearTimeout(renderTimer); await renderNow(); } }
416  function setSectionValue(path, value) {
417    if (!sectionEdit) return;
418    const [where, a, b] = path.split('.');
419    if (where === 'settings') sectionEdit.settings[a] = value; else if (sectionEdit.blocks[Number(a)]) (sectionEdit.blocks[Number(a)].settings ||= {})[b] = value;
420  }
421  async function uploadImage(input) {
422    const file = input.files?.[0]; if (!file) return;
423    const label = input.closest('.upload');
424    try {
425      if (file.size > 5 * 1024 * 1024) throw Error('Upload an image smaller than 5 MB.');
426      label?.classList.add('busy'); notify('Uploading the image…');
427      const content = await new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result).split(',')[1]); reader.onerror = reject; reader.readAsDataURL(file); });
428      const asset = await api('/asset', { name: file.name.slice(0, 150), content });
429      info.assets = [asset, ...info.assets.filter(a => a.id !== asset.id)];
430      setSectionValue(input.dataset.sfu, asset.url); inspect(); rerender(0); notify('Image uploaded. It is also kept with this website’s images in abdriven.');
431    } catch (error) { label?.classList.remove('busy'); notify(error.message); }
432  }
433  function select(el, requestedSelector) {
434    if (!el || el === document.body || el === html) return;
435    if (outside) { notify(`This page is not part of the test. Visitors see the variation on ${describeRules()}.`); return; }
436    const placed = el.closest?.('[data-split-op]');
437    if (placed) {
438      if (placing) { notify('Choose an element of the page itself, not a section placed by this test.'); return; }
439      return selectSection(placed.dataset.splitOp);
440    }
441    sectionEdit = null;
442    base = baseline(el); base.shown = tidy(el, base.text); selected = el; selector = requestedSelector || visual.selector(el);
443    const link = ops.find(o => o.selector === selector && o.type === 'link' && here(o)); linkKind = kindOf(link?.href ?? base.href);
444    notify(''); inspect(); list(); place(chosen, el); reveal(el);
445  }
446  function deselect() { sectionEdit = null; selected = null; selector = ''; inspect(); list(); refreshBoxes(); }
447  function step(from, to) {
448    if (!from.length || compare) return;
449    const next = from.at(-1), current = structuredClone(ops);
450    try { sdk._visualApply(next); } catch (error) { notify(error.message); return; }
451    from.pop(); to.push(current); ops = structuredClone(next); remember(); lastKey = null; notify('');
452    if (sectionEdit) { clearTimeout(renderTimer); renderTimer = 0; renderRun++; const op = ops.find(o => o.id === sectionEdit.opId); if (op?.section.source) { sectionEdit.settings = structuredClone(op.section.source.settings); sectionEdit.blocks = structuredClone(op.section.source.blocks); } }
453    inspect(); list(); state(); requestAnimationFrame(refreshBoxes);
454  }
455  function setView(original) {
456    if (compare === original) return;
457    compare = original; sdk._visualApply(compare ? [] : ops);
458    $('#viewEdited').classList.toggle('active', !compare); $('#viewOriginal').classList.toggle('active', compare);
459    hover.hide(); root.querySelector('#fs')?.toggleAttribute('disabled', compare); state(); refreshBoxes();
460    notify(compare ? 'Showing the page without your visual edits. Custom code still applies.' : '');
461  }
462
463  // ---- Wiring ----
464  render(); ready = true; layout();
465  if (placeId && !placing) notify('The section to place is no longer on this website.');
466  // Unsaved edits brought along from another page show on this one straight away.
467  if (dirty()) { try { sdk._visualApply(ops); list(); } catch (error) { notify(error.message); } }
468  listen(document, 'pointerover', e => { if (picking && !compare && !e.composedPath().includes(host) && e.target instanceof Element && e.target !== hover.box && e.target !== document.body && e.target !== html) place(hover, e.target.closest('[data-split-op]') || e.target); }, true);
469  listen(document, 'pointerout', e => { if (e.relatedTarget === null) hover.hide(); }, true);
470  // Only a person's own click selects. Themes click their own tabs, sliders and variant pickers from code (e.isTrusted is
471  // false then); those clicks go through, so the page starts up as it would without the editor.
472  listen(document, 'click', e => {
473    if (!e.isTrusted || !picking || compare || e.composedPath().includes(host) || !(e.target instanceof Element)) return;
474    e.preventDefault(); e.stopImmediatePropagation(); select(e.target);
475  }, true);
476  listen(document, 'keydown', e => {
477    const typing = root.activeElement && /^(INPUT|TEXTAREA|SELECT)$/.test(root.activeElement.tagName);
478    if (e.key === 'Escape' && selected && !typing) { deselect(); return; }
479    if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !typing && !/^(INPUT|TEXTAREA)$/.test(document.activeElement?.tagName)) { e.preventDefault(); e.shiftKey ? step(redoStack, undoStack) : step(undoStack, redoStack); }
480  });
481  listen(root, 'input', e => {
482    const t = e.target, sf = t.dataset?.sf, sfc = t.dataset?.sfc;
483    if (sf && sectionEdit) { if (t.type !== 'file') { setSectionValue(sf, t.type === 'checkbox' ? t.checked : t.type === 'number' ? (t.value === '' ? null : Number(t.value)) : t.value); const swatch = root.querySelector(`[data-sfc="${CSS.escape(sf)}"]`); if (swatch && /^#[0-9a-f]{6}$/i.test(t.value.trim())) swatch.value = t.value.trim(); rerender(); } return; }
484    if (sfc && sectionEdit) { setSectionValue(sfc, t.value); const text = root.querySelector(`[data-sf="${CSS.escape(sfc)}"]`); if (text) text.value = t.value; rerender(); return; }
485    const f = t.dataset?.f; if (f && f !== 'selector' && f !== 'linkkind' && f !== 'blockType') edit(f, t);
486  });
487  listen(root, 'toggle', e => { const d = e.target; if (d.dataset?.sec) openSection.set(d.dataset.sec, d.open); }, true);
488  listen(root, 'change', e => {
489    if (e.target.dataset?.sfu) { uploadImage(e.target); return; }
490    if (e.target.dataset?.sfa && e.target.value) { setSectionValue(e.target.dataset.sfa, e.target.value); inspect(); rerender(0); return; }
491    if (e.target.dataset?.sf && ['checkbox', 'select-one'].includes(e.target.type)) { rerender(0); return; }
492    const f = e.target.dataset?.f;
493    if (f === 'selector') { let match = null; try { const found = document.querySelectorAll(e.target.value); if (found.length === 1 && !found[0].closest('#split-editor-host')) match = found[0]; } catch { /* not a selector */ } if (match) select(match, e.target.value.trim()); else { notify('That selector must match exactly one element.'); e.target.value = selector; } }
494    if (f === 'linkkind') { linkKind = e.target.value; inspect(); root.querySelector('[data-f="linkkind"]')?.focus(); }
495  });
496  listen(root, 'click', async e => {
497    const button = e.target.closest('button'); if (!button) return;
498    try {
499      if (button.dataset.select) { const el = document.querySelector(button.dataset.select); if (el) select(el); else notify('That element is not on this page.'); }
500      if (button.dataset.op) selectSection(button.dataset.op);
501      if (button.dataset.spos && sectionEdit) { const id = sectionEdit.opId; if (commit(ops.map(o => o.id === id ? { ...o, position: button.dataset.spos } : o))) selectSection(id); }
502      if (button.dataset.sfclear && sectionEdit) { setSectionValue(button.dataset.sfclear, ''); inspect(); rerender(0); }
503      if (button.dataset.blk && sectionEdit) {
504        const [what, i, by] = button.dataset.blk.split(':'), list = sectionEdit.blocks, n = Number(i);
505        if (what === 'add') { const type = value('blockType'), def = sectionEdit.schema?.blocks.find(b => b.type === type); if (def) list.push({ type, settings: Object.fromEntries((def.settings || []).filter(x => x.id).map(x => [x.id, x.default ?? ''])) }); }
506        if (what === 'remove') list.splice(n, 1);
507        if (what === 'move') { const [block] = list.splice(n, 1); list.splice(n + Number(by), 0, block); }
508        inspect(); rerender(0);
509      }
510      if (button.dataset.act === 'section-anchor' && sectionEdit) { const op = ops.find(o => o.id === sectionEdit.opId); let el = null; try { el = document.querySelector(op.selector); } catch { /* not a selector */ } if (el) select(el); else notify('That element is not on this page.'); }
511      if (button.dataset.act === 'remove-section' && sectionEdit) { const id = sectionEdit.opId; clearTimeout(renderTimer); renderTimer = 0; renderRun++; if (commit(ops.filter(o => o.id !== id))) deselect(); }
512      if (button.dataset.page) { followingLink = true; location.assign(button.dataset.page); return; }
513      if (button.dataset.remove) { if (commit(ops.filter(o => keyOf(o) !== button.dataset.remove))) inspect(); }
514      if (button.dataset.reset) { const [kind, prop] = button.dataset.reset.split(':'); if (commit(ops.filter(o => !(o.selector === selector && o.type === kind && (kind !== 'style' || o.property === prop) && here(o))))) inspect(); }
515      if (button.dataset.align) { const now = find('style', 'text-align')?.value; setStyle('text-align', now === button.dataset.align ? '' : button.dataset.align); inspect(); }
516      if (button.dataset.crumb !== undefined) select(chain[Number(button.dataset.crumb)]);
517      if (button.dataset.act === 'parent' && selected?.parentElement) select(selected.parentElement);
518      if (button.dataset.act === 'parent-link') select(selected.closest('a'));
519      if (button.dataset.act === 'clear') deselect();
520      if (button.dataset.place && placing) {
521        const id = newId();
522        if (commit([...ops, { id, type: 'section', selector, position: button.dataset.place, section: placedSection(placing), page: location.pathname }])) {
523          placing = null; selectSection(id); notify('Section placed. Change its texts and images here, then save.');
524        }
525      }
526      if (button.dataset.act === 'cancel-place') { placing = null; inspect(); notify(''); }
527      if (button.dataset.act === 'insert-section') {
528        const section = info.sections.find(s => s.id === value('section')); if (!section) throw Error('Choose a saved section first.');
529        const id = newId(); if (commit([...ops, { id, type: 'section', selector, position: value('sectionPos'), section: placedSection(section), page: location.pathname }])) selectSection(id);
530      }
531      if (button.id === 'exitEditor') {
532        if (saving || (dirty() && !confirm('Discard unsaved visual changes and close the editor?'))) return;
533        closeEditor(); return;
534      }
535      if (button.id === 'collapse') { host.toggleAttribute('data-collapsed'); const c = host.hasAttribute('data-collapsed'); button.textContent = c ? '«' : '»'; button.setAttribute('aria-label', c ? 'Expand editor' : 'Collapse editor'); layout(); }
536      if (button.id === 'pick' || button.id === 'browse') {
537        if (button.id === 'pick' && outside) { notify(`This page is not part of the test. Visitors see the variation on ${describeRules()}.`); return; }
538        picking = button.id === 'pick'; $('#pick').classList.toggle('active', picking); $('#browse').classList.toggle('active', !picking); hover.hide();
539        notify(picking ? '' : 'Browsing: the page works normally. The editor and 
539your unsaved changes come along to other pages of this website.');
540      }
541      if (button.id === 'undo') step(undoStack, redoStack);
542      if (button.id === 'redo') step(redoStack, undoStack);
543      if (button.id === 'viewEdited') setView(false);
544      if (button.id === 'viewOriginal') setView(true);
545      if (button.id === 'save' || button.id === 'saveClose') {
546        if (!dirty() && !renderTimer && button.id === 'saveClose') { closeEditor(true); return; }
547        saving = true; state();
548        await flushRender();
549        const result = await api('/save', { visual: ops }); token = result.token; info.expires = result.expires;
550        // The renewed token stays in this tab (for the next pages), never in the address bar.
551        saved = JSON.stringify(ops); keep(sessionKey, { token, expires: info.expires }); remember(); notify('');
552        if (button.id === 'saveClose') { closeEditor(true); return; }
553      }
554    } catch (error) { notify(error.message); } finally { saving = false; if (host.isConnected) state(); }
555  });
556  const expiryTimer = setInterval(() => { if (host.isConnected) state(); }, 30000); lifetime.signal.addEventListener('abort', () => clearInterval(expiryTimer), {once:true});
557  let followingLink = false;
558  listen(document, 'click', e => {
559    if (picking || !(e.target instanceof Element) || e.composedPath().includes(host)) return;
560    const a = e.target.closest('a[href]'); if (!a || (a.target && a.target !== '_self')) return;
561    try { if (new URL(a.href, location.href).origin === location.origin) { followingLink = true; setTimeout(() => { followingLink = false; }, 1500); } } catch { /* not a web address */ }
562  });
563  listen(window, 'beforeunload', e => { if (dirty() && !followingLink) { e.preventDefault(); e.returnValue = ''; } });
564})();

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.