# Preview Runtime Library

Companion to `SKILL.md`. Contains the `__fcPreview` helper library injected once via `initScript` or `evaluate_script`. Reduces per-preview MCP calls from ~8-12 down to ~3-4.

**Referenced by:** `fluent-mystique-preview/SKILL.md`

---

## Quick Start (for the AI)

Instead of assembling 300 lines of JS per preview, inject this library once, then call simple functions:

```
Call 1: navigate_page(url, initScript=__fcTest + __fcPreview)     ← page + both libraries
Call 2: evaluate_script(login check + pageSummary + scanComponents + captureBaseline)  ← 1 call
Call 3: take_screenshot (baseline)                                ← 1 call
Call 4: evaluate_script(__fcPreview.applyAll(changes))            ← 1 call
Call 5: take_screenshot (with highlights)                         ← 1 call
```

**Total: 5 MCP calls for a complete preview** (was 8-12). Session-reuse skips login → effectively 5 calls. Iteration = 2 calls per round (clear+applyAll + screenshot).

---

## The Runtime Library

Inject via `initScript` on `navigate_page`, or via a single `evaluate_script` if already on the page. The library is idempotent — safe to inject multiple times.

```javascript
(function() {
  if (window.__fcPreview) return;

  // ── CSS Styles ──
  var styleId = '__fc-preview-styles';
  if (!document.getElementById(styleId)) {
    var s = document.createElement('style');
    s.id = styleId;
    s.textContent = [
      '@keyframes __fc-glow-green{0%,100%{box-shadow:0 0 12px 4px rgba(76,175,80,0.4)}50%{box-shadow:0 0 20px 8px rgba(76,175,80,0.6)}}',
      '@keyframes __fc-glow-yellow{0%,100%{box-shadow:0 0 12px 4px rgba(255,152,0,0.4)}50%{box-shadow:0 0 20px 8px rgba(255,152,0,0.6)}}',
      '@keyframes __fc-glow-red{0%,100%{box-shadow:0 0 10px 3px rgba(244,67,54,0.3)}50%{box-shadow:0 0 16px 6px rgba(244,67,54,0.5)}}',
      '@keyframes __fc-badge-pulse{0%,100%{opacity:1}50%{opacity:0.7}}',
      '@keyframes __fc-slot-shimmer{0%{transform:translateX(-100%)}100%{transform:translateX(100%)}}',
      '.__fc-preview-slot{border-radius:4px;overflow:hidden;position:relative;margin:8px 0}',
      '.__fc-slot-card{background:#fff;border-radius:4px;box-shadow:0 2px 1px -1px rgba(0,0,0,0.2),0 1px 1px 0 rgba(0,0,0,0.14),0 1px 3px 0 rgba(0,0,0,0.12);padding:16px;position:relative;overflow:hidden}',
      '.__fc-slot-bar{height:12px;border-radius:6px;background:#e0e0e0;position:relative;overflow:hidden}',
      '.__fc-slot-bar::after{content:"";position:absolute;top:0;left:0;right:0;bottom:0;background:linear-gradient(90deg,transparent,rgba(255,255,255,0.6),transparent);animation:__fc-slot-shimmer 1.5s ease-in-out infinite}',
      '.__fc-slot-bar--title{width:40%;height:16px;margin-bottom:16px}',
      '.__fc-slot-bar--field{width:60%;margin-bottom:10px}',
      '.__fc-slot-bar--short{width:30%}',
      '.__fc-slot-badge{position:absolute;top:8px;right:8px;background:linear-gradient(135deg,#42a5f5,#1565c0);color:#fff;padding:3px 10px;border-radius:12px;font:600 10px/1.6 sans-serif;letter-spacing:0.5px;animation:__fc-badge-pulse 2s ease-in-out infinite}',
      '.__fc-highlight-added{outline:3px dashed #4caf50!important;outline-offset:4px;animation:__fc-glow-green 2s ease-in-out infinite!important;position:relative}',
      '.__fc-highlight-modified{outline:3px solid #ff9800!important;outline-offset:4px;animation:__fc-glow-yellow 2s ease-in-out infinite!important;position:relative}',
      '.__fc-removed-wrapper{position:relative!important;outline:3px solid #f44336!important;outline-offset:4px;animation:__fc-glow-red 2s ease-in-out infinite!important}',
      '.__fc-highlight-removed{filter:grayscale(0.8) brightness(0.5)!important}',
      '.__fc-removed-strike{position:absolute;top:50%;left:0;right:0;height:3px;background:#f44336;z-index:10001;pointer-events:none}',
      '.__fc-change-badge{position:absolute;top:4px;right:8px;z-index:10002;color:#fff;padding:2px 10px;border-radius:12px;font:700 11px/1.6 sans-serif;letter-spacing:0.5px;animation:__fc-badge-pulse 2s ease-in-out infinite;pointer-events:none}',
      '.__fc-badge-added{background:linear-gradient(135deg,#4caf50,#2e7d32);box-shadow:0 2px 8px rgba(76,175,80,0.4)}',
      '.__fc-badge-modified{background:linear-gradient(135deg,#ff9800,#e65100);box-shadow:0 2px 8px rgba(255,152,0,0.4)}',
      '.__fc-badge-removed{background:linear-gradient(135deg,#f44336,#c62828);box-shadow:0 2px 8px rgba(244,67,54,0.4)}'
    ].join('\n');
    document.head.appendChild(s);
  }

  // ── State ──
  var changes = [];  // log of all applied changes
  var baseline = null;
  var slotCounter = 0;
  var anchorMap = [];  // { anchor: Element, preview: Element } — tracks injected ↔ target pairs

  // ── Orphan Observer ──
  // Watches for DOM mutations (React re-renders on tab switch, SPA nav).
  // When an anchor element is removed, its associated preview element is also removed.
  var orphanObserver = new MutationObserver(function(mutations) {
    if (anchorMap.length === 0) return;
    var dirty = false;
    for (var i = 0; i < mutations.length; i++) {
      if (mutations[i].removedNodes.length > 0) { dirty = true; break; }
    }
    if (!dirty) return;
    anchorMap = anchorMap.filter(function(entry) {
      if (!document.contains(entry.anchor)) {
        if (document.contains(entry.preview)) {
          try { if (window.ReactDOM) window.ReactDOM.unmountComponentAtNode(entry.preview); } catch(e) {}
          entry.preview.remove();
        }
        return false;
      }
      return true;
    });
  });
  orphanObserver.observe(document.body, { childList: true, subtree: true });

  function trackAnchor(anchor, preview) {
    anchorMap.push({ anchor: anchor, preview: preview });
  }

  // ── Badge helper ──
  function addBadge(el, text, cssClass) {
    var badge = document.createElement('div');
    badge.className = '__fc-change-badge ' + cssClass;
    badge.textContent = text;
    el.appendChild(badge);
  }

  // ── Public API ──
  window.__fcPreview = {
    // ── Highlight primitives ──
    add: function(el, label) {
      if (typeof el === 'string') el = document.querySelector(el);
      if (!el) return { ok: false, error: 'Element not found' };
      el.classList.add('__fc-highlight-added');
      el.style.position = 'relative';
      addBadge(el, label || 'ADDED', '__fc-badge-added');
      changes.push({ mode: 'add', label: label, selector: el.id || el.className });
      return { ok: true };
    },

    modify: function(el, label) {
      if (typeof el === 'string') el = document.querySelector(el);
      if (!el) return { ok: false, error: 'Element not found' };
      el.classList.add('__fc-highlight-modified');
      el.style.position = 'relative';
      addBadge(el, label || 'MODIFIED', '__fc-badge-modified');
      changes.push({ mode: 'modify', label: label });
      return { ok: true };
    },

    remove: function(el, label) {
      if (typeof el === 'string') el = document.querySelector(el);
      if (!el) return { ok: false, error: 'Element not found' };
      var wrapper = document.createElement('div');
      wrapper.className = '__fc-removed-wrapper __fc-preview-element';
      el.parentNode.insertBefore(wrapper, el);
      wrapper.appendChild(el);
      el.classList.add('__fc-highlight-removed');
      addBadge(wrapper, label || 'REMOVED', '__fc-badge-removed');
      var strike = document.createElement('div');
      strike.className = '__fc-removed-strike';
      wrapper.appendChild(strike);
      changes.push({ mode: 'remove', label: label });
      return { ok: true };
    },

    // ── Loading slot helpers ──
    // Generic loading placeholder — shows a pulsing skeleton at the target
    // location so the user knows something is being prepared.
    // opts: { label, position, rows, className }
    //   label:    badge text (default: 'LOADING…')
    //   position: 'after' | 'before' | 'inside-first' | 'inside-last'
    //   rows:     number of skeleton bars (default: 3), adapts to context
    prepareSlot: function(selector, opts) {
      opts = opts || {};
      var target = typeof selector === 'string'
        ? document.querySelector(selector) : selector;
      if (!target) return { ok: false, error: 'Target not found: ' + selector };

      var label = opts.label || 'LOADING\u2026';
      var position = opts.position || 'after';
      var rows = opts.rows || 3;

      // Build skeleton bars — vary widths for realism
      var widths = [40, 70, 55, 45, 65, 35, 60, 50, 75, 30];
      var barsHtml = '<div class="__fc-slot-bar __fc-slot-bar--title"></div>';
      for (var i = 0; i < rows; i++) {
        var w = widths[i % widths.length];
        barsHtml += '<div class="__fc-slot-bar __fc-slot-bar--field" style="width:' + w + '%"></div>';
      }

      var slot = document.createElement('div');
      slot.id = '__fc-slot-' + (++slotCounter);
      slot.className = '__fc-preview-slot __fc-preview-element'
        + (opts.className ? ' ' + opts.className : '');
      slot.innerHTML =
        '<div class="__fc-slot-card">' +
          '<div class="__fc-slot-badge">' + label + '</div>' +
          barsHtml +
        '</div>';

      if (position === 'after') target.parentNode.insertBefore(slot, target.nextSibling);
      else if (position === 'before') target.parentNode.insertBefore(slot, target);
      else if (position === 'inside-first') target.insertBefore(slot, target.firstChild);
      else if (position === 'inside-last') target.appendChild(slot);

      trackAnchor(target, slot);
      return { ok: true, slotId: slot.id };
    },

    // Replace a loading slot with actual HTML content + highlight
    fillSlot: function(slotId, html, label) {
      var slot = document.getElementById(slotId);
      if (!slot) return { ok: false, error: 'Slot not found: ' + slotId };
      slot.className = '__fc-preview-element';
      slot.style.position = 'relative';
      slot.innerHTML = html;
      this.add(slot, label || 'ADDED');
      changes.push({ mode: 'fill-slot', slotId: slotId, label: label });
      return { ok: true };
    },

    // Replace a loading slot with a React element (preferred)
    fillSlotReact: function(slotId, reactElement, label) {
      var slot = document.getElementById(slotId);
      if (!slot) return { ok: false, error: 'Slot not found: ' + slotId };
      if (!window.ReactDOM) return { ok: false, error: 'ReactDOM not available' };
      slot.className = '__fc-preview-element';
      slot.style.position = 'relative';
      window.ReactDOM.render(reactElement, slot);
      this.add(slot, label || 'ADDED');
      changes.push({ mode: 'fill-slot-react', slotId: slotId, label: label });
      return { ok: true };
    },

    // Remove all loading slots (e.g. on error / cancel)
    clearSlots: function() {
      document.querySelectorAll('.__fc-preview-slot').forEach(function(el) { el.remove(); });
      return { ok: true };
    },

    // ── Injection helpers ──
    inject: function(targetSelector, html, label, position) {
      // Inject new HTML near a target element
      // position: 'after' (default), 'before', 'inside-first', 'inside-last'
      var target = typeof targetSelector === 'string'
        ? document.querySelector(targetSelector) : targetSelector;
      if (!target) return { ok: false, error: 'Target not found: ' + targetSelector };

      var container = document.createElement('div');
      container.className = '__fc-preview-element';
      container.style.position = 'relative';
      container.innerHTML = html;

      position = position || 'after';
      if (position === 'after') {
        target.parentNode.insertBefore(container, target.nextSibling);
      } else if (position === 'before') {
        target.parentNode.insertBefore(container, target);
      } else if (position === 'inside-first') {
        target.insertBefore(container, target.firstChild);
      } else if (position === 'inside-last') {
        target.appendChild(container);
      }

      trackAnchor(target, container);
      this.add(container, label || 'ADDED');
      return { ok: true, element: container };
    },

    // Mount a real React element (uses app's global React/ReactDOM/MaterialUI)
    // reactElement: result of React.createElement(...)
    mountReact: function(targetSelector, reactElement, label, position) {
      var target = typeof targetSelector === 'string'
        ? document.querySelector(targetSelector) : targetSelector;
      if (!target) return { ok: false, error: 'Target not found' };
      if (!window.React || !window.ReactDOM) return { ok: false, error: 'React/ReactDOM not available' };

      var container = document.createElement('div');
      container.className = '__fc-preview-element';
      container.style.position = 'relative';

      position = position || 'after';
      if (position === 'after') target.parentNode.insertBefore(container, target.nextSibling);
      else if (position === 'before') target.parentNode.insertBefore(container, target);
      else if (position === 'inside-first') target.insertBefore(container, target.firstChild);
      else if (position === 'inside-last') target.appendChild(container);

      window.ReactDOM.render(reactElement, container);
      trackAnchor(target, container);
      this.add(container, label || 'ADDED');
      return { ok: true, element: container };
    },

    // Mount a React element as a MUI Grid item — copies sibling classes for perfect alignment.
    // PREFERRED over mountReact for cards on detail pages. Avoids the wrapper-div problem
    // where mountReact's extra <div> breaks MUI Grid flex layout.
    // width: 6 = half, 12 = full. anchorSibling: 'last-half' | 'last' | specific element.
    // Anchors to the sibling card (not the container), so orphan observer cleans on tab switch.
    injectGridItem: function(reactElement, label, opts) {
      opts = opts || {};
      if (!window.React || !window.ReactDOM) return { ok: false, error: 'React/ReactDOM not available' };

      var gridContainer = typeof opts.container === 'string'
        ? document.querySelector(opts.container)
        : (opts.container || document.querySelector('.MuiGrid-container.MuiGrid-spacing-xs-2'));
      if (!gridContainer) return { ok: false, error: 'Grid container not found' };

      var items = gridContainer.querySelectorAll(':scope > .MuiGrid-item');
      var width = opts.width || 6;
      var widthClass = 'MuiGrid-grid-md-' + width;

      // Find a sibling with matching width to copy exact classes
      var templateItem = null;
      for (var i = 0; i < items.length; i++) {
        if (items[i].className.indexOf(widthClass) !== -1) {
          templateItem = items[i]; break;
        }
      }

      var gridItemDiv = document.createElement('div');
      if (templateItem) {
        gridItemDiv.className = templateItem.className + ' __fc-preview-element';
      } else {
        gridItemDiv.className = 'MuiGrid-root MuiGrid-item MuiGrid-grid-xs-12 ' + widthClass + ' __fc-preview-element';
      }
      gridItemDiv.style.position = 'relative';

      // Determine insertion point and anchor
      var anchor = null;
      var insertBefore = null;
      if (opts.after) {
        // Insert after a specific element
        anchor = typeof opts.after === 'string' ? document.querySelector(opts.after) : opts.after;
        insertBefore = anchor ? anchor.nextSibling : null;
      } else {
        // Default: insert after last item matching the target width
        for (var j = items.length - 1; j >= 0; j--) {
          if (items[j].className.indexOf(widthClass) !== -1) {
            anchor = items[j]; break;
          }
        }
        insertBefore = anchor ? anchor.nextSibling : null;
      }

      if (insertBefore) {
        gridContainer.insertBefore(gridItemDiv, insertBefore);
      } else {
        gridContainer.appendChild(gridItemDiv);
      }

      window.ReactDOM.render(reactElement, gridItemDiv);
      // Anchor to the sibling card, NOT the container — ensures orphan cleanup on tab switch
      trackAnchor(anchor || gridContainer, gridItemDiv);
      this.add(gridItemDiv, label || 'ADDED');
      return { ok: true, element: gridItemDiv };
    },

    addColumn: function(tableSelector, header, cellValues, position) {
      // Add a column to a MUI table
      // cellValues: array of strings, or a function(rowIndex, row) => string
      var table = typeof tableSelector === 'string'
        ? document.querySelector(tableSelector) : tableSelector;
      if (!table) return { ok: false, error: 'Table not found' };

      var thead = table.querySelector('thead tr');
      if (thead) {
        var th = document.createElement('th');
        th.className = 'MuiTableCell-root MuiTableCell-head __fc-highlight-added __fc-preview-element';
        th.textContent = header;
        if (position != null) {
          thead.insertBefore(th, thead.children[position]);
        } else {
          thead.appendChild(th);
        }
      }

      var rows = table.querySelectorAll('tbody tr');
      rows.forEach(function(row, i) {
        var td = document.createElement('td');
        td.className = 'MuiTableCell-root MuiTableCell-body __fc-highlight-added __fc-preview-element';
        td.textContent = typeof cellValues === 'function'
          ? cellValues(i, row) : (cellValues && cellValues[i]) || '';
        if (position != null) {
          row.insertBefore(td, row.children[position]);
        } else {
          row.appendChild(td);
        }
      });

      this.modify(table, 'MODIFIED: ' + header + ' column added');
      return { ok: true, rowCount: rows.length };
    },

    // ── Batch apply ──
    // opts.showLoading: if true, drops placeholder slots first (returns slotIds)
    // opts.loadingLabel / opts.loadingSubtitle: customize placeholder text
    applyAll: function(ops, opts) {
      opts = opts || {};
      // ops: array of { mode, selector, html, label, position, header, cellValues, colPosition }
      var results = { added: 0, modified: 0, removed: 0, errors: [] };
      var self = this;

      // Sort: REMOVE first (before DOM shifts), then MODIFY, then ADD
      var order = { remove: 0, modify: 1, add: 2 };
      var sorted = ops.slice().sort(function(a, b) {
        return (order[a.mode] || 1) - (order[b.mode] || 1);
      });

      sorted.forEach(function(op) {
        var r;
        switch (op.mode) {
          case 'add':
            r = self.inject(op.selector, op.html, op.label, op.position);
            if (r.ok) results.added++; else results.errors.push(r.error);
            break;
          case 'modify':
            if (op.header) {
              r = self.addColumn(op.selector, op.header, op.cellValues, op.colPosition);
            } else if (op.fn) {
              try { op.fn(); r = { ok: true }; } catch (e) { r = { ok: false, error: e.message }; }
              if (r.ok) self.modify(op.selector, op.label);
            } else {
              r = self.modify(op.selector, op.label);
            }
            if (r && r.ok) results.modified++; else if (r) results.errors.push(r.error);
            break;
          case 'remove':
            r = self.remove(op.selector, op.label);
            if (r.ok) results.removed++; else results.errors.push(r.error);
            break;
        }
      });

      self.showSummary(results.added, results.modified, results.removed);
      return results;
    },

    // ── Summary overlay ──
    showSummary: function(added, modified, removed) {
      var existing = document.getElementById('__fc-change-summary');
      if (existing) existing.remove();

      var counts = { added: added || 0, modified: modified || 0, removed: removed || 0 };
      var colors = { added: '#4caf50', modified: '#ff9800', removed: '#f44336' };
      var total = counts.added + counts.modified + counts.removed;
      if (total === 0) return;

      var panel = document.createElement('div');
      panel.id = '__fc-change-summary';
      panel.style.cssText = 'position:fixed;top:12px;right:12px;z-index:2147483647;background:rgba(0,0,0,0.85);color:#fff;padding:12px 16px;border-radius:8px;font:13px/1.5 sans-serif;max-width:280px;box-shadow:0 4px 16px rgba(0,0,0,0.3)';

      var title = document.createElement('div');
      title.style.cssText = 'font-weight:700;margin-bottom:6px';
      title.textContent = 'Preview Changes';
      panel.appendChild(title);

      Object.keys(counts).forEach(function(key) {
        if (counts[key] === 0) return;
        var row = document.createElement('div');
        row.style.cssText = 'display:flex;align-items:center;gap:6px;margin:3px 0';
        var dot = document.createElement('span');
        dot.style.cssText = 'width:12px;height:12px;border-radius:50%;display:inline-block;background:' + colors[key];
        row.appendChild(dot);
        row.appendChild(document.createTextNode(counts[key] + ' ' + key));
        panel.appendChild(row);
      });

      document.body.appendChild(panel);
    },

    // ── Clear all previews ──
    clear: function() {
      // 1. Unwrap REMOVED elements (must run before __fc-preview-element removal)
      document.querySelectorAll('.__fc-removed-wrapper').forEach(function(wrapper) {
        var child = wrapper.querySelector('.__fc-highlight-removed');
        if (child) {
          child.classList.remove('__fc-highlight-removed');
          child.style.opacity = '';
          wrapper.parentNode.insertBefore(child, wrapper);
        }
        wrapper.remove();
      });
      // 2. Unmount React roots, then remove ADD containers + added column cells
      document.querySelectorAll('.__fc-preview-element').forEach(function(el) {
        try { if (window.ReactDOM) window.ReactDOM.unmountComponentAtNode(el); } catch(e) {}
        el.remove();
      });
      // Clear MODIFY highlights
      document.querySelectorAll('.__fc-highlight-modified').forEach(function(el) {
        el.classList.remove('__fc-highlight-modified');
      });
      document.querySelectorAll('.__fc-highlight-added').forEach(function(el) {
        el.classList.remove('__fc-highlight-added');
      });
      // Remove badges and summary
      document.querySelectorAll('.__fc-change-badge').forEach(function(b) { b.remove(); });
      var summary = document.getElementById('__fc-change-summary');
      if (summary) summary.remove();
      changes = [];
      anchorMap = [];
      return { ok: true };
    },

    // ── State capture for diffing ──
    captureBaseline: function() {
      baseline = window.__fcTest ? window.__fcTest.pageState() : document.title;
      return { ok: true, baseline: typeof baseline };
    },

    // ── Re-render check (SPA resilience) ──
    isAlive: function() {
      return document.querySelectorAll('.__fc-preview-element, .__fc-highlight-added, .__fc-highlight-modified, .__fc-removed-wrapper').length > 0;
    },

    // ── Change log ──
    getChanges: function() { return changes; },

    // ── Diagnostics ──
    getAnchorCount: function() { return anchorMap.length; },

    // ── Style Scraping (MANDATORY before building preview components) ──
    // Extracts actual computed styles from existing page elements in one call.
    // Returns an object used by buildCard/buildTable to produce style-matched output.
    scrapeStyles: function() {
      var result = { ok: true, heading: {}, label: {}, value: {}, card: {}, grid: {} };
      // Card heading
      var cards = document.querySelectorAll('.MuiPaper-root');
      for (var i = 0; i < cards.length; i++) {
        var h = cards[i].querySelector('h1,h2,h3,h4,h5,h6,[class*="MuiTypography-h"]');
        if (h) {
          var hcs = window.getComputedStyle(h);
          result.heading = { tag: h.tagName.toLowerCase(), className: h.className,
            variant: (h.className.match(/MuiTypography-(h\d)/) || [])[1] || 'h3',
            textTransform: hcs.textTransform, fontSize: hcs.fontSize,
            fontWeight: hcs.fontWeight, color: hcs.color };
          break;
        }
      }
      if (!result.heading.tag) result.heading = { tag: 'h3',
        className: 'MuiTypography-root MuiTypography-h3', variant: 'h3',
        textTransform: 'uppercase', fontSize: '48px', fontWeight: '400',
        color: 'rgba(0, 0, 0, 0.87)' };
      // Attribute labels (grey, small font)
      var spans = document.querySelectorAll('span.MuiTypography-root');
      for (var j = 0; j < spans.length; j++) {
        var scs = window.getComputedStyle(spans[j]);
        if (scs.color === 'rgb(153, 153, 153)' || parseFloat(scs.fontSize) <= 13) {
          result.label = { fontSize: scs.fontSize, fontWeight: scs.fontWeight,
            color: scs.color, className: spans[j].className };
          break;
        }
      }
      if (!result.label.fontSize) result.label = { fontSize: '12px', fontWeight: '400',
        color: 'rgb(153, 153, 153)', className: 'MuiTypography-root MuiTypography-body1' };
      // Attribute values (larger font, dark)
      var vals = document.querySelectorAll('.MuiCardContent-root span:not([class*="MuiTypography"])');
      for (var k = 0; k < vals.length; k++) {
        var vcs = window.getComputedStyle(vals[k]);
        if (parseFloat(vcs.fontSize) >= 14) {
          result.value = { fontSize: vcs.fontSize, fontWeight: vcs.fontWeight, color: vcs.color };
          break;
        }
      }
      if (!result.value.fontSize) result.value = { fontSize: '16px', fontWeight: '400',
        color: 'rgba(0, 0, 0, 0.87)' };
      // Card container
      if (cards.length > 0) {
        var ccs = window.getComputedStyle(cards[0]);
        result.card = { elevation: (cards[0].className.match(/MuiPaper-elevation(\d)/) || [])[1] || '1',
          borderRadius: ccs.borderRadius, background: ccs.backgroundColor };
      }
      var cc = document.querySelector('.MuiCardContent-root');
      if (cc) result.card.contentPadding = window.getComputedStyle(cc).padding;
      // Grid spacing
      result.grid.spacing = document.querySelector('.MuiGrid-spacing-xs-2') ? 2
        : document.querySelector('.MuiGrid-spacing-xs-3') ? 3 : 2;
      return result;
    },

    // ── Component Builders (use scraped styles) ──
    // Produce React element trees matching existing page styling exactly.
    // Call scrapeStyles() first, pass result as `styles`. Or omit — builders call it internally.

    buildCard: function(styles, opts) {
      var h = window.React.createElement, MUI = window.MaterialUI;
      if (!h || !MUI) return null;
      var s = styles || this.scrapeStyles();
      var rows = (opts.attributes || []).map(function(attr, i) {
        return h('div', { key: i, style: { display: 'flex', padding: '6px 0',
          borderBottom: '1px solid rgba(0,0,0,0.06)', alignItems: 'baseline' } },
          h('span', { className: s.label.className || '',
            style: { fontSize: s.label.fontSize, fontWeight: s.label.fontWeight,
              color: s.label.color, width: '40%', flexShrink: 0 } }, attr.label),
          attr.chip
            ? h(MUI.Chip, { label: attr.value, size: 'small',
                style: { backgroundColor: attr.chipBg || '#e3f2fd',
                  color: attr.chipColor || '#1565c0', fontWeight: 600, height: 24 } })
            : h('span', { style: { fontSize: s.value.fontSize, fontWeight: s.value.fontWeight,
                color: s.value.color } }, attr.value || '\u2014')
        );
      });
      return h(MUI.Card, { elevation: parseInt(s.card.elevation) || 1, style: { height: '100%' } },
        h(MUI.CardContent, { style: { padding: s.card.contentPadding || '12px 16px' } },
          h(s.heading.tag || 'h3', { className: s.heading.className || 'MuiTypography-root MuiTypography-h3',
            style: { textTransform: s.heading.textTransform || 'uppercase', margin: 0, padding: 0 }
          }, opts.title),
          h('div', { style: { marginTop: 12 } }, rows)
        )
      );
    },

    buildTable: function(styles, opts) {
      var h = window.React.createElement, MUI = window.MaterialUI;
      if (!h || !MUI) return null;
      var s = styles || this.scrapeStyles();
      var headerCells = (opts.columns || []).map(function(col, i) {
        return h(MUI.TableCell, { key: i, style: { fontWeight: 600,
          fontSize: s.label.fontSize || '12px', color: s.label.color || 'rgb(153,153,153)',
          borderBottom: '2px solid #eee' } }, col);
      });
      var bodyRows = (opts.rows || []).map(function(row, ri) {
        return h(MUI.TableRow, { key: ri },
          row.map(function(cell, ci) {
            return h(MUI.TableCell, { key: ci, style: { fontSize: '14px' } },
              typeof cell === 'object' && cell.chip
                ? h(MUI.Chip, { label: cell.label, size: 'small',
                    style: { backgroundColor: cell.bg || '#e8f5e9', color: cell.color || '#2e7d32',
                      fontWeight: 600, fontSize: '0.7rem', height: 24 } })
                : cell
            );
          })
        );
      });
      return h(MUI.Card, { elevation: parseInt(s.card.elevation) || 1 },
        h(MUI.CardContent, { style: { padding: s.card.contentPadding || '12px 16px' } },
          h(s.heading.tag || 'h3', { className: s.heading.className || 'MuiTypography-root MuiTypography-h3',
            style: { textTransform: s.heading.textTransform || 'uppercase', margin: 0, padding: 0 }
          }, opts.title),
          h(MUI.Table, { size: 'small', style: { marginTop: 12 } },
            h(MUI.TableHead, null, h(MUI.TableRow, null, headerCells)),
            h(MUI.TableBody, null, bodyRows)
          )
        )
      );
    },

    // Compose multiple components into a Grid layout
    buildGrid: function(styles, items) {
      var h = window.React.createElement, MUI = window.MaterialUI;
      if (!h || !MUI) return null;
      var s = styles || this.scrapeStyles();
      var gridItems = items.map(function(item, i) {
        return h(MUI.Grid, { key: i, item: true, xs: 12, md: item.width || 12 }, item.element);
      });
      return h(MUI.Grid, { container: true, spacing: s.grid.spacing || 2 }, gridItems);
    },

    // ── Version ──
    version: '2.1.0'
  };
})();
```

