[object Object]

← back to Quadrille Showroom

PJ arc rack v1: single-vertical-hinge boards on a curved brass rail (rack.js), reveal slider, flip-to-open hero; replaces flat-wall butterfly

f51220a217a02d1307aadab9ab01014f1256d6dc · 2026-06-28 07:31:31 -0700 · Steve

Files touched

Diff

commit f51220a217a02d1307aadab9ab01014f1256d6dc
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 07:31:31 2026 -0700

    PJ arc rack v1: single-vertical-hinge boards on a curved brass rail (rack.js), reveal slider, flip-to-open hero; replaces flat-wall butterfly
---
 public/css/showroom.css | 201 ++++++++++++++++++
 public/js/rack.js       | 122 +++++++++++
 public/js/showroom.js   | 351 +++++++++++++++++-------------
 public/js/versions.js   | 554 ++++++++++++++++++++++++++++++++++++++++++++++++
 public/showroom.html    |  10 +-
 5 files changed, 1091 insertions(+), 147 deletions(-)

diff --git a/public/css/showroom.css b/public/css/showroom.css
index 3d6d692..8ec098a 100644
--- a/public/css/showroom.css
+++ b/public/css/showroom.css
@@ -381,3 +381,204 @@ body { overflow: hidden; background: #0a0a0f; font-family: 'Segoe UI', system-ui
   #vm-panel { top: 60px; left: 8px; width: 200px; }
   #vm-perf { left: 216px; top: 60px; }
 }
