[object Object]

← back to Quadrille Showroom

auto-save: 2026-06-29T15:24:16 (3 files) — public/js/showroom.js public/js/dock.js public/js/modal-rig.js

61450904d787f42c3eb1f4073b493d2d838329e8 · 2026-06-29 15:24:18 -0700 · Steve Abrams

Files touched

Diff

commit 61450904d787f42c3eb1f4073b493d2d838329e8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 15:24:18 2026 -0700

    auto-save: 2026-06-29T15:24:16 (3 files) — public/js/showroom.js public/js/dock.js public/js/modal-rig.js
---
 public/js/dock.js      | 301 +++++++++++++++++++++++++++++++++++++++++++++++++
 public/js/modal-rig.js | 155 +++++++++++++++++++++++++
 public/js/showroom.js  |  17 ++-
 3 files changed, 468 insertions(+), 5 deletions(-)

diff --git a/public/js/dock.js b/public/js/dock.js
new file mode 100644
index 0000000..e0d861f
--- /dev/null
+++ b/public/js/dock.js
@@ -0,0 +1,301 @@
+/* ============================================================================
+ * dock.js — The Quadrille House: HAMBURGER DOCK.
+ *
+ * Standing ask (Steve, port 7690): on load the showroom is a bare 3D view with
+ * ONE hamburger in the corner. Every control surface — Versions, View & Theme,
+ * the bottom Controls bar, the Navigate bar, the Collection list — and every
+ * top-toolbar tool (Peruse, Wings/Overview, Explore, Music, Lighting, Age View,
+ * Fullscreen) is STOWED on load and re-launches from the hamburger as its own
+ * draggable / resizable / position-persisted modal.
+ *
+ * Non-destructive by design: the original panels keep their IDs and ALL of their
+ * existing wiring (showroom.js / viewmodes.js / versions.js bind by ID). We only
+ *   (a) hide them on load (`.qh-stowed`), and
+ *   (b) on launch, wrap each in a `.qh-modal` shell + ModalRig.rig() the shell —
+ *       so the panel's own flex/transform layout is never fought.
+ *
+ * Depends on /js/modal-rig.js (window.ModalRig). Loaded LAST so every panel the
+ * other bolt-ons inject already exists; a MutationObserver mops up late arrivals
+ * (e.g. ageview's #btn-ageview, which is added on DOMContentLoaded).
+ * ========================================================================== */
+(function () {
+  'use strict';
+
+  // --- LAUNCHABLE PANELS: each becomes a movable modal -----------------------
+  // sel = the existing element; label = menu/header text; w/h = first-open size.
+  var PANELS = [
+    { sel: '#guided-bar',     label: 'Navigate',        w: 660, h: 132 },
+    { sel: '#window-bar',     label: 'Controls',        w: 740, h: 150 },
+    { sel: '#vm-panel',       label: 'View & Theme',    w: 300, h: 380 },
+    { sel: '#ver-rail',       label: 'Versions',        w: 250, h: 440 },
+    { sel: '#vendor-sidebar', label: 'Collection',      w: 250, h: 420 }
+  ];
+
+  // --- TOP-BAR TOOLS: menu items that just proxy-click the original button ---
+  // (the buttons stay in the DOM, only their containers are hidden)
+  var TOOLS = [
+    { sel: '#btn-peruse',              label: '▷ Peruse Collection' },
+    { sel: '#top-bar [data-section="wings"]',    label: 'Wing Boards' },
+    { sel: '#top-bar [data-section="overview"]', label: 'Overview' },
+    { sel: '#btn-explore',             label: 'Explore (WASD)' },
+    { sel: '#btn-ageview',             label: 'Age View' },
+    { sel: '#btn-bookmatch',           label: 'Open Board' },
+    { sel: '#btn-walls',               label: 'Wallpaper Walls' },
+    { sel: '#btn-music',               label: 'Music' },
+    { sel: '#btn-lighting',            label: 'Lighting' },
+    { sel: '#btn-fullscreen',          label: 'Fullscreen' }
+  ];
+
+  // --- STOW-ONLY: passive overlays/clusters hidden for a clean screen --------
+  var STOW_ONLY = [
+    '.top-center', '.top-right',          // top-toolbar clusters (folded into menu)
+    '#guided-instruction', '#guided-title', '#bottom-bar'  // bottom chatter
+  ];
+
+  var Z_BASE = 5000, zTop = Z_BASE;       // modal stacking
+  var openOrder = [];                     // selectors, in focus order (last = top)
+  var LS_OPEN = 'qh_dock_open';
+
+  // ---------------------------------------------------------------- styles ----
+  function injectStyles() {
+    if (document.getElementById('qh-dock-styles')) return;
+    var st = document.createElement('style');
+    st.id = 'qh-dock-styles';
+    st.textContent = [
+      '.qh-stowed{display:none !important;}',
+      /* hamburger — top-right corner, always on top */
+      '#qh-burger{position:fixed;top:14px;right:16px;z-index:9000;width:48px;height:48px;',
+        'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;',
+        'background:rgba(12,12,17,0.86);border:1.5px solid rgba(201,169,110,0.5);border-radius:13px;',
+        'cursor:pointer;backdrop-filter:blur(12px);box-shadow:0 10px 30px -8px rgba(0,0,0,0.7);',
+        'transition:border-color .15s, transform .1s;}',
+      '#qh-burger:hover{border-color:#c9a96e;}',
+      '#qh-burger:active{transform:translateY(1px);}',
+      '#qh-burger span{display:block;width:22px;height:2.5px;border-radius:2px;background:#c9a96e;transition:.2s;}',
+      '#qh-burger.open span:nth-child(1){transform:translateY(7.5px) rotate(45deg);}',
+      '#qh-burger.open span:nth-child(2){opacity:0;}',
+      '#qh-burger.open span:nth-child(3){transform:translateY(-7.5px) rotate(-45deg);}',
+      /* launcher menu */
+      '#qh-menu{position:fixed;top:70px;right:16px;z-index:8999;width:236px;max-height:78vh;overflow:auto;',
+        'background:rgba(14,14,20,0.94);border:1px solid rgba(201,169,110,0.3);border-radius:14px;',
+        'padding:8px;backdrop-filter:blur(14px);box-shadow:0 18px 50px -12px rgba(0,0,0,0.8);',
+        'display:none;font-family:inherit;}',
+      '#qh-menu.open{display:block;}',
+      '.qh-menu-eyebrow{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;color:#8c8678;',
+        'padding:8px 10px 4px;font-weight:700;}',
+      '.qh-menu-item{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;',
+        'text-align:left;padding:9px 11px;margin:2px 0;border-radius:9px;cursor:pointer;',
+        'background:rgba(255,255,255,0.04);border:1px solid rgba(201,169,110,0.18);',
+        'color:#e8e2d4;font-size:13.5px;font-weight:600;letter-spacing:.2px;transition:background .12s,border-color .12s;}',
+      '.qh-menu-item:hover{background:rgba(201,169,110,0.2);border-color:#c9a96e;}',
+      '.qh-menu-item .qh-dot{font-size:11px;color:#c9a96e;opacity:0;}',
+      '.qh-menu-item.active .qh-dot{opacity:1;}',
+      '.qh-menu-item.active{border-color:rgba(201,169,110,0.55);}',
+      /* modal shell */
+      '.qh-modal{position:absolute;z-index:5000;display:flex;flex-direction:column;',
+        'min-width:200px;min-height:90px;overflow:hidden;border-radius:13px;',
+        'background:rgba(12,12,17,0.92);border:1px solid rgba(201,169,110,0.34);',
+        'box-shadow:0 22px 60px -14px rgba(0,0,0,0.8);backdrop-filter:blur(10px);}',
+      '.qh-modal-head{flex:0 0 auto;display:flex;align-items:center;justify-content:space-between;gap:8px;',
+        'padding:8px 12px;cursor:move;user-select:none;border-bottom:1px solid rgba(201,169,110,0.22);',
+        'background:rgba(201,169,110,0.10);}',
+      '.qh-modal-title{font-family:Georgia,serif;font-size:14px;font-weight:600;color:#f0e6cf;letter-spacing:.4px;',
+        'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}',
+      '.qh-modal-tools{flex:0 0 auto;display:flex;align-items:center;gap:2px;color:#c9a96e;}',
+      '.qh-modal-x{cursor:pointer;opacity:.75;font-size:19px;line-height:1;padding:0 4px;}',
+      '.qh-modal-x:hover{opacity:1;color:#fff;}',
+      /* the original panel, neutralized, fills the shell body */
+      '.qh-modal > .qh-pane-host{flex:1 1 auto;overflow:auto;min-height:0;}'
+    ].join('\n');
+    document.head.appendChild(st);
+  }
+
+  // ---------------------------------------------------------------- helpers ---
+  function $(sel) { return document.querySelector(sel); }
+
+  function stow(selOrEl) {
+    var el = typeof selOrEl === 'string' ? $(selOrEl) : selOrEl;
+    if (el) el.classList.add('qh-stowed');
+  }
+
+  // Launch (or re-show) a panel as a movable modal.
+  function launch(p) {
+    var el = $(p.sel);
+    if (!el) return;
+    var wrap = el.closest('.qh-modal');
+    if (!wrap) {
+      // first launch: build the shell, move the panel inside, neutralize it
+      wrap = document.createElement('div');
+      wrap.className = 'qh-modal';
+      wrap.setAttribute('data-qh-key', p.sel);
+      var head = document.createElement('div');
+      head.className = 'qh-modal-head';
+      head.innerHTML = '<span class="qh-modal-title"></span>' +
+                       '<span class="qh-modal-tools"><span class="qh-modal-x" title="Close (Esc)">×</span></span>';
+      head.querySelector('.qh-modal-title').textContent = p.label;
+      el.parentNode.insertBefore(wrap, el);
+      wrap.appendChild(head);
+      wrap.appendChild(el);
+
+      // strip the panel's own fixed-position / slide-off layout so the shell governs
+      el.classList.add('qh-pane-host');
+      el.classList.remove('collapsed', 'show', 'open');
+      var s = el.style;
+      s.position = 'static'; s.inset = 'auto';
+      s.left = s.right = s.top = s.bottom = 'auto';
+      s.transform = 'none'; s.margin = '0';
+      s.width = '100%'; s.maxWidth = 'none'; s.maxHeight = 'none';
+      s.display = ''; // clear any inline display:none from native collapse logic
+
+      head.querySelector('.qh-modal-x').addEventListener('click', function (e) {
+        e.stopPropagation(); hide(p);
+      });
+      // keep the close × from initiating a drag
+      head.querySelector('.qh-modal-x').setAttribute('data-rig-btn', '1');
+      wrap.addEventListener('mousedown', function () { bringToFront(p.sel, wrap); });
+
+      ModalRig.rig(wrap, { key: 'qhdock:' + p.sel, handle: head, width: p.w, height: p.h });
+    }
+    wrap.classList.remove('qh-stowed');
+    bringToFront(p.sel, wrap);
+    if (openOrder.indexOf(p.sel) === -1) openOrder.push(p.sel);
+    saveOpen(); reflectMenu();
+  }
+
+  function hide(p) {
+    var el = $(p.sel); if (!el) return;
+    var wrap = el.closest('.qh-modal');
+    if (wrap) wrap.classList.add('qh-stowed');
+    var i = openOrder.indexOf(p.sel); if (i > -1) openOrder.splice(i, 1);
+    saveOpen(); reflectMenu();
+  }
+
+  function isOpen(p) {
+    var el = $(p.sel); if (!el) return false;
+    var wrap = el.closest('.qh-modal');
+    return !!wrap && !wrap.classList.contains('qh-stowed');
+  }
+
+  function toggle(p) { isOpen(p) ? hide(p) : launch(p); }
+
+  function bringToFront(sel, wrap) {
+    wrap.style.zIndex = (++zTop);
+    var i = openOrder.indexOf(sel);
+    if (i > -1) { openOrder.splice(i, 1); openOrder.push(sel); }
+  }
+
+  function saveOpen() {
+    try { localStorage.setItem(LS_OPEN, JSON.stringify(openOrder)); } catch (e) {}
+  }
+
+  // ----------------------------------------------------------------- menu -----
+  var burger, menu;
+
+  function buildMenu() {
+    burger = document.createElement('div');
+    burger.id = 'qh-burger';
+    burger.title = 'Showroom controls';
+    burger.innerHTML = '<span></span><span></span><span></span>';
+    document.body.appendChild(burger);
+
+    menu = document.createElement('div');
+    menu.id = 'qh-menu';
+    document.body.appendChild(menu);
+
+    var html = '<div class="qh-menu-eyebrow">Panels</div>';
+    PANELS.forEach(function (p) {
+      html += '<button class="qh-menu-item" data-panel="' + p.sel + '">' +
+                '<span>' + p.label + '</span><span class="qh-dot">●</span></button>';
+    });
+    html += '<div class="qh-menu-eyebrow">Tools</div>';
+    TOOLS.forEach(function (t) {
+      html += '<button class="qh-menu-item" data-tool="' + t.sel + '">' +
+                '<span>' + t.label + '</span><span class="qh-dot">●</span></button>';
+    });
+    menu.innerHTML = html;
+
+    menu.addEventListener('click', function (e) {
+      var item = e.target.closest('.qh-menu-item'); if (!item) return;
+      var pSel = item.getAttribute('data-panel');
+      var tSel = item.getAttribute('data-tool');
+      if (pSel) {
+        var p = PANELS.filter(function (x) { return x.sel === pSel; })[0];
+        if (p) toggle(p);
+      } else if (tSel) {
+        var btn = $(tSel);
+        if (btn) { btn.click(); setTimeout(reflectMenu, 30); }
+      }
+    });
+
+    burger.addEventListener('click', function () {
+      var open = menu.classList.toggle('open');
+      burger.classList.toggle('open', open);
+      if (open) reflectMenu();
+    });
+    // click-away closes the menu (but not the modals)
+    document.addEventListener('mousedown', function (e) {
+      if (!menu.classList.contains('open')) return;
+      if (e.target.closest('#qh-menu') || e.target.closest('#qh-burger')) return;
+      menu.classList.remove('open'); burger.classList.remove('open');
+    });
+  }
+
+  function reflectMenu() {
+    if (!menu) return;
+    PANELS.forEach(function (p) {
+      var it = menu.querySelector('[data-panel="' + p.sel + '"]');
+      if (it) it.classList.toggle('active', isOpen(p));
+    });
+    TOOLS.forEach(function (t) {
+      var it = menu.querySelector('[data-tool="' + t.sel + '"]');
+      var btn = $(t.sel);
+      if (it) it.classList.toggle('active', !!btn && btn.classList.contains('active'));
+    });
+  }
+
+  // ------------------------------------------------------------- stow + boot --
+  function stowAll() {
+    PANELS.forEach(function (p) {
+      var el = $(p.sel);
+      if (el && !el.closest('.qh-modal')) stow(el);   // don't re-stow a launched modal's pane
+    });
+    STOW_ONLY.forEach(stow);
+  }
+
+  function restoreOpen() {
+    var saved = [];
+    try { saved = JSON.parse(localStorage.getItem(LS_OPEN) || '[]') || []; } catch (e) {}
+    saved.forEach(function (sel) {
+      var p = PANELS.filter(function (x) { return x.sel === sel; })[0];
+      if (p) launch(p);
+    });
+  }
+
+  function boot() {
+    if (!window.ModalRig) {            // modal-rig.js must load first
+      return setTimeout(boot, 60);
+    }
+    injectStyles();
+    stowAll();
+    buildMenu();
+
+    // Late-arriving nodes (ageview's #btn-ageview, or any panel injected on a
+    // later tick) — keep them stowed until first launch.
+    var mo = new MutationObserver(function () { stowAll(); });
+    mo.observe(document.body, { childList: true, subtree: true });
+    setTimeout(function () { mo.disconnect(); }, 4000);   // settle window
+
+    restoreOpen();
+
+    // Esc closes the top-most open modal.
+    document.addEventListener('keydown', function (e) {
+      if (e.key !== 'Escape' || !openOrder.length) return;
+      var sel = openOrder[openOrder.length - 1];
+      var p = PANELS.filter(function (x) { return x.sel === sel; })[0];
+      if (p) { hide(p); e.stopPropagation(); }
+    }, true);
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
+  else boot();
+})();
diff --git a/public/js/modal-rig.js b/public/js/modal-rig.js
new file mode 100644
index 0000000..a62c5f9
--- /dev/null
+++ b/public/js/modal-rig.js
@@ -0,0 +1,155 @@
+/*!
+ * modal-rig.js — make any modal/panel "fully done": drag-to-rearrange,
+ * stretch/resize, collapsible sections, maximize, all persisted to localStorage.
+ * Vanilla JS, zero deps, framework-agnostic. Exposes window.ModalRig.
+ *
+ *   ModalRig.rig(panelEl, { key:'mynote' })          // enhance an existing panel
+ *   ModalRig.section(headerEl, bodyEl, 'mynote:links') // collapsible section
+ *   var m = ModalRig.open({ key:'mynote', title:'Note', bodyHtml:'<p>…</p>' })
+ *
+ * Conventions:
+ *  - The panel is absolutely positioned; first child (or opts.handle) is the drag handle.
+ *  - resize:both gives a native corner handle; size + position persist under opts.key.
+ *  - A ⤢ maximize button is injected into the handle unless maximizable:false.
+ */
+(function () {
+  function lsGet(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (e) { return null; } }
+  function lsSet(k, v) { try { localStorage.setItem(k, JSON.stringify(v)); } catch (e) {} }
+  function px(n) { return Math.round(n) + 'px'; }
+
+  function applyRect(panel, key, opts) {
+    var w = opts.width || 760;
+    var h = opts.height || Math.min(620, Math.round(window.innerHeight * 0.8));
+    var r = lsGet('modalrig:' + key);
+    if (r && r.w) {
+      panel.style.left = px(r.left || 0); panel.style.top = px(r.top || 0);
+      panel.style.width = px(r.w); panel.style.height = px(r.h);
+    } else {
+      panel.style.left = px(Math.max(0, (window.innerWidth - w) / 2));
+      panel.style.top = px(Math.max(0, (window.innerHeight - h) / 2));
+      panel.style.width = px(w); panel.style.height = px(h);
+    }
+  }
+
+  function saveRect(panel, key) {
+    if (panel.getAttribute('data-rig-max') === '1') return; // don't persist the maximized state
+    lsSet('modalrig:' + key, {
+      left: parseInt(panel.style.left) || 0, top: parseInt(panel.style.top) || 0,
+      w: panel.offsetWidth, h: panel.offsetHeight
+    });
+  }
+
+  function startDrag(e, panel, key) {
+    if (e.target.closest && e.target.closest('[data-rig-btn]')) return; // not when clicking header buttons
+    if (panel.getAttribute('data-rig-max') === '1') unmaximize(panel, key); // dragging exits maximize
+    var box = panel.getBoundingClientRect();
+    var dx = e.clientX - box.left, dy = e.clientY - box.top;
+    function move(ev) {
+      panel.style.left = px(Math.max(0, Math.min(window.innerWidth - 80, ev.clientX - dx)));
+      panel.style.top = px(Math.max(0, Math.min(window.innerHeight - 40, ev.clientY - dy)));
+    }
+    function up() { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); saveRect(panel, key); }
+    document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
+    e.preventDefault();
+  }
+
+  function maximize(panel) {
+    panel.setAttribute('data-rig-pre', JSON.stringify({ left: panel.style.left, top: panel.style.top, w: panel.style.width, h: panel.style.height }));
+    panel.setAttribute('data-rig-max', '1');
+    panel.style.left = '2vw'; panel.style.top = '2vh'; panel.style.width = '96vw'; panel.style.height = '96vh';
+  }
+  function unmaximize(panel, key) {
+    var pre = null;
+    try { pre = JSON.parse(panel.getAttribute('data-rig-pre') || 'null'); } catch (e) { pre = null; }
+    panel.removeAttribute('data-rig-max');
+    if (pre) { panel.style.left = pre.left; panel.style.top = pre.top; panel.style.width = pre.w; panel.style.height = pre.h; }
+  }
+  function toggleMax(panel, key, btn) {
+    if (panel.getAttribute('data-rig-max') === '1') { unmaximize(panel, key); if (btn) btn.textContent = '⤢'; }
+    else { maximize(panel); if (btn) btn.textContent = '⤡'; }
+  }
+
+  function injectMaxBtn(handle, panel, key) {
+    if (handle.querySelector('[data-rig-max-btn]')) return;
+    var b = document.createElement('span');
+    b.setAttribute('data-rig-btn', '1'); b.setAttribute('data-rig-max-btn', '1');
+    b.title = 'maximize / restore'; b.textContent = '⤢';
+    b.style.cssText = 'cursor:pointer; opacity:.7; font-size:14px; line-height:1; margin-left:8px;';
+    b.addEventListener('click', function (e) { e.stopPropagation(); toggleMax(panel, key, b); });
+    handle.appendChild(b);
+  }
+
+  // Enhance an already-in-DOM panel.
+  function rig(panel, opts) {
+    if (!panel) return panel;
+    opts = opts || {};
+    var key = opts.key || 'default';
+    panel.style.position = 'absolute';
+    if (opts.resizable !== false) { panel.style.resize = 'both'; if (!panel.style.overflow) panel.style.overflow = 'hidden'; }
+    applyRect(panel, key, opts);
+    var handle = typeof opts.handle === 'string' ? panel.querySelector(opts.handle) : (opts.handle || panel.firstElementChild);
+    if (handle) {
+      handle.style.cursor = 'move'; handle.style.userSelect = 'none';
+      handle.addEventListener('mousedown', function (e) { startDrag(e, panel, key); });
+      if (opts.maximizable !== false) injectMaxBtn(handle, panel, key);
+    }
+    panel.addEventListener('mouseup', function () { saveRect(panel, key); });
+    // re-fit any registered onResize (e.g. a canvas/graph) when resized
+    if (opts.onResize && window.ResizeObserver) {
+      var ro = new ResizeObserver(function () { try { opts.onResize(panel); } catch (e) {} });
+      ro.observe(panel);
+    }
+    return panel;
+  }
+
+  // Collapsible section: clicking headerEl toggles bodyEl; state persists under key.
+  function section(headerEl, bodyEl, key) {
+    if (!headerEl || !bodyEl) return;
+    var collapsed = lsGet('modalrig-sec:' + key) === true;
+    var caret = document.createElement('span');
+    caret.textContent = collapsed ? '▸ ' : '▾ ';
+    headerEl.insertBefore(caret, headerEl.firstChild);
+    headerEl.style.cursor = 'pointer'; headerEl.style.userSelect = 'none';
+    bodyEl.style.display = collapsed ? 'none' : '';
+    headerEl.addEventListener('click', function () {
+      var nowOpen = bodyEl.style.display === 'none';
+      bodyEl.style.display = nowOpen ? '' : 'none';
+      caret.textContent = nowOpen ? '▾ ' : '▸ ';
+      lsSet('modalrig-sec:' + key, !nowOpen);
+    });
+  }
+
+  // Build a complete modal (backdrop + panel + header) and rig it. Returns
+  // { backdrop, panel, body, close }. Closes on backdrop click, ×, and Esc.
+  function open(o) {
+    o = o || {};
+    var key = o.key || 'modal';
+    var old = document.getElementById('modalrig-bd-' + key); if (old) old.remove();
+    var bd = document.createElement('div');
+    bd.id = 'modalrig-bd-' + key;
+    bd.style.cssText = 'position:fixed; inset:0; z-index:' + (o.z || 10000) + '; background:rgba(0,0,0,.5);';
+    var panel = document.createElement('div');
+    panel.style.cssText = 'display:flex; flex-direction:column; min-width:340px; min-height:180px; background:' + (o.bg || 'var(--card,#16181d)') + '; border:1px solid var(--border,#333); border-radius:10px; box-shadow:0 16px 56px rgba(0,0,0,.6);';
+    panel.addEventListener('click', function (e) { e.stopPropagation(); });
+    var head = document.createElement('div');
+    head.style.cssText = 'flex:0 0 auto; display:flex; justify-content:space-between; align-items:center; gap:8px; padding:10px 14px; border-bottom:1px solid var(--border,#333); background:var(--bg,#0a0a0f); border-radius:10px 10px 0 0;';
+    var title = document.createElement('span');
+    title.style.cssText = 'font-weight:600; font-size:12px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;';
+    title.textContent = o.title || '';
+    var btns = document.createElement('span'); btns.style.cssText = 'flex:0 0 auto; display:flex; align-items:center;';
+    var x = document.createElement('span'); x.setAttribute('data-rig-btn', '1'); x.textContent = '×';
+    x.title = 'close (Esc)'; x.style.cssText = 'cursor:pointer; opacity:.7; font-size:18px; line-height:1; margin-left:10px;';
+    var body = document.createElement('div');
+    body.style.cssText = 'flex:1 1 auto; overflow:auto; padding:14px 18px;';
+    if (o.bodyHtml != null) body.innerHTML = o.bodyHtml; else if (o.bodyEl) body.appendChild(o.bodyEl);
+    btns.appendChild(x); head.appendChild(title); head.appendChild(btns);
+    panel.appendChild(head); panel.appendChild(body); bd.appendChild(panel); document.body.appendChild(bd);
+    function close() { bd.remove(); if (o.onClose) o.onClose(); }
+    bd.addEventListener('click', close); x.addEventListener('click', close);
+    if (!window.__modalRigEsc) { window.__modalRigEsc = true; document.addEventListener('keydown', function (e) { if (e.key === 'Escape') { var t = document.querySelector('[id^="modalrig-bd-"]'); if (t) t.remove(); } }); }
+    rig(panel, { key: key, handle: head, resizable: o.resizable, maximizable: o.maximizable, width: o.width, height: o.height, onResize: o.onResize });
+    return { backdrop: bd, panel: panel, body: body, close: close };
+  }
+
+  window.ModalRig = { rig: rig, section: section, open: open };
+})();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index ac6da56..f596d77 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -2009,7 +2009,13 @@ function loadWingBook(pivot, onDone) {
 // meeting at the centre spine. Paged with [ ] / N-P. No arc, no auto-fly.
 // ============================================================
 const BOOK_SPINE_Z = -2.4;       // spine sits ~8ft ahead of the centred spawn
-const BOOK_LEAF_SPLAY = 0.30;    // radians each leaf opens off flat (gentle V, like an open book on a stand)
+// Per-leaf splay (degrees off flat). Steve's ask: wings at 65°, opening FORWARD toward the
+// viewer. Live-driven by the Open° slider, persisted to localStorage('qh_book_splay').
+let BOOK_SPLAY_DEG = (function () {
+  const v = parseFloat(localStorage.getItem('qh_book_splay'));
+  return (v >= 0 && v <= 90) ? v : 65;
+})();
+let BOOK_LEAF_SPLAY = BOOK_SPLAY_DEG * Math.PI / 180;   // radians each leaf opens off flat
 
 function buildCenterBook() {
   if (centerBook) return centerBook;
@@ -2026,11 +2032,12 @@ function buildCenterBook() {
 
   const faceGeo = new THREE.PlaneGeometry(leafW, H);
 
-  // RIGHT leaf — hinged at the spine (local x=0), face centred at +x. A small +y rotation
-  // swings its OUTER (+x) edge slightly back (-Z) so the book opens toward the viewer as a
-  // gentle V; the +Z textured front still faces the spawn.
+  // RIGHT leaf — hinged at the spine (local x=0), face centred at +x. A NEGATIVE +y rotation
+  // swings its OUTER (+x) edge FORWARD (+Z, toward the viewer) so the open book faces the
+  // avatar — spine farthest, pages opening toward you (Steve: "facing forward not backwards").
+  // The textured front rotates to (-sin, 0, +cos): still a +Z component → pattern faces the spawn.
   const leafR = new THREE.Group();
-  leafR.rotation.y = BOOK_LEAF_SPLAY;
+  leafR.rotation.y = -BOOK_LEAF_SPLAY;
   const faceR = new THREE.Mesh(faceGeo, new THREE.MeshStandardMaterial({ color: 0xece4d2, roughness: 0.7 }));
   faceR.position.set(leafW / 2, yC, 0);
   faceR.receiveShadow = true; faceR.castShadow = true;

← f1439ea Fix center-book leaf orientation: leaves face the viewer (dr  ·  back to Quadrille Showroom  ·  Center book: open leaves FORWARD at 65° + wire Open° slider 80267e9 →