---

## Usage Patterns

### Pattern 1: Scrape + Build + Inject Grid Item (DEFAULT — 5 MCP calls)

The recommended flow for adding cards to detail pages. Uses `injectGridItem` which copies sibling
grid item classes for perfect alignment and anchors to a sibling card for tab-switch cleanup.

**NEVER use `mountReact` with `inside-last` on a grid container** — the wrapper div breaks MUI Grid
flex layout and the card renders at wrong width. Always use `injectGridItem` for grid contexts.

```
1. navigate_page(url, initScript=__fcTest_LIBRARY + __fcPreview_LIBRARY)
2. evaluate_script: JSON.stringify(window.__fcTest.batch([
     { action: 'pageSummary' },
     { action: 'scanComponents' }
   ]))
3. evaluate_script:
     var styles = window.__fcPreview.scrapeStyles();
     var card = window.__fcPreview.buildCard(styles, {
       title: 'New Card Title',        // ← from plan/spec, never hardcoded
       attributes: [
         { label: 'Reference', value: 'REF-001' },
         { label: 'Status', value: 'PENDING', chip: true, chipBg: '#fff3e0', chipColor: '#e65100' },
         { label: 'Amount', value: '$45.00' }
       ]
     });
     JSON.stringify(window.__fcPreview.injectGridItem(card, 'ADDED: New Card Title', { width: 6 }))
4. take_screenshot
```

