[object Object]

← back to Quadrille Showroom

Default view = walk the avatar + single centered mirrored book + minimal caption bar

fa8ee901fbb1e551f9f2162c0728f35b74d1000c · 2026-06-29 15:14:24 -0700 · Steve

- Walking is now the default: exploreMode ON + persisted; arrows = up/down walk along
  facing dir, left/right turn the body. Avatar rig + camera move together, clamped to room.
- Spawn dead-centre, stationary, facing the wing -- no boot auto-fly (enterFixedCentre /
  enterGuidedDefault are no-ops in book mode).
- SKU display is now a quiet fixed #wing-caption strip (Pattern . Colorway . SKU . Vendor),
  never pops/animates; the old wing-detail popup card is suppressed in book mode.
- The view is ONE open sample book centred on -Z with two leaves hinged at the spine;
  left leaf is a horizontal MIRROR of the right (repeat.x flipped). The 50-board arc is
  hidden. [ ] / N-P page the catalog.
- Consultation table+chairs nudged so the furnished 20x20 room reads in the scene.

Files touched

Diff

commit fa8ee901fbb1e551f9f2162c0728f35b74d1000c
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 15:14:24 2026 -0700

    Default view = walk the avatar + single centered mirrored book + minimal caption bar
    
    - Walking is now the default: exploreMode ON + persisted; arrows = up/down walk along
      facing dir, left/right turn the body. Avatar rig + camera move together, clamped to room.
    - Spawn dead-centre, stationary, facing the wing -- no boot auto-fly (enterFixedCentre /
      enterGuidedDefault are no-ops in book mode).
    - SKU display is now a quiet fixed #wing-caption strip (Pattern . Colorway . SKU . Vendor),
      never pops/animates; the old wing-detail popup card is suppressed in book mode.
    - The view is ONE open sample book centred on -Z with two leaves hinged at the spine;
      left leaf is a horizontal MIRROR of the right (repeat.x flipped). The 50-board arc is
      hidden. [ ] / N-P page the catalog.
    - Consultation table+chairs nudged so the furnished 20x20 room reads in the scene.
---
 public/js/showroom.js | 299 ++++++++++++++++++++++++++++++++++++++++++++++----
 public/showroom.html  |  22 ++++
 2 files changed, 301 insertions(+), 20 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 50c8c3f..d646d0f 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -135,9 +135,22 @@ const WALK_SPEED = 2.5; // meters per second
 // Previous/Next/Auto-Tour buttons drive everything, NO keyboard + NO 3D walking
 // required. Free-walk (WASD/orbit) is demoted behind an 'Explore' toggle that is
 // OFF by default. exploreMode gates all walking/orbit/proximity input.
-let exploreMode = (localStorage.getItem('qh_explore') === '1');
+// Steve takeover (2026-06-29): WALKING is now the DEFAULT. The avatar spawns dead-centre
+// facing the wing and the user "takes over the body" — ↑/↓ walk along the facing
+// direction, ←/→ turn the body. exploreMode therefore defaults ON (and is persisted ON
+// the first time) so boot is the first-person walk experience, not the guided arc browser.
+let exploreMode = (localStorage.getItem('qh_explore') !== '0');
+if (localStorage.getItem('qh_explore') === null) localStorage.setItem('qh_explore', '1');
 let guidedReady = false; // set true once the middle board has been auto-focused on load
 
+// CENTER BOOK (Steve 2026-06-29): the DEFAULT view is ONE open sample book hinged at the
+// spine, centred on the spawn's forward axis (-Z), with the LEFT leaf a horizontal MIRROR
+// of the right leaf — the same pattern reads symmetrically on both sides. The 50-board arc
+// is NOT built in this view; [ ] (or N/P) page through the catalog to change the design.
+const BOOK_MODE = true;
+let centerBook = null;       // { group, leafL, leafR, faceL, faceR } — the single open book
+let bookIndex = 0;           // which catalog design is shown (within the current window)
+
 // ============================================================
 // INIT
 // ============================================================