+
+/* ============================================================
+   VERSION SYSTEM — V1…V10 picker rail + numbered-element overlay + Chosen tray
+   (versions.js builds the DOM; tokens conform to ART-DIRECTION.md — museum glass,
+   brass-hairline accent, serif names, tabular figures for the loud V-NUMBERS.)
+   ============================================================ */
+
+/* ---- VERSION PICKER RAIL — right-centre vertical strip ---- */
+#ver-rail {
+  position: fixed; right: 14px; top: 50%; transform: translateY(-50%);
+  z-index: 44; display: flex; flex-direction: column; gap: 6px;
+  background: rgba(12,12,17,0.90); border: 1.5px solid rgba(201,169,110,0.40);
+  border-radius: 18px; padding: 12px 11px;
+  backdrop-filter: blur(14px); box-shadow: 0 18px 50px -14px rgba(0,0,0,0.75);
+  max-height: 88vh; transition: transform .22s ease;
+}
+#ver-rail.collapsed { transform: translateY(-50%) translateX(calc(100% + 14px)); }
+#ver-rail-head {
+  display: flex; align-items: center; justify-content: space-between; gap: 8px;
+  padding: 0 2px 8px; margin-bottom: 2px; border-bottom: 1px solid rgba(201,169,110,0.22);
+}
+#ver-rail-head .vr-eyebrow {
+  font-size: 10px; letter-spacing: 2.6px; text-transform: uppercase;
+  color: #c9a96e; font-weight: 700;
+}
+#ver-rail-collapse {
+  width: 22px; height: 22px; border-radius: 6px; cursor: pointer; line-height: 1;
+  border: 1px solid rgba(201,169,110,0.30); background: rgba(255,255,255,0.05);
+  color: #c9a96e; font-size: 13px; font-weight: 800; padding: 0;
+}
+#ver-rail-collapse:hover { background: rgba(201,169,110,0.18); }
+/* the tab that pulls the rail back out when collapsed */
+#ver-rail-tab {
+  position: fixed; right: 0; top: 50%; transform: translateY(-50%);
+  z-index: 44; display: none; cursor: pointer;
+  writing-mode: vertical-rl; text-orientation: mixed;
+  background: rgba(12,12,17,0.90); border: 1.5px solid rgba(201,169,110,0.40); border-right: none;
+  border-radius: 14px 0 0 14px; padding: 14px 7px; color: #c9a96e;
+  font-size: 11px; letter-spacing: 2.5px; text-transform: uppercase; font-weight: 700;
+  backdrop-filter: blur(14px);
+}
+#ver-rail.collapsed ~ #ver-rail-tab { display: block; }
+.ver-chip {
+  display: flex; align-items: center; gap: 9px; cursor: pointer;
+  min-height: 40px; padding: 5px 11px 5px 6px; border-radius: 11px;
+  border: 1px solid rgba(201,169,110,0.26); background: rgba(255,255,255,0.045);
+  color: #ede4cf; transition: background .12s, border-color .12s, color .12s, transform .08s;
+  text-align: left; -webkit-user-select: none; user-select: none; width: 188px;
+}
+.ver-chip:hover { background: rgba(201,169,110,0.16); border-color: #c9a96e; }
+.ver-chip:active { transform: translateX(-1px); }
+.ver-chip .vc-num {
+  flex: 0 0 auto; min-width: 38px; height: 30px; border-radius: 8px;
+  display: inline-flex; align-items: center; justify-content: center;
+  font-size: 17px; font-weight: 800; line-height: 1;
+  font-variant-numeric: tabular-nums; letter-spacing: .5px;
+  color: #c9a96e; background: rgba(201,169,110,0.12);
+  border: 1px solid rgba(201,169,110,0.30);
+}
+.ver-chip .vc-name {
+  font-family: 'Cormorant Garamond', Georgia, serif;
+  font-size: 16.5px; font-weight: 600; line-height: 1.05; letter-spacing: .2px;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.ver-chip.scaffold .vc-name { color: #c7bfae; }
+.ver-chip.scaffold .vc-num { opacity: .72; }
+.ver-chip.active {
+  background: linear-gradient(180deg, #d8bd84, #b9933f); color: #1a1206;
+  border-color: #b9933f; box-shadow: 0 3px 14px rgba(185,147,63,0.40);
+}
+.ver-chip.active .vc-num {
+  color: #1a1206; background: rgba(26,18,6,0.18); border-color: rgba(26,18,6,0.30);
+}
+.ver-chip.active .vc-name { color: #1a1206; }
+
+/* ---- NUMBERED-ELEMENT OVERLAY toggle (lives in the rail head row 2) ---- */
+#ver-rail-tools { display: flex; gap: 6px; margin-top: 8px; padding-top: 8px;
+  border-top: 1px solid rgba(201,169,110,0.22); }
+.vr-tool {
+  flex: 1; min-height: 32px; cursor: pointer; border-radius: 9px;
+  font-size: 10.5px; letter-spacing: 1.2px; text-transform: uppercase; font-weight: 700;
+  border: 1px solid rgba(201,169,110,0.30); background: rgba(255,255,255,0.05); color: #c9a96e;
+  display: inline-flex; align-items: center; justify-content: center; gap: 5px;
+}
+.vr-tool:hover { background: rgba(201,169,110,0.16); }
+.vr-tool.active { background: rgba(201,169,110,0.85); color: #1a1206; border-color: #c9a96e; }
+
+/* ---- NUMBERED PINS — circled-number badges projected onto each element ---- */
+#pin-layer { position: fixed; inset: 0; z-index: 36; pointer-events: none; }
+.el-pin {
+  position: absolute; transform: translate(-50%, -50%); pointer-events: auto;
+  display: flex; align-items: center; gap: 0; cursor: pointer;
+  -webkit-user-select: none; user-select: none; will-change: left, top;
+}
+.el-pin .pin-badge {
+  width: 30px; height: 30px; border-radius: 50%;
+  display: flex; align-items: center; justify-content: center;
+  font-size: 15px; font-weight: 800; line-height: 1; color: #f5efdc;
+  font-variant-numeric: tabular-nums;
+  background: rgba(14,12,18,0.86); border: 2px solid #c9a96e;
+  box-shadow: 0 3px 12px rgba(0,0,0,0.55), 0 0 0 3px rgba(201,169,110,0.18);
+  transition: transform .12s, background .12s, box-shadow .12s;
+}
+.el-pin:hover .pin-badge { transform: scale(1.12); box-shadow: 0 4px 16px rgba(0,0,0,0.6), 0 0 0 4px rgba(201,169,110,0.30); }
+.el-pin .pin-label {
+  max-width: 0; overflow: hidden; white-space: nowrap;
+  font-size: 12px; font-weight: 600; letter-spacing: .3px; color: #f5efdc;
+  background: rgba(14,12,18,0.86); border: 1px solid rgba(201,169,110,0.30);
+  border-left: none; border-radius: 0 9px 9px 0; line-height: 1;
+  margin-left: -4px; padding: 0; opacity: 0;
+  transition: max-width .18s ease, opacity .15s ease, padding .18s ease;
+}
+.el-pin:hover .pin-label, .el-pin.show-label .pin-label {
+  max-width: 220px; opacity: 1; padding: 7px 11px 7px 12px;
+}
+.el-pin.chosen .pin-badge {
+  background: linear-gradient(180deg, #d8bd84, #b9933f); color: #1a1206; border-color: #fff0d2;
+  box-shadow: 0 3px 14px rgba(185,147,63,0.55), 0 0 0 3px rgba(201,169,110,0.30);
+}
+.el-pin.chosen .pin-badge::after {
+  content: '✓'; position: absolute; transform: translate(13px,-13px);
+  font-size: 11px; font-weight: 900; color: #1a1206;
+  background: #fff0d2; border-radius: 50%; width: 15px; height: 15px;
+  display: flex; align-items: center; justify-content: center;
+  box-shadow: 0 1px 4px rgba(0,0,0,0.4);
+}
+
+/* ---- CHOSEN ELEMENTS TRAY — collapsible, bottom-right ---- */
+#chosen-tray {
+  position: fixed; right: 14px; bottom: 16px; z-index: 45; width: 300px;
+  background: rgba(12,12,17,0.92); border: 1.5px solid rgba(201,169,110,0.42);
+  border-radius: 16px; backdrop-filter: blur(14px);
+  box-shadow: 0 18px 50px -14px rgba(0,0,0,0.78); overflow: hidden;
+  display: none;
+}
+#chosen-tray.show { display: block; }
+#chosen-tray-head {
+  display: flex; align-items: center; justify-content: space-between; gap: 8px;
+  padding: 11px 13px; cursor: pointer;
+  border-bottom: 1px solid rgba(201,169,110,0.0);
+}
+#chosen-tray.open #chosen-tray-head { border-bottom-color: rgba(201,169,110,0.22); }
+#chosen-tray-head .ct-title {
+  font-size: 11px; letter-spacing: 2.2px; text-transform: uppercase;
+  color: #c9a96e; font-weight: 700; display: flex; align-items: center; gap: 8px;
+}
+#chosen-tray-head .ct-count {
+  min-width: 22px; height: 22px; padding: 0 6px; border-radius: 11px;
+  background: linear-gradient(180deg,#d8bd84,#b9933f); color: #1a1206;
+  font-size: 12px; font-weight: 800; font-variant-numeric: tabular-nums;
+  display: inline-flex; align-items: center; justify-content: center;
+  transition: transform .15s;
+}
+#chosen-tray-head .ct-count.bump { transform: scale(1.35); }
+#chosen-tray-head .ct-chev { color: #c9a96e; font-size: 13px; transition: transform .18s; }
+#chosen-tray.open #chosen-tray-head .ct-chev { transform: rotate(180deg); }
+#chosen-tray-body { display: none; padding: 4px 12px 12px; }
+#chosen-tray.open #chosen-tray-body { display: block; }
+#chosen-items { display: flex; flex-direction: column; gap: 6px; max-height: 38vh; overflow-y: auto; padding: 6px 0; }
+.ct-chip {
+  display: flex; align-items: center; gap: 8px; padding: 7px 9px; border-radius: 10px;
+  background: rgba(255,255,255,0.05); border: 1px solid rgba(201,169,110,0.26);
+}
+.ct-chip .ctc-tag {
+  font-size: 11px; font-weight: 800; letter-spacing: .5px; color: #c9a96e;
+  font-variant-numeric: tabular-nums; min-width: 42px;
+}
+.ct-chip .ctc-name {
+  flex: 1; font-size: 12.5px; color: #ede4cf; line-height: 1.2;
+  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.ct-chip .ctc-x {
+  flex: 0 0 auto; width: 20px; height: 20px; border-radius: 6px; cursor: pointer;
+  border: 1px solid rgba(201,169,110,0.30); background: rgba(255,255,255,0.04);
+  color: #d8cfba; font-size: 13px; line-height: 1; padding: 0;
+  display: inline-flex; align-items: center; justify-content: center;
+}
+.ct-chip .ctc-x:hover { background: rgba(208,122,106,0.30); border-color: #d07a6a; color: #fff; }
+#chosen-empty {
+  font-size: 12px; color: #9c9485; font-style: italic; line-height: 1.4;
+  padding: 14px 6px; text-align: center;
+}
+#chosen-actions { display: flex; gap: 7px; margin-top: 8px; }
+.ct-act {
+  flex: 1; min-height: 32px; cursor: pointer; border-radius: 9px;
+  font-size: 11px; letter-spacing: 1px; text-transform: uppercase; font-weight: 700;
+  border: 1px solid rgba(201,169,110,0.30); background: rgba(255,255,255,0.05); color: #c9a96e;
+}
+.ct-act:hover { background: rgba(201,169,110,0.16); }
+.ct-act.primary { background: linear-gradient(180deg,#d8bd84,#b9933f); color: #1a1206; border-color: #b9933f; }
+.ct-act.primary:hover { background: linear-gradient(180deg,#e3cb95,#c5a04a); }
+
+@media (max-width: 720px) {
+  #ver-rail { right: 6px; padding: 9px 8px; }
+  .ver-chip { width: 150px; }
+  #chosen-tray { width: 240px; right: 6px; }
+}
+@media (pointer: coarse) {
+  /* on touch, pins always show their label so a tap target is obvious */
+  .el-pin .pin-label { max-width: 180px; opacity: 1; padding: 7px 11px; }
+}
diff --git a/public/js/rack.js b/public/js/rack.js
new file mode 100644
index 0000000..7be23f4
--- /dev/null
+++ b/public/js/rack.js
@@ -0,0 +1,122 @@
+// ============================================================
+// rack.js — PJ WINGBOARD ARC RACK (canonical target, Image #6)
+// ------------------------------------------------------------
+// Owns the geometry math for the corner-wrapping arc of single-hinge
+// wingboards in the Phillip Jeffries Dallas reference. Pure functions +
+// a thin state object; showroom.js builds the meshes and calls in here for
+// (a) where each board sits on the arc, (b) its closed RAKE (sliver) angle,
+// and (c) the per-frame flip easing between raked-closed and open-flat.
+//
+// Single vertical hinge model (NOT the old centre-spine butterfly):
+//   • Each board is ONE full-width face. Its pivot sits at the board's
+//     LEFT vertical edge (the hinge) on the curved rail.
+//   • CLOSED: the board is raked CCW about its hinge so the next board
+//     (downstream on the arc) occludes all but a thin vertical sliver —
+//     occlusion gives the sliver for free, no image slicing.
+//   • OPEN: the board swings CW about that same hinge until its face points
+//     straight at the room centre (dead-on hero), lying ~flat to the viewer.
+//
+// The reveal slider drives the closed rake: tighter rake = thinner slivers =
+// more boards read at once; looser rake = wider slivers = fewer boards.
+// ============================================================
+(function () {
+  'use strict';
+
+  // REVEAL 0..100 → closed rake (radians) off the board's "open/flat" pose.
+  // 0   = boards nearly flat to the rail tangent → WIDE sliver (few boards read)
+  // 100 = boards steeply raked → THIN sliver (many boards read)
+  // Range chosen so the default (~62) lands on the photo's medium-thin slivers.
+  function revealToRake(reveal) {
+    const r = Math.max(0, Math.min(100, reveal));
+    return (30 + (r / 100) * 48) * Math.PI / 180;   // 30°..78°
+  }
+
+  // Centre angle (radians, around the arc centre) of slot i of n boards.
+  // The sweep is centred on +Z so the bay opens toward the camera; we build
+  // the arc from one wing toward the other across arcSpanDeg.
+  function arcAngleFor(i, n, cfg) {
+    const span = cfg.arcSpanDeg * Math.PI / 180;
+    const pitch = (n > 1 ? span / (n - 1) : span) * (cfg.sliverPitchScale || 1);
+    const start = Math.PI * 0.5 - span / 2;          // measured from +Z, centred
+    return start + i * pitch;
+  }
+
+  // World-space rail point for a given arc angle. The board hangs from this point.
+  function railPointFor(angle, cfg) {
+    const R = cfg.arcRadius, cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
+    const x = cx + Math.cos(angle) * R;
+    const z = cz + Math.sin(angle) * R;
+    const tangentY = Math.atan2(Math.cos(angle), -Math.sin(angle));
+    return { x, z, tangentY };
+  }
+
+  // Base yaw that makes a board face the arc CENTRE (dead-on / open pose).
+  // A board at angle θ should face inward toward (cx,cz): the inward vector is
+  // (cx-x, cz-z); its Three.js Y-rotation (atan2(dx, dz)) orients the +Z face
+  // of the plane to point that way.
+  function faceCentreYaw(angle, cfg) {
+    const R = cfg.arcRadius, cx = cfg.arcCenter.x, cz = cfg.arcCenter.z;
+    const x = cx + Math.cos(angle) * R, z = cz + Math.sin(angle) * R;
+    return Math.atan2(cx - x, cz - z);
+  }
+
+  // Place a board's hinge pivot on the arc and store the closed/open yaw targets
+  // on its userData so the flip ease (flipUpdate) can blend between them.
+  //   pivot.position   = the hinge point (left edge) on the rail
+  //   ud.openYaw       = yaw that faces the arc centre (flat hero)
+  //   ud.closedYaw     = openYaw + rake (raked away → packed sliver)
+  // The board MESH is offset +halfWidth in local X so the pivot is its left edge.
+  function placeBoard(pivot, i, n, cfg, reveal) {
+    const ud = pivot.userData;
+    const angle = arcAngleFor(i, n, cfg);
+    const rp = railPointFor(angle, cfg);
+    const openYaw = faceCentreYaw(angle, cfg);
+    const rake = revealToRake(reveal);
+
+    pivot.position.set(rp.x, 0, rp.z);
+    ud.arcAngle = angle;
+    ud.openYaw = openYaw;
+    ud.rake = rake;
+    ud.closedYaw = openYaw + rake;     // raked CCW about the hinge → packed
+    if (typeof ud.flip !== 'number') ud.flip = 0;           // 0 closed → 1 open
+    pivot.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip;
+  }
+
+  // Re-place every board for a new reveal value (live slider). Keeps each board's
+  // current flip state so an open hero stays open while the rest re-rake.
+  function relayout(boards, cfg, reveal) {
+    const n = boards.length;
+    for (let i = 0; i < n; i++) placeBoard(boards[i], i, n, cfg, reveal);
+  }
+
+  // Per-frame flip ease. focusedPivot (if any) targets flip=1 (open/flat);
+  // all others target flip=0 (raked/closed). Returns true if anything moved.
+  function flipUpdate(boards, focusedPivot, dt) {
+    if (!boards.length) return false;
+    const k = Math.min(1, dt * 7);     // ease rate
+    let moved = false;
+    for (let i = 0; i < boards.length; i++) {
+      const p = boards[i], ud = p.userData;
+      const target = (p === focusedPivot && !ud.panelClosed) ? 1 : 0;
+      if (Math.abs(ud.flip - target) > 0.0008) {
+        ud.flip += (target - ud.flip) * k;
+        moved = true;
+      } else if (ud.flip !== target) {
+        ud.flip = target;
+        moved = true;
+      } else continue;
+      p.rotation.y = ud.closedYaw + (ud.openYaw - ud.closedYaw) * ud.flip;
+    }
+    return moved;
+  }
+
+  window.QHRack = {
+    revealToRake,
+    arcAngleFor,
+    railPointFor,
+    faceCentreYaw,
+    placeBoard,
+    relayout,
+    flipUpdate
+  };
+})();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 9fd215e..fd10eac 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -5,16 +5,26 @@
 // CONFIG
 // ============================================================
 const CONFIG = {
-  room: { width: 7.5, depth: 6.0, height: 2.75 },
+  // Larger room so the corner-wrapping ARC of wingboards + a foreground consultation
+  // nook both fit in one establishing shot (matches the PJ Dallas reference photo).
+  room: { width: 9.0, depth: 8.5, height: 3.0 },
   wing: {
     height: 1.8288,         // 6 ft tall (real sample-board height)
     boardWidthM: 0.762,     // 30" wide (real sample-board width)
-    totalCount: 8,          // boards per wall — 8 × 30" fits the back wall; Peruse pages the rest
+    totalCount: 20,         // boards in the arc at once — packed slivers; Peruse pages the 883
     wallMargin: 0.3,
     animSpeed: 0.06,
-    panelOpen: 1.35         // ~77° leaf-hinge when "fanned out" to present one design
+    panelOpen: 1.35,
+    // ---- PJ WINGBOARD ARC (canonical target, Image #6) ----
+    // The rack is a gentle ARC wrapping a room corner/bay. Boards hang from a curved
+    // brass rail, each hinged on its LEFT vertical edge, raked so packed they show only
+    // a thin vertical SLIVER of each pattern (occlusion gives the sliver for free).
+    arcRadius: 3.2,                  // radius of the curved rail (m)
+    arcSpanDeg: 104,                 // total angular sweep of the arc (wraps the corner/bay)
+    arcCenter: { x: -0.3, z: -1.1 }, // arc centre in room space (boards face inward → viewer)
+    sliverPitchScale: 1.0            // multiplier on the angular pitch (reveal slider can thin)
   },
-  camera: { startPos: { x: 0, y: 1.6, z: 2.4 }, fov: 55 }
+  camera: { startPos: { x: 1.8, y: 1.65, z: 3.6 }, fov: 55 }
 };
 
 // ============================================================
@@ -28,7 +38,7 @@ let controlsLocked = false, lockedCamPos = null, lockedCamTarget = null, previou
 let cameraAnim = null; // { startPos, endPos, startTarget, endTarget, progress, duration, onComplete }
 
 // Window paging across the full catalog (50 live wings at a time)
-let windowOffset = 0, windowTotal = 0, windowSize = 8;
+let windowOffset = 0, windowTotal = 0, windowSize = 18;  // boards in the packed arc at once
 let currentBrand = 'all', currentSort = 'natural', currentWallGroup = null;
 // Peruse (endless auto-tour) state
 let peruseActive = false, peruseIndex = 0, peruseTimer = null;
@@ -48,6 +58,15 @@ let VIEW_OPEN = (function () {
   return (v >= 0 && v <= 90 ? v : 8) * Math.PI / 180;
 })();
 
+// REVEAL SLIDER (Steve's explicit ask) — how much of each closed board's pattern shows
+// in the packed arc. Drives the per-board closed RAKE angle in QHRack (smaller reveal =
+// wider sliver = more pattern per board / fewer boards; larger reveal = thinner slivers =
+// more boards in view). Persisted to localStorage like the Wing°/Boards sliders.
+let REVEAL = (function () {
+  const v = parseFloat(localStorage.getItem('qh_reveal'));
+  return (v >= 0 && v <= 100) ? v : 62;   // default ≈ the photo's medium-thin slivers
+})();
+
 // SHARED materials — key perf optimization
 const MAT = {};
 
@@ -961,12 +980,6 @@ function updateWindowLabel() {
 function buildWingWall() {
   const W = CONFIG.room.width, D = CONFIG.room.depth;
   const WC = CONFIG.wing;
-  const margin = WC.wallMargin;
-  const availW = W - 2 * margin;         // ~6.9m usable
-  const nSlots = Math.max(1, Math.min(WC.totalCount, products.length || WC.totalCount));
-  const slotW = availW / nSlots;         // pitch between boards
-  const wingW = Math.min(WC.boardWidthM, slotW - 0.06); // 30" board face (with a gap so they don't touch)
-  const backZ = -D / 2 + 0.2;            // 0.2m off back wall
 
   // Server returns the window already sorted/brand-filtered — keep its order.
   let allProds = products.slice(0, windowSize);
@@ -993,75 +1006,72 @@ function buildWingWall() {
     else vendorSections.push({ vendor: v, startIdx: i, endIdx: i });
   });
 
-  // Single wall group at back wall
+  // ============================================================
+  // PJ WINGBOARD ARC (Image #6) — boards hang on a CURVED brass rail wrapping the
+  // room corner/bay. Each board is a SINGLE full-width face hinged on its LEFT
+  // vertical edge; QHRack places the hinge on the arc + stores the closed-rake +
+  // open-flat yaw targets. The whole rack lives in WORLD space (the arc centre is
+  // a room coordinate), so wallGroup stays at the origin.
+  // ============================================================
   const wallGroup = new THREE.Group();
-  wallGroup.position.set(0, 0, backZ);
-
-  // Chrome rail spanning full width
-  const rail = new THREE.Mesh(
-    new THREE.BoxGeometry(availW + 0.04, 0.015, 0.015), MAT.chrome
-  );
-  rail.position.set(0, WC.height + 0.08, 0);
-  rail.castShadow = true; rail.receiveShadow = true;
-  wallGroup.add(rail);
-
-  // Rail end caps
-  [-1, 1].forEach(side => {
-    const cap = new THREE.Mesh(new THREE.SphereGeometry(0.012, 8, 6), MAT.chrome);
-    cap.position.set(side * (availW / 2 + 0.02), WC.height + 0.08, 0);
-    wallGroup.add(cap);
-  });
+  wallGroup.position.set(0, 0, 0);
+  const wingW = WC.boardWidthM;          // full 30" face when open
+  const H = WC.height;
+  const yC = H / 2 + 0.04;               // board vertical centre (placard clears the floor)
+
+  // ---- Curved brass rail following the arc (segmented thin boxes ≈ a torus arc) ----
+  const railY = H + 0.10;
+  const railSegs = Math.max(28, totalWings * 2);
+  const railA0 = QHRack.arcAngleFor(0, totalWings, WC);
+  const railA1 = QHRack.arcAngleFor(totalWings - 1, totalWings, WC);
+  for (let s = 0; s < railSegs; s++) {
+    const a0 = railA0 + (s / railSegs) * (railA1 - railA0);
+    const a1 = railA0 + ((s + 1) / railSegs) * (railA1 - railA0);
+    const p0 = QHRack.railPointFor(a0, WC), p1 = QHRack.railPointFor(a1, WC);
+    const segLen = Math.hypot(p1.x - p0.x, p1.z - p0.z) + 0.004;
+    const seg = new THREE.Mesh(new THREE.BoxGeometry(segLen, 0.02, 0.02), MAT.chrome);
+    seg.position.set((p0.x + p1.x) / 2, railY, (p0.z + p1.z) / 2);
+    seg.rotation.y = -Math.atan2(p1.z - p0.z, p1.x - p0.x);
+    seg.castShadow = true; seg.receiveShadow = true;
+    wallGroup.add(seg);
+  }
 
-  // Build each wing
+  // Build each wing — SINGLE-HINGE board on the arc
   for (let i = 0; i < totalWings; i++) {
     const product = allProds[i];
-    const wingX = -availW / 2 + i * slotW + slotW / 2;
 
+    // The pivot IS the hinge (left vertical edge). QHRack.placeBoard sets its arc
+    // position + closed/open yaw; the board MESH is offset +wingW/2 in local X so the
+    // hinge sits at the board's left edge.
     const pivot = new THREE.Group();
-    pivot.position.set(wingX, 0, 0);
 
     const poolIdx = product._poolIdx !== undefined ? product._poolIdx : (i % TEXTURE_POOL.length);
     const poolMat = TEXTURE_POOL[poolIdx].material;
 
-    // WINGBOARD — two leaves hinged at a center spine (x=0). Together they present
-    // ONE continuous design (left leaf = left half U 0..0.5, right leaf = right half
-    // U 0.5..1; see loadWingBook). Closed: coplanar on the wall. Open (proximity ≤4ft
-    // or focus): both leaves swing forward ~65° to show the whole wallcovering.
-    const leafW = wingW / 2;
-    const H = WC.height;
-    const yC = H / 2 + 0.04;
     const THICK = 0.014;            // physical board thickness → real edge + cast shadow
-
-    const leafL = new THREE.Group();        // left page, hinge at spine
-    const planeL = new THREE.Mesh(new THREE.BoxGeometry(leafW, H, THICK), poolMat);
-    planeL.position.set(-leafW / 2, yC, 0);
-    planeL.castShadow = true; planeL.receiveShadow = true;
-    planeL.userData = { isWingFace: true, parentPivot: pivot, leaf: 'L' };
-    leafL.add(planeL);
-    // Per-board light gradient + sheen, parented to the LEAF FACE so it tracks the
-    // exact swing of the leaf (offset to the leaf-centre x like the face itself).
-    const litL = makeBoardLightOverlay(leafW, H, yC); litL.position.x = -leafW / 2;
-    leafL.add(litL);
-    pivot.add(leafL);
-
-    const leafR = new THREE.Group();        // right page, hinge at spine
-    const planeR = new THREE.Mesh(new THREE.BoxGeometry(leafW, H, THICK), poolMat);
-    planeR.position.set(leafW / 2, yC, 0);
-    planeR.castShadow = true; planeR.receiveShadow = true;
-    planeR.userData = { isWingFace: true, parentPivot: pivot, leaf: 'R' };
-    leafR.add(planeR);
-    const litR = makeBoardLightOverlay(leafW, H, yC); litR.position.x = leafW / 2;
-    leafR.add(litR);
-    pivot.add(leafR);
-
-    // Thin brushed-brass surround behind the board (stays put when leaves fan open).
-    // Four slim bars frame the 30" board face like a gallery mount.
-    const frameT = 0.018, frameD = 0.01, frameZ = -THICK / 2 - 0.004;
-    const fpad = 0.012; // frame sits just outside the board edges
+    const faceOffX = wingW / 2;     // board centre sits +halfWidth from the hinge
+
+    // ONE full-width board face. (faceL/faceR both alias this single plane so the
+    // raycast + loadWingBook plumbing keeps working with no per-leaf split.)
+    const face = new THREE.Mesh(new THREE.BoxGeometry(wingW, H, THICK), poolMat);
+    face.position.set(faceOffX, yC, 0);
+    face.castShadow = true; face.receiveShadow = true;
+    face.userData = { isWingFace: true, parentPivot: pivot, leaf: 'F' };
+    pivot.add(face);
+    // Per-board light gradient + sheen overlay, tracking the single face.
+    const lit = makeBoardLightOverlay(wingW, H, yC); lit.position.x = faceOffX;
+    pivot.add(lit);
+    const planeL = face, planeR = face;
+    const leafL = pivot, leafR = pivot;
+
+    // Thin dark-bronze / near-black surround framing the 30" board face (matches the
+    // PJ photo's slim dark frames). Offset to the face centre so it tracks the board.
+    const frameT = 0.020, frameD = 0.012, frameZ = THICK / 2 + 0.002;
+    const fpad = 0.010; // frame sits just outside the board edges
     const fw = wingW + fpad * 2, fh = H + fpad * 2;
     const brassBar = (w, h, x, y) => {
-      const bar = new THREE.Mesh(new THREE.BoxGeometry(w, h, frameD), MAT.chrome);
-      bar.position.set(x, yC + y, frameZ); bar.castShadow = false; bar.receiveShadow = true;
+      const bar = new THREE.Mesh(new THREE.BoxGeometry(w, h, frameD), MAT.frame || MAT.chrome);
+      bar.position.set(faceOffX + x, yC + y, frameZ); bar.castShadow = false; bar.receiveShadow = true;
       pivot.add(bar);
     };
     brassBar(fw, frameT, 0,  fh / 2 - frameT / 2);   // top
@@ -1090,7 +1100,7 @@ function buildWingWall() {
       new THREE.PlaneGeometry(Math.min(wingW * 0.9, 0.32), 0.075),
       new THREE.MeshBasicMaterial({ map: plTex, transparent: true })
     );
-    placard.position.set(0, 0.115, 0.012);
+    placard.position.set(faceOffX, 0.115, THICK / 2 + 0.012);
     pivot.add(placard);
 
     const wingImg = product.tile || product.image;
@@ -1139,48 +1149,53 @@ function buildWingWall() {
       product: normalizedProduct, index: i,
       isOpen: false, targetAngle: 0, currentAngle: 0,
       boardWidth: wingW, wallGroup, wingDirection: 1,
-      leafL, leafR, faceL: planeL, faceR: planeR,
+      leafL, leafR, faceL: planeL, faceR: planeR, face,
       pendingImage: wingImg || null, imageLoaded: false,
-      bookOpen: 0, bookTarget: 0           // book-match leaf-hinge state (separate from fan)
+      bookOpen: 0, bookTarget: 0,          // legacy butterfly state (unused in arc layout)
+      flip: 0                              // 0 = raked/closed, 1 = open/flat (rack.js drives)
     };
+
+    // Place this board's hinge on the arc + set its closed-rake / open-flat yaw.
+    QHRack.placeBoard(pivot, i, totalWings, WC, REVEAL);
+
     wallGroup.add(pivot);
     wingBoards.push(pivot);
 
-    // CONTACT GROUNDING — a soft radial contact shadow on the floor directly under
-    // this wingboard so it sits IN the room, not floating ON it. Parented to the
-    // wallGroup at local (wingX,0,0) → lands at world (wingX, floor, backZ). The
-    // footprint is a touch wider than the 30" board face and shallow front-to-back
-    // (the board is thin), the strongest "is it real" tell after the room shell.
+    // CONTACT GROUNDING — a soft radial contact shadow on the floor under the board's
+    // OPEN footprint. Parented to the pivot at the face-centre offset so it rotates with
+    // the board's hinge swing and always lands beneath the board face. Lifted to world Y=0
+    // (the pivot sits at floor level) so it reads as a ground contact, not a floating slab.
     const shadeMat = new THREE.MeshBasicMaterial({
-      map: contactShadowTexture(), transparent: true, opacity: 0.85,
+      map: contactShadowTexture(), transparent: true, opacity: 0.78,
       depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
     });
-    const shade = new THREE.Mesh(new THREE.PlaneGeometry(wingW + 0.12, 0.34), shadeMat);
+    const shade = new THREE.Mesh(new THREE.PlaneGeometry(wingW + 0.10, 0.30), shadeMat);
     shade.rotation.x = -Math.PI / 2;
-    shade.position.set(wingX, 0.006, 0.02);   // 6 mm above floor, nudged just forward of the wall
+    shade.position.set(faceOffX, 0.006, 0.0);   // 6 mm above floor, under the board face
     shade.renderOrder = 1;
-    wallGroup.add(shade);
+    pivot.add(shade);
 
-    // WALL DROP-SHADOW — a faint soft shadow of the board cast onto the wall behind it
-    // (the key light comes from upper-right, so the shadow drops slightly down + left and
-    // is a touch taller/wider than the board). A flat sprite just in front of the wall
-    // surface (local z ≈ -0.19, wall is at -0.2) so it sits ON the wall, behind the board.
-    // Pure sprite — no extra shadow-map/light cost. Skipped when the board is open/away.
+    // (legacy flat-wall drop-shadow disabled — the arc rack has no flat back wall behind
+    // each board, so a wall-cast sprite would float. Contact + cast shadows ground them.)
+    if (false) { // eslint-disable-line no-constant-condition
     const dropMat = new THREE.MeshBasicMaterial({
       map: wallDropShadowTexture(), transparent: true, opacity: 0.34,
       depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
     });
     const drop = new THREE.Mesh(new THREE.PlaneGeometry(wingW * 1.12, H * 1.04), dropMat);
-    drop.position.set(wingX - 0.05, yC - 0.05, -0.19);  // down+left of the board, on the wall
-    drop.renderOrder = 0;                                 // draw before the board face
+    drop.position.set(0, yC - 0.05, -0.19);
+    drop.renderOrder = 0;
     drop.userData.isWallDrop = true;
     wallGroup.add(drop);
-    // Carry a handle so isolateBoard()/focus can hide it when this board swings open
-    // (a swung-open board would no longer cast onto the wall directly behind it).
     pivot.userData.wallDrop = drop;
+    } // end legacy drop-shadow (disabled)
   }
 
-  // Vendor section labels and dividers — colors from the loaded brand list
+  // Vendor section labels disabled in the arc layout — the flat-wall slotW/availW
+  // positioning model no longer applies to a curved rail. (Brand wordmark lives on
+  // the feature wall instead.) Kept behind a constant-false guard for reference.
+  // eslint-disable-next-line no-constant-condition
+  if (false) {
   const vendorColors = {};
   vendors.forEach(v => { vendorColors[v.name] = v.color; });
   vendorSections.forEach(section => {
@@ -1220,6 +1235,7 @@ function buildWingWall() {
       wallGroup.add(divider);
     }
   });
+  } // end vendor-section labels (disabled in arc layout)
 
   currentWallGroup = wallGroup;
   scene.add(wallGroup);
@@ -1341,10 +1357,13 @@ function loadWingTexture(faceMesh, imageUrl, onDone) {
 // each leaf draws HALF the horizontal span. Cached per-URL (shares one image).
 //   left  leaf:  repeat.x = rx/2, offset.x = 0
 //   right leaf:  repeat.x = rx/2, offset.x = rx/2
+// SINGLE-HINGE board → load the ONE continuous design onto the single full-width face.
+// (No L/R leaf split — the whole wallcovering reads across one board, no mirror, no seam.)
 function loadWingBook(pivot, onDone) {
   const ud = pivot.userData;
   const imageUrl = ud.pendingImage;
-  if (!imageUrl || !ud.faceL || !ud.faceR) { if (onDone) onDone(); return; }
+  const face = ud.face || ud.faceL;
+  if (!imageUrl || !face) { if (onDone) onDone(); return; }
   const product = ud.product;
   const wingW = ud.boardWidth || 0.12;
   const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
@@ -1352,44 +1371,33 @@ function loadWingBook(pivot, onDone) {
 
   // roughness ~0.6 + a hint of envMap so the focused board catches the grazing picture-light
   // as a physical satin surface, while staying crisp + true-colour (not greyed).
-  const leafMat = (tex) => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
+  const faceMat = (tex) => new THREE.MeshStandardMaterial({ map: tex, roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
   const apply = (cached) => {
-    ud.faceL.material = leafMat(cached.left);   // left half U 0..0.5
-    ud.faceR.material = leafMat(cached.right);  // right half U 0.5..1 — continuous
+    face.material = faceMat(cached.texture);
     if (product) product.isFullDesign = cached.isFullDesign;
     setWingBanner(pivot, cached.isFullDesign === false);
     if (onDone) onDone();
   };
 
-  if (imageTexCache[imageUrl] && imageTexCache[imageUrl].left && imageTexCache[imageUrl].right) { apply(imageTexCache[imageUrl]); return; }
+  if (imageTexCache[imageUrl] && imageTexCache[imageUrl].texture) { apply(imageTexCache[imageUrl]); return; }
 
   const img = new Image();
   img.onload = () => {
     const isFD = (product && product.isSeamlessTile) ? true : detectFullDesign(img).isFullDesign;
-    // `side` = 'L' (left half) or 'R' (right half) of the continuous design.
-    const make = (side) => {
-      const t = new THREE.Texture(img);
-      t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
-      t.anisotropy = maxAniso; srgb(t);
-      if (isFD) {
-        const { rx, ry } = computeRepeat(wingW, product, img);
-        t.wrapS = t.wrapT = THREE.RepeatWrapping;
-        // Each leaf = half the board's horizontal span. Together they reconstruct
-        // the full design left→right with no mirror, no seam.
-        t.repeat.set(rx / 2, ry);
-        t.offset.x = (side === 'R') ? rx / 2 : 0;
-      } else {
-        // Non-seamless catalog photo: contain-fit the WHOLE image across both leaves
-        // (left half of the photo on the left leaf, right half on the right) so the
-        // open board still shows the real product, just not tiled.
-        t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
-        t.repeat.set(0.5, 1);
-        t.offset.x = (side === 'R') ? 0.5 : 0;
-      }
-      t.needsUpdate = true; return t;
-    };
-    const cached = { left: make('L'), right: make('R'), isFullDesign: isFD };
-    cached.texture = cached.right; // alias so cladWalls() can read cached.texture.image
+    const t = new THREE.Texture(img);
+    t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
+    t.anisotropy = maxAniso; srgb(t);
+    if (isFD) {
+      const { rx, ry } = computeRepeat(wingW, product, img);
+      t.wrapS = t.wrapT = THREE.RepeatWrapping;
+      t.repeat.set(Math.max(1, rx), ry);   // full board span, tiled at true scale
+    } else {
+      // Non-seamless catalog photo: contain-fit the WHOLE image across the board face.
+      t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
+      t.repeat.set(1, 1);
+    }
+    t.needsUpdate = true;
+    const cached = { texture: t, isFullDesign: isFD };
     imageTexCache[imageUrl] = cached;
     apply(cached);
   };
@@ -1814,42 +1822,33 @@ function lazyLoadNearbyTextures() {
 // PROXIMITY BOOK-MATCH — the nearest wing opens like a sample book at ≤4 ft
 // ============================================================
 const _bookWP = new THREE.Vector3();
+// ARC-RACK FLIP — single-vertical-hinge swing. The focused board eases from its packed
+// closed-rake to open/flat (facing the room centre, the hero); every other board eases
+// back to closed-rake. QHRack.flipUpdate does the math + applies pivot.rotation.y; we
+// just resolve which board is the open hero + lazy-load its design + keep shadows fresh.
 function updateBooks(dt) {
   if (!wingBoards.length) return;
-  const backZ = -CONFIG.room.depth / 2 + 0.2;
-  const interactive = exploreMode && proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
 
-  // Find the single wing nearest the camera, but only once we're close to the wall.
+  // In explore mode, a walked-up neighbour (within ~4 ft) opens like the focused board.
   let nearest = null;
+  const interactive = exploreMode && proximityEnabled && !controlsLocked && !peruseActive && !cameraAnim;
   if (interactive) {
     const camX = camera.position.x, camZ = camera.position.z;
-    if (camZ - backZ < PROX_FT + 0.5) {
-      let best = PROX_FT;
-      for (let i = 0; i < wingBoards.length; i++) {
-        wingBoards[i].getWorldPosition(_bookWP);
-        const d = Math.hypot(_bookWP.x - camX, _bookWP.z - camZ);
-        if (d < best) { best = d; nearest = wingBoards[i]; }
-      }
+    let best = PROX_FT;
+    for (let i = 0; i < wingBoards.length; i++) {
+      wingBoards[i].getWorldPosition(_bookWP);
+      const d = Math.hypot(_bookWP.x - camX, _bookWP.z - camZ);
+      if (d < best) { best = d; nearest = wingBoards[i]; }
     }
   }
 
-  const k = Math.min(1, dt * 6); // ease toward target
-  for (let i = 0; i < wingBoards.length; i++) {
-    const p = wingBoards[i], ud = p.userData;
-    const isFocus = (p === focusedWing && !ud.panelClosed);
-    // Focused board → flat dead-on VIEW angle; a walked-up neighbour → browse splay.
-    const target = isFocus ? VIEW_OPEN : (p === nearest ? BOOK_OPEN : 0);
-    if ((isFocus || p === nearest) && !ud.imageLoaded) { ud.imageLoaded = true; loadWingBook(p); }
-    if (Math.abs(ud.bookOpen - target) > 0.0008) {
-      ud.bookOpen += (target - ud.bookOpen) * k;
-      if (window._requestShadowUpdate) window._requestShadowUpdate(3); // leaf moving → keep shadows fresh
-    } else if (ud.bookOpen !== target) {
-      ud.bookOpen = target;
-      if (window._requestShadowUpdate) window._requestShadowUpdate(3);
-    } else continue;
-    ud.leafL.rotation.y = ud.bookOpen;   // left page opens +θ
-    ud.leafR.rotation.y = -ud.bookOpen;  // right page opens −θ (symmetric butterfly)
-  }
+  // The single OPEN board = the focused one (guided/peruse), else the walked-up neighbour.
+  const openPivot = (focusedWing && !focusedWing.userData.panelClosed) ? focusedWing : nearest;
+  // Lazy-load the open board's full design the moment it begins to swing open.
+  if (openPivot && !openPivot.userData.imageLoaded) { openPivot.userData.imageLoaded = true; loadWingBook(openPivot); }
+
+  const moved = QHRack.flipUpdate(wingBoards, openPivot, dt);
+  if (moved && window._requestShadowUpdate) window._requestShadowUpdate(3);
 }
 
 // ============================================================
@@ -1963,6 +1962,11 @@ function animate() {
   if (window._qh && typeof window._qh.frameHook === 'function') {
     try { window._qh.frameHook(dt); } catch (e) { /* never let a mode break the loop */ }
   }
+  // Version overlay per-frame hook (numbered-pin reprojection). Installed by
+  // public/js/versions.js; null when the overlay is off / module not loaded.
+  if (window._qh && typeof window._qh.overlayHook === 'function') {
+    try { window._qh.overlayHook(dt); } catch (e) { /* never let the overlay break the loop */ }
+  }
 
   renderer.render(scene, camera);
 }
@@ -2301,6 +2305,25 @@ function initHUD() {
     stopPeruse(); windowSize = parseInt(dens.value) || 50; await fetchWindow(0); rebuildWingWall();
   });
 
+  // REVEAL slider — how much of each closed board's pattern shows in the packed arc.
+  // Live: re-rakes every board's closed yaw via QHRack.relayout (no full rebuild), so
+  // the open hero stays open while the slivers widen/narrow underneath. Persisted.
+  const rev = document.getElementById('reveal-range');
+  const revVal = document.getElementById('reveal-val');
+  if (rev) {
+    rev.value = REVEAL;
+    if (revVal) revVal.textContent = String(Math.round(REVEAL));
+    rev.addEventListener('input', () => {
+      REVEAL = parseInt(rev.value);
+      if (revVal) revVal.textContent = rev.value;
+      localStorage.setItem('qh_reveal', REVEAL);
+      if (window.QHRack && wingBoards.length) {
+        QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
+        if (window._requestShadowUpdate) window._requestShadowUpdate(6);
+      }
+    });
+  }
+
   // ---- GUIDED MODE wiring (senior-first big buttons) ----
   const gPrev = document.getElementById('g-prev');
   const gNext = document.getElementById('g-next');
@@ -2628,7 +2651,43 @@ window._qh = {
   FOV_DEFAULT, FOV_HERO,
   setTargetFov(f) { targetFov = f; },
   requestShadowUpdate,
+  // World-position helper for the numbered-element overlay: returns the world-space
+  // centre of wing board i (or the rail centre if out of range).
+  boardWorldPos(i) {
+    const b = wingBoards[i];
+    const v = new THREE.Vector3();
+    if (b) { b.getWorldPosition(v); v.y = CONFIG.wing.height / 2 + 0.04; }
+    else { v.set(0, CONFIG.wing.height / 2 + 0.04, -CONFIG.room.depth / 2 + 0.2); }
+    return v;
+  },
+  // Project a THREE.Vector3 world point to {x,y,onScreen} CSS pixels for HUD pins.
+  projectToScreen(vec3) {
+    const v = vec3.clone().project(camera);
+    const onScreen = v.z < 1 && v.x >= -1.2 && v.x <= 1.2 && v.y >= -1.2 && v.y <= 1.2;
+    return {
+      x: (v.x * 0.5 + 0.5) * window.innerWidth,
+      y: (-v.y * 0.5 + 0.5) * window.innerHeight,
+      onScreen
+    };
+  },
+  // Convenience: a few stable named scene anchors versions.js can pin numbers to.
+  namedAnchor(name) {
+    const w = CONFIG.wing.height, R = CONFIG.room;
+    switch (name) {
+      case 'railCenter':  return new THREE.Vector3(0, w + 0.10, -R.depth / 2 + 0.25);
+      case 'signWall':    return new THREE.Vector3(-R.width / 2 + 0.4, 1.55, -R.depth / 2 + 0.6);
+      case 'nook':        return new THREE.Vector3(R.width / 2 - 1.5, 0.62, R.depth / 2 - 1.7);
+      case 'floor':       return new THREE.Vector3(0, 0.02, R.depth / 2 - 2.2);
+      case 'leftWall':    return new THREE.Vector3(-R.width / 2 + 0.2, 1.5, 0);
+      case 'rightWall':   return new THREE.Vector3(R.width / 2 - 0.2, 1.5, 0);
+      case 'ceiling':     return new THREE.Vector3(0, R.height - 0.15, 0);
+      default:            return new THREE.Vector3(0, 1.2, 0);
+    }
+  },
   // A per-frame hook the view-mode engine installs (orbit/carousel/macro drift etc.)
-  frameHook: null
+  frameHook: null,
+  // A SECOND per-frame hook for the version overlay (pin reprojection). Kept separate
+  // from frameHook so the view-mode engine and version overlay don't clobber each other.
+  overlayHook: null
 };
 })();
diff --git a/public/js/versions.js b/public/js/versions.js
new file mode 100644
index 0000000..affe83d
--- /dev/null
+++ b/public/js/versions.js
@@ -0,0 +1,554 @@
+/* ============================================================================
+ * versions.js — The Quadrille House: the "10 VERSIONS" system.
+ *
+ * Self-contained module loaded AFTER showroom.js + viewmodes.js. Talks to the
+ * showroom ONLY through the stable window._qh API (and window._viewmode). Adds:
+ *   1. a persistent VERSION PICKER rail (V1…V10) — each version is a distinct
+ *      showroom LOOK with its own build() that (re)stages the scene.
+ *   2. a NUMBERED-ELEMENT OVERLAY — clickable circled-number pins (①②③…) on
+ *      each element of the active version, with the element label.
+ *   3. a "Chosen Elements" tray — clicking a pin adds {V_.N label} to a tray
+ *      Steve uses to compose the final site (copy-as-text export).
+ *
+ * Architecture (DTD verdict A, 2026-06-28): a bolt-on like viewmodes.js. It
+ * NEVER edits the monolith; it composes existing environments / view-modes /
+ * themes + a focused scene-reconfig for V1/V2. The LOCKED features (senior
+ * guided Next/Back/Auto-Tour, collapsed popup, 27×27 specs, photoreal surfaces,
+ * on-demand shadows, FPS≥50) all keep working because every version is built on
+ * top of focusOnWing / enterGuidedDefault / peruse / the view-mode engine.
+ *
+ * State persisted to localStorage: last version (qh_version), chosen elements
+ * (qh_chosen), overlay on/off (qh_overlay), tray open (qh_tray_open).
+ * ========================================================================== */
+(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 VM = () => window._viewmode;   // view-mode engine (lazy — may load a beat later)
+
+  // ---- helpers that drive the showroom through the stable API ----------------
+  function setTheme(key) { const vm = VM(); if (vm && vm.theme) try { vm.theme(key); } catch (e) {} }
+  function setMode(key)  { const vm = VM(); if (vm && vm.set)   try { vm.set(key);   } catch (e) {} }
+  function setWingDeg(deg) {
+    const ang = document.getElementById('angle-range');
+    if (ang) { ang.value = deg; ang.dispatchEvent(new Event('input')); }
+  }
+  function setReveal(v) {
+    const r = document.getElementById('reveal-range');
+    if (r) { r.value = v; r.dispatchEvent(new Event('input')); }
+  }
+  function setBoards(n) {
+    const d = document.getElementById('density-range');
+    if (d) { d.value = n; d.dispatchEvent(new Event('input')); }
+  }
+  function focusMiddle() {
+    const wb = QH.wingBoards; if (!wb.length) return;
+    const mid = Math.floor(wb.length / 2);
+    QH.restoreBoards();
+    if (!QH.focusedWing || QH.focusedWing.userData.index !== mid) QH.focusOnWing(wb[mid]);
+  }
+
+  // ===========================================================================
+  // VERSION REGISTRY — each: { name, tag, scaffold?, build(), elements:[…] }
+  //   elements[i] = { n, label, anchor }  where anchor is:
+  //     { board: <index> }     → pin tracks wing board i
+  //     { named: '<name>' }    → pin tracks a named scene anchor (railCenter…)
+  //     { ui: '#selector' }    → pin tracks a HUD element's centre (sliders etc.)
+  // build() (re)stages the look; it MUST be idempotent + reversible (compose
+  // existing modes/themes — no direct mesh creation, so nothing leaks between
+  // versions). The numbered elements are the SELECT-AND-CHOOSE menu Steve composes.
+  // ===========================================================================
+  const VERSIONS = {
+    V1: {
+      name: 'PJ Wingboard Arc Rack', tag: 'arc',
+      build() {
+        // The canonical Phillip-Jeffries Dallas look (pj-dallas-showroom.png):
+        // packed sliver boards on the arc, one flipped open in the centre, warm
+        // gallery light, the consultation nook + brand signage in view.
+        setTheme('warm');
+        setBoards(8);            // a full arc of boards
+        setReveal(40);           // thin slivers — most boards show only an edge
+        setMode('hero');         // dead-on hero framing on the open middle board
+        setWingDeg(8);
+        focusMiddle();
+      },
+      elements: [
+        { n: 1, label: 'Arc / bay rack wrapping the corner',          anchor: { named: 'railCenter' } },
+        { n: 2, label: 'Sliver-packed wingboards (edge of each shows)', anchor: { board: 2 } },
+        { n: 3, label: 'Dark-bronze board frames',                     anchor: { board: 1 } },
+        { n: 4, label: 'Flip-to-open hero (single vertical hinge)',    anchor: { board: -1 } }, // -1 = focused board
+        { n: 5, label: 'Consultation nook (table + bouclé chairs + plant)', anchor: { named: 'nook' } },
+        { n: 6, label: 'Brand signage on the wall',                    anchor: { named: 'signWall' } },
+        { n: 7, label: 'REVEAL slider (how much of each board shows)', anchor: { ui: '#reveal-range' } }
+      ]
+    },
+
+    V2: {
+      name: 'Dry-Clean Conveyor Rack', tag: 'conveyor',
+      build() {
+        // User dead-centre + stationary; the rack rotates each product into the
+        // centre. Built on the carousel view-mode (turntable yaw of the wall
+        // group) so the boards parade past like a dry-cleaner's conveyor loop.
+        setTheme('cool');
+        setBoards(10);
+        setReveal(70);
+        setWingDeg(0);
+        setMode('carousel');     // turntable — boards rotate into the centre
+      },
+      elements: [
+        { n: 1, label: 'Rotating conveyor loop',                  anchor: { named: 'railCenter' } },
+        { n: 2, label: 'User dead-centre + stationary',           anchor: { named: 'floor' } },
+        { n: 3, label: 'ARROW KEYS / Next rotate the rack',       anchor: { ui: '#g-next' } },
+        { n: 4, label: 'Each product rotates into the centre',    anchor: { board: -1 } },
+        { n: 5, label: 'Reveal slider',                           anchor: { ui: '#reveal-range' } }
+      ]
+    },
+
+    V3: {
+      name: 'Sample-Card Wall on Hooks', tag: 'cards', scaffold: true,
+      build() {
+        // Scaffold: a flat, even-lit gallery survey reads closest to the swatch-
+        // card grid until the instanced card wall is built. Gallery mode pulls
+        // back to show every board on the rail at once.
+        setTheme('bright');
+        setBoards(12);
+        setReveal(80);
+        setWingDeg(0);
+        setMode('gallery');
+      },
+      elements: [
+        { n: 1, label: 'Grid of small swatch cards',         anchor: { named: 'railCenter' } },
+        { n: 2, label: 'Hung on hooks / pegs',               anchor: { board: 0 } },
+        { n: 3, label: 'Label header per card',              anchor: { board: 4 } },
+        { n: 4, label: 'Every collection item (instanced)',  anchor: { named: 'rightWall' } },
+        { n: 5, label: 'Tap card → enlarge + spec',          anchor: { board: -1 } }
+      ]
+    },
+
+    V4: {
+      name: 'Fan-on-Table', tag: 'fan', scaffold: true,
+      build() {
+        // Scaffold: a top-down plan over the consultation table reads closest to
+        // a radial fan of swatches laid on the table.
+        setTheme('warm');
+        setMode('topdown');
+      },
+      elements: [
+        { n: 1, label: 'Radial pinwheel of swatches',      anchor: { named: 'nook' } },
+        { n: 2, label: 'Laid on the consultation table',   anchor: { named: 'nook' } },
+        { n: 3, label: 'Label tabs radiating from centre', anchor: { named: 'floor' } },
+        { n: 4, label: 'Spin / drag to browse',            anchor: { ui: '#g-next' } }
+      ]
+    },
+
+    V5: {
+      name: 'Closet Carousel', tag: 'closet', scaffold: true,
+      build() {
+        // Scaffold: an orbit around a single board in warm boutique light reads
+        // closest to a rotating closet carousel in the right corner.
+        setTheme('boutique');
+        setWingDeg(20);
+        focusMiddle();
+        setMode('orbit');
+      },
+      elements: [
+        { n: 1, label: 'Rotating circular two-tier rack', anchor: { board: -1 } },
+        { n: 2, label: 'In the RIGHT-CORNER closet',      anchor: { named: 'rightWall' } },
+        { n: 3, label: 'Fabric samples from the collection', anchor: { board: 0 } },
+        { n: 4, label: 'Spin to browse',                  anchor: { ui: '#g-next' } },
+        { n: 5, label: 'Warm closet millwork',            anchor: { named: 'rightWall' } }
+      ]
+    },
+
+    V6: {
+      name: 'Flat Gallery Wall', tag: 'gallery',
+      build() {
+        // The current/baseline build: flat boards on the brass rail, gallery
+        // placards, even white-box light.
+        setTheme('bright');
+        setBoards(8);
+        setReveal(62);
+        setWingDeg(8);
+        setMode('hero');
+        focusMiddle();
+      },
+      elements: [
+        { n: 1, label: 'Flat boards on the brass rail', anchor: { named: 'railCenter' } },
+        { n: 2, label: 'Gallery placards',              anchor: { board: -1 } },
+        { n: 3, label: 'Even white-box light',          anchor: { named: 'ceiling' } }
+      ]
+    },
+
+    V7: {
+      name: 'Infinite-White Studio', tag: 'studio', scaffold: true,
+      build() {
+        setTheme('daylight');
+        setWingDeg(0);
+        focusMiddle();
+        setMode('hero');
+      },
+      elements: [
+        { n: 1, label: 'Seamless white cyclorama', anchor: { named: 'ceiling' } },
+        { n: 2, label: 'Single floating board',    anchor: { board: -1 } },
+        { n: 3, label: 'Pure e-comm product shot', anchor: { named: 'floor' } }
+      ]
+    },
+
+    V8: {
+      name: 'Living-Room Setting', tag: 'room', scaffold: true,
+      build() {
+        // Room mode clads the actual room walls in the focused pattern — "see it
+        // installed" with the furniture in frame.
+        setTheme('warm');
+        focusMiddle();
+        setMode('room');
+      },
+      elements: [
+        { n: 1, label: 'Pattern clad on a real room wall', anchor: { named: 'leftWall' } },
+        { n: 2, label: 'Residential furniture',            anchor: { named: 'nook' } },
+        { n: 3, label: '"See it installed"',               anchor: { board: -1 } }
+      ]
+    },
+
+    V9: {
+      name: 'Boutique Moody', tag: 'moody', scaffold: true,
+      build() {
+        setTheme('evening');
+        setWingDeg(2);
+        focusMiddle();
+        setMode('hero');
+      },
+      elements: [
+        { n: 1, label: 'Dark plaster walls',        anchor: { named: 'leftWall' } },
+        { n: 2, label: 'Spotlit hero board',        anchor: { board: -1 } },
+        { n: 3, label: 'Dramatic single-key light', anchor: { named: 'ceiling' } }
+      ]
+    },
+
+    V10: {
+      name: 'Macro Detail', tag: 'macro', scaffold: true,
+      build() {
+        setTheme('boutique');
+        focusMiddle();
+        setMode('macro');     // extreme close-up + slow parallax drift
+      },
+      elements: [
+        { n: 1, label: 'Extreme close-up of the print / weave', anchor: { board: -1 } },
+        { n: 2, label: 'Texture / normal detail',               anchor: { board: -1 } },
+        { n: 3, label: 'Slow parallax drift',                   anchor: { named: 'floor' } }
+      ]
+    }
+  };
+  const VERSION_ORDER = ['V1','V2','V3','V4','V5','V6','V7','V8','V9','V10'];
+
+  // ===========================================================================
+  // STATE
+  // ===========================================================================
+  let currentVersion = null;
+  let overlayOn = false;
+  let chosen = loadChosen();          // [{ key:'V1.4', version:'V1', n:4, label:'…' }]
+
+  function loadChosen() {
+    try { return JSON.parse(localStorage.getItem('qh_chosen') || '[]'); } catch (e) { return []; }
+  }
+  function saveChosen() {
+    try { localStorage.setItem('qh_chosen', JSON.stringify(chosen)); } catch (e) {}
+  }
+
+  // ===========================================================================
+  // VERSION SWITCH — the single switcher. Persists choice; rebuilds the look.
+  // ===========================================================================
+  function setVersion(key, fromInit) {
+    const v = VERSIONS[key]; if (!v) return;
+    currentVersion = key;
+    try { v.build(); } catch (e) { console.warn('version build failed', key, e); }
+    if (QH.requestShadowUpdate) QH.requestShadowUpdate(20);
+    // rail active state
+    document.querySelectorAll('#ver-rail .ver-chip').forEach(c =>
+      c.classList.toggle('active', c.dataset.version === key));
+    if (!fromInit) { try { localStorage.setItem('qh_version', key); } catch (e) {} }
+    // rebuild the pin overlay for the new version (its elements differ)
+    if (overlayOn) buildPins();
+  }
+
+  // ===========================================================================
+  // NUMBERED-ELEMENT OVERLAY — circled-number pins projected onto each element.
+  // ===========================================================================
+  let pinLayer = null;
+  let activePins = [];   // [{ el, anchor, n, label, key }]
+
+  function anchorWorld(anchor) {
+    if (!anchor) return null;
+    if (anchor.named) return QH.namedAnchor(anchor.named);
+    if (typeof anchor.board === 'number') {
+      let i = anchor.board;
+      if (i === -1) { // the currently-focused board (the "hero")
+        i = QH.focusedWing ? QH.focusedWing.userData.index : Math.floor(QH.wingBoards.length / 2);
+      }
+      i = Math.max(0, Math.min(QH.wingBoards.length - 1, i));
+      return QH.boardWorldPos(i);
+    }
+    return null; // UI-anchored pins handled separately (no world projection)
+  }
+
+  function buildPins() {
+    clearPins();
+    const v = VERSIONS[currentVersion]; if (!v) return;
+    if (!pinLayer) { pinLayer = document.getElementById('pin-layer'); }
+    if (!pinLayer) return;
+    v.elements.forEach(elt => {
+      const key = currentVersion + '.' + elt.n;
+      const pin = document.createElement('div');
+      pin.className = 'el-pin';
+      pin.dataset.key = key;
+      pin.innerHTML =
+        '<span class="pin-badge">' + elt.n + '</span>' +
+        '<span class="pin-label">' + escapeHtml(elt.label) + '</span>';
+      if (isChosen(key)) pin.classList.add('chosen');
+      pin.addEventListener('click', (e) => { e.stopPropagation(); toggleChoice(key, currentVersion, elt.n, elt.label, pin); });
+      pinLayer.appendChild(pin);
+      activePins.push({ el: pin, anchor: elt.anchor, n: elt.n, label: elt.label, key });
+    });
+    positionPins(); // place immediately
+  }
+
+  function clearPins() {
+    activePins.forEach(p => p.el.remove());
+    activePins = [];
+  }
+
+  // Per-frame: reproject every pin's world anchor to screen (or read a UI element's
+  // box for ui-anchored pins). Installed as QH.overlayHook only while the overlay is on.
+  function positionPins() {
+    if (!overlayOn || !activePins.length) return;
+    const w = window.innerWidth, h = window.innerHeight;
+    activePins.forEach(p => {
+      let x, y, vis = true;
+      if (p.anchor && p.anchor.ui) {
+        const tgt = document.querySelector(p.anchor.ui);
+        if (tgt) { const r = tgt.getBoundingClientRect(); x = r.left + r.width / 2; y = r.top + r.height / 2; vis = r.width > 0; }
+        else { vis = false; }
+      } else {
+        const wpt = anchorWorld(p.anchor);
+        if (!wpt) { vis = false; }
+        else {
+          const s = QH.projectToScreen(wpt);
+          x = s.x; y = s.y; vis = s.onScreen;
+        }
+      }
+      if (vis && x >= -40 && x <= w + 40 && y >= -40 && y <= h + 40) {
+        p.el.style.display = 'flex';
+        p.el.style.left = Math.round(x) + 'px';
+        p.el.style.top = Math.round(y) + 'px';
+      } else {
+        p.el.style.display = 'none';
+      }
+    });
+  }
+
+  function setOverlay(on) {
+    overlayOn = (on == null) ? !overlayOn : on;
+    const layer = document.getElementById('pin-layer');
+    if (layer) layer.style.display = overlayOn ? 'block' : 'none';
+    const btn = document.getElementById('vr-overlay-btn');
+    if (btn) { btn.classList.toggle('active', overlayOn); btn.innerHTML = overlayOn ? '① Numbers ON' : '① Numbers'; }
+    if (overlayOn) { buildPins(); QH.overlayHook = positionPins; }
+    else { clearPins(); QH.overlayHook = null; }
+    try { localStorage.setItem('qh_overlay', overlayOn ? '1' : '0'); } catch (e) {}
+  }
+
+  // ===========================================================================
+  // CHOSEN ELEMENTS TRAY
+  // ===========================================================================
+  function isChosen(key) { return chosen.some(c => c.key === key); }
+  function toggleChoice(key, version, n, label, pinEl) {
+    const i = chosen.findIndex(c => c.key === key);
+    if (i >= 0) { chosen.splice(i, 1); if (pinEl) pinEl.classList.remove('chosen'); }
+    else { chosen.push({ key, version, n, label }); if (pinEl) pinEl.classList.add('chosen'); bumpCount(); }
+    saveChosen(); renderTray();
+  }
+  function removeChoice(key) {
+    const i = chosen.findIndex(c => c.key === key);
+    if (i >= 0) chosen.splice(i, 1);
+    saveChosen(); renderTray();
+    const pin = document.querySelector('#pin-layer .el-pin[data-key="' + cssEsc(key) + '"]');
+    if (pin) pin.classList.remove('chosen');
+  }
+  function bumpCount() {
+    const c = document.getElementById('ct-count'); if (!c) return;
+    c.classList.add('bump'); setTimeout(() => c.classList.remove('bump'), 180);
+  }
+  function chosenText() {
+    // Sort by version order then element number for a tidy composition spec.
+    const sorted = chosen.slice().sort((a, b) =>
+      (VERSION_ORDER.indexOf(a.version) - VERSION_ORDER.indexOf(b.version)) || (a.n - b.n));
+    return sorted.map(c => c.key + ' ' + c.label).join('\n');
+  }
+  function renderTray() {
+    const tray = document.getElementById('chosen-tray'); if (!tray) return;
+    tray.classList.add('show');           // tray is always present once initialized
+    const items = document.getElementById('chosen-items');
+    const empty = document.getElementById('chosen-empty');
+    const count = document.getElementById('ct-count');
+    if (count) count.textContent = chosen.length;
+    if (!items) return;
+    items.innerHTML = '';
+    if (!chosen.length) {
+      if (empty) empty.style.display = 'block';
+      items.style.display = 'none';
+      return;
+    }
+    if (empty) empty.style.display = 'none';
+    items.style.display = 'flex';
+    const sorted = chosen.slice().sort((a, b) =>
+      (VERSION_ORDER.indexOf(a.version) - VERSION_ORDER.indexOf(b.version)) || (a.n - b.n));
+    sorted.forEach(c => {
+      const row = document.createElement('div');
+      row.className = 'ct-chip';
+      row.innerHTML =
+        '<span class="ctc-tag">' + c.key + '</span>' +
+        '<span class="ctc-name">' + escapeHtml(c.label) + '</span>' +
+        '<button class="ctc-x" title="Remove">&times;</button>';
+      row.querySelector('.ctc-x').addEventListener('click', () => removeChoice(c.key));
+      items.appendChild(row);
+    });
+  }
+
+  // ===========================================================================
+  // DOM — version rail, pin layer, tray. Built once; vanilla CSS (ART-DIRECTION).
+  // ===========================================================================
+  function buildDom() {
+    // pin layer (above canvas, below chrome)
+    if (!document.getElementById('pin-layer')) {
+      const pl = document.createElement('div'); pl.id = 'pin-layer'; pl.style.display = 'none';
+      document.body.appendChild(pl);
+    }
+
+    // VERSION RAIL
+    const rail = document.createElement('div'); rail.id = 'ver-rail';
+    let chips = '';
+    VERSION_ORDER.forEach(k => {
+      const v = VERSIONS[k];
+      const num = k.replace('V', '');
+      chips +=
+        '<button class="ver-chip' + (v.scaffold ? ' scaffold' : '') + '" data-version="' + k + '" title="' + escapeHtml(v.name) + (v.scaffold ? ' (scaffold)' : '') + '">' +
+          '<span class="vc-num">V' + num + '</span>' +
+          '<span class="vc-name">' + escapeHtml(v.name) + '</span>' +
+        '</button>';
+    });
+    rail.innerHTML =
+      '<div id="ver-rail-head">' +
+        '<span class="vr-eyebrow">Versions</span>' +
+        '<button id="ver-rail-collapse" title="Hide the version rail">&times;</button>' +
+      '</div>' +
+      chips +
+      '<div id="ver-rail-tools">' +
+        '<button id="vr-overlay-btn" class="vr-tool" title="Show numbered element pins to select & choose">① Numbers</button>' +
+      '</div>';
+    document.body.appendChild(rail);
+
+    // pull-out tab when collapsed
+    const tab = document.createElement('div'); tab.id = 'ver-rail-tab'; tab.textContent = 'Versions';
+    document.body.appendChild(tab);
+
+    // CHOSEN TRAY
+    const tray = document.createElement('div'); tray.id = 'chosen-tray';
+    tray.innerHTML =
+      '<div id="chosen-tray-head">' +
+        '<span class="ct-title">Chosen Elements <span id="ct-count" class="ct-count">0</span></span>' +
+        '<span class="ct-chev">&#9650;</span>' +
+      '</div>' +
+      '<div id="chosen-tray-body">' +
+        '<div id="chosen-empty">Turn on <strong>① Numbers</strong> and tap a pin to pick the elements you like. They collect here so you can compose the final site (e.g. V1.4 + V5.2).</div>' +
+        '<div id="chosen-items"></div>' +
+        '<div id="chosen-actions">' +
+          '<button class="ct-act primary" id="ct-copy">Copy as text</button>' +
+          '<button class="ct-act" id="ct-clear">Clear all</button>' +
+        '</div>' +
+      '</div>';
+    document.body.appendChild(tray);
+
+    // ---- wire events ----
+    rail.querySelectorAll('.ver-chip').forEach(c =>
+      c.addEventListener('click', () => setVersion(c.dataset.version)));
+    document.getElementById('vr-overlay-btn').addEventListener('click', () => setOverlay());
+    document.getElementById('ver-rail-collapse').addEventListener('click', () => {
+      rail.classList.add('collapsed');
+      try { localStorage.setItem('qh_ver_rail', 'collapsed'); } catch (e) {}
+    });
+    tab.addEventListener('click', () => {
+      rail.classList.remove('collapsed');
+      try { localStorage.setItem('qh_ver_rail', 'open'); } catch (e) {}
+    });
+    if (localStorage.getItem('qh_ver_rail') === 'collapsed') rail.classList.add('collapsed');
+
+    // tray: header toggles open/closed; copy + clear
+    document.getElementById('chosen-tray-head').addEventListener('click', () => {
+      tray.classList.toggle('open');
+      try { localStorage.setItem('qh_tray_open', tray.classList.contains('open') ? '1' : '0'); } catch (e) {}
+    });
+    if (localStorage.getItem('qh_tray_open') === '1') tray.classList.add('open');
+    document.getElementById('ct-copy').addEventListener('click', (e) => {
+      e.stopPropagation();
+      const txt = chosenText();
+      const btn = e.currentTarget;
+      const done = () => { const o = btn.textContent; btn.textContent = 'Copied ✓'; setTimeout(() => btn.textContent = o, 1200); };
+      if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(txt).then(done).catch(() => fallbackCopy(txt, done));
+      else fallbackCopy(txt, done);
+    });
+    document.getElementById('ct-clear').addEventListener('click', (e) => {
+      e.stopPropagation();
+      chosen = []; saveChosen(); renderTray();
+      document.querySelectorAll('#pin-layer .el-pin.chosen').forEach(p => p.classList.remove('chosen'));
+    });
+
+    tray.classList.add('show');
+    renderTray();
+  }
+
+  function fallbackCopy(txt, done) {
+    try {
+      const ta = document.createElement('textarea'); ta.value = txt;
+      ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta);
+      ta.select(); document.execCommand('copy'); document.body.removeChild(ta); done && done();
+    } catch (e) {}
+  }
+  function escapeHtml(s) { return String(s).replace(/[&<>"']/g, m => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[m])); }
+  function cssEsc(s) { return String(s).replace(/(["\\.])/g, '\\$1'); }
+
+  // ===========================================================================
+  // INIT — build DOM, restore choices, set the boot version.
+  // Boot rule (LOCKED): keep it simple — default to V1 (or V6) so the senior
+  // guided experience is intact on load. Never boot a free-camera scaffold.
+  // ===========================================================================
+  buildDom();
+
+  // Restore last version, but only the senior-safe presentation versions on boot
+  // (never auto-boot a free-camera scaffold like orbit/conveyor for a returning
+  // senior — they'd land mid-spin). Otherwise fall back to V1.
+  const safeBoot = { V1: 1, V6: 1, V7: 1, V8: 1, V9: 1, V10: 1 };
+  const saved = localStorage.getItem('qh_version');
+  const bootKey = (saved && VERSIONS[saved] && safeBoot[saved]) ? saved : 'V1';
+  // Defer the build a beat so viewmodes.js has finished its own boot (it sets Hero).
+  setTimeout(() => setVersion(bootKey, true), 900);
+
+  // Overlay restore (default OFF — the locked simple boot).
+  if (localStorage.getItem('qh_overlay') === '1') setTimeout(() => setOverlay(true), 1400);
+
+  // Expose for the test harness + cross-module use.
+  window._versions = {
+    set: setVersion,
+    overlay: setOverlay,
+    get current() { return currentVersion; },
+    get overlayOn() { return overlayOn; },
+    get chosen() { return chosen.slice(); },
+    chosenText,
+    VERSIONS, VERSION_ORDER
+  };
+}
+
+boot();
+})();
diff --git a/public/showroom.html b/public/showroom.html
index 2214b0c..0d493d2 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -5,6 +5,9 @@
   <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
   <title>The Quadrille House — Wallcovering Showroom</title>
   <link rel="icon" type="image/svg+xml" href="/favicon.svg">
+  <link rel="preconnect" href="https://fonts.googleapis.com">
+  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+  <link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@500;600;700&display=swap" rel="stylesheet">
   <link rel="stylesheet" href="/css/showroom.css">
   <!-- TEMP DEV AID (remove when the build is finalized): auto-reload every 30s so an
        open tab picks up each commit while iterating. Skips a reload if you interacted
@@ -283,11 +286,14 @@
         <option value="book">Book</option>
       </select>
       <span id="density-wrap" title="Wings per wall">
-        <span>Boards</span><input type="range" id="density-range" min="4" max="12" step="2" value="8">
+        <span>Boards</span><input type="range" id="density-range" min="8" max="24" step="2" value="18">
       </span>
       <span id="density-wrap" title="Open-board angle (0°=flat, dead-on)">
         <span>Wing&deg;</span><input type="range" id="angle-range" min="0" max="90" step="5" value="8"><span id="angle-val">8&deg;</span>
       </span>
+      <span id="density-wrap" title="How much of each closed board's pattern shows — wider edges = fewer boards, thinner slivers = more boards in the arc">
+        <span>Reveal</span><input type="range" id="reveal-range" min="0" max="100" step="5" value="62"><span id="reveal-val">62</span>
+      </span>
     </div>
 
     <!-- NOW VIEWING navigator — museum media-player strip (shown only on focus).
@@ -378,7 +384,9 @@
 
   <script src="/js/three.min.js"></script>
   <script src="/js/OrbitControls.js"></script>
+  <script src="/js/rack.js"></script>
   <script src="/js/showroom.js"></script>
   <script src="/js/viewmodes.js"></script>
+  <script src="/js/versions.js"></script>
 </body>
 </html>

← 6593c48 VERSIONS.md: 10 numbered showroom versions w/ numbered eleme  ·  back to Quadrille Showroom  ·  Consultation nook (round table + 2 boucle chairs + plant + s c499d15 →