← 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
M public/css/showroom.cssA public/js/rack.jsM public/js/showroom.jsA public/js/versions.jsM public/showroom.html
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">×</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">×</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">▲</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 => ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[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°</span><input type="range" id="angle-range" min="0" max="90" step="5" value="8"><span id="angle-val">8°</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 →