← back to Quadrille Showroom

public/js/viewmodes.js

905 lines

/* ============================================================================
 * viewmodes.js — The Quadrille House showroom: View-Mode + Theme engine.
 *
 * Self-contained module loaded AFTER showroom.js. Talks to the showroom only
 * through the stable window._qh API (see bottom of showroom.js). Adds:
 *   • a labelled "View" panel of 10 switchable presentation modes (TASK 1)
 *   • a "Theme" panel of 6 lighting/palette moods (TASK 2)
 *   • a toggleable perf readout — FPS / frame-ms / drawCalls / triangles (TASK 3)
 * Remembers the last View + Theme + perf-toggle in localStorage.
 *
 * Hard rule kept: Hero is the boot view; the senior guided Next/Back/Auto-Tour
 * keeps working in every mode that makes sense. Nothing here regresses the
 * locked features — it sits on top of focusOnWing / enterGuidedDefault / peruse.
 * ========================================================================== */
(function () {
'use strict';

function boot() {
  const QH = window._qh;
  if (!QH || !QH.wingBoards || !QH.wingBoards.length) { return setTimeout(boot, 250); }
  const THREE = QH.THREE;
  const ROOM = QH.CONFIG.room;
  const WING = QH.CONFIG.wing;
  const backZ = -ROOM.depth / 2 + 0.2;     // wing-wall plane
  const boardCY = WING.height / 2 + 0.04;  // vertical centre of a 6-ft board
  const MACRO_DIST = 0.762;                // macro camera distance = 2.5 ft (Steve 2026-06-30)

  // ---- state ----
  let currentMode = null;     // active view-mode key
  let currentTheme = null;    // active theme key
  let orbitSpin = 0;          // accumulated turntable / orbit angle
  let orbitAuto = false;      // orbit mode: auto-spin until the user drags
  let carouselT = 0;          // carousel rotation phase
  let _carouselWG = null;     // cached wall group (avoid full scene.traverse per frame)
  let _carouselShadowAccum = 0; // throttle carousel shadow rebakes
  let macroPhase = 0;         // macro slow drift phase
  let compareB = -1;          // compare: user-chosen SECOND board index (-1 = auto next)
  let compareKeyHandler = null; // compare: keydown listener to cycle the 2nd design
  let compareSetup = false;   // compare mode: two boards staged
  let savedFocusIndex = 0;    // remember which board to return to
  let tableSetUp = false;     // table mode: seated set + overlay staged
  let tableClickHandler = null; // table mode: capture-phase board-pick click listener

  // The mode that the guided Next/Back/Auto-Tour buttons operate within.
  // Hero / Angled / Macro / Compare / Room re-frame the SAME focused board, so
  // guided nav stays meaningful. Orbit / Gallery / Top-down / Walk / Carousel are
  // free-camera surveys; guided Next still advances the focused board underneath.

  // ===========================================================================
  // VIEW MODES — each: { label, hint, guided(bool), enter(), exit()?, frame(dt)? }
  // enter() positions the camera / sets the presentation; frame() runs per-rAF.
  // ===========================================================================
  function curIdx() {
    const f = QH.focusedWing; return f && f.userData ? f.userData.index : savedFocusIndex;
  }
  function boardWorld(i) {
    const b = QH.wingBoards[i]; if (!b) return new THREE.Vector3(0, boardCY, backZ);
    const v = new THREE.Vector3(); b.getWorldPosition(v); v.y = boardCY; return v;
  }
  // Lock the camera to a fixed pose (mode survey shots that shouldn't drift).
  function park(pos, target, fov) {
    QH.setControlsLocked(false);
    QH.smoothCameraTo(pos, target, () => { QH.lockControls(pos.clone(), target.clone()); }, fov);
  }
  // Free the camera so a frame() hook (orbit/carousel) can drive it.
  function freeCam() { QH.setControlsLocked(false); QH.unlockControls(); }

  // Ensure a board is focused/open AND isolated so ONE design is presented
  // (Hero / Angled / Macro / Room). If already focused on this board, re-isolate
  // explicitly (focusOnWing only isolates on a focus *change*).
  function ensureFocused(i) {
    const idx = (i == null) ? curIdx() : i;
    if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) {
      QH.restoreBoards();
      if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]);
    } else {
      QH.isolateBoard(QH.focusedWing);   // re-hide neighbours → single hero design
    }
  }

  // Compare-mode render: show boards i + j open, frame the pair, name both in the banner.
  function renderCompare(i, j) {
    QH.revertWalls();
    const boards = QH.wingBoards;
    boards.forEach((b, k) => { b.visible = (k === i || k === j); if (b.userData.wallDrop) b.userData.wallDrop.visible = false; });
    [i, j].forEach(k => {
      const ud = boards[k] && boards[k].userData; if (!ud) return;
      ud.panelClosed = false;
      if (ud.leafL) ud.leafL.rotation.y = 0.10;
      if (ud.leafR) ud.leafR.rotation.y = -0.10;
    });
    const a = boardWorld(i), b = boardWorld(j);
    const midX = (a.x + b.x) / 2;
    park(new THREE.Vector3(midX, boardCY, backZ + 2.9), new THREE.Vector3(midX, boardCY, backZ), 44);
    const nameOf = (k) => { const p = boards[k] && boards[k].userData && boards[k].userData.product; return p ? (p.pattern_name || p.name || ('Board ' + (k + 1))) : ('Board ' + (k + 1)); };
    setBanner('Comparing “' + nameOf(i) + '”  vs  “' + nameOf(j) + '”   ·   ◀ ▶ change the second design');
  }

  const MODES = {
    hero: {
      label: 'Hero', hint: 'Flat, dead-on — the gallery hero panel.', guided: true,
      enter() {
        // BOOT GUARD (Slice-2): on the very first load, do NOT auto-focus the middle
        // board — that would clad the walls + open the detail panel + mark a selection,
        // violating the Slice-1 boot invariant (resting-open middle, no selection, no
        // panel, neutral walls). The showroom's enterGuidedDefault owns the boot pose;
        // hero only focuses once the user has actually interacted (a real click/nav).
        if (window._qhBootSettled === false) { setWingDeg(8); return; }
        ensureFocused();
        // focusOnWing already gives the PJ flat hero framing; just make sure the
        // open angle is near-flat for this mode.
        setWingDeg(8);
      }
    },

    gallery: {
      label: 'Gallery', hint: 'Pull back to see the whole wall of boards.', guided: false,
      enter() {
        // Close + show every board on the rail; neutral walls; straight-on from the
        // FRONT of the room so the whole 6-ft wall of boards reads at a glance.
        QH.restoreBoards();
        if (QH.focusedWing) QH.unfocusWing(true);
        QH.revertWalls();
        setWingDeg(0);
        const pos = new THREE.Vector3(0, 1.55, ROOM.depth / 2 - 0.2);
        const tgt = new THREE.Vector3(0, boardCY, backZ);
        park(pos, tgt, 62);
      }
    },

    walk: {
      label: 'Walk-Through', hint: 'First-person — W A S D to walk the room.', guided: false,
      enter() {
        QH.restoreBoards();
        if (QH.focusedWing) QH.unfocusWing(true);
        QH.revertWalls();
        setWingDeg(0);
        freeCam();
        const ctl = QH.controls;
        ctl.enableRotate = true; ctl.enableZoom = true; ctl.enablePan = false;
        // Hand off to the showroom's native Explore so WASD + proximity book-match work.
        if (!QH.exploreMode && window._toggleExploreFromViewmode) window._toggleExploreFromViewmode(true);
        // Stand IN THE DOORWAY at the back (entrance) end of the room, centred, eye-level,
        // looking straight in toward the board wall (Steve 2026-07-01).
        const pos = new THREE.Vector3(0, 1.6, ROOM.depth / 2 - 0.25);
        QH.camera.position.copy(pos);
        ctl.target.set(0, 1.4, backZ);
        QH.setTargetFov(55); QH.camera.fov = 55; QH.camera.updateProjectionMatrix();
      },
      exit() {
        // Leave Explore again so the other modes get a parked camera.
        if (QH.exploreMode && window._toggleExploreFromViewmode) window._toggleExploreFromViewmode(false);
      }
    },

    carousel: {
      label: 'Carousel', hint: 'Boards rotate past like a turntable.', guided: false,
      enter() {
        QH.restoreBoards();
        if (QH.focusedWing) QH.unfocusWing(true);
        QH.revertWalls();
        setWingDeg(0);
        freeCam();
        carouselT = 0;
        // Camera sits in the room centre looking at the wall; the WALL GROUP yaws.
        const pos = new THREE.Vector3(0, 1.55, ROOM.depth / 2 - 1.4);
        const tgt = new THREE.Vector3(0, boardCY, backZ);
        park(pos, tgt, 50);
      },
      frame(dt) {
        // Yaw the whole wing-wall group slowly so boards parade past — a lazy-susan.
        // PERF: cache the wall group (full scene.traverse every frame was the FPS
        // killer — dropped V2 to ~31fps) and throttle shadow rebakes to ~6/s instead
        // of every frame (the rotation is slow; 60 rebakes/s is pure waste).
        if (!_carouselWG) _carouselWG = findWallGroup();
        const wg = _carouselWG;
        if (wg) {
          // CONTINUOUS parade (Steve 2026-06-30) — boards rotate steadily past like a
          // real turntable, instead of the old Math.sin() rock (which just wobbled the
          // arc back and forth ±0.5 rad and never actually paraded a new board to front).
          wg.rotation.y += dt * 0.18;
          if (wg.rotation.y > Math.PI * 2) wg.rotation.y -= Math.PI * 2;
          _carouselShadowAccum += dt;
          if (_carouselShadowAccum > 0.16) { _carouselShadowAccum = 0; if (QH.requestShadowUpdate) QH.requestShadowUpdate(1); }
        }
      },
      exit() { if (_carouselWG) _carouselWG.rotation.y = 0; _carouselWG = null; _carouselShadowAccum = 0; }
    },

    macro: {
      label: 'Macro', hint: 'Zoom close to read the texture detail.', guided: true,
      enter() {
        ensureFocused();
        setWingDeg(2);
        const c = boardWorld(curIdx());
        // Zoom in to exactly 2.5 FEET from the board (Steve 2026-06-30) — 0.762 m —
        // tight FOV to read the weave/print. Slight off-centre so grazing light catches
        // the surface. MACRO_DIST is reused by the parallax drift so it stays at 2.5 ft.
        const pos = new THREE.Vector3(c.x - 0.12, c.y + 0.15, c.z + MACRO_DIST);
        const tgt = new THREE.Vector3(c.x - 0.05, c.y + 0.1, c.z);
        park(pos, tgt, 30);
        macroPhase = 0;
      },
      frame(dt) {
        // Gentle parallax drift so the macro shot feels alive (purely visual).
        macroPhase += dt * 0.4;
        if (QH.controlsLocked) {
          const c = boardWorld(curIdx());
          const dx = Math.sin(macroPhase) * 0.05, dy = Math.cos(macroPhase * 0.7) * 0.03;
          QH.camera.position.x = c.x - 0.12 + dx;
          QH.camera.position.y = c.y + 0.15 + dy;
          QH.camera.lookAt(c.x - 0.05, c.y + 0.1, c.z);
        }
      }
    },

    compare: {
      label: 'Compare', hint: 'Two designs side-by-side — ◀ ▶ change the second one.', guided: false,
      enter() {
        // A = the current board; B = a user-CHOOSABLE second design (Steve 2026-06-30).
        // Previously compare force-picked i+1 with no way to change it — confusing. Now
        // ◀ ▶ (or [ ]) cycle the second design; the banner names both so you know what
        // you're looking at.
        const len = QH.wingBoards.length;
        const i = curIdx();
        if (compareB < 0 || compareB === i || compareB >= len) compareB = (i + 1) % len;
        renderCompare(i, compareB);
        compareKeyHandler = (e) => {
          if (!compareSetup) return;
          if (e.code === 'ArrowRight' || e.key === ']') { do { compareB = (compareB + 1) % len; } while (compareB === i); renderCompare(i, compareB); e.preventDefault(); }
          else if (e.code === 'ArrowLeft' || e.key === '[') { do { compareB = (compareB - 1 + len) % len; } while (compareB === i); renderCompare(i, compareB); e.preventDefault(); }
        };
        window.addEventListener('keydown', compareKeyHandler);
        compareSetup = true;
      },
      exit() {
        compareSetup = false;
        if (compareKeyHandler) { window.removeEventListener('keydown', compareKeyHandler); compareKeyHandler = null; }
        QH.restoreBoards();
        clearBanner();
      }
    },

    room: {
      label: 'Room Preview', hint: 'See the pattern clad on a full room wall.', guided: true,
      enter() {
        ensureFocused();           // focusing already clads the walls (wallsAutoClad)
        setWingDeg(0);
        const f = QH.focusedWing;
        if (f && f.userData.product) {
          QH.cladWalls(f.userData.product.image, f.userData.product.isSeamlessTile);
        }
        // Stand the camera back in the room so the CLAD WALL fills the frame, board small.
        const c = boardWorld(curIdx());
        const pos = new THREE.Vector3(c.x + 1.1, 1.55, ROOM.depth / 2 - 0.9);
        const tgt = new THREE.Vector3(c.x - 0.3, 1.35, backZ);
        park(pos, tgt, 56);
      }
    },

    // PHASE 3 — "At the Table" (DTD verdict A). A SEATED third-person avatar at a
    // consultation table in front of the boards. Clicking a board still centres that
    // design (reuses focusOnWing → _openCenteredBoard → cladWalls). A CSS-3D sample
    // overlay (ported from proto/sample-table.html) lets the user drop the currently-
    // centred product's swatch onto the table + drag/remove it; the basket persists to
    // localStorage('qh_sample_basket').
    table: {
      label: 'At the Table', hint: 'Sit at the consultation table — add swatches, drag to arrange.', guided: true,
      enter() {
        // FIRST entry (not a guided-reframe re-entry): reset to the full rail of boards on
        // neutral walls so the seated user surveys every design, none pre-selected. On a
        // reframe (tableSetUp already true) we skip this so we don't unfocus the board the
        // user just navigated to.
        if (!tableSetUp) {
          QH.restoreBoards();
          if (QH.focusedWing) QH.unfocusWing(true);
          QH.revertWalls();
        }
        // Reveal the seated set + table (idempotent) and park the over-the-shoulder camera.
        if (QH.setTableSeatVisible) QH.setTableSeatVisible(true);
        if (window._qhNookGroup) window._qhNookGroup.visible = false;  // hide the side nook so only the centre table shows
        setWingDeg(0);
        parkTableCamera();
        // A table-mode click handler owns board selection: because parkTableCamera() locks
        // the camera (so onCanvasClick early-returns and clicks don't snap the eye back to
        // room-centre), we raycast boards ourselves, centre the design via the existing
        // focusOnWing → _openCenteredBoard → cladWalls path, then re-park the table camera.
        if (!tableClickHandler) {
          tableClickHandler = (e) => {
            if (currentMode !== 'table') return;
            // Ignore clicks that land on the sample overlay / UI (they have their own handlers).
            if (e.target && e.target.closest &&
                e.target.closest('#vm-sample-stage, #vm-sample-add, #vm-panel, #vm-perf, #vm-banner, ' +
                                 '#wing-detail, #window-bar, #dock, button, input, select, a')) return;
            const idx = pickBoardAt(e.clientX, e.clientY);
            if (idx < 0) return;
            const b = QH.wingBoards[idx];
            if (b) {
              // We handled this click — stop it reaching showroom.onCanvasClick (which,
              // during the ~0.8s before park() locks controls, would otherwise ALSO fire
              // focusOnWing and snap the camera off the table pose). Then re-park.
              e.stopPropagation();
              QH.focusOnWing(b);
              // focusOnWing synchronously snaps the eye to room-centre (its normal behaviour);
              // immediately re-park so the camera glides straight back to the table pose
              // instead of flashing through centre. A second re-park on next tick covers the
              // async carousel settle that may re-touch the pose.
              parkTableCamera();
              setTimeout(parkTableCamera, 60);
            }
          };
          window.addEventListener('click', tableClickHandler, true);
        }
        showSampleTable();
        tableSetUp = true;
      },
      exit() {
        tableSetUp = false;
        hideSampleTable();
        if (tableClickHandler) { window.removeEventListener('click', tableClickHandler, true); tableClickHandler = null; }
        if (QH.setTableSeatVisible) QH.setTableSeatVisible(false);
        if (window._qhNookGroup) window._qhNookGroup.visible = true;   // restore the side nook when leaving table view
      }
    }
  };

  // Park the camera behind + above the seated figure, looking past it at the boards.
  function parkTableCamera() {
    const cam = (QH.tableViewCamera && QH.tableViewCamera()) ||
      { pos: new THREE.Vector3(0.55, 1.85, backZ + 3.2), target: new THREE.Vector3(0, boardCY, backZ) };
    park(cam.pos.clone(), cam.target.clone(), 52);
  }
  // Raycast the board faces from the CURRENT (table) camera and return the hit board index,
  // or -1. Mirrors showroom.onCanvasClick's raycast but works while the camera is locked.
  let _tableRay = null, _tableMouse = null;
  function pickBoardAt(clientX, clientY) {
    if (!_tableRay) { _tableRay = new THREE.Raycaster(); _tableMouse = new THREE.Vector2(); }
    _tableMouse.x = (clientX / window.innerWidth) * 2 - 1;
    _tableMouse.y = -(clientY / window.innerHeight) * 2 + 1;
    _tableRay.setFromCamera(_tableMouse, QH.camera);
    const faces = [];
    QH.wingBoards.forEach(p => { const ud = p.userData; if (ud.faceL) faces.push(ud.faceL); if (ud.faceR) faces.push(ud.faceR); });
    const hits = _tableRay.intersectObjects(faces, false);
    if (hits.length && hits[0].object.userData.parentPivot) {
      return hits[0].object.userData.parentPivot.userData.index;
    }
    return -1;
  }

  const MODE_ORDER = ['walk','hero','gallery','carousel','macro','compare','room','table'];

  // Helper to drive the showroom's open-board angle (the Wing° slider value).
  function setWingDeg(deg) {
    const ang = document.getElementById('angle-range');
    if (ang) { ang.value = deg; ang.dispatchEvent(new Event('input')); }
  }
  function findWallGroup() {
    let g = null;
    QH.scene.traverse(o => { if (!g && o.isGroup && o.children && o.children.some(ch => ch.geometry && ch.geometry.type === 'BoxGeometry' && Math.abs(ch.position.y - (WING.height + 0.08)) < 0.05)) g = o; });
    return g;
  }
  function setCeilingVisible(v) { if (window._ceilingMesh) window._ceilingMesh.visible = v; }
  function setBanner(txt) { const el = document.getElementById('vm-banner'); if (el) { el.textContent = txt; el.style.display = 'block'; } }
  function clearBanner() { const el = document.getElementById('vm-banner'); if (el) el.style.display = 'none'; }

  // ===========================================================================
  // PHASE 3 — "At the Table" CSS-3D SAMPLE OVERLAY.
  // Ported from public/proto/sample-table.html (the proven CSS-3D interaction). A
  // transparent overlay anchored over the lower-screen "table" plane: a "+ Add sample"
  // affordance drops the currently-centred product's swatch as a small card lying flat
  // in perspective; cards can be dragged to rearrange and removed with ×. The basket
  // persists to localStorage('qh_sample_basket'). This coexists with the 3D scene (the
  // seated figure + wood table live in Three.js; the draggable swatch cards are this
  // lightweight CSS layer, exactly the low-risk path the spec preferred).
  // ===========================================================================
  const SAMPLE_COS = Math.cos(54 * Math.PI / 180);   // matches the proto table rotateX
  let _sampleSlotIndex = 0;
  let _samplePlaced = [];   // [{el, sku}]

  function injectSampleTableStyles() {
    if (document.getElementById('vm-sample-style')) return;
    const s = document.createElement('style');
    s.id = 'vm-sample-style';
    s.textContent = [
      '#vm-sample-stage{ position:fixed; inset:0; z-index:40; pointer-events:none;',
        'perspective:1150px; perspective-origin:50% 40%; display:none; }',
      '#vm-sample-stage.on{ display:block; }',
      '#vm-sample-table{ position:absolute; left:50%; bottom:-6vh; width:170vw; height:78vh;',
        'transform:translateX(-50%) rotateX(54deg); transform-style:preserve-3d; }',
      '#vm-sample-surface{ position:absolute; inset:0; transform-style:preserve-3d; }',
      '.vm-sample{ position:absolute; width:150px; height:150px; margin:-75px 0 0 -75px;',
        'transform-style:preserve-3d; cursor:grab; will-change:transform,opacity; pointer-events:auto; }',
      '.vm-sample .paper{ position:absolute; inset:0; border-radius:3px; background:#fbf7ee; padding:6px 6px 22px;',
        'box-shadow:0 1px 0 rgba(255,255,255,.7) inset, 0 24px 30px -12px rgba(20,14,6,.55), 0 6px 12px -6px rgba(20,14,6,.5);',
        'border:1px solid rgba(120,96,60,.35); }',
      '.vm-sample .swatch{ position:absolute; left:6px; right:6px; top:6px; bottom:22px; border-radius:2px;',
        'background-size:cover; background-position:center; box-shadow:inset 0 0 0 1px rgba(0,0,0,.06); }',
      '.vm-sample .plate{ position:absolute; left:6px; right:6px; bottom:5px; height:14px; display:flex;',
        'align-items:center; justify-content:space-between; gap:5px; font-family:"Cormorant Garamond",Georgia,serif; }',
      '.vm-sample .pname{ font-size:10px; line-height:1; color:#2b2114; letter-spacing:.2px; white-space:nowrap;',
        'overflow:hidden; text-overflow:ellipsis; max-width:74%; }',
      '.vm-sample .pcol{ font-size:8px; letter-spacing:1px; text-transform:uppercase; color:#9a7a52; white-space:nowrap; }',
      '.vm-sample .contact{ position:absolute; left:6%; right:6%; top:14%; bottom:-6%; border-radius:50%;',
        'background:rgba(20,12,4,.34); filter:blur(10px); transform:translateZ(-2px); z-index:-1; }',
      '.vm-sample .rm{ position:absolute; top:-12px; right:-12px; width:28px; height:28px; border-radius:50%;',
        'border:1.5px solid #fff; background:#1c1c22; color:#fff; font:700 15px/1 -apple-system,Segoe UI,sans-serif;',
        'cursor:pointer; display:none; align-items:center; justify-content:center;',
        'box-shadow:0 6px 16px -4px rgba(0,0,0,.6); transform:translateZ(28px); }',
      '.vm-sample:hover .rm{ display:flex; }',
      '.vm-sample.lifted{ cursor:grabbing; }',
      // "+ Add sample" affordance + running count, bottom-LEFT (clear of the centred
      // #window-bar sort/density controls at bottom:54px).
      '#vm-sample-add{ position:fixed; left:22px; bottom:22px; z-index:42;',
        'display:none; align-items:center; gap:12px; }',
      '#vm-sample-add.on{ display:flex; }',
      '#vm-sample-addbtn{ min-height:44px; padding:0 22px; border-radius:11px; cursor:pointer;',
        'border:1.5px solid #b9933f; color:#1a1206; letter-spacing:.4px;',
        'font:700 16px/1 "Cormorant Garamond",Georgia,serif; background:linear-gradient(180deg,#e3cb95,#c19a44);',
        'box-shadow:0 7px 18px -8px rgba(0,0,0,.55); transition:transform .1s,filter .1s; }',
      '#vm-sample-addbtn:hover{ filter:brightness(1.05); transform:translateY(-1px); }',
      '#vm-sample-addbtn:active{ transform:translateY(1px); }',
      '#vm-sample-count{ font:600 12px/1 -apple-system,Segoe UI,sans-serif; letter-spacing:1.5px;',
        'text-transform:uppercase; color:#cbb98e; background:rgba(16,13,9,.86); border:1px solid rgba(201,169,110,.4);',
        'padding:10px 14px; border-radius:11px; backdrop-filter:blur(8px); }'
    ].join('\n');
    document.head.appendChild(s);
  }

  function buildSampleTableDom() {
    if (document.getElementById('vm-sample-stage')) return;
    injectSampleTableStyles();
    const stage = document.createElement('div');
    stage.id = 'vm-sample-stage';
    stage.innerHTML = '<div id="vm-sample-table"><div id="vm-sample-surface"></div></div>';
    document.body.appendChild(stage);

    const add = document.createElement('div');
    add.id = 'vm-sample-add';
    add.innerHTML = '<button id="vm-sample-addbtn">+ Add sample to table</button>' +
                    '<span id="vm-sample-count">0 samples</span>';
    document.body.appendChild(add);

    document.getElementById('vm-sample-addbtn').addEventListener('click', () => {
      const p = currentCenteredProduct();
      if (!p) { flashAddHint('Click a board to centre a design first'); return; }
      addSampleToTable(p);
    });
    // Restore any previously-basketed skus as a count hint (cards aren't re-hydrated —
    // the persisted basket is a request list, not a scene snapshot).
    updateSampleCount();
  }

  function flashAddHint(msg) {
    const c = document.getElementById('vm-sample-count');
    if (!c) return;
    c.textContent = msg;
    clearTimeout(flashAddHint._t);
    flashAddHint._t = setTimeout(() => { updateSampleCount(); }, 1800);
  }

  function currentCenteredProduct() {
    const f = QH.focusedWing;
    if (f && f.userData && f.userData.product) return f.userData.product;
    // No board focused yet — fall back to the current/middle board so "Add sample"
    // always has a swatch to drop (prevents a null-deref when nothing's been clicked).
    const boards = QH.wingBoards || [];
    const i = curIdx();
    const b = boards[i] || boards[Math.floor(boards.length / 2)];
    return (b && b.userData && b.userData.product) ? b.userData.product : null;
  }

  function imgUrl(u) {
    if (!u) return '';
    if (u.charAt(0) === '/') return u;
    return '/api/proxy/image?url=' + encodeURIComponent(u);
  }
  function escHtml(s) {
    return String(s == null ? '' : s).replace(/[&<>"']/g, m =>
      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
  }
  function sampleName(p) { return p.pattern_name || p.name || p.title || p.sku || 'Sample'; }
  function sampleColor(p) { return p.color || p.color_name || p.collection || p.vendor || ''; }

  function tableSize() {
    const t = document.getElementById('vm-sample-table');
    return t ? { w: t.clientWidth, h: t.clientHeight } : { w: window.innerWidth, h: window.innerHeight };
  }
  // Golden-angle spiral spread biased to the visible near band of the table (from proto).
  function nextSampleSlot() {
    const s = tableSize();
    const cx = s.w * 0.5, cy = s.h * 0.42;
    const i = _sampleSlotIndex++;
    const ang = i * 2.399963;                       // golden angle
    let rad = Math.min(s.w, s.h) * 0.072 * Math.sqrt(i + 0.6);
    rad = Math.min(rad, Math.min(s.w, s.h) * 0.22);
    const jx = ((Math.sin(i * 12.9898) * 43758.5453) % 1) * 70 - 35;
    const jy = ((Math.sin(i * 78.233) * 12543.1234) % 1) * 40 - 20;
    const x = cx + Math.cos(ang) * rad * 1.25 + jx;
    const y = cy + Math.sin(ang) * rad * 0.55 + jy;
    const rot = (((Math.sin(i * 3.1) * 1000)) % 1) * 36 - 18;
    return { x, y, rot };
  }

  function persistBasket() {
    try {
      const skus = _samplePlaced.map(r => r.sku).filter(Boolean);
      localStorage.setItem('qh_sample_basket', JSON.stringify(skus));
    } catch (e) {}
  }

  function updateSampleCount() {
    const el = document.getElementById('vm-sample-count');
    if (!el) return;
    const n = _samplePlaced.length;
    el.textContent = n + (n === 1 ? ' sample' : ' samples');
  }

  function addSampleToTable(p) {
    if (!p) { flashAddHint && flashAddHint('Click a design first'); return; }
    const surface = document.getElementById('vm-sample-surface');
    if (!surface) return;
    // De-dupe: same SKU already on the table → don't stack, just nudge the count.
    if (p.sku && _samplePlaced.some(r => r.sku === p.sku)) { flashAddHint('Already on the table'); return; }

    const slot = nextSampleSlot();
    const el = document.createElement('div');
    el.className = 'vm-sample';
    el.dataset.sku = p.sku || '';
    el.innerHTML =
      '<div class="contact"></div>' +
      '<div class="paper">' +
        '<div class="swatch" style="background-image:url(\'' + imgUrl(p.tile || p.image) + '\')"></div>' +
        '<div class="plate">' +
          '<span class="pname">' + escHtml(sampleName(p)) + '</span>' +
          '<span class="pcol">' + escHtml(sampleColor(p)) + '</span>' +
        '</div>' +
      '</div>' +
      '<button class="rm" title="Remove">×</button>';

    const land = 'translate3d(' + slot.x + 'px,' + slot.y + 'px,0) rotateZ(' + slot.rot.toFixed(1) + 'deg)';
    const lift = 'translate3d(' + slot.x + 'px,' + (slot.y - 60) + 'px,140px) rotateZ(' + (slot.rot - 20).toFixed(1) + 'deg) scale(1.06)';
    el.style.transform = lift;
    el.style.opacity = '0';
    surface.appendChild(el);
    // force reflow then animate to the landed transform
    void el.offsetWidth;
    el.style.transition = 'transform .6s cubic-bezier(.22,1.08,.36,1), opacity .3s ease-out';
    el.style.opacity = '1';
    el.style.transform = land;
    el.dataset.x = slot.x; el.dataset.y = slot.y; el.dataset.rot = slot.rot;

    el.querySelector('.rm').addEventListener('click', (ev) => {
      ev.stopPropagation();
      el.style.transition = 'transform .38s ease-in, opacity .38s ease-in';
      el.style.transform = 'translate3d(' + slot.x + 'px,' + (slot.y - 80) + 'px,170px) scale(.9)';
      el.style.opacity = '0';
      setTimeout(() => { el.remove(); }, 380);
      _samplePlaced = _samplePlaced.filter(r => r.el !== el);
      updateSampleCount();
      persistBasket();
    });

    enableSampleDrag(el);
    _samplePlaced.push({ el, sku: p.sku });
    updateSampleCount();
    persistBasket();
  }

  // Perspective-correct drag (from proto): screen-Y delta un-foreshortened by cos(54°).
  function enableSampleDrag(el) {
    const surface = document.getElementById('vm-sample-surface');
    let sx, sy, ox, oy, rot, active = false;
    function down(e) {
      if (e.target.classList.contains('rm')) return;
      active = true; el.classList.add('lifted');
      const pt = e.touches ? e.touches[0] : e;
      sx = pt.clientX; sy = pt.clientY;
      ox = parseFloat(el.dataset.x) || 0; oy = parseFloat(el.dataset.y) || 0;
      rot = parseFloat(el.dataset.rot) || 0;
      if (surface) surface.appendChild(el);   // bring to front
      el.style.transition = 'transform .12s ease-out';
      el.style.transform = 'translate3d(' + ox + 'px,' + oy + 'px,40px) rotateZ(' + rot + 'deg) scale(1.04)';
      e.preventDefault();
      window.addEventListener('mousemove', move); window.addEventListener('mouseup', up);
      window.addEventListener('touchmove', move, { passive: false }); window.addEventListener('touchend', up);
    }
    function move(e) {
      if (!active) return;
      const pt = e.touches ? e.touches[0] : e;
      const nx = ox + (pt.clientX - sx);
      const ny = oy + (pt.clientY - sy) / SAMPLE_COS;
      el.style.transition = 'none';
      el.style.transform = 'translate3d(' + nx + 'px,' + ny + 'px,40px) rotateZ(' + rot + 'deg) scale(1.04)';
      el.dataset.x = nx; el.dataset.y = ny;
      e.preventDefault();
    }
    function up() {
      if (!active) return; active = false; el.classList.remove('lifted');
      const nx = parseFloat(el.dataset.x) || 0, ny = parseFloat(el.dataset.y) || 0;
      el.style.transition = 'transform .2s cubic-bezier(.22,1,.36,1)';
      el.style.transform = 'translate3d(' + nx + 'px,' + ny + 'px,0) rotateZ(' + rot + 'deg)';
      window.removeEventListener('mousemove', move); window.removeEventListener('mouseup', up);
      window.removeEventListener('touchmove', move); window.removeEventListener('touchend', up);
    }
    el.addEventListener('mousedown', down);
    el.addEventListener('touchstart', down, { passive: false });
  }

  function showSampleTable() {
    buildSampleTableDom();
    const stage = document.getElementById('vm-sample-stage');
    const add = document.getElementById('vm-sample-add');
    if (stage) stage.classList.add('on');
    if (add) add.classList.add('on');
  }
  function hideSampleTable() {
    const stage = document.getElementById('vm-sample-stage');
    const add = document.getElementById('vm-sample-add');
    if (stage) stage.classList.remove('on');
    if (add) add.classList.remove('on');
  }

  // ===========================================================================
  // THEMES — lighting moods. Each tweaks exposure + light intensities + colour
  // temperature + fog + a CSS vignette class, WITHOUT crushing swatch legibility.
  // ===========================================================================
  const THEMES = {
    bright:   { label: 'Bright Gallery', exposure: 1.16, ambient: 0.55, hemi: 0.50, key: 1.05, fill: 0.30, pic: 0.55, keyColor: 0xfff6ea, bg: 0x14141a, fog: 0.016, vignette: 0.06 },
    warm:     { label: 'Warm Atelier',  exposure: 1.10, ambient: 0.42, hemi: 0.40, key: 1.15, fill: 0.22, pic: 0.70, keyColor: 0xffe9c8, bg: 0x16110a, fog: 0.020, vignette: 0.14 },
    cool:     { label: 'Cool Modern',   exposure: 1.12, ambient: 0.50, hemi: 0.46, key: 1.00, fill: 0.40, pic: 0.45, keyColor: 0xeaf0ff, bg: 0x101218, fog: 0.016, vignette: 0.08 },
    evening:  { label: 'Evening',       exposure: 1.02, ambient: 0.22, hemi: 0.18, key: 0.70, fill: 0.14, pic: 0.85, keyColor: 0xffdca8, bg: 0x0a0a10, fog: 0.034, vignette: 0.22 },
    daylight: { label: 'Daylight',      exposure: 1.22, ambient: 0.62, hemi: 0.58, key: 1.10, fill: 0.45, pic: 0.40, keyColor: 0xfffefb, bg: 0x191b1f, fog: 0.012, vignette: 0.04 },
    boutique: { label: 'Boutique',      exposure: 1.08, ambient: 0.34, hemi: 0.30, key: 1.20, fill: 0.20, pic: 0.95, keyColor: 0xffeccf, bg: 0x120e0a, fog: 0.024, vignette: 0.18 }
  };
  const THEME_ORDER = ['bright','warm','cool','evening','daylight','boutique'];

  function applyTheme(key) {
    const t = THEMES[key]; if (!t) return;
    currentTheme = key;
    const r = QH.renderer, s = QH.scene;
    r.toneMappingExposure = t.exposure;
    if (s.background && s.background.setHex) s.background.setHex(t.bg);
    if (s.fog) s.fog.density = t.fog;
    s.children.forEach(l => {
      if (!l.isLight) return;
      if (l.isAmbientLight) l.intensity = t.ambient;
      else if (l.isHemisphereLight) l.intensity = t.hemi;
      else if (l.isDirectionalLight && l.castShadow) { l.intensity = t.key; if (l.color && l.color.setHex) l.color.setHex(t.keyColor); }
      else if (l.isDirectionalLight) l.intensity = t.fill;
    });
    if (QH.picLight) QH.picLight.intensity = t.pic;
    if (QH.requestShadowUpdate) QH.requestShadowUpdate(8); // lights changed → rebake shadows
    // CSS vignette strength
    const pd = document.getElementById('photo-depth');
    if (pd) pd.style.opacity = String(0.5 + t.vignette * 2);  // subtle scale
    document.querySelectorAll('#vm-theme .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.theme === key));
    try { localStorage.setItem('qh_theme', key); } catch (e) {}
  }

  // ===========================================================================
  // ROOM TYPES (Phase 2) — furnished preset chooser + flooring picker.
  // ===========================================================================
  let currentRoom = null;
  function applyRoomType(key, opts) {
    if (!QH.buildRoomType) return;
    const ok = QH.buildRoomType(key, opts || {});
    if (!ok) return;
    currentRoom = key;
    document.querySelectorAll('#vm-room .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.room === key));
    try { localStorage.setItem('qh_room_type', key); } catch (e) {}
    // Re-frame the room's default camera unless we're mid-walk (respect free explore).
    if (!(opts && opts.noFrame) && QH.frameRoomType) { try { QH.frameRoomType(key); } catch (e) {} }
  }
  function setFloor(val) {
    if (QH.setFloorOverride) QH.setFloorOverride(val);
    document.querySelectorAll('#vm-floor .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.floor === val));
  }
  // Expose so the window-bar room-size sliders + tests can drive it.
  window._roomType = { apply: applyRoomType, floor: setFloor, get current() { return currentRoom; } };

  // ===========================================================================
  // setMode — the single switcher. Instant + reversible.
  // ===========================================================================
  function setMode(key, fromInit) {
    const m = MODES[key]; if (!m) return;
    // Exit the previous mode cleanly.
    if (currentMode && MODES[currentMode] && MODES[currentMode].exit) {
      try { MODES[currentMode].exit(); } catch (e) {}
    }
    savedFocusIndex = curIdx();
    currentMode = key;
    QH.frameHook = m.frame ? ((dt) => { try { m.frame(dt); } catch (e) {} }) : perfFrameOnly;
    // Wrap so the perf readout always updates regardless of the mode's own frame().
    const modeFrame = m.frame || null;
    QH.frameHook = (dt) => { if (modeFrame) { try { modeFrame(dt); } catch (e) {} } perfTick(dt); };
    try { m.enter(); } catch (e) { console.warn('view-mode enter failed', key, e); }
    // Avatar body is HIDDEN in every view (DTD verdict B, 3/3, 2026-06-29): the showroom's
    // core action is walking up to a board to inspect a pattern, which is exactly when a
    // visible body occludes the product / clips into the wall. Pure first-person in Walk-
    // Through; never rendered in framed views either. The rig + setAvatarVisible API are
    // kept so a future third-person "see yourself in the room" view can re-enable it.
    if (QH.setAvatarVisible) QH.setAvatarVisible(false);
    if (QH.requestShadowUpdate) QH.requestShadowUpdate(20); // mode changed geometry/visibility → rebake shadows
    // Update panel UI
    document.querySelectorAll('#vm-view .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.mode === key));
    const hint = document.getElementById('vm-hint');
    if (hint) hint.textContent = m.hint || '';
    if (!fromInit) { try { localStorage.setItem('qh_viewmode', key); } catch (e) {} }
  }
  function perfFrameOnly(dt) { perfTick(dt); }

  // ===========================================================================
  // PERF READOUT (TASK 3) — FPS, frame-ms, drawCalls, triangles. Toggleable.
  // ===========================================================================
  let perfOn = false, perfAccum = 0, perfFrames = 0, perfLast = performance.now();
  function perfTick() {
    if (!perfOn) return;
    perfFrames++;
    const now = performance.now();
    const elapsed = now - perfLast;
    if (elapsed >= 500) {
      const fps = Math.round(perfFrames * 1000 / elapsed);
      const ms = (elapsed / perfFrames).toFixed(2);
      const ri = QH.renderer.info.render;
      const el = document.getElementById('vm-perf-body');
      if (el) {
        el.innerHTML =
          '<span class="' + (fps >= 55 ? 'pf-good' : fps >= 40 ? 'pf-warn' : 'pf-bad') + '">' + fps + ' FPS</span>' +
          '<span>' + ms + ' ms</span>' +
          '<span>' + ri.calls + ' draws</span>' +
          '<span>' + (ri.triangles >= 1000 ? (ri.triangles / 1000).toFixed(1) + 'k' : ri.triangles) + ' tris</span>';
      }
      perfFrames = 0; perfLast = now;
    }
  }
  function togglePerf(on) {
    perfOn = (on == null) ? !perfOn : on;
    const box = document.getElementById('vm-perf');
    if (box) box.classList.toggle('open', perfOn);
    const btn = document.getElementById('vm-perf-btn');
    if (btn) btn.classList.toggle('active', perfOn);
    try { localStorage.setItem('qh_perf', perfOn ? '1' : '0'); } catch (e) {}
  }

  // ===========================================================================
  // PANEL UI — vanilla CSS, ART-DIRECTION tokens (brass on museum glass).
  // ===========================================================================
  function buildPanel() {
    const wrap = document.createElement('div');
    wrap.id = 'vm-panel';
    wrap.innerHTML =
      '<div class="vm-section">' +
        '<div class="vm-eyebrow">View</div>' +
        '<div id="vm-view" class="vm-grid"></div>' +
        '<div id="vm-hint" class="vm-hint"></div>' +
      '</div>' +
      '<div class="vm-section">' +
        '<div class="vm-eyebrow">Theme</div>' +
        '<div id="vm-theme" class="vm-grid vm-grid-theme"></div>' +
      '</div>' +
      '<div class="vm-section">' +
        '<div class="vm-eyebrow">Room</div>' +
        '<div id="vm-room" class="vm-grid vm-grid-theme"></div>' +
        '<div class="vm-eyebrow" style="margin-top:8px">Flooring</div>' +
        '<div id="vm-floor" class="vm-grid vm-grid-theme"></div>' +
      '</div>' +
      '<div class="vm-section vm-perfrow">' +
        '<button id="vm-perf-btn" class="vm-perf-btn" title="Toggle performance readout">Perf</button>' +
        '<button id="vm-collapse" class="vm-perf-btn" title="Collapse / expand the panel">View &amp; Theme</button>' +
      '</div>';
    document.body.appendChild(wrap);

    // perf readout box (separate, top-left)
    const perf = document.createElement('div');
    perf.id = 'vm-perf';
    perf.innerHTML = '<div class="vm-perf-title">Render</div><div id="vm-perf-body"></div>';
    document.body.appendChild(perf);

    // compare-mode banner (under the title)
    const banner = document.createElement('div');
    banner.id = 'vm-banner';
    document.body.appendChild(banner);

    // populate view chips
    const vg = document.getElementById('vm-view');
    MODE_ORDER.forEach((k, n) => {
      const b = document.createElement('button');
      b.className = 'vm-chip'; b.dataset.mode = k;
      b.innerHTML = '<span class="vm-num">' + (n + 1) + '</span>' + MODES[k].label;
      b.title = MODES[k].hint;
      b.addEventListener('click', () => setMode(k));
      vg.appendChild(b);
    });
    // populate theme chips
    const tg = document.getElementById('vm-theme');
    THEME_ORDER.forEach(k => {
      const b = document.createElement('button');
      b.className = 'vm-chip'; b.dataset.theme = k; b.textContent = THEMES[k].label;
      b.addEventListener('click', () => applyTheme(k));
      tg.appendChild(b);
    });

    // populate ROOM-TYPE chips (Phase 2) — copy the theme-chip UI. Chooses a furnished
    // room preset and re-frames its default camera. Persisted (qh_room_type).
    const rg = document.getElementById('vm-room');
    const rOrder = (QH.ROOM_TYPE_ORDER || []);
    rOrder.forEach(k => {
      const rec = QH.ROOM_TYPES[k];
      const b = document.createElement('button');
      b.className = 'vm-chip'; b.dataset.room = k; b.textContent = rec.label;
      b.title = rec.label + ' — ' + (rec.mood || '');
      b.addEventListener('click', () => applyRoomType(k));
      rg.appendChild(b);
    });

    // populate FLOORING picker — oak / stone / (room default). Persisted (qh_floor).
    const fg = document.getElementById('vm-floor');
    [['default', 'Default'], ['oak', 'Oak'], ['stone', 'Stone']].forEach(([val, lbl]) => {
      const b = document.createElement('button');
      b.className = 'vm-chip'; b.dataset.floor = val; b.textContent = lbl;
      b.addEventListener('click', () => setFloor(val));
      fg.appendChild(b);
    });

    document.getElementById('vm-perf-btn').addEventListener('click', () => togglePerf());
    document.getElementById('vm-collapse').addEventListener('click', () => {
      wrap.classList.toggle('collapsed');
      try { localStorage.setItem('qh_vm_collapsed', wrap.classList.contains('collapsed') ? '1' : '0'); } catch (e) {}
    });
    if (localStorage.getItem('qh_vm_collapsed') === '1') wrap.classList.add('collapsed');
  }

  // ===========================================================================
  // INIT — build the panel, restore last choices, default to Hero on first load.
  // ===========================================================================
  buildPanel();

  // Theme first (so lights are set before the view frames).
  const savedTheme = localStorage.getItem('qh_theme');
  applyTheme(THEMES[savedTheme] ? savedTheme : 'bright');

  // Perf toggle restore.
  if (localStorage.getItem('qh_perf') === '1') togglePerf(true); else togglePerf(false);

  // Room-type dressing (sofa / lamps / wingchair / boards / rug / sculpture / plant) is
  // OFF on boot (Steve 2026-07-02): "onload load nothing except the table and chair;
  // remove all other elements." The room boots as a CLEAN empty room + the active style's
  // table + chair only (swapFurniture owns those). A furnished room type is built ONLY when
  // the user explicitly clicks a room-type chip (applyRoomType, wired at buildPanel). We do
  // NOT auto-restore a saved room type either — a returning user also boots to the clean
  // empty room and re-dresses by choosing a chip.
  const savedFloor = QH.floorOverride || 'default';
  document.querySelectorAll('#vm-floor .vm-chip').forEach(c => c.classList.toggle('active', c.dataset.floor === savedFloor));

  // View: Hero is ALWAYS the boot view for a first-time load (locked rule). But if
  // the user previously picked a mode, restore it AFTER the guided default has
  // settled — except never auto-restore the free-camera modes on boot (they'd land
  // on an empty room for a returning senior). Restore only the board-presentation
  // modes; otherwise fall back to Hero.
  // Boot = WALK-THROUGH the CENTER of the showroom (Steve 2026-06-30): first-person
  // walk down the middle of the room past the boards, with an automated slow dolly
  // (Peruse) so it moves on its own until the user takes WASD control. A returning
  // user's saved board-presentation mode still restores; free-cam modes never do.
  const savedMode = localStorage.getItem('qh_viewmode');
  const safeBootModes = { walk: 1, hero: 1, macro: 1, room: 1, table: 1 };
  const bootMode = (savedMode && MODES[savedMode] && safeBootModes[savedMode]) ? savedMode : 'walk';
  setMode(bootMode, true);
  // Boot = the first-person user STANDS IN THE DOORWAY at the back (entrance) end of
  // the room and STOPS (Steve 2026-07-01) — stationary, looking in toward the boards.
  // The BOOK_MODE boot chain re-centres the camera on the book at +700/+1500ms, so we
  // re-assert the doorway pose just after it settles. No dolly — stationary until WASD.
  if (bootMode === 'walk') {
    [1700, 2100].forEach(t => setTimeout(() => {
      if (currentMode === 'walk' && window._qhStandInDoorway) window._qhStandInDoorway();
    }, t));
  }

  // Expose for the test harness + for showroom guided buttons to re-assert the view.
  window._viewmode = {
    set: setMode,
    theme: applyTheme,
    perf: togglePerf,
    room: applyRoomType,
    floor: setFloor,
    get current() { return currentMode; },
    get theme_() { return currentTheme; },
    get room_() { return currentRoom; },
    // PHASE 3 "At the Table" hooks — verification/automation surface.
    addSample: (p) => addSampleToTable(p || currentCenteredProduct()),
    get sampleCount() { return _samplePlaced.length; },
    get samplePlaced() { return _samplePlaced.slice(); },
    MODES, THEMES, MODE_ORDER, THEME_ORDER
  };

  // Re-apply the current mode's framing when the guided Next/Back lands on a new
  // board (so Macro/Angled/Room re-frame the new board, not just Hero).
  window._viewmode.reframe = () => {
    const m = MODES[currentMode];
    if (m && m.guided && m.enter) { try { m.enter(); } catch (e) {} }
  };
}

boot();
})();