[object Object]

← back to Quadrille Showroom

High-end versions batch 3: V18 Infinity Mirror + V19 Conservatory Garden + V20 Penthouse Dusk — ALL 10 high-end versions (V12-V21) now built + registered in the picker

f94d966a7fce62d6272f48c6e3be871039ad2ef1 · 2026-06-29 06:37:05 -0700 · Steve

Files touched

Diff

commit f94d966a7fce62d6272f48c6e3be871039ad2ef1
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 06:37:05 2026 -0700

    High-end versions batch 3: V18 Infinity Mirror + V19 Conservatory Garden + V20 Penthouse Dusk — ALL 10 high-end versions (V12-V21) now built + registered in the picker
---
 public/js/versions.js                 |  32 +-
 public/proto/v18-infinity-mirror.html | 694 ++++++++++++++++++++++++++++++
 public/proto/v19-conservatory.html    | 773 ++++++++++++++++++++++++++++++++++
 public/proto/v20-penthouse-dusk.html  | 737 ++++++++++++++++++++++++++++++++
 4 files changed, 2235 insertions(+), 1 deletion(-)

diff --git a/public/js/versions.js b/public/js/versions.js
index 98f7fe3..3692f1b 100644
--- a/public/js/versions.js
+++ b/public/js/versions.js
@@ -410,9 +410,39 @@ function boot() {
         { n: 4, label: 'Light-wood swivel boards, centred board full-face' },
         { n: 5, label: 'Wabi-sabi serenity' }
       ]
+    },
+    V18: {
+      name: 'Infinity Mirror', tag: 'infinitymirror', overlay: '/proto/v18-infinity-mirror.html',
+      elements: [
+        { n: 1, label: 'Mirrored floor + side walls — the bank repeats into infinity' },
+        { n: 2, label: 'Thin brushed-metal swivel boards' },
+        { n: 3, label: 'Tasteful cool-white / amber neon edge accents' },
+        { n: 4, label: 'Single CubeCamera reflection probe (FPS-cheap mirror)' },
+        { n: 5, label: 'Centred board full-face amid the reflected depth' }
+      ]
+    },
+    V19: {
+      name: 'Conservatory Garden', tag: 'conservatory', overlay: '/proto/v19-conservatory.html',
+      elements: [
+        { n: 1, label: 'Victorian palm-house — wrought-iron-and-glass conservatory' },
+        { n: 2, label: 'Lush potted palms framing the bank' },
+        { n: 3, label: 'Dappled leaf-shadow gobo light across boards + stone floor' },
+        { n: 4, label: 'Hazy jungle treeline beyond the glazing' },
+        { n: 5, label: 'Iron-framed boards, centred board full-face' }
+      ]
+    },
+    V20: {
+      name: 'Penthouse Dusk', tag: 'penthousedusk', overlay: '/proto/v20-penthouse-dusk.html',
+      elements: [
+        { n: 1, label: 'High-rise penthouse — floor-to-ceiling dusk-gradient glass' },
+        { n: 2, label: 'Soft city-bokeh lights beyond the windows' },
+        { n: 3, label: 'Warm interior key, dark polished stone floor with reflections' },
+        { n: 4, label: 'Sophisticated metropolitan evening mood' },
+        { n: 5, label: 'Bronze-framed boards, centred board full-face' }
+      ]
     }
   };
-  const VERSION_ORDER = ['V1','V2','V3','V4','V5','V6','V7','V8','V9','V10','V11','V12','V13','V14','V15','V16','V17','V21'];
+  const VERSION_ORDER = ['V1','V2','V3','V4','V5','V6','V7','V8','V9','V10','V11','V12','V13','V14','V15','V16','V17','V18','V19','V20','V21'];
 
   // ===========================================================================
   // GLOBALLY-UNIQUE ELEMENT CODES (Slice-3 / chunk H)