`injectGridItem` options: `{ width: 6|12, container: selector, after: selector|element }`

### Pattern 1b: Multi-Component Grid (6 MCP calls)

```
1. navigate_page + initScript
2. pageSummary + scanComponents
3. evaluate_script:
     var s = window.__fcPreview.scrapeStyles();
     var grid = window.__fcPreview.buildGrid(s, [
       { width: 6, element: window.__fcPreview.buildCard(s, { title: 'Summary', attributes: [...] }) },
       { width: 6, element: window.__fcPreview.buildCard(s, { title: 'Details', attributes: [...] }) },
       { width: 12, element: window.__fcPreview.buildTable(s, { title: 'Items', columns: [...], rows: [...] }) }
     ]);
     JSON.stringify(window.__fcPreview.mountReact('.MuiGrid-container', grid, 'ADDED: Section'))
4. take_screenshot
```

### Pattern 1c (Legacy): HTML Batch Preview (4 MCP calls)

```
1. navigate_page(url, initScript=__fcTest_LIBRARY + __fcPreview_LIBRARY)
2. evaluate_script: JSON.stringify(window.__fcTest.batch([
     { action: 'pageSummary' },
     { action: 'scanComponents' }
   ]))
3. evaluate_script: JSON.stringify(window.__fcPreview.applyAll([
     { mode: 'add', selector: '.MuiCard-root:nth-of-type(2)', html: '<div>...</div>', label: 'ADDED: New Card' },
     { mode: 'modify', selector: '.MuiTable-root', header: 'Priority', cellValues: ['High','Low','Med'] },
     { mode: 'remove', selector: '[data-testid="shipping-chart"]', label: 'REMOVED: Shipping Chart' }
   ]))
4. take_screenshot
```

