[object Object]

← back to Quadrille Showroom

Add 10 switchable view modes + 6 lighting themes + toggleable perf readout (View/Theme panel)

52f212e8cf139a53802f29ae8a042f8f804cd79c · 2026-06-28 06:02:08 -0700 · Steve

Files touched

Diff

commit 52f212e8cf139a53802f29ae8a042f8f804cd79c
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 06:02:08 2026 -0700

    Add 10 switchable view modes + 6 lighting themes + toggleable perf readout (View/Theme panel)
---
 public/css/showroom.css |  84 +++++++++
 public/js/showroom.js   |  69 ++++++-
 public/js/viewmodes.js  | 483 ++++++++++++++++++++++++++++++++++++++++++++++++
 public/showroom.html    |   1 +
 4 files changed, 635 insertions(+), 2 deletions(-)

diff --git a/public/css/showroom.css b/public/css/showroom.css
index 946bda1..3d6d692 100644
--- a/public/css/showroom.css
+++ b/public/css/showroom.css
@@ -297,3 +297,87 @@ body { overflow: hidden; background: #0a0a0f; font-family: 'Segoe UI', system-ui
 #wing-detail.collapsed .detail-actions { display: none; }
 #wing-detail.collapsed .wd-body { padding: 14px 40px 14px 20px; }
 #wing-detail.collapsed .wd-title { margin-bottom: 0; }
+
+/* ============================================================
+   VIEW-MODE + THEME PANEL (viewmodes.js) — museum-glass chrome,
+   ART-DIRECTION tokens: brushed brass on dark, tracked eyebrows.
+   Top-LEFT so it never fights the bottom guided bar or right HUD.
+   ============================================================ */
+#vm-panel {
+  position: fixed; top: 70px; left: 16px; z-index: 42;
+  width: 248px; display: flex; flex-direction: column; gap: 12px;
+  background: rgba(12,12,17,0.90); border: 1.5px solid rgba(201,169,110,0.40);
+  border-radius: 16px; padding: 14px 14px 12px;
+  backdrop-filter: blur(14px); box-shadow: 0 18px 50px -14px rgba(0,0,0,0.75);
+  transition: transform .2s ease, opacity .2s ease;
+}
+#vm-panel.collapsed { transform: translateX(-228px); }
+#vm-panel.collapsed:hover { transform: translateX(0); }
+#vm-panel .vm-section { display: flex; flex-direction: column; gap: 8px; }
+#vm-panel .vm-eyebrow {
+  font-size: 11px; letter-spacing: 2.4px; text-transform: uppercase;
+  color: #c9a96e; font-weight: 700; opacity: .9;
+}
+#vm-panel .vm-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
+#vm-panel .vm-grid-theme { grid-template-columns: 1fr 1fr; }
+#vm-panel .vm-chip {
+  display: flex; align-items: center; gap: 6px;
+  min-height: 34px; padding: 6px 9px; cursor: pointer;
+  font-size: 12.5px; font-weight: 600; letter-spacing: .2px; line-height: 1.1;
+  border-radius: 9px; border: 1px solid rgba(201,169,110,0.30);
+  background: rgba(255,255,255,0.05); color: #ede4cf;
+  transition: background .12s, border-color .12s, color .12s;
+  text-align: left; -webkit-user-select: none; user-select: none;
+}
+#vm-panel .vm-chip:hover { background: rgba(201,169,110,0.18); border-color: #c9a96e; }
+#vm-panel .vm-chip.active {
+  background: linear-gradient(180deg, #d8bd84, #b9933f); color: #1a1206;
+  border-color: #b9933f; box-shadow: 0 2px 10px rgba(185,147,63,0.35);
+}
+#vm-panel .vm-chip .vm-num {
+  font-size: 10px; font-weight: 800; opacity: .65;
+  min-width: 13px; text-align: center; font-variant-numeric: tabular-nums;
+}
+#vm-panel .vm-chip.active .vm-num { opacity: .85; }
+#vm-panel .vm-hint {
+  font-size: 11.5px; line-height: 1.35; color: #b8b0a0; min-height: 30px;
+  padding-top: 2px; font-style: italic;
+}
+#vm-panel .vm-perfrow { flex-direction: row; gap: 8px; }
+#vm-panel .vm-perf-btn {
+  flex: 1; min-height: 30px; font-size: 11px; letter-spacing: 1px;
+  text-transform: uppercase; font-weight: 700; cursor: pointer;
+  border-radius: 8px; border: 1px solid rgba(201,169,110,0.30);
+  background: rgba(255,255,255,0.05); color: #c9a96e;
+}
+#vm-panel .vm-perf-btn:hover { background: rgba(201,169,110,0.16); }
+#vm-panel .vm-perf-btn.active { background: rgba(201,169,110,0.85); color: #1a1206; border-color: #c9a96e; }
+
+/* Perf readout — small fixed badge, top-centre-left, only when toggled on */
+#vm-perf {
+  position: fixed; top: 70px; left: 280px; z-index: 41; display: none;
+  background: rgba(10,10,14,0.86); border: 1px solid rgba(201,169,110,0.35);
+  border-radius: 10px; padding: 8px 12px; backdrop-filter: blur(8px);
+  font-variant-numeric: tabular-nums; box-shadow: 0 10px 30px -10px rgba(0,0,0,0.7);
+}
+#vm-perf.open { display: block; }
+#vm-perf .vm-perf-title {
+  font-size: 10px; letter-spacing: 2px; text-transform: uppercase;
+  color: #c9a96e; font-weight: 700; margin-bottom: 4px;
+}
+#vm-perf-body { display: flex; gap: 14px; font-size: 13px; color: #ded6c6; font-weight: 600; }
+#vm-perf-body .pf-good { color: #7ec97e; } #vm-perf-body .pf-warn { color: #d4c06a; } #vm-perf-body .pf-bad { color: #d07a6a; }
+
+/* Compare-mode banner under the title */
+#vm-banner {
+  position: fixed; left: 50%; top: 116px; transform: translateX(-50%);
+  z-index: 37; display: none; max-width: 80vw; text-align: center;
+  font-size: 13px; font-weight: 600; letter-spacing: .3px; color: #f5efe2;
+  background: rgba(12,12,17,0.74); border: 1px solid rgba(201,169,110,0.30);
+  padding: 7px 18px; border-radius: 18px; backdrop-filter: blur(8px);
+}
+
+@media (max-width: 720px) {
+  #vm-panel { top: 60px; left: 8px; width: 200px; }
+  #vm-perf { left: 216px; top: 60px; }
+}
diff --git a/public/js/showroom.js b/public/js/showroom.js
index cdd4b37..09b8a93 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -1496,9 +1496,17 @@ function restoreBoards() {
 async function gotoBoardOffset(delta) {
   if (!wingBoards.length) return;
   let idx = (focusedWing ? focusedWing.userData.index : 0) + delta;
-  if (idx >= wingBoards.length) { if (await advanceWindow(1)) { restoreBoards(); focusOnWing(wingBoards[0]); return; } idx = wingBoards.length - 1; }
-  else if (idx < 0) { if (await advanceWindow(-1)) { restoreBoards(); focusOnWing(wingBoards[wingBoards.length - 1]); return; } idx = 0; }
+  if (idx >= wingBoards.length) { if (await advanceWindow(1)) { restoreBoards(); focusOnWing(wingBoards[0]); reframeView(); return; } idx = wingBoards.length - 1; }
+  else if (idx < 0) { if (await advanceWindow(-1)) { restoreBoards(); focusOnWing(wingBoards[wingBoards.length - 1]); reframeView(); return; } idx = 0; }
   focusOnWing(wingBoards[idx]);
+  reframeView();
+}
+// If a board-presentation view-mode (Macro/Angled/Room) is active, re-apply its
+// framing to the newly-focused board after the guided Next/Back flip settles.
+function reframeView() {
+  if (window._viewmode && typeof window._viewmode.reframe === 'function') {
+    setTimeout(() => { try { window._viewmode.reframe(); } catch (e) {} }, 60);
+  }
 }
 
 // "Fan out" — open the FOCUSED board's two panels (left & right) to present its
@@ -1926,6 +1934,12 @@ function animate() {
     frameCount = 0; lastFpsTime = now;
   }
 
+  // View-mode engine per-frame hook (orbit auto-spin, carousel turntable, macro drift,
+  // perf readout). Installed by public/js/viewmodes.js; null when no module loaded.
+  if (window._qh && typeof window._qh.frameHook === 'function') {
+    try { window._qh.frameHook(dt); } catch (e) { /* never let a mode break the loop */ }
+  }
+
   renderer.render(scene, camera);
 }
 