@@ -221,6 +234,9 @@ function init() {
   window.addEventListener('resize', onResize, false);
   window.addEventListener('keydown', e => {
     keysDown[e.code] = true;
+    // CENTER BOOK paging — [ / ] (and N / P) change which catalog design the open book shows.
+    if (BOOK_MODE && (e.code === 'BracketRight' || e.code === 'KeyN')) { e.preventDefault(); pageCenterBook(1); return; }
+    if (BOOK_MODE && (e.code === 'BracketLeft'  || e.code === 'KeyP')) { e.preventDefault(); pageCenterBook(-1); return; }
     if (e.code === 'KeyM') {
       minimapVisible = !minimapVisible;
       const mc = document.getElementById('minimap');
@@ -930,7 +946,10 @@ function buildFurniture() {
   // walking, and the 72° clamp still reaches it. Relocated proportionally for the 20'×20'
   // room (Chunk A′): old (1.25,-0.55) × 6.10/4.27 ≈ (1.78,-0.78) — same yaw angle, farther out.
   // ============================================================
-  const nookX = 1.78, nookZ = -0.78;
+  // Pulled a touch left + forward (was 1.78,-0.78) so the table + bouclé chairs read as
+  // part of the furnished 20'×20' scene — a gentle right-turn from the spawn lands on them,
+  // and they stay clear of the centred open book (x=0, z≈-2.4). Steve 2026-06-29.
+  const nookX = 1.55, nookZ = -1.1;
   buildConsultationNook(nookX, nookZ);
 
   // (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
@@ -1389,6 +1408,17 @@ async function loadProducts() {
   updateLoadStatus('Building wing wall...', 80);
   buildWingWall();
   updateWindowLabel();
+
+  // CENTER BOOK default (Steve 2026-06-29): hide the 50-board arc and present ONE open
+  // mirrored sample book centred on the spawn's forward axis. The avatar spawns dead-centre,
+  // stationary, facing it — NO auto-fly. [ ] / N-P page the catalog.
+  if (BOOK_MODE) {
+    if (currentWallGroup) currentWallGroup.visible = false;   // no arc in the book view
+    buildCenterBook();
+    setCenterBook(Math.floor((products.slice(0, windowSize).length) / 2));
+    document.body.classList.add('book-mode');
+  }
+
   updateLoadStatus('Ready!', 100);
   setTimeout(() => {
     document.getElementById('loading-screen').classList.add('fade-out');
@@ -1970,6 +2000,159 @@ function loadWingBook(pivot, onDone) {
   img.src = src;
 }
 
+// ============================================================
+// CENTER BOOK — single open mirrored sample book (Steve 2026-06-29 default view)
+// ------------------------------------------------------------
+// ONE board centred on the spawn's forward axis (-Z), opened like a sample book with
+// TWO leaves hinged at the spine. The RIGHT leaf shows the design normally; the LEFT leaf
+// shows a horizontal MIRROR of it (repeat.x negative) so the pattern reads symmetrically
+// meeting at the centre spine. Paged with [ ] / N-P. No arc, no auto-fly.
+// ============================================================
+const BOOK_SPINE_Z = -2.4;       // spine sits ~8ft ahead of the centred spawn
+const BOOK_LEAF_SPLAY = 0.30;    // radians each leaf opens off flat (gentle V, like an open book on a stand)
+
+function buildCenterBook() {
+  if (centerBook) return centerBook;
+  const WC = CONFIG.wing;
+  const H = WC.height;
+  const leafW = WC.boardWidthM;           // each leaf is a full 30" face → ~60" open spread
+  const yC = H / 2 + 0.04;
+  const THICK = 0.014;
+
+  const group = new THREE.Group();
+  group.position.set(0, 0, BOOK_SPINE_Z);  // hinge/spine on the room centre-line, ahead of the viewer
+  group.rotation.y = Math.PI;              // face the leaves back toward the +Z spawn (viewer at z=0)
+
+  const faceGeo = new THREE.PlaneGeometry(leafW, H);
+
+  // RIGHT leaf — hinged at the spine (local x=0), extends to +x, splayed toward the viewer.
+  const leafR = new THREE.Group();
+  leafR.rotation.y = -BOOK_LEAF_SPLAY;
+  const faceR = new THREE.Mesh(faceGeo, MAT.wall.clone ? new THREE.MeshStandardMaterial({ color: 0xece4d2, roughness: 0.7 }) : MAT.wall);
+  faceR.position.set(leafW / 2, yC, 0);
+  faceR.receiveShadow = true; faceR.castShadow = true;
+  faceR.userData = { isWingFace: true, parentPivot: centerBookPivotProxy(), leaf: 'R' };
+  leafR.add(faceR);
+
+  // LEFT leaf — mirror twin: hinged at the spine, extends to -x, splayed toward the viewer.
+  const leafL = new THREE.Group();
+  leafL.rotation.y = BOOK_LEAF_SPLAY;
+  const faceL = new THREE.Mesh(faceGeo.clone(), new THREE.MeshStandardMaterial({ color: 0xece4d2, roughness: 0.7 }));
+  faceL.position.set(-leafW / 2, yC, 0);
+  faceL.receiveShadow = true; faceL.castShadow = true;
+  faceL.userData = { isWingFace: true, parentPivot: null, leaf: 'L' };
+  leafL.add(faceL);
+
+  // Slim dark-bronze spine post + a thin frame around the open spread.
+  const spine = new THREE.Mesh(new THREE.BoxGeometry(0.02, H + 0.04, 0.05), MAT.frame || MAT.dark);
+  spine.position.set(0, yC, 0.0); spine.castShadow = true;
+  group.add(spine);
+
+  group.add(leafL);
+  group.add(leafR);
+  scene.add(group);
+
+  centerBook = { group, leafL, leafR, faceL, faceR, leafW, H, yC };
+  if (window._requestShadowUpdate) window._requestShadowUpdate(8);
+  return centerBook;
+}
+
+// Lightweight pivot proxy so loadWingBook (which reads ud.face / ud.product / ud.boardWidth
+// off a parentPivot) can paint the RIGHT leaf via the existing loader path.
+let _bookPivotProxy = null;
+function centerBookPivotProxy() {
+  if (!_bookPivotProxy) _bookPivotProxy = { userData: {} };
+  return _bookPivotProxy;
+}
+
+// Load the catalog design at bookIndex onto both leaves: the right leaf normal, the left
+// leaf a horizontal mirror (repeat.x flipped) so the two meet symmetrically at the spine.
+function setCenterBook(index) {
+  if (!centerBook || !products.length) return;
+  const list = products.slice(0, windowSize);
+  if (!list.length) return;
+  bookIndex = ((index % list.length) + list.length) % list.length;
+  const raw = list[bookIndex];
+  const product = normalizeProduct(raw);
+  centerBook.product = product;
+
+  const imageUrl = product.image || raw.tile || raw.image;
+  updateWingCaption(product);     // minimal fixed caption bar (Steve's pick)
+
+  if (!imageUrl) return;
+  const src = imageUrl.charAt(0) === '/' ? imageUrl : ('/api/proxy/image?url=' + encodeURIComponent(imageUrl));
+  const maxAniso = (renderer.capabilities && renderer.capabilities.getMaxAnisotropy) ? renderer.capabilities.getMaxAnisotropy() : 1;
+  const wingW = centerBook.leafW;
+
+  const paint = (source, fdImg) => {
+    const isFD = product.isSeamlessTile ? true : (fdImg ? detectFullDesign(fdImg).isFullDesign : true);
+    const { rx, ry } = isFD ? computeRepeat(wingW, product, source) : { rx: 1, ry: 1 };
+    const mkTex = (mirror) => {
+      const t = (typeof ImageBitmap !== 'undefined' && source instanceof ImageBitmap)
+        ? new THREE.CanvasTexture(source) : new THREE.Texture(source);
+      t.image = source;
+      t.minFilter = THREE.LinearMipmapLinearFilter; t.magFilter = THREE.LinearFilter; t.generateMipmaps = true;
+      t.anisotropy = maxAniso; srgb(t);
+      if (isFD) {
+        t.wrapS = t.wrapT = THREE.RepeatWrapping;
+        // RIGHT leaf: repeat.x = +rx. LEFT leaf: repeat.x = -rx flips the map horizontally
+        // (offset.x = rx keeps it positioned), so the left leaf is a true mirror of the right.
+        t.repeat.set(mirror ? -Math.max(1, rx) : Math.max(1, rx), ry);
+        if (mirror) t.offset.x = Math.max(1, rx);
+      } else {
+        t.wrapS = t.wrapT = THREE.ClampToEdgeWrapping;
+        t.repeat.set(mirror ? -1 : 1, 1);
+        if (mirror) t.offset.x = 1;
+      }
+      t.needsUpdate = true;
+      return t;
+    };
+    centerBook.faceR.material = new THREE.MeshStandardMaterial({ map: mkTex(false), roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
+    centerBook.faceL.material = new THREE.MeshStandardMaterial({ map: mkTex(true),  roughness: 0.6, metalness: 0.0, envMapIntensity: 0.18 });
+    if (window._requestShadowUpdate) window._requestShadowUpdate(6);
+  };
+
+  if (typeof createImageBitmap === 'function' && src.charAt(0) === '/') {
+    let fdImg = null;
+    if (!product.isSeamlessTile) { fdImg = new Image(); fdImg.src = src; }
+    fetch(src).then(r => r.blob()).then(b => createImageBitmap(b))
+      .then(bmp => paint(bmp, (fdImg && fdImg.complete) ? fdImg : null))
+      .catch(() => { const im = new Image(); im.onload = () => paint(im, im); im.src = src; });
+    return;
+  }
+  const im = new Image(); im.onload = () => paint(im, im); im.src = src;
+}
+
+// Page the centered book through the catalog ([ ] / N-P). delta = +1 next, -1 previous.
+function pageCenterBook(delta) {
+  if (!centerBook) return;
+  setCenterBook(bookIndex + delta);
+}
+
+// Normalize a raw product row into the display shape used by the book + caption.
+// (Mirrors the per-board normalization in buildWingWall, kept small + shared.)
+function normalizeProduct(p) {
+  let widthInches = 27;
+  if (typeof p.width === 'string') { const m = p.width.match(/([\d.]+)/); if (m) widthInches = parseFloat(m[1]); }
+  else if (typeof p.width === 'number' && p.width) widthInches = p.width;
+  let repeatInches = null;
+  if (typeof p.repeat === 'string') { const m = p.repeat.match(/([\d.]+)/); if (m) repeatInches = parseFloat(m[1]); }
+  else if (typeof p.repeat === 'number' && p.repeat) repeatInches = p.repeat;
+  return {
+    pattern_name: p.pattern_name || p.name || 'Pattern',
+    color: p.color_name || p.color || '',
+    sku: p.sku || p.mfr_sku || '',
+    vendor: p.vendor || 'Quadrille House',
+    width_inches: widthInches, repeat_inches: repeatInches,
+    material: p.material || 'Wallcovering',
+    name: p.pattern_name || p.name || 'Pattern',
+    image: p.tile || p.image || null,
+    isSeamlessTile: !!p.tile,
+    collection: p.collection || '',
+    isFullDesign: null
+  };
+}
+
 // ============================================================
 // CLOSED-SLIVER REAL THUMBNAILS (Image #6 gap closer)
 // ------------------------------------------------------------
@@ -2253,6 +2436,8 @@ function restoreBoards() {
 
 // Next / Back — flip to the next/previous wingboard (pages window edges, endless).
 async function gotoBoardOffset(delta) {
+  // CENTER BOOK default: Next/Prev page the open book through the catalog.
+  if (BOOK_MODE) { pageCenterBook(delta > 0 ? 1 : -1); return; }
   if (!wingBoards.length) return;
   let idx = (focusedWing ? focusedWing.userData.index : 0) + delta;
   if (idx >= wingBoards.length) { if (await advanceWindow(1)) { restoreBoards(); focusOnWing(wingBoards[0]); reframeView(); return; } idx = wingBoards.length - 1; }
@@ -2366,6 +2551,24 @@ function setDetailCollapsed(c) {
   if (btn) { btn.innerHTML = c ? '▸' : '▾'; btn.title = c ? 'Show details' : 'Hide details'; }
 }
 
+// MINIMAL FIXED CAPTION BAR (Steve's pick 2026-06-29) — a small static strip pinned to
+// the bottom of the screen showing `PatternName · Colorway · SKU · Vendor` for the
+// currently-faced / centred design. It NEVER pops, slides, or animates — it just updates
+// its text. Replaces the popping wing-detail card.
+function updateWingCaption(product) {
+  const bar = document.getElementById('wing-caption');
+  if (!bar) return;
+  window._activeProduct = product;
+  if (!product) { bar.textContent = ''; return; }
+  const { pattern, colorway } = splitPatternColor(product.pattern_name || product.name);
+  const color = product.color || colorway || '';
+  const parts = [pattern || 'Pattern'];
+  if (color) parts.push(color);
+  if (product.sku) parts.push(product.sku);
+  if (product.vendor) parts.push(product.vendor);
+  bar.textContent = parts.join('  ·  ');
+}
+
 function showWingDetail(product) {
   window._activeProduct = product;
   const panel = document.getElementById('wing-detail');
@@ -2559,9 +2762,41 @@ function applySpinPose() {
   controls.target.set(cp.x + dir.x * TARGET_DIST, cp.y + dir.y * TARGET_DIST, cp.z + dir.z * TARGET_DIST);
 }
 
+// Half the room (minus a margin) the avatar may roam — keeps the body inside the walls.
+const WALK_HALF = (CONFIG.room.width / 2) - 0.5;
+
 function updateWASD(dt) {
-  if (controlsLocked || cameraAnim) return; // no spin when focused on a wing or animating
+  if (controlsLocked || cameraAnim) return; // no input when focused on a wing or animating
 
+  // ── EXPLORE / TAKEOVER (default): you ARE the avatar. ↑/↓ = walk forward/back along the
+  // facing direction; ←/→ = TURN the body (yaw). The camera AND avatarRig move together.
+  if (exploreMode) {
+    let moveIn = 0, turnIn = 0;
+    if (keysDown['ArrowUp']    || keysDown['KeyW']) moveIn += 1;   // walk forward
+    if (keysDown['ArrowDown']  || keysDown['KeyS']) moveIn -= 1;   // walk back
+    if (keysDown['ArrowLeft']  || keysDown['KeyA']) turnIn -= 1;   // turn body left
+    if (keysDown['ArrowRight'] || keysDown['KeyD']) turnIn += 1;   // turn body right
+
+    // Turn the body first so a held forward walks along the NEW facing this frame.
+    if (turnIn !== 0) { spinYaw += turnIn * SPIN_YAW_SPEED * dt; applySpinPose(); }
+
+    if (moveIn !== 0 && lockedSpinEye) {
+      // Forward = the look direction projected onto the floor (XZ). yaw=0 faces -Z.
+      const fx = Math.sin(spinYaw), fz = -Math.cos(spinYaw);
+      const step = moveIn * WALK_SPEED * dt;
+      let nx = lockedSpinEye.x + fx * step;
+      let nz = lockedSpinEye.z + fz * step;
+      nx = Math.max(-WALK_HALF, Math.min(WALK_HALF, nx));
+      nz = Math.max(-WALK_HALF, Math.min(WALK_HALF, nz));
+      lockedSpinEye.x = nx; lockedSpinEye.z = nz;
+      camera.position.x = nx; camera.position.z = nz;
+      applySpinPose();        // re-aim the look-target from the new eye position
+    }
+    return;
+  }
+
+  // ── GUIDED (Explore off): the legacy fixed-centre yaw-spin. ←/→ spin the look,
+  // ↑/↓ pitch it; the eye never moves.
   let yawIn = 0, pitchIn = 0;
   if (keysDown['ArrowLeft']  || keysDown['KeyA']) yawIn   -= 1;
   if (keysDown['ArrowRight'] || keysDown['KeyD']) yawIn   += 1;
@@ -2705,19 +2940,14 @@ function animate() {
     camera.position.copy(lockedCamPos);
     controls.target.copy(lockedCamTarget);
   } else if (!cameraAnim) {
-    // FIXED-CENTRE YAW-SPIN: the camera never moves position. updateWASD rotates only the
-    // clamped look-target around the fixed eye point, then we aim the camera at it directly.
-    // We deliberately do NOT call controls.update() in guided mode — OrbitControls would
-    // re-derive the camera position around the moving target and break the fixed centre.
-    if (exploreMode) {
-      updateWASD(dt);
-      controls.update();
-    } else {
-      const fixedEye = lockedSpinEye || camera.position.clone();
-      updateWASD(dt);
-      camera.position.copy(fixedEye);   // hard-pin the eye every frame
-      camera.lookAt(controls.target);
-    }
+    // FIRST-PERSON: the arrow keys drive everything. updateWASD walks the eye (explore)
+    // or spins the look-target (guided); either way we then aim the camera directly at the
+    // resolved look-target. We deliberately do NOT call controls.update() — OrbitControls
+    // would re-derive the camera position around the target and fight the walk/spin pose.
+    updateWASD(dt);
+    const fixedEye = lockedSpinEye || camera.position;
+    camera.position.copy(fixedEye);   // hard-pin the eye to the walked/centred point
+    camera.lookAt(controls.target);
   }
 
   // Chunk K — keep Steve's first-person body yawed with the look + pinned under the eye
@@ -2972,6 +3202,8 @@ function syncGuidedTourBtn() {
 // On load (and when leaving Explore), fly to the MIDDLE board, open it, and present
 // its design — so the big Previous/Next/Auto-Tour buttons immediately have a hero.
 function enterGuidedDefault() {
+  // CENTER BOOK is the default presentation — there is no resting-open arc board to set.
+  if (BOOK_MODE) { guidedReady = true; return; }
   if (!wingBoards.length) return;
   // BOOT RACE GUARD: if the user clicked a board while the collection was still loading, that
   // queued click has already been replayed into a real selection — do NOT overwrite their
@@ -3006,6 +3238,9 @@ function enterGuidedDefault() {
 // the resting-open middle board. This is the home pose for the build — used at boot and
 // when V1 is (re)applied. NO camera lock, NO detail panel, NOTHING "selected".
 function enterFixedCentre() {
+  // CENTER BOOK is the default presentation; the arc fixed-centre rest pose does not apply.
+  // (V1's boot apply still calls this — keep it a no-op so it can't disturb the book/eye.)
+  if (BOOK_MODE) return;
   // BOOT RACE GUARD: during the boot window, if the user has a queued/just-consumed boot click,
   // the version engine's boot-time V1 apply (versions.js → enterFixedCentre) must NOT wipe
   // their selection. Their click is the authoritative final boot action (replayed at +2600ms).
@@ -3022,10 +3257,17 @@ function enterFixedCentre() {
   previousCamPos = null; previousCamTarget = null;
   const wd = document.getElementById('wing-detail'); if (wd) wd.classList.add('hidden');
   hideGuidedTitle();
-  // Pin the eye back at room centre and face the bank.
-  if (lockedSpinEye) camera.position.copy(lockedSpinEye);
-  else { camera.position.set(CONFIG.camera.startPos.x, CONFIG.camera.startPos.y, CONFIG.camera.startPos.z); lockedSpinEye = camera.position.clone(); }
-  spinYaw = 0; spinPitch = -0.08;
+  // TAKEOVER: in explore/walk mode the eye belongs to the user — never snatch it back to
+  // centre on a V1 (re)apply, just make sure it exists. In guided mode, pin the eye at
+  // room centre facing the bank (the legacy fixed-centre rest pose).
+  if (!lockedSpinEye) {
+    camera.position.set(CONFIG.camera.startPos.x, CONFIG.camera.startPos.y, CONFIG.camera.startPos.z);
+    lockedSpinEye = camera.position.clone();
+  }
+  if (!exploreMode) {
+    camera.position.copy(lockedSpinEye);
+    spinYaw = 0; spinPitch = -0.08;
+  }
   applySpinPose();
   if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
   camera.lookAt(controls.target);
@@ -3526,6 +3768,23 @@ window._qh = {
   get restingOpenWing(){ return restingOpenWing; },
   get peruseActive(){ return peruseActive; },
   get exploreMode(){ return exploreMode; },
+  // CENTER BOOK (Steve 2026-06-29) verification surface — proves the single mirrored book
+  // is present, which catalog design it shows, and lets a test page through it.
+  get bookMode()   { return BOOK_MODE; },
+  get bookPresent(){ return !!centerBook; },
+  get bookIndex()  { return bookIndex; },
+  bookState() {
+    if (!centerBook) return null;
+    const fl = centerBook.faceL, fr = centerBook.faceR;
+    return {
+      present: true, index: bookIndex,
+      product: centerBook.product ? { pattern: centerBook.product.pattern_name, sku: centerBook.product.sku, vendor: centerBook.product.vendor } : null,
+      leftMirrored: !!(fl && fl.material && fl.material.map && fl.material.map.repeat.x < 0),
+      rightNormal:  !!(fr && fr.material && fr.material.map && fr.material.map.repeat.x > 0),
+      spineZ: centerBook.group.position.z
+    };
+  },
+  pageCenterBook,
   CONFIG,
   // Camera + focus
   smoothCameraTo,
diff --git a/public/showroom.html b/public/showroom.html
index ce2b3f5..6fb9c5b 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -178,6 +178,24 @@
         linear-gradient(180deg, rgba(28,28,34,0.10) 0%, rgba(255,255,255,0) 26%, rgba(255,255,255,0) 80%, rgba(28,28,34,0.07) 100%),
         radial-gradient(120% 90% at 50% 42%, rgba(255,255,255,0) 58%, rgba(20,18,22,0.10) 86%, rgba(16,14,18,0.20) 100%);
     }
+
+    /* MINIMAL FIXED CAPTION BAR (Steve's pick) — a quiet static strip at the bottom centre.
+       NO transition / NO animation: it only ever changes its text. */
+    #wing-caption {
+      position:fixed; left:50%; bottom:18px; transform:translateX(-50%); z-index:24;
+      max-width:90vw; padding:7px 18px; border-radius:6px;
+      background:rgba(14,14,20,0.66); border:1px solid rgba(201,169,110,0.28);
+      color:#e9e1cf; font-family:Georgia,"Times New Roman",serif; font-size:15px;
+      letter-spacing:.04em; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+      pointer-events:none; backdrop-filter:blur(6px);
+    }
+    #wing-caption:empty { display:none; }
+    /* In book mode the caption bar IS the SKU surface — keep the popping card + guided
+       surfaces out of the way so the view stays the quiet open mirrored book + caption. */
+    body.book-mode #wing-detail,
+    body.book-mode #now-viewing,
+    body.book-mode #guided-title,
+    body.book-mode #guided-instruction { display:none !important; }
 </style>
 </head>
 <body class="guided-mode">
@@ -310,6 +328,10 @@
       <span id="fps-counter">60 FPS</span>
     </div>
 
+    <!-- MINIMAL FIXED CAPTION BAR (Steve's pick) — quiet static strip for the centred design.
+         Pattern · Colorway · SKU · Vendor. Never pops, slides, or animates; text only. -->
+    <div id="wing-caption" aria-live="polite"></div>
+
     <!-- GUIDED MODE — large readable pattern title (top-centre) -->
     <div id="guided-title">
       <div class="gt-name" id="gt-name">&nbsp;</div>

← 04948ab Phase 5.5 /contrarian final pass: prove the AVATAR half + fi  ·  back to Quadrille Showroom  ·  Fix center-book leaf orientation: leaves face the viewer (dr f1439ea →