### Pattern 2: Quick Column Add (3 MCP calls)

```
1. navigate_page(url, initScript=LIBRARIES)
2. evaluate_script: JSON.stringify(window.__fcPreview.addColumn(
     '.MuiTable-root', 'Priority', ['High','Low','Med','High','Low'], 2
   ))
3. take_screenshot
```

### Pattern 3: Iteration (2 MCP calls per round)

```
1. evaluate_script: window.__fcPreview.clear(); JSON.stringify(window.__fcPreview.applyAll(NEW_OPS))
2. take_screenshot
```

### Pattern 4: Inject Card After Existing Card (3 MCP calls)

```
1. navigate_page(url, initScript=LIBRARIES)
2. evaluate_script: JSON.stringify(window.__fcPreview.inject(
     window.__fcTest.findCard('Order Details'),   // returns the element directly
     CARD_HTML,
     'ADDED: New Card',
     'after'
   ))
3. take_screenshot
```

### Pattern 5: Loading Skeleton → Fill (6 MCP calls, best UX)

Show an MUI-styled skeleton card first so the user sees immediate visual feedback, then replace with the actual component. The skeleton has shimmer-animated bars and a blue badge.

```
1. navigate_page(url, initScript=LIBRARIES)
2. evaluate_script: pageSummary + scanComponents
3. evaluate_script: JSON.stringify(window.__fcPreview.prepareSlot(
     '.MuiCard-root:nth-of-type(2)',
     { label: 'BUILDING…', rows: 4 }
   ))
   → User sees MUI skeleton card with shimmer immediately — { ok: true, slotId: '__fc-slot-1' }
4. take_screenshot (optional — captures the loading state)
5. evaluate_script: JSON.stringify(window.__fcPreview.fillSlot(
     '__fc-slot-1', CARD_HTML, 'ADDED: New Card'
   ))
   → Skeleton replaced with actual component + green highlight
6. take_screenshot (final)
```

