← 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
A public/js/dock.jsA public/js/modal-rig.jsM public/js/showroom.js
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 →