[object Object]

← back to Quadrille Showroom

Chunk I: dry-cleaner/tie-rack carousel — click any sliver conveyor-shifts the whole rack so the clicked board eases to the dead-front centre slot, then fans open (camera stays fixed-centre)

396a02d99521bc11d847674bfac190fc5a23989e · 2026-06-28 17:34:36 -0700 · Steve

Files touched

Diff

commit 396a02d99521bc11d847674bfac190fc5a23989e
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 17:34:36 2026 -0700

    Chunk I: dry-cleaner/tie-rack carousel — click any sliver conveyor-shifts the whole rack so the clicked board eases to the dead-front centre slot, then fans open (camera stays fixed-centre)
---
 HIGH-END-VERSIONS-SPEC.md   |  57 ++++++++++++++
 public/js/rack.js           |  26 ++++++-
 public/js/showroom.js       | 155 ++++++++++++++++++++++++++------------
 scripts/verify-carousel.mjs | 180 ++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 366 insertions(+), 52 deletions(-)

diff --git a/HIGH-END-VERSIONS-SPEC.md b/HIGH-END-VERSIONS-SPEC.md
new file mode 100644
index 0000000..c53ed6d
--- /dev/null
+++ b/HIGH-END-VERSIONS-SPEC.md
@@ -0,0 +1,57 @@
+# HIGH-END VERSIONS SPEC — V12–V21 (10 new max-FPS, high-end-graphics showrooms)
+
+Steve: "add 10 more versions of a showroom with highest fps high end graphics available… This has to be better."
+The bar is **genuinely high-end** — Steve has repeatedly rejected "juvenile" graphics. A toy scene fails.
+
+## Architecture (parallel-safe)
+- Each version = ONE standalone overlay file `public/proto/v{N}-{slug}.html` (N = 12..21), same pattern as
+  the existing `public/proto/v6-colorriver.html` … `v10-concierge.html` (READ ONE first for structure:
+  how it loads, pulls products, a "Back" button, postMessage to the picker).
+- Pull real product imagery from the live API: `GET /api/showroom/products?limit=50` (China Seas) — use
+  the `image`/`tile` fields as the wallcovering textures on the boards/walls. Real designs, not placeholders.
+- Do NOT edit `showroom.js`, `rack.js`, or `versions.js` — those are owned elsewhere. Your file only.
+- Three.js r128 (match the rest of the app; load from the same CDN the existing protos use).
+
+## HIGH-END GRAPHICS BASELINE (every version MUST use all of these)
+1. **Renderer:** `WebGLRenderer({ antialias:true, powerPreference:'high-performance' })`,
+   `outputEncoding = sRGBEncoding`, `toneMapping = ACESFilmicToneMapping`, `toneMappingExposure` tuned,
+   `physicallyCorrectLights = true`, shadows `PCFSoftShadowMap`.
+2. **Image-based lighting:** a `PMREMGenerator` environment (procedural `RoomEnvironment` or a gradient/
+   studio env) so metals/gloss reflect — `scene.environment = envMap`. No flat ambient-only lighting.
+3. **PBR materials:** `MeshPhysicalMaterial`/`MeshStandardMaterial` with real `metalness`/`roughness`/
+   `clearcoat`/`envMapIntensity`. Wallcovering textures get `anisotropy = renderer.capabilities.getMaxAnisotropy()`,
+   `sRGBEncoding`, correct `repeat` for the 27" pattern scale.
+4. **Lighting:** key + fill + rim, area/spot where the aesthetic calls for it; soft shadows from the key.
+5. **Polish (use what fits the look, FPS permitting):** subtle bloom (UnrealBloomPass) for emissive/spec
+   highlights, gentle vignette, optional SSAO/contact shadows, fog for depth. Tasteful, not blown-out.
+
+## HIGHEST-FPS BUDGET (every version MUST hold these)
+- `renderer.setPixelRatio(Math.min(devicePixelRatio, 2))`.
+- **InstancedMesh** for any repeated geometry (the 50 board frames, repeated props).
+- Cap texture sizes; lazy-load board textures; reuse one geometry/material where possible (low draw calls).
+- Prefer **on-demand rendering** (render only when the camera/scene changes) OR a steady rAF loop that
+  holds ≥60fps on a laptop GPU. Pause rendering on `visibilitychange`.
+- Show an FPS readout (toggle) so the number is provable. Target ≥60fps; never ship < 55.
+- Keep the user FIXED in the center looking at the wing bank (the project's core rule) unless the
+  aesthetic is explicitly a seated/room variant — match the established interaction where sensible.
+
+## THE 10 AESTHETICS (each a DISTINCT high-end look — no two alike)
+- **V12 Gallery Noir** — black box gallery, single warm spotlights per board, deep chiaroscuro, faint bloom, polished concrete floor with contact reflections.
+- **V13 Atrium Daylight** — glass atrium, bright HDRI-style sky environment, soft global illumination, long sun-shaft shadows, airy.
+- **V14 Velvet Lounge** — moody jewel-tone velvet walls, warm area lights, shallow depth-of-field, bloom on brass sconces.
+- **V15 Brutalist Concrete** — raw board-formed concrete (PBR normal/roughness), one hard directional key, strong AO, monochrome restraint.
+- **V16 Art-Deco Brass & Marble** — brass-framed boards, marble floor (clearcoat), env-mapped reflective metals, symmetrical, warm.
+- **V17 Japanese Tatami** — tatami + shoji screens, soft diffuse north light, calm minimal palette, subtle paper translucency.
+- **V18 Infinity Mirror** — mirrored floor + side walls (reflection), neon edge-light accents, the bank repeating into the distance.
+- **V19 Conservatory Garden** — botanical conservatory, dappled leaf-shadow gobo light, greenery, light volumetric haze.
+- **V20 Penthouse Dusk** — floor-to-ceiling windows, dusk skybox + city-bokeh backdrop, warm interior key, reflections in the glass.
+- **V21 Grateful Dead / Garcia Room** — Steve's psychedelic room: animated liquid-light-show color wash, tie-dye env map, slow color cycling, dancing-bears / lightning-bolt motif accents, emissive bloom. Reference the `garcia-room-agent` skill if a local asset/build exists; else build it procedurally. Joyful, trippy, but still rendered high-end (not garish).
+
+## Per-version proof (each must pass before it counts)
+- Loads at `/proto/v{N}-{slug}.html`, 0 console errors, real China Seas textures on the boards/walls.
+- Visibly distinct high-end look (screenshot), FPS readout ≥ 55 (say if only swiftshader/headless was available — don't fake a hardware number).
+- "Back" button + picker postMessage works (so it slots into the V-picker like V6–V10).
+
+## Registration (orchestrator does this LAST, serialized)
+After the proto files exist, ONE edit to `versions.js` adds V12–V21 to the registry/VERSION_ORDER as
+`overlay:'/proto/v{N}-{slug}.html'` entries (same shape as V6–V10), each with a numbered-element list.
diff --git a/public/js/rack.js b/public/js/rack.js
index 64528c3..1724376 100644
--- a/public/js/rack.js
+++ b/public/js/rack.js
@@ -34,16 +34,35 @@
     return (42 + (r / 100) * 42) * Math.PI / 180;
   }
 
+  // Continuous CAROUSEL index-offset (Chunk I — the dry-cleaner / tie-rack conveyor).
+  // Every board's effective slot index is shifted by this value, so the whole rack
+  // slides along the curved rail as one unit. carouselTo(K) eases this toward a target
+  // that brings board K's effective index to the centre slot ((n-1)/2); each frame
+  // re-placeBoard()s every board at its shifted arc-angle so they glide along the rail.
+  // 0 = no shift (board i sits in slot i, the natural boot layout).
+  let indexOffset = 0;
+  function setIndexOffset(v) { indexOffset = (typeof v === 'number' && isFinite(v)) ? v : 0; }
+  function getIndexOffset() { return indexOffset; }
+
   // Centre angle (radians, around the arc centre) of slot i of n boards.
   // The sweep centres on cfg.arcCentreDeg (default 90° = +Z legacy). For the Slice-1
   // fixed-centre layout we centre on -90° (-Z) so the boards sit in FRONT of a viewer
   // standing at the arc-centre (concave bank facing the centred viewer).
+  // The continuous `indexOffset` (Chunk I carousel) shifts every board by the same
+  // fractional number of slots so the rack conveyor-slides as one piece.
   function arcAngleFor(i, n, cfg) {
     const span = cfg.arcSpanDeg * Math.PI / 180;
     const pitch = (n > 1 ? span / (n - 1) : span) * (cfg.sliverPitchScale || 1);
     const centre = (typeof cfg.arcCentreDeg === 'number' ? cfg.arcCentreDeg : 90) * Math.PI / 180;
     const start = centre - span / 2;
-    return start + i * pitch;
+    return start + (i + indexOffset) * pitch;
+  }
+
+  // The fractional index offset that lands board K's effective index on the centre slot.
+  // Centre slot index = (n-1)/2 (the dead-front board the resting-open middle occupies);
+  // we want K + offset === (n-1)/2, so offset = (n-1)/2 - K.
+  function centerOffsetFor(targetIndex, n) {
+    return ((n - 1) / 2) - targetIndex;
   }
 
   // World-space rail point for a given arc angle. The board hangs from this point.
@@ -125,6 +144,9 @@
     faceCentreYaw,
     placeBoard,
     relayout,
-    flipUpdate
+    flipUpdate,
+    setIndexOffset,
+    getIndexOffset,
+    centerOffsetFor
   };
 })();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index c4209e9..0c1c6e9 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -46,6 +46,15 @@ let fanWings = [], fanCenterIndex = -1; // Fan cascade state
 let controlsLocked = false, lockedCamPos = null, lockedCamTarget = null, previousCamPos = null, previousCamTarget = null;
 let cameraAnim = null; // { startPos, endPos, startTarget, endTarget, progress, duration, onComplete }
 