### Pattern 6: Multiple Loading Skeletons → Batch Fill

```
1. evaluate_script: JSON.stringify([
     window.__fcPreview.prepareSlot('.target-1', { label: 'CARD A…' }),
     window.__fcPreview.prepareSlot('.target-2', { label: 'CARD B…', rows: 2 })
   ])
   → Two skeleton cards appear simultaneously
2. evaluate_script: JSON.stringify([
     window.__fcPreview.fillSlot('__fc-slot-1', HTML_A, 'ADDED: Card A'),
     window.__fcPreview.fillSlot('__fc-slot-2', HTML_B, 'ADDED: Card B')
   ])
3. take_screenshot
```

### Pattern 7: Custom React Component (fallback when buildCard/buildTable don't fit)

For components not covered by the built-in builders (e.g., custom layouts, nested accordions, dialogs), use `React.createElement` directly. **Always use `scrapeStyles()` first** to get the correct variants — never hardcode them.

**CRITICAL: Match existing styles.** Before building, call `scrapeStyles()` or inspect neighbouring elements via `scanComponents()` / `take_snapshot` to determine the correct MUI `variant`, `elevation`, spacing, and heading level. Common Mystique patterns observed on live OMS:

| Element | Typical MUI variant | CSS applied by theme |
|---------|-------------------|---------------------|
| Card header (detail page) | `Typography variant="h3"` | `text-transform: uppercase` via jss class |
| Card header (list/modal) | `Typography variant="h5"` or `h6` | Normal case |
| Field label | `Typography variant="caption" color="textSecondary"` | 12px, rgba(0,0,0,0.54) |
| Field value | `Typography variant="body2"` | 14px |
| Card container | `Card elevation={1}` | Standard MUI shadow |
| Grid layout | `Grid container spacing={2}`, items `xs={6}` for half-width | 16px gaps |

