live-browser.js

   1/**
   2 * Impeccable Live Variant Mode — Browser Script
   3 *
   4 * Injected into the user's page via <script src="http://localhost:PORT/live.js">.
   5 * The server prepends window.__IMPECCABLE_TOKEN__ and window.__IMPECCABLE_PORT__
   6 * before this code.
   7 *
   8 * UI: a single floating bar that morphs between three states —
   9 * configure (pick action + go), generating (progressive dots), and cycling
  10 * (prev/next + accept/discard). Feels like Spotlight, not a modal.
  11 */
  12(function () {
  13  'use strict';
  14  if (typeof window === 'undefined') return;
  15
  16  // Guard against double-init. Bun's HTML loader may process the <script> tag
  17  // and create a bundled copy alongside the external load, or HMR may re-execute.
  18  // Check BEFORE reading token/port to catch all cases.
  19  if (window.__IMPECCABLE_LIVE_INIT__) return;
  20  window.__IMPECCABLE_LIVE_INIT__ = true;
  21
  22  const TOKEN = window.__IMPECCABLE_TOKEN__;
  23  const PORT = window.__IMPECCABLE_PORT__;
  24  if (!TOKEN || !PORT) {
  25    window.__IMPECCABLE_LIVE_INIT__ = false; // reset so the real load can init
  26    return;
  27  }
  28
  29  // ---------------------------------------------------------------------------
  30  // Design tokens
  31  // ---------------------------------------------------------------------------
  32
  33  // Brand magenta is pinned to the site token (--color-accent in main.css)
  34  // so Accept / knobs / cycle-dots match the site's accent, not a washed
  35  // theme-adjusted one.
  36  const C = {
  37    brand:     'oklch(60% 0.25 350)',
  38    brandHov:  'oklch(52% 0.25 350)',
  39    brandSoft: 'oklch(60% 0.25 350 / 0.15)',
  40    ink:       'oklch(15% 0.01 350)',
  41    ash:       'oklch(55% 0 0)',
  42    paper:     'oklch(98% 0.005 350 / 0.92)',
  43    paperSolid:'oklch(98% 0.005 350)',
  44    mist:      'oklch(90% 0.01 350 / 0.6)',
  45    white:     'oklch(99% 0 0)',
  46  };
  47  const FONT = 'system-ui, -apple-system, sans-serif';
  48  const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
  49  // z-index: detect overlays use 99999, so our UI must be above them
  50  const Z = { highlight: 100001, bar: 100005, picker: 100007, toast: 100010 };
  51  const EASE = 'cubic-bezier(0.22, 1, 0.36, 1)'; // ease-out-quint
  52  const PREFIX = 'impeccable-live';
  53  const sessionState = window.__IMPECCABLE_LIVE_SESSION__?.createLiveBrowserSessionState({
  54    prefix: PREFIX,
  55    storage: localStorage,
  56    idFactory: () => crypto.randomUUID().replace(/-/g, '').slice(0, 8),
  57  });
  58  if (!sessionState) {
  59    console.error('[impeccable] live-browser-session.js was not loaded. Live mode cannot start safely.');
  60    window.__IMPECCABLE_LIVE_INIT__ = false;
  61    return;
  62  }
  63  const HIGHLIGHT_TRANSITION =
  64    'top 140ms ' + EASE +
  65    ', left 140ms ' + EASE +
  66    ', width 140ms ' + EASE +
  67    ', height 140ms ' + EASE +
  68    ', opacity 150ms ease';
  69  const TOOLTIP_TRANSITION =
  70    'top 140ms ' + EASE + ', left 140ms ' + EASE + ', opacity 150ms ease';
  71
  72  const SKIP_TAGS = new Set([
  73    'html', 'head', 'body', 'script', 'style', 'link', 'meta', 'noscript', 'br', 'wbr',
  74  ]);
  75
  76  // SVG icons stack above each chip label. All strokes use currentColor so the
  77  // icon recolors to C.brand when its chip is selected. 20x20 render, 24-viewBox,
  78  // 1.5 stroke — visually consistent with the Foundation grid on the homepage.
  79  const ICON_ATTRS = 'width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="display:block"';
  80  const ICONS = {
  81    impeccable: `<svg ${ICON_ATTRS}><path d="M4 20l4-1L18 9l-3-3L5 16z"/><path d="M14 7l3 3"/></svg>`,
  82    bolder:     `<svg ${ICON_ATTRS}><rect x="6" y="12" width="4" height="7" rx="0.5"/><rect x="14" y="5" width="4" height="14" rx="0.5"/></svg>`,
  83    quieter:    `<svg ${ICON_ATTRS}><rect x="6" y="5" width="4" height="14" rx="0.5"/><rect x="14" y="12" width="4" height="7" rx="0.5"/></svg>`,
  84    distill:    `<svg ${ICON_ATTRS}><path d="M4 5h16l-6 8v7l-4-2v-5z"/></svg>`,
  85    polish:     `<svg ${ICON_ATTRS}><path d="M15 3l1 3 3 1-3 1-1 3-1-3-3-1 3-1z"/><path d="M7 13l0.6 1.8 1.8 0.6-1.8 0.6-0.6 1.8-0.6-1.8-1.8-0.6 1.8-0.6z"/></svg>`,
  86    typeset:    `<svg ${ICON_ATTRS}><path d="M5 6h14" stroke-width="2.6"/><path d="M5 12h9" stroke-width="1.9"/><path d="M5 18h5" stroke-width="1.3"/></svg>`,
  87    colorize:   `<svg ${ICON_ATTRS}><circle cx="9" cy="10" r="5"/><circle cx="15" cy="10" r="5"/><circle cx="12" cy="15" r="5"/></svg>`,
  88    layout:     `<svg ${ICON_ATTRS}><rect x="3" y="4" width="8" height="16" rx="0.5"/><rect x="13" y="4" width="8" height="7" rx="0.5"/><rect x="13" y="13" width="8" height="7" rx="0.5"/></svg>`,
  89    adapt:      `<svg ${ICON_ATTRS}><rect x="2.5" y="5" width="12" height="11" rx="1"/><line x1="2.5" y1="19" x2="14.5" y2="19"/><rect x="16.5" y="8" width="5" height="11" rx="1"/></svg>`,
  90    animate:    `<svg ${ICON_ATTRS}><path d="M3 18c4-4 6-10 10-10"/><path d="M13 8c3 0 5 5 8 10"/><circle cx="13" cy="8" r="1.6" fill="currentColor" stroke="none"/></svg>`,
  91    delight:    `<svg ${ICON_ATTRS}><path d="M12 3l2 6 6 2-6 2-2 6-2-6-6-2 6-2z"/></svg>`,
  92    overdrive:  `<svg ${ICON_ATTRS}><path d="M13 3L5 13h5l-1 8 9-12h-6z"/></svg>`,
  93  };
  94
  95  const ACTIONS = [
  96    { value: 'impeccable', label: 'Freeform' },
  97    { value: 'bolder',     label: 'Bolder' },
  98    { value: 'quieter',    label: 'Quieter' },
  99    { value: 'distill',    label: 'Distill' },
 100    { value: 'polish',     label: 'Polish' },
 101    { value: 'typeset',    label: 'Typeset' },
 102    { value: 'colorize',   label: 'Colorize' },
 103    { value: 'layout',     label: 'Layout' },
 104    { value: 'adapt',      label: 'Adapt' },
 105    { value: 'animate',    label: 'Animate' },
 106    { value: 'delight',    label: 'Delight' },
 107    { value: 'overdrive',  label: 'Overdrive' },
 108  ];
 109
 110  // ---------------------------------------------------------------------------
 111  // State
 112  // ---------------------------------------------------------------------------
 113
 114  let state = 'IDLE';
 115  let hoveredElement = null;
 116  let selectedElement = null;
 117  let currentSessionId = null;
 118  let expectedVariants = 0;
 119  let arrivedVariants = 0;
 120  let visibleVariant = 0;
 121  let variantObserver = null;
 122  let hasProjectContext = false;
 123  let selectedAction = 'impeccable';
 124  let selectedCount = 3;
 125  const browserOwner = sessionState.owner;
 126  let checkpointTimer = null;
 127
 128  // Scroll lock — holds window.scrollY at a fixed value while the session is
 129  // active, so HMR DOM patches and variant swaps can't drift the page. See
 130  // startScrollLock / stopScrollLock below.
 131  let scrollLockObserver = null;
 132  let scrollLockTargetY = null;
 133  let scrollLockRaf = null;
 134  let scrollLockAbort = null;
 135
 136  // Dedicated key for scroll position — SEPARATE from LS_KEY so that
 137  // saveSession's state updates don't clobber a carefully-captured scrollY.
 138  // (Previously: saveSession wrote scrollY alongside state, so every call
 139  // during resume overwrote the pre-reload value with whatever the browser
 140  // had landed on, typically 0.)
 141  function writeScrollY(y) { sessionState.writeScrollY(y); }
 142  function readScrollY() { return sessionState.readScrollY(); }
 143  function clearScrollY() { sessionState.clearScrollY(); }
 144
 145  // Pre-empt the browser: apply manual scroll restoration and jump to the
 146  // saved scrollY at script-parse time. Retries on fonts.ready and load
 147  // are essential: scrollTo(y) clamps to the current document.scrollHeight,
 148  // which is often hundreds of pixels short of the final value until
 149  // async-loaded fonts swap in and reflow.
 150  try {
 151    history.scrollRestoration = 'manual';
 152    const savedY = readScrollY();
 153    if (savedY != null) {
 154      const apply = () => {
 155        if (Math.abs(window.scrollY - savedY) > 0.5) {
 156          console.log('[impeccable.scroll] early restore', { from: window.scrollY, to: savedY });
 157          window.scrollTo(0, savedY);
 158        }
 159      };
 160      apply();
 161      if (document.fonts?.ready) document.fonts.ready.then(apply).catch(() => {});
 162      window.addEventListener('load', apply, { once: true });
 163    }
 164  } catch {}
 165
 166  // UI refs
 167  let highlightEl = null;
 168  let tooltipEl = null;
 169  let barEl = null;
 170  let pickerEl = null;
 171  let toastEl = null;
 172  let scrollRaf = null;
 173
 174  // ---------------------------------------------------------------------------
 175  // Helpers
 176  // ---------------------------------------------------------------------------
 177
 178  function own(el) {
 179    return el && (el.id?.startsWith(PREFIX) || el.closest?.('[id^="' + PREFIX + '"]'));
 180  }
 181
 182  function pickable(el) {
 183    if (!el || el.nodeType !== 1) return false;
 184    if (SKIP_TAGS.has(el.tagName.toLowerCase())) return false;
 185    if (own(el)) return false;
 186    const r = el.getBoundingClientRect();
 187    return r.width >= 20 && r.height >= 20;
 188  }
 189
 190  function desc(el) {
 191    if (!el) return '';
 192    let s = el.tagName.toLowerCase();
 193    if (el.id) s += '#' + el.id;
 194    else if (el.classList.length) s += '.' + [...el.classList].slice(0, 2).join('.');
 195    return s;
 196  }
 197
 198  function id8() { return crypto.randomUUID().replace(/-/g, '').slice(0, 8); }
 199
 200  // Modal-aware chrome: keep our floating UI clickable inside Radix /
 201  // Headless UI / vaul portals.
 202  //
 203  // Two host-page behaviors break us when the picked element lives inside a
 204  // modal dialog:
 205  //
 206  //   1. Modal scroll-lock disables outside pointer events. Radix's
 207  //      `DismissableLayer` sets `document.body.style.pointerEvents = 'none'`
 208  //      while a modal is open and only restores `auto` on the layer. Our
 209  //      chrome inherits `none` from <body> and becomes unclickable.
 210  //   2. The dialog's outside-interaction handler (Radix's
 211  //      `usePointerDownOutside`) listens at document level and dismisses
 212  //      the dialog whenever a `pointerdown` lands outside the layer node.
 213  //      Our chrome is a sibling of <body>, so Radix classifies our clicks
 214  //      as outside and tears the dialog down mid-task.
 215  //
 216  // We can't reliably re-parent our chrome into the dialog subtree (z-index
 217  // stacking, scroll containers, theming all become host-page concerns), so
 218  // we defang both behaviors at our root:
 219  //
 220  //   - `pointer-events: auto !important` overrides the inherited `none`.
 221  //   - Stop `pointerdown` / `mousedown` propagation so the document-level
 222  //     dismiss listener never fires for our clicks.
 223  //   - Stop `focusin` propagation so any focus shifts inside our chrome
 224  //     don't read as "focus moved outside the dialog" to focus traps.
 225  //
 226  // Click events still bubble normally — only the early pointer/focus
 227  // signals that drive outside-interaction detection are silenced.
 228  function defangOutsideHandlers(rootEl, { setPointerEvents = true } = {}) {
 229    if (!rootEl) return;
 230    if (setPointerEvents) {
 231      rootEl.style.setProperty('pointer-events', 'auto', 'important');
 232    }
 233    const stop = (e) => e.stopPropagation();
 234    rootEl.addEventListener('pointerdown', stop);
 235    rootEl.addEventListener('mousedown', stop);
 236    rootEl.addEventListener('focusin', stop);
 237  }
 238
 239  // ---------------------------------------------------------------------------
 240  // Highlight overlay
 241  // ---------------------------------------------------------------------------
 242
 243  function initHighlight() {
 244    highlightEl = document.createElement('div');
 245    highlightEl.id = PREFIX + '-highlight';
 246    Object.assign(highlightEl.style, {
 247      position: 'fixed', top: '0', left: '0', width: '0', height: '0',
 248      border: '2px solid ' + C.brand, borderRadius: '3px',
 249      pointerEvents: 'none', zIndex: Z.highlight, boxSizing: 'border-box',
 250      transition: HIGHLIGHT_TRANSITION,
 251      display: 'none', opacity: '0',
 252    });
 253    document.body.appendChild(highlightEl);
 254
 255    tooltipEl = document.createElement('div');
 256    tooltipEl.id = PREFIX + '-tooltip';
 257    Object.assign(tooltipEl.style, {
 258      position: 'fixed',
 259      background: C.ink, color: C.white,
 260      fontFamily: MONO, fontSize: '10px', fontWeight: '500',
 261      padding: '2px 6px', borderRadius: '3px',
 262      zIndex: Z.highlight + 1, pointerEvents: 'none',
 263      whiteSpace: 'nowrap', display: 'none',
 264      letterSpacing: '0.02em',
 265      transition: TOOLTIP_TRANSITION,
 266    });
 267    document.body.appendChild(tooltipEl);
 268  }
 269
 270  function showHighlight(el) {
 271    if (!el || !highlightEl) return;
 272    const r = el.getBoundingClientRect();
 273    const top = (r.top - 2) + 'px', left = (r.left - 2) + 'px';
 274    const width = (r.width + 4) + 'px', height = (r.height + 4) + 'px';
 275    const tipTop = r.top - 20;
 276    const tipY = (tipTop < 4 ? r.bottom + 4 : tipTop) + 'px';
 277    const tipX = Math.max(4, r.left) + 'px';
 278    tooltipEl.textContent = desc(el);
 279
 280    const hiWasHidden = highlightEl.style.display === 'none' || highlightEl.style.opacity === '0';
 281    if (hiWasHidden) {
 282      // Snap to first target without animating from (0,0), then fade in.
 283      highlightEl.style.transition = 'none';
 284      Object.assign(highlightEl.style, { top, left, width, height, display: 'block' });
 285      tooltipEl.style.transition = 'none';
 286      Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block' });
 287      void highlightEl.offsetWidth;
 288      highlightEl.style.transition = HIGHLIGHT_TRANSITION;
 289      highlightEl.style.opacity = '1';
 290      tooltipEl.style.transition = TOOLTIP_TRANSITION;
 291      tooltipEl.style.opacity = '1';
 292    } else {
 293      Object.assign(highlightEl.style, { top, left, width, height, display: 'block', opacity: '1' });
 294      Object.assign(tooltipEl.style, { top: tipY, left: tipX, display: 'block', opacity: '1' });
 295    }
 296  }
 297
 298  function hideHighlight() {
 299    if (highlightEl) { highlightEl.style.opacity = '0'; highlightEl.style.display = 'none'; }
 300    if (tooltipEl) { tooltipEl.style.opacity = '0'; tooltipEl.style.display = 'none'; }
 301  }
 302
 303  // ---------------------------------------------------------------------------
 304  // Annotation overlay (comment pins + magenta strokes)
 305  //
 306  // Active while state === 'CONFIGURING'. The overlay is a fixed-positioned
 307  // sibling of <body> mirroring selectedElement's bounding rect. Click (no
 308  // drag) drops a comment pin; drag paints a magenta SVG stroke. All coords
 309  // are stored in element-local CSS px so they survive scroll / resize and
 310  // correlate directly with the captured PNG.
 311  // ---------------------------------------------------------------------------
 312
 313  const DRAG_THRESHOLD = 5;       // px — below this, treat pointerup as a click
 314  const PIN_DBL_CLICK_MS = 300;   // two clicks on the same pin within this delete it
 315  let annotOverlayEl = null;
 316  let annotSvgEl = null;
 317  let annotPinsEl = null;
 318  let annotClearChipEl = null;
 319  let annotState = { comments: [], strokes: [] };
 320  let annotActive = false;
 321  // `annotPointer` is either:
 322  //   { kind: 'new',   x0, y0, moved, strokeEl, strokePoints }   creating a stroke/pin
 323  //   { kind: 'pin',   idx, startPointer, startPin, moved }     dragging an existing pin
 324  let annotPointer = null;
 325  let annotEditing = null;        // { idx, input, wrapEl }
 326  let annotLastPinClick = { idx: -1, time: 0 }; // for click-click-to-delete
 327
 328  function initAnnotOverlay() {
 329    annotOverlayEl = document.createElement('div');
 330    annotOverlayEl.id = PREFIX + '-annot';
 331    Object.assign(annotOverlayEl.style, {
 332      position: 'fixed', top: '0', left: '0', width: '0', height: '0',
 333      pointerEvents: 'auto', zIndex: Z.highlight + 2,
 334      display: 'none', overflow: 'visible',
 335      cursor: 'crosshair', touchAction: 'none',
 336    });
 337
 338    annotSvgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
 339    annotSvgEl.id = PREFIX + '-annot-svg';
 340    Object.assign(annotSvgEl.style, {
 341      position: 'absolute', top: '0', left: '0',
 342      width: '100%', height: '100%',
 343      // The SVG itself doesn't absorb clicks; individual hit-paths opt-in via
 344      // pointer-events=stroke so gaps still fall through to the overlay.
 345      pointerEvents: 'none', overflow: 'visible',
 346    });
 347    annotOverlayEl.appendChild(annotSvgEl);
 348
 349    annotPinsEl = document.createElement('div');
 350    annotPinsEl.id = PREFIX + '-annot-pins';
 351    Object.assign(annotPinsEl.style, {
 352      position: 'absolute', inset: '0',
 353      pointerEvents: 'none',
 354    });
 355    annotOverlayEl.appendChild(annotPinsEl);
 356
 357    annotClearChipEl = document.createElement('div');
 358    annotClearChipEl.id = PREFIX + '-annot-clear';
 359    annotClearChipEl.dataset.annotClear = 'true';
 360    annotClearChipEl.textContent = 'Clear';
 361    Object.assign(annotClearChipEl.style, {
 362      position: 'absolute', top: '8px', right: '8px',
 363      background: C.ink, color: C.white,
 364      fontFamily: FONT, fontSize: '10px', fontWeight: '500',
 365      letterSpacing: '0.08em', textTransform: 'uppercase',
 366      padding: '5px 12px', borderRadius: '999px',
 367      cursor: 'pointer', pointerEvents: 'auto',
 368      display: 'none', userSelect: 'none',
 369      boxShadow: '0 1px 3px rgba(0,0,0,0.2)',
 370    });
 371    annotOverlayEl.appendChild(annotClearChipEl);
 372
 373    annotOverlayEl.addEventListener('pointerdown', onAnnotDown);
 374    annotOverlayEl.addEventListener('pointermove', onAnnotMove);
 375    annotOverlayEl.addEventListener('pointerup', onAnnotUp);
 376    annotOverlayEl.addEventListener('pointercancel', onAnnotUp);
 377    document.body.appendChild(annotOverlayEl);
 378    // Modal-host friendliness: pointer-events is already 'auto' on this
 379    // overlay; we only need to silence the host's outside-interaction
 380    // listeners. Don't override pointer-events here (the overlay toggles
 381    // visibility via display:none, which is fine).
 382    defangOutsideHandlers(annotOverlayEl, { setPointerEvents: false });
 383  }
 384
 385  function updateClearChip() {
 386    if (!annotClearChipEl) return;
 387    const hasAny = annotState.comments.length > 0 || annotState.strokes.length > 0;
 388    annotClearChipEl.style.display = hasAny ? 'block' : 'none';
 389  }
 390
 391  function showAnnotOverlay(el) {
 392    if (!annotOverlayEl || !el) return;
 393    annotActive = true;
 394    positionAnnotOverlay(el);
 395    annotOverlayEl.style.display = 'block';
 396  }
 397
 398  function hideAnnotOverlay() {
 399    annotActive = false;
 400    if (annotOverlayEl) annotOverlayEl.style.display = 'none';
 401    // Drop any in-progress edit without touching annotState — clearAnnotations
 402    // (if the caller is exiting configure mode) handles state reset.
 403    annotEditing = null;
 404  }
 405
 406  function positionAnnotOverlay(el) {
 407    if (!annotOverlayEl || !el) return;
 408    const r = el.getBoundingClientRect();
 409    Object.assign(annotOverlayEl.style, {
 410      top: r.top + 'px', left: r.left + 'px',
 411      width: r.width + 'px', height: r.height + 'px',
 412    });
 413    annotSvgEl.setAttribute('viewBox', '0 0 ' + r.width + ' ' + r.height);
 414  }
 415
 416  function clearAnnotations() {
 417    annotState.comments = [];
 418    annotState.strokes = [];
 419    if (annotSvgEl) while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
 420    if (annotPinsEl) annotPinsEl.innerHTML = '';
 421    annotPointer = null;
 422    annotEditing = null;
 423    annotLastPinClick = { idx: -1, time: 0 };
 424    updateClearChip();
 425  }
 426
 427  // Rebuild the SVG layer. Each stroke gets a wider invisible hit path
 428  // beneath the visible magenta path so clicks register on thin lines.
 429  function redrawStrokes() {
 430    while (annotSvgEl.firstChild) annotSvgEl.removeChild(annotSvgEl.firstChild);
 431    annotState.strokes.forEach((s, idx) => {
 432      const d = pointsToPath(s.points);
 433      const hit = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 434      hit.setAttribute('d', d);
 435      hit.setAttribute('stroke', 'transparent');
 436      hit.setAttribute('stroke-width', '16');
 437      hit.setAttribute('stroke-linecap', 'round');
 438      hit.setAttribute('stroke-linejoin', 'round');
 439      hit.setAttribute('fill', 'none');
 440      hit.setAttribute('pointer-events', 'stroke');
 441      hit.style.cursor = 'pointer';
 442      hit.dataset.annotStroke = String(idx);
 443      annotSvgEl.appendChild(hit);
 444      const visible = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 445      visible.setAttribute('d', d);
 446      visible.setAttribute('stroke', C.brand);
 447      visible.setAttribute('stroke-width', '3');
 448      visible.setAttribute('stroke-linecap', 'round');
 449      visible.setAttribute('stroke-linejoin', 'round');
 450      visible.setAttribute('fill', 'none');
 451      visible.setAttribute('pointer-events', 'none');
 452      annotSvgEl.appendChild(visible);
 453    });
 454    updateClearChip();
 455  }
 456
 457  function localCoords(e) {
 458    const rect = annotOverlayEl.getBoundingClientRect();
 459    return { x: e.clientX - rect.left, y: e.clientY - rect.top };
 460  }
 461
 462  function onAnnotDown(e) {
 463    if (!annotActive) return;
 464
 465    // 1) Clear chip → wipe all annotations
 466    if (e.target.closest?.('[data-annot-clear]')) {
 467      if (annotEditing) annotEditing = null;
 468      clearAnnotations();
 469      renderAllPins();
 470      redrawStrokes();
 471      e.stopPropagation(); e.preventDefault();
 472      return;
 473    }
 474
 475    // 2) Stroke hit path → delete that stroke
 476    const strokeHit = e.target.closest?.('[data-annot-stroke]');
 477    if (strokeHit) {
 478      const idx = parseInt(strokeHit.dataset.annotStroke, 10);
 479      if (Number.isInteger(idx)) {
 480        annotState.strokes.splice(idx, 1);
 481        redrawStrokes();
 482      }
 483      e.stopPropagation(); e.preventDefault();
 484      return;
 485    }
 486
 487    // 3) Pin → drag, edit, or delete-on-double-click
 488    const pinWrap = e.target.closest?.('[data-annot-pin]');
 489    if (pinWrap) {
 490      const idx = parseInt(pinWrap.dataset.annotPin, 10);
 491      if (!Number.isInteger(idx)) return;
 492      // Double-click (two pointerdowns on the same pin within window) → delete.
 493      const now = Date.now();
 494      if (annotLastPinClick.idx === idx && now - annotLastPinClick.time < PIN_DBL_CLICK_MS) {
 495        if (annotEditing && annotEditing.idx === idx) annotEditing = null;
 496        annotState.comments.splice(idx, 1);
 497        annotLastPinClick = { idx: -1, time: 0 };
 498        renderAllPins();
 499        e.stopPropagation(); e.preventDefault();
 500        return;
 501      }
 502      annotLastPinClick = { idx, time: now };
 503      // If editing a different pin, commit that edit before starting here.
 504      if (annotEditing && annotEditing.idx !== idx) finalizeEditingPin();
 505      // If already editing THIS pin and the user clicked the dot, let the
 506      // input keep focus (don't start a drag — the click wasn't meant as one).
 507      if (annotEditing && annotEditing.idx === idx) return;
 508      const p = localCoords(e);
 509      const pin = annotState.comments[idx];
 510      annotPointer = {
 511        kind: 'pin', idx,
 512        startPointer: p,
 513        startPin: { x: pin.x, y: pin.y },
 514        moved: false,
 515      };
 516      try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
 517      e.stopPropagation(); e.preventDefault();
 518      return;
 519    }
 520
 521    // 4) Empty area → commit any open edit, then start new annotation
 522    if (annotEditing) {
 523      finalizeEditingPin();
 524      e.stopPropagation(); e.preventDefault();
 525      return;
 526    }
 527    const p = localCoords(e);
 528    annotPointer = { kind: 'new', x0: p.x, y0: p.y, moved: false, strokeEl: null, strokePoints: null };
 529    try { annotOverlayEl.setPointerCapture(e.pointerId); } catch {}
 530    e.stopPropagation(); e.preventDefault();
 531  }
 532
 533  function onAnnotMove(e) {
 534    if (!annotActive || !annotPointer) return;
 535    const p = localCoords(e);
 536
 537    if (annotPointer.kind === 'pin') {
 538      const dx = p.x - annotPointer.startPointer.x;
 539      const dy = p.y - annotPointer.startPointer.y;
 540      if (!annotPointer.moved) {
 541        if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
 542        annotPointer.moved = true;
 543      }
 544      const pin = annotState.comments[annotPointer.idx];
 545      if (!pin) { annotPointer = null; return; }
 546      pin.x = annotPointer.startPin.x + dx;
 547      pin.y = annotPointer.startPin.y + dy;
 548      renderAllPins();
 549      e.stopPropagation();
 550      return;
 551    }
 552
 553    // kind === 'new'
 554    const dx = p.x - annotPointer.x0, dy = p.y - annotPointer.y0;
 555    if (!annotPointer.moved) {
 556      if (Math.hypot(dx, dy) < DRAG_THRESHOLD) return;
 557      annotPointer.moved = true;
 558      const strokeEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 559      strokeEl.setAttribute('stroke', C.brand);
 560      strokeEl.setAttribute('stroke-width', '3');
 561      strokeEl.setAttribute('stroke-linecap', 'round');
 562      strokeEl.setAttribute('stroke-linejoin', 'round');
 563      strokeEl.setAttribute('fill', 'none');
 564      strokeEl.setAttribute('pointer-events', 'none');
 565      annotSvgEl.appendChild(strokeEl);
 566      annotPointer.strokeEl = strokeEl;
 567      annotPointer.strokePoints = [[annotPointer.x0, annotPointer.y0]];
 568    }
 569    annotPointer.strokePoints.push([p.x, p.y]);
 570    annotPointer.strokeEl.setAttribute('d', pointsToPath(annotPointer.strokePoints));
 571    e.stopPropagation();
 572  }
 573
 574  function onAnnotUp(e) {
 575    if (!annotActive || !annotPointer) return;
 576
 577    if (annotPointer.kind === 'pin') {
 578      const wasDrag = annotPointer.moved;
 579      const idx = annotPointer.idx;
 580      try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
 581      annotPointer = null;
 582      if (wasDrag) {
 583        // A drag is an intentional reposition; a follow-up click shouldn't be
 584        // interpreted as a double-click-to-delete.
 585        annotLastPinClick = { idx: -1, time: 0 };
 586      } else {
 587        beginEditPin(idx);
 588      }
 589      e.stopPropagation();
 590      return;
 591    }
 592
 593    // kind === 'new'
 594    const wasDrag = annotPointer.moved;
 595    if (wasDrag) {
 596      annotState.strokes.push({ points: annotPointer.strokePoints });
 597      // Swap the temporary preview SVG path for the full render with hit paths.
 598      redrawStrokes();
 599    } else {
 600      const idx = annotState.comments.length;
 601      annotState.comments.push({ x: annotPointer.x0, y: annotPointer.y0, text: '' });
 602      renderAllPins();
 603      beginEditPin(idx);
 604    }
 605    try { annotOverlayEl.releasePointerCapture(e.pointerId); } catch {}
 606    annotPointer = null;
 607    e.stopPropagation();
 608  }
 609
 610  function pointsToPath(points) {
 611    if (!points || points.length === 0) return '';
 612    let d = 'M' + points[0][0].toFixed(1) + ' ' + points[0][1].toFixed(1);
 613    for (let i = 1; i < points.length; i++) {
 614      d += ' L' + points[i][0].toFixed(1) + ' ' + points[i][1].toFixed(1);
 615    }
 616    return d;
 617  }
 618
 619  function renderAllPins() {
 620    annotPinsEl.innerHTML = '';
 621    annotState.comments.forEach((c, idx) => {
 622      annotPinsEl.appendChild(buildPinElement(c, idx));
 623    });
 624    updateClearChip();
 625  }
 626
 627  function buildPinElement(comment, idx) {
 628    const interactive = idx >= 0;
 629    const wrap = document.createElement('div');
 630    if (interactive) wrap.dataset.annotPin = String(idx);
 631    Object.assign(wrap.style, {
 632      position: 'absolute',
 633      left: (comment.x - 7) + 'px', top: (comment.y - 7) + 'px',
 634      pointerEvents: interactive ? 'auto' : 'none',
 635      display: 'flex', alignItems: 'flex-start', gap: '6px',
 636      cursor: interactive ? 'grab' : 'default',
 637      touchAction: 'none',
 638    });
 639    const dot = document.createElement('div');
 640    Object.assign(dot.style, {
 641      width: '14px', height: '14px', borderRadius: '50%',
 642      background: C.brand, border: '2px solid ' + C.white,
 643      boxShadow: '0 1px 3px rgba(0,0,0,0.25)',
 644      flexShrink: '0',
 645    });
 646    wrap.appendChild(dot);
 647
 648    if (comment.text) {
 649      const bubble = document.createElement('div');
 650      bubble.textContent = comment.text;
 651      Object.assign(bubble.style, {
 652        background: C.ink, color: C.white,
 653        fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
 654        padding: '4px 8px', borderRadius: '3px',
 655        marginTop: '-2px', maxWidth: '220px',
 656        pointerEvents: 'none', whiteSpace: 'pre-wrap',
 657        wordBreak: 'break-word',
 658      });
 659      wrap.appendChild(bubble);
 660    }
 661    return wrap;
 662  }
 663
 664  function beginEditPin(idx) {
 665    const wrapEl = annotPinsEl.querySelector('[data-annot-pin="' + idx + '"]');
 666    if (!wrapEl) return;
 667    // Strip any existing bubble (but keep the dot)
 668    wrapEl.querySelectorAll('div:not(:first-child)').forEach(n => n.remove());
 669    const input = document.createElement('input');
 670    input.type = 'text';
 671    input.placeholder = 'Note…';
 672    Object.assign(input.style, {
 673      background: C.ink, color: C.white,
 674      fontFamily: FONT, fontSize: '12px', lineHeight: '1.4',
 675      padding: '4px 8px', borderRadius: '3px',
 676      border: '1px solid ' + C.brand,
 677      outline: 'none', marginTop: '-2px',
 678      width: '220px', pointerEvents: 'auto',
 679    });
 680    const originalText = annotState.comments[idx].text || '';
 681    input.value = originalText;
 682    wrapEl.appendChild(input);
 683    annotEditing = { idx, input, wrapEl, originalText };
 684    input.addEventListener('keydown', onAnnotInputKey, true);
 685    input.addEventListener('blur', () => {
 686      // Fires on both focus-loss and programmatic blur; commit unless we
 687      // already handled it.
 688      if (annotEditing && annotEditing.input === input) finalizeEditingPin();
 689    });
 690    // Stop clicks/pointerdowns inside the input from bubbling to the overlay
 691    ['pointerdown', 'click'].forEach(ev => {
 692      input.addEventListener(ev, e => e.stopPropagation());
 693    });
 694    setTimeout(() => input.focus(), 0);
 695  }
 696
 697  function onAnnotInputKey(e) {
 698    if (e.key === 'Enter') {
 699      e.preventDefault(); e.stopPropagation();
 700      finalizeEditingPin();
 701    } else if (e.key === 'Escape') {
 702      e.preventDefault(); e.stopPropagation();
 703      cancelEditingPin();
 704    } else {
 705      // Keep arrows / backspace from hitting global handlers
 706      e.stopPropagation();
 707    }
 708  }
 709
 710  function finalizeEditingPin() {
 711    if (!annotEditing) return;
 712    const { idx, input } = annotEditing;
 713    const text = input.value.trim();
 714    annotEditing = null;
 715    if (text) annotState.comments[idx].text = text;
 716    else annotState.comments.splice(idx, 1);
 717    renderAllPins();
 718  }
 719
 720  function cancelEditingPin() {
 721    if (!annotEditing) return;
 722    const { idx, originalText } = annotEditing;
 723    annotEditing = null;
 724    // If the pin had text before this edit, revert to it. If it was a
 725    // just-created empty pin, Escape removes it.
 726    if (originalText) {
 727      annotState.comments[idx].text = originalText;
 728    } else {
 729      annotState.comments.splice(idx, 1);
 730    }
 731    renderAllPins();
 732  }
 733
 734  // Build a detached annotation subtree suitable for injection into the clone
 735  // modern-screenshot creates. Coordinates are element-local so this slots
 736  // straight into an element that's been made position:relative. Takes an
 737  // explicit snapshot so it works after annotState has been cleared.
 738  function buildAnnotationsForCapture(rect, snapshot) {
 739    const comments = snapshot ? snapshot.comments : annotState.comments;
 740    const strokes = snapshot ? snapshot.strokes : annotState.strokes;
 741    if (comments.length === 0 && strokes.length === 0) return null;
 742    const wrap = document.createElement('div');
 743    Object.assign(wrap.style, {
 744      position: 'absolute', top: '0', left: '0',
 745      width: rect.width + 'px', height: rect.height + 'px',
 746      pointerEvents: 'none', overflow: 'visible',
 747    });
 748    if (strokes.length > 0) {
 749      const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
 750      svg.setAttribute('viewBox', '0 0 ' + rect.width + ' ' + rect.height);
 751      Object.assign(svg.style, {
 752        position: 'absolute', top: '0', left: '0',
 753        width: '100%', height: '100%', overflow: 'visible',
 754      });
 755      for (const s of strokes) {
 756        const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
 757        path.setAttribute('stroke', C.brand);
 758        path.setAttribute('stroke-width', '3');
 759        path.setAttribute('stroke-linecap', 'round');
 760        path.setAttribute('stroke-linejoin', 'round');
 761        path.setAttribute('fill', 'none');
 762        path.setAttribute('d', pointsToPath(s.points));
 763        svg.appendChild(path);
 764      }
 765      wrap.appendChild(svg);
 766    }
 767    for (const c of comments) {
 768      // idx=-1 means non-interactive; pointerEvents stay off in the clone
 769      wrap.appendChild(buildPinElement(c, -1));
 770    }
 771    return wrap;
 772  }
 773
 774  // ---------------------------------------------------------------------------
 775  // Element context extraction
 776  // ---------------------------------------------------------------------------
 777
 778  function extractContext(el) {
 779    const cs = getComputedStyle(el);
 780    const r = el.getBoundingClientRect();
 781    const props = {};
 782    for (const sheet of document.styleSheets) {
 783      try {
 784        for (const rule of sheet.cssRules) {
 785          if (rule.style) for (let i = 0; i < rule.style.length; i++) {
 786            const p = rule.style[i];
 787            if (p.startsWith('--') && !props[p]) {
 788              const v = cs.getPropertyValue(p).trim();
 789              if (v) props[p] = v;
 790            }
 791          }
 792        }
 793      } catch { /* cross-origin */ }
 794    }
 795    return {
 796      tagName: el.tagName.toLowerCase(), id: el.id || null,
 797      classes: [...el.classList],
 798      textContent: (el.textContent || '').slice(0, 500),
 799      outerHTML: el.outerHTML.slice(0, 10000),
 800      computedStyles: {
 801        'font-family': cs.fontFamily, 'font-size': cs.fontSize,
 802        'font-weight': cs.fontWeight, 'line-height': cs.lineHeight,
 803        'color': cs.color, 'background': cs.background,
 804        'background-color': cs.backgroundColor,
 805        'padding': cs.padding, 'margin': cs.margin,
 806        'display': cs.display, 'position': cs.position,
 807        'gap': cs.gap, 'border-radius': cs.borderRadius,
 808        'box-shadow': cs.boxShadow,
 809      },
 810      cssCustomProperties: props,
 811      parentContext: el.parentElement
 812        ? '<' + el.parentElement.tagName.toLowerCase()
 813          + (el.parentElement.id ? ' id="' + el.parentElement.id + '"' : '')
 814          + (el.parentElement.className ? ' class="' + el.parentElement.className + '"' : '')
 815          + '>'
 816        : null,
 817      boundingRect: { width: Math.round(r.width), height: Math.round(r.height) },
 818    };
 819  }
 820
 821  // ---------------------------------------------------------------------------
 822  // The Bar — one floating element, three modes
 823  // ---------------------------------------------------------------------------
 824
 825  // Contextual-bar palette. Cached at init so every build*Row reads a
 826  // consistent set of colors; detectPageTheme runs once rather than on every
 827  // phase transition.
 828  let BP = null;
 829
 830  // Bar shadow variants. The default projects down + subtle around. When
 831  // the Tune popover opens below the bar, a downward shadow lands on the
 832  // dark popover and reads as a bright ghost line. We swap to UP-only while
 833  // tune is open below so the popover's top edge is clean.
 834  const BAR_SHADOW_DEFAULT = '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)';
 835  const BAR_SHADOW_UP = '0 -4px 20px oklch(0% 0 0 / 0.08), 0 -1px 3px oklch(0% 0 0 / 0.06)';
 836  const BAR_SHADOW_DOWN = BAR_SHADOW_DEFAULT;
 837
 838  function initBar() {
 839    BP = barPaletteForTheme(detectPageTheme());
 840    barEl = document.createElement('div');
 841    barEl.id = PREFIX + '-bar';
 842    Object.assign(barEl.style, {
 843      position: 'fixed', zIndex: Z.bar,
 844      display: 'none', opacity: '0',
 845      transform: 'translateY(6px)',
 846      transition: 'opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
 847      background: BP.surface,
 848      backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
 849      border: '1px solid ' + BP.hairline,
 850      borderRadius: '10px',
 851      boxShadow: BAR_SHADOW_DEFAULT,
 852      transition: 'box-shadow 0.2s ease, opacity 0.25s ' + EASE + ', transform 0.3s ' + EASE,
 853      fontFamily: FONT, fontSize: '13px', color: BP.text,
 854      padding: '6px',
 855      maxWidth: '520px', minWidth: '320px',
 856    });
 857    document.body.appendChild(barEl);
 858    defangOutsideHandlers(barEl);
 859  }
 860
 861  function positionBar() {
 862    if (!barEl || !selectedElement) return;
 863    const r = selectedElement.getBoundingClientRect();
 864    const barH = barEl.offsetHeight || 44;
 865    const barW = barEl.offsetWidth || 380;
 866    const GLOBAL_BAR_RESERVE = 64; // global bar height + bottom margin + breathing room
 867    const GAP = 8;
 868
 869    // Prefer below the element; fall back to above; if neither fits (element
 870    // taller than viewport), pin to a stable viewport anchor so the bar
 871    // doesn't teleport between top and bottom as the user scrolls.
 872    let top;
 873    const belowTop = r.bottom + GAP;
 874    const aboveTop = r.top - barH - GAP;
 875    if (belowTop + barH + GAP <= window.innerHeight - GLOBAL_BAR_RESERVE) {
 876      top = belowTop;
 877    } else if (aboveTop >= GAP) {
 878      top = aboveTop;
 879    } else {
 880      top = window.innerHeight - barH - GLOBAL_BAR_RESERVE;
 881    }
 882
 883    let left = r.left + (r.width - barW) / 2;
 884    if (left < GAP) left = GAP;
 885    if (left + barW > window.innerWidth - GAP) left = window.innerWidth - barW - GAP;
 886    Object.assign(barEl.style, { top: top + 'px', left: left + 'px' });
 887  }
 888
 889  function showBar(mode) {
 890    barEl.innerHTML = '';
 891    if (mode === 'configure') barEl.appendChild(buildConfigureRow());
 892    else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
 893    else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
 894    barEl.style.display = 'block';
 895    positionBar();
 896    requestAnimationFrame(() => {
 897      barEl.style.opacity = '1';
 898      barEl.style.transform = 'translateY(0)';
 899    });
 900  }
 901
 902  function hideBar() {
 903    if (!barEl) return;
 904    barEl.style.opacity = '0';
 905    barEl.style.transform = 'translateY(6px)';
 906    setTimeout(() => { if (barEl) barEl.style.display = 'none'; }, 250);
 907    hideActionPicker();
 908    closeTunePopover();
 909  }
 910
 911  function updateBarContent(mode) {
 912    if (!barEl || barEl.style.display === 'none') return;
 913    barEl.innerHTML = '';
 914    // Reset bar styling to the theme-aware palette
 915    barEl.style.background = BP.surface;
 916    barEl.style.border = '1px solid ' + BP.hairline;
 917    if (mode === 'configure') barEl.appendChild(buildConfigureRow());
 918    else if (mode === 'generating') barEl.appendChild(buildGeneratingRow());
 919    else if (mode === 'cycling') barEl.appendChild(buildCyclingRow());
 920    else if (mode === 'saving') barEl.appendChild(buildSavingRow());
 921    else if (mode === 'confirmed') {
 922      barEl.appendChild(buildConfirmedRow());
 923      barEl.style.background = 'oklch(95% 0.05 145)';
 924      barEl.style.border = '1px solid oklch(75% 0.12 145 / 0.4)';
 925    }
 926  }
 927
 928  // --- Configure row ---
 929
 930  function buildConfigureRow() {
 931    const row = el('div', {
 932      display: 'flex', alignItems: 'center', gap: '4px',
 933    });
 934
 935    // Action pill
 936    const pill = el('button', {
 937      display: 'inline-flex', alignItems: 'center', gap: '4px',
 938      padding: '5px 10px', borderRadius: '6px',
 939      background: BP.mark, color: BP.markText,
 940      fontFamily: FONT, fontSize: '12px', fontWeight: '500',
 941      border: 'none', cursor: 'pointer',
 942      transition: 'background 0.12s ease, transform 0.1s ease',
 943      whiteSpace: 'nowrap', flexShrink: '0',
 944    });
 945    pill.textContent = actionLabel() + ' \u25BE';
 946    pill.addEventListener('mouseenter', () => pill.style.background = BP.accent);
 947    pill.addEventListener('mouseleave', () => pill.style.background = BP.mark);
 948    pill.addEventListener('mousedown', () => pill.style.transform = 'scale(0.97)');
 949    pill.addEventListener('mouseup', () => pill.style.transform = 'scale(1)');
 950    pill.addEventListener('click', (e) => { e.stopPropagation(); toggleActionPicker(); });
 951    row.appendChild(pill);
 952
 953    // Freeform input. Focus state shows an accent-colored border only —
 954    // an earlier version tinted the background with `BP.accentSoft`, which
 955    // composited against the dark bar surface to a murky purple where the
 956    // browser's default placeholder gray was unreadable. Placeholder color
 957    // is set explicitly via a one-shot stylesheet keyed off this input's id
 958    // so it picks up the bar's `textDim` token in both themes.
 959    const input = document.createElement('input');
 960    input.id = PREFIX + '-input';
 961    input.type = 'text';
 962    input.placeholder = selectedAction === 'impeccable' ? 'describe what you want...' : 'refine further (optional)...';
 963    Object.assign(input.style, {
 964      flex: '1', minWidth: '0',
 965      padding: '5px 8px', borderRadius: '6px',
 966      border: '1px solid transparent', background: 'transparent',
 967      fontFamily: FONT, fontSize: '12px', color: BP.text,
 968      outline: 'none',
 969      transition: 'border-color 0.15s ease',
 970    });
 971    if (!document.getElementById(PREFIX + '-input-style')) {
 972      const s = document.createElement('style');
 973      s.id = PREFIX + '-input-style';
 974      s.textContent =
 975        '#' + PREFIX + '-input::placeholder { color: ' + BP.textDim + '; opacity: 1; }';
 976      document.head.appendChild(s);
 977    }
 978    input.addEventListener('focus', () => {
 979      input.style.borderColor = BP.accent;
 980    });
 981    input.addEventListener('blur', () => {
 982      input.style.borderColor = 'transparent';
 983    });
 984    input.addEventListener('keydown', (e) => {
 985      if (e.key === 'Enter') { e.stopPropagation(); e.preventDefault(); handleGo(); return; }
 986      if (e.key === 'Escape') { e.stopPropagation(); e.preventDefault(); input.blur(); hideBar(); state = 'PICKING'; return; }
 987      // Let arrow keys pass through to the element picker when the input is empty
 988      if ((e.key === 'ArrowUp' || e.key === 'ArrowDown') && !input.value) return;
 989      e.stopPropagation();
 990    });
 991    row.appendChild(input);
 992
 993    // Variant count toggle
 994    const count = el('button', {
 995      padding: '4px 6px', borderRadius: '5px',
 996      border: '1px solid ' + BP.hairline, background: 'transparent',
 997      fontFamily: MONO, fontSize: '11px', fontWeight: '600',
 998      color: BP.textDim, cursor: 'pointer',
 999      transition: 'color 0.12s ease, border-color 0.12s ease',
1000      flexShrink: '0', whiteSpace: 'nowrap',
1001    });
1002    count.textContent = '\u00D7' + selectedCount;
1003    count.title = 'Variants: click to change';
1004    count.addEventListener('mouseenter', () => { count.style.color = BP.text; count.style.borderColor = BP.text; });
1005    count.addEventListener('mouseleave', () => { count.style.color = BP.textDim; count.style.borderColor = BP.hairline; });
1006    count.addEventListener('click', (e) => {
1007      e.stopPropagation();
1008      selectedCount = selectedCount >= 4 ? 2 : selectedCount + 1;
1009      count.textContent = '\u00D7' + selectedCount;
1010    });
1011    row.appendChild(count);
1012
1013    // Go button
1014    const go = el('button', {
1015      padding: '5px 12px', borderRadius: '6px',
1016      border: 'none', background: BP.accent, color: BP.mark,
1017      fontFamily: FONT, fontSize: '12px', fontWeight: '600',
1018      cursor: 'pointer',
1019      transition: 'filter 0.12s ease, transform 0.1s ease',
1020      flexShrink: '0', whiteSpace: 'nowrap',
1021    });
1022    go.textContent = 'Go \u2192';
1023    go.addEventListener('mouseenter', () => go.style.filter = 'brightness(1.1)');
1024    go.addEventListener('mouseleave', () => go.style.filter = 'none');
1025    go.addEventListener('mousedown', () => go.style.transform = 'scale(0.97)');
1026    go.addEventListener('mouseup', () => go.style.transform = 'scale(1)');
1027    go.addEventListener('click', (e) => { e.stopPropagation(); handleGo(); });
1028    row.appendChild(go);
1029
1030    // Auto-focus input after a beat
1031    setTimeout(() => input.focus(), 60);
1032    return row;
1033  }
1034
1035  // --- Generating row ---
1036
1037  function buildGeneratingRow() {
1038    const row = el('div', {
1039      display: 'flex', alignItems: 'center', gap: '8px',
1040      padding: '2px 4px',
1041    });
1042
1043    // Action label
1044    const label = el('span', {
1045      fontWeight: '600', fontSize: '12px', color: BP.text,
1046      flexShrink: '0', whiteSpace: 'nowrap',
1047    });
1048    label.textContent = actionLabel();
1049    row.appendChild(label);
1050
1051    // Dots
1052    row.appendChild(buildDots(false));
1053
1054    // Status
1055    const status = el('span', {
1056      fontSize: '11px', color: BP.textDim, whiteSpace: 'nowrap',
1057      marginLeft: 'auto',
1058    });
1059    // Variants currently arrive atomically in a single file edit, so a
1060    // per-variant counter would lie. Say what's true.
1061    status.textContent = arrivedVariants < expectedVariants
1062      ? 'Generating ' + expectedVariants + ' variants...'
1063      : 'Done';
1064    row.appendChild(status);
1065
1066    return row;
1067  }
1068
1069  // --- Cycling row ---
1070
1071  const TUNE_ICON_SVG = '<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" style="flex-shrink:0"><line x1="4" y1="8" x2="20" y2="8"/><circle cx="14" cy="8" r="2.4" fill="currentColor" stroke="none"/><line x1="4" y1="16" x2="20" y2="16"/><circle cx="10" cy="16" r="2.4" fill="currentColor" stroke="none"/></svg>';
1072
1073  function buildCyclingRow() {
1074    const row = el('div', {
1075      display: 'flex', alignItems: 'center', gap: '6px',
1076      padding: '1px 2px',
1077    });
1078
1079    // Prev
1080    const prev = navBtn('\u2190');
1081    prev.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(-1); });
1082    if (visibleVariant <= 1) prev.style.opacity = '0.3';
1083    row.appendChild(prev);
1084
1085    // Dots (clickable)
1086    row.appendChild(buildDots(true));
1087
1088    // Counter
1089    const counter = el('span', {
1090      fontFamily: MONO, fontSize: '11px', fontWeight: '500',
1091      color: BP.textDim, minWidth: '24px', textAlign: 'center',
1092    });
1093    counter.textContent = visibleVariant + '/' + arrivedVariants;
1094    row.appendChild(counter);
1095
1096    // Next
1097    const next = navBtn('\u2192');
1098    next.addEventListener('click', (e) => { e.stopPropagation(); cycleVariant(1); });
1099    if (visibleVariant >= arrivedVariants) next.style.opacity = '0.3';
1100    row.appendChild(next);
1101
1102    // Tune chip — only when the visible variant exposes params
1103    const visParams = parseVariantParams(getVisibleVariantEl());
1104    const hasParams = visParams.length > 0;
1105    if (hasParams) {
1106      const tune = el('button', {
1107        display: 'inline-flex', alignItems: 'center', gap: '6px',
1108        padding: '4px 10px', borderRadius: '5px',
1109        border: '1px solid transparent',
1110        background: tuneOpen ? BP.accentSoft : 'transparent',
1111        color: tuneOpen ? BP.accent : BP.text,
1112        fontFamily: FONT, fontSize: '11px', fontWeight: '500',
1113        cursor: 'pointer',
1114        transition: 'color 0.12s ease, background 0.12s ease',
1115        whiteSpace: 'nowrap',
1116      });
1117      tune.innerHTML = TUNE_ICON_SVG;
1118      const tuneLabel = document.createElement('span');
1119      tuneLabel.textContent = 'Tune';
1120      tune.appendChild(tuneLabel);
1121      const tuneBadge = document.createElement('span');
1122      Object.assign(tuneBadge.style, {
1123        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
1124        minWidth: '16px', height: '16px', padding: '0 4px',
1125        borderRadius: '999px',
1126        background: tuneOpen ? C.brand : BP.hairline,
1127        color: tuneOpen ? 'oklch(98% 0 0)' : 'inherit',
1128        fontFamily: MONO, fontSize: '9.5px', fontWeight: '600',
1129        lineHeight: '1',
1130        boxSizing: 'border-box',
1131      });
1132      tuneBadge.textContent = String(visParams.length);
1133      tune.appendChild(tuneBadge);
1134      tune.title = 'Tune this variant (' + visParams.length + ' knob' + (visParams.length === 1 ? '' : 's') + ')';
1135      tune.addEventListener('mouseenter', () => {
1136        if (!tuneOpen) tune.style.background = BP.accentSoft;
1137      });
1138      tune.addEventListener('mouseleave', () => {
1139        if (!tuneOpen) tune.style.background = 'transparent';
1140      });
1141      tune.addEventListener('click', (e) => { e.stopPropagation(); toggleTunePopover(); });
1142      tune.dataset.iceqTune = '1';
1143      row.appendChild(tune);
1144    }
1145
1146    // Spacer
1147    row.appendChild(el('div', { flex: '1' }));
1148
1149    // Accept — primary action, uses the site's saturated brand magenta
1150    // with paper-white text, not the theme-muted BP.accent.
1151    const accept = el('button', {
1152      padding: '5px 14px', borderRadius: '5px',
1153      border: 'none', background: C.brand, color: 'oklch(98% 0 0)',
1154      fontFamily: FONT, fontSize: '11px', fontWeight: '600',
1155      cursor: 'pointer', transition: 'filter 0.12s ease, transform 0.1s ease',
1156      whiteSpace: 'nowrap',
1157    });
1158    accept.textContent = '\u2713 Accept';
1159    accept.addEventListener('mouseenter', () => accept.style.filter = 'brightness(1.08)');
1160    accept.addEventListener('mouseleave', () => accept.style.filter = 'none');
1161    accept.addEventListener('mousedown', () => accept.style.transform = 'scale(0.97)');
1162    accept.addEventListener('mouseup', () => accept.style.transform = 'scale(1)');
1163    accept.addEventListener('click', (e) => { e.stopPropagation(); handleAccept(); });
1164    if (arrivedVariants === 0) { accept.style.opacity = '0.3'; accept.style.pointerEvents = 'none'; }
1165    row.appendChild(accept);
1166
1167    // Discard
1168    const discard = el('button', {
1169      padding: '4px 6px', borderRadius: '5px',
1170      border: '1px solid ' + BP.hairline, background: 'transparent',
1171      fontFamily: FONT, fontSize: '11px', color: BP.textDim,
1172      cursor: 'pointer', transition: 'color 0.12s ease, border-color 0.12s ease',
1173    });
1174    discard.textContent = '\u2715';
1175    discard.title = 'Discard all variants';
1176    discard.addEventListener('mouseenter', () => { discard.style.color = BP.text; discard.style.borderColor = BP.text; });
1177    discard.addEventListener('mouseleave', () => { discard.style.color = BP.textDim; discard.style.borderColor = BP.hairline; });
1178    discard.addEventListener('click', (e) => { e.stopPropagation(); handleDiscard(); });
1179    row.appendChild(discard);
1180
1181    return row;
1182  }
1183
1184  // --- Shared UI builders ---
1185
1186  // --- Saving row (waiting for agent to process accept/discard) ---
1187
1188  function buildSavingRow() {
1189    const row = el('div', {
1190      display: 'flex', alignItems: 'center', gap: '8px',
1191      padding: '2px 8px',
1192    });
1193    const spinner = el('div', {
1194      width: '14px', height: '14px', borderRadius: '50%',
1195      border: '2px solid ' + BP.hairline,
1196      borderTopColor: BP.accent,
1197      animation: 'impeccable-spin 0.6s linear infinite',
1198      flexShrink: '0',
1199    });
1200    row.appendChild(spinner);
1201    const label = el('span', {
1202      fontSize: '12px', color: BP.textDim, fontWeight: '500',
1203    });
1204    label.textContent = 'Applying variant...';
1205    row.appendChild(label);
1206
1207    // Inject the keyframes if not already present
1208    if (!document.getElementById(PREFIX + '-keyframes')) {
1209      const style = document.createElement('style');
1210      style.id = PREFIX + '-keyframes';
1211      style.textContent = '@keyframes impeccable-spin { to { transform: rotate(360deg); } }';
1212      document.head.appendChild(style);
1213    }
1214    return row;
1215  }
1216
1217  // --- Confirmed row (green success, auto-dismisses) ---
1218
1219  function buildConfirmedRow() {
1220    const row = el('div', {
1221      display: 'flex', alignItems: 'center', gap: '8px',
1222      padding: '2px 8px',
1223    });
1224    const check = el('span', {
1225      fontSize: '15px', lineHeight: '1', flexShrink: '0',
1226      color: 'oklch(45% 0.15 145)',
1227    });
1228    check.textContent = '\u2713';
1229    row.appendChild(check);
1230    const label = el('span', {
1231      fontSize: '12px', color: 'oklch(35% 0.1 145)', fontWeight: '600',
1232    });
1233    label.textContent = 'Variant applied';
1234    row.appendChild(label);
1235    return row;
1236  }
1237
1238  // --- Shared UI builders ---
1239
1240  function buildDots(clickable) {
1241    const container = el('div', {
1242      display: 'flex', alignItems: 'center', gap: '4px',
1243    });
1244    for (let i = 1; i <= expectedVariants; i++) {
1245      const arrived = i <= arrivedVariants;
1246      const active = i === visibleVariant;
1247      // active: solid site-brand magenta dot. arrived+inactive: muted neutral.
1248      // pending (not yet arrived): faint outline ring. No borders on arrived
1249      // dots — the previous "accent ring + ash fill" combo read as noisy
1250      // magenta chips, especially when all variants had arrived and every
1251      // dot wore an accent ring.
1252      const dotBg = active ? C.brand
1253        : arrived ? BP.textDim
1254        : 'transparent';
1255      const dotBorder = arrived ? 'none' : '1.5px solid ' + BP.hairline;
1256      const dot = el('div', {
1257        width: active ? '8px' : '6px',
1258        height: active ? '8px' : '6px',
1259        borderRadius: '50%',
1260        background: dotBg,
1261        border: dotBorder,
1262        boxSizing: 'border-box',
1263        transition: 'all 0.2s ' + EASE,
1264        cursor: (clickable && arrived) ? 'pointer' : 'default',
1265        transform: arrived ? 'scale(1)' : 'scale(0.85)',
1266        opacity: arrived ? (active ? '1' : '0.6') : '0.4',
1267      });
1268      if (clickable && arrived) {
1269        const idx = i;
1270        dot.addEventListener('click', (e) => {
1271          e.stopPropagation();
1272          visibleVariant = idx;
1273          showVariantInDOM(currentSessionId, idx);
1274          updateSelectedElement();
1275          updateBarContent('cycling');
1276        });
1277      }
1278      container.appendChild(dot);
1279    }
1280    return container;
1281  }
1282
1283  function navBtn(text) {
1284    const b = el('button', {
1285      width: '26px', height: '26px', borderRadius: '5px',
1286      border: '1px solid ' + BP.hairline, background: 'transparent',
1287      color: BP.text, fontFamily: FONT, fontSize: '13px',
1288      cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
1289      transition: 'border-color 0.12s ease, background 0.12s ease',
1290      padding: '0', lineHeight: '1',
1291    });
1292    b.textContent = text;
1293    b.addEventListener('mouseenter', () => { b.style.borderColor = BP.text; });
1294    b.addEventListener('mouseleave', () => { b.style.borderColor = BP.hairline; });
1295    return b;
1296  }
1297
1298  function actionLabel() {
1299    const a = ACTIONS.find(a => a.value === selectedAction);
1300    return a ? a.label : 'Freeform';
1301  }
1302
1303  function el(tag, styles) {
1304    const e = document.createElement(tag);
1305    if (styles) Object.assign(e.style, styles);
1306    return e;
1307  }
1308
1309  // ---------------------------------------------------------------------------
1310  // Action picker popover
1311  // ---------------------------------------------------------------------------
1312
1313  function initActionPicker() {
1314    const P = barPaletteForTheme(detectPageTheme());
1315    pickerEl = document.createElement('div');
1316    pickerEl.id = PREFIX + '-picker';
1317    Object.assign(pickerEl.style, {
1318      position: 'fixed', zIndex: Z.picker,
1319      display: 'none', opacity: '0',
1320      transform: 'scale(0.96) translateY(4px)',
1321      transformOrigin: 'bottom left',
1322      transition: 'opacity 0.18s ' + EASE + ', transform 0.2s ' + EASE,
1323      background: P.surface,
1324      border: '1px solid ' + P.hairline,
1325      borderRadius: '10px',
1326      boxShadow: '0 8px 30px oklch(0% 0 0 / 0.10), 0 2px 6px oklch(0% 0 0 / 0.06)',
1327      padding: '6px',
1328      fontFamily: FONT,
1329      backdropFilter: 'blur(10px)',
1330      WebkitBackdropFilter: 'blur(10px)',
1331    });
1332
1333    // Build the chip grid
1334    const grid = el('div', {
1335      display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: '3px',
1336    });
1337
1338    ACTIONS.forEach(action => {
1339      const chip = el('button', {
1340        display: 'flex', flexDirection: 'column', alignItems: 'center',
1341        gap: '4px',
1342        padding: '8px 6px', borderRadius: '6px',
1343        border: 'none',
1344        background: action.value === selectedAction ? P.accentSoft : 'transparent',
1345        color: action.value === selectedAction ? P.accent : P.text,
1346        fontFamily: FONT, fontSize: '11px', fontWeight: '500',
1347        cursor: 'pointer',
1348        transition: 'background 0.1s ease, color 0.1s ease',
1349        textAlign: 'center', whiteSpace: 'nowrap',
1350      });
1351      const iconWrap = el('span', {
1352        display: 'flex', alignItems: 'center', justifyContent: 'center',
1353        height: '20px', opacity: '0.9',
1354      });
1355      iconWrap.innerHTML = ICONS[action.value] || '';
1356      const labelEl = el('span', { lineHeight: '1' });
1357      labelEl.textContent = action.label;
1358      chip.appendChild(iconWrap);
1359      chip.appendChild(labelEl);
1360      chip.dataset.action = action.value;
1361      chip.addEventListener('mouseenter', () => {
1362        if (action.value !== selectedAction) chip.style.background = P.accentSoft;
1363      });
1364      chip.addEventListener('mouseleave', () => {
1365        chip.style.background = action.value === selectedAction ? P.accentSoft : 'transparent';
1366      });
1367      chip.addEventListener('click', (e) => {
1368        e.stopPropagation();
1369        selectedAction = action.value;
1370        hideActionPicker();
1371        updateBarContent('configure');
1372      });
1373      grid.appendChild(chip);
1374    });
1375
1376    pickerEl.appendChild(grid);
1377    document.body.appendChild(pickerEl);
1378    defangOutsideHandlers(pickerEl);
1379
1380    // Cache the palette on the picker so toggleActionPicker's state refresh
1381    // uses the same theme-aware colors when it repaints chips.
1382    pickerEl.__iceq_palette = P;
1383  }
1384
1385  function toggleActionPicker() {
1386    if (pickerEl.style.display !== 'none') { hideActionPicker(); return; }
1387    // Rebuild chips to reflect current selection
1388    const P = pickerEl.__iceq_palette || barPaletteForTheme(detectPageTheme());
1389    pickerEl.querySelectorAll('button').forEach(chip => {
1390      const isActive = chip.dataset.action === selectedAction;
1391      chip.style.background = isActive ? P.accentSoft : 'transparent';
1392      chip.style.color = isActive ? P.accent : P.text;
1393    });
1394    // Position above the bar
1395    const barRect = barEl.getBoundingClientRect();
1396    const pickerH = 170; // approximate; grows with icon + label rows
1397    let top = barRect.top - pickerH - 6;
1398    if (top < 8) top = barRect.bottom + 6;
1399    Object.assign(pickerEl.style, {
1400      top: top + 'px', left: barRect.left + 'px',
1401      display: 'block',
1402    });
1403    requestAnimationFrame(() => {
1404      pickerEl.style.opacity = '1';
1405      pickerEl.style.transform = 'scale(1) translateY(0)';
1406    });
1407  }
1408
1409  function hideActionPicker() {
1410    if (!pickerEl) return;
1411    pickerEl.style.opacity = '0';
1412    pickerEl.style.transform = 'scale(0.96) translateY(4px)';
1413    setTimeout(() => { if (pickerEl) pickerEl.style.display = 'none'; }, 180);
1414  }
1415
1416  // ---------------------------------------------------------------------------
1417  // Params panel (per-variant coarse controls)
1418  //
1419  // Variants may declare a parameter manifest via a JSON attribute on the
1420  // variant wrapper:
1421  //
1422  //   <div data-impeccable-variant="1"
1423  //        data-impeccable-params='[{"id":"density","kind":"steps",...}]'>
1424  //
1425  // The panel docks to the right edge of the outline during CYCLING and
1426  // exposes 2-5 coarse knobs. Values apply to the variant wrapper so scoped
1427  // CSS can respond instantly without regeneration:
1428  //
1429  //   range  / numeric toggle  → CSS var  (`--p-<id>`)  used via var(--p-foo, N)
1430  //   steps  / boolean toggle  → data-p-<id> attribute  used via :scope[data-p-foo="..."]
1431  //
1432  // On variant switch, values reset to that variant's declared defaults.
1433  // On accept, current values are sent in the event payload so the agent
1434  // can bake them into the source-file write.
1435  // ---------------------------------------------------------------------------
1436
1437  let paramsPanelEl = null;     // outer wrapper (overflow:hidden, clips the slide)
1438  let paramsPanelInner = null;  // translating content (carries bg, padding, knobs)
1439  let paramsPanelBody = null;   // grid holding the knob cells
1440  let paramsCurrentValues = {}; // {paramId: value} — mirror of the visible variant's live values
1441  let tuneOpen = false;         // whether the Tune popover is open right now
1442
1443  // Theme-aware Tune popover. Appears as a drawer that slides out from the
1444  // contextual bar's bar-facing edge (below if the bar sits below the
1445  // element, above otherwise). Same width as the bar. Auto-wraps to extra
1446  // rows when the knobs exceed one row. The bar's border-radius on the
1447  // popover side goes flat while open so the two shapes read as one.
1448  let paramsPanelPalette = null;
1449
1450  function initParamsPanel() {
1451    paramsPanelPalette = barPaletteForTheme(detectPageTheme());
1452    const P = paramsPanelPalette;
1453
1454    // Single element, always in the DOM. The slide animation is a CSS mask
1455    // with mask-size growing from 0% to 100% along the bar-facing axis — no
1456    // display toggle, no opacity toggle, no transform trickery. The mask
1457    // hides everything initially; as it grows, content is revealed from
1458    // the bar edge outward.
1459    paramsPanelEl = document.createElement('div');
1460    paramsPanelEl.id = PREFIX + '-params-panel';
1461    Object.assign(paramsPanelEl.style, {
1462      position: 'fixed', zIndex: String(Z.bar - 1),
1463      background: P.surfaceDeep,
1464      color: P.text,
1465      fontFamily: FONT,
1466      padding: '14px 18px',
1467      boxSizing: 'border-box',
1468      borderRadius: '0 0 10px 10px',
1469      pointerEvents: 'none',
1470      backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
1471
1472      // clip-path is the same conceptual reveal as mask but with rock-solid
1473      // transition support across engines. Closed state clips from the far
1474      // edge; open = inset(0) shows everything.
1475      clipPath: 'inset(0 0 100% 0)',
1476      transition: 'clip-path 0.44s ' + EASE,
1477
1478      // Park off-screen until positionParamsPanel places it. These are NOT
1479      // in the transition list, so they snap instantly — no fly-in from the
1480      // top-left when first shown.
1481      top: '-9999px', left: '-9999px', width: '0',
1482    });
1483
1484    paramsPanelBody = el('div', {
1485      display: 'grid',
1486      gridTemplateColumns: 'repeat(auto-fit, minmax(120px, 1fr))',
1487      gap: '12px 16px',
1488    });
1489
1490    paramsPanelEl.appendChild(paramsPanelBody);
1491    document.body.appendChild(paramsPanelEl);
1492    // Don't override pointer-events: the panel toggles between 'none' (closed,
1493    // click-through) and 'auto' (open) on its own. Just silence the host's
1494    // outside-interaction listeners while the panel is open.
1495    defangOutsideHandlers(paramsPanelEl, { setPointerEvents: false });
1496    paramsPanelInner = paramsPanelEl; // compatibility alias for the rest of the code
1497  }
1498
1499  function getVisibleVariantEl() {
1500    if (!currentSessionId) return null;
1501    const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1502    if (!wrapper) return null;
1503    return wrapper.querySelector('[data-impeccable-variant="' + visibleVariant + '"]');
1504  }
1505
1506  function parseVariantParams(variantEl) {
1507    if (!variantEl) return [];
1508    const raw = variantEl.getAttribute('data-impeccable-params');
1509    if (!raw) return [];
1510    try {
1511      const parsed = JSON.parse(raw);
1512      return Array.isArray(parsed) ? parsed : [];
1513    } catch (err) {
1514      console.warn('[impeccable] Invalid data-impeccable-params JSON:', err.message);
1515      return [];
1516    }
1517  }
1518
1519  function applyParamValue(variantEl, param, value) {
1520    if (!variantEl) return;
1521    const attr = 'data-p-' + param.id;
1522    if (param.kind === 'range') {
1523      variantEl.style.setProperty('--p-' + param.id, String(value));
1524    } else if (param.kind === 'toggle') {
1525      const on = !!value;
1526      variantEl.style.setProperty('--p-' + param.id, on ? '1' : '0');
1527      if (on) variantEl.setAttribute(attr, 'on');
1528      else variantEl.removeAttribute(attr);
1529    } else if (param.kind === 'steps') {
1530      variantEl.setAttribute(attr, String(value));
1531    }
1532  }
1533
1534  function applyParamDefaults(variantEl, params) {
1535    paramsCurrentValues = {};
1536    for (const p of params) {
1537      paramsCurrentValues[p.id] = p.default;
1538      applyParamValue(variantEl, p, p.default);
1539    }
1540  }
1541
1542  function formatRangeValue(input) {
1543    const max = parseFloat(input.max), min = parseFloat(input.min);
1544    const v = parseFloat(input.value);
1545    if (!isFinite(v)) return input.value;
1546    return (max - min) <= 2 ? v.toFixed(2) : String(Math.round(v));
1547  }
1548
1549  function buildParamsPanel(variantEl, params) {
1550    const P = paramsPanelPalette || barPaletteForTheme(detectPageTheme());
1551    paramsPanelBody.innerHTML = '';
1552    for (const p of params) {
1553      const row = el('div', { display: 'flex', flexDirection: 'column', gap: '6px' });
1554      const labelRow = el('div', {
1555        display: 'flex', justifyContent: 'space-between',
1556        alignItems: 'baseline', gap: '8px',
1557      });
1558      const lbl = el('span', {
1559        fontSize: '10.5px', fontWeight: '600', color: P.text,
1560        letterSpacing: '0.03em',
1561      });
1562      lbl.textContent = p.label || p.id;
1563      labelRow.appendChild(lbl);
1564      const readout = el('span', {
1565        fontSize: '10.5px', color: P.textDim,
1566        fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace',
1567      });
1568      labelRow.appendChild(readout);
1569      row.appendChild(labelRow);
1570
1571      if (p.kind === 'range') {
1572        const input = document.createElement('input');
1573        input.type = 'range';
1574        input.min = String(p.min != null ? p.min : 0);
1575        input.max = String(p.max != null ? p.max : 1);
1576        input.step = String(p.step != null ? p.step : 0.05);
1577        input.value = String(p.default);
1578        Object.assign(input.style, {
1579          width: '100%', accentColor: C.brand, cursor: 'pointer',
1580        });
1581        readout.textContent = formatRangeValue(input);
1582        input.addEventListener('input', (e) => {
1583          e.stopPropagation();
1584          const v = parseFloat(input.value);
1585          paramsCurrentValues[p.id] = v;
1586          readout.textContent = formatRangeValue(input);
1587          applyParamValue(variantEl, p, v);
1588          queueCheckpoint('param_changed');
1589        });
1590        row.appendChild(input);
1591      } else if (p.kind === 'toggle') {
1592        const initial = !!p.default;
1593        readout.textContent = initial ? 'On' : 'Off';
1594        const track = el('button', {
1595          position: 'relative', width: '36px', height: '20px',
1596          borderRadius: '10px', border: 'none', padding: '0',
1597          cursor: 'pointer',
1598          background: initial ? C.brand : P.hairline,
1599          transition: 'background 0.15s ease',
1600          alignSelf: 'flex-start',
1601        });
1602        const knob = el('span', {
1603          position: 'absolute', top: '2px',
1604          left: initial ? '18px' : '2px',
1605          width: '16px', height: '16px', borderRadius: '50%',
1606          background: 'oklch(98% 0 0)',
1607          transition: 'left 0.18s ' + EASE,
1608          boxShadow: '0 1px 2px oklch(0% 0 0 / 0.2)',
1609        });
1610        track.appendChild(knob);
1611        track.addEventListener('click', (e) => {
1612          e.stopPropagation();
1613          const next = !paramsCurrentValues[p.id];
1614          paramsCurrentValues[p.id] = next;
1615          track.style.background = next ? C.brand : P.hairline;
1616          knob.style.left = next ? '18px' : '2px';
1617          readout.textContent = next ? 'On' : 'Off';
1618          applyParamValue(variantEl, p, next);
1619          queueCheckpoint('param_changed');
1620        });
1621        row.appendChild(track);
1622      } else if (p.kind === 'steps') {
1623        const opts = (p.options || []).map(o =>
1624          typeof o === 'string' ? { value: o, label: o } : o
1625        );
1626        const activeOpt = opts.find(o => o.value === p.default) || opts[0];
1627        readout.textContent = activeOpt ? activeOpt.label : String(p.default);
1628        const segRow = el('div', {
1629          display: 'grid',
1630          gridTemplateColumns: 'repeat(' + opts.length + ', 1fr)',
1631          gap: '1px', padding: '2px',
1632          background: P.hairline, borderRadius: '5px',
1633        });
1634        const segBtns = [];
1635        opts.forEach(o => {
1636          const active = o.value === p.default;
1637          const b = el('button', {
1638            padding: '5px 4px', border: 'none', borderRadius: '3px',
1639            background: active ? C.brand : 'transparent',
1640            color: active ? 'oklch(98% 0 0)' : P.text,
1641            fontFamily: FONT, fontSize: '10.5px', fontWeight: '500',
1642            cursor: 'pointer', whiteSpace: 'nowrap',
1643            transition: 'background 0.1s ease, color 0.1s ease',
1644          });
1645          b.textContent = o.label;
1646          b.addEventListener('click', (e) => {
1647            e.stopPropagation();
1648            paramsCurrentValues[p.id] = o.value;
1649            readout.textContent = o.label;
1650            segBtns.forEach(({ btn, val }) => {
1651              const on = val === o.value;
1652              btn.style.background = on ? C.brand : 'transparent';
1653              btn.style.color = on ? 'oklch(98% 0 0)' : P.text;
1654            });
1655            applyParamValue(variantEl, p, o.value);
1656            queueCheckpoint('param_changed');
1657          });
1658          segRow.appendChild(b);
1659          segBtns.push({ btn: b, val: o.value });
1660        });
1661        row.appendChild(segRow);
1662      }
1663
1664      paramsPanelBody.appendChild(row);
1665    }
1666  }
1667
1668  // Decide which way the popover opens: away from the picked element. If the
1669  // bar landed below the element, popover slides DOWN from the bar's bottom.
1670  // If the bar landed above, popover slides UP from the bar's top.
1671  function popoverDirection() {
1672    if (!barEl || !selectedElement) return 'below';
1673    const br = barEl.getBoundingClientRect();
1674    const er = selectedElement.getBoundingClientRect();
1675    return br.top >= er.bottom - 4 ? 'below' : 'above';
1676  }
1677
1678  // The popover overlaps the bar by OVERLAP px on the bar-facing side. With
1679  // popover z-index below bar, that overlap sits behind bar (invisible) and
1680  // reinforces the "tucked behind" feel. Padding compensates so the real
1681  // content starts flush with bar's outer edge.
1682  const TUNE_OVERLAP = 6;
1683
1684  // Closed clip-path depends on direction: for 'below' clip from the far
1685  // (bottom) edge so the reveal grows downward from the bar; for 'above'
1686  // clip from the top edge so the reveal grows upward from the bar.
1687  function closedClipPath(direction) {
1688    return direction === 'below' ? 'inset(0 0 100% 0)' : 'inset(100% 0 0 0)';
1689  }
1690
1691  function setClipPath(value, withTransition) {
1692    const saved = paramsPanelEl.style.transition;
1693    if (!withTransition) paramsPanelEl.style.transition = 'none';
1694    paramsPanelEl.style.clipPath = value;
1695    if (!withTransition) {
1696      void paramsPanelEl.offsetHeight;
1697      paramsPanelEl.style.transition = saved;
1698    }
1699  }
1700
1701  function positionParamsPanel() {
1702    if (!paramsPanelEl || !barEl || barEl.style.display === 'none') return;
1703    const br = barEl.getBoundingClientRect();
1704    const direction = popoverDirection();
1705    const prevDirection = paramsPanelEl.dataset.tuneDirection;
1706
1707    // top/left/width are NOT in the transition list, so they snap instantly.
1708    paramsPanelEl.style.left = br.left + 'px';
1709    paramsPanelEl.style.width = br.width + 'px';
1710
1711    if (direction === 'below') {
1712      paramsPanelEl.style.top = (br.bottom - TUNE_OVERLAP) + 'px';
1713      paramsPanelEl.style.borderRadius = '0 0 10px 10px';
1714      paramsPanelEl.style.paddingTop = (14 + TUNE_OVERLAP) + 'px';
1715      paramsPanelEl.style.paddingBottom = '14px';
1716    } else {
1717      const ih = paramsPanelEl.offsetHeight || 80;
1718      paramsPanelEl.style.top = (br.top - ih + TUNE_OVERLAP) + 'px';
1719      paramsPanelEl.style.borderRadius = '10px 10px 0 0';
1720      paramsPanelEl.style.paddingTop = '14px';
1721      paramsPanelEl.style.paddingBottom = (14 + TUNE_OVERLAP) + 'px';
1722    }
1723    paramsPanelEl.dataset.tuneDirection = direction;
1724
1725    // If currently closed and direction flipped (or first-time setup),
1726    // snap the clip-path to the new direction's closed pose without
1727    // transitioning (so the clip doesn't slide across the element).
1728    if (!tuneOpen && (!prevDirection || prevDirection !== direction)) {
1729      setClipPath(closedClipPath(direction), false);
1730    }
1731  }
1732
1733  function showParamsPanel() {
1734    if (!paramsPanelEl) return;
1735    positionParamsPanel();
1736    paramsPanelEl.style.pointerEvents = 'auto';
1737    // rAF so the positioning paint commits before the transition fires.
1738    requestAnimationFrame(() => {
1739      setClipPath('inset(0 0 0 0)', true);
1740    });
1741  }
1742
1743  function hideParamsPanel() {
1744    if (!paramsPanelEl) return;
1745    paramsPanelEl.style.pointerEvents = 'none';
1746    const direction = paramsPanelEl.dataset.tuneDirection || 'below';
1747    setClipPath(closedClipPath(direction), true);
1748  }
1749
1750  // Build/rebuild the panel's contents for the current variant AND apply
1751  // its defaults to the variant wrapper (so scoped CSS responds even before
1752  // the user opens the popover). Visibility is governed by tuneOpen.
1753  function refreshParamsPanel() {
1754    if (state !== 'CYCLING') {
1755      paramsCurrentValues = {};
1756      tuneOpen = false;
1757      hideParamsPanel();
1758      return;
1759    }
1760    const variantEl = getVisibleVariantEl();
1761    const params = parseVariantParams(variantEl);
1762    if (!variantEl || params.length === 0) {
1763      paramsCurrentValues = {};
1764      tuneOpen = false;
1765      hideParamsPanel();
1766      return;
1767    }
1768    applyParamDefaults(variantEl, params);
1769    buildParamsPanel(variantEl, params);
1770    if (tuneOpen) {
1771      // If already visible (variant cycled while open), refresh in place
1772      // instead of re-running the clip-path animation.
1773      const alreadyVisible = paramsPanelEl.style.display === 'block'
1774        && paramsPanelEl.style.opacity === '1';
1775      if (alreadyVisible) positionParamsPanel();
1776      else showParamsPanel();
1777    } else {
1778      hideParamsPanel();
1779    }
1780  }
1781
1782  function toggleTunePopover() {
1783    if (tuneOpen) { closeTunePopover(); return; }
1784    openTunePopover();
1785  }
1786
1787  function openTunePopover() {
1788    if (state !== 'CYCLING') return;
1789    const variantEl = getVisibleVariantEl();
1790    const params = parseVariantParams(variantEl);
1791    if (!variantEl || params.length === 0) return;
1792    // Build fresh to ensure the current variant's controls are shown.
1793    applyParamDefaults(variantEl, params);
1794    buildParamsPanel(variantEl, params);
1795    tuneOpen = true;
1796    showParamsPanel();
1797    // Kill the bar's shadow on the popover-facing side so the dark popover
1798    // doesn't pick up a bright glow line.
1799    if (barEl) {
1800      const direction = paramsPanelEl?.dataset.tuneDirection || 'below';
1801      barEl.style.boxShadow = direction === 'below' ? BAR_SHADOW_UP : BAR_SHADOW_DOWN;
1802    }
1803    // Re-render the bar so the Tune chip picks up the active styling.
1804    updateBarContent('cycling');
1805  }
1806
1807  function closeTunePopover() {
1808    tuneOpen = false;
1809    hideParamsPanel();
1810    if (barEl) barEl.style.boxShadow = BAR_SHADOW_DEFAULT;
1811    if (barEl && barEl.style.display !== 'none' && state === 'CYCLING') {
1812      updateBarContent('cycling');
1813    }
1814  }
1815
1816  // ---------------------------------------------------------------------------
1817  // Variant cycling in DOM
1818  // ---------------------------------------------------------------------------
1819
1820  function showVariantInDOM(sessionId, num) {
1821    const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1822    if (!wrapper) return;
1823    for (const child of wrapper.children) {
1824      const v = child.dataset ? child.dataset.impeccableVariant : null;
1825      if (!v) continue;
1826      child.style.display = (v === String(num)) ? '' : 'none';
1827    }
1828    // Unconditional refresh — covers first-reveal (no-op if state isn't
1829    // CYCLING yet, the subsequent CYCLING transition triggers its own
1830    // refresh) and every cycle step.
1831    refreshParamsPanel();
1832  }
1833
1834  /**
1835   * No-HMR fallback: fetch the raw source file from the live server,
1836   * parse it, extract the variant wrapper, and inject it into the live DOM.
1837   * This works even when the dev server caches HTML (Bun, static servers).
1838   */
1839  function injectVariantsFromSource(filePath, sessionId) {
1840    const url = 'http://localhost:' + PORT + '/source?token=' + TOKEN + '&path=' + encodeURIComponent(filePath);
1841    fetch(url)
1842      .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); })
1843      .then(html => {
1844        // Parse the raw source HTML
1845        const parser = new DOMParser();
1846        const doc = parser.parseFromString(html, 'text/html');
1847        const srcWrapper = doc.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1848        if (!srcWrapper) {
1849          console.error('[impeccable] Variant wrapper not found in source file.');
1850          return;
1851        }
1852
1853        // Find the original element in the live DOM.
1854        // The original is inside the wrapper in the source. We find the
1855        // corresponding element in the live DOM by matching the first child's
1856        // tag + classes from the original snapshot.
1857        const origContent = srcWrapper.querySelector('[data-impeccable-variant="original"] > :first-child');
1858        if (!origContent) return;
1859
1860        const tag = origContent.tagName.toLowerCase();
1861        const cls = origContent.className;
1862        let liveEl = null;
1863        if (origContent.id) {
1864          liveEl = document.getElementById(origContent.id);
1865        } else if (cls) {
1866          // Find by tag + exact class match
1867          const candidates = document.querySelectorAll(tag + '.' + cls.split(' ')[0]);
1868          for (const c of candidates) {
1869            if (c.className === cls && !own(c)) { liveEl = c; break; }
1870          }
1871        }
1872
1873        if (!liveEl) {
1874          console.error('[impeccable] Could not find original element in live DOM.');
1875          return;
1876        }
1877
1878        const previousVisibleVariant = currentSessionId === sessionId ? visibleVariant : 0;
1879
1880        // Replace the live element with the full wrapper from source
1881        const wrapper = srcWrapper.cloneNode(true);
1882        liveEl.parentElement.replaceChild(wrapper, liveEl);
1883
1884        // Update state: count variants, preserving the user's current variant
1885        // when a late HMR/source reinjection lands after they have cycled.
1886        const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
1887        arrivedVariants = variants.length;
1888        expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || arrivedVariants);
1889        const saved = loadSession();
1890        const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
1891        visibleVariant = previousVisibleVariant > 0 && previousVisibleVariant <= arrivedVariants
1892          ? previousVisibleVariant
1893          : (savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1);
1894        showVariantInDOM(sessionId, visibleVariant);
1895
1896        // Update selectedElement to the visible variant's content
1897        selectedElement = pickVariantContent(wrapper, visibleVariant) || wrapper.parentElement;
1898
1899        state = 'CYCLING';
1900        hideShaderOverlay();
1901        updateBarContent('cycling');
1902        refreshParamsPanel();
1903        saveSession();
1904        console.log('[impeccable] Injected ' + arrivedVariants + ' variants from source file.');
1905      })
1906      .catch(err => {
1907        console.error('[impeccable] Failed to fetch source:', err);
1908        showToast('Could not load variants. Try refreshing the page.', 5000);
1909      });
1910  }
1911
1912  function cycleVariant(dir) {
1913    const next = visibleVariant + dir;
1914    if (next < 1 || next > arrivedVariants) return;
1915    visibleVariant = next;
1916    showVariantInDOM(currentSessionId, next); // calls refreshParamsPanel itself
1917    updateSelectedElement();
1918    updateBarContent('cycling');
1919    saveSession();
1920    queueCheckpoint('variant_changed');
1921  }
1922
1923  function updateSelectedElement() {
1924    if (!currentSessionId) return;
1925    const wrapper = document.querySelector('[data-impeccable-variants="' + currentSessionId + '"]');
1926    if (!wrapper) return;
1927    const visEl = pickVariantContent(wrapper, visibleVariant);
1928    if (visEl) selectedElement = visEl;
1929  }
1930
1931  function readVisibleVariantFromDOM(sessionId) {
1932    const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
1933    if (!wrapper) return 0;
1934    const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
1935    for (const variant of variants) {
1936      if (variant.style.display === 'none') continue;
1937      const idx = parseInt(variant.dataset.impeccableVariant || '0', 10);
1938      if (idx > 0) return idx;
1939    }
1940    return 0;
1941  }
1942
1943  // Resolve the element that represents the variant's visible content.
1944  // Contract: each variant div should contain exactly one top-level element
1945  // (the full replacement). In practice a model may ship loose siblings or
1946  // lead with <style>/<script>. Be defensive: skip non-visual elements, and
1947  // if the variant has multiple element children, use the variant div itself
1948  // (it wraps all of them and gets correct bounds).
1949  function pickVariantContent(wrapper, index) {
1950    if (!wrapper) return null;
1951    const variantDiv = wrapper.querySelector('[data-impeccable-variant="' + index + '"]');
1952    if (!variantDiv) return null;
1953    const NON_VISUAL = new Set(['STYLE', 'SCRIPT', 'LINK', 'META', 'TEMPLATE']);
1954    const visual = [];
1955    for (const child of variantDiv.children) {
1956      if (!NON_VISUAL.has(child.tagName)) visual.push(child);
1957    }
1958    if (visual.length === 1) return visual[0];
1959    return variantDiv;
1960  }
1961
1962  // Hold window.scrollY at a fixed value across DOM mutations inside the
1963  // session's wrapper (HMR patches, variant inserts, cycle swaps).
1964  function startScrollLock(sessionId, initialTargetY) {
1965    stopScrollLock();
1966    scrollLockTargetY = typeof initialTargetY === 'number' && isFinite(initialTargetY)
1967      ? initialTargetY
1968      : window.scrollY;
1969    console.log('[impeccable.scroll] startScrollLock', { sessionId, scrollY: window.scrollY, targetY: scrollLockTargetY, initialOverride: initialTargetY });
1970
1971    try { history.scrollRestoration = 'manual'; } catch {}
1972
1973    const prevHtmlAnchor = document.documentElement.style.overflowAnchor;
1974    const prevBodyAnchor = document.body.style.overflowAnchor;
1975    document.documentElement.style.overflowAnchor = 'none';
1976    document.body.style.overflowAnchor = 'none';
1977
1978    const correct = (why) => {
1979      scrollLockRaf = null;
1980      if (scrollLockTargetY == null) return;
1981      const before = window.scrollY;
1982      const delta = before - scrollLockTargetY;
1983      if (Math.abs(delta) < 0.5) {
1984        console.log('[impeccable.scroll] correct noop', { why, scrollY: before, targetY: scrollLockTargetY });
1985        return;
1986      }
1987      window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
1988      console.log('[impeccable.scroll] corrected', { why, from: before, to: scrollLockTargetY, delta, nowAt: window.scrollY });
1989    };
1990    const schedule = (why) => {
1991      if (scrollLockRaf != null) return;
1992      scrollLockRaf = requestAnimationFrame(() => correct(why));
1993    };
1994
1995    scrollLockObserver = new MutationObserver((mutations) => {
1996      for (const m of mutations) {
1997        if (m.target?.closest?.('[data-impeccable-variants="' + sessionId + '"]')) {
1998          const childAdds = Array.from(m.addedNodes).map(n => n.nodeType === 1 ? (n.tagName + (n.dataset?.impeccableVariant ? ('[variant=' + n.dataset.impeccableVariant + ']') : '')) : n.nodeType).join(',');
1999          console.log('[impeccable.scroll] mutation inside wrapper', { type: m.type, target: m.target?.tagName, adds: childAdds, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
2000          schedule('mutation-in-wrapper');
2001          return;
2002        }
2003        for (const n of m.addedNodes) {
2004          if (n.nodeType === 1 && (n.matches?.('[data-impeccable-variants="' + sessionId + '"]') || n.querySelector?.('[data-impeccable-variants="' + sessionId + '"]'))) {
2005            console.log('[impeccable.scroll] wrapper node added', { tag: n.tagName, scrollYBefore: window.scrollY, targetY: scrollLockTargetY });
2006            schedule('wrapper-added');
2007            return;
2008          }
2009        }
2010      }
2011    });
2012    scrollLockObserver.observe(document.body, { childList: true, subtree: true });
2013
2014    scrollLockAbort = new AbortController();
2015    scrollLockAbort.signal.addEventListener('abort', () => {
2016      document.documentElement.style.overflowAnchor = prevHtmlAnchor;
2017      document.body.style.overflowAnchor = prevBodyAnchor;
2018    }, { once: true });
2019    const sig = { signal: scrollLockAbort.signal };
2020    // Track whether the most recent scroll came from a user gesture. We
2021    // gate user-scroll re-anchoring on this flag so programmatic smooth
2022    // scrolls (browser reload-restore, scrollIntoView from other scripts)
2023    // don't accidentally update our target.
2024    let userGestureAt = 0;
2025    const USER_GESTURE_WINDOW_MS = 250;
2026
2027    const reanchor = (why) => {
2028      if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
2029      const prevTarget = scrollLockTargetY;
2030      scrollLockTargetY = window.scrollY;
2031      writeScrollY(scrollLockTargetY);
2032      console.log('[impeccable.scroll] reanchor', { why, prevTarget, newTarget: scrollLockTargetY });
2033    };
2034    const markGesture = (why) => {
2035      userGestureAt = performance.now();
2036      reanchor(why);
2037    };
2038    window.addEventListener('wheel', () => markGesture('wheel'), { passive: true, ...sig });
2039    window.addEventListener('touchstart', () => markGesture('touchstart'), { passive: true, ...sig });
2040    window.addEventListener('touchmove', () => markGesture('touchmove'), { passive: true, ...sig });
2041    window.addEventListener('keydown', (e) => {
2042      if (['PageDown', 'PageUp', ' ', 'End', 'Home', 'ArrowDown', 'ArrowUp'].includes(e.key)) markGesture('key:' + e.key);
2043    }, sig);
2044
2045    // Correct on EVERY scroll event: whether it's the browser's
2046    // post-reload animated restore or some other script calling
2047    // scrollIntoView, we want to snap back immediately. Only skip if a
2048    // user gesture fired in the last 250ms.
2049    let lastLoggedScrollY = window.scrollY;
2050    window.addEventListener('scroll', () => {
2051      const now = window.scrollY;
2052      if (Math.abs(now - lastLoggedScrollY) > 5) {
2053        console.log('[impeccable.scroll] scroll event', { from: lastLoggedScrollY, to: now, targetY: scrollLockTargetY });
2054        lastLoggedScrollY = now;
2055      }
2056      if (scrollLockTargetY == null) return;
2057      if (performance.now() - userGestureAt < USER_GESTURE_WINDOW_MS) return;
2058      if (Math.abs(now - scrollLockTargetY) < 0.5) return;
2059      console.log('[impeccable.scroll] scroll-event snap', { from: now, to: scrollLockTargetY });
2060      window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
2061    }, { passive: true, ...sig });
2062
2063    // Apply target synchronously, not via rAF — racing the browser's
2064    // restore or a smooth-scroll animation means we want to win now.
2065    if (Math.abs(window.scrollY - scrollLockTargetY) > 0.5) {
2066      window.scrollTo({ top: scrollLockTargetY, left: window.scrollX, behavior: 'instant' });
2067      console.log('[impeccable.scroll] startScrollLock initial apply', { to: scrollLockTargetY });
2068    }
2069  }
2070
2071  function stopScrollLock() {
2072    if (scrollLockObserver) { scrollLockObserver.disconnect(); scrollLockObserver = null; }
2073    if (scrollLockRaf != null) { cancelAnimationFrame(scrollLockRaf); scrollLockRaf = null; }
2074    if (scrollLockAbort) { scrollLockAbort.abort(); scrollLockAbort = null; }
2075    scrollLockTargetY = null;
2076    // NOTE: do NOT clear the persistent scroll key here. startScrollLock
2077    // calls us as a reset, and clearing the key would nuke the Go-time
2078    // scrollY that the next resume needs to read.
2079  }
2080
2081  // ---------------------------------------------------------------------------
2082  // MutationObserver for progressive variant reveal
2083  // ---------------------------------------------------------------------------
2084
2085  function startVariantObserver(sessionId) {
2086    let updating = false; // re-entrancy guard
2087
2088    const obs = new MutationObserver((mutations) => {
2089      if (updating) return;
2090
2091      // Only react to mutations that add nodes with data-impeccable-variant,
2092      // or mutations inside the variant wrapper. Ignore our own bar/UI changes.
2093      let dominated = false;
2094      for (const m of mutations) {
2095        if (m.target.closest?.('[data-impeccable-variants]')) { dominated = true; break; }
2096        for (const n of m.addedNodes) {
2097          if (n.nodeType !== 1) continue;
2098          // Direct hit: the added node itself is the wrapper or a variant.
2099          if (n.dataset?.impeccableVariants || n.dataset?.impeccableVariant) {
2100            dominated = true; break;
2101          }
2102          // Subtree hit: framework HMR (notably SvelteKit) sometimes replaces
2103          // a whole subtree where the wrapper is a descendant of the added
2104          // node. Without this check, the observer ignores those mutations
2105          // and the session stays in GENERATING forever.
2106          if (n.querySelector?.('[data-impeccable-variants],[data-impeccable-variant]')) {
2107            dominated = true; break;
2108          }
2109        }
2110        if (dominated) break;
2111      }
2112      if (!dominated) return;
2113
2114      const wrapper = document.querySelector('[data-impeccable-variants="' + sessionId + '"]');
2115      if (!wrapper) return;
2116
2117      // Re-anchor selectedElement if it was detached by live-wrap's HMR swap.
2118      // Without this, the shader / highlight / bar track a zero-rect phantom
2119      // and the overlay appears frozen.
2120      if (selectedElement && !document.body.contains(selectedElement)) {
2121        selectedElement = pickVariantContent(wrapper, 'original') || wrapper;
2122      }
2123
2124      const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
2125      const count = variants.length;
2126
2127      // Nothing new
2128      if (count <= arrivedVariants) return;
2129
2130      updating = true;
2131      arrivedVariants = count;
2132      if (visibleVariant === 0 && arrivedVariants > 0) {
2133        const saved = loadSession();
2134        const savedVisibleVariant = saved && saved.id === sessionId ? saved.visible : 0;
2135        visibleVariant = savedVisibleVariant > 0 && savedVisibleVariant <= arrivedVariants ? savedVisibleVariant : 1;
2136        showVariantInDOM(sessionId, visibleVariant);
2137        // showVariantInDOM hid the original (display:none); if we were still
2138        // anchored to the original's content, its boundingRect is now zero
2139        // and the bar snaps to (0,0). Re-point at the visible variant instead.
2140        const visEl = pickVariantContent(wrapper, visibleVariant);
2141        if (visEl) selectedElement = visEl;
2142      }
2143
2144      const expected = parseInt(wrapper.dataset.impeccableVariantCount || '0');
2145      if (expected > 0) expectedVariants = expected;
2146
2147      if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
2148        state = 'CYCLING';
2149        hideShaderOverlay();
2150        updateBarContent('cycling');
2151        refreshParamsPanel();
2152      } else if (state === 'GENERATING') {
2153        updateBarContent('generating');
2154      }
2155      saveSession();
2156      queueCheckpoint(state === 'CYCLING' ? 'variants_ready' : 'variants_progress');
2157      updating = false;
2158    });
2159
2160    obs.observe(document.body, { childList: true, subtree: true });
2161    return obs;
2162  }
2163
2164  // ---------------------------------------------------------------------------
2165  // Bar scroll tracking
2166  // ---------------------------------------------------------------------------
2167
2168  function startScrollTracking() {
2169    function tick() {
2170      if (state === 'CONFIGURING' || state === 'GENERATING' || state === 'CYCLING') {
2171        positionBar();
2172        showHighlight(selectedElement);
2173        if (tuneOpen) positionParamsPanel();
2174      }
2175      if (annotActive) positionAnnotOverlay(selectedElement);
2176      // Shader overlay (via debug P toggle or generation) is repositioned
2177      // by its own branch below; debug no longer has a separate overlay.
2178      if (shaderState) positionShaderOverlay();
2179      scrollRaf = requestAnimationFrame(tick);
2180    }
2181    scrollRaf = requestAnimationFrame(tick);
2182  }
2183
2184  function stopScrollTracking() {
2185    if (scrollRaf) { cancelAnimationFrame(scrollRaf); scrollRaf = null; }
2186  }
2187
2188  // ---------------------------------------------------------------------------
2189  // SSE (server→browser) + fetch POST (browser→server)
2190  // Zero-dependency replacement for WebSocket.
2191  // ---------------------------------------------------------------------------
2192
2193  let evtSource = null;
2194  let sseRetries = 0;
2195  const SSE_MAX_RETRIES = 20;  // generous: heartbeats keep the connection alive, so retries mean real trouble
2196
2197  function connectSSE() {
2198    evtSource = new EventSource('http://localhost:' + PORT + '/events?token=' + TOKEN);
2199
2200    evtSource.onopen = () => {
2201      sseRetries = 0; // reset on successful (re)connect
2202    };
2203
2204    evtSource.onmessage = (e) => {
2205      sseRetries = 0; // reset on any successful message
2206      let msg; try { msg = JSON.parse(e.data); } catch { return; }
2207      switch (msg.type) {
2208        case 'connected':
2209          hasProjectContext = !!msg.hasProjectContext;
2210          if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000);
2211          console.log('[impeccable] Live mode connected.');
2212          if (state === 'IDLE') state = 'PICKING';
2213          break;
2214        case 'done':
2215          // Variants already arrived via HMR → normal transition.
2216          if (arrivedVariants >= expectedVariants && expectedVariants > 0) {
2217            if (state === 'GENERATING') {
2218              state = 'CYCLING';
2219              updateBarContent('cycling');
2220              refreshParamsPanel();
2221            }
2222            break;
2223          }
2224          // Variants are in source but not in the DOM yet. Common when the
2225          // picked element lived inside conditional render (closed modal,
2226          // hidden tab, a route the user navigated away from). The variant
2227          // MutationObserver stays armed and auto-transitions to CYCLING
2228          // the moment the wrapper actually mounts. Nudge the user toward
2229          // that path with a toast — better than the prior force-reload
2230          // which reset framework state and left the session stuck.
2231          setTimeout(() => {
2232            if (arrivedVariants >= expectedVariants && expectedVariants > 0) return;
2233            if (state !== 'GENERATING') return;
2234            showToast(
2235              "Variants ready. If the picked element isn't visible, retrace the path that revealed it — they'll appear automatically.",
2236              15000,
2237            );
2238          }, 2000);
2239          break;
2240        case 'error':
2241          console.error('[impeccable] Error:', msg.message);
2242          showToast('Error: ' + msg.message, 5000);
2243          hideBar();
2244          state = 'PICKING';
2245          break;
2246      }
2247    };
2248
2249    evtSource.onerror = () => {
2250      sseRetries++;
2251      if (sseRetries <= SSE_MAX_RETRIES) {
2252        console.log('[impeccable] SSE connection lost. Retry ' + sseRetries + '/' + SSE_MAX_RETRIES + '...');
2253        return; // EventSource auto-reconnects
2254      }
2255      // Server is gone. Clean up gracefully.
2256      console.log('[impeccable] Live server unreachable. Cleaning up UI.');
2257      evtSource.close();
2258      evtSource = null;
2259      handleServerLost();
2260    };
2261  }
2262
2263  /** Server died or became unreachable. Reset UI to a clean state. */
2264  function handleServerLost() {
2265    const recoveryState = currentSessionId ? state : 'IDLE';
2266    if (state === 'GENERATING' || state === 'CYCLING' || state === 'SAVING') {
2267      showToast('Live server disconnected. Session ended.', 5000);
2268    }
2269    hideBar();
2270    hideHighlight();
2271    hideShaderOverlay();
2272    hideAnnotOverlay();
2273    stopScrollTracking();
2274    if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
2275    stopScrollLock();
2276    // Preserve local session state on server loss. The durable journal is the
2277    // source of truth, but localStorage plus the variant wrapper lets the UI
2278    // resume after a helper restart or page reload instead of treating a
2279    // transient disconnect as an explicit discard.
2280    selectedElement = null;
2281    selectedAction = 'impeccable';
2282    state = recoveryState;
2283    if (currentSessionId) saveSession();
2284  }
2285
2286  function sendEvent(msg, opts) {
2287    msg.token = TOKEN;
2288    function handleFailure(err) {
2289      console.error('[impeccable] Failed to send event:', err);
2290      if (opts && opts.throwOnError) throw err;
2291      return null;
2292    }
2293    return fetch('http://localhost:' + PORT + '/events', {
2294      method: 'POST',
2295      headers: { 'Content-Type': 'application/json' },
2296      body: JSON.stringify(msg),
2297    }).then(res => {
2298      if (res.ok) return res;
2299      return handleFailure(new Error('HTTP ' + res.status + ' ' + res.statusText));
2300    }).catch(handleFailure);
2301  }
2302
2303  function checkpointPayload(reason) {
2304    return {
2305      type: 'checkpoint',
2306      id: currentSessionId,
2307      revision: sessionState.nextCheckpointRevision(),
2308      owner: browserOwner,
2309      phase: String(state || '').toLowerCase(),
2310      reason,
2311      pageUrl: location.pathname,
2312      expectedVariants,
2313      arrivedVariants,
2314      visibleVariant,
2315      paramValues: { ...paramsCurrentValues },
2316    };
2317  }
2318
2319  function sendCheckpoint(reason) {
2320    if (!currentSessionId) return Promise.resolve(null);
2321    return sendEvent(checkpointPayload(reason)).catch(() => null);
2322  }
2323
2324  function queueCheckpoint(reason) {
2325    if (!currentSessionId) return;
2326    if (checkpointTimer) clearTimeout(checkpointTimer);
2327    checkpointTimer = setTimeout(() => {
2328      checkpointTimer = null;
2329      sendCheckpoint(reason);
2330    }, 120);
2331  }
2332
2333  // ---------------------------------------------------------------------------
2334  // Event handlers
2335  // ---------------------------------------------------------------------------
2336
2337  function handleMouseMove(e) {
2338    if (state !== 'PICKING' || !pickActive) return;
2339    const target = document.elementFromPoint(e.clientX, e.clientY);
2340    if (!target || !pickable(target) || target === hoveredElement) return;
2341    hoveredElement = target;
2342    showHighlight(target);
2343  }
2344
2345  function handleClick(e) {
2346    // Close action picker on any outside click
2347    if (pickerEl?.style.display !== 'none' && !own(e.target)) {
2348      hideActionPicker();
2349    }
2350    // Close Tune popover on outside click (anything outside panel + bar)
2351    if (tuneOpen && paramsPanelEl && !paramsPanelEl.contains(e.target) && barEl && !barEl.contains(e.target)) {
2352      closeTunePopover();
2353    }
2354    // In CONFIGURING: click outside the bar and selected element returns to PICKING
2355    if (state === 'CONFIGURING' && !own(e.target) && selectedElement && !selectedElement.contains(e.target)) {
2356      hideBar();
2357      stopScrollTracking();
2358      hideAnnotOverlay();
2359      clearAnnotations();
2360      state = 'PICKING';
2361      hoveredElement = null;
2362      hideHighlight();
2363      return;
2364    }
2365    if (state !== 'PICKING' || !pickActive) return;
2366    if (own(e.target)) return;
2367    if (!hoveredElement || !pickable(hoveredElement)) return;
2368    e.preventDefault();
2369    e.stopPropagation();
2370    selectedElement = hoveredElement;
2371    state = 'CONFIGURING';
2372    showHighlight(selectedElement);
2373    clearAnnotations();
2374    showAnnotOverlay(selectedElement);
2375    showBar('configure');
2376    startScrollTracking();
2377    maybePrefetchPage();
2378    maybeWarnConditionalAncestor(selectedElement);
2379  }
2380
2381  /**
2382   * Surface a brief, non-blocking heads-up when the picked element lives
2383   * inside a container whose visibility is gated by ephemeral state — modals,
2384   * collapsible panels, popovers, off-screen tab panels. If HMR remounts the
2385   * parent during generation (Vite Fast Refresh, SvelteKit page reload), the
2386   * variants land in source but stay invisible until the user re-opens the
2387   * container. Telling the user upfront is much friendlier than the silent
2388   * timeout-then-toast that they'd otherwise hit.
2389   *
2390   * Heuristic, intentionally narrow — only fires for unambiguous cases so
2391   * we don't cry wolf on every nested element.
2392   */
2393  function maybeWarnConditionalAncestor(el) {
2394    let node = el?.parentElement;
2395    let depth = 0;
2396    while (node && depth < 12) {
2397      // 1. Active dialog / modal
2398      if (node.getAttribute && node.getAttribute('role') === 'dialog'
2399          && node.getAttribute('aria-modal') === 'true') {
2400        showToast('Heads up: this element lives inside a dialog. If state resets during generation, you may need to re-open it.', 6000);
2401        return;
2402      }
2403      // 2. Common Radix / shadcn / headless-ui open-state attribute
2404      if (node.dataset && node.dataset.state === 'open') {
2405        showToast('Heads up: this element lives inside an open panel. If state resets during generation, you may need to re-open it.', 6000);
2406        return;
2407      }
2408      // 3. Tab panel — only meaningful when the page also shows ANOTHER
2409      // tab as selected. A single tabpanel with no tablist is just a static
2410      // section in disguise and isn't conditional.
2411      if (node.getAttribute && node.getAttribute('role') === 'tabpanel') {
2412        const list = document.querySelector('[role="tablist"]');
2413        if (list) {
2414          const tabs = list.querySelectorAll('[role="tab"]');
2415          if (tabs.length > 1) {
2416            showToast('Heads up: this element lives in a tab panel. If state resets during generation, switch back to this tab.', 6000);
2417            return;
2418          }
2419        }
2420      }
2421      // 4. Collapsible: aria-expanded sibling. Look for the trigger button.
2422      if (node.id) {
2423        const trigger = document.querySelector(`[aria-controls="${CSS.escape(node.id)}"][aria-expanded="true"]`);
2424        if (trigger) {
2425          showToast('Heads up: this element lives inside an expandable section. If state resets during generation, re-expand it.', 6000);
2426          return;
2427        }
2428      }
2429      node = node.parentElement;
2430      depth++;
2431    }
2432  }
2433
2434  // Fire a lightweight prefetch event the first time the user selects an
2435  // element on a given route. The agent uses this to Read the underlying file
2436  // into context before Go is hit, shaving the read off the critical path.
2437  // Dedupe per session by pathname — clicking around on the same page doesn't
2438  // re-fire.
2439  //
2440  // DISABLED: quick-Go workflows pay an extra harness round trip because
2441  // prefetch + generate arrive as two events instead of one. Re-enable with
2442  // a browser-side debounce (~800–1000ms, cancelled on Go) if we want to
2443  // resurrect this. Server validator and skill dispatch remain in place so
2444  // flipping this flag is the only change needed.
2445  const PREFETCH_ENABLED = false;
2446  const prefetchedPaths = new Set();
2447  function maybePrefetchPage() {
2448    if (!PREFETCH_ENABLED) return;
2449    const path = location.pathname;
2450    if (prefetchedPaths.has(path)) return;
2451    prefetchedPaths.add(path);
2452    sendEvent({ type: 'prefetch', pageUrl: path });
2453  }
2454
2455  function handleKeyDown(e) {
2456    // When the annotation input is focused, let it handle its own keys.
2457    if (annotEditing && annotEditing.input && e.target === annotEditing.input) return;
2458    if (e.key === 'Escape') {
2459      e.preventDefault();
2460      if (pickerEl?.style.display !== 'none') { hideActionPicker(); return; }
2461      if (state === 'CONFIGURING') { hideBar(); stopScrollTracking(); hideAnnotOverlay(); clearAnnotations(); state = 'PICKING'; return; }
2462      if (state === 'CYCLING') { handleDiscard(); return; }
2463      if (state === 'SAVING' || state === 'CONFIRMED') return; // don't interrupt
2464      if (state === 'PICKING') {
2465        // Use togglePick so the "Pick" button in the global bar also flips
2466        // off, otherwise the bar stays lit while nothing else is active.
2467        if (pickActive) togglePick();
2468        else { hideHighlight(); state = 'IDLE'; }
2469        return;
2470      }
2471    }
2472
2473    // Arrow/Enter nav works in PICKING (hover) and CONFIGURING (selected, input empty)
2474    var navEl = (state === 'PICKING') ? hoveredElement : (state === 'CONFIGURING') ? selectedElement : null;
2475    if (navEl && (e.key === 'ArrowUp' || e.key === 'ArrowDown' || (e.key === 'Enter' && state === 'PICKING'))) {
2476      let next = null;
2477      if (e.key === 'ArrowDown' && !e.shiftKey) {
2478        next = navEl.nextElementSibling;
2479        while (next && !pickable(next)) next = next.nextElementSibling;
2480      } else if (e.key === 'ArrowUp' && !e.shiftKey) {
2481        next = navEl.previousElementSibling;
2482        while (next && !pickable(next)) next = next.previousElementSibling;
2483      } else if (e.key === 'ArrowUp' && e.shiftKey) {
2484        next = navEl.parentElement;
2485        if (next && !pickable(next)) next = null;
2486      } else if (e.key === 'ArrowDown' && e.shiftKey) {
2487        next = navEl.firstElementChild;
2488        while (next && !pickable(next)) next = next.nextElementSibling;
2489      } else if (e.key === 'Enter') {
2490        e.preventDefault();
2491        selectedElement = hoveredElement;
2492        state = 'CONFIGURING';
2493        showHighlight(selectedElement);
2494        clearAnnotations();
2495        showAnnotOverlay(selectedElement);
2496        showBar('configure');
2497        startScrollTracking();
2498        return;
2499      }
2500      if (next) {
2501        e.preventDefault();
2502        if (state === 'PICKING') {
2503          hoveredElement = next;
2504        } else {
2505          // CONFIGURING: re-select the new element and refresh the bar
2506          selectedElement = next;
2507          clearAnnotations();
2508          showAnnotOverlay(next);
2509          showBar('configure');
2510          startScrollTracking();
2511        }
2512        showHighlight(next);
2513        next.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
2514      }
2515      return;
2516    }
2517
2518    if (state === 'CYCLING') {
2519      if (e.key === 'ArrowLeft') { e.preventDefault(); cycleVariant(-1); }
2520      if (e.key === 'ArrowRight') { e.preventDefault(); cycleVariant(1); }
2521      if (e.key === 'Enter') { e.preventDefault(); handleAccept(); }
2522    }
2523  }
2524
2525  function handleGo() {
2526    if (!selectedElement || state !== 'CONFIGURING') return;
2527    const input = document.getElementById(PREFIX + '-input');
2528    const prompt = input ? input.value.trim() : '';
2529
2530    // Commit any pending pin edit BEFORE we snapshot annotations.
2531    if (annotEditing) finalizeEditingPin();
2532
2533    currentSessionId = id8();
2534    expectedVariants = selectedCount;
2535    arrivedVariants = 0;
2536    visibleVariant = 0;
2537
2538    // Flip to GENERATING immediately so the bar morphs without waiting on
2539    // capture + upload. The event is emitted from captureAndEmit() once the
2540    // screenshot is uploaded (or capture fails — we still emit, just without
2541    // screenshotPath).
2542    const elForCapture = selectedElement;
2543    const captureRect = elForCapture.getBoundingClientRect();
2544    const snapshot = {
2545      comments: annotState.comments.map(c => ({ x: c.x, y: c.y, text: c.text })),
2546      strokes: annotState.strokes.map(s => ({ points: s.points.map(p => [p[0], p[1]]) })),
2547    };
2548    const basePayload = {
2549      type: 'generate', id: currentSessionId,
2550      action: selectedAction,
2551      freeformPrompt: prompt || undefined,
2552      count: selectedCount,
2553      pageUrl: location.pathname,
2554      element: extractContext(elForCapture),
2555    };
2556    if (snapshot.comments.length > 0) basePayload.comments = snapshot.comments;
2557    if (snapshot.strokes.length > 0) basePayload.strokes = snapshot.strokes;
2558
2559    // Hide the interactive overlay so it doesn't linger during generation.
2560    hideAnnotOverlay();
2561    clearAnnotations();
2562
2563    state = 'GENERATING';
2564    showBar('generating');
2565    saveSession();
2566    sendCheckpoint('generate_started');
2567    writeScrollY(window.scrollY);
2568    if (variantObserver) variantObserver.disconnect();
2569    variantObserver = startVariantObserver(currentSessionId);
2570    console.log('[impeccable.scroll] Go pressed', { scrollY: window.scrollY, sessionId: currentSessionId });
2571    startScrollLock(currentSessionId);
2572
2573    captureAndEmit(elForCapture, basePayload, snapshot, captureRect);
2574  }
2575
2576  // ---------------------------------------------------------------------------
2577  // Screenshot capture + upload
2578  // ---------------------------------------------------------------------------
2579
2580  let msLoadPromise = null;
2581  function loadModernScreenshot() {
2582    if (window.modernScreenshot) return Promise.resolve(window.modernScreenshot);
2583    if (msLoadPromise) return msLoadPromise;
2584    msLoadPromise = new Promise((resolve, reject) => {
2585      const s = document.createElement('script');
2586      s.src = 'http://localhost:' + PORT + '/modern-screenshot.js';
2587      s.onload = () => resolve(window.modernScreenshot);
2588      s.onerror = () => { msLoadPromise = null; reject(new Error('modern-screenshot failed to load')); };
2589      document.head.appendChild(s);
2590    });
2591    return msLoadPromise;
2592  }
2593
2594  // Collect @font-face rules from every stylesheet on the page. Cross-origin
2595  // sheets (Google Fonts, Typekit, etc.) throw SecurityError on .cssRules
2596  // access, so modern-screenshot can't embed them on its own — the resulting
2597  // SVG falls back to system fonts and text re-wraps + renders with different
2598  // weight. We fetch the raw CSS text (CORS-permitted for these providers),
2599  // extract @font-face blocks, inline the referenced font files as base64
2600  // data URIs (SVGs rasterized via canvas can't fetch external resources,
2601  // so URLs inside the SVG silently fail without this), and pass the result
2602  // to modern-screenshot as font.cssText.
2603  const FONT_EXT_RE = /\.(woff2?|ttf|otf|eot)(\?.*)?$/i;
2604  const FONT_MIME = {
2605    woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', otf: 'font/otf', eot: 'application/vnd.ms-fontobject',
2606  };
2607  function bufferToBase64(buf) {
2608    const bytes = new Uint8Array(buf);
2609    let binary = '';
2610    const CHUNK = 0x8000;
2611    for (let i = 0; i < bytes.length; i += CHUNK) {
2612      binary += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
2613    }
2614    return btoa(binary);
2615  }
2616  async function inlineFontUrls(cssText) {
2617    const urlRe = /url\((['"]?)(https?:\/\/[^'")\s]+)\1\)/g;
2618    const urls = new Set();
2619    let m;
2620    while ((m = urlRe.exec(cssText))) {
2621      if (FONT_EXT_RE.test(m[2])) urls.add(m[2]);
2622    }
2623    const map = new Map();
2624    await Promise.all([...urls].map(async (url) => {
2625      try {
2626        const res = await fetch(url);
2627        if (!res.ok) return;
2628        const buf = await res.arrayBuffer();
2629        const ext = url.toLowerCase().match(FONT_EXT_RE)?.[1] || 'woff2';
2630        const mime = FONT_MIME[ext] || 'application/octet-stream';
2631        map.set(url, 'data:' + mime + ';base64,' + bufferToBase64(buf));
2632      } catch { /* skip; fall through to URL */ }
2633    }));
2634    return cssText.replace(urlRe, (orig, q, url) => {
2635      const data = map.get(url);
2636      return data ? 'url(' + q + data + q + ')' : orig;
2637    });
2638  }
2639  async function collectFontCssText() {
2640    const chunks = [];
2641    const fontFaceRe = /@font-face\s*\{[^}]*\}/g;
2642    for (const sheet of document.styleSheets) {
2643      try {
2644        const rules = sheet.cssRules;
2645        for (const rule of rules) {
2646          if (rule.constructor.name === 'CSSFontFaceRule' || rule.cssText?.startsWith('@font-face')) {
2647            chunks.push(rule.cssText);
2648          }
2649        }
2650      } catch {
2651        if (!sheet.href) continue;
2652        try {
2653          const res = await fetch(sheet.href);
2654          if (!res.ok) continue;
2655          const text = await res.text();
2656          let m2;
2657          while ((m2 = fontFaceRe.exec(text))) chunks.push(m2[0]);
2658        } catch { /* ignore; capture is best-effort */ }
2659      }
2660    }
2661    if (chunks.length === 0) return '';
2662    return inlineFontUrls(chunks.join('\n'));
2663  }
2664
2665  // True if `s` is a computed color string that renders as nothing
2666  // (explicit `transparent`, or `rgba(...)` with alpha 0).
2667  function isTransparentColor(s) {
2668    if (!s) return true;
2669    if (s === 'transparent') return true;
2670    const m = /rgba?\(([^)]+)\)/.exec(s);
2671    if (!m) return false;
2672    const parts = m[1].split(',').map((p) => p.trim());
2673    if (parts.length === 4) return parseFloat(parts[3]) === 0;
2674    return false;
2675  }
2676
2677  // modern-screenshot force-sets `background-color: X !important` on the
2678  // cloned root whenever `backgroundColor` is passed, clobbering the
2679  // element's own background. So we only pass it when the element is
2680  // genuinely transparent (no own color, no own image) — in that case
2681  // we resolve up the DOM to the nearest opaque ancestor so the capture
2682  // sits on the page's real background instead of rendering black.
2683  function resolveCanvasBackground(el) {
2684    const own = getComputedStyle(el);
2685    if (!isTransparentColor(own.backgroundColor)) return null;
2686    if (own.backgroundImage && own.backgroundImage !== 'none') return null;
2687    let node = el.parentElement;
2688    while (node) {
2689      const cs = getComputedStyle(node);
2690      if (!isTransparentColor(cs.backgroundColor)) return cs.backgroundColor;
2691      node = node.parentElement;
2692    }
2693    // The walk already passed through <body> and <html>; if they had been
2694    // opaque we would have returned. Falling through with the previous
2695    // `getComputedStyle(body).backgroundColor || …` chain is a trap: that
2696    // call returns the literal string `"rgba(0, 0, 0, 0)"` for a page that
2697    // never set its own bg, which is truthy and short-circuits the chain to
2698    // transparent-black — modern-screenshot then renders the capture on a
2699    // black canvas and the shader overlay flashes solid black during load.
2700    // The browser canvas defaults to white, so we do too.
2701    return '#ffffff';
2702  }
2703
2704  // Capture the element (with current annotations baked in) and return a PNG
2705  // Blob. Shared between the Go flow (uploads it to the server) and the
2706  // debug toggle (displays it as an overlay for side-by-side comparison).
2707  async function captureElementToBlob(el, snapshot, rect) {
2708    try { if (document.fonts?.ready) await document.fonts.ready; } catch {}
2709    const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
2710    let annotNode = null;
2711    let savedPosition = null;
2712    if (hasAnnotations) {
2713      const pos = getComputedStyle(el).position;
2714      if (pos === 'static') {
2715        savedPosition = el.style.position;
2716        el.style.position = 'relative';
2717      }
2718      annotNode = buildAnnotationsForCapture(rect, snapshot);
2719      el.appendChild(annotNode);
2720    }
2721    try {
2722      const ms = await loadModernScreenshot();
2723      const fontCssText = await collectFontCssText();
2724      const backgroundColor = resolveCanvasBackground(el);
2725      return await ms.domToBlob(el, {
2726        scale: Math.min(window.devicePixelRatio || 1, 2),
2727        font: fontCssText ? { cssText: fontCssText } : undefined,
2728        ...(backgroundColor ? { backgroundColor } : {}),
2729      });
2730    } finally {
2731      if (annotNode) annotNode.remove();
2732      if (savedPosition !== null) el.style.position = savedPosition;
2733    }
2734  }
2735
2736  async function captureAndEmit(el, basePayload, snapshot, rect) {
2737    let screenshotPath;
2738    let blob;
2739    try {
2740      blob = await captureElementToBlob(el, snapshot, rect);
2741    } catch (err) {
2742      console.warn('[impeccable] capture failed, proceeding without screenshot:', err);
2743    }
2744    // Light up the shader overlay the moment capture is ready — no reason to
2745    // wait for the upload to complete before the user sees something alive.
2746    if (blob && state === 'GENERATING') {
2747      showShaderOverlay(el, blob, rect);
2748    }
2749    // Only upload + forward the screenshot when annotations (comments/strokes)
2750    // are present. Without annotations the image is pure visual anchoring —
2751    // it biases the model toward the current rendering and works against the
2752    // three-distinct-directions brief.
2753    const hasAnnotations = snapshot && (snapshot.comments.length > 0 || snapshot.strokes.length > 0);
2754    if (blob && hasAnnotations) {
2755      try {
2756        const uploadRes = await fetch(
2757          'http://localhost:' + PORT + '/annotation?token=' + encodeURIComponent(TOKEN) +
2758          '&eventId=' + encodeURIComponent(basePayload.id),
2759          { method: 'POST', headers: { 'Content-Type': 'image/png' }, body: blob },
2760        );
2761        if (uploadRes.ok) {
2762          const { path: p } = await uploadRes.json();
2763          screenshotPath = p;
2764        } else {
2765          console.warn('[impeccable] annotation upload failed:', uploadRes.status);
2766        }
2767      } catch (err) {
2768        console.warn('[impeccable] annotation upload failed:', err);
2769      }
2770    }
2771    sendEvent(screenshotPath ? { ...basePayload, screenshotPath } : basePayload);
2772  }
2773
2774  // ---------------------------------------------------------------------------
2775  // Shader overlay — renders the captured screenshot as a WebGL texture and
2776  // runs an editorial "ink-wash" fragment shader over it during generation.
2777  // A single rolling band sweeps top-to-bottom, desaturating + tinting magenta
2778  // and leaving a soft trail. Makes the wait feel like a letterpress scan
2779  // instead of a dead spinner.
2780  // ---------------------------------------------------------------------------
2781
2782  const SHADER_VS = `attribute vec2 a_position;
2783attribute vec2 a_uv;
2784varying vec2 v_uv;
2785void main() {
2786  v_uv = a_uv;
2787  gl_Position = vec4(a_position, 0.0, 1.0);
2788}`;
2789
2790  const SHADER_FS = `precision highp float;
2791uniform sampler2D u_texture;
2792uniform float u_time;
2793uniform vec2 u_resolution;
2794uniform vec3 u_accent;
2795varying vec2 v_uv;
2796
2797// Asymmetric roller band. Product of two one-sided smoothsteps — peaks at
2798// d=0 with a short sharp leading ramp and a longer soft trailing tail. Clean
2799// outside the [-leadW, trailW] range (no rogue "trail=1 everywhere below"
2800// failure that reversed-edge smoothstep would give).
2801float bandAt(float d, float leadW, float trailW) {
2802  float above = smoothstep(-leadW, 0.0, d);
2803  float below = 1.0 - smoothstep(0.0, trailW, d);
2804  return above * below;
2805}
2806
2807void main() {
2808  vec2 uv = v_uv;
2809  // Roller sweeps top-to-bottom with small overshoot so each cycle enters
2810  // and exits the element cleanly.
2811  float phase = fract(u_time / 3.4);
2812  float y = phase * 1.25 - 0.12;
2813  float band = bandAt(uv.y - y, 0.05, 0.32);
2814
2815  // Halftone cell grid (fixed ~10 px pitch).
2816  float cellPx = 10.0;
2817  vec2 gridUv = uv * u_resolution / cellPx;
2818  vec2 cellId = floor(gridUv);
2819  vec2 cellUv = fract(gridUv) - 0.5;
2820  vec2 sampleCenter = (cellId + 0.5) * cellPx / u_resolution;
2821  vec3 cellImg = texture2D(u_texture, sampleCenter).rgb;
2822  float luma = dot(cellImg, vec3(0.299, 0.587, 0.114));
2823  // Darker cells → bigger magenta dots (classic risograph halftone curve).
2824  float radius = sqrt(clamp(1.0 - luma, 0.0, 1.0)) * 0.56;
2825  float dotMask = smoothstep(radius + 0.06, radius, length(cellUv));
2826  vec3 paper = vec3(0.975, 0.965, 0.955);
2827  vec3 dotLayer = mix(paper, u_accent, dotMask);
2828
2829  // Blend the halftone layer in where the roller is passing; leave the
2830  // element pristine elsewhere.
2831  vec3 base = texture2D(u_texture, uv).rgb;
2832  gl_FragColor = vec4(mix(base, dotLayer, band), 1.0);
2833}`;
2834
2835  // Editorial Magenta converted to approximate sRGB 0-1 (matches oklch(60% 0.25 350))
2836  const SHADER_ACCENT = [0.82, 0.16, 0.47];
2837  let shaderState = null; // { canvas, gl, program, texture, rafId, startTime }
2838
2839  function compileShader(gl, type, source) {
2840    const sh = gl.createShader(type);
2841    gl.shaderSource(sh, source);
2842    gl.compileShader(sh);
2843    if (!gl.getShaderParameter(sh, gl.COMPILE_STATUS)) {
2844      const info = gl.getShaderInfoLog(sh);
2845      gl.deleteShader(sh);
2846      throw new Error('shader compile failed: ' + info);
2847    }
2848    return sh;
2849  }
2850
2851  function positionShaderOverlay() {
2852    if (!shaderState || !selectedElement) return;
2853    const r = selectedElement.getBoundingClientRect();
2854    Object.assign(shaderState.canvas.style, {
2855      top: r.top + 'px', left: r.left + 'px',
2856      width: r.width + 'px', height: r.height + 'px',
2857    });
2858  }
2859
2860  function hideShaderOverlay() {
2861    if (!shaderState) return;
2862    if (shaderState.rafId) cancelAnimationFrame(shaderState.rafId);
2863    if (shaderState.canvas) shaderState.canvas.remove();
2864    const lose = shaderState.gl?.getExtension?.('WEBGL_lose_context');
2865    try { lose?.loseContext(); } catch {}
2866    shaderState = null;
2867  }
2868
2869  async function showShaderOverlay(el, blob, rect) {
2870    hideShaderOverlay();
2871    if (!blob || !el) return;
2872    const canvas = document.createElement('canvas');
2873    canvas.id = PREFIX + '-shader';
2874    const dpr = Math.min(window.devicePixelRatio || 1, 2);
2875    canvas.width = Math.max(1, Math.floor(rect.width * dpr));
2876    canvas.height = Math.max(1, Math.floor(rect.height * dpr));
2877    Object.assign(canvas.style, {
2878      position: 'fixed',
2879      top: rect.top + 'px', left: rect.left + 'px',
2880      width: rect.width + 'px', height: rect.height + 'px',
2881      pointerEvents: 'none',
2882      zIndex: Z.bar - 1,
2883    });
2884    document.body.appendChild(canvas);
2885
2886    const gl = canvas.getContext('webgl', { premultipliedAlpha: false, preserveDrawingBuffer: false })
2887            || canvas.getContext('experimental-webgl');
2888    if (!gl) {
2889      // WebGL unavailable — fall back to a plain <img> overlay so the user
2890      // still sees something meaningful during generation.
2891      canvas.remove();
2892      const img = document.createElement('img');
2893      img.src = URL.createObjectURL(blob);
2894      img.id = PREFIX + '-shader';
2895      // Copy positioning via cssText. Object.assign across CSSStyleDeclaration
2896      // throws in modern Chromium because the source's indexed properties
2897      // (style[0], [1], ...) are read-only and the engine forbids writing
2898      // them on the destination.
2899      img.style.cssText = canvas.style.cssText;
2900      img.style.outline = '2px dashed ' + C.brand;
2901      img.style.outlineOffset = '-2px';
2902      document.body.appendChild(img);
2903      shaderState = { canvas: img, gl: null, program: null, texture: null, rafId: 0, startTime: 0 };
2904      return;
2905    }
2906
2907    let program, texture;
2908    try {
2909      const vs = compileShader(gl, gl.VERTEX_SHADER, SHADER_VS);
2910      const fs = compileShader(gl, gl.FRAGMENT_SHADER, SHADER_FS);
2911      program = gl.createProgram();
2912      gl.attachShader(program, vs);
2913      gl.attachShader(program, fs);
2914      gl.linkProgram(program);
2915      if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
2916        throw new Error('program link failed: ' + gl.getProgramInfoLog(program));
2917      }
2918      // Full-screen quad
2919      const buf = gl.createBuffer();
2920      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
2921      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
2922        -1, -1, 0, 1,
2923         1, -1, 1, 1,
2924        -1,  1, 0, 0,
2925        -1,  1, 0, 0,
2926         1, -1, 1, 1,
2927         1,  1, 1, 0,
2928      ]), gl.STATIC_DRAW);
2929      const posLoc = gl.getAttribLocation(program, 'a_position');
2930      const uvLoc = gl.getAttribLocation(program, 'a_uv');
2931      gl.enableVertexAttribArray(posLoc);
2932      gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0);
2933      gl.enableVertexAttribArray(uvLoc);
2934      gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8);
2935    } catch (err) {
2936      console.warn('[impeccable] shader setup failed:', err);
2937      canvas.remove();
2938      return;
2939    }
2940
2941    // Upload the screenshot as a texture
2942    let bitmap;
2943    try {
2944      bitmap = await createImageBitmap(blob);
2945    } catch {
2946      // Safari fallback: go via a regular Image
2947      const imgUrl = URL.createObjectURL(blob);
2948      const img = new Image();
2949      img.src = imgUrl;
2950      await new Promise((r, rej) => { img.onload = r; img.onerror = rej; });
2951      bitmap = img;
2952      URL.revokeObjectURL(imgUrl);
2953    }
2954    texture = gl.createTexture();
2955    gl.bindTexture(gl.TEXTURE_2D, texture);
2956    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
2957    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
2958    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
2959    gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
2960    gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
2961    gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bitmap);
2962    if (bitmap.close) bitmap.close();
2963
2964    const uTime = gl.getUniformLocation(program, 'u_time');
2965    const uRes = gl.getUniformLocation(program, 'u_resolution');
2966    const uAccent = gl.getUniformLocation(program, 'u_accent');
2967    const uTex = gl.getUniformLocation(program, 'u_texture');
2968    const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
2969
2970    shaderState = { canvas, gl, program, texture, rafId: 0, startTime: performance.now(), reduced };
2971    function frame() {
2972      if (!shaderState) return;
2973      const elapsed = (performance.now() - shaderState.startTime) / 1000;
2974      const t = shaderState.reduced ? 0.0 : elapsed;
2975      gl.viewport(0, 0, canvas.width, canvas.height);
2976      gl.useProgram(program);
2977      gl.activeTexture(gl.TEXTURE0);
2978      gl.bindTexture(gl.TEXTURE_2D, texture);
2979      gl.uniform1i(uTex, 0);
2980      gl.uniform1f(uTime, t);
2981      gl.uniform2f(uRes, canvas.width, canvas.height);
2982      gl.uniform3f(uAccent, SHADER_ACCENT[0], SHADER_ACCENT[1], SHADER_ACCENT[2]);
2983      gl.drawArrays(gl.TRIANGLES, 0, 6);
2984      shaderState.rafId = requestAnimationFrame(frame);
2985    }
2986    frame();
2987  }
2988
2989  function handleAccept() {
2990    if (!currentSessionId || arrivedVariants === 0) return;
2991    const domVisibleVariant = readVisibleVariantFromDOM(currentSessionId);
2992    if (domVisibleVariant > 0) visibleVariant = domVisibleVariant;
2993    const acceptPayload = { type: 'accept', id: currentSessionId, variantId: String(visibleVariant) };
2994    if (Object.keys(paramsCurrentValues).length > 0) {
2995      acceptPayload.paramValues = { ...paramsCurrentValues };
2996    }
2997    // The accepted variant is already the only visible child of the wrapper
2998    // (all other variants are display:none). HMR from the source rewrite will
2999    // replace the wrapper imminently. Don't eagerly replaceChild here — React
3000    // reconciliation races with our mutation and throws NotFoundError in Next
3001    // 16 / Turbopack. Schedule a fallback that runs the manual swap only if
3002    // HMR hasn't cleaned up by then (keeps static-server flows working).
3003    const acceptedSessionId = currentSessionId;
3004    const acceptedVariant = visibleVariant;
3005
3006    state = 'SAVING';
3007    updateBarContent('saving');
3008
3009    sendEvent(acceptPayload, { throwOnError: true })
3010      .then(() => {
3011        markSessionHandled();
3012        confirmAcceptAfterReceipt();
3013      })
3014      .catch(() => {
3015        state = 'CYCLING';
3016        updateBarContent('cycling');
3017        showToast('Could not confirm accept with the live server. Session kept for recovery; try Accept again.', 5000);
3018      });
3019
3020    function confirmAcceptAfterReceipt() {
3021      state = 'CONFIRMED';
3022      updateBarContent('confirmed');
3023      scheduleAcceptCleanup();
3024    }
3025
3026    function scheduleAcceptCleanup() {
3027      setTimeout(function() {
3028      hideBar();
3029      hideHighlight();
3030      stopScrollTracking();
3031      if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
3032      stopScrollLock();
3033      clearScrollY();
3034      clearSession();
3035      selectedElement = null;
3036      currentSessionId = null;
3037      selectedAction = 'impeccable';
3038      state = 'PICKING';
3039    }, 1800);
3040
3041    // Static-server / no-HMR fallback: if the wrapper is still around 2s after
3042    // the cleanup above, swap it out manually. By now React has either moved
3043    // on or the app isn't React at all. Preserve the `data-impeccable-variant="N"`
3044    // div (with display:contents) so @scope rules anchored to the variant
3045    // attribute keep matching until reload replaces it with the carbonize block.
3046    setTimeout(function() {
3047      const wrapper = document.querySelector('[data-impeccable-variants="' + acceptedSessionId + '"]');
3048      if (!wrapper) return;
3049      const accepted = wrapper.querySelector('[data-impeccable-variant="' + acceptedVariant + '"]');
3050      if (accepted && accepted.firstElementChild) {
3051        const parent = wrapper.parentElement;
3052        if (!parent) return;
3053        accepted.style.display = 'contents';
3054        parent.replaceChild(accepted, wrapper);
3055      }
3056      }, 2000);
3057    }
3058  }
3059
3060  function handleDiscard() {
3061    if (!currentSessionId) return;
3062    sendEvent({ type: 'discard', id: currentSessionId }, { throwOnError: true })
3063      .then(() => {
3064        markSessionHandled();
3065        cleanup();
3066      })
3067      .catch(() => showToast('Could not confirm discard with the live server. Session kept for recovery.', 5000));
3068  }
3069
3070  // ---------------------------------------------------------------------------
3071  // Session persistence via live-browser-session.js
3072  // ---------------------------------------------------------------------------
3073  // Survives page reloads, browser close/reopen, HMR, and accidental refreshes.
3074
3075  function saveSession() {
3076    if (!currentSessionId) return;
3077    // NOTE: scrollY is stored under a separate key (writeScrollY). Storing
3078    // it here would overwrite the Go-time value every time state changes.
3079    sessionState.saveSession({
3080      id: currentSessionId,
3081      state,
3082      action: selectedAction,
3083      count: selectedCount,
3084      expected: expectedVariants,
3085      arrived: arrivedVariants,
3086      visible: visibleVariant,
3087    });
3088  }
3089
3090  function loadSession() {
3091    return sessionState.loadSession();
3092  }
3093
3094  function clearSession() {
3095    sessionState.clearSession();
3096  }
3097
3098  /** Mark session as handled (accepted/discarded). The agent will clean up
3099   *  the source, but until it does the wrapper is still in the HTML. This
3100   *  prevents resumeSession from picking it up again after reload. */
3101  function markSessionHandled() {
3102    if (!currentSessionId) return;
3103    sessionState.markHandled(currentSessionId);
3104  }
3105
3106  function isSessionHandled(id) {
3107    return sessionState.isHandled(id);
3108  }
3109
3110  function clearHandled() {
3111    sessionState.clearHandled();
3112  }
3113
3114  function cleanup() {
3115    // Hide the wrapper immediately so variants disappear. DON'T structurally
3116    // mutate the DOM yet — HMR from the agent's source rewrite is on its way,
3117    // and a manual replaceChild under React causes NotFoundError when the
3118    // reconciler later tries to remove a wrapper we already removed.
3119    // Schedule a 2s fallback that does the manual swap only if HMR hasn't
3120    // replaced the wrapper by then (keeps static-server / no-HMR flows alive).
3121    const cleanupSessionId = currentSessionId;
3122    if (cleanupSessionId) {
3123      const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
3124      if (wrapper) wrapper.style.display = 'none';
3125    }
3126    setTimeout(function() {
3127      if (!cleanupSessionId) return;
3128      const wrapper = document.querySelector('[data-impeccable-variants="' + cleanupSessionId + '"]');
3129      if (!wrapper) return;
3130      const orig = wrapper.querySelector('[data-impeccable-variant="original"]');
3131      if (orig) {
3132        const content = orig.firstElementChild;
3133        if (content) {
3134          wrapper.parentElement.replaceChild(content, wrapper);
3135          return;
3136        }
3137      }
3138      wrapper.remove();
3139    }, 2000);
3140    hideBar();
3141    hideHighlight();
3142    stopScrollTracking();
3143    if (variantObserver) { variantObserver.disconnect(); variantObserver = null; }
3144    stopScrollLock();
3145    clearScrollY();
3146    clearSession();
3147    selectedElement = null;
3148    currentSessionId = null;
3149    selectedAction = 'impeccable';
3150    state = 'PICKING';
3151  }
3152
3153  // ---------------------------------------------------------------------------
3154  // Toast
3155  // ---------------------------------------------------------------------------
3156
3157  function showToast(message, duration) {
3158    if (toastEl) toastEl.remove();
3159    // Stack the toast above the global bar (which sits at bottom:14px) so
3160    // the two never overlap. Read the bar's actual rect — its height varies
3161    // with hover-expanded labels — and fall back to a sensible default
3162    // when the bar isn't mounted yet.
3163    const barRect = globalBarEl?.getBoundingClientRect();
3164    const barTopFromBottom = barRect && barRect.height > 0
3165      ? Math.max(16, window.innerHeight - barRect.top + 12)
3166      : 16;
3167    toastEl = el('div', {
3168      position: 'fixed', bottom: barTopFromBottom + 'px', left: '50%',
3169      transform: 'translateX(-50%) translateY(8px)',
3170      background: C.ink, color: C.white,
3171      fontFamily: FONT, fontSize: '12px',
3172      padding: '8px 16px', borderRadius: '8px',
3173      zIndex: Z.toast, opacity: '0',
3174      transition: 'opacity 0.25s ' + EASE + ', transform 0.25s ' + EASE,
3175      pointerEvents: 'none', maxWidth: '420px', textAlign: 'center',
3176    });
3177    toastEl.id = PREFIX + '-toast';
3178    toastEl.textContent = message;
3179    document.body.appendChild(toastEl);
3180    requestAnimationFrame(() => {
3181      toastEl.style.opacity = '1';
3182      toastEl.style.transform = 'translateX(-50%) translateY(0)';
3183    });
3184    setTimeout(() => {
3185      if (toastEl) {
3186        toastEl.style.opacity = '0';
3187        toastEl.style.transform = 'translateX(-50%) translateY(8px)';
3188        setTimeout(() => { if (toastEl) { toastEl.remove(); toastEl = null; } }, 250);
3189      }
3190    }, duration);
3191  }
3192
3193  // ---------------------------------------------------------------------------
3194  // Init
3195  // ---------------------------------------------------------------------------
3196
3197  // Resume an active variant session after HMR/page reload.
3198  // If a [data-impeccable-variants] wrapper exists in the DOM, the agent wrote
3199  // variants before HMR fired. Pick up where we left off.
3200  function resumeSession() {
3201    const wrapper = document.querySelector('[data-impeccable-variants]');
3202    if (!wrapper) { clearSession(); clearHandled(); return false; }
3203
3204    const sessionId = wrapper.dataset.impeccableVariants;
3205
3206    // Don't resume if this session was already accepted/discarded
3207    if (isSessionHandled(sessionId)) return false;
3208
3209    currentSessionId = sessionId;
3210    expectedVariants = parseInt(wrapper.dataset.impeccableVariantCount || '0');
3211    const variants = wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])');
3212    arrivedVariants = variants.length;
3213
3214    // Restore state from localStorage if available
3215    const saved = loadSession();
3216    if (saved && saved.id === sessionId) {
3217      visibleVariant = (saved.visible > 0 && saved.visible <= arrivedVariants) ? saved.visible : (arrivedVariants > 0 ? 1 : 0);
3218      if (saved.action) selectedAction = saved.action;
3219      if (saved.count) selectedCount = saved.count;
3220    } else {
3221      visibleVariant = arrivedVariants > 0 ? 1 : 0;
3222    }
3223
3224    // Find the visible variant's content element for highlight positioning.
3225    // Try the visible variant first, fall back to the original's content.
3226    const visEl = visibleVariant > 0 ? pickVariantContent(wrapper, visibleVariant) : null;
3227    const origEl = pickVariantContent(wrapper, 'original');
3228    selectedElement = visEl || origEl || wrapper.parentElement;
3229
3230    // Set display state BEFORE starting observer (avoid triggering it)
3231    if (visibleVariant > 0) showVariantInDOM(currentSessionId, visibleVariant);
3232
3233    state = arrivedVariants >= expectedVariants ? 'CYCLING' : 'GENERATING';
3234    showBar(state === 'CYCLING' ? 'cycling' : 'generating');
3235    startScrollTracking();
3236    // Build the params panel for the restored visible variant. Previously
3237    // this was missed on page-reload resume: showVariantInDOM above fires
3238    // refreshParamsPanel, but state was still IDLE at that moment so it
3239    // hid. Now that state is CYCLING, re-fire.
3240    if (state === 'CYCLING') refreshParamsPanel();
3241    saveSession();
3242    queueCheckpoint('browser_resumed');
3243
3244    // Start observing for more variants AFTER initial setup
3245    if (variantObserver) variantObserver.disconnect();
3246    variantObserver = startVariantObserver(currentSessionId);
3247
3248    // Hold the target at its saved viewport top through any subsequent
3249    // HMR patches, variant inserts, or cycle swaps.
3250    startScrollLock(currentSessionId, readScrollY());
3251
3252    // If we reloaded mid-generation (Bun's HTML HMR destroys the shader
3253    // canvas), re-capture the original's content and restart the shader so
3254    // the wait doesn't go dead.
3255    if (state === 'GENERATING' && origEl) {
3256      (async () => {
3257        try {
3258          const rect = origEl.getBoundingClientRect();
3259          if (rect.width === 0 || rect.height === 0) return;
3260          const blob = await captureElementToBlob(origEl, null, rect);
3261          if (blob && state === 'GENERATING') {
3262            showShaderOverlay(origEl, blob, rect);
3263          }
3264        } catch (err) {
3265          console.warn('[impeccable] shader resume failed:', err);
3266        }
3267      })();
3268    }
3269    return true;
3270  }
3271
3272  // ---------------------------------------------------------------------------
3273  // Global bar (always visible at bottom)
3274  // ---------------------------------------------------------------------------
3275
3276  let globalBarEl = null;
3277  let detectActive = false;
3278  let pickActive = true;
3279  let detectCount = 0;
3280  let detectScriptLoaded = false;
3281
3282  // Theme-aware color palette for the global bar. We detect the page's
3283  // ambient background and invert — dark bar on light pages, light bar on
3284  // dark pages. This keeps the bar from fighting with the host design.
3285  function detectPageTheme() {
3286    try {
3287      // Dev override: set localStorage 'impeccable-dev-theme' to 'light' or
3288      // 'dark' to preview the opposite palette without actually changing the
3289      // page bg. Used for screenshots and theme QA.
3290      const override = localStorage.getItem('impeccable-dev-theme');
3291      if (override === 'light' || override === 'dark') return override;
3292
3293      // Walk body → html, taking the first opaque background. The browser's
3294      // default body / html background is `rgba(0, 0, 0, 0)`, which a naive
3295      // regex would read as black and mislabel a perfectly white page as
3296      // dark. Honoring alpha avoids that — and falling through to <html>
3297      // catches the common pattern of a bg only on <html> (or only on body).
3298      function readOpaque(el) {
3299        if (!el) return null;
3300        const bg = getComputedStyle(el).backgroundColor;
3301        const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/);
3302        if (!m) return null;
3303        const alpha = m[4] == null ? 1 : parseFloat(m[4]);
3304        if (alpha < 0.5) return null; // transparent / nearly transparent → skip
3305        return [+m[1], +m[2], +m[3]];
3306      }
3307
3308      const rgb = readOpaque(document.body) || readOpaque(document.documentElement);
3309      // Both transparent → fall back to the browser's effective canvas color.
3310      // White is the universal default; only one in a thousand sites swaps it
3311      // via `color-scheme: dark` on <html>, and `prefers-color-scheme` lets
3312      // us catch that case.
3313      if (!rgb) {
3314        return matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
3315      }
3316      const [r, g, b] = rgb;
3317      // Perceptual luminance (Rec. 709)
3318      const L = (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
3319      return L > 0.55 ? 'light' : 'dark';
3320    } catch { return 'light'; }
3321  }
3322
3323  function barPaletteForTheme(theme) {
3324    if (theme === 'dark') {
3325      // Light bar on dark page
3326      return {
3327        surface: 'oklch(98% 0 0 / 0.92)',
3328        surfaceDeep: 'oklch(92% 0.005 60 / 0.96)', // slightly deeper, faint warm
3329        hairline: 'oklch(70% 0 0 / 0.35)',
3330        text: 'oklch(15% 0 0)',
3331        textDim: 'oklch(45% 0 0)',
3332        accent: 'oklch(60% 0.25 350)',
3333        accentSoft: 'oklch(60% 0.25 350 / 0.18)',
3334        mark: 'oklch(98% 0 0)',      // logo mark fill
3335        markText: 'oklch(15% 0 0)',  // logo "/" color
3336        exitHover: 'oklch(85% 0 0 / 0.5)',
3337      };
3338    }
3339    // Dark bar on light page. Bar is a warm charcoal, logo slab is much
3340    // deeper so the rounded-right shape reads as a clear sculpted mark.
3341    return {
3342      surface: 'oklch(26% 0 0 / 0.94)',
3343      surfaceDeep: 'oklch(18% 0 0 / 0.96)', // darker sand for Tune popover
3344      hairline: 'oklch(42% 0 0 / 0.5)',
3345      text: 'oklch(96% 0 0)',
3346      textDim: 'oklch(72% 0 0)',
3347      accent: 'oklch(72% 0.22 350)',
3348      accentSoft: 'oklch(72% 0.22 350 / 0.22)',
3349      mark: 'oklch(8% 0 0)',
3350      markText: 'oklch(96% 0 0)',
3351      exitHover: 'oklch(36% 0 0 / 0.6)',
3352    };
3353  }
3354
3355  // Impeccable logo mark — matches the site-header SVG (rounded square + "/").
3356  function brandMarkSvg(fill, ink, size = 18) {
3357    return `<svg width="${size}" height="${size}" viewBox="0 0 32 32" aria-hidden="true">
3358      <rect width="32" height="32" rx="7" fill="${fill}"/>
3359      <text x="16" y="24" font-family="system-ui, -apple-system, sans-serif" font-size="22" font-weight="500" fill="${ink}" text-anchor="middle">/</text>
3360    </svg>`;
3361  }
3362
3363  function initGlobalBar() {
3364    const theme = detectPageTheme();
3365    const P = barPaletteForTheme(theme);
3366
3367    // Custom focus-visible for bar buttons. Browser default is a heavy
3368    // blue ring that looks jarring on the dark capsule. Replace with a
3369    // soft accent-tinted inner ring that respects the bar's palette.
3370    if (!document.getElementById(PREFIX + '-bar-focus-style')) {
3371      const s = document.createElement('style');
3372      s.id = PREFIX + '-bar-focus-style';
3373      s.textContent =
3374        '#' + PREFIX + '-global-bar button:focus { outline: none; }' +
3375        '#' + PREFIX + '-global-bar button:focus-visible {' +
3376        '  outline: none;' +
3377        '  box-shadow: 0 0 0 2px ' + P.accentSoft + ', 0 0 0 3px ' + P.accent + ';' +
3378        '}';
3379      document.head.appendChild(s);
3380    }
3381
3382    globalBarEl = el('div', {
3383      position: 'fixed', bottom: '14px', left: '50%',
3384      transform: 'translateX(-50%) translateY(20px)',
3385      zIndex: Z.bar + 5,
3386      display: 'flex', alignItems: 'stretch',
3387      gap: '2px',
3388      background: P.surface,
3389      backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)',
3390      border: '1px solid ' + P.hairline,
3391      borderRadius: '10px',
3392      boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)',
3393      fontFamily: FONT, fontSize: '12px', lineHeight: '1',
3394      opacity: '0',
3395      overflow: 'hidden',          // clip the full-bleed brand mark to the bar radius
3396      transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE,
3397    });
3398    globalBarEl.id = PREFIX + '-global-bar';
3399    globalBarEl.dataset.theme = theme;
3400
3401    // Brand mark — fills bar height on the left. Left side inherits the bar's
3402    // rounded corner via overflow:hidden; right side is a clean hard edge since
3403    // the near-black/charcoal contrast does the shape-defining work.
3404    const brand = el('span', {
3405      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
3406      alignSelf: 'stretch',
3407      padding: '0 12px 0 14px',
3408      background: P.mark,
3409      color: P.markText,
3410      fontFamily: 'system-ui, -apple-system, sans-serif',
3411      fontWeight: '500',
3412      fontSize: '18px', lineHeight: '1',
3413    });
3414    brand.textContent = '/';
3415    brand.title = 'Impeccable';
3416    globalBarEl.appendChild(brand);
3417
3418    // Inner wrapper: holds the toggles with normal bar padding.
3419    const inner = el('div', {
3420      display: 'flex', alignItems: 'center',
3421      padding: '4px 5px', gap: '2px',
3422    });
3423    inner.id = PREFIX + '-global-bar-inner';
3424    globalBarEl.appendChild(inner);
3425
3426    // --- button factory: icon-only at rest, label slides in on hover/active ---
3427    function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) {
3428      const b = el('button', {
3429        position: 'relative',
3430        display: 'inline-flex', alignItems: 'center',
3431        padding: '6px 8px', borderRadius: '7px',
3432        border: 'none', background: 'transparent',
3433        color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500',
3434        cursor: 'pointer',
3435        transition: 'background 0.15s ease, color 0.15s ease',
3436        whiteSpace: 'nowrap', overflow: 'hidden',
3437      });
3438      b.id = id;
3439      b.title = ariaLabel || label || '';
3440      b.setAttribute('aria-label', ariaLabel || label || '');
3441      b.innerHTML = svg + (label
3442        ? `<span class="icon-btn-label" style="display:inline-block;max-width:0;opacity:0;margin-left:0;overflow:hidden;font-family:${labelFont || FONT};transition:max-width 0.25s ${EASE}, opacity 0.2s ease, margin-left 0.25s ${EASE};">${label}</span>`
3443        : '');
3444      const labelEl = b.querySelector('.icon-btn-label');
3445      const expand = () => {
3446        if (!labelEl) return;
3447        labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px';
3448      };
3449      const collapse = () => {
3450        if (!labelEl || b.dataset.active === 'true') return;
3451        labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0';
3452      };
3453      // Per-button hover only changes color (no layout). The label expand/
3454      // collapse is driven by the bar-level mouseenter/mouseleave so moving
3455      // the mouse between adjacent buttons doesn't trigger per-button width
3456      // thrashing — the whole bar grows once and shrinks once.
3457      b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; });
3458      b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; });
3459      b.addEventListener('click', onClick);
3460      b._expandLabel = expand;
3461      b._collapseLabel = collapse;
3462      return b;
3463    }
3464
3465    // Pick toggle — starts active (primary intent when entering live mode).
3466    const pickBtn = makeIconBtn({
3467      id: PREFIX + '-pick-toggle',
3468      svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><circle cx="12" cy="12" r="10"/><line x1="22" y1="12" x2="18" y2="12"/><line x1="6" y1="12" x2="2" y2="12"/><line x1="12" y1="6" x2="12" y2="2"/><line x1="12" y1="22" x2="12" y2="18"/></svg>',
3469      label: 'Pick',
3470      ariaLabel: 'Pick element',
3471      onClick: () => togglePick(),
3472    });
3473    pickBtn.style.background = P.accentSoft;
3474    pickBtn.style.color = P.accent;
3475    pickBtn.dataset.active = 'true';
3476    pickBtn._expandLabel();
3477    inner.appendChild(pickBtn);
3478
3479    // Detect toggle
3480    const detectBtn = makeIconBtn({
3481      id: PREFIX + '-detect-toggle',
3482      svg: '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>',
3483      label: 'Detect',
3484      ariaLabel: 'Detect anti-patterns',
3485      onClick: () => toggleDetect(),
3486    });
3487    const detectBadge = el('span', {
3488      fontSize: '10px', fontWeight: '600',
3489      padding: '0px 5px', borderRadius: '7px', lineHeight: '16px',
3490      background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)',
3491      display: 'none', fontFamily: MONO, marginLeft: '4px',
3492    });
3493    detectBadge.id = PREFIX + '-detect-badge';
3494    detectBtn.appendChild(detectBadge);
3495    inner.appendChild(detectBtn);
3496
3497    // DESIGN.md panel toggle — quartet of color squares as the mark.
3498    const designBtn = makeIconBtn({
3499      id: PREFIX + '-design-toggle',
3500      svg: `<span style="display:inline-grid;grid-template-columns:1fr 1fr;grid-template-rows:1fr 1fr;width:14px;height:14px;border-radius:3px;overflow:hidden;box-shadow:inset 0 0 0 1px ${P.hairline};flex-shrink:0">
3501        <span style="background:oklch(60% 0.25 350)"></span>
3502        <span style="background:oklch(60% 0.15 45)"></span>
3503        <span style="background:oklch(55% 0.12 250)"></span>
3504        <span style="background:oklch(30% 0 0)"></span>
3505      </span>`,
3506      label: 'DESIGN.md',
3507      ariaLabel: 'Toggle DESIGN.md panel',
3508      labelFont: MONO,
3509      onClick: () => toggleDesignPanel(),
3510    });
3511    inner.appendChild(designBtn);
3512
3513    // Thin divider before the exit button
3514    const divider = el('span', {
3515      width: '1px', height: '18px',
3516      background: P.hairline,
3517      margin: '0 4px 0 2px',
3518    });
3519    inner.appendChild(divider);
3520
3521    // Exit × on the right — intentionally subtle (textDim at rest, text on
3522    // hover) so it sits behind the active toggles in visual hierarchy.
3523    //
3524    // Explicit padding + box-sizing here is load-bearing: a host page like
3525    // `button { padding: 0.5rem 1rem; }` (very common in resets) would
3526    // otherwise inflate this 24x24 button into 56x40 and push the SVG out
3527    // of the visible bar — the X stays invisible even though the styles in
3528    // DevTools look fine. Every other chrome button sets padding inline;
3529    // this one needed it too.
3530    const exitBtn = el('button', {
3531      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
3532      padding: '0', boxSizing: 'border-box',
3533      width: '24px', height: '24px', borderRadius: '6px',
3534      border: 'none', background: 'transparent',
3535      color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0',
3536      cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease',
3537    });
3538    exitBtn.innerHTML = '<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"><line x1="3" y1="3" x2="11" y2="11"/><line x1="11" y1="3" x2="3" y2="11"/></svg>';
3539    exitBtn.title = 'Exit live mode';
3540    exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; });
3541    exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; });
3542    exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); });
3543    inner.appendChild(exitBtn);
3544
3545    // Bar-level hover: expand every toggle's label at once; collapse on leave.
3546    // Buttons with dataset.active="true" ignore collapse (their label stays).
3547    const toggles = [pickBtn, detectBtn, designBtn];
3548    globalBarEl.addEventListener('mouseenter', () => {
3549      toggles.forEach((t) => t._expandLabel && t._expandLabel());
3550    });
3551    globalBarEl.addEventListener('mouseleave', () => {
3552      toggles.forEach((t) => t._collapseLabel && t._collapseLabel());
3553    });
3554
3555    document.body.appendChild(globalBarEl);
3556    defangOutsideHandlers(globalBarEl);
3557
3558    requestAnimationFrame(() => {
3559      globalBarEl.style.opacity = '1';
3560      globalBarEl.style.transform = 'translateX(-50%) translateY(0)';
3561    });
3562
3563    // Listen for detection results AND ready signal
3564    window.addEventListener('message', onDetectMessage);
3565  }
3566
3567  function updateGlobalBarState() {
3568    const detectToggle = document.getElementById(PREFIX + '-detect-toggle');
3569    const detectBadge = document.getElementById(PREFIX + '-detect-badge');
3570    const pickToggle = document.getElementById(PREFIX + '-pick-toggle');
3571    const designToggle = document.getElementById(PREFIX + '-design-toggle');
3572    const theme = globalBarEl?.dataset.theme || 'light';
3573    const P = barPaletteForTheme(theme);
3574
3575    // Sync one toggle's active state, colors, and slide-label visibility.
3576    function sync(btn, active) {
3577      if (!btn) return;
3578      btn.style.background = active ? P.accentSoft : 'transparent';
3579      btn.style.color = active ? P.accent : P.textDim;
3580      btn.dataset.active = active ? 'true' : 'false';
3581      if (active && btn._expandLabel) btn._expandLabel();
3582      else if (!active && btn._collapseLabel) btn._collapseLabel();
3583    }
3584    sync(pickToggle, pickActive);
3585    sync(detectToggle, detectActive);
3586    sync(designToggle, designState.open);
3587
3588    // If the bar is currently under the cursor, keep all labels expanded —
3589    // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md)
3590    // would collapse its label while the user's mouse is still on the bar.
3591    if (globalBarEl && globalBarEl.matches(':hover')) {
3592      [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.());
3593    }
3594
3595    if (detectBadge) {
3596      detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none';
3597      detectBadge.textContent = detectCount;
3598    }
3599
3600    // When pick is active, make detect overlays click-through so the picker works
3601    document.querySelectorAll('.impeccable-overlay').forEach(o => {
3602      o.style.pointerEvents = pickActive ? 'none' : '';
3603    });
3604  }
3605
3606  let detectReady = false; // true once detect script posts 'impeccable-ready'
3607  let detectPendingScan = false; // scan requested before script was ready
3608
3609  function toggleDetect() {
3610    detectActive = !detectActive;
3611    updateGlobalBarState();
3612
3613    if (detectActive) {
3614      if (!detectScriptLoaded) {
3615        detectPendingScan = true;
3616        loadDetectScript();
3617      } else if (detectReady) {
3618        window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
3619      } else {
3620        detectPendingScan = true;
3621      }
3622    } else {
3623      window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
3624      detectCount = 0;
3625      updateGlobalBarState();
3626    }
3627  }
3628
3629  function togglePick() {
3630    pickActive = !pickActive;
3631    updateGlobalBarState();
3632
3633    if (!pickActive) {
3634      // Disabling pick clears any in-flight selection and UI: highlight,
3635      // contextual bar, selectedElement. Otherwise a stale selection sits
3636      // on screen with no obvious way to dismiss.
3637      hideHighlight();
3638      hideBar();
3639      hideActionPicker();
3640      selectedElement = null;
3641      if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE';
3642    } else {
3643      if (state === 'IDLE') state = 'PICKING';
3644    }
3645  }
3646
3647  function loadDetectScript() {
3648    if (detectScriptLoaded) return;
3649    detectScriptLoaded = true;
3650    const s = document.createElement('script');
3651    s.src = 'http://localhost:' + PORT + '/detect.js';
3652    s.dataset.impeccableExtension = 'true';
3653    document.head.appendChild(s);
3654  }
3655
3656  function onDetectMessage(e) {
3657    if (!e.data || typeof e.data.source !== 'string') return;
3658    // Detection script is loaded and ready
3659    if (e.data.source === 'impeccable-ready') {
3660      detectReady = true;
3661      if (detectPendingScan && detectActive) {
3662        detectPendingScan = false;
3663        window.postMessage({ source: 'impeccable-command', action: 'scan' }, '*');
3664      }
3665    }
3666    // Scan results arrived
3667    if (e.data.source === 'impeccable-results') {
3668      detectCount = e.data.count || 0;
3669      updateGlobalBarState();
3670    }
3671  }
3672
3673  /** Full teardown: remove all UI, disconnect SSE, clean up. */
3674  function teardown() {
3675    cleanup();
3676    hideBar();
3677    if (globalBarEl) {
3678      globalBarEl.style.transform = 'translateY(100%)';
3679      setTimeout(() => { if (globalBarEl) globalBarEl.remove(); globalBarEl = null; }, 300);
3680    }
3681    if (highlightEl) { highlightEl.remove(); highlightEl = null; }
3682    if (tooltipEl) { tooltipEl.remove(); tooltipEl = null; }
3683    if (barEl) { barEl.remove(); barEl = null; }
3684    if (pickerEl) { pickerEl.remove(); pickerEl = null; }
3685    if (paramsPanelEl) { paramsPanelEl.remove(); paramsPanelEl = null; paramsPanelInner = null; paramsPanelBody = null; }
3686    if (evtSource) { evtSource.close(); evtSource = null; }
3687    document.removeEventListener('mousemove', handleMouseMove, true);
3688    document.removeEventListener('click', handleClick, true);
3689    document.removeEventListener('keydown', handleKeyDown, true);
3690    window.removeEventListener('message', onDetectMessage);
3691    // Remove detection overlays
3692    window.postMessage({ source: 'impeccable-command', action: 'remove' }, '*');
3693    state = 'IDLE';
3694    window.__IMPECCABLE_LIVE_INIT__ = false;
3695    console.log('[impeccable] Live mode exited.');
3696  }
3697
3698  // ---------------------------------------------------------------------------
3699  // Design System Panel — visualizes the project's .impeccable/design.json sidecar
3700  // ---------------------------------------------------------------------------
3701
3702  const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
3703  const DESIGN_PANEL_WIDTH = 440;
3704
3705  let designHost = null;
3706  let designShadow = null;
3707  let designState = {
3708    open: false,
3709    tab: 'visual',          // 'visual' | 'raw'
3710    parsed: null,           // parseDesignMd output (frontmatter + body sections)
3711    sidecar: null,          // .impeccable/design.json v2 payload (extensions + components + narrative)
3712    hasMd: false,
3713    hasSidecar: false,
3714    present: null,          // true/false once fetch resolves
3715    raw: null,              // raw DESIGN.md for the raw tab
3716    mdNewerThanJson: false, // stale-hint flag
3717    loading: false,
3718    error: null,
3719    collapsed: {            // narrative-section accordion state
3720      rules: true, dosdonts: true, overview: true,
3721    },
3722  };
3723
3724  function loadDesignPrefs() {
3725    // `open` is intentionally NOT persisted — the panel always starts closed
3726    // so live mode doesn't auto-slide a big panel over the page on startup.
3727    try {
3728      const raw = localStorage.getItem(DESIGN_PREFS_KEY);
3729      if (!raw) return;
3730      const prefs = JSON.parse(raw);
3731      if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;
3732      if (prefs.collapsed && typeof prefs.collapsed === 'object') {
3733        Object.assign(designState.collapsed, prefs.collapsed);
3734      }
3735    } catch { /* ignore */ }
3736  }
3737
3738  function saveDesignPrefs() {
3739    try {
3740      localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({
3741        tab: designState.tab,
3742        collapsed: designState.collapsed,
3743      }));
3744    } catch { /* ignore */ }
3745  }
3746
3747  function initDesignPanel() {
3748    designHost = document.createElement('div');
3749    designHost.id = PREFIX + '-design-host';
3750    Object.assign(designHost.style, {
3751      position: 'fixed', top: '0', left: '0',
3752      width: '0', height: '0',
3753      zIndex: String(Z.bar + 10),
3754      pointerEvents: 'none',
3755    });
3756    designShadow = designHost.attachShadow({ mode: 'open' });
3757
3758    const style = document.createElement('style');
3759    // Theme-match the bar: dark chrome on light pages, light chrome on dark pages.
3760    const theme = detectPageTheme();
3761    style.textContent = designPanelCss(barPaletteForTheme(theme));
3762    designShadow.appendChild(style);
3763
3764    const root = document.createElement('div');
3765    root.className = 'root';
3766    designShadow.appendChild(root);
3767
3768    document.body.appendChild(designHost);
3769    // The host is pointer-events: none; the panel inside the shadow DOM
3770    // manages its own auto/none. Events bubble through the shadow boundary,
3771    // so attaching here silences host-page outside-interaction handlers
3772    // without touching the host's click-through behavior.
3773    defangOutsideHandlers(designHost, { setPointerEvents: false });
3774
3775    loadDesignPrefs();
3776    renderDesignChrome();
3777    if (designState.open) {
3778      fetchDesignSystem();
3779    }
3780  }
3781
3782  // Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
3783  // a viewer of the project's design system, not an Impeccable surface.
3784  const DP = {
3785    canvas:   'oklch(94% 0 0)',            // panel background
3786    tile:     'oklch(98.5% 0 0)',          // card-on-canvas
3787    tileAlt:  'oklch(96% 0 0)',            // subtler tile for inner surfaces
3788    ink:      'oklch(15% 0 0)',
3789    ink2:     'oklch(35% 0 0)',
3790    meta:     'oklch(55% 0 0)',
3791    hairline: 'oklch(88% 0 0)',
3792    hairlineSoft: 'oklch(92% 0 0)',
3793    amber:    'oklch(70% 0.13 65)',         // stale-hint accent
3794    amberBg:  'oklch(95% 0.05 80)',
3795  };
3796
3797  function designPanelCss(BP) {
3798    // BP = bar palette (theme-aware, matches the global bar).
3799    // DP = internal content palette (neutral, so tiles render colors true).
3800    return `
3801      :host, .root { all: initial; }
3802      .root {
3803        font-family: ${FONT};
3804        color: ${DP.ink};
3805        pointer-events: none;
3806      }
3807      .root * { box-sizing: border-box; }
3808      button { font: inherit; color: inherit; }
3809
3810      /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */
3811      .panel {
3812        position: fixed; top: 12px; bottom: 72px; right: 12px;
3813        width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);
3814        background: ${BP.surface};
3815        border: 1px solid ${BP.hairline};
3816        border-radius: 14px;
3817        backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px);
3818        box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08);
3819        display: flex; flex-direction: column;
3820        transform: translateX(calc(100% + 24px));
3821        opacity: 0;
3822        transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};
3823        pointer-events: none;
3824        overflow: hidden;
3825      }
3826      .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
3827
3828      .panel-header {
3829        display: flex; align-items: center; gap: 10px;
3830        padding: 10px 10px 10px 14px;
3831        background: transparent;
3832        border-bottom: 1px solid ${BP.hairline};
3833      }
3834      .panel-title {
3835        flex: 1; min-width: 0;
3836        font-family: ${MONO};
3837        font-size: 11.5px; font-weight: 600;
3838        letter-spacing: 0.02em;
3839        color: ${BP.text};
3840        white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
3841      }
3842      .panel-close {
3843        border: none; background: transparent; color: ${BP.textDim};
3844        width: 26px; height: 26px; border-radius: 7px;
3845        display: inline-flex; align-items: center; justify-content: center;
3846        cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
3847      }
3848      .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; }
3849
3850      .tabs {
3851        display: inline-flex; padding: 2px;
3852        background: ${BP.hairline};
3853        border-radius: 7px;
3854        gap: 2px;
3855      }
3856      .tab {
3857        border: none; background: transparent;
3858        padding: 4px 10px; border-radius: 5px;
3859        font-family: ${MONO};
3860        font-size: 10px; font-weight: 600; letter-spacing: 0.08em;
3861        text-transform: uppercase;
3862        color: ${BP.textDim}; cursor: pointer;
3863        transition: background 0.15s ease, color 0.15s ease;
3864      }
3865      .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; }
3866
3867      .panel-body {
3868        flex: 1; overflow-y: auto;
3869        padding: 12px 12px 20px;
3870        background: ${DP.canvas};
3871        scrollbar-width: thin;
3872        scrollbar-color: ${DP.hairline} transparent;
3873      }
3874      .panel-body::-webkit-scrollbar { width: 8px; }
3875      .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
3876
3877      /* --- States --- */
3878      .empty, .loading, .error {
3879        margin: 16px 4px;
3880        padding: 28px 20px; text-align: center;
3881        background: ${DP.tile}; border-radius: 14px;
3882        color: ${DP.ink2}; font-size: 13px; line-height: 1.55;
3883      }
3884      .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }
3885      .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }
3886      .error { color: oklch(45% 0.15 25); }
3887
3888      /* --- Stale hint --- */
3889      .stale {
3890        display: flex; align-items: center; gap: 8px;
3891        margin: 8px 4px 12px;
3892        padding: 8px 12px;
3893        background: ${DP.amberBg};
3894        border-radius: 10px;
3895        font-size: 11.5px; color: ${DP.ink2};
3896      }
3897      .stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; }
3898      .stale-text { flex: 1; min-width: 0; }
3899      .stale-text strong { color: ${DP.ink}; font-weight: 600; }
3900
3901      /* --- Parsed-md fallback banner --- */
3902      .parsed-md-cta {
3903        margin: 8px 4px 14px;
3904        padding: 14px 16px;
3905        background: ${DP.tile};
3906        border: 1px dashed ${DP.hairline};
3907        border-radius: 12px;
3908        font-size: 12px; color: ${DP.ink2}; line-height: 1.55;
3909      }
3910      .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; }
3911      .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; }
3912
3913      /* --- Tile primitives --- */
3914      .tile {
3915        position: relative;
3916        background: ${DP.tile};
3917        border-radius: 16px;
3918        padding: 16px;
3919        margin: 0 4px 10px;
3920      }
3921      .tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
3922      .tile-row .tile { margin: 0; }
3923      .tile-meta {
3924        display: flex; align-items: baseline; justify-content: space-between;
3925        gap: 10px;
3926        font-family: ${MONO};
3927        font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;
3928        color: ${DP.meta};
3929      }
3930      .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; }
3931
3932      /* --- Color tile --- */
3933      .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }
3934      .c-tile:hover { transform: translateY(-1px); }
3935      .c-hero {
3936        height: 72px; border-radius: 10px; margin-top: 10px;
3937        box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
3938      }
3939      .c-ramp {
3940        display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;
3941        margin-top: 8px;
3942        box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04);
3943      }
3944      .c-ramp > span { flex: 1; }
3945      .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; }
3946
3947      /* --- Type tile --- */
3948      .t-tile { }
3949      .t-specimen {
3950        margin: 4px 0 6px;
3951        color: ${DP.ink};
3952        line-height: 0.9;
3953      }
3954      .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; }
3955      .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; }
3956
3957      /* --- Shadow tile --- */
3958      .s-tile { }
3959      .s-surface {
3960        height: 60px; margin: 8px 2px 10px;
3961        background: ${DP.tile};
3962        border-radius: 10px;
3963      }
3964      .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; }
3965      .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; }
3966
3967      /* --- Radii strip --- */
3968      .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
3969      .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; }
3970      .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); }
3971      .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; }
3972      .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; }
3973
3974      /* --- Component tile (hosts live primitives) --- */
3975      .cmp-tile { }
3976      .cmp-stage {
3977        margin: 12px -4px 0;
3978        padding: 18px 16px 10px;
3979        border-top: 1px solid ${DP.hairlineSoft};
3980        display: flex; flex-direction: column; align-items: center; justify-content: center;
3981        gap: 14px;
3982        min-height: 68px;
3983      }
3984      .cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; }
3985      .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; }
3986      .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; }
3987
3988      /* --- Collapsible --- */
3989      .coll {
3990        margin: 0 4px 8px;
3991        background: ${DP.tile};
3992        border-radius: 12px;
3993        overflow: hidden;
3994      }
3995      .coll-head {
3996        display: flex; align-items: center; gap: 10px;
3997        width: 100%;
3998        padding: 12px 14px;
3999        background: transparent; border: none;
4000        cursor: pointer; text-align: left;
4001        font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};
4002        transition: background 0.12s ease;
4003      }
4004      .coll-head:hover { background: ${DP.tileAlt}; }
4005      .coll-chev {
4006        width: 12px; height: 12px; flex-shrink: 0;
4007        color: ${DP.meta};
4008        transition: transform 0.2s ${EASE};
4009      }
4010      .coll[data-open="true"] .coll-chev { transform: rotate(90deg); }
4011      .coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; }
4012      .coll-body { padding: 0 14px 14px; display: none; }
4013      .coll[data-open="true"] .coll-body { display: block; }
4014
4015      .rule-card {
4016        padding: 10px 0;
4017        border-top: 1px solid ${DP.hairlineSoft};
4018      }
4019      .rule-card:first-child { border-top: none; padding-top: 2px; }
4020      .rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; }
4021      .rule-card .name .section { font-family: ${MONO}; font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; margin-left: 8px; }
4022      .rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; }
4023
4024      .coll .dos { display: grid; gap: 0; margin-top: 2px; }
4025      .coll .do, .coll .dont {
4026        position: relative;
4027        padding: 8px 0 8px 22px;
4028        font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};
4029        border-top: 1px solid ${DP.hairlineSoft};
4030      }
4031      .coll .do:first-child, .coll .dont:first-child,
4032      .coll .do:first-of-type { border-top: none; }
4033      .coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; }
4034      .coll .do::before, .coll .dont::before {
4035        content: ''; position: absolute; left: 4px; top: 13px;
4036        width: 8px; height: 8px; border-radius: 50%;
4037      }
4038      .coll .do::before { background: oklch(62% 0.16 145); }
4039      .coll .dont::before { background: oklch(58% 0.22 25); }
4040
4041      .coll .overview-body {
4042        font-size: 12px; line-height: 1.55; color: ${DP.ink2};
4043      }
4044      .coll .overview-body .north-star {
4045        display: block; font-family: ${FONT}; font-style: italic;
4046        font-size: 15px; line-height: 1.3; color: ${DP.ink};
4047        margin-bottom: 8px;
4048      }
4049      .coll .overview-body p { margin: 0 0 8px; }
4050      .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; }
4051      .coll .overview-body li { margin-bottom: 3px; }
4052
4053      /* --- raw tab markdown (unchanged layout, neutralized palette) --- */
4054      .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; }
4055      .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; }
4056      .md h1 { font-size: 18px; }
4057      .md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; }
4058      .md h3 { font-size: 13px; }
4059      .md h4 { font-size: 12px; color: ${DP.meta}; }
4060      .md p { margin: 0 0 10px; }
4061      .md ul, .md ol { margin: 0 0 10px; padding-left: 20px; }
4062      .md li { margin-bottom: 4px; }
4063      .md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; }
4064      .md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; }
4065      .md pre code { background: none; padding: 0; }
4066      .md strong { font-weight: 700; }
4067      .md em { font-style: italic; }
4068      .md a { color: ${DP.ink}; text-decoration: underline; }
4069      .md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; }
4070    `;
4071  }
4072
4073  function renderDesignChrome() {
4074    const root = designShadow.querySelector('.root');
4075    root.innerHTML = '';
4076
4077    // (Panel toggle lives in the global bar — no floating FAB.)
4078    // Panel
4079    const panel = document.createElement('aside');
4080    panel.className = 'panel';
4081    panel.setAttribute('data-open', designState.open ? 'true' : 'false');
4082    panel.appendChild(buildDesignHeader());
4083    const body = document.createElement('div');
4084    body.className = 'panel-body';
4085    body.id = 'panel-body';
4086    panel.appendChild(body);
4087    root.appendChild(panel);
4088
4089    renderDesignBody();
4090  }
4091
4092  function buildDesignHeader() {
4093    const header = document.createElement('div');
4094    header.className = 'panel-header';
4095
4096    const title = document.createElement('div');
4097    title.className = 'panel-title';
4098    title.textContent = 'DESIGN.md';
4099    header.appendChild(title);
4100
4101    const tabs = document.createElement('div');
4102    tabs.className = 'tabs';
4103    for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {
4104      const btn = document.createElement('button');
4105      btn.className = 'tab';
4106      btn.textContent = t[1];
4107      btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');
4108      btn.addEventListener('click', () => {
4109        if (designState.tab === t[0]) return;
4110        designState.tab = t[0];
4111        saveDesignPrefs();
4112        renderDesignChrome();
4113        if (t[0] === 'raw' && designState.raw === null && !designState.loading) {
4114          fetchDesignSystem(); // raw is part of the same fetch pair
4115        }
4116      });
4117      tabs.appendChild(btn);
4118    }
4119    header.appendChild(tabs);
4120
4121    const close = document.createElement('button');
4122    close.className = 'panel-close';
4123    close.innerHTML = '&#x2715;';
4124    close.setAttribute('aria-label', 'Close panel');
4125    close.addEventListener('click', toggleDesignPanel);
4126    header.appendChild(close);
4127
4128    return header;
4129  }
4130
4131  function toggleDesignPanel() {
4132    designState.open = !designState.open;
4133    renderDesignChrome();
4134    updateGlobalBarState();
4135    if (designState.open && designState.present === null && !designState.loading) {
4136      fetchDesignSystem();
4137    }
4138  }
4139
4140  async function fetchDesignSystem() {
4141    designState.loading = true;
4142    designState.error = null;
4143    renderDesignBody();
4144    try {
4145      const [jsonRes, rawRes] = await Promise.all([
4146        fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),
4147        fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),
4148      ]);
4149      const jsonData = await jsonRes.json();
4150      designState.present = jsonData.present === true;
4151      designState.parsed = jsonData.parsed || null;
4152      designState.sidecar = jsonData.sidecar || null;
4153      designState.hasMd = !!jsonData.hasMd;
4154      designState.hasSidecar = !!jsonData.hasSidecar;
4155      designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
4156      designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
4157      designState.error = jsonData.parseError || jsonData.sidecarError || null;
4158    } catch (err) {
4159      designState.error = err?.message || 'Failed to load design system.';
4160    } finally {
4161      designState.loading = false;
4162      renderDesignChrome(); // refresh title from data
4163    }
4164  }
4165
4166  function renderDesignBody() {
4167    const body = designShadow.querySelector('#panel-body');
4168    if (!body) return;
4169    body.innerHTML = '';
4170
4171    if (designState.loading) {
4172      body.appendChild(msgDiv('loading', 'Loading design system…'));
4173      return;
4174    }
4175    if (designState.error) {
4176      body.appendChild(msgDiv('error', designState.error));
4177      return;
4178    }
4179    if (designState.present === false) {
4180      const empty = document.createElement('div');
4181      empty.className = 'empty';
4182      empty.innerHTML = `<strong>No DESIGN.md yet</strong>Create one by running <code>/impeccable document</code> in your terminal, then re-open this panel.`;
4183      body.appendChild(empty);
4184      return;
4185    }
4186
4187    if (designState.tab === 'raw') {
4188      renderRawTab(body, designState.raw || '');
4189      return;
4190    }
4191
4192    // Visual tab — single unified render path.
4193    if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
4194    if (designState.hasMd && !designState.hasSidecar) {
4195      body.appendChild(renderParsedMdCta());
4196    }
4197    renderDesignVisual(body, designState.parsed, designState.sidecar);
4198  }
4199
4200  function msgDiv(cls, text) {
4201    const d = document.createElement('div');
4202    d.className = cls;
4203    d.textContent = text;
4204    return d;
4205  }
4206
4207  function renderStaleHint() {
4208    const box = document.createElement('div');
4209    box.className = 'stale';
4210    box.innerHTML = `
4211      <span class="stale-dot"></span>
4212      <span class="stale-text"><strong>DESIGN.md is newer than .impeccable/design.json.</strong> Run <code>/impeccable document</code> to refresh the sidecar.</span>
4213    `;
4214    return box;
4215  }
4216
4217  function renderParsedMdCta() {
4218    const box = document.createElement('div');
4219    box.className = 'parsed-md-cta';
4220    box.innerHTML = `<strong>Basic view</strong>This panel reads the tokens in your <code>DESIGN.md</code> frontmatter. Running <code>/impeccable document</code> also generates a <code>.impeccable/design.json</code> sidecar with your project's actual component snippets (button, input, nav) and tonal ramps, rendered live below the tokens.`;
4221    return box;
4222  }
4223
4224  // --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 ---
4225
4226  function renderDesignVisual(body, parsed, sidecar) {
4227    const frontmatter = parsed?.frontmatter || {};
4228    const extensions = sidecar?.extensions || {};
4229    const proseColors = parsed?.colors || null;
4230
4231    const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);
4232    if (colors.length) renderColorTiles(body, colors);
4233
4234    const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);
4235    if (types.length) renderTypeTiles(body, types);
4236
4237    const radii = buildRadiiModels(frontmatter.rounded);
4238    if (radii.length) renderRadiiTile(body, radii);
4239
4240    if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);
4241
4242    const components = sidecar?.components || [];
4243    if (components.length) renderComponentTiles(body, components);
4244
4245    // Narrative: sidecar wins if present (richer, agent-curated). Otherwise
4246    // synthesize from prose sections.
4247    const narrative = sidecar?.narrative || synthesizeNarrative(parsed);
4248    if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));
4249    if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));
4250    if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {
4251      body.appendChild(renderOverviewCollapsible(narrative));
4252    }
4253
4254    if (body.childElementCount === 0) {
4255      body.appendChild(msgDiv('empty', 'No design system data available.'));
4256    }
4257  }
4258
4259  // Frontmatter primitives + sidecar colorMeta → tile-ready color models.
4260  // A matching prose bullet (when the slug sits in the bullet text) supplies
4261  // description as a last-resort fallback.
4262  function buildColorModels(fmColors, colorMeta, proseColors) {
4263    if (!fmColors) return [];
4264    const meta = colorMeta || {};
4265    return Object.entries(fmColors).map(([key, value]) => {
4266      const m = meta[key] || {};
4267      return {
4268        role: m.role || humanizeKey(key),
4269        name: m.displayName || humanizeKey(key),
4270        value: value,
4271        canonical: m.canonical || null,
4272        description: m.description || findProseDescription(proseColors, key, m.displayName),
4273        tonalRamp: m.tonalRamp || null,
4274      };
4275    });
4276  }
4277
4278  function buildTypographyModels(fmTypography, typographyMeta) {
4279    if (!fmTypography) return [];
4280    const meta = typographyMeta || {};
4281    return Object.entries(fmTypography).map(([key, spec]) => {
4282      const m = meta[key] || {};
4283      const { family, fallback } = splitFontFamily(spec?.fontFamily);
4284      return {
4285        role: key,
4286        name: m.displayName || humanizeKey(key),
4287        family,
4288        fallback,
4289        weight: spec?.fontWeight ?? 400,
4290        // fontStyle isn't in Stitch's frontmatter schema; the sidecar carries
4291        // it when a role is rendered in italic (e.g. display italic).
4292        style: m.style || 'normal',
4293        sampleSize: spec?.fontSize || '1rem',
4294        lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',
4295        letterSpacing: spec?.letterSpacing,
4296        purpose: m.purpose,
4297      };
4298    });
4299  }
4300
4301  function buildRadiiModels(fmRounded) {
4302    if (!fmRounded) return [];
4303    return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));
4304  }
4305
4306  function splitFontFamily(stack) {
4307    if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };
4308    const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));
4309    return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };
4310  }
4311
4312  function humanizeKey(k) {
4313    return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
4314  }
4315
4316  function findProseDescription(proseColors, key, displayName) {
4317    if (!proseColors || !proseColors.groups) return null;
4318    const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());
4319    for (const g of proseColors.groups) {
4320      for (const c of g.colors || []) {
4321        const hay = String(c.name || '').toLowerCase();
4322        if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {
4323          return c.description || null;
4324        }
4325      }
4326    }
4327    return null;
4328  }
4329
4330  function synthesizeNarrative(parsed) {
4331    if (!parsed) return {};
4332    const md = parsed;
4333    return {
4334      northStar: md.overview?.creativeNorthStar,
4335      overview: (md.overview?.philosophy || []).join(' '),
4336      keyCharacteristics: md.overview?.keyCharacteristics || [],
4337      rules: [
4338        ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
4339        ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
4340        ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
4341      ],
4342      dos: md.dosDonts?.dos || [],
4343      donts: md.dosDonts?.donts || [],
4344    };
4345  }
4346
4347  function renderColorTiles(body, colors) {
4348    for (const c of colors) {
4349      const tile = document.createElement('div');
4350      tile.className = 'tile c-tile';
4351      tile.title = 'Click to copy';
4352      tile.addEventListener('click', () => copyToClipboard(c.value));
4353
4354      const meta = document.createElement('div');
4355      meta.className = 'tile-meta';
4356      meta.innerHTML = `<span class="name">${escapeHtml(c.name || c.role || 'Color')}</span><span>${escapeHtml(c.value || '')}</span>`;
4357      tile.appendChild(meta);
4358
4359      const hero = document.createElement('div');
4360      hero.className = 'c-hero';
4361      hero.style.background = c.value;
4362      tile.appendChild(hero);
4363
4364      const ramp = synthesizeRamp(c);
4365      if (ramp.length) {
4366        const r = document.createElement('div');
4367        r.className = 'c-ramp';
4368        r.innerHTML = ramp.map((v) => `<span style="background:${cssSafe(v)}"></span>`).join('');
4369        tile.appendChild(r);
4370      }
4371
4372      if (c.description) {
4373        const d = document.createElement('div');
4374        d.className = 'c-desc';
4375        d.textContent = c.description;
4376        tile.appendChild(d);
4377      }
4378      body.appendChild(tile);
4379    }
4380  }
4381
4382  function synthesizeRamp(c) {
4383    if (c.tonalRamp?.length) return c.tonalRamp;
4384    // If base value is OKLCH, synthesize an 8-step ramp across lightness.
4385    const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i);
4386    if (!m) return [];
4387    const [, , chroma, hue] = m;
4388    const steps = [20, 32, 44, 56, 68, 80, 90, 96];
4389    return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`);
4390  }
4391
4392  function renderTypeTiles(body, types) {
4393    for (const t of types) {
4394      const tile = document.createElement('div');
4395      tile.className = 'tile t-tile';
4396
4397      const meta = document.createElement('div');
4398      meta.className = 'tile-meta';
4399      meta.innerHTML = `<span>${escapeHtml(t.role || '')}</span><span>${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')}</span>`;
4400      tile.appendChild(meta);
4401
4402      const specimen = document.createElement('div');
4403      specimen.className = 't-specimen';
4404      specimen.textContent = 'Aa';
4405      specimen.style.fontFamily = fontStack(t);
4406      specimen.style.fontWeight = String(t.weight || 400);
4407      specimen.style.fontStyle = t.style || 'normal';
4408      specimen.style.fontSize = '56px';  // Fixed specimen size — compare faces, not scales.
4409      specimen.style.letterSpacing = 'normal';
4410      specimen.style.textTransform = 'none';
4411      tile.appendChild(specimen);
4412
4413      // The system's actual sample size for this role, shown as small mono meta below.
4414      if (t.sampleSize) {
4415        const scale = document.createElement('div');
4416        scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;';
4417        scale.textContent = t.sampleSize;
4418        tile.appendChild(scale);
4419      }
4420
4421      const family = document.createElement('div');
4422      family.className = 't-family';
4423      family.textContent = t.family || t.name || '';
4424      tile.appendChild(family);
4425
4426      if (t.purpose) {
4427        const p = document.createElement('div');
4428        p.className = 't-purpose';
4429        p.textContent = t.purpose;
4430        tile.appendChild(p);
4431      }
4432      body.appendChild(tile);
4433    }
4434  }
4435
4436  function fontStack(t) {
4437    const fam = t.family || '';
4438    const fb = t.fallback || '';
4439    if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) {
4440      return `"${fam}", ${fb}`;
4441    }
4442    return fam && fb ? `"${fam}", ${fb}` : (fam || fb);
4443  }
4444
4445  function renderRadiiTile(body, radii) {
4446    const tile = document.createElement('div');
4447    tile.className = 'tile';
4448    const meta = document.createElement('div');
4449    meta.className = 'tile-meta';
4450    meta.innerHTML = `<span class="name">Corner Radii</span><span>${radii.length}</span>`;
4451    tile.appendChild(meta);
4452
4453    const strip = document.createElement('div');
4454    strip.className = 'r-strip';
4455    for (const r of radii) {
4456      const item = document.createElement('div');
4457      item.className = 'r-item';
4458      const s = document.createElement('div');
4459      s.className = 'r-sample';
4460      s.style.borderRadius = r.value || '0';
4461      item.appendChild(s);
4462      const lbl = document.createElement('div');
4463      lbl.className = 'r-label';
4464      lbl.textContent = r.name || '';
4465      item.appendChild(lbl);
4466      const val = document.createElement('div');
4467      val.className = 'r-val';
4468      val.textContent = r.value || '';
4469      item.appendChild(val);
4470      strip.appendChild(item);
4471    }
4472    tile.appendChild(strip);
4473    body.appendChild(tile);
4474  }
4475
4476  function renderShadowTiles(body, shadows) {
4477    for (const sh of shadows) {
4478      const tile = document.createElement('div');
4479      tile.className = 'tile s-tile';
4480
4481      const meta = document.createElement('div');
4482      meta.className = 'tile-meta';
4483      meta.innerHTML = `<span class="name">${escapeHtml(sh.name || 'Shadow')}</span><span>Elevation</span>`;
4484      tile.appendChild(meta);
4485
4486      const surface = document.createElement('div');
4487      surface.className = 's-surface';
4488      surface.style.boxShadow = sh.value || 'none';
4489      tile.appendChild(surface);
4490
4491      const val = document.createElement('div');
4492      val.className = 's-value';
4493      val.textContent = sh.value || '';
4494      tile.appendChild(val);
4495
4496      if (sh.purpose) {
4497        const p = document.createElement('div');
4498        p.className = 's-purpose';
4499        p.textContent = sh.purpose;
4500        tile.appendChild(p);
4501      }
4502      body.appendChild(tile);
4503    }
4504  }
4505
4506  function renderComponentTiles(body, components) {
4507    // Group consecutive components that share a kind into one tile. This avoids
4508    // a pile of one-component tiles (e.g., three button variants = three tiles)
4509    // and reads more like a proper category.
4510    const groups = groupByKind(components);
4511
4512    for (const group of groups) {
4513      const tile = document.createElement('div');
4514      tile.className = 'tile cmp-tile';
4515
4516      const meta = document.createElement('div');
4517      meta.className = 'tile-meta';
4518      const groupTitle = group.length === 1
4519        ? (group[0].name || group[0].kind || 'Component')
4520        : titleForKind(group[0].kind, group.length);
4521      meta.innerHTML = `<span class="name">${escapeHtml(groupTitle)}</span><span class="cmp-kind">${escapeHtml(group[0].kind || '')}</span>`;
4522      tile.appendChild(meta);
4523
4524      for (const c of group) {
4525        const stage = document.createElement('div');
4526        stage.className = 'cmp-stage';
4527
4528        // Render the component in its own shadow root so its CSS can't bleed.
4529        const host = document.createElement('div');
4530        const sub = host.attachShadow({ mode: 'open' });
4531        const style = document.createElement('style');
4532        style.textContent = c.css || '';
4533        sub.appendChild(style);
4534        const container = document.createElement('div');
4535        container.innerHTML = c.html || '';
4536        sub.appendChild(container);
4537        stage.appendChild(host);
4538
4539        // Show component name as a sublabel only when the tile groups >1 item,
4540        // or when the component's display name differs from its kind.
4541        const showSublabel = group.length > 1;
4542        if (showSublabel) {
4543          const lbl = document.createElement('div');
4544          lbl.className = 'cmp-sublabel';
4545          lbl.textContent = c.name || '';
4546          stage.appendChild(lbl);
4547        }
4548        tile.appendChild(stage);
4549      }
4550
4551      // Single shared description if all items carry the same one; otherwise
4552      // skip — per-item descriptions clutter a grouped tile.
4553      if (group.length === 1 && group[0].description) {
4554        const d = document.createElement('div');
4555        d.className = 'c-desc';
4556        d.textContent = group[0].description;
4557        tile.appendChild(d);
4558      }
4559      body.appendChild(tile);
4560    }
4561  }
4562
4563  function groupByKind(components) {
4564    const groups = [];
4565    for (const c of components) {
4566      const last = groups[groups.length - 1];
4567      if (last && last[0].kind && c.kind === last[0].kind) {
4568        last.push(c);
4569      } else {
4570        groups.push([c]);
4571      }
4572    }
4573    return groups;
4574  }
4575
4576  function titleForKind(kind, count) {
4577    const labels = {
4578      button: 'Buttons',
4579      input: 'Inputs',
4580      nav: 'Navigation',
4581      chip: 'Chips',
4582      card: 'Cards',
4583      custom: 'Components',
4584    };
4585    return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components');
4586  }
4587
4588  // --- Collapsibles ---------------------------------------------------------
4589
4590  function buildCollapsible(key, label, count) {
4591    const wrap = document.createElement('div');
4592    wrap.className = 'coll';
4593    wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true');
4594
4595    const head = document.createElement('button');
4596    head.className = 'coll-head';
4597    head.innerHTML = `
4598      <svg class="coll-chev" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5L8 6 4 9.5"/></svg>
4599      <span>${escapeHtml(label)}</span>
4600      ${count != null ? `<span class="coll-count">${escapeHtml(String(count))}</span>` : ''}
4601    `;
4602    head.addEventListener('click', () => {
4603      designState.collapsed[key] = !designState.collapsed[key];
4604      saveDesignPrefs();
4605      renderDesignBody();
4606    });
4607    wrap.appendChild(head);
4608
4609    const body = document.createElement('div');
4610    body.className = 'coll-body';
4611    wrap.appendChild(body);
4612    return { wrap, body };
4613  }
4614
4615  function renderRulesCollapsible(rules) {
4616    const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length);
4617    for (const r of rules) {
4618      const card = document.createElement('div');
4619      card.className = 'rule-card';
4620      const name = document.createElement('div');
4621      name.className = 'name';
4622      name.innerHTML = `${escapeHtml(r.name)}${r.section ? `<span class="section">${escapeHtml(r.section)}</span>` : ''}`;
4623      card.appendChild(name);
4624      const b = document.createElement('div');
4625      b.className = 'body';
4626      b.textContent = r.body || '';
4627      card.appendChild(b);
4628      body.appendChild(card);
4629    }
4630    return wrap;
4631  }
4632
4633  function renderDosDontsCollapsible(n) {
4634    const total = (n.dos?.length || 0) + (n.donts?.length || 0);
4635    const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total);
4636    const grid = document.createElement('div');
4637    grid.className = 'dos';
4638    for (const d of n.dos || []) {
4639      const el = document.createElement('div');
4640      el.className = 'do';
4641      el.innerHTML = inlineMd(d);
4642      grid.appendChild(el);
4643    }
4644    for (const d of n.donts || []) {
4645      const el = document.createElement('div');
4646      el.className = 'dont';
4647      el.innerHTML = inlineMd(d);
4648      grid.appendChild(el);
4649    }
4650    body.appendChild(grid);
4651    return wrap;
4652  }
4653
4654  function renderOverviewCollapsible(n) {
4655    const { wrap, body } = buildCollapsible('overview', 'Overview', null);
4656    const ov = document.createElement('div');
4657    ov.className = 'overview-body';
4658    if (n.northStar) {
4659      const star = document.createElement('span');
4660      star.className = 'north-star';
4661      star.textContent = '“' + n.northStar + '”';
4662      ov.appendChild(star);
4663    }
4664    if (n.overview) {
4665      const p = document.createElement('p');
4666      p.innerHTML = inlineMd(n.overview);
4667      ov.appendChild(p);
4668    }
4669    if (n.keyCharacteristics?.length) {
4670      const ul = document.createElement('ul');
4671      ul.innerHTML = n.keyCharacteristics.map((k) => `<li>${inlineMd(k)}</li>`).join('');
4672      ov.appendChild(ul);
4673    }
4674    body.appendChild(ov);
4675    return wrap;
4676  }
4677
4678  function cssSafe(v) {
4679    // Strip anything outside valid CSS value chars to prevent injection via
4680    // .impeccable/design.json values rendered into inline style strings.
4681    return String(v).replace(/[<>"'`\n]/g, '');
4682  }
4683
4684  // --- Raw tab: minimal markdown renderer (subset) --------------------------
4685
4686  function renderRawTab(body, md) {
4687    const wrap = document.createElement('div');
4688    wrap.className = 'md';
4689    wrap.innerHTML = renderMarkdown(md);
4690    body.appendChild(wrap);
4691  }
4692
4693  function renderMarkdown(md) {
4694    const lines = md.split(/\r?\n/);
4695    const out = [];
4696    let i = 0;
4697    let inCode = false;
4698    let codeBuf = [];
4699    let paraBuf = [];
4700    let listBuf = [];  // array of { indent, html }
4701    let listType = null; // 'ul' | 'ol'
4702
4703    const flushPara = () => {
4704      if (paraBuf.length) {
4705        out.push(`<p>${inlineMd(paraBuf.join(' '))}</p>`);
4706        paraBuf = [];
4707      }
4708    };
4709    const flushList = () => {
4710      if (listBuf.length) {
4711        out.push(buildListHtml(listBuf, listType));
4712        listBuf = [];
4713        listType = null;
4714      }
4715    };
4716    const flushAll = () => { flushPara(); flushList(); };
4717
4718    for (; i < lines.length; i++) {
4719      const line = lines[i];
4720
4721      // Code fence
4722      const fence = line.match(/^```(\w*)\s*$/);
4723      if (fence) {
4724        if (!inCode) { flushAll(); inCode = true; codeBuf = []; }
4725        else {
4726          out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
4727          inCode = false;
4728        }
4729        continue;
4730      }
4731      if (inCode) { codeBuf.push(line); continue; }
4732
4733      if (line.trim() === '') { flushAll(); continue; }
4734
4735      const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/);
4736      if (hr) { flushAll(); out.push('<hr />'); continue; }
4737
4738      const heading = line.match(/^(#{1,4})\s+(.+)$/);
4739      if (heading) {
4740        flushAll();
4741        const lvl = heading[1].length;
4742        out.push(`<h${lvl}>${inlineMd(heading[2])}</h${lvl}>`);
4743        continue;
4744      }
4745
4746      const bullet = line.match(/^(\s*)([-*])\s+(.+)$/);
4747      const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
4748      if (bullet || ordered) {
4749        flushPara();
4750        const m = bullet || ordered;
4751        const indent = Math.floor(m[1].length / 2);
4752        const t = bullet ? 'ul' : 'ol';
4753        if (listType && listType !== t) flushList();
4754        listType = t;
4755        listBuf.push({ indent, html: inlineMd(m[3]) });
4756        continue;
4757      }
4758
4759      paraBuf.push(line);
4760    }
4761    flushAll();
4762    if (inCode && codeBuf.length) {
4763      out.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
4764    }
4765    return out.join('\n');
4766  }
4767
4768  function buildListHtml(items, type) {
4769    // Nest by indent (one level deep is plenty for DESIGN.md).
4770    let html = `<${type}>`;
4771    let lastIndent = 0;
4772    for (const it of items) {
4773      if (it.indent > lastIndent) html += `<${type}>`;
4774      else if (it.indent < lastIndent) html += `</${type}>`.repeat(lastIndent - it.indent);
4775      html += `<li>${it.html}</li>`;
4776      lastIndent = it.indent;
4777    }
4778    html += `</${type}>`.repeat(lastIndent + 1);
4779    return html;
4780  }
4781
4782  function inlineMd(text) {
4783    // Order matters: escape first, then re-inject tags.
4784    let s = escapeHtml(text);
4785    // Code spans
4786    s = s.replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
4787    // Links [text](url)
4788    s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `<a href="${u}" target="_blank" rel="noopener noreferrer">${t}</a>`);
4789    // Bold
4790    s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
4791    // Italic (only single *…*, skip if inside bold already handled)
4792    s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1<em>$2</em>');
4793    return s;
4794  }
4795
4796  function highlightBold(text) {
4797    return inlineMd(text);
4798  }
4799
4800  function escapeHtml(s) {
4801    return String(s)
4802      .replace(/&/g, '&amp;')
4803      .replace(/</g, '&lt;')
4804      .replace(/>/g, '&gt;')
4805      .replace(/"/g, '&quot;')
4806      .replace(/'/g, '&#39;');
4807  }
4808
4809  function copyToClipboard(text) {
4810    if (!text) return;
4811    try {
4812      navigator.clipboard.writeText(text);
4813      showToast('Copied: ' + text);
4814    } catch { /* ignore */ }
4815  }
4816
4817  // ---------------------------------------------------------------------------
4818  // Init
4819  // ---------------------------------------------------------------------------
4820
4821  function init() {
4822    try { history.scrollRestoration = 'manual'; } catch {}
4823    initHighlight();
4824    initAnnotOverlay();
4825    initBar();
4826    initActionPicker();
4827    initParamsPanel();
4828    initGlobalBar();
4829    initDesignPanel();
4830    document.addEventListener('mousemove', handleMouseMove, true);
4831    document.addEventListener('click', handleClick, true);
4832    document.addEventListener('keydown', handleKeyDown, true);
4833    connectSSE();
4834
4835    // Check for an active session to resume (variant wrapper already in DOM after HMR)
4836    if (!resumeSession()) {
4837      console.log('[impeccable] Live variant mode ready. Hover over elements to pick one.');
4838      // SvelteKit (and any framework that hydrates after HTML parse) may add
4839      // the variant wrapper AFTER init runs. Watch for it and retry resume
4840      // once it appears. Disconnect on first hit.
4841      const scout = new MutationObserver(() => {
4842        const wrapper = document.querySelector('[data-impeccable-variants]');
4843        if (!wrapper) return;
4844        scout.disconnect();
4845        if (resumeSession()) {
4846          console.log('[impeccable] Resumed deferred session ' + currentSessionId + ' (post-hydration).');
4847        }
4848      });
4849      scout.observe(document.body, { childList: true, subtree: true });
4850    } else {
4851      console.log('[impeccable] Resumed active variant session ' + currentSessionId + ' (' + arrivedVariants + '/' + expectedVariants + ' variants).');
4852    }
4853  }
4854
4855  if (document.readyState === 'loading') {
4856    document.addEventListener('DOMContentLoaded', init);
4857  } else {
4858    init();
4859  }
4860})();