← 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
A HIGH-END-VERSIONS-SPEC.mdM public/js/rack.jsM public/js/showroom.jsA scripts/verify-carousel.mjs
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 →