**Do NOT assume** — always check the actual page. Different pages may use different variants.

```
1. navigate_page(url, initScript=LIBRARIES)
2. evaluate_script: pageSummary + scanComponents
   → Note heading variants, card count, grid structure
3. evaluate_script:
     // Inspect existing card header to match style:
     var existingH3 = document.querySelector('h3.MuiTypography-root');
     var headerVariant = existingH3 ? 'h3' : 'h5';  // match what's on the page
     var h = React.createElement, MUI = MaterialUI;
     var card = h(MUI.Card, { elevation: 1 },
       h(MUI.CardHeader, {
         title: h(MUI.Typography, { variant: headerVariant,
           style: { textTransform: 'uppercase', letterSpacing: '0.5px' }
         }, 'CARD TITLE')
       }),
       h(MUI.CardContent, null,
         h(MUI.Grid, { container: true, spacing: 2 },
           h(MUI.Grid, { item: true, xs: 6 },
             h(MUI.Typography, { variant: 'caption', color: 'textSecondary' }, 'Label'),
             h(MUI.Typography, { variant: 'body2' }, 'Value')
           ),
           h(MUI.Grid, { item: true, xs: 6 },
             h(MUI.Typography, { variant: 'caption', color: 'textSecondary' }, 'Status'),
             h(MUI.Chip, { label: 'ACTIVE', size: 'small', color: 'primary', variant: 'outlined' })
           )
         )
       )
     );
     JSON.stringify(window.__fcPreview.mountReact(targetCard, card, 'ADDED: Card Title'))
4. take_screenshot
```

