[object Object]

← back to Wallco Ai

PDP: universal theme customizer — render-a-room + free drag-rearrange + save, on every theme incl monolith

fe1224453c43b91c65ce6652f5f7e6f68438eeb7 · 2026-06-02 09:08:02 -0700 · Steve

Files touched

Diff

commit fe1224453c43b91c65ce6652f5f7e6f68438eeb7
Author: Steve <steve@designerwallcoverings.com>
Date:   Tue Jun 2 09:08:02 2026 -0700

    PDP: universal theme customizer — render-a-room + free drag-rearrange + save, on every theme incl monolith
---
 public/theme-variants/theme-section-rearrange.js | 544 ++++++++++++++++-------
 scripts/smoke-test-customizer.js                 | 138 ++++++
 2 files changed, 509 insertions(+), 173 deletions(-)

diff --git a/public/theme-variants/theme-section-rearrange.js b/public/theme-variants/theme-section-rearrange.js
index 748a2e4..18d3b5a 100644
--- a/public/theme-variants/theme-section-rearrange.js
+++ b/public/theme-variants/theme-section-rearrange.js
@@ -1,233 +1,431 @@
-/* theme-section-rearrange.js — admin-only drag-to-reorder for PDP sections.
+/* theme-section-rearrange.js — universal PDP THEME CUSTOMIZER (user-facing).
  *
- * Drop-in (ABSOLUTE path):
+ * Drop-in (ABSOLUTE path), already referenced by all 17 theme variants and
+ * injected into the classic monolith PDP via the /design/:id middleware:
  *   <script src="/theme-variants/theme-section-rearrange.js" defer></script>
- * Admin gate: active ONLY when localStorage.dwAdmin === '1' (set via ?admin=1).
  *
- * Makes each PDP section moveable + rearrangeable: any column that holds ≥2
- * recognised sections becomes a drag zone; grab a section's ⠿ handle and drop it
- * to reorder. Order persists per THEME (slug) in localStorage and is re-applied
- * after the PDP re-renders (#root rebuilds, e.g. the v11 Wallpaper⇄Mural toggle).
- * A small "Layout" pill (bottom-left) resets the order. Idempotent · zero deps.
+ * What it gives EVERY theme (the 17 variants + the classic monolith):
+ *   1. 🛋  Render a new room      — POSTs /api/room (the existing, auth-gated,
+ *         $/call renderer), rotates the room type, injects the fresh render into
+ *         the page's room area, and remembers it per design so it persists.
+ *   2. ✎  Customize layout       — edit mode: every recognised module gets a
+ *         drag handle. Drag it ANYWHERE on screen (free 2-D position) OR drop it
+ *         over a sibling module to reorder / relocate across columns.
+ *   3. 💾  Save                   — persists the arrangement (order + free
+ *         offsets) + the rendered rooms to localStorage, per THEME (slug).
+ *   4. ↺  Reset                  — clears this theme's saved layout.
+ *
+ * User-facing (NOT admin-gated). Per-visitor (localStorage). Re-applies after a
+ * re-render (#root rebuilds, e.g. the v11 Wallpaper⇄Mural toggle). Idempotent ·
+ * zero deps · pointer-events (mouse + touch).
  */
 (function () {
   'use strict';
-  if (window.__themeSectionRearrangeLoaded) return;
-  window.__themeSectionRearrangeLoaded = true;
+  if (window.__themeCustomizerLoaded) return;
+  window.__themeCustomizerLoaded = true;
 
-  // admin gate
+  // ?admin=1 still sets the admin flag for OTHER tools, but we no longer gate on it.
   try {
     var q = new URLSearchParams(location.search);
     if (q.get('admin') === '1') localStorage.setItem('dwAdmin', '1');
     if (q.get('admin') === '0') localStorage.removeItem('dwAdmin');
-    if (localStorage.getItem('dwAdmin') !== '1') return;
-  } catch (e) { return; }
+  } catch (e) { /* ignore */ }
+
+  // ---- identity: theme slug (persistence key) + design id (room render) ----
+  // slug = theme name for persistence. /design/:id/<slug> → that slug; the bare
+  // /design/:id monolith has no slug segment → 'classic' (so the layout is keyed
+  // PER-THEME, not per design id).
+  var slugMatch = location.pathname.match(/^\/design\/[^/]+\/([^/?#]+)/);
+  var SLUG = slugMatch ? slugMatch[1] : 'classic';
+  var idMatch = location.pathname.match(/\/design\/(\d+)/);
+  var DESIGN_ID = idMatch ? idMatch[1] : null;
+  var LKEY = 'wallco:layout:' + SLUG;                 // {order:{zoneKey:[keys]}, off:{modKey:{x,y}}}
+  var RKEY = 'wallco:rooms:' + (DESIGN_ID || SLUG);   // [{url,room}]
 
-  // theme slug (for the persistence key) — layout is per-theme, not per-design
-  var rm = location.pathname.match(/^\/design\/[^/]+\/([^/?#]+)/);
-  var SLUG = rm ? rm[1] : location.pathname.split('/').pop().replace(/\.html?$/i, '').replace(/^v\d+-/, '') || 'pdp';
-  var KEY = 'wallco:secorder:' + SLUG;
+  var ROOM_TYPES = ['living_room', 'bedroom', 'office', 'dining_room'];
 
-  // recurring section/block classnames across the 17 variant families
-  var SECTION_SEL = [
-    '.cta-row', '.preview-row', '.preview', '.tab-bar', '.tabs', '.fmt-tabs',
+  // ---- which elements count as draggable "modules" (union over 18 layouts) ----
+  var MOD_SEL = [
+    // variant families
+    '.cta-row', '.preview-row', '.preview', '.roomprev', '.tab-bar', '.tabs', '.fmt-tabs',
     '.calc', '.calculator', '.specs', '.spec-table', '.spectab',
-    '.room', '.room-main', '.room-thumbs', '.room-cap', '.roomprev',
-    '.atscale', '.info', '.meta', '.palette', '.color-dots',
-    '.card', '.acc', '.cell',  // stack cards / accordion blocks / bento cells
+    '.room', '.room-main', '.room-thumbs', '.room-cap', '.atscale',
+    '.info', '.meta', '.palette', '.color-dots', '.card', '.acc', '.cell',
+    // classic monolith in-flow blocks
+    'details.collapse-pane', '.tool-group', '.room-toggle', '.detail-image-col',
+    'section', 'figure',
   ].join(',');
+  // never treat site chrome / our own UI / drawer headers as modules
+  var EXCLUDE_ANCESTOR = '#site-header, header.site-header, nav, footer, .site-footer, ' +
+    '.tsr-dock, .tsr-toast, .tsr-room-results, .buyer-sheet-head, .admin-sheet, .edit-modal, .hb-submenu, #page-theme-toggle';
 
   var DRAG_CLASS = 'tsr-dragging';
-  var observer = null, applying = false, dragEl = null;
+  var editing = false, observer = null, applying = false;
+  var pending = { order: null, off: null };  // staged from live DOM, written on 💾 Save
 
-  function load() { try { return JSON.parse(localStorage.getItem(KEY)) || {}; } catch (e) { return {}; } }
-  function save(o) { try { localStorage.setItem(KEY, JSON.stringify(o)); } catch (e) {} }
+  // ---------- storage ----------
+  function loadLayout() { try { return JSON.parse(localStorage.getItem(LKEY)) || {}; } catch (e) { return {}; } }
+  function saveLayout(o) { try { localStorage.setItem(LKEY, JSON.stringify(o)); } catch (e) {} }
+  function loadRooms() { try { return JSON.parse(localStorage.getItem(RKEY)) || []; } catch (e) { return []; } }
+  function saveRooms(a) { try { localStorage.setItem(RKEY, JSON.stringify(a)); } catch (e) {} }
+
+  // ---------- discovery ----------
+  function containers() {
+    var c = [];
+    ['root', 'detail-main'].forEach(function (id) { var e = document.getElementById(id); if (e) c.push(e); });
+    if (!c.length) { var m = document.querySelector('main'); if (m) c.push(m); }
+    if (!c.length) c.push(document.body);
+    return c;
+  }
+  function isModule(el) {
+    if (!el || el.nodeType !== 1 || !el.matches) return false;
+    if (!el.matches(MOD_SEL)) return false;
+    if (el.closest(EXCLUDE_ANCESTOR)) return false;
+    if (el.classList.contains('tsr-handle')) return false;
+    var r = el.getBoundingClientRect();
+    if (r.width < 40 || r.height < 24) return false;       // ignore slivers
+    return true;
+  }
+  // top-level modules only (a module not nested inside another module)
+  function topModules() {
+    var out = [], seen = new Set();
+    containers().forEach(function (root) {
+      Array.prototype.slice.call(root.querySelectorAll(MOD_SEL)).forEach(function (el) {
+        if (seen.has(el) || !isModule(el)) return;
+        var p = el.parentElement, nested = false;
+        while (p && p !== root) {
+          if (p.matches && p.matches(MOD_SEL) && !p.closest(EXCLUDE_ANCESTOR)) { nested = true; break; }
+          p = p.parentElement;
+        }
+        if (nested) return;
+        seen.add(el); out.push(el);
+      });
+    });
+    return out;
+  }
 
-  // stable-ish key for an element from id or sorted class tokens (+ dedupe index)
+  // ---------- stable keys ----------
   function rawKey(el) {
     if (el.id) return '#' + el.id;
-    var c = el.getAttribute && el.getAttribute('class') || '';
+    var c = (el.getAttribute && el.getAttribute('class')) || '';
     c = String(c).replace(/\btsr-[\w-]+/g, '').trim().split(/\s+/).filter(Boolean).sort().join('.');
-    return c ? '.' + c : el.tagName.toLowerCase();
+    return (c ? '.' + c : el.tagName.toLowerCase());
   }
-  function keysFor(children) {
+  function dedupeKeys(els) {
     var seen = {}, out = [];
-    for (var i = 0; i < children.length; i++) {
-      var k = rawKey(children[i]);
-      seen[k] = (seen[k] || 0) + 1;
+    for (var i = 0; i < els.length; i++) {
+      var k = rawKey(els[i]); seen[k] = (seen[k] || 0) + 1;
       out.push(seen[k] > 1 ? k + '~' + seen[k] : k);
     }
     return out;
   }
+  function modKey(el) {
+    var p = el.parentElement;
+    var sibs = p ? Array.prototype.slice.call(p.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); }) : [el];
+    var idx = sibs.indexOf(el);
+    var keys = dedupeKeys(sibs);
+    return rawKey(p || document.body) + '>' + (keys[idx] || rawKey(el));
+  }
 
-  // a zone = element with >=2 DIRECT children matching SECTION_SEL
-  function findZones() {
-    var matches = Array.prototype.slice.call(document.querySelectorAll('#root ' + SECTION_SEL.replace(/,/g, ', #root ')));
+  // ---------- zones (parents with >=2 top-level modules) for reorder ----------
+  function zones() {
     var byParent = new Map();
-    matches.forEach(function (el) {
-      var p = el.parentElement; if (!p) return;
-      if (!byParent.has(p)) byParent.set(p, 0);
-      byParent.set(p, byParent.get(p) + 1);
-    });
-    var zones = [];
-    byParent.forEach(function (count, p) {
-      // count only DIRECT-child matches
-      var direct = 0, kids = p.children;
-      for (var i = 0; i < kids.length; i++) if (kids[i].matches && kids[i].matches(SECTION_SEL)) direct++;
-      if (direct >= 2) zones.push(p);
-    });
-    return zones;
+    topModules().forEach(function (m) { var p = m.parentElement; if (!p) return; if (!byParent.has(p)) byParent.set(p, 0); byParent.set(p, byParent.get(p) + 1); });
+    var z = []; byParent.forEach(function (n, p) { if (n >= 2) z.push(p); });
+    return z;
   }
-
   function zoneKey(zone, i) { return rawKey(zone) + '#z' + i; }
 
-  function makeHandle() {
-    var h = document.createElement('div');
-    h.className = 'tsr-handle';
-    h.setAttribute('draggable', 'true');
-    h.setAttribute('title', 'Drag to reorder section');
-    h.innerHTML = '⠿';
-    return h;
-  }
-
-  function applyOrder(zone, zk) {
-    var store = load();
-    var order = store[zk];
-    if (!order || !order.length) return;
-    var kids = Array.prototype.slice.call(zone.children);
-    var keyed = kids.map(function (el, i) { return { el: el, k: keysForOne(kids, i) }; });
-    keyed.sort(function (a, b) {
-      var ia = order.indexOf(a.k), ib = order.indexOf(b.k);
-      if (ia === -1 && ib === -1) return 0;
-      if (ia === -1) return 1; if (ib === -1) return -1;
-      return ia - ib;
-    });
-    keyed.forEach(function (o) { zone.appendChild(o.el); });
-  }
-  // key for child index i within its sibling set (matches keysFor dedupe)
-  function keysForOne(children, i) { return keysFor(children)[i]; }
-
-  function persist(zone, zk) {
-    var store = load();
-    store[zk] = keysFor(Array.prototype.slice.call(zone.children));
-    save(store);
-  }
-
-  function wireZone(zone, i) {
-    var zk = zoneKey(zone, i);
-    if (zone.__tsrWired === zk) { applyOrder(zone, zk); return; }
-    zone.__tsrWired = zk;
-    zone.classList.add('tsr-zone');
-
-    // apply saved order first
-    applyOrder(zone, zk);
-
-    // add handles to each direct child section
-    Array.prototype.slice.call(zone.children).forEach(function (sec) {
-      if (sec.classList.contains('tsr-handle')) return;
-      if (sec.querySelector && sec.querySelector(':scope > .tsr-handle')) return;
-      var cs = getComputedStyle(sec);
-      if (cs.position === 'static') sec.style.position = 'relative';
-      sec.classList.add('tsr-sec');
-      var h = makeHandle();
-      sec.appendChild(h);
-      h.addEventListener('dragstart', function (e) {
-        dragEl = sec; sec.classList.add(DRAG_CLASS);
-        try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', ''); } catch (x) {}
+  // ---------- apply saved layout ----------
+  function applySaved() {
+    var L = loadLayout();
+    if (L.order) {
+      zones().forEach(function (zone, i) {
+        var order = L.order[zoneKey(zone, i)];
+        if (!order || !order.length) return;
+        var kids = Array.prototype.slice.call(zone.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); });
+        var keys = dedupeKeys(kids);
+        var paired = kids.map(function (el, j) { return { el: el, k: keys[j] }; });
+        paired.sort(function (a, b) {
+          var ia = order.indexOf(a.k), ib = order.indexOf(b.k);
+          if (ia === -1 && ib === -1) return 0; if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib;
+        });
+        paired.forEach(function (o) { zone.appendChild(o.el); });
       });
-      h.addEventListener('dragend', function () {
-        if (dragEl) dragEl.classList.remove(DRAG_CLASS);
-        dragEl = null;
-        persist(zone, zk);
+    }
+    if (L.off) {
+      topModules().forEach(function (m) {
+        var o = L.off[modKey(m)];
+        if (o) { m.style.transform = 'translate(' + o.x + 'px,' + o.y + 'px)'; m.dataset.tsrOff = o.x + ',' + o.y; }
       });
-    });
+    }
+  }
 
-    zone.addEventListener('dragover', function (e) {
-      if (!dragEl || dragEl.parentElement !== zone) return;
-      e.preventDefault();
-      var after = siblingAfter(zone, e.clientY);
-      if (after == null) zone.appendChild(dragEl);
-      else zone.insertBefore(dragEl, after);
-    });
+  // ---------- handles + drag (pointer events: mouse + touch) ----------
+  function ensureHandle(m) {
+    if (m.querySelector(':scope > .tsr-handle')) return;
+    if (getComputedStyle(m).position === 'static') m.style.position = 'relative';
+    m.classList.add('tsr-sec');
+    var h = document.createElement('div');
+    h.className = 'tsr-handle'; h.title = 'Drag to move or reorder'; h.innerHTML = '⠿';
+    m.appendChild(h);
+    wireDrag(h, m);
   }
 
-  function siblingAfter(zone, y) {
-    var els = Array.prototype.slice.call(zone.children).filter(function (c) {
-      return c !== dragEl && !c.classList.contains('tsr-handle');
+  var drag = null, hintEl = null;
+  function wireDrag(handle, m) {
+    handle.addEventListener('pointerdown', function (e) {
+      if (!editing) return;
+      e.preventDefault(); e.stopPropagation();
+      var off = (m.dataset.tsrOff || '0,0').split(',').map(Number);
+      drag = { m: m, sx: e.clientX, sy: e.clientY, bx: off[0] || 0, by: off[1] || 0, moved: false };
+      m.classList.add(DRAG_CLASS);
+      try { handle.setPointerCapture(e.pointerId); } catch (x) {}
     });
+    handle.addEventListener('pointermove', function (e) {
+      if (!drag) return;
+      var dx = e.clientX - drag.sx, dy = e.clientY - drag.sy;
+      if (!drag.moved && Math.abs(dx) + Math.abs(dy) < 4) return;
+      drag.moved = true;
+      drag.m.style.transform = 'translate(' + (drag.bx + dx) + 'px,' + (drag.by + dy) + 'px)';
+      reorderHint(e.clientX, e.clientY);
+    });
+    function end(e) {
+      if (!drag) return;
+      var m2 = drag.m; m2.classList.remove(DRAG_CLASS); clearHint();
+      if (drag.moved) {
+        if (tryReorder(e.clientX, e.clientY, m2)) {
+          m2.style.transform = ''; delete m2.dataset.tsrOff;   // reorder wins → drop free offset
+          stageOrder();
+        } else {
+          var nx = drag.bx + (e.clientX - drag.sx), ny = drag.by + (e.clientY - drag.sy);
+          m2.dataset.tsrOff = nx + ',' + ny;
+          m2.style.transform = 'translate(' + nx + 'px,' + ny + 'px)';
+          stageOffsets();
+        }
+        toast('Layout changed — press 💾 Save to keep it');
+      }
+      drag = null;
+    }
+    handle.addEventListener('pointerup', end);
+    handle.addEventListener('pointercancel', end);
+  }
+  function reorderHint(x, y) {
+    clearHint();
+    var t = moduleUnder(x, y, drag.m); if (!t) return;
+    var r = t.getBoundingClientRect(), before = y < r.top + r.height / 2;
+    hintEl = document.createElement('div'); hintEl.className = 'tsr-hint';
+    document.body.appendChild(hintEl);
+    hintEl.style.left = r.left + 'px'; hintEl.style.width = r.width + 'px';
+    hintEl.style.top = (before ? r.top - 2 : r.bottom) + 'px';
+  }
+  function clearHint() { if (hintEl) { hintEl.remove(); hintEl = null; } }
+  function moduleUnder(x, y, exclude) {
+    var els = document.elementsFromPoint(x, y);
     for (var i = 0; i < els.length; i++) {
-      var r = els[i].getBoundingClientRect();
-      if (y < r.top + r.height / 2) return els[i];
+      var el = els[i];
+      if (el === exclude || el.classList.contains('tsr-handle')) continue;
+      var mod = el.closest && el.closest(MOD_SEL);
+      if (mod && mod !== exclude && isModule(mod) && !mod.contains(exclude) && !exclude.contains(mod)) return mod;
     }
     return null;
   }
+  function tryReorder(x, y, m) {
+    var t = moduleUnder(x, y, m);
+    if (!t || !t.parentElement) return false;
+    var r = t.getBoundingClientRect();
+    if (y < r.top + r.height / 2) t.parentElement.insertBefore(m, t);
+    else t.parentElement.insertBefore(m, t.nextSibling);
+    return true;
+  }
 
-  function setup() {
-    if (applying) return;
-    applying = true;
-    if (observer) observer.disconnect();
-    try {
-      var zones = findZones();
-      zones.forEach(wireZone);
-    } finally {
-      applying = false;
-      if (observer) observer.observe(document.getElementById('root') || document.body, { childList: true, subtree: true });
-    }
+  // ---------- stage live DOM → pending; commit on 💾 ----------
+  function stageOrder() {
+    var o = {}; zones().forEach(function (zone, i) {
+      var kids = Array.prototype.slice.call(zone.children).filter(function (x) { return x.matches && x.matches(MOD_SEL); });
+      o[zoneKey(zone, i)] = dedupeKeys(kids);
+    });
+    pending.order = o;
+  }
+  function stageOffsets() {
+    var o = {}; topModules().forEach(function (m) {
+      if (m.dataset.tsrOff) { var p = m.dataset.tsrOff.split(',').map(Number); o[modKey(m)] = { x: p[0], y: p[1] }; }
+    });
+    pending.off = o;
+  }
+  function commitSave() {
+    stageOrder(); stageOffsets();
+    var L = loadLayout();
+    L.order = pending.order; L.off = pending.off;
+    saveLayout(L);
+    toast('✓ Layout saved for this theme');
+  }
+  function resetLayout() {
+    try { localStorage.removeItem(LKEY); } catch (e) {}
+    toast('Layout reset'); setTimeout(function () { location.reload(); }, 400);
   }
 
-  // ---- styles + reset pill ----
+  // ---------- render a new room ----------
+  var roomBusy = false;
+  function nextRoomType() {
+    var used = loadRooms().map(function (r) { return r.room; });
+    for (var i = 0; i < ROOM_TYPES.length; i++) if (used.indexOf(ROOM_TYPES[i]) === -1) return ROOM_TYPES[i];
+    return ROOM_TYPES[loadRooms().length % ROOM_TYPES.length];
+  }
+  function roomAnchor() {
+    var sel = '.room, .room-main, .roomprev, .room-toggle, [class*="room"]';
+    var cands = [];
+    containers().forEach(function (root) { cands = cands.concat(Array.prototype.slice.call(root.querySelectorAll(sel))); });
+    cands = cands.filter(function (e) { return !e.closest(EXCLUDE_ANCESTOR); });
+    return cands.length ? cands[0] : (containers()[0] || document.body);
+  }
+  function resultsBox() {
+    var box = document.getElementById('tsr-room-results');
+    if (box) return box;
+    box = document.createElement('div');
+    box.id = 'tsr-room-results'; box.className = 'tsr-room-results';
+    box.innerHTML = '<div class="tsr-rr-h">Your rendered rooms</div><div class="tsr-rr-grid"></div>';
+    var anchor = roomAnchor();
+    if (anchor.parentNode) anchor.parentNode.insertBefore(box, anchor.nextSibling);
+    else document.body.appendChild(box);
+    return box;
+  }
+  function addRoomImg(url, room, persist) {
+    var grid = resultsBox().querySelector('.tsr-rr-grid');
+    var fig = document.createElement('figure');
+    fig.className = 'tsr-rr-item';
+    fig.innerHTML = '<img loading="lazy" src="' + url + (url.indexOf('?') < 0 ? '?t=' + Date.now() : '') + '" alt="Rendered room">' +
+      '<figcaption>' + String(room || 'room').replace(/_/g, ' ') + '</figcaption>';
+    grid.appendChild(fig);
+    if (persist) { var a = loadRooms(); a.push({ url: url, room: room }); saveRooms(a); }
+  }
+  function renderNewRoom() {
+    if (roomBusy) return;
+    if (!DESIGN_ID) { toast('Could not detect the design id', true); return; }
+    roomBusy = true;
+    var room = nextRoomType();
+    toast('🛋 Rendering your ' + room.replace(/_/g, ' ') + '… (10–30s)');
+    fetch('/api/room', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin',
+      body: JSON.stringify({ design_id: DESIGN_ID, roomType: room, angle: 'straight_on', cameraDistance: 5 }),
+    }).then(function (r) { return r.json().then(function (j) { return { status: r.status, j: j }; }); })
+      .then(function (res) {
+        roomBusy = false;
+        var j = res.j || {};
+        if (res.status === 401 || j.error === 'sign_in_required') {
+          toast('Sign in to render a room', true, j.sign_in_url || '/trade/login?return=' + encodeURIComponent(location.pathname));
+          return;
+        }
+        if (j.ok && j.image_url) {
+          addRoomImg(j.image_url, room, true);
+          toast('✓ Room ready');
+          resultsBox().scrollIntoView({ behavior: 'smooth', block: 'center' });
+        } else {
+          toast('Room render failed: ' + (j.error || 'try again'), true);
+        }
+      }).catch(function (e) { roomBusy = false; toast('Room render error: ' + e.message, true); });
+  }
+  function restoreRooms() { loadRooms().forEach(function (r) { addRoomImg(r.url, r.room, false); }); }
+
+  // ---------- UI: dock + toasts ----------
+  function toast(msg, isErr, link) {
+    var t = document.createElement('div');
+    t.className = 'tsr-toast' + (isErr ? ' err' : '');
+    t.innerHTML = msg + (link ? ' <a href="' + link + '">Sign in →</a>' : '');
+    document.body.appendChild(t);
+    requestAnimationFrame(function () { t.classList.add('show'); });
+    setTimeout(function () { t.classList.remove('show'); setTimeout(function () { t.remove(); }, 300); }, link ? 6000 : 3000);
+  }
+  function buildDock() {
+    if (document.getElementById('tsr-dock')) return;
+    var d = document.createElement('div');
+    d.id = 'tsr-dock'; d.className = 'tsr-dock';
+    d.innerHTML =
+      '<button class="tsr-room" title="Render this design in a new room">🛋 <span>Render a room</span></button>' +
+      '<button class="tsr-edit" title="Move &amp; rearrange the sections of this page">✎ <span>Customize</span></button>' +
+      '<span class="tsr-edit-tools" hidden>' +
+        '<button class="tsr-save" title="Save this layout">💾 Save</button>' +
+        '<button class="tsr-reset" title="Reset this theme\'s layout">↺ Reset</button>' +
+        '<button class="tsr-done" title="Exit edit mode">✓ Done</button>' +
+      '</span>';
+    document.body.appendChild(d);
+    d.querySelector('.tsr-room').addEventListener('click', renderNewRoom);
+    d.querySelector('.tsr-edit').addEventListener('click', function () { setEditing(true); });
+    d.querySelector('.tsr-done').addEventListener('click', function () { setEditing(false); });
+    d.querySelector('.tsr-save').addEventListener('click', commitSave);
+    d.querySelector('.tsr-reset').addEventListener('click', resetLayout);
+  }
+  function setEditing(on) {
+    editing = on;
+    document.body.classList.toggle('tsr-editing', on);
+    var d = document.getElementById('tsr-dock');
+    if (d) { d.querySelector('.tsr-edit').hidden = on; d.querySelector('.tsr-edit-tools').hidden = !on; }
+    if (on) { topModules().forEach(ensureHandle); toast('Edit mode — drag ⠿ to move sections anywhere, drop on another to reorder'); }
+  }
   function styles() {
-    var css = document.createElement('style');
+    if (document.getElementById('tsr-css')) return;
+    var css = document.createElement('style'); css.id = 'tsr-css';
     css.textContent = [
-      '.tsr-handle{position:absolute;top:4px;left:4px;z-index:50;width:20px;height:20px;display:flex;align-items:center;',
-        'justify-content:center;font-size:13px;line-height:1;color:#fff;background:rgba(20,20,20,.55);border:1px solid rgba(255,255,255,.25);',
-        'border-radius:5px;cursor:grab;opacity:0;transition:opacity .15s;user-select:none}',
-      '.tsr-sec:hover > .tsr-handle{opacity:.85}',
-      '.tsr-handle:hover{opacity:1!important;background:rgba(20,20,20,.8)}',
-      '.tsr-handle:active{cursor:grabbing}',
-      '.tsr-zone > .tsr-sec{outline:1px dashed transparent;outline-offset:2px;transition:outline-color .15s}',
-      '.tsr-zone:hover > .tsr-sec:hover{outline-color:rgba(120,120,120,.4)}',
-      '.' + DRAG_CLASS + '{opacity:.55;outline:2px dashed rgba(63,208,154,.9)!important}',
-      '#tsr-pill{position:fixed;left:16px;bottom:14px;z-index:99998;display:flex;align-items:center;gap:8px;',
-        'background:rgba(12,12,12,.82);backdrop-filter:blur(8px);border:1px solid rgba(245,241,232,.18);border-radius:999px;',
-        'padding:5px 12px;font:500 11px/1 -apple-system,system-ui,sans-serif;color:#f5f1e8;box-shadow:0 6px 20px rgba(0,0,0,.4)}',
-      '#tsr-pill .tsr-grip{opacity:.8}',
-      '#tsr-pill button{background:transparent;border:0;color:#f5f1e8;font-size:12px;cursor:pointer;opacity:.7;padding:0}',
-      '#tsr-pill button:hover{opacity:1}',
+      '.tsr-handle{position:absolute;top:6px;left:6px;z-index:60;width:24px;height:24px;display:none;align-items:center;justify-content:center;',
+        'font-size:14px;line-height:1;color:#fff;background:rgba(20,20,20,.62);border:1px solid rgba(255,255,255,.3);border-radius:6px;cursor:grab;',
+        'touch-action:none;user-select:none}',
+      'body.tsr-editing .tsr-handle{display:flex}',
+      'body.tsr-editing .tsr-sec{outline:1px dashed rgba(120,120,120,.35);outline-offset:3px}',
+      'body.tsr-editing .tsr-sec:hover{outline-color:rgba(63,208,154,.7)}',
+      '.tsr-handle:hover{background:rgba(20,20,20,.85)}.tsr-handle:active{cursor:grabbing}',
+      '.' + DRAG_CLASS + '{opacity:.7;z-index:9990!important;outline:2px solid rgba(63,208,154,.95)!important;box-shadow:0 12px 36px rgba(0,0,0,.35)}',
+      '.tsr-hint{position:fixed;height:3px;background:#3fd09a;z-index:9991;border-radius:2px;pointer-events:none;box-shadow:0 0 8px rgba(63,208,154,.8)}',
+      '.tsr-dock{position:fixed;left:16px;bottom:16px;z-index:99996;display:flex;align-items:center;gap:6px;',
+        'background:rgba(12,12,12,.86);backdrop-filter:blur(10px);border:1px solid rgba(245,241,232,.18);border-radius:999px;',
+        'padding:6px 8px;font:600 12px/1 -apple-system,system-ui,sans-serif;color:#f5f1e8;box-shadow:0 8px 26px rgba(0,0,0,.45)}',
+      '.tsr-dock button{background:transparent;border:0;color:#f5f1e8;cursor:pointer;font:inherit;padding:7px 11px;border-radius:999px;',
+        'display:inline-flex;align-items:center;gap:5px;white-space:nowrap;transition:background .15s}',
+      '.tsr-dock button:hover{background:rgba(245,241,232,.16)}',
+      '.tsr-dock .tsr-room{background:rgba(63,208,154,.16)}',
+      '.tsr-dock .tsr-edit-tools{display:inline-flex;align-items:center;gap:4px}',
+      '.tsr-dock .tsr-done{background:rgba(63,208,154,.22)}',
+      '@media(max-width:560px){.tsr-dock button span{display:none}}',
+      '.tsr-toast{position:fixed;left:50%;bottom:74px;transform:translate(-50%,12px);z-index:99997;max-width:88vw;',
+        'background:rgba(18,18,18,.94);color:#f5f1e8;border:1px solid rgba(245,241,232,.18);border-radius:10px;padding:10px 16px;',
+        'font:500 13px/1.35 -apple-system,system-ui,sans-serif;box-shadow:0 10px 30px rgba(0,0,0,.5);opacity:0;transition:all .28s}',
+      '.tsr-toast.show{opacity:1;transform:translate(-50%,0)}',
+      '.tsr-toast.err{background:#5a1f1f;border-color:#a33}',
+      '.tsr-toast a{color:#7fe3bd;font-weight:700;text-decoration:none;margin-left:4px}',
+      '.tsr-room-results{margin:18px 0;padding:0}',
+      '.tsr-room-results .tsr-rr-h{font:600 13px/1 -apple-system,system-ui,sans-serif;letter-spacing:.04em;text-transform:uppercase;opacity:.7;margin:0 0 10px}',
+      '.tsr-rr-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(220px,1fr));gap:12px}',
+      '.tsr-rr-item{margin:0;border-radius:10px;overflow:hidden;background:rgba(0,0,0,.05);border:1px solid rgba(0,0,0,.08)}',
+      '.tsr-rr-item img{display:block;width:100%;height:auto}',
+      '.tsr-rr-item figcaption{font:12px/1 -apple-system,system-ui,sans-serif;padding:7px 10px;opacity:.7;text-transform:capitalize}',
     ].join('');
     document.head.appendChild(css);
   }
 
-  function pill() {
-    var el = document.createElement('div');
-    el.id = 'tsr-pill';
-    el.innerHTML = '<span class="tsr-grip">⠿</span><span>Drag sections to reorder</span><button id="tsr-reset" title="Reset this theme\'s layout">↺ reset</button>';
-    document.body.appendChild(el);
-    document.getElementById('tsr-reset').addEventListener('click', function () {
-      var store = load();
-      Object.keys(store).forEach(function (k) { delete store[k]; });
-      save(store);
-      location.reload();
-    });
+  // ---------- lifecycle ----------
+  function setup() {
+    if (applying) return; applying = true;
+    if (observer) observer.disconnect();
+    try {
+      applySaved();
+      if (editing) topModules().forEach(ensureHandle);
+    } finally {
+      applying = false;
+      if (observer) observer.observe(containers()[0] || document.body, { childList: true, subtree: true });
+    }
+  }
+  function start() {
+    styles(); buildDock(); restoreRooms(); applySaved();
+    observer = new MutationObserver(function () { if (!applying) { clearTimeout(window.__tsrT); window.__tsrT = setTimeout(setup, 140); } });
+    observer.observe(containers()[0] || document.body, { childList: true, subtree: true });
   }
-
   function waitForRender() {
     var root = document.getElementById('root');
-    function ready() {
-      var t = (document.body.innerText || '');
-      return root && root.children.length > 0 && !/Loading design/i.test(t);
-    }
+    if (!root) return start();  // monolith is server-rendered
+    function ready() { return root.children.length > 0 && !/Loading design/i.test(document.body.innerText || ''); }
     if (ready()) return start();
     var mo = new MutationObserver(function () { if (ready()) { mo.disconnect(); start(); } });
-    mo.observe(root || document.body, { childList: true, subtree: true });
-    setTimeout(function () { if (ready()) { try { mo.disconnect(); } catch (e) {} start(); } }, 4000);
-  }
-
-  function start() {
-    styles();
-    pill();
-    observer = new MutationObserver(function () { if (!applying) { clearTimeout(window.__tsrT); window.__tsrT = setTimeout(setup, 120); } });
-    setup();
+    mo.observe(root, { childList: true, subtree: true });
+    setTimeout(function () { try { mo.disconnect(); } catch (e) {} start(); }, 4000);
   }
 
   if (document.body) waitForRender();
diff --git a/scripts/smoke-test-customizer.js b/scripts/smoke-test-customizer.js
new file mode 100644
index 0000000..1c711ee
--- /dev/null
+++ b/scripts/smoke-test-customizer.js
@@ -0,0 +1,138 @@
+#!/usr/bin/env node
+/**
+ * smoke-test-customizer.js — proves the universal PDP THEME CUSTOMIZER
+ * (theme-section-rearrange.js) is FULLY WIRED on every theme, per the THEMES
+ * rule (Steve 2026-06-02): every theme must (1) offer "render a new room",
+ * (2) let the user move/rearrange every module, and (3) save the layout.
+ *
+ * For BOTH the classic monolith (/design/:id) and a variant (/design/:id/best):
+ *   A. the customizer dock + Render-a-room + Customize buttons exist
+ *   B. Render-a-room is wired to POST /api/room — proven by MOCKING the route
+ *      (zero $ cost, no upstream call): a 401 shows the sign-in toast; an ok
+ *      response injects a room image AND persists it to localStorage
+ *   C. Customize reveals drag handles on modules + the Save/Reset/Done tools
+ *   D. dragging a handle registers a layout change, and Save persists it to
+ *      localStorage; after reload the rendered room re-appears (persistence)
+ *
+ * Usage:
+ *   BASE=http://127.0.0.1:9905 node scripts/smoke-test-customizer.js 55203
+ *   node scripts/smoke-test-customizer.js <designId> <BASE>
+ *
+ * Exit: 0 pass · 1 wiring failure · 2 harness error · 3 can't run (no PW/browser)
+ */
+'use strict';
+const EXIT_SKIP = 3;
+function loadChromium() {
+  const tries = ['playwright', '/Users/stevestudio2/Projects/animals/node_modules/playwright'];
+  for (const t of tries) { try { return require(t).chromium; } catch {} }
+  console.error('SKIP: playwright not found'); process.exit(EXIT_SKIP);
+}
+const chromium = loadChromium();
+const BASE = process.argv[3] || process.env.BASE || 'http://127.0.0.1:9905';
+const ID = process.argv[2] || process.env.ID || '55203';
+const PAGES = [
+  { label: 'monolith', url: `${BASE}/design/${ID}`, slug: 'classic' },
+  { label: 'variant:best', url: `${BASE}/design/${ID}/best`, slug: 'best' },
+];
+
+let failures = 0;
+const ok = (c, m) => { console.log(`  ${c ? '✅' : '❌'} ${m}`); if (!c) failures++; };
+
+async function run() {
+  let browser;
+  try { browser = await chromium.launch(); }
+  catch { try { browser = await chromium.launch({ channel: 'chrome' }); } catch (e) { console.error('SKIP: no browser — ' + e.message); process.exit(EXIT_SKIP); } }
+
+  for (const P of PAGES) {
+    console.log(`\n── ${P.label}  (${P.url})`);
+    const ctx = await browser.newContext();
+    const page = await ctx.newPage();
+
+    // ---- B: mock /api/room so we never hit the $/call renderer ----
+    let roomMode = 'signin';  // 'signin' | 'ok'
+    await page.route('**/api/room', (route) => {
+      if (roomMode === 'signin') {
+        return route.fulfill({ status: 401, contentType: 'application/json',
+          body: JSON.stringify({ ok: false, error: 'sign_in_required', sign_in_url: '/trade/login?return=/' }) });
+      }
+      return route.fulfill({ status: 200, contentType: 'application/json',
+        body: JSON.stringify({ ok: true, image_url: '/designs/img/' + 'smoke_room.png', room_id: 1 }) });
+    });
+
+    const errs = [];
+    page.on('pageerror', (e) => errs.push(e.message));
+    try {
+      await page.goto(P.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
+      // wait for the dock the script builds (works on monolith + variant)
+      await page.waitForSelector('#tsr-dock', { timeout: 12000 });
+
+      // A. controls present
+      ok(await page.$('#tsr-dock .tsr-room') != null, 'Render-a-room button present');
+      ok(await page.$('#tsr-dock .tsr-edit') != null, 'Customize button present');
+
+      // B1. sign-in path
+      await page.click('#tsr-dock .tsr-room');
+      await page.waitForSelector('.tsr-toast.err a', { timeout: 6000 });
+      ok((await page.$$eval('.tsr-toast', ts => ts.map(t => t.textContent).join(' '))).match(/sign in/i) != null,
+        'Render-a-room → sign-in toast when /api/room is 401');
+
+      // B2. ok path → image injected + persisted
+      roomMode = 'ok';
+      await page.click('#tsr-dock .tsr-room');
+      await page.waitForSelector('#tsr-room-results .tsr-rr-item img', { timeout: 8000 });
+      ok(await page.$('#tsr-room-results .tsr-rr-item img') != null, 'Rendered room image injected into the page');
+      const savedRooms = await page.evaluate(id => { try { return JSON.parse(localStorage.getItem('wallco:rooms:' + id) || '[]'); } catch { return []; } }, ID);
+      ok(savedRooms.length >= 1, 'Rendered room persisted to localStorage (wallco:rooms:' + ID + ')');
+
+      // C. customize → handles + tools
+      await page.click('#tsr-dock .tsr-edit');
+      await page.waitForSelector('.tsr-handle', { timeout: 6000 });
+      const handleCount = await page.$$eval('.tsr-handle', hs => hs.length);
+      ok(handleCount >= 1, `Customize reveals drag handles (${handleCount})`);
+      ok(await page.$eval('#tsr-dock .tsr-save', el => !el.hidden && getComputedStyle(el).display !== 'none').catch(() => false),
+        'Save/Reset/Done tools visible in edit mode');
+
+      // D. drag a handle → the module actually moves on screen / changes order.
+      // Tag the dragged handle's module, record its pre-drag rect + DOM index.
+      const before = await page.evaluate(() => {
+        const h = document.querySelector('.tsr-handle');
+        const m = h.closest('.tsr-sec'); m.setAttribute('data-tsr-test', '1');
+        const r = m.getBoundingClientRect();
+        return { x: r.x, y: r.y, idx: Array.prototype.indexOf.call(m.parentElement.children, m) };
+      });
+      const hb = await page.$eval('[data-tsr-test="1"] > .tsr-handle', el => { const r = el.getBoundingClientRect(); return { x: r.x + r.width / 2, y: r.y + r.height / 2 }; });
+      await page.mouse.move(hb.x, hb.y);
+      await page.mouse.down();
+      await page.mouse.move(hb.x + 140, hb.y + 120, { steps: 8 });
+      await page.mouse.move(hb.x + 260, hb.y + 220, { steps: 8 });
+      await page.mouse.up();
+      const after = await page.evaluate(() => {
+        const m = document.querySelector('[data-tsr-test="1"]');
+        const r = m.getBoundingClientRect();
+        return { x: r.x, y: r.y, idx: Array.prototype.indexOf.call(m.parentElement.children, m), off: m.dataset.tsrOff || '', tf: m.style.transform || '' };
+      });
+      const moved = Math.abs(after.x - before.x) > 20 || Math.abs(after.y - before.y) > 20 || after.idx !== before.idx || !!after.off || !!after.tf;
+      ok(moved, `Dragging a module moves it on screen / reorders (Δx=${Math.round(after.x - before.x)} Δy=${Math.round(after.y - before.y)} idx ${before.idx}→${after.idx})`);
+
+      await page.click('#tsr-dock .tsr-save');
+      await page.waitForTimeout(300);
+      const layout = await page.evaluate(slug => { try { return JSON.parse(localStorage.getItem('wallco:layout:' + slug)); } catch { return null; } }, P.slug);
+      ok(layout && (layout.order || layout.off), `Save persists layout to localStorage (wallco:layout:${P.slug})`);
+
+      // E. persistence across reload — the saved room re-appears
+      await page.reload({ waitUntil: 'domcontentloaded' });
+      await page.waitForSelector('#tsr-dock', { timeout: 12000 });
+      await page.waitForSelector('#tsr-room-results .tsr-rr-item img', { timeout: 8000 }).catch(() => {});
+      ok(await page.$('#tsr-room-results .tsr-rr-item img') != null, 'Saved room re-appears after reload (persistence)');
+
+      ok(errs.length === 0, 'No uncaught page errors' + (errs.length ? ' — ' + errs.slice(0, 2).join(' | ') : ''));
+    } catch (e) {
+      ok(false, 'page threw: ' + e.message);
+    } finally { await ctx.close(); }
+  }
+
+  await browser.close();
+  console.log(`\n${failures === 0 ? '✅ ALL CUSTOMIZER CHECKS PASSED' : '❌ ' + failures + ' CHECK(S) FAILED'}`);
+  process.exit(failures === 0 ? 0 : 1);
+}
+run().catch(e => { console.error('HARNESS ERROR:', e); process.exit(2); });

← 093259a tif-gate C+D (DTD verdict 2026-06-02): correct the docs + ad  ·  back to Wallco Ai  ·  pilot-build-tifs.py: source from design image_url, fall back 4b22f53 →