+// CAROUSEL (Chunk I — dry-cleaner / tie-rack conveyor). carouselTo(K) eases the rack's
+// continuous index-offset (QHRack.setIndexOffset) so board K's effective slot glides to
+// the dead-front centre slot, all boards sliding along the curved rail together. The
+// CAMERA NEVER MOVES — only the rack shifts. When the shift settles, _carouselPending
+// fires the centred board's open + detail + 4-wall clad (the existing focus payload).
+let carouselTarget = 0;        // the index-offset we're easing toward
+let carouselActive = false;    // true while the rack is mid-shift
+let _carouselPending = null;   // pivot to open once the shift settles
+
 // Window paging across the full catalog (50 live wings at a time)
 let windowOffset = 0, windowTotal = 0, windowSize = 50;  // 50 boards in the packed arc (Slice-1 spec): each closed wing shows a ~2" sliver, fanning left+right around the centred viewer
 let currentBrand = 'all', currentSort = 'natural', currentWallGroup = null;
@@ -1749,10 +1758,11 @@ function onCanvasClick(event) {
   }
 }
 
-// STEP 1: Click on wing — fly camera close, then HARD LOCK position
+// STEP 1: Click on a wing/sliver — CAROUSEL it to the dead-front centre slot (the whole
+// rack conveyor-shifts like a dry-cleaner / tie-rack), THEN fan it open. The CAMERA STAYS
+// FIXED-CENTRE — only the rack moves. focusOnWing now starts the shift; _openCenteredBoard
+// (fired on settle by the per-frame carousel ease) delivers the open + detail + 4-wall clad.
 function focusOnWing(pivot) {
-  const product = pivot.userData.product;
-
   // The user has interacted — boot settle is over; from here a Hero-mode re-entry may
   // legitimately auto-focus. (Before this, the boot stays at the resting-open default.)
   window._qhBootSettled = true;
@@ -1764,58 +1774,82 @@ function focusOnWing(pivot) {
   // Close any active fan cascade
   if (fanWings.length > 0) closeFanCascade(true);
 
-  // If re-clicking same focused wing, unfocus
-  if (focusedWing === pivot) { unfocusWing(); return; }
-  // Close previous focus
-  if (focusedWing) unfocusWing(true);
-
-  // Save current camera to restore later
-  previousCamPos = camera.position.clone();
-  previousCamTarget = controls.target.clone();
+  // If re-clicking the SAME centred+open wing, unfocus (carousel stays put).
+  if (focusedWing === pivot && !carouselActive) { unfocusWing(); return; }
 
+  // Mark this board the selection IMMEDIATELY (index tracking for Prev/Next), but keep its
+  // panel CLOSED so updateBooks/flipUpdate does NOT fan it open until it reaches centre.
+  if (focusedWing && focusedWing !== pivot) focusedWing.userData.panelClosed = true;
   focusedWing = pivot;
-  pivot.userData.panelClosed = false;  // focusing a board opens it to present its design
-  isolateBoard(pivot);                 // hide the other boards → the design stands alone
-
-  // Calculate world position of the wing
-  const wingWorldPos = new THREE.Vector3();
-  pivot.getWorldPosition(wingWorldPos);
-
-  // PJ HERO FRAMING: DEAD-CENTRE on the board, aimed at its vertical centre, but
-  // shot through a NARROW FOV (FOV_HERO=30°) from further back so perspective is
-  // compressed and the wallcovering reads as a FLAT rectangular plane — no edge
-  // bow, no leaf flare — like a Phillip Jeffries hero photograph. Distance is solved
-  // so the 6-ft board still fills the frame top-to-bottom with a hair of margin:
-  //   fillH = 2 · d · tan(fov/2)  →  d = (fillH/2) / tan(fov/2)
-  const boardCenterY = CONFIG.wing.height / 2 + 0.04;       // 0.954 m, the real board centre
-  // Fill the frame TIGHT: target ~82% of the 6-ft board so the design reads large and
-  // fills top-to-bottom like a PJ hero panel (the very ends can run past the frame edge
-  // — that's the hero look). The HUD eats the right third, so the board lives in the
-  // left two-thirds; this is the clean dead-on read REVIEW.md asked for.
-  // Steve: it was TOO CLOSE — step back. Board occupies ~70% of frame height so there's
-  // clear margin all around (frame height = board / fill), not cropped at the edges.
-  const BOARD_FILL = 0.70;
-  const fillH = CONFIG.wing.height / BOARD_FILL;            // frame height (bigger than board → camera steps back)
-  const dHero = (fillH / 2) / Math.tan((FOV_HERO * Math.PI / 180) / 2);
-  // Clamp so the camera never backs through the front wall. The board sits near the
-  // back wall (z≈-2.8); the camera moves toward +z. Usable run = front-wall − board.
-  const maxBack = (CONFIG.room.depth / 2) - wingWorldPos.z - 0.4;
-  const back = Math.min(dHero, maxBack);
-  const camTarget = new THREE.Vector3(wingWorldPos.x, boardCenterY, wingWorldPos.z);
-  const camPos = new THREE.Vector3(wingWorldPos.x, boardCenterY, wingWorldPos.z + back);
-
-  // Fly camera there + narrow the FOV in lock-step, then hard-lock.
-  smoothCameraTo(camPos, camTarget, () => {
-    lockControls(camPos, camTarget);
-  }, FOV_HERO);
-
-  // Lazy-load this wing's book-matched pattern immediately on focus
+  pivot.userData.panelClosed = true;     // stays raked-closed while it conveyors in
+  isolateBoard(pivot);                   // hero will cast its shadow; slivers stay visible
+
+  // The camera never leaves the fixed centre — reset the spin pose so the dead-front slot
+  // (where this board is sliding to) is framed head-on. No camera fly, no lock.
+  if (controlsLocked) unlockControls();
+  if (!exploreMode) { spinYaw = 0; spinPitch = -0.08; if (lockedSpinEye) camera.position.copy(lockedSpinEye); applySpinPose(); }
+  if (camera.fov !== FOV_DEFAULT) { targetFov = FOV_DEFAULT; camera.fov = FOV_DEFAULT; camera.updateProjectionMatrix(); }
+
+  // Conveyor the rack so this board's effective index lands on the centre slot, THEN open.
+  carouselTo(pivot.userData.index, () => _openCenteredBoard(pivot));
+}
+
+// Drive the rack's continuous index-offset toward the value that centres targetIndex.
+// The per-frame ease in animate()/updateCarousel re-places every board along the rail
+// until the offset settles, then fires onSettle. Re-clicking a DIFFERENT board just
+// re-points carouselTarget so the rack re-shifts to bring THAT board to centre.
+function carouselTo(targetIndex, onSettle) {
+  if (!window.QHRack || !wingBoards.length) { if (onSettle) onSettle(); return; }
+  carouselTarget = QHRack.centerOffsetFor(targetIndex, wingBoards.length);
+  _carouselPending = (typeof onSettle === 'function') ? { fn: onSettle } : null;
+  carouselActive = true;
+  // If we're already (essentially) centred on this board, settle now — no shift needed.
+  if (Math.abs(QHRack.getIndexOffset() - carouselTarget) < 0.002) {
+    QHRack.setIndexOffset(carouselTarget);
+    QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
+    carouselActive = false;
+    const p = _carouselPending; _carouselPending = null;
+    if (p) p.fn();
+  }
+}
+
+// Per-frame carousel ease — same lerp pattern as flipUpdate. Slides the rack's
+// index-offset toward carouselTarget, re-placing every board at its shifted arc-angle so
+// they glide along the rail as one conveyor, then fires the pending open on settle.
+function updateCarousel(dt) {
+  if (!carouselActive || !window.QHRack || !wingBoards.length) return;
+  const cur = QHRack.getIndexOffset();
+  const k = Math.min(1, dt * 6);          // ease rate (matches flipUpdate's feel)
+  let next = cur + (carouselTarget - cur) * k;
+  if (Math.abs(carouselTarget - next) < 0.0015) next = carouselTarget;
+  QHRack.setIndexOffset(next);
+  // Re-place every board's hinge on the (shifted) rail. relayout preserves each board's
+  // flip state, so an opening hero keeps opening while the rest re-rake along the rail.
+  QHRack.relayout(wingBoards, CONFIG.wing, REVEAL);
+  if (window._requestShadowUpdate) window._requestShadowUpdate(1);
+  if (next === carouselTarget) {
+    carouselActive = false;
+    const p = _carouselPending; _carouselPending = null;
+    if (p) p.fn();
+  }
+}
+
+// Deliver the focus payload to the board now sitting at the dead-front centre slot:
+// fan it open (panel un-closed → flipUpdate eases it flat), load its design, show the
+// (collapsed) detail card, and clad ALL FOUR walls. The camera is already fixed-centre
+// and head-on to this slot, so NO camera move happens.
+function _openCenteredBoard(pivot) {
+  if (!pivot || pivot !== focusedWing) return;   // a newer click superseded this one
+  const product = pivot.userData.product;
+  pivot.userData.panelClosed = false;            // now fan open (flipUpdate → flat hero)
+
+  // Lazy-load this wing's book-matched pattern now that it's the centre hero.
   if (pivot.userData.pendingImage && !pivot.userData.imageLoaded) {
     pivot.userData.imageLoaded = true;
     loadWingBook(pivot);
   }
 
-  // Show detail panel with product info
+  // Show detail panel (collapsed-by-default) with product info.
   showWingDetail(product);
   // Clad ALL FOUR room walls in this pattern on EVERY selection (Steve hard rule:
   // the click IS the selection, and selecting fills the whole room so a yaw to any
@@ -1823,7 +1857,7 @@ function focusOnWing(pivot) {
   // is the definitive default — clad unconditionally, not gated on wallsAutoClad.
   cladWalls(product.image, product.isSeamlessTile);
   updateOpenBtnState(false);
-  document.getElementById('info-text').textContent = `${product.pattern_name || product.name} · ${product.vendor}  |  ◀ Back / Next ▶ to flip through`;
+  document.getElementById('info-text').textContent = `${product.pattern_name || product.name} · ${product.vendor}  |  ◀ Back / Next ▶ to carousel through`;
 }
 
 // Isolate the focused board — the PJ way: the hero swings OPEN and presents its design,
@@ -1888,11 +1922,15 @@ function closeFanCascade(skipBtnUpdate) {
   if (!skipBtnUpdate) updateOpenBtnState(0);
 }
 
-// Back button — close fan, unlock controls, fly camera back
+// Back button — close fan, recentre the carousel on the middle, stay fixed-centre.
 function unfocusWing(skipPanel) {
   if (fanWings.length > 0) closeFanCascade();
   restoreBoards();          // bring the other wingboards back
   focusedWing = null;
+  // Cancel any pending carousel open and conveyor the rack back to its boot layout
+  // (middle board centred, index-offset → 0) so Back returns the resting-open middle.
+  _carouselPending = null;
+  if (window.QHRack && wingBoards.length && !skipPanel) carouselTo(Math.floor(wingBoards.length / 2), null);
   // Revert walls only on a true exit — peruse's internal wing-to-wing close passes
   // skipPanel=true and the next cladWalls overwrites, so no neutral flicker.
   if (!skipPanel) revertWalls();
@@ -2317,6 +2355,11 @@ function animate() {
     }
   }
 
+  // Carousel conveyor (Chunk I) — slide the rack so the selected board reaches the
+  // dead-front centre slot, then fire its open. Runs before updateBooks so the board is
+  // re-placed on the rail BEFORE flipUpdate eases its swing this frame.
+  updateCarousel(dt);
+
   // Proximity book-match — nearest wing opens its two leaves when you're within 4 ft
   updateBooks(dt);
 
@@ -2570,6 +2613,10 @@ function enterGuidedDefault() {
 function enterFixedCentre() {
   if (focusedWing) focusedWing.userData.panelClosed = true;
   focusedWing = null;
+  // Hard-snap the carousel home (middle board centred, no in-flight shift) so a V1
+  // (re)apply / canonical-rest always boots with the rack at its natural layout.
+  carouselActive = false; _carouselPending = null;
+  if (window.QHRack && wingBoards.length) { QHRack.setIndexOffset(0); QHRack.relayout(wingBoards, CONFIG.wing, REVEAL); carouselTarget = 0; }
   if (controlsLocked) unlockControls();
   previousCamPos = null; previousCamTarget = null;
   const wd = document.getElementById('wing-detail'); if (wd) wd.classList.add('hidden');
@@ -3095,6 +3142,14 @@ window._qh = {
   gotoBoardOffset,
   stopPeruse,
   startPeruse,
+  // Carousel (Chunk I) — verification surface. carouselTo(K) conveyors board K to the
+  // dead-front centre slot; indexOffset/centerOffsetFor/carouselActive let the proof bar
+  // assert the rack (not the camera) moved and the clicked board reached centre.
+  carouselTo,
+  get carouselActive() { return carouselActive; },
+  get carouselTarget() { return carouselTarget; },
+  get indexOffset() { return window.QHRack ? window.QHRack.getIndexOffset() : 0; },
+  centerSlotIndex() { return (wingBoards.length - 1) / 2; },
   // Lighting
   get keyLight()  { return window._keyLight; },
   get picLight()  { return window._picLight; },
diff --git a/scripts/verify-carousel.mjs b/scripts/verify-carousel.mjs
new file mode 100644
index 0000000..7fcef1b
--- /dev/null
+++ b/scripts/verify-carousel.mjs
@@ -0,0 +1,180 @@
+// Chunk I verification — the dry-cleaner / tie-rack CAROUSEL.
+// Drives the real focus path (window._qh.focusOnWing == onCanvasClick) and asserts:
+//   - before-click: boot resting state (middle open, no selection, no detail)
+//   - click a LEFT-of-centre board -> the RACK conveyor-shifts (indexOffset animates over
+//     frames, NOT a teleport), the clicked board reaches the centre slot, fans open,
+//     detail card visible+collapsed, all 4 walls clad
+//   - click a RIGHT-of-centre board -> carousels to centre (closes prior)
+//   - the CAMERA never moves across the whole interaction (posDelta ~0)
+//   - errorCount 0
+import pw from '/Users/stevestudio2/.npm-global/lib/node_modules/playwright/index.js';
+const { chromium } = pw;
+import { mkdirSync } from 'fs';
+
+const OUT = '/Users/stevestudio2/Projects/quadrille-showroom/recordings/carousel';
+mkdirSync(OUT, { recursive: true });
+
+const result = {};
+const errors = [];
+
+const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-webgl', '--ignore-gpu-blocklist'] });
+const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+await page.goto('http://localhost:7690/', { waitUntil: 'networkidle' });
+await page.waitForFunction(() => window._qh && window._qh.wingBoards && window._qh.wingBoards.length > 0, { timeout: 20000 });
+await page.waitForTimeout(2500);   // boot resting-open ease + first textures
+
+// Camera-position sampler (fixed-centre invariant): grab the eye now and after every step.
+const camNow = () => page.evaluate(() => {
+  const p = window._cam.position; return [p.x, p.y, p.z];
+});
+const dist = (a, b) => Math.sqrt((a[0]-b[0])**2 + (a[1]-b[1])**2 + (a[2]-b[2])**2);
+
+// ---------- 1. BEFORE-CLICK (boot resting state) ----------
+const boot = await page.evaluate(() => ({
+  focused: !!window._qh.focusedWing,
+  resting: !!window._qh.restingOpenWing,
+  restingIdx: window._qh.restingOpenWing ? window._qh.restingOpenWing.userData.index : null,
+  detailHidden: document.getElementById('wing-detail').classList.contains('hidden'),
+  indexOffset: +window._qh.indexOffset.toFixed(4),
+  centerSlotIndex: window._qh.centerSlotIndex(),
+  boards: window._qh.wingBoards.length,
+}));
+result.boot = boot;
+const camBoot = await camNow();
+await page.screenshot({ path: OUT + '/01-before-click.png' });
+
+// Helper: world-X of a board's pivot (centre slot pivot sits at world x≈0, z≈front).
+const pivotWorldX = (idx) => page.evaluate((i) => {
+  const THREE = window._qh.THREE;
+  const v = new THREE.Vector3();
+  window._qh.wingBoards[i].getWorldPosition(v);
+  return { x: +v.x.toFixed(4), z: +v.z.toFixed(4) };
+}, idx);
+
+// ---------- 2. CLICK A LEFT-OF-CENTRE SLIVER ----------
+// Pick a board well LEFT of centre. Lower index = left side of the fanned arc.
+const leftIdx = await page.evaluate(() => {
+  const n = window._qh.wingBoards.length;
+  const idx = Math.max(0, Math.floor(n / 2) - 8);   // 8 slots left of the middle
+  window.__leftIdx = idx;
+  // Stamp the offset BEFORE the click so we can prove it animates, not teleports.
+  window.__offBefore = window._qh.indexOffset;
+  window.__offSamples = [];
+  window._qh.focusOnWing(window._qh.wingBoards[idx]);
+  return idx;
+});
+// Sample indexOffset across several animation frames to PROVE it eases (not a teleport).
+const leftSamples = [];
+for (let i = 0; i < 6; i++) {
+  await page.waitForTimeout(60);
+  leftSamples.push(await page.evaluate(() => +window._qh.indexOffset.toFixed(4)));
+}
+// MID-SHIFT screenshot (rack caught mid-conveyor).
+await page.screenshot({ path: OUT + '/02-click-left-sliver.png' });
+
+// Let the shift settle + the centred board fan open + walls clad.
+await page.waitForTimeout(3200);
+
+const leftPivot = await pivotWorldX(leftIdx);
+const centerPivotX = await page.evaluate(() => {
+  // The board that NOW occupies the centre slot should be the clicked one; its pivot
+  // world-X should be ~0 (dead-front). Read the focused board's world X.
+  const THREE = window._qh.THREE;
+  const v = new THREE.Vector3();
+  window._qh.focusedWing.getWorldPosition(v);
+  return +v.x.toFixed(4);
+});
+const afterLeft = await page.evaluate(() => {
+  const panel = document.getElementById('wing-detail');
+  const cladSides = (window._cladSides || []).slice();
+  const wallSides = (window._wallSides || []).slice();
+  return {
+    focusedIdx: window._qh.focusedWing ? window._qh.focusedWing.userData.index : null,
+    pickedIdx: window.__leftIdx,
+    indexOffset: +window._qh.indexOffset.toFixed(4),
+    targetOffset: +window._qh.carouselTarget.toFixed(4),
+    carouselActive: window._qh.carouselActive,
+    detailHidden: panel.classList.contains('hidden'),
+    detailCollapsed: panel.classList.contains('collapsed'),
+    boardFlip: window._qh.focusedWing ? +window._qh.focusedWing.userData.flip.toFixed(3) : null,
+    cladSides, wallSides,
+    allFourClad: wallSides.length === 4 && wallSides.every(s => cladSides.includes(s)),
+    offsetChanged: window.__offBefore !== window._qh.indexOffset,
+  };
+});
+const camAfterLeft = await camNow();
+result.afterLeftShift = {
+  ...afterLeft,
+  leftSamples,
+  centeredPivotWorldX: centerPivotX,
+  reachedCenter: Math.abs(centerPivotX) < 0.05,
+  posDeltaFromBoot: +dist(camAfterLeft, camBoot).toFixed(4),
+};
+await page.screenshot({ path: OUT + '/03-after-shift.png' });
+
+// ---------- 3. CLICK A RIGHT-OF-CENTRE SLIVER ----------
+const rightIdx = await page.evaluate(() => {
+  const n = window._qh.wingBoards.length;
+  const idx = Math.min(n - 1, Math.floor(n / 2) + 8);   // 8 slots right of the middle
+  window.__rightIdx = idx;
+  window.__offBefore2 = window._qh.indexOffset;
+  window._qh.focusOnWing(window._qh.wingBoards[idx]);
+  return idx;
+});
+const rightSamples = [];
+for (let i = 0; i < 6; i++) {
+  await page.waitForTimeout(60);
+  rightSamples.push(await page.evaluate(() => +window._qh.indexOffset.toFixed(4)));
+}
+await page.screenshot({ path: OUT + '/04-click-right-sliver.png' });
+await page.waitForTimeout(3200);
+
+const centerPivotX2 = await page.evaluate(() => {
+  const THREE = window._qh.THREE;
+  const v = new THREE.Vector3();
+  window._qh.focusedWing.getWorldPosition(v);
+  return +v.x.toFixed(4);
+});
+const afterRight = await page.evaluate(() => {
+  const panel = document.getElementById('wing-detail');
+  const cladSides = (window._cladSides || []).slice();
+  const wallSides = (window._wallSides || []).slice();
+  return {
+    focusedIdx: window._qh.focusedWing ? window._qh.focusedWing.userData.index : null,
+    pickedIdx: window.__rightIdx,
+    indexOffset: +window._qh.indexOffset.toFixed(4),
+    targetOffset: +window._qh.carouselTarget.toFixed(4),
+    detailCollapsed: panel.classList.contains('collapsed'),
+    boardFlip: window._qh.focusedWing ? +window._qh.focusedWing.userData.flip.toFixed(3) : null,
+    allFourClad: wallSides.length === 4 && wallSides.every(s => cladSides.includes(s)),
+    offsetChanged: window.__offBefore2 !== window._qh.indexOffset,
+  };
+});
+const camAfterRight = await camNow();
+result.afterRightShift = {
+  ...afterRight,
+  rightSamples,
+  centeredPivotWorldX: centerPivotX2,
+  reachedCenter: Math.abs(centerPivotX2) < 0.05,
+  posDeltaFromBoot: +dist(camAfterRight, camBoot).toFixed(4),
+};
+await page.screenshot({ path: OUT + '/05-after-shift2.png' });
+
+// ---------- FIXED-CENTRE INVARIANT (whole interaction) ----------
+result.camera = {
+  camBoot,
+  camAfterLeft,
+  camAfterRight,
+  posDeltaBootToLeft: +dist(camBoot, camAfterLeft).toFixed(4),
+  posDeltaLeftToRight: +dist(camAfterLeft, camAfterRight).toFixed(4),
+  posDeltaWhole: +dist(camBoot, camAfterRight).toFixed(4),
+};
+
+result.errorCount = errors.length;
+result.errors = errors;
+
+console.log(JSON.stringify(result, null, 2));
+await browser.close();

← 0ae67ed snapshot before carousel build: Slice-2 boot-guard (_qhBootS  ·  back to Quadrille Showroom  ·  Add Playwright carousel verifier + npm verify-carousel scrip 15572c3 →