@@ -2028,6 +2042,14 @@ function applyExploreMode() {
   updateGuidedInstruction();
 }
 
+// Let the view-mode engine (Walk / Carousel) flip native Explore on/off without
+// looping back through the panel. Sets the state + applies it; no localStorage write
+// so a view-mode choice doesn't permanently change the user's Explore preference.
+window._toggleExploreFromViewmode = function (on) {
+  exploreMode = !!on;
+  applyExploreMode();
+};
+
 function toggleExplore() {
   exploreMode = !exploreMode;
   localStorage.setItem('qh_explore', exploreMode ? '1' : '0');
@@ -2541,4 +2563,47 @@ window._getSceneStats = function() {
   const ri = renderer.info;
   return { total, meshes, groups, lights, drawCalls: ri.render.calls, triangles: ri.render.triangles, textures: ri.memory.textures, geometries: ri.memory.geometries, programs: ri.programs ? ri.programs.length : 'n/a' };
 };
+
+// ============================================================
+// VIEW-MODE / THEME API — stable surface for public/js/viewmodes.js.
+// Keeps the view-mode + theme engine in its own module without re-plumbing
+// the whole showroom. Everything here is a thin wrapper over existing internals.
+// ============================================================
+window._qh = {
+  THREE,
+  get scene()      { return scene; },
+  get camera()     { return camera; },
+  get controls()   { return controls; },
+  get renderer()   { return renderer; },
+  get wingBoards() { return wingBoards; },
+  get focusedWing(){ return focusedWing; },
+  get peruseActive(){ return peruseActive; },
+  get exploreMode(){ return exploreMode; },
+  CONFIG,
+  // Camera + focus
+  smoothCameraTo,
+  focusOnWing,
+  unfocusWing,
+  enterGuidedDefault,
+  isolateBoard,
+  restoreBoards,
+  lockControls,
+  unlockControls,
+  get controlsLocked() { return controlsLocked; },
+  setControlsLocked(v) { controlsLocked = v; },
+  gotoBoardOffset,
+  stopPeruse,
+  startPeruse,
+  // Lighting
+  get keyLight()  { return window._keyLight; },
+  get picLight()  { return window._picLight; },
+  // Walls
+  cladWalls,
+  revertWalls,
+  // FOV constants
+  FOV_DEFAULT, FOV_HERO,
+  setTargetFov(f) { targetFov = f; },
+  // A per-frame hook the view-mode engine installs (orbit/carousel/macro drift etc.)
+  frameHook: null
+};
 })();
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
new file mode 100644
index 0000000..8a44a5e
--- /dev/null
+++ b/public/js/viewmodes.js
@@ -0,0 +1,483 @@
+/* ============================================================================
+ * 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
+
+  // ---- 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 macroPhase = 0;         // macro slow drift phase
+  let compareSetup = false;   // compare mode: two boards staged
+  let savedFocusIndex = 0;    // remember which board to return to
+
+  // 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 so the design is presented (Hero/Angled/Macro/etc).
+  function ensureFocused(i) {
+    const idx = (i == null) ? curIdx() : i;
+    QH.restoreBoards();
+    if (!QH.focusedWing || QH.focusedWing.userData.index !== idx) {
+      if (QH.wingBoards[idx]) QH.focusOnWing(QH.wingBoards[idx]);
+    }
+  }
+
+  const MODES = {
+    hero: {
+      label: 'Hero', hint: 'Flat, dead-on — the gallery hero panel.', guided: true,
+      enter() {
+        ensureFocused();
+        // focusOnWing already gives the PJ flat hero framing; just make sure the
+        // open angle is near-flat for this mode.
+        setWingDeg(8);
+      }
+    },
+
+    angled: {
+      label: 'Angled', hint: 'Board opens to a ~65° V — the open-book look.', guided: true,
+      enter() {
+        ensureFocused();
+        setWingDeg(60);
+        // Step a touch closer + lower so the V reads dimensionally.
+        const c = boardWorld(curIdx());
+        const pos = new THREE.Vector3(c.x + 0.35, c.y - 0.05, c.z + 1.7);
+        const tgt = new THREE.Vector3(c.x, c.y, c.z);
+        park(pos, tgt, 38);
+      },
+      exit() { setWingDeg(8); }
+    },
+
+    orbit: {
+      label: 'Orbit', hint: 'Drag to orbit around the focused board.', guided: true,
+      enter() {
+        ensureFocused();
+        setWingDeg(20);
+        freeCam();
+        // Turn orbit controls ON for this mode regardless of Explore.
+        const ctl = QH.controls;
+        ctl.enableRotate = true; ctl.enableZoom = true; ctl.enablePan = false;
+        const c = boardWorld(curIdx());
+        ctl.target.copy(c);
+        ctl.minDistance = 1.2; ctl.maxDistance = 5.5;
+        QH.camera.position.set(c.x, c.y + 0.1, c.z + 2.6);
+        QH.setTargetFov(40); QH.camera.fov = 40; QH.camera.updateProjectionMatrix();
+        orbitAuto = true; orbitSpin = 0;
+      },
+      frame(dt) {
+        if (!orbitAuto) return;              // user drag cancels auto-spin
+        const ctl = QH.controls, cam = QH.camera, c = ctl.target;
+        orbitSpin += dt * 0.32;
+        const r = 2.6, h = c.y + 0.12;
+        cam.position.set(c.x + Math.sin(orbitSpin) * r, h, c.z + Math.cos(orbitSpin) * r);
+        cam.lookAt(c);
+      },
+      exit() {
+        orbitAuto = false;
+        QH.controls.minDistance = 1.0; QH.controls.maxDistance = 8.0;
+      }
+    },
+
+    gallery: {
+      label: 'Gallery', hint: 'Pull back to see the whole wall of boards.', guided: false,
+      enter() {
+        // Show every board on the rail; no isolation.
+        QH.restoreBoards();
+        if (QH.focusedWing) QH.unfocusWing(true);
+        setWingDeg(0);
+        const pos = new THREE.Vector3(0, 1.62, ROOM.depth / 2 - 0.7);
+        const tgt = new THREE.Vector3(0, boardCY, backZ);
+        park(pos, tgt, 58);
+      }
+    },
+
+    topdown: {
+      label: 'Top-Down', hint: 'Overhead plan view of the showroom.', guided: false,
+      enter() {
+        QH.restoreBoards();
+        if (QH.focusedWing) QH.unfocusWing(true);
+        setWingDeg(0);
+        const pos = new THREE.Vector3(0, ROOM.height + 3.4, 0.2);
+        const tgt = new THREE.Vector3(0, 0, backZ + 0.4);
+        park(pos, tgt, 60);
+      }
+    },
+
+    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);
+        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);
+        const pos = new THREE.Vector3(0, 1.6, ROOM.depth / 2 - 1.2);
+        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);
+        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.
+        const wg = findWallGroup();
+        if (wg) { carouselT += dt * 0.22; wg.rotation.y = Math.sin(carouselT) * 0.5; }
+      },
+      exit() { const wg = findWallGroup(); if (wg) wg.rotation.y = 0; }
+    },
+
+    macro: {
+      label: 'Macro', hint: 'Zoom close to read the texture detail.', guided: true,
+      enter() {
+        ensureFocused();
+        setWingDeg(2);
+        const c = boardWorld(curIdx());
+        // Very close, tight FOV — read the weave/print. Slight off-centre so the
+        // grazing light catches the surface.
+        const pos = new THREE.Vector3(c.x - 0.12, c.y + 0.15, c.z + 0.62);
+        const tgt = new THREE.Vector3(c.x - 0.05, c.y + 0.1, c.z);
+        park(pos, tgt, 26);
+        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 boards side-by-side for A/B.', guided: false,
+      enter() {
+        // Stage two adjacent boards open + visible; frame both.
+        const i = curIdx();
+        const j = (i + 1) % QH.wingBoards.length;
+        // Show only these two.
+        QH.wingBoards.forEach((b, k) => { b.visible = (k === i || k === j); if (b.userData.wallDrop) b.userData.wallDrop.visible = false; });
+        // Open both leaves a touch so each presents its design.
+        [i, j].forEach(k => {
+          const ud = QH.wingBoards[k].userData;
+          ud.panelClosed = false;
+          // mark as a "compare-open" so the showroom's updateBooks won't fight it:
+          // we set the leaf rotation directly here each enter; small static splay.
+          if (ud.leafL) ud.leafL.rotation.y = 0.10;
+          if (ud.leafR) ud.leafR.rotation.y = -0.10;
+        });
+        QH.focusOnWing && (QH._noop = 0);
+        // Frame the pair: camera centred between the two boards, pulled back.
+        const a = boardWorld(i), b = boardWorld(j);
+        const midX = (a.x + b.x) / 2;
+        const pos = new THREE.Vector3(midX, boardCY, backZ + 2.9);
+        const tgt = new THREE.Vector3(midX, boardCY, backZ);
+        park(pos, tgt, 44);
+        compareSetup = true;
+        // Update the title to say what's being compared.
+        setBanner('Comparing two designs side-by-side — Next steps both forward.');
+      },
+      exit() {
+        compareSetup = false;
+        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);
+      }
+    }
+  };
+
+  const MODE_ORDER = ['hero','angled','orbit','gallery','topdown','walk','carousel','macro','compare','room'];
+
+  // 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 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'; }
+
+  // ===========================================================================
+  // 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;
+    // 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) {}
+  }
+
+  // ===========================================================================
+  // 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); }
+    // 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 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);
+    });
+
+    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);
+
+  // 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.
+  const savedMode = localStorage.getItem('qh_viewmode');
+  const safeBootModes = { hero: 1, angled: 1, macro: 1, room: 1 };
+  setMode('hero', true);   // boot = Hero, guided focus already set the middle board
+  if (savedMode && MODES[savedMode] && safeBootModes[savedMode] && savedMode !== 'hero') {
+    setTimeout(() => setMode(savedMode, true), 1200);
+  }
+
+  // Expose for the test harness + for showroom guided buttons to re-assert the view.
+  window._viewmode = {
+    set: setMode,
+    theme: applyTheme,
+    perf: togglePerf,
+    get current() { return currentMode; },
+    get theme_() { return currentTheme; },
+    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();
+})();
diff --git a/public/showroom.html b/public/showroom.html
index 60f4567..2214b0c 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -379,5 +379,6 @@
   <script src="/js/three.min.js"></script>
   <script src="/js/OrbitControls.js"></script>
   <script src="/js/showroom.js"></script>
+  <script src="/js/viewmodes.js"></script>
 </body>
 </html>

← c1cf76b TEMP dev aid: auto-reload open tab every 30s (idle-guarded 6  ·  back to Quadrille Showroom  ·  auto-save: 2026-06-28T06:13:03 (3 files) — public/js/showroo afcaf8b →