### Pattern 8: Loading Skeleton → React Fill (best UX + best fidelity)

```
1. evaluate_script: JSON.stringify(window.__fcPreview.prepareSlot('.target', { label: 'BUILDING…' }))
   → User sees shimmer skeleton immediately
2. evaluate_script:
     var h = React.createElement, MUI = MaterialUI;
     var component = h(MUI.Card, { elevation: 1 }, /* ... */);
     JSON.stringify(window.__fcPreview.fillSlotReact('__fc-slot-1', component, 'ADDED: My Card'))
   → Skeleton replaced with real MUI component
3. take_screenshot
```

**Available globals (webpack externals):**

| Global | Version | Key exports |
|--------|---------|-------------|
| `React` | 17.0.2 | `createElement`, Fragment (hooks won't work in standalone islands) |
| `ReactDOM` | 17.0.2 | `render`, `unmountComponentAtNode`, `createPortal` |
| `MaterialUI` | 4.12.x | Card, CardHeader, CardContent, CardActions, Typography, Grid, Chip, Button, Table, TableHead, TableBody, TableRow, TableCell, Avatar, Badge, Accordion, Dialog, Tabs, Tab, TextField, Select, MenuItem, Tooltip, IconButton, Divider, Box, Paper, List, ListItem, ListItemText, etc. |

**When to use React vs HTML:** Prefer `mountReact`/`fillSlotReact` — the output inherits the app's MUI theme (colors, spacing, typography) and is indistinguishable from real components. Fall back to `inject`/`fillSlot` (HTML strings) only when building simple indicators or when the AI generates HTML templates from MUI Component HTML Templates below.

**Hooks limitation:** `useState`, `useEffect`, etc. won't work in standalone `ReactDOM.render()` islands because they're not inside the app's React fiber tree. For preview purposes, static rendering is sufficient — the goal is visual fidelity, not interactivity.

**Tab-awareness (v1.4.0):** All `inject()`, `mountReact()`, and `prepareSlot()` calls track the anchor element. A `MutationObserver` watches the DOM — when React removes the anchor (e.g., switching tabs), the associated preview element is automatically cleaned up. No manual handling needed. If you switch back to the original tab and React re-mounts the content, call the inject/mount again to re-add.

**Navigation:** Never hardcode OMS URLs. Mystique routes are hash-based (`#/orders/{id}/{retailerId}/{ref}`) and defined in manifest fragments — custom code can change paths. Always discover URLs from the DOM (snapshot link hrefs, accessibility tree) or navigate through UI clicks.

Note: `__fcPreview.inject()` and `mountReact()` accept both CSS selector strings and DOM elements. Use `__fcTest.findCard(title)` to locate MUI cards by header text — standard `querySelector` does NOT support `:contains()`.

---

## MUI Component HTML Templates

Quick-reference HTML for fallback when React rendering is unavailable. **Prefer `mountReact` (Pattern 7)** — these are fallback only.

**IMPORTANT:** Before using any template, inspect the existing page to determine the correct heading variant. Detail pages typically use `MuiTypography-h3` with `text-transform: uppercase`; list/modal pages may use `MuiTypography-h5`. **Never assume — always check.**

### Card with attributes (fc.card.attribute)

```html
<!-- HEADING: inspect existing cards — use MuiTypography-h3 for detail pages, h5 for modals -->
<div class="MuiPaper-root MuiCard-root MuiPaper-elevation1 MuiPaper-rounded" style="margin:16px 0">
  <div class="MuiCardHeader-root" style="padding:16px">
    <div class="MuiCardHeader-content">
      <h3 class="MuiTypography-root MuiCardHeader-title MuiTypography-h3" style="text-transform:uppercase">CARD_TITLE</h3>
    </div>
  </div>
  <div class="MuiCardContent-root" style="padding:0 16px 16px">
    <div style="display:grid;grid-template-columns:repeat(COLS,1fr);gap:12px">
      <!-- Repeat per attribute -->
      <div>
        <div style="color:rgba(0,0,0,0.54);font-size:12px;margin-bottom:4px">LABEL</div>
        <div style="font-size:14px">VALUE</div>
      </div>
    </div>
  </div>
</div>
```

### Table row (fc.list additional column)

```html
<!-- th for header -->
<th class="MuiTableCell-root MuiTableCell-head" style="font-weight:600">HEADER</th>
<!-- td for each body row -->
<td class="MuiTableCell-root MuiTableCell-body">VALUE</td>
```

### Status badge

```html
<span style="display:inline-block;padding:2px 12px;border-radius:12px;font-size:12px;font-weight:600;
  background:BGCOLOR;color:TEXTCOLOR">STATUS_TEXT</span>
```

Status colors: `CREATED=#e3f2fd/#1565c0`, `ACTIVE/#4caf50`, `COMPLETE=#e8f5e9/#2e7d32`, `CANCELLED=#ffebee/#c62828`, `PENDING=#fff3e0/#e65100`

### Tabs (fc.tabs.card)

```html
<div class="MuiPaper-root MuiCard-root MuiPaper-elevation1" style="margin:16px 0">
  <div class="MuiTabs-root" style="border-bottom:1px solid #e0e0e0">
    <div class="MuiTabs-flexContainer" role="tablist">
      <button class="MuiTab-root MuiTab-textColorPrimary Mui-selected" style="min-width:120px;padding:12px 16px;font-weight:600;color:#1976d2;border-bottom:2px solid #1976d2">TAB_1</button>
      <button class="MuiTab-root MuiTab-textColorPrimary" style="min-width:120px;padding:12px 16px;color:rgba(0,0,0,0.54)">TAB_2</button>
    </div>
  </div>
  <div role="tabpanel" style="padding:16px">TAB_CONTENT</div>
</div>
```
