← back to Quadrille Showroom
public/js/dock.js
459 lines
/* ============================================================================
* dock.js — The Quadrille House: 4-CORNER HAMBURGER DOCK.
*
* Standing ask (Steve, port 7690, 2026-07-02): the single top-right hamburger is
* replaced by FOUR corner hamburgers — one in each screen corner — and every
* control function is SPLIT across the corners by theme. Everything is COLLAPSED
* and clean on load (DTD verdict A); each corner's hamburger expands only its own
* group. Controls scale with viewport width (clamp) and the bottom pager always
* shows cleanly.
*
* TOP-LEFT → Collection (vendor list) + Wings / Overview / Peruse
* TOP-RIGHT → View & Theme + display tools (Explore, Open Board, Walls,
* Lighting, Music, Fullscreen)
* BOTTOM-LEFT→ Furniture Style (the style tray, now collapsible)
* BOTTOM-RIGHT→ Versions + Controls (sort, density, wing sliders, room size,
* window pager)
*
* Non-destructive by design: the original panels keep their IDs and ALL of their
* existing wiring (showroom.js / viewmodes.js / versions.js / furniture-styles.js
* bind by ID). We only (a) stow them on load (`.qh-stowed`), and (b) on launch
* wrap each in a `.qh-modal` shell (ModalRig-rigged), seeded to open ANCHORED to
* its owning corner. Panels carry a collapse (–) toggle so any element can be
* folded to its title bar.
*
* Depends on /js/modal-rig.js (window.ModalRig). Loaded LAST so every panel the
* other bolt-ons inject already exists; a MutationObserver + a dedicated poller
* mop up late arrivals (ageview's #btn-ageview; furniture-styles' #qh-style-tray,
* which is built async once the furniture API is live).
* ========================================================================== */
(function () {
'use strict';
// --- CORNERS: each owns a themed group of panels + tool proxy-clicks --------
// panels: existing elements launched as movable modals.
// tools : menu items that proxy-click an existing button (button stays in DOM).
var CORNERS = [
{ id: 'tl', pos: 'top-left', label: 'Collection & Tour',
panels: [ { sel: '#vendor-sidebar', label: 'Collection', w: 250, h: 420 } ],
tools: [ { sel: '#top-bar [data-section="wings"]', label: 'Wing Boards' },
{ sel: '#top-bar [data-section="overview"]', label: 'Overview' },
{ sel: '#btn-peruse', label: '▷ Peruse Collection' } ] },
{ id: 'tr', pos: 'top-right', label: 'View & Tools',
panels: [ { sel: '#vm-panel', label: 'View & Theme', w: 300, h: 380 } ],
tools: [ { sel: '#btn-explore', label: 'Explore (WASD)' },
{ sel: '#btn-bookmatch', label: 'Open Board' },
{ sel: '#btn-walls', label: 'Wallpaper Walls' },
{ sel: '#btn-lighting', label: 'Lighting' },
{ sel: '#btn-music', label: 'Music' },
{ sel: '#btn-fullscreen', label: 'Fullscreen' } ] },
{ id: 'bl', pos: 'bottom-left', label: 'Furniture Style',
panels: [ { sel: '#qh-style-tray', label: 'Furniture Style', w: 260, h: 200 } ],
tools: [] },
{ id: 'br', pos: 'bottom-right', label: 'Versions & Controls',
panels: [ { sel: '#ver-rail', label: 'Versions', w: 250, h: 440 },
{ sel: '#window-bar', label: 'Controls', w: 760, h: 260 } ],
tools: [] }
];
// --- STOW-ONLY: passive chrome hidden for a clean "just hamburgers" screen ---
var STOW_ONLY = [
'#top-bar', // whole top toolbar (tools folded into corner menus)
'#guided-instruction', '#guided-title', // guided-mode overlays
'#bottom-bar', '#now-viewing', // bottom navigator chatter (caption stays — book HUD)
'#minimap', // spatial minimap (explore aid)
'#welcome-tip', // "how to browse" tip card
'#chosen-tray', '#sample-tray' // side trays (re-appear via their own features)
];
var Z_BASE = 5000, zTop = Z_BASE; // modal stacking
var openOrder = []; // panel selectors, in focus order (last = top)
var LS_OPEN = 'qh_dock_open_v2';
// flat index of every panel across corners (sel -> {panel, corner})
var PANEL_INDEX = {};
CORNERS.forEach(function (c) {
c.panels.forEach(function (p) { PANEL_INDEX[p.sel] = { p: p, corner: c }; });
});
function allPanels() { return CORNERS.reduce(function (a, c) { return a.concat(c.panels); }, []); }
// ---------------------------------------------------------------- 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;}',
/* corner hamburgers — one per corner, responsive size */
'.qh-cburger{position:fixed;z-index:9000;',
'width:clamp(40px,5.2vw,48px);height:clamp(40px,5.2vw,48px);',
'display:flex;flex-direction:column;align-items:center;justify-content:center;gap:clamp(4px,0.6vw,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-cburger:hover{border-color:#c9a96e;}',
'.qh-cburger:active{transform:translateY(1px);}',
'.qh-cburger span{display:block;width:clamp(18px,2.4vw,22px);height:2.5px;border-radius:2px;',
'background:#c9a96e;transition:.2s;}',
'.qh-cburger.open span:nth-child(1){transform:translateY(7.5px) rotate(45deg);}',
'.qh-cburger.open span:nth-child(2){opacity:0;}',
'.qh-cburger.open span:nth-child(3){transform:translateY(-7.5px) rotate(-45deg);}',
/* corner placement */
'#qh-cb-tl{top:14px;left:16px;} #qh-cb-tr{top:14px;right:16px;}',
'#qh-cb-bl{bottom:16px;left:16px;} #qh-cb-br{bottom:16px;right:16px;}',
/* first-visit attention pulse (one-time) */
'.qh-cburger.qh-attn{animation:qh-attn-pulse 1.9s ease-out infinite;}',
'@keyframes qh-attn-pulse{0%{box-shadow:0 10px 30px -8px rgba(0,0,0,0.7),0 0 0 0 rgba(201,169,110,0.5);}' +
'70%{box-shadow:0 10px 30px -8px rgba(0,0,0,0.7),0 0 0 13px rgba(201,169,110,0);}' +
'100%{box-shadow:0 10px 30px -8px rgba(0,0,0,0.7),0 0 0 0 rgba(201,169,110,0);}}',
/* corner labels (tiny eyebrow under/over each burger, shown on hover) */
'.qh-clabel{position:fixed;z-index:8998;pointer-events:none;font-family:Georgia,serif;',
'font-size:10px;letter-spacing:1px;text-transform:uppercase;color:#c9b98a;',
'background:rgba(12,12,17,0.8);border:1px solid rgba(201,169,110,0.3);',
'padding:3px 8px;border-radius:7px;opacity:0;transition:opacity .18s;white-space:nowrap;}',
'.qh-cburger:hover + .qh-clabel{opacity:1;}',
/* per-corner launcher menu */
'.qh-cmenu{position:fixed;z-index:8999;width:min(230px,78vw);max-height:70vh;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-cmenu.open{display:block;}',
'#qh-cm-tl{top:70px;left:16px;} #qh-cm-tr{top:70px;right:16px;}',
'#qh-cm-bl{bottom:74px;left:16px;} #qh-cm-br{bottom:74px;right:16px;}',
'.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:44px;overflow:hidden;border-radius:13px;',
'max-width:96vw;',
'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.qh-collapsed{height:auto !important;resize:none !important;}',
'.qh-modal.qh-collapsed > .qh-pane-host{display:none !important;}',
'.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:6px;color:#c9a96e;}',
'.qh-modal-min,.qh-modal-x{cursor:pointer;opacity:.75;line-height:1;padding:0 2px;}',
'.qh-modal-min{font-size:17px;} .qh-modal-x{font-size:19px;}',
'.qh-modal-min:hover,.qh-modal-x:hover{opacity:1;color:#fff;}',
'.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');
}
// Seed a panel's FIRST-OPEN rect anchored to its owning corner (so it reads as
// "belonging" to the hamburger it launched from). A position the user later
// drags wins on subsequent opens (ModalRig.saveRect writes the same key).
function seedCornerRect(p, corner) {
var key = 'modalrig:qhdock3:' + p.sel;
try { if (localStorage.getItem(key)) return; } catch (e) {}
var w = p.w || 320, h = p.h || 360;
var m = 16, burgerZone = 70; // clear the corner burger
// Bottom corners must clear the always-visible centre pager so it "shows
// cleanly at the bottom" (Steve) and never eats the pager's clicks. 5x
// sweep-1 caught the wide Controls panel occluding #g-next — this is the fix.
var pagerBand = 76;
var vw = window.innerWidth, vh = window.innerHeight;
// stagger multiple panels in the same corner so they don't stack
var siblings = corner.panels;
var idx = siblings.findIndex(function (x) { return x.sel === p.sel; });
var off = (idx < 0 ? 0 : idx) * 34;
var bottomClear = (corner.pos.indexOf('bottom') > -1) ? pagerBand : 0;
var left, top;
if (corner.pos.indexOf('left') > -1) left = m + off; else left = Math.max(m, vw - w - m - off);
if (corner.pos.indexOf('top') > -1) top = burgerZone + off; else top = Math.max(m, vh - h - m - bottomClear - off);
left = Math.max(0, Math.min(left, vw - 80));
top = Math.max(0, Math.min(top, vh - 40));
try { localStorage.setItem(key, JSON.stringify({ left: left, top: top, w: w, h: h })); } catch (e) {}
}
// Launch (or re-show) a panel as a movable modal.
function launch(p) {
var el = $(p.sel);
if (!el) return;
var meta = PANEL_INDEX[p.sel];
var corner = meta ? meta.corner : CORNERS[0];
var wrap = el.closest('.qh-modal');
if (!wrap) {
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-min" title="Collapse">–</span>' +
'<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 = '';
var xBtn = head.querySelector('.qh-modal-x');
xBtn.setAttribute('data-rig-btn', '1');
xBtn.addEventListener('click', function (e) { e.stopPropagation(); hide(p); });
var minBtn = head.querySelector('.qh-modal-min');
minBtn.setAttribute('data-rig-btn', '1');
minBtn.addEventListener('click', function (e) {
e.stopPropagation();
var collapsed = wrap.classList.toggle('qh-collapsed');
minBtn.textContent = collapsed ? '▸' : '–'; // ▸ when folded
minBtn.title = collapsed ? 'Expand' : 'Collapse';
});
wrap.addEventListener('mousedown', function () { bringToFront(p.sel, wrap); });
seedCornerRect(p, corner);
ModalRig.rig(wrap, { key: 'qhdock3:' + p.sel, handle: head, width: p.w, height: p.h });
}
wrap.classList.remove('qh-stowed');
el.classList.remove('qh-stowed');
bringToFront(p.sel, wrap);
if (openOrder.indexOf(p.sel) === -1) openOrder.push(p.sel);
saveOpen(); reflectMenus();
}
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(); reflectMenus();
}
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) {}
}
// ----------------------------------------------------------------- menus ----
var burgers = {}, menus = {};
function closeAllMenus(except) {
CORNERS.forEach(function (c) {
if (c.id === except) return;
if (menus[c.id]) menus[c.id].classList.remove('open');
if (burgers[c.id]) burgers[c.id].classList.remove('open');
});
}
function dismissAttn() {
CORNERS.forEach(function (c) { if (burgers[c.id]) burgers[c.id].classList.remove('qh-attn'); });
try { localStorage.setItem('qh_corners_seen', '1'); } catch (e) {}
}
function buildCorner(c) {
var b = document.createElement('div');
b.className = 'qh-cburger';
b.id = 'qh-cb-' + c.id;
b.title = c.label;
b.innerHTML = '<span></span><span></span><span></span>';
document.body.appendChild(b);
burgers[c.id] = b;
var lab = document.createElement('div');
lab.className = 'qh-clabel';
lab.textContent = c.label;
// position the label just inboard of the burger
var m = 16, gap = 70;
if (c.pos === 'top-left') { lab.style.top = '22px'; lab.style.left = gap + 'px'; }
else if (c.pos === 'top-right') { lab.style.top = '22px'; lab.style.right = gap + 'px'; }
else if (c.pos === 'bottom-left') { lab.style.bottom = '24px'; lab.style.left = gap + 'px'; }
else { lab.style.bottom = '24px'; lab.style.right = gap + 'px'; }
document.body.appendChild(lab);
var menu = document.createElement('div');
menu.className = 'qh-cmenu';
menu.id = 'qh-cm-' + c.id;
var html = '';
if (c.panels.length) {
html += '<div class="qh-menu-eyebrow">Panels</div>';
c.panels.forEach(function (p) {
html += '<button class="qh-menu-item" data-panel="' + p.sel + '">' +
'<span>' + p.label + '</span><span class="qh-dot">●</span></button>';
});
}
if (c.tools.length) {
html += '<div class="qh-menu-eyebrow">Tools</div>';
c.tools.forEach(function (t, i) {
html += '<button class="qh-menu-item" data-tool-idx="' + i + '">' +
'<span>' + t.label + '</span><span class="qh-dot">●</span></button>';
});
}
menu.innerHTML = html;
document.body.appendChild(menu);
menus[c.id] = menu;
menu.addEventListener('click', function (e) {
var item = e.target.closest('.qh-menu-item'); if (!item) return;
var pSel = item.getAttribute('data-panel');
var tIdx = item.getAttribute('data-tool-idx');
if (pSel) {
var p = c.panels.filter(function (x) { return x.sel === pSel; })[0];
if (p) toggle(p);
closeAllMenus();
} else if (tIdx !== null) {
var t = c.tools[+tIdx];
var btn = t && $(t.sel);
if (btn) { btn.click(); setTimeout(reflectMenus, 30); }
closeAllMenus();
}
});
b.addEventListener('click', function () {
dismissAttn();
// single-item corner (1 panel, no tools) → toggle it directly, no menu
if (c.panels.length === 1 && c.tools.length === 0) {
toggle(c.panels[0]);
b.classList.toggle('open', isOpen(c.panels[0]));
closeAllMenus();
return;
}
var open = menu.classList.toggle('open');
b.classList.toggle('open', open);
closeAllMenus(c.id);
if (open) reflectMenus();
});
}
function buildCorners() {
CORNERS.forEach(buildCorner);
var seen = false;
try { seen = !!localStorage.getItem('qh_corners_seen'); } catch (e) {}
if (!seen) CORNERS.forEach(function (c) { if (burgers[c.id]) burgers[c.id].classList.add('qh-attn'); });
// click-away closes menus (never the modals)
document.addEventListener('mousedown', function (e) {
if (e.target.closest('.qh-cmenu') || e.target.closest('.qh-cburger')) return;
closeAllMenus();
});
}
function reflectMenus() {
CORNERS.forEach(function (c) {
var menu = menus[c.id]; if (!menu) return;
c.panels.forEach(function (p) {
var it = menu.querySelector('[data-panel="' + p.sel + '"]');
if (it) it.classList.toggle('active', isOpen(p));
});
c.tools.forEach(function (t, i) {
var it = menu.querySelector('[data-tool-idx="' + i + '"]');
var btn = $(t.sel);
if (it) it.classList.toggle('active', !!btn && btn.classList.contains('active'));
});
// single-item corner: reflect open-state on the burger itself
if (c.panels.length === 1 && c.tools.length === 0 && burgers[c.id]) {
burgers[c.id].classList.toggle('open', isOpen(c.panels[0]));
}
});
}
// ------------------------------------------------------------- stow + boot --
function stowAll() {
allPanels().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 meta = PANEL_INDEX[sel];
if (meta) launch(meta.p);
});
}
// The furniture-style tray is built async (furniture-styles.js waits for the
// furniture API). Poll for it, stow it on arrival, and if it was open last
// session re-launch it.
function ownStyleTray(tries) {
tries = tries || 0;
var el = $('#qh-style-tray');
if (el) {
if (!el.closest('.qh-modal')) stow(el);
var saved = [];
try { saved = JSON.parse(localStorage.getItem(LS_OPEN) || '[]') || []; } catch (e) {}
if (saved.indexOf('#qh-style-tray') > -1) launch(PANEL_INDEX['#qh-style-tray'].p);
return;
}
if (tries > 240) return; // ~14s
setTimeout(function () { ownStyleTray(tries + 1); }, 60);
}
function boot() {
if (!window.ModalRig) return setTimeout(boot, 60);
injectStyles();
stowAll();
buildCorners();
var mo = new MutationObserver(function () { stowAll(); });
mo.observe(document.body, { childList: true, subtree: true });
setTimeout(function () { mo.disconnect(); }, 4000);
restoreOpen();
ownStyleTray(0);
// 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 meta = PANEL_INDEX[sel];
if (meta) { hide(meta.p); e.stopPropagation(); }
}, true);
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
else boot();
})();