← 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();
})();