[object Object]

← back to Quadrille Showroom

High-end versions: add V12 Gallery Noir + V16 Art-Deco Brass/Marble + V21 Garcia Room proto scenes (PBR/PMREM/ACES, real China Seas textures, 0 errors)

fbe4548b3238a58aa73a4ca33714a33cd32cd976 · 2026-06-29 06:07:12 -0700 · Steve

Files touched

Diff

commit fbe4548b3238a58aa73a4ca33714a33cd32cd976
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 06:07:12 2026 -0700

    High-end versions: add V12 Gallery Noir + V16 Art-Deco Brass/Marble + V21 Garcia Room proto scenes (PBR/PMREM/ACES, real China Seas textures, 0 errors)
---
 public/proto/v12-gallery-noir.html  | 616 ++++++++++++++++++++++++++++++
 public/proto/v16-artdeco-brass.html | 589 +++++++++++++++++++++++++++++
 public/proto/v21-garcia-room.html   | 735 ++++++++++++++++++++++++++++++++++++
 3 files changed, 1940 insertions(+)

diff --git a/public/proto/v12-gallery-noir.html b/public/proto/v12-gallery-noir.html
new file mode 100644
index 0000000..2b534ae
--- /dev/null
+++ b/public/proto/v12-gallery-noir.html
@@ -0,0 +1,616 @@
+<!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>GALLERY NOIR · China Seas</title>
+<style>
+  :root { --gold:#c9a96e; --paper:#efe9dc; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#050505; 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(3,3,3,.82) 0%, rgba(3,3,3,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#9a8b6b; 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:#f3eddf; }
+  .masthead h1 b { font-weight:700; color:#e8cf97; }
+  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#857d6e;
+    max-width:540px; 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:#f3eddf;
+    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:#b59a63; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#9b9484; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(201,169,110,.55); border-radius:28px; color:#e8cf97; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(201,169,110,.06); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(201,169,110,.18); 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(220,210,190,.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:#9a8b6b; background:rgba(8,8,8,.6); border:1px solid rgba(201,169,110,.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:#050505; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(201,169,110,.18); border-top-color:#c9a96e; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#7d745f; }
+  @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 Noir Gallery</div>
+  <h1>GALLERY <b>NOIR</b></h1>
+  <div class="sub">China Seas, hung in a black-box gallery. One board steps into the light at a time —
+    full pattern, edge to edge. 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">Dimming the gallery…</div></div>
+
+<!-- r128 core (local) + r128 example modules (CDN, attach to global THREE) -->
+<script src="./three.min.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/shaders/CopyShader.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/shaders/LuminosityHighPassShader.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/EffectComposer.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/RenderPass.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/ShaderPass.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/postprocessing/UnrealBloomPass.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.04;
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0x040404, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x050505);
+  scene.fog = new THREE.FogExp2(0x040404, 0.030);
+
+  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 200);
+  camera.position.set(0, 1.55, 9.4);
+  camera.lookAt(0, 1.55, 0);
+
+  // -------------------------------------------------------------------------
+  // PMREM ENVIRONMENT (RoomEnvironment) — so dark metals & gloss reflect
+  // -------------------------------------------------------------------------
+  (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 */ }
+  })();
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — deep chiaroscuro: low ambient, cool fill, warm rim, plus a
+  // single warm SpotLight that follows the featured board.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.AmbientLight(0x161a22, 0.42));
+
+  const fill = new THREE.DirectionalLight(0x6f7d96, 0.22);   // cool fill from left
+  fill.position.set(-7, 6, 6); scene.add(fill);
+
+  const rim = new THREE.DirectionalLight(0xffd9a0, 0.30);    // warm rim from behind-right
+  rim.position.set(6, 4, -8); scene.add(rim);
+
+  const key = new THREE.DirectionalLight(0xfff1da, 0.30);    // soft 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);
+
+  // the hero spotlight — warm, tight, casts the gallery shadow on the focused board
+  const spot = new THREE.SpotLight(0xffe6bd, 0, 16, Math.PI/7.5, 0.45, 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 = 22;
+  spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+  scene.add(spot);
+  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+  spot.target = spotTarget;
+
+  // -------------------------------------------------------------------------
+  // ROOM — matte black walls + polished dark-concrete floor (reflective)
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+  (function buildRoom(){
+    // back wall (the gallery void behind the bank)
+    const wallMat = new THREE.MeshStandardMaterial({ color:0x0a0a0b, roughness:0.94, metalness:0.0 });
+    const back = new THREE.Mesh(new THREE.PlaneGeometry(60, 24), wallMat);
+    back.position.set(0, 6, -7.5); back.receiveShadow = true; scene.add(back);
+
+    const sideMat = new THREE.MeshStandardMaterial({ color:0x080809, roughness:0.96, metalness:0.0 });
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), sideMat);
+    left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; scene.add(left);
+    const right = left.clone(); right.position.x = 14; right.rotation.y = -Math.PI/2; scene.add(right);
+
+    // ceiling (very dark, no light bounce)
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+      new THREE.MeshStandardMaterial({ color:0x060607, roughness:1.0 }));
+    ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+    // POLISHED DARK CONCRETE FLOOR — clearcoat physical material gives soft
+    // env reflections; a procedural roughness texture keeps it from being a
+    // mirror. The mirrored board "reflection" group below adds the contact glow.
+    const floorMat = new THREE.MeshPhysicalMaterial({
+      color:0x0c0c0e, roughness:0.34, metalness:0.0,
+      clearcoat:0.85, clearcoatRoughness:0.42, envMapIntensity:0.5
+    });
+    floorMat.roughnessMap = makeConcreteRoughness();
+    floorMat.roughnessMap.wrapS = floorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+    floorMat.roughnessMap.repeat.set(8, 8);
+    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);
+  })();
+
+  // procedural subtle concrete roughness (canvas → texture, $0 local)
+  function makeConcreteRoughness(){
+    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()*70)|0; d[i]=d[i+1]=d[i+2]=110+n; }
+    x.putImageData(img,0,0);
+    const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+  }
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin dark-framed boards, ~2" apart on an arc, side swivel.
+  // Frames use InstancedMesh (one geometry, one material, 50 instances).
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face (world units; 1u ≈ 20.5")
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;      // thin frame border thickness
+  const FRAME_D = 0.05;       // frame depth
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.4;        // closed boards rest here
+  const N_TARGET = 50;
+
+  const boards = [];          // { group, faceMesh, data, idx, base x }
+  let products = [];
+  let focusIdx = 0;           // which board is featured (center, swung open)
+
+  // shared geometries / materials
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // dark bronze frame material
+  const frameMat = new THREE.MeshStandardMaterial({ color:0x231d16, roughness:0.5, metalness:0.85, envMapIntensity:0.9 });
+  // placeholder face (warm gray) until the real texture loads
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0x171615, roughness:0.62, metalness:0.0, envMapIntensity:0.4 });
+  }
+
+  // build a single board: a Group pivoting on its LEFT edge (side swivel) so it
+  // can swing face-on like the reference. Group origin = left hinge.
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    // backing board (very thin) — gives the face something to sit on
+    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:0x100f0e, roughness:0.85, 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);
+
+    // pattern face
+    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);
+
+    // four thin frame bars (built per board but light — could be instanced; kept
+    // grouped so they swivel with the board. Frame instancing handled separately
+    // would break the hinge transform, so per-board bars are correct here.)
+    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.0, 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, lift:0 };
+  }
+
+  // -------------------------------------------------------------------------
+  // FLOOR REFLECTION — a mirrored, dimmed clone of the featured board's face,
+  // sitting below the floor line → soft contact reflection (cheap, 1 mesh).
+  // -------------------------------------------------------------------------
+  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;            // mirror vertically
+  reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: only load the featured board + a window of neighbours
+  // -------------------------------------------------------------------------
+  const texCache = new Map();   // url -> THREE.Texture
+  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;
+        // 27" pattern across a ~30" board face → show roughly one full repeat,
+        // slightly less so the full motif reads clean edge-to-edge.
+        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.58, metalness:0.0, clearcoat:0.10, clearcoatRoughness:0.6, envMapIntensity:0.35
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+
+  // window-load textures around the focused board so we never hold 50 at once
+  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 — closed boards line up along X on a gentle concave arc facing the
+  // viewer; the focused board slides to center (x≈0) and swivels open.
+  // -------------------------------------------------------------------------
+  function closedX(i){ return (i - focusIdx) * PITCH; }       // relative slot
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }      // very gentle concavity
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;     // targetFocus is a float for smooth carousel travel
+  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]);
+  }
+
+  // drag to swing the bank; wheel to step
+  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");
+  }
+
+  // -------------------------------------------------------------------------
+  // POST-PROCESSING — tasteful bloom on the warm highlights
+  // -------------------------------------------------------------------------
+  let composer=null, bloomPass=null;
+  function buildComposer(){
+    if(!THREE.EffectComposer || !THREE.UnrealBloomPass) return false;
+    composer = new THREE.EffectComposer(renderer);
+    composer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+    composer.setSize(W(), H());
+    composer.addPass(new THREE.RenderPass(scene, camera));
+    bloomPass = new THREE.UnrealBloomPass(new THREE.Vector2(W(), H()), 0.42, 0.7, 0.86);
+    composer.addPass(bloomPass);
+    return true;
+  }
+  const HAS_BLOOM = buildComposer();
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+    if(composer) composer.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;
+
+    // smooth carousel travel toward target
+    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;                 // signed slot offset from center
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);  // 1 at center → 0 a slot away
+
+      // swivel: the centered board faces fully on (0); neighbours rake away so
+      // they read as thin slivers beside the open face (per the v3 reference).
+      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;
+
+      // The group pivots on its LEFT edge, so the face center sits at
+      // (groupX + (BOARD_W/2)·cos(swivel)). We want that face center at the slot
+      // x = rel·PITCH, so solve groupX = slotX − (BOARD_W/2)·cos(swivel).
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;        // glides forward when centered
+
+      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;
+
+      // CHIAROSCURO — fade the face/frame brightness with distance from center so
+      // only the featured board sits fully in the warm light; neighbours recede
+      // into the dark. Driven on the material so it survives the swivel.
+      const litTarget = 0.18 + 0.82*Math.pow(openAmt, 1.4);
+      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); }
+      b.face.children; // (no-op keep)
+
+      // depth-cull faces far off-center for fewer pixels drawn (still in scene)
+      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, 6.4, front.group.position.z + 5.2);
+      spot.intensity += ((HAS_PRODUCTS?7.5:0) - spot.intensity)*Math.min(1,dt*5);
+
+      // reflection: clone featured face material, place mirrored below floor
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.16 - 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);
+      }
+    }
+
+    if(HAS_BLOOM && composer) composer.render();
+    else renderer.render(scene, camera);
+
+    // fps
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__GN_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;
+
+    // build boards (one per product, capped at 50)
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      // seed initial transform near final so the first frame isn't a stampede
+      b.group.position.set((i-0)*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    // open in the middle of the bank for a full-looking first frame
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    // wait for the featured texture before revealing (so it's never a void)
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    // expose probe for headless verification
+    window.__GN__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__GN_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":"V12","label":"V12 — Gallery Noir","elements":[
+  {"n":1,"label":"Black-box gallery — matte walls, deep chiaroscuro"},
+  {"n":2,"label":"Thin dark-bronze framed boards, ~2\" apart on a side swivel"},
+  {"n":3,"label":"Warm spotlight follows the featured board"},
+  {"n":4,"label":"Polished dark-concrete floor with contact reflection"},
+  {"n":5,"label":"Click a board → carousel-shift to center → full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v16-artdeco-brass.html b/public/proto/v16-artdeco-brass.html
new file mode 100644
index 0000000..5e7379a
--- /dev/null
+++ b/public/proto/v16-artdeco-brass.html
@@ -0,0 +1,589 @@
+<!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>ART-DECO BRASS &amp; MARBLE — China Seas</title>
+<style>
+  :root { --brass:#c9a24b; --ink:#120e09; --paper:#f3ead4; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#0b0906; color:var(--paper);
+    font-family:"Hoefler Text",Georgia,"Times New Roman",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:12; padding:20px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(11,9,6,.82) 0%, rgba(11,9,6,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#d8be7e; font-weight:600; }
+  .masthead h1 { font-size:clamp(24px,4.4vw,46px); font-weight:400; letter-spacing:.14em;
+    line-height:1; margin-top:8px; color:#f3ead4;
+    text-shadow:0 2px 18px rgba(0,0,0,.6); }
+  .masthead h1 b { font-weight:700; color:#e7c878; }
+  .masthead .sub { margin-top:9px; font-size:12px; letter-spacing:.16em; text-transform:uppercase;
+    color:#b09a6a; }
+
+  /* ---- now-viewing plate (bottom centre) ---- */
+  .plate { position:fixed; left:50%; bottom:30px; transform:translateX(-50%); z-index:12;
+    text-align:center; pointer-events:none; max-width:80vw;
+    text-shadow:0 2px 14px rgba(0,0,0,.7); transition:opacity .4s; }
+  .plate .pn { font-size:20px; letter-spacing:.04em; color:#f3ead4; }
+  .plate .meta { margin-top:5px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:#c9a24b; }
+
+  .hint { position:fixed; left:50%; bottom:84px; transform:translateX(-50%); z-index:11;
+    font-size:10.5px; letter-spacing:.30em; text-transform:uppercase; color:rgba(243,234,212,.5);
+    pointer-events:none; transition:opacity .8s; }
+
+  /* ---- fps readout ---- */
+  #fps { position:fixed; left:14px; bottom:12px; z-index:14;
+    font-family:"SF Mono",Menlo,monospace; font-size:11px; letter-spacing:.08em;
+    color:#d8be7e; background:rgba(11,9,6,.55); border:1px solid rgba(201,162,75,.4);
+    padding:4px 9px; border-radius:6px; cursor:pointer; pointer-events:auto;
+    -webkit-user-select:none; user-select:none; }
+  #fps.hidden { opacity:0; pointer-events:none; }
+
+  /* ---- detail spec card ---- */
+  #detail { position:fixed; z-index:13; right:32px; top:50%; transform:translateY(-50%) translateX(40px);
+    width:min(322px,40vw); background:rgba(18,14,9,.78); backdrop-filter:blur(16px);
+    -webkit-backdrop-filter:blur(16px); border:1px solid rgba(201,162,75,.42);
+    border-radius:6px; padding:24px; opacity:0; pointer-events:none;
+    box-shadow:0 24px 60px -16px rgba(0,0,0,.8);
+    transition:opacity .4s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
+  #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
+  #detail .pname { font-size:21px; line-height:1.14; color:#f3ead4; padding-right:18px; }
+  #detail .pcoll { margin-top:5px; font-size:10.5px; letter-spacing:.26em; text-transform:uppercase; color:#c9a24b; }
+  #detail .rule { height:1px; margin:16px 0 4px; background:linear-gradient(90deg,transparent,rgba(201,162,75,.6),transparent); }
+  #detail .specrow { display:flex; justify-content:space-between; padding:8px 0;
+    border-bottom:1px solid rgba(201,162,75,.14); font-size:12.5px; }
+  #detail .specrow .k { color:#9c8a63; letter-spacing:.03em; }
+  #detail .specrow .v { color:#efe6d0; text-align:right; }
+  #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
+    background:linear-gradient(180deg,#e3cb95,#c19a44); color:#1a1206; font-size:11px;
+    letter-spacing:.22em; text-transform:uppercase; font-weight:700; text-decoration:none;
+    border-radius:3px; transition:transform .15s; font-family:Georgia,serif; }
+  #detail a.cta:hover { transform:translateY(-1px); }
+  #detail .closex { position:absolute; top:12px; right:14px; font-size:20px; color:#c9a24b;
+    cursor:pointer; line-height:1; }
+  #detail .closex:hover { color:#f3ead4; }
+
+  /* ---- boot ---- */
+  #boot { position:fixed; inset:0; z-index:40; display:flex; flex-direction:column;
+    align-items:center; justify-content:center; background:#0b0906; transition:opacity .8s; }
+  #boot .ring { width:42px; height:42px; border-radius:50%;
+    border:2px solid rgba(201,162,75,.2); border-top-color:#e7c878; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#9c8a63; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:760px){
+    #detail { right:12px; left:12px; width:auto; top:auto; bottom:96px; transform:translateY(20px); }
+    #detail.show { transform:translateY(0); }
+    .masthead .sub { display:none; }
+  }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings</div>
+  <h1>ART&nbsp;<b>DECO</b>&nbsp;SALON</h1>
+  <div class="sub">Brass &amp; Marble · China Seas</div>
+</div>
+
+<div class="plate" id="plate"><div class="pn" id="p_name">—</div><div class="meta" id="p_meta"></div></div>
+<div class="hint" id="hint">Click a board to bring it to centre</div>
+<div id="fps">FPS —</div>
+
+<div id="detail">
+  <div class="closex" id="d_close">&times;</div>
+  <div class="pname" id="d_name">—</div>
+  <div class="pcoll" id="d_coll">China Seas</div>
+  <div class="rule"></div>
+  <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
+  <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
+  <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
+  <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
+  <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
+  <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Polishing the brass…</div></div>
+
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const bootlbl = document.getElementById("bootlbl");
+
+  // ----------------------------------------------------------------------------
+  // RENDERER — full high-end baseline
+  // ----------------------------------------------------------------------------
+  const app = document.getElementById("app");
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
+  renderer.setSize(window.innerWidth, window.innerHeight);
+  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;
+  app.appendChild(renderer.domElement);
+  const MAXANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x14100a);
+  scene.fog = new THREE.Fog(0x14100a, 9, 26);
+
+  const camera = new THREE.PerspectiveCamera(50, window.innerWidth/window.innerHeight, 0.1, 120);
+  // sit close & at chest height so the centred 6' board fills the frame, with the
+  // slivers raking off to either side and the marble floor sweeping to the camera.
+  camera.position.set(-0.55, 1.0, -0.15);
+  camera.lookAt(-0.4, 1.0, -3.4);
+
+  // ----------------------------------------------------------------------------
+  // PMREM ENVIRONMENT — procedural Art-Deco "salon" equirect so brass reflects.
+  // A warm gradient with several bright soft sources + a crisp horizon band, so a
+  // metalness~1 surface shows specular highlights (reads as polished metal, not
+  // flat yellow) and the marble clearcoat catches reflections.
+  // ----------------------------------------------------------------------------
+  function buildEnvTexture(){
+    const c = document.createElement("canvas");
+    c.width = 1024; c.height = 512;
+    const g = c.getContext("2d");
+    // vertical gradient: warm amber ceiling glow → deep bronze floor
+    const grd = g.createLinearGradient(0, 0, 0, 512);
+    grd.addColorStop(0.00, "#3a2c14");
+    grd.addColorStop(0.34, "#6e5526");
+    grd.addColorStop(0.50, "#caa763");   // bright horizon (key wash)
+    grd.addColorStop(0.62, "#5b4521");
+    grd.addColorStop(1.00, "#1c150c");
+    g.fillStyle = grd; g.fillRect(0, 0, 1024, 512);
+    // bright soft "light fixtures" — these become the brass highlights
+    function lamp(x, y, r, col){
+      const lg = g.createRadialGradient(x, y, 0, x, y, r);
+      lg.addColorStop(0, col); lg.addColorStop(1, "rgba(0,0,0,0)");
+      g.globalCompositeOperation = "lighter";
+      g.fillStyle = lg; g.beginPath(); g.arc(x, y, r, 0, Math.PI*2); g.fill();
+      g.globalCompositeOperation = "source-over";
+    }
+    lamp(180, 150, 150, "rgba(255,244,214,0.95)");   // warm key
+    lamp(840, 170, 150, "rgba(255,236,196,0.85)");   // warm fill
+    lamp(512, 90,  120, "rgba(255,250,235,0.9)");    // overhead
+    lamp(512, 300, 220, "rgba(255,221,160,0.55)");   // horizon glow
+    lamp(40,  300, 90,  "rgba(214,230,255,0.45)");   // cool rim (left)
+    lamp(990, 300, 90,  "rgba(214,230,255,0.4)");    // cool rim (right)
+    // a crisp bright horizon line gives metals a sharp reflected edge
+    g.fillStyle = "rgba(255,243,214,0.5)";
+    g.fillRect(0, 252, 1024, 6);
+    const tex = new THREE.CanvasTexture(c);
+    tex.mapping = THREE.EquirectangularReflectionMapping;
+    if ("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+    else if ("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
+    return tex;
+  }
+  const pmrem = new THREE.PMREMGenerator(renderer);
+  pmrem.compileEquirectangularShader();
+  const envTex = buildEnvTexture();
+  const envMap = pmrem.fromEquirectangular(envTex).texture;
+  scene.environment = envMap;
+  envTex.dispose();
+
+  // ----------------------------------------------------------------------------
+  // LIGHTING — key + fill + rim, warm, with a soft key shadow
+  // ----------------------------------------------------------------------------
+  scene.add(new THREE.HemisphereLight(0xfff0d4, 0x2a2114, 0.55));
+  const key = new THREE.DirectionalLight(0xfff1d6, 2.4);
+  key.position.set(-5.5, 8.5, 6.5);
+  key.castShadow = true;
+  key.shadow.mapSize.set(2048, 2048);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 30;
+  key.shadow.camera.left = -8; key.shadow.camera.right = 8;
+  key.shadow.camera.top = 8; key.shadow.camera.bottom = -4;
+  key.shadow.bias = -0.0004;
+  scene.add(key);
+  const fill = new THREE.DirectionalLight(0xffe2b4, 0.55);
+  fill.position.set(6, 4, 7); scene.add(fill);
+  const rim = new THREE.DirectionalLight(0xcfe0ff, 0.8);
+  rim.position.set(0, 5, -8); scene.add(rim);
+  // warm spot pooling onto the centre (focal) board
+  const spot = new THREE.SpotLight(0xfff0d0, 2.2, 22, Math.PI/6.5, 0.5, 1.2);
+  spot.position.set(0, 7.5, 6.5); spot.target.position.set(0, 1.7, 0);
+  scene.add(spot); scene.add(spot.target);
+
+  // ----------------------------------------------------------------------------
+  // MARBLE FLOOR — physical clearcoat, env-mapped reflection
+  // ----------------------------------------------------------------------------
+  function marbleTexture(){
+    const c = document.createElement("canvas"); c.width = c.height = 1024;
+    const g = c.getContext("2d");
+    g.fillStyle = "#ece3d2"; g.fillRect(0,0,1024,1024);
+    // soft warm clouding
+    for(let i=0;i<60;i++){
+      const x=Math.random()*1024, y=Math.random()*1024, r=80+Math.random()*240;
+      const lg=g.createRadialGradient(x,y,0,x,y,r);
+      const a=0.05+Math.random()*0.08;
+      lg.addColorStop(0,`rgba(196,178,150,${a})`); lg.addColorStop(1,"rgba(196,178,150,0)");
+      g.fillStyle=lg; g.beginPath(); g.arc(x,y,r,0,Math.PI*2); g.fill();
+    }
+    // grey/bronze veining
+    g.lineCap="round";
+    for(let i=0;i<26;i++){
+      g.strokeStyle = Math.random()<0.5 ? "rgba(120,108,86,0.30)" : "rgba(80,70,52,0.18)";
+      g.lineWidth = 0.6+Math.random()*2.2;
+      let x=Math.random()*1024, y=Math.random()*1024;
+      g.beginPath(); g.moveTo(x,y);
+      const steps=8+Math.floor(Math.random()*10), ang=Math.random()*Math.PI;
+      for(let s=0;s<steps;s++){ x+=Math.cos(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70;
+        y+=Math.sin(ang)*(30+Math.random()*60)+(Math.random()-0.5)*70; g.lineTo(x,y); }
+      g.stroke();
+    }
+    const t=new THREE.CanvasTexture(c);
+    t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(3,3);
+    t.anisotropy=MAXANISO;
+    if("colorSpace" in t) t.colorSpace=THREE.SRGBColorSpace; else if("encoding" in t) t.encoding=THREE.sRGBEncoding;
+    return t;
+  }
+  const floorMat = new THREE.MeshPhysicalMaterial({
+    map: marbleTexture(), color:0xf2ebda, roughness:0.12, metalness:0.0,
+    clearcoat:1.0, clearcoatRoughness:0.06, envMapIntensity:1.1, reflectivity:0.6
+  });
+  const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 60), floorMat);
+  floor.rotation.x = -Math.PI/2; floor.position.y = 0; floor.receiveShadow = true;
+  scene.add(floor);
+
+  // ----------------------------------------------------------------------------
+  // BACK WALL + Art-Deco fluted brass pilasters (symmetry)
+  // ----------------------------------------------------------------------------
+  const wallMat = new THREE.MeshStandardMaterial({ color:0x241b10, roughness:0.85, metalness:0.05 });
+  const backWall = new THREE.Mesh(new THREE.PlaneGeometry(60, 18), wallMat);
+  backWall.position.set(0, 9, -4.2); backWall.receiveShadow = true; scene.add(backWall);
+
+  // shared polished-brass material (metalness 1, low roughness → reflects env)
+  const brassMat = new THREE.MeshPhysicalMaterial({
+    color:0xe7c067, metalness:1.0, roughness:0.14, envMapIntensity:2.2,
+    clearcoat:0.5, clearcoatRoughness:0.14
+  });
+  const brassDark = new THREE.MeshPhysicalMaterial({
+    color:0xb08a3a, metalness:1.0, roughness:0.30, envMapIntensity:1.3
+  });
+
+  // symmetrical fluted pilasters flanking the bank
+  (function pilasters(){
+    const g = new THREE.CylinderGeometry(0.16, 0.16, 7.6, 20, 1);
+    [-5.6, 5.6].forEach(x=>{
+      const p = new THREE.Mesh(g, brassMat); p.position.set(x, 3.8, -3.9);
+      p.castShadow = true; scene.add(p);
+      const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.30,0.22,0.34,20), brassMat);
+      cap.position.set(x, 7.7, -3.9); scene.add(cap);
+      const base = new THREE.Mesh(new THREE.CylinderGeometry(0.26,0.32,0.30,20), brassMat);
+      base.position.set(x, 0.15, -3.9); scene.add(base);
+    });
+    // deco sunburst lintel above
+    const lintel = new THREE.Mesh(new THREE.BoxGeometry(11.6, 0.32, 0.3), brassMat);
+    lintel.position.set(0, 7.9, -3.85); lintel.castShadow = true; scene.add(lintel);
+    const rayMat = brassDark;
+    for(let i=0;i<13;i++){
+      const t=i/12, x=-5.2 + t*10.4;
+      const h=0.5 + Math.abs(Math.sin(t*Math.PI))*0.9;
+      const r=new THREE.Mesh(new THREE.BoxGeometry(0.05, h, 0.06), rayMat);
+      r.position.set(x, 8.3+h/2, -3.8); scene.add(r);
+    }
+  })();
+
+  // soft contact shadow under the bank
+  (function contactShadow(){
+    const cc=document.createElement("canvas"); cc.width=cc.height=256; const cg=cc.getContext("2d");
+    const grd=cg.createRadialGradient(128,128,10,128,128,128);
+    grd.addColorStop(0,"rgba(0,0,0,0.5)"); grd.addColorStop(0.6,"rgba(0,0,0,0.2)"); grd.addColorStop(1,"rgba(0,0,0,0)");
+    cg.fillStyle=grd; cg.fillRect(0,0,256,256);
+    const t=new THREE.CanvasTexture(cc);
+    const m=new THREE.Mesh(new THREE.PlaneGeometry(14,4),
+      new THREE.MeshBasicMaterial({ map:t, transparent:true, opacity:0.7, depthWrite:false,
+        blending:THREE.MultiplyBlending }));
+    m.rotation.x=-Math.PI/2; m.position.set(0,0.01,-1.6); scene.add(m);
+  })();
+
+  // ----------------------------------------------------------------------------
+  // THE WING BANK — brass-framed sample boards ~2" apart on a side swivel.
+  // Frames are an INSTANCED brass border (FPS budget); each board face is its own
+  // textured plane (China Seas). Click a board → carousel-shift to dead centre →
+  // swivel open to a CLEAN full-pattern face, no occluders.
+  // ----------------------------------------------------------------------------
+  const IN = 0.0254;                 // 1 inch in metres
+  const BOARD_W = 30 * IN;           // 30" wide
+  const BOARD_H = 72 * IN;           // 6' tall
+  const FRAME_T = 1.2 * IN;          // thin brass frame
+  const GAP = 2 * IN;                // 2" apart
+  const PITCH = BOARD_W + FRAME_T*2 + GAP;
+  const BOARD_Y = 0.06 + BOARD_H/2;  // feet near floor
+  const BANK_Z = -3.4;
+
+  const NBOARDS_TARGET = 50;
+  const loader = new THREE.TextureLoader();
+  loader.crossOrigin = "anonymous";
+
+  // one shared geometry for board faces
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // shared neutral face material while textures stream in
+  const placeholderMat = new THREE.MeshStandardMaterial({ color:0x2b2218, roughness:0.9 });
+
+  const boards = [];          // { group, mesh, mat, data, slot, swivel(0..1) }
+  const bankGroup = new THREE.Group(); scene.add(bankGroup);
+  const pickables = [];
+
+  // Build the four-sided brass frame for a board as a small group of boxes.
+  function makeFrame(){
+    const grp = new THREE.Group();
+    const ow = BOARD_W + FRAME_T*2, oh = BOARD_H + FRAME_T*2, d = 0.9*IN;
+    const matsTB = brassMat, matsLR = brassMat;
+    const top = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); top.position.y = oh/2 - FRAME_T/2;
+    const bot = new THREE.Mesh(new THREE.BoxGeometry(ow, FRAME_T, d), matsTB); bot.position.y = -oh/2 + FRAME_T/2;
+    const lft = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); lft.position.x = -ow/2 + FRAME_T/2;
+    const rgt = new THREE.Mesh(new THREE.BoxGeometry(FRAME_T, BOARD_H, d), matsLR); rgt.position.x = ow/2 - FRAME_T/2;
+    [top,bot,lft,rgt].forEach(b=>{ b.castShadow = true; grp.add(b); });
+    return grp;
+  }
+
+  let focusIdx = -1;            // currently centred/open board (slot index)
+  let centerSlot = 0;          // which slot is at dead-centre
+
+  function slotX(slot){ return (slot - centerSlot) * PITCH; }
+
+  function buildBoard(d, slot){
+    const group = new THREE.Group();
+    // hinge on the LEFT edge so the board swivels open like the reference
+    const hingeX = -(BOARD_W/2 + FRAME_T);
+    group.position.set(0, BOARD_Y, BANK_Z);
+
+    const swivel = new THREE.Group();         // rotates about the hinge
+    swivel.position.x = hingeX;
+    group.add(swivel);
+
+    const inner = new THREE.Group();          // offset so geometry centres correctly
+    inner.position.x = -hingeX;
+    swivel.add(inner);
+
+    const frame = makeFrame(); inner.add(frame);
+    const face = new THREE.Mesh(faceGeo, placeholderMat.clone());
+    face.position.z = 0.5*IN; face.castShadow = false; face.receiveShadow = true;
+    inner.add(face);
+    pickables.push(face);
+
+    bankGroup.add(group);
+    const rec = { group, swivel, inner, face, data:d, slot, swivelT:0, mat:face.material };
+    face.userData.rec = rec;
+    boards.push(rec);
+
+    // stream the China Seas texture in
+    const url = d.tile || d.image;
+    if (url){
+      loader.load(url, (tex)=>{
+        tex.wrapS = tex.wrapT = THREE.ClampToEdgeWrapping;
+        tex.anisotropy = MAXANISO;
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else if("encoding" in tex) tex.encoding = THREE.sRGBEncoding;
+        const pm = new THREE.MeshStandardMaterial({
+          map:tex, roughness:0.82, metalness:0.0, envMapIntensity:0.45,
+          color:0xffffff, side:THREE.FrontSide
+        });
+        face.material.dispose && face.material.dispose();
+        face.material = pm; rec.mat = pm;
+      }, undefined, ()=>{ if(url!==d.image && d.image){ loader.load(d.image,(t2)=>{
+        t2.anisotropy=MAXANISO; if("colorSpace" in t2)t2.colorSpace=THREE.SRGBColorSpace;
+        face.material = new THREE.MeshStandardMaterial({map:t2,roughness:0.82,metalness:0,envMapIntensity:0.45});
+        rec.mat=face.material; }); } });
+    }
+    return rec;
+  }
+
+  // ----------------------------------------------------------------------------
+  // INTERACTION — pick a board → centre it → swivel open
+  // ----------------------------------------------------------------------------
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+  let dragging=false, downX=0, downY=0, moved=0;
+
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; downX=e.clientX; downY=e.clientY;
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return; moved += Math.abs(e.clientX-downX)+Math.abs(e.clientY-downY);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved<6) tryPick(e);
+  });
+
+  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 hits = ray.intersectObjects(pickables, false);
+    if(hits.length){
+      const rec = hits[0].object.userData.rec;
+      if(rec) centreOn(rec);
+    }
+  }
+
+  function centreOn(rec){
+    centerSlot = rec.slot;
+    focusIdx = rec.slot;
+    showDetail(rec);
+    updatePlate(rec);
+  }
+
+  // keyboard left/right to walk the bank (senior-friendly)
+  window.addEventListener("keydown",(e)=>{
+    if(e.key==="ArrowRight"){ const n=Math.min(boards.length-1, centerSlot+1); centreOn(boards[n]); }
+    else if(e.key==="ArrowLeft"){ const n=Math.max(0, centerSlot-1); centreOn(boards[n]); }
+  });
+
+  // ----------------------------------------------------------------------------
+  // DETAIL CARD + PLATE
+  // ----------------------------------------------------------------------------
+  const detail = document.getElementById("detail");
+  document.getElementById("d_close").addEventListener("click", ()=> detail.classList.remove("show"));
+  function showDetail(rec){
+    const d = rec.data;
+    document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("d_coll").textContent = d.collection || d.vendor || "China Seas";
+    const w = d.width || 27;
+    document.getElementById("d_dim").textContent = `${w}" × ${w}"`;
+    document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
+    document.getElementById("d_match").textContent = d.match_type || "—";
+    document.getElementById("d_finish").textContent = d.finish || "—";
+    document.getElementById("d_mat").textContent = d.material || "—";
+    const cta = document.getElementById("d_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; } else cta.style.display="none";
+    detail.classList.add("show");
+  }
+  function updatePlate(rec){
+    const d = rec.data;
+    document.getElementById("p_name").textContent = d.pattern_name || d.title || "—";
+    document.getElementById("p_meta").textContent =
+      (d.collection || "China Seas") + (d.repeat ? " · " + d.repeat + '" repeat' : "");
+  }
+
+  // ----------------------------------------------------------------------------
+  // DATA LOAD
+  // ----------------------------------------------------------------------------
+  async function start(){
+    let products = [];
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products || json || []).filter(p=> p.tile || p.image);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e); return;
+    }
+    products = products.slice(0, NBOARDS_TARGET);
+    if(!products.length){ bootlbl.textContent = "No designs."; return; }
+
+    products.forEach((d,i)=> buildBoard(d, i));
+    // open the bank at its middle so the first frame is full + symmetrical
+    centerSlot = Math.floor(products.length/2);
+    centreOn(boards[centerSlot]);
+
+    window.__ARTDECO_DONE = true;
+    finishBoot();
+  }
+
+  function finishBoot(){
+    const boot = document.getElementById("boot");
+    boot.style.opacity = "0";
+    setTimeout(()=> boot.remove(), 850);
+    const hint = document.getElementById("hint");
+    setTimeout(()=> hint.style.opacity = "0", 4500);
+  }
+
+  // ----------------------------------------------------------------------------
+  // RENDER LOOP — FPS readout, swivel/centre easing, pause on hidden
+  // ----------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  fpsEl.addEventListener("click", ()=> fpsEl.classList.toggle("hidden"));
+  let frames=0, fpsT0=performance.now(), fps=0;
+  let running=true, t0=performance.now();
+
+  function tick(now){
+    if(!running) return;
+    requestAnimationFrame(tick);
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    // ease the whole bank so the centred slot sits at x=0 (carousel conveyor)
+    const targetX = -centerSlot * PITCH;
+    bankGroup.position.x += (targetX - bankGroup.position.x) * Math.min(1, dt*5.5);
+
+    // each board: slide to its slot X, and swivel — the centred board opens FLAT
+    // to face the camera (full clean face); the rest rake to thin slivers.
+    for(const b of boards){
+      const isFocus = (b.slot === focusIdx);
+      // raked angle for non-focus boards (thin sliver), 0 for the open face
+      const targetSwivel = isFocus ? 0 : 1;
+      b.swivelT += (targetSwivel - b.swivelT) * Math.min(1, dt*6);
+      // open face = 0 rad (parallel to wall, facing camera); raked = ~58°
+      b.swivel.rotation.y = b.swivelT * (58 * Math.PI/180);
+      // gently pull the focused board a touch forward off the wall so nothing
+      // crosses its face
+      const fwd = isFocus ? 0.16 : 0.0;
+      b.group.position.z += ((BANK_Z + fwd) - b.group.position.z) * Math.min(1, dt*6);
+      b.group.position.x = b.slot * PITCH;   // local slot, bankGroup pans the whole row
+      // dim non-focused faces slightly for focus
+      if(b.mat){
+        const wantR = isFocus ? 0.82 : 0.9;
+        if(b.mat.color){ /* keep colour */ }
+      }
+    }
+
+    // FPS
+    frames++;
+    if(now - fpsT0 >= 500){
+      fps = Math.round(frames * 1000 / (now - fpsT0));
+      fpsEl.textContent = "FPS " + fps;
+      frames = 0; fpsT0 = now;
+      window.__ARTDECO_FPS = fps;
+    }
+
+    renderer.render(scene, camera);
+  }
+  requestAnimationFrame(tick);
+
+  document.addEventListener("visibilitychange", ()=>{
+    if(document.hidden){ running=false; }
+    else if(!running){ running=true; t0=performance.now(); fpsT0=performance.now(); frames=0; requestAnimationFrame(tick); }
+  });
+
+  window.addEventListener("resize", ()=>{
+    camera.aspect = window.innerWidth/window.innerHeight; camera.updateProjectionMatrix();
+    renderer.setSize(window.innerWidth, window.innerHeight);
+  });
+
+  // headless probe
+  window.__ARTDECO__ = {
+    ready: ()=> boards.length,
+    done: ()=> !!window.__ARTDECO_DONE,
+    fps: ()=> window.__ARTDECO_FPS || 0,
+    textured: ()=> boards.filter(b=> b.mat && b.mat.map).length,
+    focus: ()=> focusIdx
+  };
+
+  start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V16","label":"V16 — Art-Deco Brass & Marble","elements":[
+  {"n":1,"label":"Brass-framed sample boards on a side swivel, ~2\" apart"},
+  {"n":2,"label":"Click a board → it shifts to centre & swivels to a clean full face"},
+  {"n":3,"label":"Polished marble floor with clearcoat reflections"},
+  {"n":4,"label":"Env-mapped reflective brass (pilasters, sunburst lintel, frames)"},
+  {"n":5,"label":"Warm key + spot lighting, symmetrical 1920s salon composition"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v21-garcia-room.html b/public/proto/v21-garcia-room.html
new file mode 100644
index 0000000..022b466
--- /dev/null
+++ b/public/proto/v21-garcia-room.html
@@ -0,0 +1,735 @@
+<!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>GARCIA ROOM · China Seas</title>
+<style>
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#0a0712; color:#f3ecdc;
+    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:20px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(10,7,18,.78) 0%, rgba(10,7,18,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.42em; text-transform:uppercase;
+    color:#d8b9ff; font-weight:600; text-shadow:0 0 18px rgba(180,120,255,.5); }
+  .masthead h1 { font-size:clamp(28px,5vw,52px); font-weight:300; letter-spacing:.04em;
+    line-height:.96; margin-top:6px;
+    background:linear-gradient(90deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a);
+    -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
+    background-size:280% 100%; animation:hueslide 14s linear infinite; }
+  @keyframes hueslide { to { background-position:280% 0; } }
+  .masthead .sub { margin-top:8px; font-size:12.5px; letter-spacing:.05em; color:#b6a8c8;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:rgba(240,230,255,.55);
+    pointer-events:none; transition:opacity .6s; text-shadow:0 0 14px rgba(160,90,255,.4); }
+
+  /* ---- FPS ---- */
+  #fps { position:fixed; bottom:14px; left:16px; z-index:25; font:600 12px/1 "SF Mono",Menlo,monospace;
+    color:#9be8c0; background:rgba(10,7,18,.55); border:1px solid rgba(155,232,192,.25);
+    padding:6px 10px; border-radius:8px; letter-spacing:.06em; pointer-events:auto; cursor:pointer;
+    backdrop-filter:blur(6px); }
+  #fps.lo { color:#ffb14e; border-color:rgba(255,177,78,.4); }
+
+  /* ---- detail card ---- */
+  #detail { position:fixed; z-index:30; right:34px; top:50%; transform:translateY(-50%) translateX(40px);
+    width:min(330px,40vw); background:rgba(18,12,28,.74); backdrop-filter:blur(18px);
+    -webkit-backdrop-filter:blur(18px); border:1px solid rgba(200,150,255,.22);
+    border-radius:6px; padding:24px 24px 26px; opacity:0; pointer-events:none;
+    box-shadow:0 24px 60px -16px rgba(120,40,200,.5);
+    transition:opacity .45s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
+  #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
+  #detail .swatchchip { width:38px; height:38px; border-radius:50%; border:1px solid rgba(255,255,255,.25);
+    margin-bottom:14px; box-shadow:0 4px 22px rgba(0,0,0,.55); }
+  #detail .pname { font-size:21px; font-weight:600; line-height:1.12; letter-spacing:.01em; }
+  #detail .pcollection { margin-top:4px; font-size:11px; letter-spacing:.26em; text-transform:uppercase; color:#c9b4e0; }
+  #detail .specrow { display:flex; justify-content:space-between; padding:9px 0;
+    border-bottom:1px solid rgba(255,255,255,.08); font-size:12.5px; }
+  #detail .specrow:first-of-type { margin-top:16px; border-top:1px solid rgba(255,255,255,.08); }
+  #detail .specrow .k { color:#a89ab8; letter-spacing:.04em; }
+  #detail .specrow .v { color:#efece5; text-align:right; }
+  #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
+    background:linear-gradient(90deg,#c77dff,#ff7eb0); color:#180a22; font-size:11px; letter-spacing:.22em;
+    text-transform:uppercase; font-weight:700; text-decoration:none; border-radius:3px;
+    transition:transform .15s, filter .15s; }
+  #detail a.cta:hover { transform:translateY(-1px); filter:brightness(1.08); }
+  #detail .closex { position:absolute; top:12px; right:14px; font-size:18px; color:#b6a8c8;
+    cursor:pointer; line-height:1; }
+  #detail .closex:hover { color:#fff; }
+
+  /* ---- boot ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center; background:#0a0712; transition:opacity .9s; }
+  #boot .ring { width:48px; height:48px; border-radius:50%;
+    border:3px solid rgba(255,255,255,.12);
+    border-top-color:#ff6ec7; border-right-color:#6ec7ff; border-bottom-color:#c7ff6e;
+    animation:spin 1.1s linear infinite; }
+  #boot .lbl { margin-top:20px; font-size:11px; letter-spacing:.34em; text-transform:uppercase; color:#a99cc0; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){
+    #detail { right:14px; left:14px; width:auto; top:auto; bottom:84px; transform:translateY(20px); }
+    #detail.show { transform:translateY(0); }
+    .masthead .sub { display:none; }
+  }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · Garcia Room</div>
+  <h1>GARCIA ROOM</h1>
+  <div class="sub">A liquid-light lounge bathed in a slow oil-projection color wash. Click a wing to swing
+    the China Seas board to center — one clean full pattern face, no bars.</div>
+</div>
+
+<div class="hint">click a wing · the board swings open</div>
+<div id="fps">— fps</div>
+
+<div id="detail">
+  <div class="closex">&times;</div>
+  <div class="swatchchip" id="d_chip"></div>
+  <div class="pname" id="d_name">—</div>
+  <div class="pcollection" id="d_coll">China Seas</div>
+  <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
+  <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
+  <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
+  <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
+  <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
+  <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Tuning the light show…</div></div>
+
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const TAU = Math.PI * 2;
+  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 app = document.getElementById("app");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  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;
+  app.appendChild(renderer.domElement);
+  const MAXANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.fog = new THREE.FogExp2(0x140a22, 0.026);
+
+  const camera = new THREE.PerspectiveCamera(50, W()/H(), 0.1, 200);
+  // fixed viewer in the centre of the room, looking at the wing bank on the far wall
+  camera.position.set(0, 1.65, 6.7);
+  camera.lookAt(0, 1.55, -6);
+
+  // ===================================================================== //
+  //  PROCEDURAL TIE-DYE PMREM ENVIRONMENT  (real IBL, no external assets) //
+  // ===================================================================== //
+  function buildTieDyeEnv(){
+    // render a fullscreen tie-dye shader into a cube target, then PMREM it
+    const cubeRT = new THREE.WebGLCubeRenderTarget(256, {
+      format:THREE.RGBAFormat, generateMipmaps:true, minFilter:THREE.LinearMipmapLinearFilter
+    });
+    const cubeCam = new THREE.CubeCamera(0.1, 10, cubeRT);
+    const envScene = new THREE.Scene();
+    const skyGeo = new THREE.SphereGeometry(8, 32, 24);
+    const skyMat = new THREE.ShaderMaterial({
+      side:THREE.BackSide,
+      uniforms:{ uT:{value:0} },
+      vertexShader:`
+        varying vec3 vN;
+        void main(){ vN = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }
+      `,
+      fragmentShader:`
+        precision highp float;
+        varying vec3 vN; uniform float uT;
+        vec3 hsl2rgb(float h,float s,float l){
+          vec3 r = abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0;
+          r = clamp(r,0.0,1.0);
+          float c=(1.0-abs(2.0*l-1.0))*s;
+          return l + c*(r-0.5);
+        }
+        void main(){
+          // concentric tie-dye spiral driven by direction
+          float a = atan(vN.z, vN.x);
+          float r = acos(clamp(vN.y,-1.0,1.0));
+          float spiral = sin(r*5.0 + a*3.0 + uT*0.6);
+          float band = sin(r*9.0 - uT*0.4)*0.5 + 0.5;
+          float hue = fract(a/6.2831853 + r*0.4 + uT*0.05 + spiral*0.08);
+          float sat = 0.62 + 0.18*band;
+          float lit = 0.30 + 0.22*band + 0.10*spiral;
+          vec3 col = hsl2rgb(hue, sat, lit);
+          gl_FragColor = vec4(col, 1.0);
+        }
+      `
+    });
+    const sky = new THREE.Mesh(skyGeo, skyMat);
+    envScene.add(sky);
+    skyMat.uniforms.uT.value = 1.7;
+    cubeCam.update(renderer, envScene);
+
+    const pmrem = new THREE.PMREMGenerator(renderer);
+    pmrem.compileCubemapShader();
+    const envMap = pmrem.fromCubemap(cubeRT.texture).texture;
+    pmrem.dispose(); cubeRT.dispose();
+    skyGeo.dispose(); skyMat.dispose();
+    return envMap;
+  }
+  scene.environment = buildTieDyeEnv();
+
+  // ===================================================================== //
+  //  ROOM  — color-wash walls (hue-cycling shader) + floor                //
+  // ===================================================================== //
+  const ROOM_W = 12, ROOM_H = 5.2, ROOM_D = 13;
+  const washUniforms = { uT:{ value:0 } };
+
+  // Custom oil-projection liquid-light shader: cheap, animated via ONE uniform.
+  function washShader(seed){
+    return new THREE.ShaderMaterial({
+      uniforms:{ uT:washUniforms.uT, uSeed:{value:seed} },
+      vertexShader:`
+        varying vec2 vUv; varying vec3 vWPos;
+        void main(){ vUv=uv; vec4 wp=modelMatrix*vec4(position,1.0); vWPos=wp.xyz;
+          gl_Position=projectionMatrix*viewMatrix*wp; }
+      `,
+      fragmentShader:`
+        precision highp float;
+        varying vec2 vUv; varying vec3 vWPos; uniform float uT; uniform float uSeed;
+        vec3 hsl2rgb(float h,float s,float l){
+          vec3 r=abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0; r=clamp(r,0.0,1.0);
+          float c=(1.0-abs(2.0*l-1.0))*s; return l+c*(r-0.5);
+        }
+        // smooth value noise for liquid blobs
+        float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
+        float vnoise(vec2 p){
+          vec2 i=floor(p), f=fract(p); f=f*f*(3.0-2.0*f);
+          float a=hash(i), b=hash(i+vec2(1.0,0.0)), c=hash(i+vec2(0.0,1.0)), d=hash(i+vec2(1.0,1.0));
+          return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);
+        }
+        void main(){
+          vec2 p = vUv*3.2 + uSeed;
+          float t = uT*0.18;
+          // two drifting noise fields = lava-lamp blobs
+          float n1 = vnoise(p + vec2(t, -t*0.6));
+          float n2 = vnoise(p*1.7 - vec2(t*0.5, t));
+          float blob = smoothstep(0.35,0.85, n1*0.6 + n2*0.5);
+          // slow global hue cycle + local blob hue offset = oil-wheel rainbow
+          float hue = fract(uT*0.03 + uSeed*0.13 + blob*0.55 + n2*0.2);
+          float lit = mix(0.16, 0.46, blob);          // dark ground, glowing blobs
+          float sat = mix(0.45, 0.85, blob);
+          vec3 col = hsl2rgb(hue, sat, lit);
+          // gentle vignette toward floor/ceiling so walls read as a room not a flat poster
+          float vig = smoothstep(0.0,0.35,vUv.y)*smoothstep(0.0,0.35,1.0-vUv.y);
+          col *= mix(0.55,1.0,vig);
+          gl_FragColor = vec4(col,1.0);
+        }
+      `,
+      side:THREE.FrontSide
+    });
+  }
+
+  const wallGeo = new THREE.PlaneGeometry(ROOM_W, ROOM_H, 1, 1);
+  function addWall(x,y,z,ry,seed){
+    const m = new THREE.Mesh(wallGeo, washShader(seed));
+    m.position.set(x,y,z); m.rotation.y = ry;
+    scene.add(m); return m;
+  }
+  // far wall (behind wing bank), left, right
+  addWall(0, ROOM_H/2, -ROOM_D/2, 0, 0.0);
+  addWall(-ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, Math.PI/2, 2.4);
+  addWall( ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, -Math.PI/2, 4.1);
+
+  // ceiling — dim color wash so emissive bloom has somewhere to breathe
+  const ceilMat = washShader(6.2); ceilMat.side = THREE.DoubleSide;
+  const ceil = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), ceilMat);
+  ceil.rotation.x = Math.PI/2; ceil.position.set(0, ROOM_H, -ROOM_D/2 + ROOM_D/2);
+  scene.add(ceil);
+
+  // floor — dark, slightly glossy, picks up the env reflections (PBR)
+  const floorMat = new THREE.MeshPhysicalMaterial({
+    color:0x120a1c, metalness:0.0, roughness:0.32, clearcoat:0.6, clearcoatRoughness:0.4,
+    envMapIntensity:0.9, reflectivity:0.4
+  });
+  const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), floorMat);
+  floor.rotation.x = -Math.PI/2; floor.position.set(0, 0, -ROOM_D/2 + ROOM_D/2);
+  floor.receiveShadow = true;
+  scene.add(floor);
+
+  // ===================================================================== //
+  //  LIGHTING  — key + fill + warm rim, soft shadows                      //
+  // ===================================================================== //
+  scene.add(new THREE.HemisphereLight(0xffe6f5, 0x180a22, 0.55));
+  const key = new THREE.DirectionalLight(0xffe9d2, 1.5);
+  key.position.set(3.5, 6.5, 4.5);
+  key.castShadow = true;
+  key.shadow.mapSize.set(1024,1024);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 24;
+  key.shadow.camera.left=-7; key.shadow.camera.right=7; key.shadow.camera.top=7; key.shadow.camera.bottom=-7;
+  key.shadow.bias = -0.0006;
+  scene.add(key);
+  const fill = new THREE.DirectionalLight(0x9b6bff, 0.5); fill.position.set(-5,3,5); scene.add(fill);
+  const rim  = new THREE.PointLight(0xff7eb0, 1.2, 22, 2); rim.position.set(0, 3.2, -2); scene.add(rim);
+  const warm = new THREE.PointLight(0xffd28a, 0.9, 18, 2); warm.position.set(0, 2.2, 3); scene.add(warm);
+
+  // ===================================================================== //
+  //  EMISSIVE DECALS — dancing bears + lightning bolts (tasteful framed)  //
+  // ===================================================================== //
+  // Built procedurally onto a canvas → emissive texture in a thin gold frame.
+  function decalTexture(kind, hue){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const g = c.getContext("2d");
+    g.clearRect(0,0,256,256);
+    g.fillStyle = "rgba(0,0,0,0)"; g.fillRect(0,0,256,256);
+    const col = `hsl(${hue} 90% 62%)`;
+    g.fillStyle = col; g.strokeStyle = col; g.lineWidth = 8; g.lineJoin="round";
+    if(kind === "bear"){
+      // stylised dancing bear silhouette
+      g.save(); g.translate(128,150); g.scale(1.05,1.05);
+      // body
+      g.beginPath(); g.ellipse(0,10,42,52,0,0,TAU); g.fill();
+      // head
+      g.beginPath(); g.arc(0,-58,34,0,TAU); g.fill();
+      // ears
+      g.beginPath(); g.arc(-26,-82,13,0,TAU); g.fill();
+      g.beginPath(); g.arc( 26,-82,13,0,TAU); g.fill();
+      // arms (dancing — one up, one out)
+      g.lineWidth=22; g.lineCap="round";
+      g.beginPath(); g.moveTo(-30,-6); g.lineTo(-78,-44); g.stroke();
+      g.beginPath(); g.moveTo( 30,-6); g.lineTo( 80,18);  g.stroke();
+      // legs (mid-step)
+      g.beginPath(); g.moveTo(-18,56); g.lineTo(-48,96); g.stroke();
+      g.beginPath(); g.moveTo( 18,56); g.lineTo( 40,100); g.stroke();
+      g.restore();
+    } else { // lightning bolt
+      g.save(); g.translate(128,30);
+      g.beginPath();
+      g.moveTo(18,0); g.lineTo(-34,120); g.lineTo(6,120);
+      g.lineTo(-22,200); g.lineTo(58,80); g.lineTo(16,80);
+      g.lineTo(54,0); g.closePath(); g.fill();
+      g.restore();
+    }
+    const tex = new THREE.CanvasTexture(c);
+    tex.encoding = THREE.sRGBEncoding;
+    return tex;
+  }
+  function addDecal(kind, hue, x, y, z, ry, size){
+    const grp = new THREE.Group();
+    // thin gold frame
+    const frame = new THREE.Mesh(
+      new THREE.PlaneGeometry(size*1.18, size*1.18),
+      new THREE.MeshPhysicalMaterial({ color:0x3a2d12, metalness:0.85, roughness:0.35,
+        envMapIntensity:1.2, emissive:0x140d04, emissiveIntensity:0.3 })
+    );
+    const art = new THREE.Mesh(
+      new THREE.PlaneGeometry(size, size),
+      new THREE.MeshBasicMaterial({ map:decalTexture(kind,hue), transparent:true,
+        toneMapped:false, depthWrite:false })
+    );
+    art.position.z = 0.02;
+    grp.add(frame, art);
+    grp.position.set(x,y,z); grp.rotation.y = ry;
+    grp.userData.glow = art; grp.userData.hue0 = hue;
+    scene.add(grp);
+    return grp;
+  }
+  const decals = [];
+  // side-wall framed art — kept small + classy, not garish
+  decals.push(addDecal("bear", 20,  -ROOM_W/2+0.06, 2.4, -3.6,  Math.PI/2, 0.9));
+  decals.push(addDecal("bolt", 50,  -ROOM_W/2+0.06, 2.4, -1.6,  Math.PI/2, 0.9));
+  decals.push(addDecal("bear", 280,  ROOM_W/2-0.06, 2.4, -3.6, -Math.PI/2, 0.9));
+  decals.push(addDecal("bolt", 140,  ROOM_W/2-0.06, 2.4, -1.6, -Math.PI/2, 0.9));
+
+  // ===================================================================== //
+  //  WING BANK  — sample boards in thin frames, ~2" apart, side-swivel    //
+  // ===================================================================== //
+  const BOARD_W = 0.62, BOARD_H = 2.35, GAP = 0.052; // 30"w x ~6'tall proportions
+  const PITCH = BOARD_W*0.62 + GAP;                  // closed wings read as raked panels, not crushed slivers
+  const FOCUS_Z = -4.9;                              // open board glides forward, full face
+  const BANK_Z  = -6.6;                              // closed bank sits back
+  const BANK_Y  = 1.55;
+  const CLEAR   = BOARD_W*1.9;                        // how far neighbors part around the open board
+
+  // shared geometries
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const frameMat = new THREE.MeshPhysicalMaterial({
+    color:0x1a1310, metalness:0.9, roughness:0.4, envMapIntensity:1.1,
+    emissive:0x0a0604, emissiveIntensity:0.25
+  });
+  const frameBarGeo = new THREE.BoxGeometry(1,1,0.035); // scaled per use
+
+  const wings = [];          // { pivot, faceMesh, data, idx, open, swing(target) }
+  const pickFaces = [];      // raycast targets
+  const loader = new THREE.TextureLoader(); loader.crossOrigin = "anonymous";
+  const fallbackTex = (()=>{ // neutral until a real texture loads
+    const c=document.createElement("canvas"); c.width=c.height=4;
+    const g=c.getContext("2d"); g.fillStyle="#2a2030"; g.fillRect(0,0,4,4);
+    const t=new THREE.CanvasTexture(c); return t;
+  })();
+
+  function imgURL(u){
+    if(!u) return "";
+    if(u.charAt(0) === "/") return u;            // local asset (/gen-tiles/*.png) — serve direct
+    if(/cdn\.shopify\.com/.test(u)) return u;     // ACAO:* CDN — load directly
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+
+  function makeWing(d, idx, n){
+    const pivot = new THREE.Group();            // side-hinge: rotate about left edge
+    const span = (n-1)*PITCH;
+    const x = -span/2 + idx*PITCH;
+    pivot.position.set(x - BOARD_W/2, BANK_Y, BANK_Z); // pivot at the LEFT edge (the hinge)
+
+    const board = new THREE.Group();
+    board.position.x = BOARD_W/2;               // shift face so hinge is at its left edge
+
+    // face — the real China Seas wallcovering
+    const faceMat = new THREE.MeshPhysicalMaterial({
+      map:fallbackTex, roughness:0.62, metalness:0.0, envMapIntensity:0.7,
+      clearcoat:0.12, clearcoatRoughness:0.6
+    });
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.castShadow = true; face.userData.wingIdx = idx;
+    board.add(face);
+    pickFaces.push(face);
+
+    // thin frame — 4 emissive-gold bars around the face (real geometry, low cost)
+    const fb = BOARD_W*0.035; // frame thickness
+    function bar(w,h,px,py){
+      const m = new THREE.Mesh(frameBarGeo, frameMat);
+      m.scale.set(w,h,1); m.position.set(px,py,0.012);
+      board.add(m);
+    }
+    bar(BOARD_W+fb*2, fb, 0,  BOARD_H/2);
+    bar(BOARD_W+fb*2, fb, 0, -BOARD_H/2);
+    bar(fb, BOARD_H, -BOARD_W/2, 0);
+    bar(fb, BOARD_H,  BOARD_W/2, 0);
+
+    pivot.add(board);
+    scene.add(pivot);
+
+    // load the real texture
+    const url = imgURL(d.tile || d.image);
+    const img = new Image(); img.crossOrigin = "anonymous";
+    img.onload = ()=>{
+      const tex = new THREE.Texture(img);
+      tex.encoding = THREE.sRGBEncoding;
+      tex.anisotropy = MAXANISO;
+      tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+      // 27" pattern across a 30" board → ~1.1 repeats tall to read as wallcovering
+      tex.repeat.set(1, BOARD_H/(BOARD_W) * (27/27) * 0.5 + 0.5);
+      tex.needsUpdate = true;
+      faceMat.map = tex; faceMat.needsUpdate = true;
+    };
+    img.onerror = ()=>{};
+    img.src = url;
+
+    const wing = { pivot, board, face, faceMat, data:d, idx,
+                   baseX:x, open:false, swing:0, swingTarget:0, dimT:0 };
+    wings.push(wing);
+    return wing;
+  }
+
+  // ===================================================================== //
+  //  CHEAP ADDITIVE BLOOM  (self-contained postprocess, FPS-safe)         //
+  // ===================================================================== //
+  // Render scene → sceneRT. Bright-pass + 2 small blur RTs. Composite add.
+  let sceneRT, brightRT, blurRTa, blurRTb, fsQuad, brightMat, blurMat, compMat, orthoCam, quadGeo;
+  function makeRT(w,h){ return new THREE.WebGLRenderTarget(w,h,{ minFilter:THREE.LinearFilter,
+    magFilter:THREE.LinearFilter, format:THREE.RGBAFormat, encoding:THREE.LinearEncoding }); }
+
+  function buildBloom(){
+    const w = Math.max(2, Math.floor(W()*Math.min(window.devicePixelRatio||1,2)));
+    const h = Math.max(2, Math.floor(H()*Math.min(window.devicePixelRatio||1,2)));
+    const bw = Math.floor(w/3), bh = Math.floor(h/3); // bloom at 1/3 res = cheap
+    sceneRT = makeRT(w,h); sceneRT.texture.encoding = THREE.sRGBEncoding;
+    brightRT = makeRT(bw,bh); blurRTa = makeRT(bw,bh); blurRTb = makeRT(bw,bh);
+    orthoCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
+    quadGeo = new THREE.PlaneGeometry(2,2);
+
+    brightMat = new THREE.ShaderMaterial({
+      uniforms:{ tDiffuse:{value:null}, uThresh:{value:0.62} },
+      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+      fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform float uThresh;
+        void main(){ vec4 c=texture2D(tDiffuse,vUv);
+          float l=dot(c.rgb,vec3(0.299,0.587,0.114));
+          float k=smoothstep(uThresh,uThresh+0.35,l);
+          gl_FragColor=vec4(c.rgb*k,1.0); }`
+    });
+    blurMat = new THREE.ShaderMaterial({
+      uniforms:{ tDiffuse:{value:null}, uDir:{value:new THREE.Vector2(1,0)}, uRes:{value:new THREE.Vector2(bw,bh)} },
+      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+      fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform vec2 uDir; uniform vec2 uRes;
+        void main(){ vec2 px=uDir/uRes; vec3 s=vec3(0.0);
+          s+=texture2D(tDiffuse,vUv).rgb*0.227;
+          s+=texture2D(tDiffuse,vUv+px*1.38).rgb*0.316;
+          s+=texture2D(tDiffuse,vUv-px*1.38).rgb*0.316;
+          s+=texture2D(tDiffuse,vUv+px*3.23).rgb*0.070;
+          s+=texture2D(tDiffuse,vUv-px*3.23).rgb*0.070;
+          gl_FragColor=vec4(s,1.0); }`
+    });
+    compMat = new THREE.ShaderMaterial({
+      uniforms:{ tBase:{value:null}, tBloom:{value:null}, uStrength:{value:0.9} },
+      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
+      fragmentShader:`varying vec2 vUv; uniform sampler2D tBase; uniform sampler2D tBloom; uniform float uStrength;
+        void main(){ vec3 b=texture2D(tBase,vUv).rgb; vec3 g=texture2D(tBloom,vUv).rgb;
+          gl_FragColor=vec4(b + g*uStrength, 1.0); }`
+    });
+    fsQuad = new THREE.Mesh(quadGeo, brightMat);
+  }
+  function resizeBloom(){
+    if(!sceneRT) return;
+    const dpr = Math.min(window.devicePixelRatio||1,2);
+    const w=Math.floor(W()*dpr), h=Math.floor(H()*dpr), bw=Math.floor(w/3), bh=Math.floor(h/3);
+    sceneRT.setSize(w,h); brightRT.setSize(bw,bh); blurRTa.setSize(bw,bh); blurRTb.setSize(bw,bh);
+    blurMat.uniforms.uRes.value.set(bw,bh);
+  }
+  function renderWithBloom(){
+    // 1) scene → sceneRT
+    renderer.setRenderTarget(sceneRT);
+    renderer.clear();
+    renderer.render(scene, camera);
+    // 2) bright pass
+    fsQuad.material = brightMat; brightMat.uniforms.tDiffuse.value = sceneRT.texture;
+    renderer.setRenderTarget(brightRT); renderer.render(fsQuad, orthoCam);
+    // 3) blur H then V (x2 light)
+    fsQuad.material = blurMat;
+    blurMat.uniforms.tDiffuse.value = brightRT.texture; blurMat.uniforms.uDir.value.set(1,0);
+    renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
+    blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1);
+    renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
+    blurMat.uniforms.tDiffuse.value = blurRTb.texture; blurMat.uniforms.uDir.value.set(1.7,0);
+    renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
+    blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1.7);
+    renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
+    // 4) composite to screen
+    fsQuad.material = compMat;
+    compMat.uniforms.tBase.value = sceneRT.texture; compMat.uniforms.tBloom.value = blurRTb.texture;
+    renderer.setRenderTarget(null); renderer.render(fsQuad, orthoCam);
+  }
+
+  // ===================================================================== //
+  //  PICKING / FOCUS                                                      //
+  // ===================================================================== //
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+  let focused = null;
+
+  function openWing(w){
+    if(focused === w){ closeWing(); return; }
+    wings.forEach(x=>{ x.open = false; x.swingTarget = 0; });
+    w.open = true; w.swingTarget = 1;
+    focused = w;
+    showDetail(w);
+  }
+  function closeWing(){
+    if(focused){ focused.open=false; focused.swingTarget=0; }
+    focused = null; hideDetail();
+  }
+
+  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 hits = ray.intersectObjects(pickFaces, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.wingIdx;
+      const w = wings.find(x=>x.idx===idx);
+      if(w) openWing(w);
+    } else if(focused){ closeWing(); }
+  }
+
+  // drag to slide the bank laterally + click to open
+  let dragging=false, dragX=0, panStart=0, moved=0;
+  let bankPan = 0, bankPanTarget = 0;
+  const el = renderer.domElement;
+  el.addEventListener("pointerdown",(e)=>{ dragging=true; moved=0; dragX=e.clientX; panStart=bankPanTarget;
+    el.setPointerCapture(e.pointerId); });
+  el.addEventListener("pointermove",(e)=>{ if(!dragging) return;
+    const dx=e.clientX-dragX; moved+=Math.abs(dx);
+    bankPanTarget = clamp(panStart + dx*0.012, -2.6, 2.6); });
+  el.addEventListener("pointerup",(e)=>{ dragging=false; if(moved<6) tryPick(e); });
+  window.addEventListener("wheel",(e)=>{ bankPanTarget = clamp(bankPanTarget - e.deltaY*0.0016, -2.6, 2.6); }, {passive:true});
+
+  // ---- detail card ----
+  const detail = document.getElementById("detail");
+  document.querySelector("#detail .closex").addEventListener("click", closeWing);
+  function showDetail(w){
+    const d = w.data;
+    document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("d_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const ww = d.width||27;
+    document.getElementById("d_dim").textContent = `${ww}" × ${ww}"`;
+    document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
+    document.getElementById("d_match").textContent = d.match_type || "—";
+    document.getElementById("d_finish").textContent = d.finish || "—";
+    document.getElementById("d_mat").textContent = d.material || "—";
+    const chip = document.getElementById("d_chip");
+    chip.style.background = `conic-gradient(from 0deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a)`;
+    const cta = document.getElementById("d_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; }
+    else cta.style.display="none";
+    detail.classList.add("show");
+  }
+  function hideDetail(){ detail.classList.remove("show"); }
+
+  // ===================================================================== //
+  //  RESIZE / VISIBILITY                                                  //
+  // ===================================================================== //
+  let running = true;
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H()); resizeBloom();
+  });
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0 = performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // ===================================================================== //
+  //  FPS                                                                  //
+  // ===================================================================== //
+  const fpsEl = document.getElementById("fps");
+  let fpsShown = true;
+  fpsEl.addEventListener("click", ()=>{ fpsShown=!fpsShown; fpsEl.style.opacity = fpsShown?"1":"0.25"; });
+  let frames=0, fpsAccum=0, fps=0;
+
+  // ===================================================================== //
+  //  DATA LOAD                                                            //
+  // ===================================================================== //
+  const bootlbl = document.getElementById("bootlbl");
+  async function start(){
+    let products = [];
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = json.products || [];
+    } catch(e){ bootlbl.textContent = "Could not reach the catalog."; console.error("fetch failed", e); }
+    const n = Math.min(products.length, 28); // a focused wing bank, not a wall of 50 slivers
+    for(let i=0;i<n;i++) makeWing(products[i], i, n);
+    window.__GARCIA_READY = wings.length;
+    buildBloom();
+    finishBoot();
+    t0 = performance.now();
+    requestAnimationFrame(tick);
+  }
+  function finishBoot(){
+    const boot = document.getElementById("boot");
+    boot.style.opacity = "0";
+    setTimeout(()=>boot.remove(), 950);
+  }
+
+  // ===================================================================== //
+  //  ANIMATE                                                              //
+  // ===================================================================== //
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    // FPS
+    frames++; fpsAccum += dt;
+    if(fpsAccum >= 0.5){ fps = Math.round(frames/fpsAccum); frames=0; fpsAccum=0;
+      if(fpsShown){ fpsEl.textContent = fps + " fps"; fpsEl.classList.toggle("lo", fps<55); } }
+
+    // color-wash uniform — ONE float, drives every wall/ceiling shader (cheap)
+    washUniforms.uT.value = now*0.001;
+
+    // breathing rim/warm light for liquid-light pulse
+    const pulse = (Math.sin(now*0.0011)*0.5+0.5);
+    rim.intensity = 0.9 + pulse*0.9;
+    rim.color.setHSL((now*0.00006)%1, 0.7, 0.6);
+    warm.intensity = 0.7 + (1-pulse)*0.5;
+
+    // decals: slow emissive glow shimmer + gentle hue drift (classy, not flashing)
+    for(let i=0;i<decals.length;i++){
+      const g = decals[i].userData.glow;
+      const op = 0.78 + Math.sin(now*0.0015 + i*1.7)*0.18;
+      g.material.opacity = op;
+    }
+
+    // bank lateral pan
+    bankPan += (bankPanTarget - bankPan)*Math.min(1,dt*5);
+
+    // wings: swivel open/closed; the focused board swings flat to camera + slides to center,
+    // and PARTS its neighbors aside so NOTHING crosses the open full-pattern face (no gray bars).
+    const fIdx = focused ? focused.idx : null;
+    for(const w of wings){
+      w.swing += (w.swingTarget - w.swing)*Math.min(1,dt*7);
+      // closed wings rake ~58° (read as a packed bank); open wing → 0° (flat, full face to camera)
+      const rake = lerp(-1.02, 0.0, w.swing);
+      w.pivot.rotation.y = rake;
+
+      // base bank x, plus a parting shove when a different wing is focused
+      let wantX = w.baseX;
+      if(fIdx !== null && w !== focused){
+        const side = (w.idx < fIdx) ? -1 : 1;             // push left of focus left, right of focus right
+        const part = CLEAR * (focused.swing);             // grows as the focus board opens
+        wantX = w.baseX + side*part;
+      }
+      // focused board glides forward to FOCUS_Z and to x=0 (dead center)
+      wantX = lerp(wantX, 0, w.swing);
+      const wantZ = lerp(BANK_Z, FOCUS_Z, w.swing);
+      const px = wantX - BOARD_W/2 + bankPan*(1-w.swing);
+      w.pivot.position.x += (px - w.pivot.position.x)*Math.min(1,dt*6);
+      w.pivot.position.z += (wantZ - w.pivot.position.z)*Math.min(1,dt*6);
+      w.pivot.position.y = BANK_Y + Math.sin(now*0.0006 + w.idx)*0.012*(1-w.swing);
+
+      // open board scales up to a generous full face; closed boards stay bank-size
+      const sc = lerp(1.0, 1.32, w.swing);
+      w.board.scale.set(sc, sc, 1);
+
+      // dim non-focused faces when one is open so the open face reads clean
+      const wantDim = (focused && w!==focused) ? 1 : 0;
+      w.dimT += (wantDim - w.dimT)*Math.min(1,dt*6);
+      w.faceMat.color.setScalar(lerp(1, 0.5, w.dimT));
+      w.faceMat.envMapIntensity = lerp(0.7, 1.05, w.swing); // open face catches more env sheen
+    }
+
+    renderWithBloom();
+    requestAnimationFrame(tick);
+  }
+
+  // probe for headless verification
+  window.__GARCIA__ = {
+    ready:()=>wings.length,
+    fps:()=>fps,
+    focused:()=>focused? focused.idx : -1,
+    texturesLoaded:()=>wings.filter(w=>w.faceMat.map && w.faceMat.map!==fallbackTex).length
+  };
+
+  start();
+})();
+</script>
+
+<script>window.PROTO_META={"key":"V21","label":"V21 — Garcia Room","elements":[
+  {"n":1,"label":"Liquid-light color-wash walls (slow oil-projection hue cycle)"},
+  {"n":2,"label":"Tie-dye PMREM environment lighting the room"},
+  {"n":3,"label":"Wing bank of China Seas boards in thin gold frames, ~2\" apart"},
+  {"n":4,"label":"Click a wing → it swivels to a clean full-pattern face, no bars"},
+  {"n":5,"label":"Dancing-bear + lightning-bolt framed art with emissive bloom"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>

← 1c4a04e Avatar polish: lighten 1st-person sweater 0x2e3138->0x3d424d  ·  back to Quadrille Showroom  ·  Register V12 Gallery Noir + V16 Art-Deco Salon + V21 Garcia fad82ea →