diff --git a/public/proto/v18-infinity-mirror.html b/public/proto/v18-infinity-mirror.html
new file mode 100644
index 0000000..c048bc5
--- /dev/null
+++ b/public/proto/v18-infinity-mirror.html
@@ -0,0 +1,694 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>INFINITY MIRROR · China Seas</title>
+<style>
+  :root { --neon:#bfe9ff; --amber:#ffca7a; --paper:#eef2f6; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#04060a; color:var(--paper);
+    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+  #app { position:fixed; inset:0; }
+  canvas { display:block; }
+
+  /* ---- masthead ---- */
+  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:64px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(4,6,10,.78) 0%, rgba(4,6,10,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#7fb9d6; font-weight:600; }
+  .masthead h1 { font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; font-size:clamp(26px,4.6vw,48px);
+    font-weight:200; letter-spacing:.26em; line-height:1; margin-top:11px; color:#f1f6fb;
+    text-shadow:0 0 22px rgba(140,210,255,.35); }
+  .masthead h1 b { font-weight:600; color:#bfe9ff; text-shadow:0 0 26px rgba(140,210,255,.6); }
+  .masthead .sub { margin-top:11px; font-size:12px; letter-spacing:.05em; color:#7e8a96;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  /* ---- focused board caption ---- */
+  #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
+    z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
+    pointer-events:none; }
+  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #caption .cn { font-size:22px; font-weight:300; letter-spacing:.04em; color:#f1f6fb;
+    text-shadow:0 2px 22px rgba(0,0,0,.9), 0 0 18px rgba(150,215,255,.3); }
+  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#86c6e6; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#8c97a3; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(150,215,255,.5); border-radius:28px; color:#bfe9ff; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(150,215,255,.05); transition:background .15s, transform .15s, box-shadow .15s;
+    box-shadow:0 0 14px rgba(150,215,255,.12); }
+  #caption a.cta:hover { background:rgba(150,215,255,.14); transform:translateY(-1px);
+    box-shadow:0 0 22px rgba(150,215,255,.3); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(190,233,255,.42);
+    pointer-events:none; transition:opacity .6s; }
+
+  /* ---- FPS readout ---- */
+  #fps { position:fixed; bottom:14px; right:16px; z-index:25; font:600 11px/1.3 ui-monospace,Menlo,monospace;
+    color:#7fb9d6; background:rgba(6,9,14,.62); border:1px solid rgba(150,215,255,.22); border-radius:7px;
+    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+  #fps.off { opacity:.32; }
+
+  /* ---- loading ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center; background:#04060a; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(150,215,255,.16); border-top-color:#bfe9ff; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#5f7282; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:58px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · The Infinity Gallery</div>
+  <h1>INFINITY <b>MIRROR</b></h1>
+  <div class="sub">China Seas, hung in a mirrored hall. Floor and side walls reflect the bank into the
+    distance, traced by a thread of cool neon. Drag to swing the bank; tap a board to bring it forward.</div>
+</div>
+
+<div id="caption">
+  <div class="cn" id="cap_name">—</div>
+  <div class="cc" id="cap_coll">China Seas</div>
+  <div class="cspec" id="cap_spec">—</div>
+  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Lighting the mirror hall…</div></div>
+
+<!-- r128 core (local). RoomEnvironment from CDN for IBL; NO bloom dep (neon faked via emissive + additive planes). -->
+<script src="./three.min.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+
+  // -------------------------------------------------------------------------
+  // RENDERER (high-end baseline)
+  // -------------------------------------------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+  renderer.toneMapping = THREE.ACESFilmicToneMapping;
+  renderer.toneMappingExposure = 1.06;
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0x04060a, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x05070c);
+  scene.fog = new THREE.FogExp2(0x05070c, 0.034);
+
+  const camera = new THREE.PerspectiveCamera(48, W()/H(), 0.1, 220);
+  camera.position.set(0, 1.55, 9.2);
+  camera.lookAt(0, 1.55, 0);
+
+  // -------------------------------------------------------------------------
+  // PMREM ENVIRONMENT (RoomEnvironment) — drives reflections on the mirror
+  // surfaces and gloss on the boards. We blend in a couple of emissive neon
+  // panels so the env map carries a faint cool/amber tint into the mirrors.
+  // -------------------------------------------------------------------------
+  let ENV_MAP = null;
+  (function buildEnv(){
+    try {
+      const pmrem = new THREE.PMREMGenerator(renderer);
+      const env = new THREE.RoomEnvironment();
+      ENV_MAP = pmrem.fromScene(env, 0.04).texture;
+      scene.environment = ENV_MAP;
+      pmrem.dispose();
+    } catch(e){ /* env optional */ }
+  })();
+
+  // -------------------------------------------------------------------------
+  // ONE infrequently-updated CubeCamera reflection probe at the hall centre.
+  // The mirror floor + side walls sample THIS cube map (a single shared env)
+  // rather than recursive real-time reflectors — cheap, holds FPS. We refresh
+  // it only every Nth frame (and only when the scene meaningfully changed).
+  // -------------------------------------------------------------------------
+  const cubeRT = new THREE.WebGLCubeRenderTarget(256, {
+    format: THREE.RGBAFormat,
+    generateMipmaps: true,
+    minFilter: THREE.LinearMipmapLinearFilter
+  });
+  if ("texture" in cubeRT && "colorSpace" in cubeRT.texture) cubeRT.texture.colorSpace = THREE.SRGBColorSpace;
+  else if (cubeRT.texture) cubeRT.texture.encoding = THREE.sRGBEncoding;
+  const cubeCam = new THREE.CubeCamera(0.1, 80, cubeRT);
+  cubeCam.position.set(0, 1.4, 2.0);
+  scene.add(cubeCam);
+  let cubeReady = false;
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — cool key + warm rim + low fill; high-end gallery-futuristic.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.AmbientLight(0x10161f, 0.45));
+
+  const fill = new THREE.DirectionalLight(0x5d7d9c, 0.26);
+  fill.position.set(-7, 6, 6); scene.add(fill);
+
+  const rim = new THREE.DirectionalLight(0xffd4a0, 0.30);   // warm amber rim from behind
+  rim.position.set(6, 4, -8); scene.add(rim);
+
+  const key = new THREE.DirectionalLight(0xeaf4ff, 0.42);   // cool key
+  key.position.set(2, 9, 8);
+  key.castShadow = true;
+  key.shadow.mapSize.set(2048, 2048);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 40;
+  key.shadow.camera.left = -10; key.shadow.camera.right = 10;
+  key.shadow.camera.top = 10; key.shadow.camera.bottom = -10;
+  key.shadow.bias = -0.0004; key.shadow.radius = 4;
+  scene.add(key);
+
+  // featured-board spotlight (cool white, tight)
+  const spot = new THREE.SpotLight(0xdcefff, 0, 18, Math.PI/7.5, 0.5, 1.4);
+  spot.position.set(0, 6.2, 5.4);
+  spot.castShadow = true;
+  spot.shadow.mapSize.set(2048, 2048);
+  spot.shadow.camera.near = 1; spot.shadow.camera.far = 24;
+  spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+  scene.add(spot);
+  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+  spot.target = spotTarget;
+
+  // -------------------------------------------------------------------------
+  // ROOM — MIRRORED floor + side walls. Instead of THREE.Reflector (real-time,
+  // expensive), the mirror surfaces are env-mapped MeshStandardMaterial at high
+  // metalness / very low roughness sampling the shared CubeCamera probe. This
+  // gives a believable infinity-repeat of the bank without recursive reflection.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+  let mirrorFloorMat=null, mirrorLeftMat=null, mirrorRightMat=null;
+  const neonGlows = [];   // {mat, base} for a gentle breathing pulse — declared before buildRoom()→buildNeon()
+
+  (function buildRoom(){
+    // back wall — dark, faint sheen so neon halo registers
+    const back = new THREE.Mesh(
+      new THREE.PlaneGeometry(80, 30),
+      new THREE.MeshStandardMaterial({ color:0x070a10, roughness:0.92, metalness:0.05, envMapIntensity:0.3 })
+    );
+    back.position.set(0, 8, -8.5); back.receiveShadow = true; scene.add(back);
+
+    // ceiling — near-black, holds the ceiling neon line
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(80, 60),
+      new THREE.MeshStandardMaterial({ color:0x05070b, roughness:1.0 }));
+    ceil.position.set(0, 13.2, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+    // MIRROR FLOOR — high metalness, low roughness, samples the cube probe.
+    // A faint procedural roughness keeps it from being a perfect (banded) mirror.
+    mirrorFloorMat = new THREE.MeshStandardMaterial({
+      color:0x0a0e14, metalness:1.0, roughness:0.14, envMapIntensity:1.25
+    });
+    mirrorFloorMat.roughnessMap = makeMicroRoughness(118, 30);
+    mirrorFloorMat.roughnessMap.wrapS = mirrorFloorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+    mirrorFloorMat.roughnessMap.repeat.set(10, 10);
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 60), mirrorFloorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+
+    // MIRROR SIDE WALLS — same treatment, so the bank repeats sideways into the
+    // distance on both sides (the "infinity" of the aesthetic).
+    mirrorLeftMat = new THREE.MeshStandardMaterial({
+      color:0x090d13, metalness:1.0, roughness:0.18, envMapIntensity:1.1
+    });
+    mirrorLeftMat.roughnessMap = mirrorFloorMat.roughnessMap;
+    mirrorRightMat = mirrorLeftMat.clone();
+    mirrorRightMat.roughnessMap = mirrorFloorMat.roughnessMap;
+
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(60, 30), mirrorLeftMat);
+    left.position.set(-11.5, 8, 0); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+    const right = new THREE.Mesh(new THREE.PlaneGeometry(60, 30), mirrorRightMat);
+    right.position.set(11.5, 8, 0); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
+
+    buildNeon();
+  })();
+
+  function makeMicroRoughness(base, spread){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#777"; x.fillRect(0,0,256,256);
+    const img = x.getImageData(0,0,256,256); const d = img.data;
+    for(let i=0;i<d.length;i+=4){ const n=(Math.random()*spread)|0; d[i]=d[i+1]=d[i+2]=base+n; }
+    x.putImageData(img,0,0);
+    const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+  }
+
+  // -------------------------------------------------------------------------
+  // NEON EDGE-LIGHT — thin emissive LED lines tracing the ceiling + the wall/
+  // floor seams, with FAKE glow (additive blur planes around each line). Cool
+  // white for the verticals, a warm amber line along the floor seam. No bloom
+  // pass needed; this reads as crisp neon and is nearly free.
+  // -------------------------------------------------------------------------
+  function neonLine(len, thick, color, glowColor, glowScale){
+    const g = new THREE.Group();
+    // the crisp core line — emissive, unlit, additive so it pops
+    const core = new THREE.Mesh(
+      new THREE.PlaneGeometry(len, thick),
+      new THREE.MeshBasicMaterial({ color:color, toneMapped:false })
+    );
+    g.add(core);
+    // the fake-glow halo — a wider, soft additive plane behind it
+    const haloMat = new THREE.MeshBasicMaterial({
+      color:glowColor, transparent:true, opacity:0.5, blending:THREE.AdditiveBlending,
+      depthWrite:false, toneMapped:false
+    });
+    const halo = new THREE.Mesh(new THREE.PlaneGeometry(len*1.01, thick*glowScale), haloMat);
+    halo.position.z = -0.01;
+    g.add(halo);
+    neonGlows.push({ mat:haloMat, base:0.5 });
+    return g;
+  }
+
+  function buildNeon(){
+    const COOL = 0x9fe2ff, COOLG = 0x4ea8e0;
+    const AMBER = 0xffc278, AMBERG = 0xe08a3a;
+
+    // ceiling centre line running into the distance (the spine of the hall)
+    const ceilLine = neonLine(70, 0.06, COOL, COOLG, 9);
+    ceilLine.rotation.x = Math.PI/2;        // lie flat on the ceiling
+    ceilLine.position.set(0, 13.15, 0);
+    scene.add(ceilLine);
+
+    // two ceiling-edge runs where ceiling meets the side walls (perspective rails)
+    [-11.35, 11.35].forEach(xp=>{
+      const r = neonLine(70, 0.05, COOL, COOLG, 8);
+      r.rotation.x = Math.PI/2;
+      r.position.set(xp, 13.1, 0);
+      scene.add(r);
+    });
+
+    // FLOOR/WALL seam amber lines (both sides) — warm counterpoint, low to the
+    // mirror floor so they smear into a reflected glow.
+    [-11.4, 11.4].forEach((xp,i)=>{
+      const r = neonLine(70, 0.06, AMBER, AMBERG, 10);
+      r.rotation.x = -Math.PI/2;
+      r.position.set(xp, 0.06, 0);
+      scene.add(r);
+    });
+
+    // vertical neon at the back corners — frames the far end of the hall
+    [-11.4, 11.4].forEach(xp=>{
+      const v = neonLine(26, 0.05, COOL, COOLG, 9);
+      v.rotation.z = Math.PI/2;
+      v.position.set(xp, 8, -8.3);
+      scene.add(v);
+    });
+  }
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin-framed boards, ~2" apart, side swivel. (Mechanic
+  // reused from v6/v12: pivot on the left edge, carousel-shift to centre, the
+  // focused board swings fully face-on to a clean full pattern face.)
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;
+  const FRAME_D = 0.05;
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.4;
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // cool-tinted brushed-metal frame (catches the neon + env)
+  const frameMat = new THREE.MeshStandardMaterial({ color:0x1a2029, roughness:0.34, metalness:0.92, envMapIntensity:1.1 });
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0x141820, roughness:0.6, metalness:0.0, envMapIntensity:0.5 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
+      new THREE.MeshStandardMaterial({ color:0x0c0f15, roughness:0.8, metalness:0.1 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    // a thin emissive neon edge on the FOCUSED board's frame (faded in/out) so
+    // the featured piece glows tastefully — a single additive outline plane.
+    const edge = new THREE.Mesh(
+      new THREE.PlaneGeometry(BOARD_W + 0.14, BOARD_H + 0.14),
+      new THREE.MeshBasicMaterial({ color:0x9fe2ff, transparent:true, opacity:0, blending:THREE.AdditiveBlending, depthWrite:false, toneMapped:false })
+    );
+    edge.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.04);
+    g.add(edge);
+
+    scene.add(g);
+    return { group:g, face, faceMat, edge, idx, data:null, texLoaded:false, swivel:0, _lit:undefined };
+  }
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: only featured board + a window of neighbours.
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);
+        tex.needsUpdate = true;
+        texCache.set(url, tex);
+        resolve(tex);
+      };
+      img.onerror = ()=>resolve(null);
+      img.src = url;
+    });
+  }
+  function ensureBoardTexture(b){
+    if(b.texLoaded || !b.data) return;
+    const url = imgURL(b.data.image || b.data.tile);
+    if(!url){ b.texLoaded = true; return; }
+    b.texLoaded = true;
+    loadTexture(url).then(tex=>{
+      if(!tex) return;
+      const m = new THREE.MeshPhysicalMaterial({
+        map:tex, roughness:0.5, metalness:0.0, clearcoat:0.14, clearcoatRoughness:0.55, envMapIntensity:0.5
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+      cubeReady = false;   // force a reflection-probe refresh after a new texture
+    });
+  }
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.abs(i-focusIdx);
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+    cubeReady = false;   // re-render the probe when the centred board changes
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    cubeReady = false;
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // REFLECTION PROBE refresh — cheap: hide the mirror surfaces, render the cube
+  // from the hall centre, assign to the mirror materials. Done only when the
+  // scene changed (cubeReady=false) and at most ~once/sec otherwise.
+  // -------------------------------------------------------------------------
+  let probeFrame = 0;
+  function updateProbe(){
+    if(!mirrorFloorMat) return;
+    // hide mirrors so they don't reflect themselves (no recursion)
+    const hidden = [];
+    scene.traverse(o=>{
+      if(o.isMesh && (o.material===mirrorFloorMat || o.material===mirrorLeftMat || o.material===mirrorRightMat)){
+        if(o.visible){ hidden.push(o); o.visible=false; }
+      }
+    });
+    cubeCam.update(renderer, scene);
+    hidden.forEach(o=>o.visible=true);
+    mirrorFloorMat.envMap = cubeRT.texture;
+    mirrorLeftMat.envMap  = cubeRT.texture;
+    mirrorRightMat.envMap = cubeRT.texture;
+    mirrorFloorMat.needsUpdate = mirrorLeftMat.needsUpdate = mirrorRightMat.needsUpdate = true;
+    cubeReady = true;
+  }
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = 0;
+
+      // brightness recede — only the featured board is fully lit
+      const litTarget = 0.30 + 0.70*Math.pow(openAmt, 1.3);
+      if(b._lit===undefined) b._lit = litTarget;
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
+
+      // neon frame glow only on the centred board
+      const edgeTarget = 0.0 + 0.55*Math.pow(openAmt, 2.2);
+      b.edge.material.opacity += (edgeTarget - b.edge.material.opacity)*Math.min(1,dt*6);
+
+      const farOff = Math.abs(rel) > 8;
+      b.group.visible = !farOff;
+    }
+
+    // spotlight tracks the featured board
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
+      spot.position.set(fx, 6.4, front.group.position.z + 5.2);
+      spot.intensity += ((HAS_PRODUCTS?9.0:0) - spot.intensity)*Math.min(1,dt*5);
+    }
+
+    // neon breathing pulse (subtle, tasteful)
+    const pulse = 0.5 + Math.sin(now*0.0014)*0.12;
+    for(const ng of neonGlows){ ng.mat.opacity = pulse; }
+
+    // refresh the reflection probe lazily: when flagged dirty, and otherwise a
+    // gentle periodic refresh so the breathing neon shows in the mirrors too.
+    probeFrame++;
+    if(!cubeReady || (probeFrame % 90 === 0)){
+      updateProbe();
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__IM_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    // prime the reflection probe once before reveal
+    updateProbe();
+
+    window.__IM__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      mirrors: ()=>!!(mirrorFloorMat && mirrorFloorMat.envMap),
+      fps: ()=>window.__IM_FPS||0,
+      ready: true
+    };
+
+    finishBoot();
+    requestAnimationFrame(tick);
+  }
+
+  function finishBoot(){
+    const boot = document.getElementById("boot");
+    boot.style.opacity = "0";
+    setTimeout(()=>boot.remove(), 950);
+    const hint = document.getElementById("hint");
+    setTimeout(()=>{ hint.style.opacity="0"; }, 6000);
+  }
+
+  start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V18","label":"V18 — Infinity Mirror","elements":[
+  {"n":1,"label":"Mirrored floor + side walls — the bank repeats into the distance"},
+  {"n":2,"label":"Crisp cool-white + amber neon edge-light tracing ceiling & seams"},
+  {"n":3,"label":"Thin brushed-metal framed boards, ~2\" apart on a side swivel"},
+  {"n":4,"label":"Featured board glows with a neon edge under a cool spotlight"},
+  {"n":5,"label":"Click a board → carousel-shift to centre → clean full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v19-conservatory.html b/public/proto/v19-conservatory.html
new file mode 100644
index 0000000..aa7861f
--- /dev/null
+++ b/public/proto/v19-conservatory.html
@@ -0,0 +1,773 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>CONSERVATORY · China Seas</title>
+<style>
+  :root { --leaf:#5d7a52; --brass:#b9a06a; --paper:#eef1e6; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#0d1410; color:var(--paper);
+    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+  #app { position:fixed; inset:0; }
+  canvas { display:block; }
+
+  /* ---- masthead ---- */
+  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:72px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(18,30,22,.74) 0%, rgba(18,30,22,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#9bb18a; font-weight:600; }
+  .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+    font-weight:400; letter-spacing:.12em; line-height:1; margin-top:9px; color:#f1f5ea;
+    text-shadow:0 2px 24px rgba(0,0,0,.5); }
+  .masthead h1 b { font-weight:700; color:#cdb784; }
+  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#a7b59a;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  /* ---- focused board caption ---- */
+  #caption { position:fixed; left:50%; bottom:96px; transform:translateX(-50%) translateY(14px);
+    z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
+    pointer-events:none; }
+  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #caption .cn { font-family:Georgia,serif; font-size:22px; letter-spacing:.02em; color:#f3f6ec;
+    text-shadow:0 2px 18px rgba(0,0,0,.85); }
+  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#bcc9a6; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#9fad8f; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(185,160,106,.6); border-radius:28px; color:#d8c590; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(185,160,106,.08); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(185,160,106,.2); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(225,235,210,.42);
+    pointer-events:none; transition:opacity .6s; }
+
+  /* ---- FPS readout ---- */
+  #fps { position:fixed; bottom:14px; right:16px; z-index:25; font:600 11px/1.3 ui-monospace,Menlo,monospace;
+    color:#9bb18a; background:rgba(14,22,16,.6); border:1px solid rgba(155,177,138,.3); border-radius:7px;
+    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+  #fps.off { opacity:.32; }
+
+  /* ---- loading ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center; background:#0d1410; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(155,177,138,.2); border-top-color:#9bb18a; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#7a8a6c; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:66px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · The Palm House</div>
+  <h1>CONSERVATORY <b>GARDEN</b></h1>
+  <div class="sub">China Seas, hung in a Victorian glasshouse — wrought-iron and glass, lush palms,
+    dappled leaf-light falling across the boards. Drag to swing the bank; tap a board to bring it into the light.</div>
+</div>
+
+<div id="caption">
+  <div class="cn" id="cap_name">—</div>
+  <div class="cc" id="cap_coll">China Seas</div>
+  <div class="cspec" id="cap_spec">—</div>
+  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Opening the glasshouse…</div></div>
+
+<!-- r128 core (local) + r128 RoomEnvironment (CDN, attaches to global THREE). No UnrealBloomPass dep. -->
+<script src="./three.min.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+  const lerp = (a,b,t)=>a+(b-a)*t;
+
+  // -------------------------------------------------------------------------
+  // RENDERER (high-end baseline)
+  // -------------------------------------------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+  renderer.toneMapping = THREE.ACESFilmicToneMapping;
+  renderer.toneMappingExposure = 1.12;
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0x18241b, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x1a2a1e);
+  // light volumetric haze — warm-green, gives the glasshouse depth
+  scene.fog = new THREE.FogExp2(0x223024, 0.022);
+
+  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 220);
+  camera.position.set(0, 1.55, 9.4);
+  camera.lookAt(0, 1.55, 0);
+
+  // -------------------------------------------------------------------------
+  // PMREM ENVIRONMENT (RoomEnvironment) — so glass, brass & leaves catch light
+  // -------------------------------------------------------------------------
+  (function buildEnv(){
+    try {
+      const pmrem = new THREE.PMREMGenerator(renderer);
+      const env = new THREE.RoomEnvironment();
+      const envMap = pmrem.fromScene(env, 0.04).texture;
+      scene.environment = envMap;
+      pmrem.dispose();
+    } catch(e){ /* env optional */ }
+  })();
+
+  // -------------------------------------------------------------------------
+  // DAPPLED LEAF GOBO — a procedural canvas texture (organic leaf-shaped light
+  // gaps) projected through the hero SpotLight via spot.map, AND reused as a
+  // moving overlay on ambient brightness so leaf-shadow plays across the floor.
+  // $0 local, one canvas.
+  // -------------------------------------------------------------------------
+  function makeLeafGobo(){
+    const S = 512;
+    const c = document.createElement("canvas"); c.width = c.height = S;
+    const x = c.getContext("2d");
+    // base = deep canopy shadow; bright warm pools = gaps where sun pours through.
+    // Higher contrast so the dapple reads boldly on the floor + boards.
+    x.fillStyle = "#0e150a"; x.fillRect(0,0,S,S);
+    // scatter warm dappled sun-pools, additive so overlaps brighten like real light
+    x.globalCompositeOperation = "lighter";
+    for(let i=0;i<70;i++){
+      const cx = Math.random()*S, cy = Math.random()*S;
+      const r = 26 + Math.random()*90;
+      const g = x.createRadialGradient(cx,cy,0,cx,cy,r);
+      const warm = Math.random()<0.6;
+      g.addColorStop(0, warm ? "rgba(255,240,196,1.0)" : "rgba(214,238,176,0.95)");
+      g.addColorStop(0.5, warm ? "rgba(196,206,128,0.45)" : "rgba(150,196,118,0.4)");
+      g.addColorStop(1, "rgba(0,0,0,0)");
+      x.fillStyle = g; x.beginPath(); x.arc(cx,cy,r,0,Math.PI*2); x.fill();
+    }
+    // cut crisp leaf-silhouette shadows back through the pools (multiply darkens)
+    x.globalCompositeOperation = "multiply";
+    x.fillStyle = "rgba(8,14,6,0.96)";
+    for(let i=0;i<110;i++){
+      const cx = Math.random()*S, cy = Math.random()*S;
+      const len = 34 + Math.random()*110, wdt = 9 + Math.random()*26;
+      const ang = Math.random()*Math.PI*2;
+      x.save(); x.translate(cx,cy); x.rotate(ang);
+      x.beginPath();
+      x.moveTo(0,-len/2);
+      x.quadraticCurveTo(wdt, -len*0.1, 0, len/2);
+      x.quadraticCurveTo(-wdt, len*0.1, 0, -len/2);
+      x.fill();
+      x.restore();
+    }
+    x.globalCompositeOperation = "source-over";
+    const t = new THREE.CanvasTexture(c);
+    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
+    t.wrapS = t.wrapT = THREE.RepeatWrapping;
+    t.anisotropy = MAX_ANISO;
+    return t;
+  }
+  const leafGobo = makeLeafGobo();
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — bright botanical daylight: green-tinted ambient/hemisphere, a
+  // warm sun key casting leaf-dappled shadows, cool sky fill, warm rim, plus a
+  // gobo-masked SpotLight that pours dappled light onto the featured board.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.HemisphereLight(0xdfeecb, 0x33402b, 0.55)); // sky/ground
+  scene.add(new THREE.AmbientLight(0x46553a, 0.35));
+
+  const fill = new THREE.DirectionalLight(0xaecbe6, 0.35);   // cool sky fill from left
+  fill.position.set(-8, 7, 6); scene.add(fill);
+
+  const rim = new THREE.DirectionalLight(0xffe2ab, 0.42);    // warm rim from behind-right
+  rim.position.set(7, 5, -8); scene.add(rim);
+
+  // SUN key — warm, high, casts the dappled leaf shadow across boards + floor
+  const sun = new THREE.DirectionalLight(0xfff2d2, 0.95);
+  sun.position.set(-5, 13, 9);
+  sun.castShadow = true;
+  sun.shadow.mapSize.set(2048, 2048);
+  sun.shadow.camera.near = 1; sun.shadow.camera.far = 46;
+  sun.shadow.camera.left = -12; sun.shadow.camera.right = 12;
+  sun.shadow.camera.top = 12; sun.shadow.camera.bottom = -12;
+  sun.shadow.bias = -0.0004; sun.shadow.radius = 5;
+  scene.add(sun);
+
+  // HERO gobo spotlight — pours warm dappled light onto the featured board face
+  const spot = new THREE.SpotLight(0xfff0cf, 0, 20, Math.PI/6.2, 0.5, 1.2);
+  spot.position.set(0, 7.2, 5.6);
+  if("map" in spot) spot.map = leafGobo;     // r128 SpotLight supports a projected map
+  spot.castShadow = true;
+  spot.shadow.mapSize.set(2048, 2048);
+  spot.shadow.camera.near = 1; spot.shadow.camera.far = 26;
+  spot.shadow.bias = -0.0005; spot.shadow.radius = 6;
+  scene.add(spot);
+  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+  spot.target = spotTarget;
+
+  // -------------------------------------------------------------------------
+  // ROOM — wrought-iron & glass conservatory. Glass back/side walls (physical,
+  // transmissive-ish), a planted stone floor with a moving dappled-leaf overlay,
+  // and an iron-mullion grid arching overhead.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+  let floorDapple = null;     // material whose emissive map we scroll for moving dapple
+
+  (function buildRoom(){
+    // --- glasshouse glazing: cool sky-toned glass panes behind/beside the bank ---
+    const glassMat = new THREE.MeshPhysicalMaterial({
+      color:0xbfd8d0, roughness:0.08, metalness:0.0,
+      transmission: ("transmission" in THREE.MeshPhysicalMaterial.prototype ? 0.55 : 0.0),
+      transparent:true, opacity:0.5, thickness:0.4,
+      clearcoat:0.6, clearcoatRoughness:0.18, envMapIntensity:1.1, side:THREE.DoubleSide
+    });
+    const back = new THREE.Mesh(new THREE.PlaneGeometry(64, 26), glassMat);
+    back.position.set(0, 7, -8.0); scene.add(back);
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(44, 26), glassMat);
+    left.position.set(-15, 7, 2); left.rotation.y = Math.PI/2; scene.add(left);
+    const right = left.clone(); right.position.x = 15; right.rotation.y = -Math.PI/2; scene.add(right);
+
+    // daylight sky + a distant JUNGLE silhouette layer behind the glass, so the
+    // glasshouse reads as looking out onto lush gardens — not a flat grey wall.
+    const skyC = document.createElement("canvas"); skyC.width = 1024; skyC.height = 512;
+    const sx = skyC.getContext("2d");
+    // warm-green daylight gradient
+    const sg = sx.createLinearGradient(0,0,0,512);
+    sg.addColorStop(0, "#eef3da"); sg.addColorStop(0.45, "#dcebcb"); sg.addColorStop(1, "#a9c6a0");
+    sx.fillStyle = sg; sx.fillRect(0,0,1024,512);
+    // far hazy treeline (soft, low contrast — atmospheric distance)
+    sx.fillStyle = "rgba(120,158,108,0.55)";
+    for(let i=0;i<60;i++){
+      const cx = Math.random()*1024, r = 40+Math.random()*130;
+      sx.beginPath(); sx.arc(cx, 360+Math.random()*60, r, 0, Math.PI*2); sx.fill();
+    }
+    // nearer foliage clumps along the bottom (denser, greener)
+    sx.fillStyle = "rgba(74,112,62,0.8)";
+    for(let i=0;i<80;i++){
+      const cx = Math.random()*1024, cy = 430+Math.random()*80, r = 30+Math.random()*90;
+      sx.beginPath(); sx.arc(cx, cy, r, 0, Math.PI*2); sx.fill();
+    }
+    // a few tall palm silhouettes breaking the treeline
+    sx.strokeStyle = "rgba(58,92,50,0.85)"; sx.lineWidth = 6;
+    for(let i=0;i<7;i++){
+      const px = 90 + Math.random()*860, py = 470, ph = 150+Math.random()*120;
+      sx.beginPath(); sx.moveTo(px, py); sx.lineTo(px-10, py-ph); sx.stroke();
+      sx.fillStyle = "rgba(58,92,50,0.8)";
+      for(let f=0;f<7;f++){
+        const a = -Math.PI/2 + (f-3)*0.4;
+        sx.save(); sx.translate(px-10, py-ph); sx.rotate(a);
+        sx.beginPath(); sx.ellipse(40,0,46,11,0,0,Math.PI*2); sx.fill(); sx.restore();
+      }
+    }
+    const skyTex = new THREE.CanvasTexture(skyC);
+    if("colorSpace" in skyTex) skyTex.colorSpace = THREE.SRGBColorSpace;
+    skyTex.anisotropy = MAX_ANISO;
+    const sky = new THREE.Mesh(new THREE.PlaneGeometry(84, 40),
+      new THREE.MeshBasicMaterial({ map:skyTex, fog:false }));
+    sky.position.set(0, 9, -10.8); scene.add(sky);
+
+    // --- wrought-iron mullions: a grid of thin dark bars over the glazing ---
+    const ironMat = new THREE.MeshStandardMaterial({ color:0x1c241c, roughness:0.45, metalness:0.85, envMapIntensity:0.9 });
+    const vBar = new THREE.BoxGeometry(0.07, 26, 0.07);
+    const hBar = new THREE.BoxGeometry(64, 0.07, 0.07);
+    const mullions = new THREE.Group();
+    for(let i=-5;i<=5;i++){ const m = new THREE.Mesh(vBar, ironMat); m.position.set(i*5.6, 7, -7.9); mullions.add(m); }
+    for(let j=0;j<5;j++){ const m = new THREE.Mesh(hBar, ironMat); m.position.set(0, 1.5+j*3.0, -7.9); mullions.add(m); }
+    scene.add(mullions);
+
+    // --- arching iron ribs overhead (the palm-house roof) ---
+    const ribMat = ironMat;
+    const ribGroup = new THREE.Group();
+    for(let r=0;r<6;r++){
+      const z = 2 - r*2.4;
+      const pts = [];
+      for(let a=0;a<=20;a++){
+        const t = a/20, ang = Math.PI*t;
+        pts.push(new THREE.Vector3(Math.cos(ang)*14, 12.5 + Math.sin(ang)*3.4, z));
+      }
+      const curve = new THREE.CatmullRomCurve3(pts);
+      const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, 24, 0.08, 6, false), ribMat);
+      ribGroup.add(tube);
+    }
+    scene.add(ribGroup);
+
+    // --- planted stone floor with a MOVING dappled-leaf light overlay ---
+    // base stone roughness
+    const stoneRough = (function(){
+      const c = document.createElement("canvas"); c.width=c.height=256; const g=c.getContext("2d");
+      g.fillStyle="#888"; g.fillRect(0,0,256,256);
+      const im=g.getImageData(0,0,256,256), d=im.data;
+      for(let i=0;i<d.length;i+=4){ const n=(Math.random()*60)|0; d[i]=d[i+1]=d[i+2]=150+n; }
+      g.putImageData(im,0,0);
+      const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(10,10); t.anisotropy=MAX_ANISO; return t;
+    })();
+    floorDapple = new THREE.MeshStandardMaterial({
+      color:0x575243, roughness:0.82, metalness:0.0,
+      roughnessMap: stoneRough,
+      emissive:0xfff0c8, emissiveIntensity:0.9, emissiveMap: leafGobo, envMapIntensity:0.45
+    });
+    floorDapple.emissiveMap.repeat.set(2.0, 2.0);
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), floorDapple);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+  })();
+
+  // -------------------------------------------------------------------------
+  // GREENERY — cheap potted palms/ferns flanking the bank. Built from a few
+  // SHARED low-poly cone/plane fronds + alpha leaf billboards. Instanced trunks,
+  // shared frond geometry. NOT heavy meshes.
+  // -------------------------------------------------------------------------
+  // one alpha leaf texture (frond silhouette) reused everywhere
+  function makeFrondTex(){
+    const S=256, c=document.createElement("canvas"); c.width=c.height=S; const x=c.getContext("2d");
+    x.clearRect(0,0,S,S);
+    // a lush feathered palm frond drawn as individual leaflets off a central rib
+    // (reads naturalistic, not a flat lozenge). Transparent ground for alpha cut.
+    const midX = S*0.5;
+    // central rib
+    x.strokeStyle = "#3a5a30"; x.lineWidth = 4;
+    x.beginPath(); x.moveTo(midX, S*0.06); x.lineTo(midX, S*0.97); x.stroke();
+    // paired leaflets, getting shorter toward the tip, with a light/dark gradient
+    for(let i=0.08;i<0.94;i+=0.045){
+      const t = i;
+      const taper = Math.sin(t*Math.PI);            // long in the middle, short at ends
+      const ll = S*0.40*taper;
+      const droop = 0.18 + t*0.10;                  // leaflets angle down toward the tip
+      const shade = 0.55 + 0.4*(1-t);               // brighter near base
+      const top = `rgba(${Math.round(70*shade+30)},${Math.round(120*shade+40)},${Math.round(58*shade+30)},1)`;
+      x.strokeStyle = top; x.lineWidth = 3.0;
+      // right leaflet
+      x.beginPath(); x.moveTo(midX, S*t);
+      x.quadraticCurveTo(midX+ll*0.6, S*(t+droop*0.4), midX+ll, S*(t+droop)); x.stroke();
+      // left leaflet
+      x.beginPath(); x.moveTo(midX, S*t);
+      x.quadraticCurveTo(midX-ll*0.6, S*(t+droop*0.4), midX-ll, S*(t+droop)); x.stroke();
+    }
+    const t = new THREE.CanvasTexture(c);
+    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace;
+    t.anisotropy = MAX_ANISO;
+    return t;
+  }
+  const frondTex = makeFrondTex();
+  const frondGeo = new THREE.PlaneGeometry(1.0, 2.0);
+  const frondMat = new THREE.MeshStandardMaterial({
+    map:frondTex, alphaTest:0.25, transparent:true, side:THREE.DoubleSide,
+    roughness:0.66, metalness:0.0, envMapIntensity:0.55
+  });
+  const potMat = new THREE.MeshStandardMaterial({ color:0x6b4a32, roughness:0.78, metalness:0.05, envMapIntensity:0.4 });
+  const trunkMat = new THREE.MeshStandardMaterial({ color:0x4e5d39, roughness:0.85, metalness:0.0 });
+  const potGeo = new THREE.CylinderGeometry(0.55, 0.42, 0.8, 14);
+  const trunkGeo = new THREE.CylinderGeometry(0.08, 0.12, 1.6, 8);
+
+  function buildPalm(x, z, scale){
+    const g = new THREE.Group();
+    const pot = new THREE.Mesh(potGeo, potMat); pot.position.y = 0.4; pot.castShadow=true; pot.receiveShadow=true; g.add(pot);
+    const trunk = new THREE.Mesh(trunkGeo, trunkMat); trunk.position.y = 1.6; trunk.castShadow=true; g.add(trunk);
+    // crown of ~9 fronds radiating from the top — shared geo/material
+    const NF = 9, crownY = 2.5;
+    for(let i=0;i<NF;i++){
+      const f = new THREE.Mesh(frondGeo, frondMat);
+      const a = (i/NF)*Math.PI*2;
+      const tilt = 0.35 + Math.random()*0.4;
+      f.position.set(Math.cos(a)*0.5, crownY + 0.7, Math.sin(a)*0.5);
+      f.rotation.order = "YXZ";
+      f.rotation.y = -a + Math.PI/2;
+      f.rotation.x = tilt;
+      f.scale.set(0.8+Math.random()*0.4, 1.0+Math.random()*0.3, 1);
+      f.castShadow = true;       // fronds throw their own leaf-shadow
+      g.add(f);
+    }
+    g.position.set(x, 0, z);
+    g.scale.setScalar(scale);
+    g.userData.fronds = g.children.slice(2); // for breeze sway
+    scene.add(g);
+    return g;
+  }
+  const palms = [
+    buildPalm(-7.5, -1.2, 1.25),
+    buildPalm( 7.5, -1.2, 1.25),
+    buildPalm(-9.8,  1.8, 1.55),
+    buildPalm( 9.8,  1.8, 1.55),
+    buildPalm(-6.2,  3.4, 1.0),
+    buildPalm( 6.2,  3.4, 1.0)
+  ];
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin-framed boards, ~2" apart on an arc, side swivel.
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;
+  const FRAME_D = 0.05;
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.4;
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // light wrought-iron / aged-brass frame (matches the glasshouse, not bronze noir)
+  const frameMat = new THREE.MeshStandardMaterial({ color:0x2b3326, roughness:0.42, metalness:0.85, envMapIntensity:1.0 });
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0xd8dccb, roughness:0.6, metalness:0.0, envMapIntensity:0.4 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
+      new THREE.MeshStandardMaterial({ color:0x232a1f, roughness:0.82, metalness:0.08 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;   // receives the leaf gobo shadow
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+  }
+
+  // floor reflection of the featured board (cheap soft contact glow on stone)
+  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
+  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
+  reflMesh.scale.y = -1; reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy window around the focused board
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);   // clean full pattern face, edge to edge
+        tex.needsUpdate = true;
+        texCache.set(url, tex);
+        resolve(tex);
+      };
+      img.onerror = ()=>resolve(null);
+      img.src = url;
+    });
+  }
+  function ensureBoardTexture(b){
+    if(b.texLoaded || !b.data) return;
+    const url = imgURL(b.data.image || b.data.tile);
+    if(!url){ b.texLoaded = true; return; }
+    b.texLoaded = true;
+    loadTexture(url).then(tex=>{
+      if(!tex) return;
+      const m = new THREE.MeshPhysicalMaterial({
+        map:tex, roughness:0.56, metalness:0.0, clearcoat:0.08, clearcoatRoughness:0.6, envMapIntensity:0.4
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.min(Math.abs(i-focusIdx), boards.length-Math.abs(i-focusIdx));
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+    const tt = now*0.001;
+
+    // moving dappled-leaf light: slowly scroll the floor emissive map + gobo
+    if(floorDapple && floorDapple.emissiveMap){
+      floorDapple.emissiveMap.offset.x = Math.sin(tt*0.08)*0.06 + tt*0.01;
+      floorDapple.emissiveMap.offset.y = Math.cos(tt*0.06)*0.05;
+      floorDapple.emissiveIntensity = 0.5 + Math.sin(tt*0.5)*0.12;
+    }
+    if(spot.map){ spot.map.offset.x = Math.sin(tt*0.1)*0.04; spot.map.offset.y = Math.cos(tt*0.08)*0.04; }
+
+    // gentle breeze sway on the palm fronds (cheap rotation jiggle)
+    for(const p of palms){
+      const fr = p.userData.fronds;
+      for(let k=0;k<fr.length;k++){
+        const f = fr[k];
+        if(f.geometry === frondGeo){
+          f.rotation.z = Math.sin(tt*0.9 + k + p.position.x)*0.06;
+        }
+      }
+    }
+
+    // smooth carousel travel
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = 0;
+
+      // soft brightness falloff with distance (gallery focus) — botanical version
+      // keeps neighbours readable in the bright glasshouse (not deep noir).
+      const litTarget = 0.62 + 0.38*Math.pow(openAmt, 1.3);
+      if(b._lit===undefined) b._lit = litTarget;
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat && !b.faceMat.map){ b.faceMat.color.setScalar(b._lit); }
+      else if(b.faceMat && b.faceMat.map){ b.faceMat.color.setScalar(0.62 + 0.38*Math.pow(openAmt,1.3)); }
+
+      const farOff = Math.abs(rel) > 7;
+      b.group.visible = !farOff;
+    }
+
+    // spotlight + reflection track the featured board
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
+      spot.position.set(fx, 7.2, front.group.position.z + 5.4);
+      spot.intensity += ((HAS_PRODUCTS?9.0:0) - spot.intensity)*Math.min(1,dt*5);
+
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.12 - reflMat.opacity)*Math.min(1,dt*5);
+        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
+        reflMesh.needsUpdate = true;
+      } else {
+        reflMat.opacity += (0 - reflMat.opacity)*Math.min(1,dt*5);
+      }
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__CV_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__CV__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      palms: ()=>palms.length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__CV_FPS||0,
+      ready: true
+    };
+
+    finishBoot();
+    requestAnimationFrame(tick);
+  }
+
+  function finishBoot(){
+    const boot = document.getElementById("boot");
+    boot.style.opacity = "0";
+    setTimeout(()=>boot.remove(), 950);
+    const hint = document.getElementById("hint");
+    setTimeout(()=>{ hint.style.opacity="0"; }, 6000);
+  }
+
+  start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V19","label":"V19 — Conservatory Garden","elements":[
+  {"n":1,"label":"Victorian glasshouse — wrought-iron mullions, arched ribs, transmissive glass walls"},
+  {"n":2,"label":"Lush potted palms flanking the bank with breeze-sway fronds"},
+  {"n":3,"label":"Dappled leaf-shadow gobo light across the boards + moving leaf-light on the stone floor"},
+  {"n":4,"label":"Thin iron-framed China Seas boards, ~2\" apart on a side swivel"},
+  {"n":5,"label":"Click a board → carousel-shift to center → clean full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v20-penthouse-dusk.html b/public/proto/v20-penthouse-dusk.html
new file mode 100644
index 0000000..d80f3ee
--- /dev/null
+++ b/public/proto/v20-penthouse-dusk.html
@@ -0,0 +1,737 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
+<title>PENTHOUSE DUSK · China Seas</title>
+<style>
+  :root { --gold:#d8b878; --paper:#f1ebdd; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#0a0a12; color:var(--paper);
+    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+  #app { position:fixed; inset:0; }
+  canvas { display:block; }
+
+  /* ---- masthead ---- */
+  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:80px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(10,9,18,.78) 0%, rgba(10,9,18,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#c0a06a; font-weight:600; }
+  .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+    font-weight:400; letter-spacing:.12em; line-height:1; margin-top:9px; color:#f4eee0; }
+  .masthead h1 b { font-weight:700; color:#f0cf93; }
+  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#9a917f;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  /* ---- focused board caption ---- */
+  #caption { position:fixed; left:50%; bottom:96px; transform:translateX(-50%) translateY(14px);
+    z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
+    pointer-events:none; }
+  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #caption .cn { font-family:Georgia,serif; font-size:22px; letter-spacing:.02em; color:#f4eee0;
+    text-shadow:0 2px 18px rgba(0,0,0,.9); }
+  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#cba968; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#a39b8a; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(216,184,120,.55); border-radius:28px; color:#f0cf93; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(216,184,120,.07); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(216,184,120,.2); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(225,215,195,.42);
+    pointer-events:none; transition:opacity .6s; }
+
+  /* ---- FPS readout ---- */
+  #fps { position:fixed; bottom:14px; right:16px; z-index:25; font:600 11px/1.3 ui-monospace,Menlo,monospace;
+    color:#c0a06a; background:rgba(10,10,18,.55); border:1px solid rgba(216,184,120,.25); border-radius:7px;
+    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+  #fps.off { opacity:.32; }
+
+  /* ---- loading ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center;
+    background:radial-gradient(120% 90% at 50% 38%, #2a2438 0%, #14111e 55%, #0a0912 100%);
+    transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(216,184,120,.18); border-top-color:#d8b878; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#8a8170; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:74px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · The Penthouse at Dusk</div>
+  <h1>PENTHOUSE <b>DUSK</b></h1>
+  <div class="sub">China Seas, hung in a high-rise penthouse as the city lights come up. Floor-to-ceiling
+    glass, a warm interior, the skyline glowing beyond. Drag to swing the bank; tap a board to bring it forward.</div>
+</div>
+
+<div id="caption">
+  <div class="cn" id="cap_name">—</div>
+  <div class="cc" id="cap_coll">China Seas</div>
+  <div class="cspec" id="cap_spec">—</div>
+  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Raising the blinds at dusk…</div></div>
+
+<!-- r128 core (local) + r128 example modules (CDN, attach to global THREE) -->
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+  const lerp = (a,b,t)=>a+(b-a)*t;
+
+  // ---- dusk palette ----
+  const DUSK = {
+    skyHi:  0x1b2440,   // deep indigo zenith
+    skyMid: 0x4a3a63,   // dusk violet
+    skyLo:  0xc8744a,   // warm horizon glow (sunset band)
+    skyGnd: 0x14101c,   // city silhouette base
+    warm:   0xffd6a0,   // interior key
+  };
+
+  // -------------------------------------------------------------------------
+  // RENDERER (high-end baseline)
+  // -------------------------------------------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+  renderer.toneMapping = THREE.ACESFilmicToneMapping;
+  renderer.toneMappingExposure = 1.12;
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0x0a0912, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x0a0912);
+  scene.fog = new THREE.FogExp2(0x130f1c, 0.020);
+
+  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 400);
+  camera.position.set(0, 1.55, 9.4);
+  camera.lookAt(0, 1.55, 0);
+
+  // -------------------------------------------------------------------------
+  // DUSK SKY + CITY BOKEH  (one canvas texture → used for the window plane AND
+  // the PMREM environment, so interior + glass reflect the same dusk glow).
+  // Cheap: a vertical gradient + a scatter of soft additive "lights" drawn into
+  // a 2D canvas, then blurred — NO particle system.
+  // -------------------------------------------------------------------------
+  function makeDuskCanvas(w, h){
+    const c = document.createElement("canvas"); c.width = w; c.height = h;
+    const x = c.getContext("2d");
+    const css = (n)=>"#"+n.toString(16).padStart(6,"0");
+    // sky gradient: indigo zenith → dusk violet → warm sunset horizon
+    const g = x.createLinearGradient(0,0,0,h);
+    g.addColorStop(0.00, css(DUSK.skyHi));
+    g.addColorStop(0.42, css(DUSK.skyMid));
+    g.addColorStop(0.70, css(DUSK.skyLo));
+    g.addColorStop(0.82, "#3a2640");
+    g.addColorStop(1.00, css(DUSK.skyGnd));
+    x.fillStyle = g; x.fillRect(0,0,w,h);
+
+    // a soft sun-bloom low on the horizon
+    const sunY = h*0.74, sx = w*0.62;
+    const sg = x.createRadialGradient(sx, sunY, 0, sx, sunY, h*0.42);
+    sg.addColorStop(0, "rgba(255,210,150,0.55)");
+    sg.addColorStop(0.4, "rgba(230,140,90,0.22)");
+    sg.addColorStop(1, "rgba(230,140,90,0)");
+    x.fillStyle = sg; x.fillRect(0,0,w,h);
+
+    // city silhouette band — dark rooflines along the bottom third
+    const horizon = h*0.80;
+    x.fillStyle = "rgba(8,6,14,0.92)";
+    let bx = 0;
+    while(bx < w){
+      const bw = 14 + Math.random()*46;
+      const bh = (h-horizon) * (0.25 + Math.random()*0.95);
+      x.fillRect(bx, horizon - bh, bw, bh + (h-horizon));
+      bx += bw + 2 + Math.random()*8;
+    }
+
+    // CITY BOKEH — soft additive window-lights scattered over the silhouette and
+    // lower sky. Warm + a few cool. Drawn as radial-gradient dots (the "blur").
+    x.globalCompositeOperation = "lighter";
+    const N = Math.round(w*h/2600);
+    for(let i=0;i<N;i++){
+      const px = Math.random()*w;
+      // lights concentrate in the city band, thinning upward into haze
+      const band = Math.random();
+      const py = horizon - (h-horizon)*0.9*Math.pow(band,1.6) + (Math.random()-0.5)*8;
+      if(py > h) continue;
+      const r = 1.2 + Math.random()*5.5;
+      const warm = Math.random() > 0.22;
+      const a = 0.25 + Math.random()*0.6;
+      const rg = x.createRadialGradient(px,py,0,px,py,r);
+      if(warm){ rg.addColorStop(0,`rgba(255,214,150,${a})`); rg.addColorStop(0.5,`rgba(255,180,110,${a*0.4})`); rg.addColorStop(1,"rgba(255,180,110,0)"); }
+      else    { rg.addColorStop(0,`rgba(170,205,255,${a})`); rg.addColorStop(0.5,`rgba(140,180,255,${a*0.4})`); rg.addColorStop(1,"rgba(140,180,255,0)"); }
+      x.fillStyle = rg; x.beginPath(); x.arc(px,py,r,0,Math.PI*2); x.fill();
+    }
+    // a faint upper-sky star scatter for depth
+    for(let i=0;i<Math.round(N*0.12);i++){
+      const px = Math.random()*w, py = Math.random()*horizon*0.7;
+      x.fillStyle = `rgba(220,225,255,${0.06+Math.random()*0.18})`;
+      x.fillRect(px,py,1,1);
+    }
+    x.globalCompositeOperation = "source-over";
+    return c;
+  }
+
+  const duskCanvas = makeDuskCanvas(1024, 1024);
+  const skyTex = new THREE.CanvasTexture(duskCanvas);
+  if("colorSpace" in skyTex) skyTex.colorSpace = THREE.SRGBColorSpace; else skyTex.encoding = THREE.sRGBEncoding;
+  skyTex.anisotropy = MAX_ANISO;
+
+  // -------------------------------------------------------------------------
+  // PMREM ENVIRONMENT built from the dusk sky → metals/gloss/glass reflect the
+  // dusk gradient + city glow. We render the sky onto a sphere into a scratch
+  // scene and PMREM it (no RoomEnvironment dependency).
+  // -------------------------------------------------------------------------
+  (function buildEnv(){
+    try {
+      const pmrem = new THREE.PMREMGenerator(renderer);
+      pmrem.compileEquirectangularShader && pmrem.compileEquirectangularShader();
+      const envScene = new THREE.Scene();
+      const dome = new THREE.Mesh(
+        new THREE.SphereGeometry(50, 32, 16),
+        new THREE.MeshBasicMaterial({ map:skyTex, side:THREE.BackSide, toneMapped:false })
+      );
+      envScene.add(dome);
+      // a warm bounce card (the interior) so reflections aren't pure-sky cold
+      const card = new THREE.Mesh(
+        new THREE.PlaneGeometry(40, 24),
+        new THREE.MeshBasicMaterial({ color:0x4a3322, side:THREE.DoubleSide, toneMapped:false })
+      );
+      card.position.set(0, -6, 8); card.lookAt(0,0,0); envScene.add(card);
+      const envMap = pmrem.fromScene(envScene, 0.05).texture;
+      scene.environment = envMap;
+      pmrem.dispose();
+    } catch(e){ /* env optional */ }
+  })();
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — warm interior key + cool dusk fill from the glass + warm rim.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.AmbientLight(0x2a2433, 0.55));
+
+  // cool dusk fill spilling in from the window wall (behind/left of the bank)
+  const duskFill = new THREE.DirectionalLight(0x6f7fae, 0.45);
+  duskFill.position.set(-5, 7, -9); scene.add(duskFill);
+
+  // warm rim from the city glow
+  const cityRim = new THREE.DirectionalLight(0xffb978, 0.34);
+  cityRim.position.set(7, 3, -8); scene.add(cityRim);
+
+  // warm interior KEY — a recessed ceiling wash, casts the soft shadow
+  const key = new THREE.DirectionalLight(0xffe2bc, 0.62);
+  key.position.set(2.5, 9, 7.5);
+  key.castShadow = true;
+  key.shadow.mapSize.set(2048, 2048);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 44;
+  key.shadow.camera.left = -11; key.shadow.camera.right = 11;
+  key.shadow.camera.top = 11; key.shadow.camera.bottom = -11;
+  key.shadow.bias = -0.0004; key.shadow.radius = 4;
+  scene.add(key);
+
+  // hero spotlight — warm, follows the featured board (intimate gallery pool)
+  const spot = new THREE.SpotLight(0xffe7c2, 0, 18, Math.PI/7, 0.5, 1.3);
+  spot.position.set(0, 6.4, 5.4);
+  spot.castShadow = true;
+  spot.shadow.mapSize.set(2048, 2048);
+  spot.shadow.camera.near = 1; spot.shadow.camera.far = 24;
+  spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+  scene.add(spot);
+  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+  spot.target = spotTarget;
+
+  // -------------------------------------------------------------------------
+  // ROOM — warm wood/stone walls, the WINDOW WALL of dusk glass behind the bank,
+  // and a dark polished stone floor that reflects the warm interior + city glow.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+  (function buildRoom(){
+    // THE WINDOW WALL — far back: floor-to-ceiling dusk glass. The sky texture is
+    // emissive so the city glow reads even unlit; a faint glass pane sits in front.
+    const winBack = new THREE.Mesh(
+      new THREE.PlaneGeometry(60, 26),
+      new THREE.MeshStandardMaterial({
+        map:skyTex, emissive:0xffffff, emissiveMap:skyTex, emissiveIntensity:0.9,
+        roughness:1.0, metalness:0.0, toneMapped:true
+      })
+    );
+    winBack.position.set(0, 8, -8.2); scene.add(winBack);
+
+    // glass pane (very subtle) — physical, transmissive, reflects the env
+    const glass = new THREE.Mesh(
+      new THREE.PlaneGeometry(60, 26),
+      new THREE.MeshPhysicalMaterial({
+        color:0x223047, roughness:0.06, metalness:0.0, transparent:true, opacity:0.10,
+        clearcoat:1.0, clearcoatRoughness:0.05, envMapIntensity:1.1, depthWrite:false
+      })
+    );
+    glass.position.set(0, 8, -7.9); scene.add(glass);
+
+    // slim dark mullions on the glass (vertical) — penthouse window frame grid
+    const mulMat = new THREE.MeshStandardMaterial({ color:0x14110d, roughness:0.5, metalness:0.6, envMapIntensity:0.8 });
+    const mulGeo = new THREE.BoxGeometry(0.08, 26, 0.12);
+    for(let mx=-24; mx<=24; mx+=8){
+      const m = new THREE.Mesh(mulGeo, mulMat); m.position.set(mx, 8, -7.85); scene.add(m);
+    }
+    const mulH = new THREE.Mesh(new THREE.BoxGeometry(60, 0.08, 0.12), mulMat);
+    mulH.position.set(0, 14.4, -7.85); scene.add(mulH);
+
+    // warm wood/stone side walls (the room is warm; the glass is cool)
+    const sideMat = new THREE.MeshStandardMaterial({ color:0x261d15, roughness:0.78, metalness:0.04, envMapIntensity:0.5 });
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(34, 26), sideMat);
+    left.position.set(-14, 8, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+    const right = left.clone(); right.position.x = 14; right.rotation.y = -Math.PI/2; scene.add(right);
+
+    // warm wood baseboard glow strips at the floor (interior warmth)
+    const ledgeMat = new THREE.MeshStandardMaterial({ color:0x3a2a1c, emissive:0x140c06, roughness:0.6, metalness:0.1 });
+    const ledgeL = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.4, 34), ledgeMat);
+    ledgeL.position.set(-13.7, 0.2, 2); scene.add(ledgeL);
+    const ledgeR = ledgeL.clone(); ledgeR.position.x = 13.7; scene.add(ledgeR);
+
+    // ceiling — warm, with a hint of recessed light
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 36),
+      new THREE.MeshStandardMaterial({ color:0x1a140e, roughness:0.95, metalness:0.0, emissive:0x0a0704 }));
+    ceil.position.set(0, 15, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+    // DARK POLISHED STONE FLOOR — clearcoat physical material reflecting the env
+    // (warm interior + dusk glass). Procedural roughness keeps it stone, not a
+    // mirror; the board reflection group below adds the contact glow.
+    const floorMat = new THREE.MeshPhysicalMaterial({
+      color:0x100d12, roughness:0.22, metalness:0.0,
+      clearcoat:0.95, clearcoatRoughness:0.30, envMapIntensity:0.85
+    });
+    floorMat.roughnessMap = makeStoneRoughness();
+    floorMat.roughnessMap.wrapS = floorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+    floorMat.roughnessMap.repeat.set(7, 7);
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), floorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+
+    // a soft warm "spill" plane on the floor near the bank (reads as window-light
+    // pooling on the stone) — additive, faint
+    const pool = new THREE.Mesh(
+      new THREE.PlaneGeometry(20, 14),
+      new THREE.MeshBasicMaterial({ color:0x3a2614, transparent:true, opacity:0.28, depthWrite:false, blending:THREE.AdditiveBlending })
+    );
+    pool.rotation.x = -Math.PI/2; pool.position.set(0, 0.012, 1.5); scene.add(pool);
+  })();
+
+  // procedural stone roughness (canvas → texture, $0 local)
+  function makeStoneRoughness(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#6a6a6a"; x.fillRect(0,0,256,256);
+    const img = x.getImageData(0,0,256,256); const d = img.data;
+    for(let i=0;i<d.length;i+=4){ const n = (Math.random()*64)|0; d[i]=d[i+1]=d[i+2]=92+n; }
+    x.putImageData(img,0,0);
+    // a few faint vein streaks for marble feel
+    x.strokeStyle="rgba(140,140,140,0.4)"; x.lineWidth=1;
+    for(let k=0;k<8;k++){ x.beginPath(); x.moveTo(Math.random()*256,0);
+      x.bezierCurveTo(Math.random()*256,80,Math.random()*256,170,Math.random()*256,256); x.stroke(); }
+    const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+  }
+
+  // -------------------------------------------------------------------------
+  // CITY BOKEH SPRITES — a handful of large soft additive sprites floating just
+  // beyond the glass, giving real out-of-focus depth to the skyline (cheap, ~14
+  // sprites, shared blurred-dot texture). NOT a particle system.
+  // -------------------------------------------------------------------------
+  const BOKEH = [];
+  (function buildBokeh(){
+    const c = document.createElement("canvas"); c.width = c.height = 64;
+    const g = c.getContext("2d");
+    const rg = g.createRadialGradient(32,32,0,32,32,32);
+    rg.addColorStop(0,"rgba(255,255,255,0.9)");
+    rg.addColorStop(0.35,"rgba(255,210,150,0.45)");
+    rg.addColorStop(1,"rgba(255,210,150,0)");
+    g.fillStyle = rg; g.beginPath(); g.arc(32,32,32,0,Math.PI*2); g.fill();
+    const dotTex = new THREE.CanvasTexture(c);
+    const warm = new THREE.Color(0xffce8c), cool = new THREE.Color(0x9bc0ff);
+    for(let i=0;i<14;i++){
+      const isWarm = Math.random()>0.25;
+      const mat = new THREE.SpriteMaterial({
+        map:dotTex, color:(isWarm?warm:cool), transparent:true,
+        opacity:0.16+Math.random()*0.22, blending:THREE.AdditiveBlending, depthWrite:false, toneMapped:false
+      });
+      const s = new THREE.Sprite(mat);
+      const sz = 0.6 + Math.random()*2.2;
+      s.scale.set(sz, sz, 1);
+      s.position.set((Math.random()-0.5)*40, 3 + Math.random()*9, -9.5 - Math.random()*6);
+      s.userData.tw = Math.random()*Math.PI*2;   // twinkle phase
+      s.userData.base = mat.opacity;
+      scene.add(s); BOKEH.push(s);
+    }
+  })();
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin dark-framed boards, ~2" apart on an arc, side swivel.
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;
+  const FRAME_D = 0.05;
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.4;
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // warm brushed-bronze frame (picks up the dusk env)
+  const frameMat = new THREE.MeshStandardMaterial({ color:0x2e251a, roughness:0.42, metalness:0.9, envMapIntensity:1.05 });
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0x1a1714, roughness:0.6, metalness:0.0, envMapIntensity:0.5 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
+      new THREE.MeshStandardMaterial({ color:0x12100d, roughness:0.82, metalness:0.05 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+  }
+
+  // -------------------------------------------------------------------------
+  // FLOOR REFLECTION — mirrored, dimmed clone of the featured board's face.
+  // -------------------------------------------------------------------------
+  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
+  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
+  reflMesh.scale.y = -1;
+  reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: featured board + window of neighbours
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace; else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);
+        tex.needsUpdate = true;
+        texCache.set(url, tex);
+        resolve(tex);
+      };
+      img.onerror = ()=>resolve(null);
+      img.src = url;
+    });
+  }
+
+  function ensureBoardTexture(b){
+    if(b.texLoaded || !b.data) return;
+    const url = imgURL(b.data.image || b.data.tile);
+    if(!url){ b.texLoaded = true; return; }
+    b.texLoaded = true;
+    loadTexture(url).then(tex=>{
+      if(!tex) return;
+      const m = new THREE.MeshPhysicalMaterial({
+        map:tex, roughness:0.56, metalness:0.0, clearcoat:0.12, clearcoatRoughness:0.5, envMapIntensity:0.45
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.abs(i-focusIdx);
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = 0;
+
+      // intimate-evening chiaroscuro: featured board sits in the warm pool, the
+      // rest recede into the dusk — but never fully black (penthouse, not noir).
+      const litTarget = 0.32 + 0.68*Math.pow(openAmt, 1.3);
+      if(b._lit===undefined) b._lit = litTarget;
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
+
+      const farOff = Math.abs(rel) > 7;
+      b.group.visible = !farOff;
+    }
+
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
+      spot.position.set(fx, 6.6, front.group.position.z + 5.2);
+      spot.intensity += ((HAS_PRODUCTS?9.0:0) - spot.intensity)*Math.min(1,dt*5);
+
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.20 - reflMat.opacity)*Math.min(1,dt*5);
+        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
+      } else {
+        reflMat.opacity += (0 - reflMat.opacity)*Math.min(1,dt*5);
+      }
+    }
+
+    // city bokeh twinkle (very cheap)
+    const tt = now*0.0007;
+    for(const s of BOKEH){
+      s.material.opacity = s.userData.base * (0.7 + 0.3*Math.sin(tt + s.userData.tw));
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__PD_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__PD__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__PD_FPS||0,
+      bokeh: ()=>BOKEH.length,
+      ready: true
+    };
+
+    finishBoot();
+    requestAnimationFrame(tick);
+  }
+
+  function finishBoot(){
+    const boot = document.getElementById("boot");
+    boot.style.opacity = "0";
+    setTimeout(()=>boot.remove(), 950);
+    const hint = document.getElementById("hint");
+    setTimeout(()=>{ hint.style.opacity="0"; }, 6000);
+  }
+
+  start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V20","label":"V20 — Penthouse Dusk","elements":[
+  {"n":1,"label":"High-rise penthouse — floor-to-ceiling dusk glass behind the bank"},
+  {"n":2,"label":"Dusk-gradient sky + soft city-bokeh lights beyond the windows"},
+  {"n":3,"label":"Warm interior key + cool dusk fill; intimate evening mood"},
+  {"n":4,"label":"Dark polished stone floor reflecting the warm interior + city glow"},
+  {"n":5,"label":"Click a board → carousel-shift to center → full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>

← c4480af High-end versions batch 2: V13 Atrium Daylight + V14 Velvet  ·  back to Quadrille Showroom  ·  Add AGE-VIEWS-SPEC.md — overseer plan for 10 age-adaptive av bdcbe78 →