[object Object]

← back to Quadrille Showroom

auto-save: 2026-06-29T06:19:10 (4 files) — public/proto/v13-atrium-daylight.html public/proto/v14-velvet-lounge.html public/proto/v15-brutalist-concrete.html public/proto/v17-tatami.html

607b67f994bceaec317340d14ef972ba7ae1e992 · 2026-06-29 06:19:13 -0700 · Steve Abrams

Files touched

Diff

commit 607b67f994bceaec317340d14ef972ba7ae1e992
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon Jun 29 06:19:13 2026 -0700

    auto-save: 2026-06-29T06:19:10 (4 files) — public/proto/v13-atrium-daylight.html public/proto/v14-velvet-lounge.html public/proto/v15-brutalist-concrete.html public/proto/v17-tatami.html
---
 public/proto/v13-atrium-daylight.html    | 646 +++++++++++++++++++++++++++
 public/proto/v14-velvet-lounge.html      | 679 +++++++++++++++++++++++++++++
 public/proto/v15-brutalist-concrete.html | 685 +++++++++++++++++++++++++++++
 public/proto/v17-tatami.html             | 723 +++++++++++++++++++++++++++++++
 4 files changed, 2733 insertions(+)

diff --git a/public/proto/v13-atrium-daylight.html b/public/proto/v13-atrium-daylight.html
new file mode 100644
index 0000000..3d2cfd1
--- /dev/null
+++ b/public/proto/v13-atrium-daylight.html
@@ -0,0 +1,646 @@
+<!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>ATRIUM DAYLIGHT · China Seas</title>
+<style>
+  :root { --ink:#2b2b28; --paper:#fbf9f3; --accent:#8a7a55; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#eaf1f6; color:var(--ink);
+    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:34px 30px 56px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(251,249,243,.9) 0%, rgba(251,249,243,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#9a8c66; font-weight:600; }
+  .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+    font-weight:400; letter-spacing:.1em; line-height:1; margin-top:9px; color:#34322c; }
+  .masthead h1 b { font-weight:700; color:#8a7a55; }
+  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.04em; color:#6f6a5e;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  /* ---- focused board caption ---- */
+  #caption { position:fixed; left:50%; bottom:84px; 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:.01em; color:#2f2d27;
+    text-shadow:0 1px 14px rgba(255,255,255,.8); }
+  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#9a8c66; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#6a6458; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(138,122,85,.55); border-radius:28px; color:#6e5f3d; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(138,122,85,.08); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(138,122,85,.2); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:28px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(70,66,56,.5);
+    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:#6e5f3d; background:rgba(255,255,255,.6); border:1px solid rgba(138,122,85,.3); border-radius:7px;
+    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+  #fps.off { opacity:.32; }
+
+  /* ---- loading ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center;
+    background:linear-gradient(180deg,#dcebf4 0%, #f3f6f1 58%, #efe9da 100%); transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(138,122,85,.22); border-top-color:#8a7a55; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#857a60; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:28px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · The Daylight Atrium</div>
+  <h1>ATRIUM <b>DAYLIGHT</b></h1>
+  <div class="sub">China Seas in a glass atrium — sun pouring across pale stone, soft shafts and long
+    shadows. One board steps forward at a time, full pattern edge to edge. Drag to swing the bank;
+    tap a board to bring it into the light.</div>
+</div>
+
+<div id="caption">
+  <div class="cn" id="cap_name">—</div>
+  <div class="cc" id="cap_coll">China Seas</div>
+  <div class="cspec" id="cap_spec">—</div>
+  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Opening the atrium…</div></div>
+
+<!-- r128 core (local). NOTE: r128 three.min.js has no UnrealBloomPass; this look
+     relies on the bright PMREM environment for highlights, not postprocessing. -->
+<script src="./three.min.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+  const lerp = (a,b,t)=>a+(b-a)*t;
+
+  // -------------------------------------------------------------------------
+  // 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.05;            // bright but held back from blow-out
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0xdfeaf2, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  // airy haze for atmospheric depth — light, not murky
+  scene.fog = new THREE.Fog(0xeaf1f6, 22, 58);
+
+  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 220);
+  camera.position.set(0, 1.62, 9.4);
+  camera.lookAt(0, 1.6, 0);
+
+  // -------------------------------------------------------------------------
+  // SKY-GRADIENT ENVIRONMENT — a procedural luminous sky dome baked through
+  // PMREM so every gloss surface picks up soft daylight (IBL). Doubles as the
+  // visible backdrop sky so the atrium reads as glass-open to a bright sky.
+  // -------------------------------------------------------------------------
+  function makeSkyTexture(){
+    const c = document.createElement("canvas"); c.width = 16; c.height = 256;
+    const x = c.getContext("2d");
+    const g = x.createLinearGradient(0,0,0,256);
+    g.addColorStop(0.00, "#bcd9ee");   // high sky — soft cyan-blue
+    g.addColorStop(0.42, "#dfeaf2");   // pale horizon haze
+    g.addColorStop(0.62, "#f4f1e8");   // warm light band where sun pools
+    g.addColorStop(1.00, "#efe7d6");   // warm ground glow
+    x.fillStyle = g; x.fillRect(0,0,16,256);
+    const t = new THREE.CanvasTexture(c);
+    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
+    t.needsUpdate = true;
+    return t;
+  }
+  // big inverted sky sphere as the visible backdrop
+  (function buildSkyDome(){
+    const tex = makeSkyTexture();
+    const sky = new THREE.Mesh(
+      new THREE.SphereGeometry(120, 32, 24),
+      new THREE.MeshBasicMaterial({ map:tex, side:THREE.BackSide, fog:false, toneMapped:true })
+    );
+    scene.add(sky);
+  })();
+  // PMREM IBL from a bright gradient room scene → light wraps onto everything
+  (function buildEnv(){
+    try {
+      const pmrem = new THREE.PMREMGenerator(renderer);
+      // a simple bright box whose walls glow the sky palette = a daylight studio env
+      const envScene = new THREE.Scene();
+      const tex = makeSkyTexture();
+      const dome = new THREE.Mesh(
+        new THREE.SphereGeometry(40, 24, 16),
+        new THREE.MeshBasicMaterial({ map:tex, side:THREE.BackSide })
+      );
+      envScene.add(dome);
+      // a bright soft area light proxy (emissive panel) gives a hot key reflection
+      const sunPanel = new THREE.Mesh(
+        new THREE.PlaneGeometry(26, 26),
+        new THREE.MeshBasicMaterial({ color:0xffffff })
+      );
+      sunPanel.position.set(10, 16, 8); sunPanel.lookAt(0,0,0);
+      envScene.add(sunPanel);
+      const envMap = pmrem.fromScene(envScene, 0.02).texture;
+      scene.environment = envMap;
+      pmrem.dispose();
+    } catch(e){ /* env optional */ }
+  })();
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — bright, airy daylight. Strong soft sky ambient + hemisphere
+  // (sky/ground bounce) + a warm low-angle SUN that throws long soft shadows
+  // across the pale stone. Cool fill keeps shadows from going muddy.
+  // -------------------------------------------------------------------------
+  // a touch less flat ambient so the warm sun can carve real (soft) shadow depth
+  scene.add(new THREE.AmbientLight(0xeaf2fb, 0.4));
+  const hemi = new THREE.HemisphereLight(0xcfe4f5, 0xe9e0cd, 0.78);  // sky cyan over warm-stone bounce
+  hemi.position.set(0, 20, 0);
+  scene.add(hemi);
+
+  // the SUN — warm, low and raking from upper-left so shadows stretch long & soft
+  const sun = new THREE.DirectionalLight(0xfff4dc, 2.35);
+  sun.position.set(-11, 8.5, 7);
+  sun.castShadow = true;
+  sun.shadow.mapSize.set(2048, 2048);
+  sun.shadow.camera.near = 1; sun.shadow.camera.far = 50;
+  sun.shadow.camera.left = -16; sun.shadow.camera.right = 16;
+  sun.shadow.camera.top = 14; sun.shadow.camera.bottom = -6;
+  sun.shadow.bias = -0.0004; sun.shadow.radius = 7;     // soft penumbra
+  scene.add(sun);
+  const sunTarget = new THREE.Object3D(); sunTarget.position.set(2, 1, -3); scene.add(sunTarget);
+  sun.target = sunTarget;
+
+  // gentle cool fill from camera-right so the shadow side still reads
+  const fill = new THREE.DirectionalLight(0xbcd3ea, 0.35);
+  fill.position.set(8, 5, 6); scene.add(fill);
+
+  // -------------------------------------------------------------------------
+  // ROOM — glass atrium: pale stone floor (clearcoat sheen), bright walls, a
+  // mullioned glass ceiling/back that lets the sky read through.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+
+  // procedural pale-stone roughness/tint (canvas → texture, $0 local)
+  function makeStoneRoughness(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#b8b8b8"; 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()*46)|0; const v=150+n; d[i]=d[i+1]=d[i+2]=v; }
+    x.putImageData(img,0,0);
+    // faint slab seams (limestone tiles) — slightly rougher grout lines
+    x.strokeStyle = "rgba(90,90,90,0.5)"; x.lineWidth = 2;
+    for(let i=0;i<=256;i+=64){ x.beginPath(); x.moveTo(i,0); x.lineTo(i,256); x.stroke();
+      x.beginPath(); x.moveTo(0,i); x.lineTo(256,i); x.stroke(); }
+    const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+  }
+  function makeStoneColor(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#ece7da"; x.fillRect(0,0,256,256);   // warm pale limestone
+    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()*16-8)|0;
+      d[i]=clamp(236+n,210,250); d[i+1]=clamp(231+n,205,248); d[i+2]=clamp(218+n,195,238); }
+    x.putImageData(img,0,0);
+    x.strokeStyle = "rgba(150,142,124,0.45)"; x.lineWidth = 1.5;
+    for(let i=0;i<=256;i+=64){ x.beginPath(); x.moveTo(i,0); x.lineTo(i,256); x.stroke();
+      x.beginPath(); x.moveTo(0,i); x.lineTo(256,i); x.stroke(); }
+    const t = new THREE.CanvasTexture(c);
+    if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding;
+    t.anisotropy = MAX_ANISO; return t;
+  }
+
+  (function buildRoom(){
+    // PALE STONE FLOOR — clearcoat physical gives a soft daylight sheen + long
+    // shadows land legibly on it. Tiled limestone color + roughness.
+    const stoneCol = makeStoneColor();
+    const stoneRgh = makeStoneRoughness();
+    stoneCol.wrapS = stoneCol.wrapT = THREE.RepeatWrapping; stoneCol.repeat.set(10,10);
+    stoneRgh.wrapS = stoneRgh.wrapT = THREE.RepeatWrapping; stoneRgh.repeat.set(10,10);
+    const floorMat = new THREE.MeshPhysicalMaterial({
+      map:stoneCol, roughnessMap:stoneRgh, roughness:0.62, metalness:0.0,
+      clearcoat:0.35, clearcoatRoughness:0.5, envMapIntensity:0.85
+    });
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(80, 60), floorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+
+    // bright back wall — warm off-white plaster, receives the wing-bank shadow
+    const wallMat = new THREE.MeshStandardMaterial({ color:0xf3efe6, roughness:0.92, metalness:0.0, envMapIntensity:0.6 });
+    const back = new THREE.Mesh(new THREE.PlaneGeometry(70, 26), wallMat);
+    back.position.set(0, 7, -8.2); back.receiveShadow = true; scene.add(back);
+
+    // side walls — pale, recede toward the light
+    const sideMat = new THREE.MeshStandardMaterial({ color:0xf6f3ec, roughness:0.94, metalness:0.0, envMapIntensity:0.5 });
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 26), sideMat);
+    left.position.set(-16, 7, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+    const right = left.clone(); right.position.x = 16; right.rotation.y = -Math.PI/2; scene.add(right);
+
+    // GLASS CEILING with mullions — thin frame grid against the bright sky so the
+    // atrium reads as a daylit glass roof. Mullions cast faint shadow bars too.
+    const mullMat = new THREE.MeshStandardMaterial({ color:0xd8d2c4, roughness:0.5, metalness:0.3, envMapIntensity:0.8 });
+    const ceilGroup = new THREE.Group();
+    const span = 36, depth = 30, y = 13;
+    for(let i=-span/2; i<=span/2; i+=4){
+      const bar = new THREE.Mesh(new THREE.BoxGeometry(0.12, 0.12, depth), mullMat);
+      bar.position.set(i, y, -2); ceilGroup.add(bar);
+    }
+    for(let j=-depth/2; j<=depth/2; j+=5){
+      const bar = new THREE.Mesh(new THREE.BoxGeometry(span, 0.12, 0.12), mullMat);
+      bar.position.set(0, y, j-2); ceilGroup.add(bar);
+    }
+    scene.add(ceilGroup);
+
+    // a few faint floating dust motes catching the light (tiny additive points)
+    const moteGeo = new THREE.BufferGeometry();
+    const MOTES = 120; const pos = new Float32Array(MOTES*3);
+    for(let i=0;i<MOTES;i++){
+      pos[i*3]   = (Math.random()-0.5)*24;
+      pos[i*3+1] = Math.random()*9 + 1;
+      pos[i*3+2] = (Math.random()-0.5)*12 - 2;
+    }
+    moteGeo.setAttribute("position", new THREE.BufferAttribute(pos,3));
+    const moteMat = new THREE.PointsMaterial({ color:0xfff6e0, size:0.045, transparent:true,
+      opacity:0.5, depthWrite:false, blending:THREE.AdditiveBlending, sizeAttenuation:true });
+    const motes = new THREE.Points(moteGeo, moteMat);
+    scene.add(motes);
+    window.__motes = motes;
+  })();
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin pale-framed boards, ~2" apart, side swivel.
+  // Same proven mechanic as the gallery build; reskinned for daylight.
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;      // thin frame border
+  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;
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // thin light-bronze / champagne frame (matches the bright-gallery white frames)
+  const frameMat = new THREE.MeshStandardMaterial({ color:0xe8e2d3, roughness:0.42, metalness:0.55, envMapIntensity:1.0 });
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0xf2efe7, roughness:0.6, metalness:0.0, envMapIntensity:0.5 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
+      new THREE.MeshStandardMaterial({ color:0xeae6db, roughness:0.8, metalness:0.05 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0, _lit:1 };
+  }
+
+  // -------------------------------------------------------------------------
+  // FLOOR REFLECTION — soft mirrored clone of the featured board (subtle on
+  // stone, just a hint of the pattern picked up by the polished slab).
+  // -------------------------------------------------------------------------
+  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
+  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
+  reflMesh.scale.y = -1;
+  reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: featured board + a window of neighbours only.
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace; else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);          // ~one full 27" repeat across the board face
+        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.55, metalness:0.0, clearcoat:0.06, clearcoatRoughness:0.6, envMapIntensity:0.55
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      if(Math.abs(i-focusIdx) <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = 0;
+
+      // DAYLIGHT — everything stays bright (no chiaroscuro fade). The featured
+      // board is at full daylight; neighbours are only very gently cooled so the
+      // eye settles on center without losing the airy, well-lit feel.
+      const litTarget = 0.82 + 0.18*Math.pow(openAmt, 1.2);
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
+
+      const farOff = Math.abs(rel) > 8;
+      b.group.visible = !farOff;
+    }
+
+    // sun + reflection track the featured board (sun follows so its long shadow
+    // sweeps with whatever's open)
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      sunTarget.position.set(fx*0.6, 1.2, front.group.position.z - 1);
+      reflMesh.visible = true;
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.11 - 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);
+      }
+    }
+
+    // drifting motes
+    if(window.__motes){
+      window.__motes.rotation.y = now*0.00004;
+      window.__motes.material.opacity = 0.4 + Math.sin(now*0.0008)*0.12;
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__AD_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__AD__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__AD_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":"V13","label":"V13 — Atrium Daylight","elements":[
+  {"n":1,"label":"Glass atrium — luminous sky-gradient environment, soft global illumination"},
+  {"n":2,"label":"Thin champagne-framed boards, ~2\" apart on a side swivel"},
+  {"n":3,"label":"Warm low sun throws long soft shadows across pale limestone"},
+  {"n":4,"label":"Mullioned glass ceiling + drifting dust motes catch the light"},
+  {"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/v14-velvet-lounge.html b/public/proto/v14-velvet-lounge.html
new file mode 100644
index 0000000..335318c
--- /dev/null
+++ b/public/proto/v14-velvet-lounge.html
@@ -0,0 +1,679 @@
+<!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>VELVET LOUNGE · China Seas</title>
+<style>
+  :root { --brass:#d8b779; --paper:#f1e8d6; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#0a0607; color:var(--paper);
+    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+  #app { position:fixed; inset:0; }
+  canvas { display:block; }
+
+  /* ---- masthead ---- */
+  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:80px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(10,6,7,.78) 0%, rgba(10,6,7,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.46em; text-transform:uppercase;
+    color:#b89a66; font-weight:600; }
+  .masthead h1 { font-family:Georgia,"Times New Roman",serif; font-size:clamp(26px,4.4vw,46px);
+    font-weight:400; letter-spacing:.14em; line-height:1; margin-top:9px; color:#f3ead8; }
+  .masthead h1 b { font-weight:700; color:#e9c98f; }
+  .masthead .sub { margin-top:10px; font-size:12px; letter-spacing:.05em; color:#9a8d76;
+    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:#f4ecdb;
+    text-shadow:0 2px 22px rgba(0,0,0,.92); }
+  #caption .cc { margin-top:5px; font-size:10.5px; letter-spacing:.34em; text-transform:uppercase; color:#c4a36a; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.04em; color:#a59a85; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:14px; padding:10px 26px;
+    border:1px solid rgba(216,183,121,.55); border-radius:28px; color:#e9c98f; font-size:10.5px;
+    letter-spacing:.24em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(216,183,121,.07); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(216,183,121,.2); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.3em; text-transform:uppercase; color:rgba(228,214,188,.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:#b89a66; background:rgba(14,9,10,.6); border:1px solid rgba(216,183,121,.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:#0a0607; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(216,183,121,.18); border-top-color:#d8b779; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.36em; text-transform:uppercase; color:#8a7d62; }
+  @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 Velvet Lounge</div>
+  <h1>VELVET <b>LOUNGE</b></h1>
+  <div class="sub">China Seas, set in a jewel-box salon — deep velvet walls, brass sconces, low warm light.
+    One board glides into the glow at a time, full pattern edge to edge. Drag to swing the bank.</div>
+</div>
+
+<div id="caption">
+  <div class="cn" id="cap_name">—</div>
+  <div class="cc" id="cap_coll">China Seas</div>
+  <div class="cspec" id="cap_spec">—</div>
+  <a class="cta" id="cap_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
+</div>
+
+<div class="hint" id="hint">drag · click a board · scroll</div>
+<div id="fps" title="Toggle FPS">— fps</div>
+
+<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Lighting the sconces…</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>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+  const lerp = (a,b,t)=>a+(b-a)*t;
+
+  // -------------------------------------------------------------------------
+  // RENDERER (high-end baseline)
+  // -------------------------------------------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+  renderer.toneMapping = THREE.ACESFilmicToneMapping;
+  renderer.toneMappingExposure = 1.12;        // a touch hot so the jewel tones glow, not muddy
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0x0a0607, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+  var _glowTex = null;   // shared additive-glow texture (hoisted; built lazily)
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x0c0709);
+  // slight warm haze for plush depth (the "slight haze" the brief asks for)
+  scene.fog = new THREE.FogExp2(0x140a10, 0.026);
+
+  const camera = new THREE.PerspectiveCamera(46, W()/H(), 0.1, 200);
+  camera.position.set(0, 1.9, 9.0);     // a touch higher & closer so velvet walls
+  camera.lookAt(0, 2.05, 0);            // fill the frame, less muddy floor in view
+
+  // -------------------------------------------------------------------------
+  // PMREM ENVIRONMENT (RoomEnvironment) — so brass & gloss reflect richly
+  // -------------------------------------------------------------------------
+  (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 */ }
+  })();
+
+  // r128 sheen API: MeshPhysicalMaterial.sheen is a COLOR (or null), NOT the
+  // scalar sheen/sheenColor/sheenRoughness split that arrived in r132+. So we
+  // detect by whether assigning a Color sticks, and drive velvet plushness that way.
+  const SHEEN_OK = (function(){
+    try {
+      const m = new THREE.MeshPhysicalMaterial();
+      return ("sheen" in m) && !("sheenColor" in m);   // r128-style sheen=Color
+    } catch(e){ return false; }
+  })();
+
+  // -------------------------------------------------------------------------
+  // LIGHTING — intimate, low-key, warm. A warm amber key + cool sapphire fill +
+  // warm rim, PLUS two brass-sconce point lights flanking the bank and a hero
+  // spotlight that follows the featured board.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.AmbientLight(0x241019, 0.5));            // warm plum base, low
+
+  const fill = new THREE.DirectionalLight(0x5a6fa0, 0.26);     // cool sapphire fill from left
+  fill.position.set(-7, 6, 6); scene.add(fill);
+
+  const rim = new THREE.DirectionalLight(0xffcaa0, 0.34);      // warm rim from behind-right
+  rim.position.set(6, 4, -8); scene.add(rim);
+
+  // low warm wash from floor level so the emerald velvet glows from below the
+  // sconces rather than dying to black — keeps the lounge plush, not muddy-dark.
+  const uplight = new THREE.DirectionalLight(0xffb888, 0.16);
+  uplight.position.set(0, -2, 4); scene.add(uplight);
+
+  const key = new THREE.DirectionalLight(0xffe7c2, 0.34);      // soft warm 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);
+
+  // hero spotlight — warm, tight, casts the gallery shadow on the featured board
+  const spot = new THREE.SpotLight(0xffe2b4, 0, 18, Math.PI/7.0, 0.5, 1.3);
+  spot.position.set(0, 6.2, 5.4);
+  spot.castShadow = true;
+  spot.shadow.mapSize.set(2048, 2048);
+  spot.shadow.camera.near = 1; spot.shadow.camera.far = 24;
+  spot.shadow.bias = -0.0005; spot.shadow.radius = 5;
+  scene.add(spot);
+  const spotTarget = new THREE.Object3D(); scene.add(spotTarget);
+  spot.target = spotTarget;
+
+  // two brass wall-sconce point lights flanking the bank — warm pools of light
+  const sconceL = new THREE.PointLight(0xffb774, 6.5, 14, 2.0); sconceL.position.set(-6.4, 4.4, -2.0); scene.add(sconceL);
+  const sconceR = new THREE.PointLight(0xffb774, 6.5, 14, 2.0); sconceR.position.set( 6.4, 4.4, -2.0); scene.add(sconceR);
+
+  // -------------------------------------------------------------------------
+  // ROOM — deep jewel-tone VELVET walls (emerald / sapphire / oxblood), a warm
+  // dark herringbone-ish floor, brass sconce fixtures with emissive "glow".
+  // Velvet = high roughness + sheen (when supported) so the nap catches the rim.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+
+  function makeVelvetMat(color, sheenColor){
+    const m = new THREE.MeshPhysicalMaterial({
+      color: color, roughness: 0.96, metalness: 0.0, envMapIntensity: 0.35
+    });
+    if (SHEEN_OK){
+      // r128: sheen is a Color — set it to the jewel highlight so the velvet nap
+      // catches the warm rim/sconce light at grazing angles (plush, not flat).
+      m.sheen = new THREE.Color(sheenColor);
+    }
+    return m;
+  }
+
+  // a soft vertical "drape" gradient + faint nap noise so velvet walls aren't flat
+  function makeVelvetRoughness(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#f0f0f0"; 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()*40)|0; d[i]=d[i+1]=d[i+2]=210+n; }
+    x.putImageData(img,0,0);
+    const t = new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.repeat.set(4,4);
+    t.anisotropy = MAX_ANISO; return t;
+  }
+  const velvetRough = makeVelvetRoughness();
+
+  (function buildRoom(){
+    // BACK WALL — deep emerald velvet (the jewel anchor behind the bank).
+    // Slightly richer envMap so the nap glints under the sconces, not flat.
+    const backMat = makeVelvetMat(0x103a2c, 0x2a8062);
+    backMat.envMapIntensity = 0.5;
+    backMat.roughnessMap = velvetRough;
+    const back = new THREE.Mesh(new THREE.PlaneGeometry(60, 24), backMat);
+    back.position.set(0, 6, -7.5); back.receiveShadow = true; scene.add(back);
+
+    // SIDE WALLS — sapphire (left) + oxblood (right) for jewel-tone contrast
+    const leftMat = makeVelvetMat(0x122347, 0x2a4a8f);
+    leftMat.roughnessMap = velvetRough;
+    const left = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), leftMat);
+    left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+
+    const rightMat = makeVelvetMat(0x3d1218, 0x7a2a36);   // oxblood
+    rightMat.roughnessMap = velvetRough;
+    const right = new THREE.Mesh(new THREE.PlaneGeometry(40, 24), rightMat);
+    right.position.set(14, 6, 2); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
+
+    // CEILING — deep warm plum, near-matte, no bounce
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+      new THREE.MeshStandardMaterial({ color:0x14090f, roughness:1.0 }));
+    ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+    // FLOOR — warm dark wood, satin clearcoat so the brass + boards reflect softly
+    const floorMat = new THREE.MeshPhysicalMaterial({
+      color:0x18100b, roughness:0.42, metalness:0.0,
+      clearcoat:0.7, clearcoatRoughness:0.5, envMapIntensity:0.45
+    });
+    floorMat.roughnessMap = makeFloorRoughness();
+    floorMat.roughnessMap.wrapS = floorMat.roughnessMap.wrapT = THREE.RepeatWrapping;
+    floorMat.roughnessMap.repeat.set(10, 10);
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(60, 40), floorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+
+    // a faint warm GLOW pool on the back wall behind each sconce (radial sprite-ish
+    // additive plane) — cheap fake-bloom so the sconces read as light sources.
+    addGlowPanel(-6.4, 4.4, -7.35, 0xffb262, 3.2);
+    addGlowPanel( 6.4, 4.4, -7.35, 0xffb262, 3.2);
+
+    // BRASS SCONCES — small emissive brass fixtures on the back wall
+    buildSconce(-6.4, 4.4);
+    buildSconce( 6.4, 4.4);
+  })();
+
+  // warm wood roughness streak
+  function makeFloorRoughness(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#8a8a8a"; 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()*60)|0; d[i]=d[i+1]=d[i+2]=120+n; }
+    x.putImageData(img,0,0);
+    const t = new THREE.CanvasTexture(c); t.anisotropy = MAX_ANISO; return t;
+  }
+
+  // additive radial glow texture (shared) for fake-bloom glow pools.
+  // var (hoisted, TDZ-safe) so the buildRoom IIFE above can call glowTexture().
+  function glowTexture(){
+    if(_glowTex) return _glowTex;
+    const c = document.createElement("canvas"); c.width=c.height=128;
+    const g = c.getContext("2d");
+    const grd = g.createRadialGradient(64,64,2, 64,64,64);
+    grd.addColorStop(0, "rgba(255,255,255,1)");
+    grd.addColorStop(0.35, "rgba(255,255,255,0.45)");
+    grd.addColorStop(1, "rgba(255,255,255,0)");
+    g.fillStyle = grd; g.fillRect(0,0,128,128);
+    _glowTex = new THREE.CanvasTexture(c); return _glowTex;
+  }
+  function addGlowPanel(x,y,z,color,size){
+    const m = new THREE.MeshBasicMaterial({
+      map: glowTexture(), color: color, transparent:true, opacity:0.55,
+      blending: THREE.AdditiveBlending, depthWrite:false, toneMapped:false
+    });
+    const mesh = new THREE.Mesh(new THREE.PlaneGeometry(size, size), m);
+    mesh.position.set(x,y,z); mesh.renderOrder = 2; scene.add(mesh);
+  }
+
+  // a brass wall sconce: a small emissive brass cup + a tiny additive flame glow
+  function buildSconce(x,y){
+    const brass = new THREE.MeshPhysicalMaterial({
+      color:0x8a6a2e, roughness:0.28, metalness:1.0,
+      clearcoat:0.6, clearcoatRoughness:0.3, envMapIntensity:1.2,
+      emissive:0x4a2f0c, emissiveIntensity:0.6
+    });
+    const arm = new THREE.Mesh(new THREE.CylinderGeometry(0.04,0.04,0.5,12), brass);
+    arm.position.set(x, y, -7.05); arm.rotation.x = Math.PI/2; scene.add(arm);
+    const cup = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.3, 14, 1, true), brass);
+    cup.position.set(x, y+0.05, -6.78); scene.add(cup);
+    // emissive "flame" core
+    const flameMat = new THREE.MeshBasicMaterial({ color:0xffd089, toneMapped:false });
+    const flame = new THREE.Mesh(new THREE.SphereGeometry(0.07, 10, 10), flameMat);
+    flame.position.set(x, y+0.16, -6.78); scene.add(flame);
+    // small additive halo right at the flame
+    const halo = new THREE.Mesh(new THREE.PlaneGeometry(1.0,1.0), new THREE.MeshBasicMaterial({
+      map: glowTexture(), color:0xffc070, transparent:true, opacity:0.7,
+      blending:THREE.AdditiveBlending, depthWrite:false, toneMapped:false
+    }));
+    halo.position.set(x, y+0.16, -6.7); halo.renderOrder = 3; scene.add(halo);
+  }
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin brass-framed boards, ~2" apart on an arc, side swivel.
+  // -------------------------------------------------------------------------
+  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;
+
+  // shared geometries / materials
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // polished BRASS frame material — metalness + clearcoat per the brief
+  const frameMat = new THREE.MeshPhysicalMaterial({
+    color:0x9c7836, roughness:0.26, metalness:1.0,
+    clearcoat:0.55, clearcoatRoughness:0.28, envMapIntensity:1.25,
+    emissive:0x2a1c08, emissiveIntensity:0.35
+  });
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0x1a1512, roughness:0.62, metalness:0.0, envMapIntensity:0.35 });
+  }
+
+  // board pivots on its LEFT edge (side swivel). Group origin = left hinge.
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.03),
+      new THREE.MeshStandardMaterial({ color:0x140f0c, 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);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+  }
+
+  // -------------------------------------------------------------------------
+  // FLOOR REFLECTION — mirrored, dimmed clone of the featured board's face.
+  // -------------------------------------------------------------------------
+  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
+  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
+  reflMesh.scale.y = -1;
+  reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: only load the featured board + a window of neighbours
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);
+        tex.needsUpdate = true;
+        texCache.set(url, tex);
+        resolve(tex);
+      };
+      img.onerror = ()=>resolve(null);
+      img.src = url;
+    });
+  }
+
+  function ensureBoardTexture(b){
+    if(b.texLoaded || !b.data) return;
+    const url = imgURL(b.data.image || b.data.tile);
+    if(!url){ b.texLoaded = true; return; }
+    b.texLoaded = true;
+    loadTexture(url).then(tex=>{
+      if(!tex) return;
+      const m = new THREE.MeshPhysicalMaterial({
+        map:tex, roughness:0.56, 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;
+    });
+  }
+
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.min(Math.abs(i-focusIdx), boards.length-Math.abs(i-focusIdx));
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    // gentle candle-flicker on the sconce lights so the lounge feels alive
+    const fl = 1 + Math.sin(now*0.013)*0.05 + Math.sin(now*0.041)*0.03;
+    sconceL.intensity = 6.5*fl;
+    sconceR.intensity = 6.5*(2 - fl);
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = 0;
+
+      // low-key chiaroscuro — only the featured board sits fully in the warm pool
+      const litTarget = 0.22 + 0.78*Math.pow(openAmt, 1.35);
+      if(b._lit===undefined) b._lit = litTarget;
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
+
+      const farOff = Math.abs(rel) > 7;
+      b.group.visible = !farOff;
+    }
+
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      spotTarget.position.set(fx, BOARD_H*0.5, front.group.position.z);
+      spot.position.set(fx, 6.4, front.group.position.z + 5.2);
+      spot.intensity += ((HAS_PRODUCTS?8.5:0) - spot.intensity)*Math.min(1,dt*5);
+
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.14 - reflMat.opacity)*Math.min(1,dt*5);
+        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
+        reflMesh.needsUpdate = true;
+      } else {
+        reflMat.opacity += (0 - reflMat.opacity)*Math.min(1,dt*5);
+      }
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__VL_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set((i-0)*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__VL__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__VL_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":"V14","label":"V14 — Velvet Lounge","elements":[
+  {"n":1,"label":"Jewel-box salon — emerald / sapphire / oxblood velvet walls"},
+  {"n":2,"label":"Brass wall sconces with warm flame-glow pools"},
+  {"n":3,"label":"Intimate low-key warm key + sapphire fill + flicker"},
+  {"n":4,"label":"Thin brass-framed boards, ~2\" apart on a side swivel"},
+  {"n":5,"label":"Click a board → glides to center → full pattern face + soft floor reflection"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v15-brutalist-concrete.html b/public/proto/v15-brutalist-concrete.html
new file mode 100644
index 0000000..cc662ce
--- /dev/null
+++ b/public/proto/v15-brutalist-concrete.html
@@ -0,0 +1,685 @@
+<!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>BRUTALIST CONCRETE · China Seas</title>
+<style>
+  :root { --conc:#9a958c; --paper:#e9e6df; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#16161a; color:var(--paper);
+    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
+  #app { position:fixed; inset:0; }
+  canvas { display:block; }
+
+  /* ---- masthead — hard architectural type ---- */
+  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:78px 30px 46px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(14,14,16,.78) 0%, rgba(14,14,16,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.52em; text-transform:uppercase;
+    color:#7e7a72; font-weight:700; }
+  .masthead h1 { font-family:"Helvetica Neue",Arial,sans-serif; font-size:clamp(28px,5vw,54px);
+    font-weight:200; letter-spacing:.22em; line-height:1; margin-top:12px; color:#eceae3;
+    text-transform:uppercase; }
+  .masthead h1 b { font-weight:800; color:#fbfaf6; }
+  .masthead .sub { margin-top:12px; font-size:12px; letter-spacing:.06em; color:#827e76;
+    max-width:560px; margin-left:auto; margin-right:auto; }
+
+  /* ---- focused board caption — concrete slab card ---- */
+  #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
+    z-index:22; text-align:center; opacity:0; transition:opacity .5s, transform .5s cubic-bezier(.2,.8,.2,1);
+    pointer-events:none; }
+  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #caption .cn { font-family:"Helvetica Neue",Arial,sans-serif; font-weight:300; font-size:23px;
+    letter-spacing:.04em; color:#f2efe8; text-shadow:0 2px 22px rgba(0,0,0,.85); }
+  #caption .cc { margin-top:6px; font-size:10.5px; letter-spacing:.40em; text-transform:uppercase; color:#a59f93; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.05em; color:#928d82; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:15px; padding:11px 28px;
+    border:1px solid rgba(210,206,196,.45); border-radius:2px; color:#eceae3; font-size:10.5px;
+    letter-spacing:.26em; text-transform:uppercase; text-decoration:none; font-weight:700;
+    background:rgba(210,206,196,.05); transition:background .15s, transform .15s, border-color .15s; }
+  #caption a.cta:hover { background:rgba(210,206,196,.14); border-color:rgba(210,206,196,.7); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:rgba(220,216,206,.4);
+    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:#8c887f; background:rgba(16,16,18,.62); border:1px solid rgba(180,176,166,.22); border-radius:4px;
+    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:#16161a; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(180,176,166,.16); border-top-color:#cfcabf; animation:spin 1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.38em; text-transform:uppercase; color:#736f67; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:72px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · The Concrete Hall</div>
+  <h1>BRUTALIST <b>CONCRETE</b></h1>
+  <div class="sub">China Seas hung in a board-formed concrete hall. One hard shaft of light, raw grey
+    surfaces, and the pattern as the only colour in the room. 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">Pouring the concrete…</div></div>
+
+<!-- r128 core (local) + r128 RoomEnvironment for PMREM (CDN, attaches to global THREE). No bloom dep. -->
+<script src="./three.min.js"></script>
+<script src="https://unpkg.com/three@0.128.0/examples/js/environments/RoomEnvironment.js"></script>
+<script>
+"use strict";
+(function () {
+  const API = "/api/showroom/products?limit=50";
+  const app = document.getElementById("app");
+  const bootlbl = document.getElementById("bootlbl");
+  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;
+  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
+  const lerp = (a,b,t)=>a+(b-a)*t;
+
+  // -------------------------------------------------------------------------
+  // RENDERER (high-end baseline)
+  // -------------------------------------------------------------------------
+  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
+  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
+  renderer.setSize(W(), H());
+  if ("outputColorSpace" in renderer) renderer.outputColorSpace = THREE.SRGBColorSpace;
+  else if ("outputEncoding" in renderer) renderer.outputEncoding = THREE.sRGBEncoding;
+  renderer.toneMapping = THREE.ACESFilmicToneMapping;
+  renderer.toneMappingExposure = 1.02;
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;     // soft sampling, but a HARD key (small radius) keeps shadows crisp
+  renderer.setClearColor(0x16161a, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0x17171b);
+  scene.fog = new THREE.FogExp2(0x141417, 0.018);       // gentle haze for depth, not a blackout
+
+  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) — neutral IBL so the concrete reads as a
+  // real lit material and the steel frames pick up soft grey reflections. The
+  // environment is intentionally LOW intensity per material so the one hard key
+  // remains the dominant light (brutalist = single dramatic source).
+  // -------------------------------------------------------------------------
+  (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 — ONE hard directional KEY (crisp shadows) + a low cool ambient and
+  // a soft fill. No spotlight follow, no rim warmth: the brutalist read is a
+  // single raking shaft of daylight across raw grey.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.AmbientLight(0x2c2e34, 0.24));               // low cool ambient — recesses stay dark (AO read)
+
+  const fill = new THREE.HemisphereLight(0x4c4f58, 0x0c0c0f, 0.22); // restrained soft fill so the key dominates
+  scene.add(fill);
+
+  // THE hard key — raking, near-white daylight, full strength, TIGHT crisp shadow.
+  const key = new THREE.DirectionalLight(0xfff4e2, 3.0);
+  key.position.set(-7.5, 11.5, 7.5);                  // rakes from upper-left → long crisp shadows to the right
+  key.castShadow = true;
+  key.shadow.mapSize.set(2048, 2048);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 48;
+  key.shadow.camera.left = -13; key.shadow.camera.right = 13;
+  key.shadow.camera.top = 13;  key.shadow.camera.bottom = -13;
+  key.shadow.bias = -0.00035;
+  key.shadow.radius = 1.6;                            // SMALL radius → hard-edged, crisp gallery shadows
+  scene.add(key);
+  const keyTarget = new THREE.Object3D(); keyTarget.position.set(0, 1.6, -3); scene.add(keyTarget);
+  key.target = keyTarget;
+
+  // -------------------------------------------------------------------------
+  // PROCEDURAL CONCRETE MAPS — board-formed concrete: horizontal plank seams +
+  // vertical form-tie marks + wood grain inside each board + mottled aggregate.
+  // We bake a colour map, a roughness map, a normal map and a baked-AO map on a
+  // single canvas set, $0 local. Shared across all concrete surfaces.
+  // -------------------------------------------------------------------------
+  function makeConcreteMaps(opts){
+    opts = opts || {};
+    const S = 512;
+    const planks = opts.planks || 7;            // board-form planks down the height
+    const base = opts.base || 152;              // mid grey
+    // --- colour canvas ---
+    const cc = document.createElement("canvas"); cc.width = cc.height = S;
+    const cx = cc.getContext("2d");
+    cx.fillStyle = `rgb(${base},${base},${base+2})`; cx.fillRect(0,0,S,S);
+    // mottled aggregate + pour staining
+    const cimg = cx.getImageData(0,0,S,S); const cd = cimg.data;
+    for(let y=0;y<S;y++){
+      for(let x=0;x<S;x++){
+        const i=(y*S+x)*4;
+        // low-freq stain (vertical pour streaks)
+        const stain = Math.sin(x*0.013 + Math.sin(y*0.006)*2.0)*10
+                    + Math.sin(x*0.05)*4;
+        // fine aggregate speckle
+        const spk = (Math.random()*26 - 13);
+        const v = clamp(base + stain + spk, 96, 196);
+        cd[i]=v; cd[i+1]=v; cd[i+2]=v+2; cd[i+3]=255;
+      }
+    }
+    cx.putImageData(cimg,0,0);
+
+    // --- roughness canvas (mostly matte; seams slightly darker = rougher) ---
+    const rc = document.createElement("canvas"); rc.width = rc.height = S;
+    const rx = rc.getContext("2d");
+    rx.fillStyle = "#c8c8c8"; rx.fillRect(0,0,S,S);            // ~0.78 rough base
+    const rimg = rx.getImageData(0,0,S,S); const rd = rimg.data;
+    for(let i=0;i<rd.length;i+=4){ const n=(Math.random()*30-15); const v=clamp(200+n,150,235); rd[i]=rd[i+1]=rd[i+2]=v; rd[i+3]=255; }
+    rx.putImageData(rimg,0,0);
+
+    // --- normal canvas — start flat (128,128,255) ---
+    const nc = document.createElement("canvas"); nc.width = nc.height = S;
+    const nx = nc.getContext("2d");
+    nx.fillStyle = "rgb(128,128,255)"; nx.fillRect(0,0,S,S);
+
+    // --- AO canvas — white (no occlusion) by default; we darken grooves ---
+    const ac = document.createElement("canvas"); ac.width = ac.height = S;
+    const ax = ac.getContext("2d");
+    ax.fillStyle = "#fff"; ax.fillRect(0,0,S,S);
+
+    // helper to draw a horizontal groove (plank seam) into all maps
+    const plankH = S/planks;
+    function drawSeam(cy){
+      // colour: dark line + a faint highlight lip above it (lit from top-left)
+      cx.strokeStyle = "rgba(40,40,44,0.55)"; cx.lineWidth = 3;
+      cx.beginPath(); cx.moveTo(0,cy); cx.lineTo(S,cy); cx.stroke();
+      cx.strokeStyle = "rgba(220,220,224,0.28)"; cx.lineWidth = 1.5;
+      cx.beginPath(); cx.moveTo(0,cy-2.5); cx.lineTo(S,cy-2.5); cx.stroke();
+      // roughness: seam is rougher (brighter in roughness map)
+      rx.strokeStyle = "rgba(255,255,255,0.6)"; rx.lineWidth = 4;
+      rx.beginPath(); rx.moveTo(0,cy); rx.lineTo(S,cy); rx.stroke();
+      // AO: groove is occluded (darker)
+      const g = ax.createLinearGradient(0,cy-5,0,cy+5);
+      g.addColorStop(0,"rgba(0,0,0,0)"); g.addColorStop(0.5,"rgba(0,0,0,0.45)"); g.addColorStop(1,"rgba(0,0,0,0)");
+      ax.fillStyle = g; ax.fillRect(0,cy-5,S,10);
+      // normal: a small ridge → bright(up)/dark(down) green-channel banding
+      nx.strokeStyle = "rgba(128,210,255,0.9)"; nx.lineWidth = 2;      // surface tilts up just above seam
+      nx.beginPath(); nx.moveTo(0,cy-2); nx.lineTo(S,cy-2); nx.stroke();
+      nx.strokeStyle = "rgba(128,46,255,0.9)"; nx.lineWidth = 2;       // tilts down just below
+      nx.beginPath(); nx.moveTo(0,cy+2); nx.lineTo(S,cy+2); nx.stroke();
+    }
+    for(let p=1;p<planks;p++) drawSeam(Math.round(p*plankH));
+
+    // wood-grain form lines INSIDE each plank — subtle horizontal striations that
+    // give board-formed concrete its signature timber-imprint texture.
+    cx.save();
+    for(let p=0;p<planks;p++){
+      const y0 = p*plankH, y1 = (p+1)*plankH;
+      const lines = 9 + ((Math.random()*5)|0);
+      for(let l=0;l<lines;l++){
+        const yy = y0 + (l+0.5)/lines*(y1-y0) + (Math.random()*3-1.5);
+        const a = 0.05 + Math.random()*0.06;
+        cx.strokeStyle = `rgba(70,70,74,${a})`;
+        cx.lineWidth = 0.8 + Math.random()*0.7;
+        cx.beginPath();
+        // gentle wave so grain isn't ruler-straight
+        cx.moveTo(0, yy);
+        for(let xx=0; xx<=S; xx+=32){ cx.lineTo(xx, yy + Math.sin(xx*0.02 + p)*0.8); }
+        cx.stroke();
+        // matching faint normal striation
+        nx.strokeStyle = `rgba(128,${138+((Math.random()*16)|0)},255,0.20)`;
+        nx.lineWidth = 0.8; nx.beginPath(); nx.moveTo(0,yy); nx.lineTo(S,yy); nx.stroke();
+      }
+    }
+    cx.restore();
+
+    // form-tie cone holes — the little recessed circles brutalist concrete is known
+    // for, on a regular grid. Dark dot + AO well + normal divot.
+    const cols = 4, rows = planks;
+    for(let r=0;r<rows;r++){
+      for(let c=0;c<cols;c++){
+        const tx = (c+0.5)/cols*S, ty = (r+0.5)/rows*S;
+        // colour: dark recessed cone
+        const rg = cx.createRadialGradient(tx,ty,0, tx,ty,7);
+        rg.addColorStop(0,"rgba(30,30,34,0.85)"); rg.addColorStop(0.6,"rgba(60,60,64,0.5)"); rg.addColorStop(1,"rgba(0,0,0,0)");
+        cx.fillStyle = rg; cx.beginPath(); cx.arc(tx,ty,7,0,Math.PI*2); cx.fill();
+        // AO well
+        const ag = ax.createRadialGradient(tx,ty,0, tx,ty,9);
+        ag.addColorStop(0,"rgba(0,0,0,0.55)"); ag.addColorStop(1,"rgba(0,0,0,0)");
+        ax.fillStyle = ag; ax.beginPath(); ax.arc(tx,ty,9,0,Math.PI*2); ax.fill();
+        // normal divot (left bright, right dark for a round dent)
+        const ng = nx.createRadialGradient(tx-2,ty-2,0, tx,ty,7);
+        ng.addColorStop(0,"rgba(150,150,255,0.7)"); ng.addColorStop(1,"rgba(128,128,255,0)");
+        nx.fillStyle = ng; nx.beginPath(); nx.arc(tx,ty,7,0,Math.PI*2); nx.fill();
+      }
+    }
+
+    const mk = (canvas, srgb)=>{
+      const t = new THREE.CanvasTexture(canvas);
+      t.wrapS = t.wrapT = THREE.RepeatWrapping;
+      t.anisotropy = MAX_ANISO;
+      if(srgb){ if("colorSpace" in t) t.colorSpace = THREE.SRGBColorSpace; else t.encoding = THREE.sRGBEncoding; }
+      return t;
+    };
+    return { map:mk(cc,true), roughnessMap:mk(rc,false), normalMap:mk(nc,false), aoMap:mk(ac,false) };
+  }
+
+  // build the shared concrete map set once
+  const CMAP = makeConcreteMaps({ planks:7, base:150 });
+
+  function concreteMaterial(o){
+    o = o || {};
+    const m = new THREE.MeshStandardMaterial({
+      color: o.color!==undefined ? o.color : 0xbdbab2,
+      map: CMAP.map,
+      roughnessMap: CMAP.roughnessMap,
+      normalMap: CMAP.normalMap,
+      aoMap: CMAP.aoMap,
+      roughness: 0.94,
+      metalness: 0.0,
+      envMapIntensity: o.env!==undefined ? o.env : 0.22
+    });
+    m.normalScale = new THREE.Vector2(o.nrm!==undefined?o.nrm:0.85, o.nrm!==undefined?o.nrm:0.85);
+    m.aoMapIntensity = o.ao!==undefined ? o.ao : 1.15;     // strong AO in the recesses
+    return m;
+  }
+
+  // -------------------------------------------------------------------------
+  // ROOM — a board-formed concrete hall: back wall, two side walls, a heavy
+  // ceiling soffit, and a raw concrete floor with a raised plinth under the bank.
+  // PlaneGeometry needs a 2nd UV set (uv2) for aoMap → we copy uv→uv2.
+  // -------------------------------------------------------------------------
+  function withUV2(geo, repeatU, repeatV){
+    geo.setAttribute("uv2", new THREE.BufferAttribute(geo.attributes.uv.array, 2));
+    return geo;
+  }
+  const FLOOR_Y = 0;
+  (function buildRoom(){
+    // BACK WALL — the big board-formed slab behind the bank (the hero concrete face)
+    const backMat = concreteMaterial({ color:0xc4c1b9, env:0.20, ao:1.25, nrm:1.0 });
+    backMat.map = CMAP.map.clone(); backMat.map.needsUpdate = true;
+    backMat.map.repeat.set(3.2, 1.6); backMat.map.wrapS = backMat.map.wrapT = THREE.RepeatWrapping;
+    backMat.roughnessMap = CMAP.roughnessMap; backMat.normalMap = CMAP.normalMap; backMat.aoMap = CMAP.aoMap;
+    backMat.roughnessMap.repeat = backMat.normalMap.repeat = backMat.aoMap.repeat = backMat.map.repeat;
+    const back = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 24)), backMat);
+    back.position.set(0, 6, -7.6); back.receiveShadow = true; scene.add(back);
+
+    // SIDE WALLS — darker grey, recede
+    const sideMat = concreteMaterial({ color:0x9d9a92, env:0.16, ao:1.2, nrm:0.8 });
+    const left = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat);
+    left.position.set(-14, 6, 2); left.rotation.y = Math.PI/2; left.receiveShadow = true; scene.add(left);
+    const right = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(40, 24)), sideMat.clone());
+    right.position.set(14, 6, 2); right.rotation.y = -Math.PI/2; right.receiveShadow = true; scene.add(right);
+
+    // CEILING SOFFIT — heavy dark slab so the key reads as a shaft from a gap
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(60, 40),
+      new THREE.MeshStandardMaterial({ color:0x6f6d68, roughness:1.0, metalness:0.0 }));
+    ceil.position.set(0, 13, 0); ceil.rotation.x = Math.PI/2; ceil.receiveShadow = true; scene.add(ceil);
+
+    // FLOOR — raw matte concrete (no clearcoat: brutalist floor is NOT polished)
+    const floorMat = concreteMaterial({ color:0xa7a49c, env:0.14, ao:1.1, nrm:0.6 });
+    floorMat.map = CMAP.map.clone(); floorMat.map.repeat.set(8, 6);
+    floorMat.map.wrapS = floorMat.map.wrapT = THREE.RepeatWrapping; floorMat.map.needsUpdate = true;
+    floorMat.roughness = 0.97;
+    const floor = new THREE.Mesh(withUV2(new THREE.PlaneGeometry(60, 40)), floorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true; scene.add(floor);
+
+    // RAISED PLINTH — a low concrete dais the wing bank sits on (architectural base)
+    const plinthMat = concreteMaterial({ color:0xb4b1a9, env:0.18, ao:1.2, nrm:0.7 });
+    const plinth = new THREE.Mesh(new THREE.BoxGeometry(20, 0.32, 4.4), plinthMat);
+    // box geo already has uv2-able uvs per face; ensure uv2 exists
+    plinth.geometry.setAttribute("uv2", new THREE.BufferAttribute(plinth.geometry.attributes.uv.array, 2));
+    plinth.position.set(0, 0.16, -4.2); plinth.castShadow = true; plinth.receiveShadow = true; scene.add(plinth);
+
+    // two heavy concrete pilasters framing the bank (symmetry / depth)
+    const pilMat = concreteMaterial({ color:0x96938b, env:0.16, ao:1.25, nrm:0.9 });
+    [-8.6, 8.6].forEach(px=>{
+      const pil = new THREE.Mesh(new THREE.BoxGeometry(1.5, 22, 1.5), pilMat.clone());
+      pil.geometry.setAttribute("uv2", new THREE.BufferAttribute(pil.geometry.attributes.uv.array, 2));
+      pil.position.set(px, 11, -6.6); pil.castShadow = true; pil.receiveShadow = true; scene.add(pil);
+    });
+  })();
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin-framed boards (~2" apart) on a side swivel; the focused
+  // board swings face-on showing a CLEAN full pattern face. Frames are raw steel
+  // (charcoal, low-gloss) to read against the concrete — still monochrome.
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face (1u ≈ 20.5")
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.04;       // thin frame border
+  const FRAME_D = 0.05;
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.4;
+  const PLINTH_TOP = 0.32;    // boards stand on the plinth
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // raw blackened-steel frame — charcoal, slightly metallic, picks up the env
+  const frameMat = new THREE.MeshStandardMaterial({ color:0x2b2b2e, roughness:0.42, metalness:0.78, envMapIntensity:0.6 });
+  function placeholderFace(){
+    return new THREE.MeshStandardMaterial({ color:0x6a6862, roughness:0.7, metalness:0.0, envMapIntensity:0.18 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    // backing slab (thin) — concrete-toned so closed slivers still read as material
+    const backing = new THREE.Mesh(
+      new THREE.BoxGeometry(BOARD_W + FRAME_T*1.6, BOARD_H + FRAME_T*1.6, 0.035),
+      new THREE.MeshStandardMaterial({ color:0x4f4d49, roughness:0.9, metalness:0.04 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.025);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+  }
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy: only featured board + a small neighbour window.
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);          // one clean full motif edge-to-edge on the open face
+        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;
+      // The pattern face is a flat, low-spec surface so the COLOUR pops cleanly
+      // against the monochrome room (no clearcoat sheen washing it out).
+      const m = new THREE.MeshStandardMaterial({
+        map:tex, roughness:0.62, metalness:0.0, envMapIntensity:0.18
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.abs(i-focusIdx);
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT helpers
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.018; }
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.55;
+
+      b.group.position.x += (targetX - b.group.position.x)*Math.min(1,dt*9);
+      b.group.position.z += (targetZ - b.group.position.z)*Math.min(1,dt*9);
+      b.group.position.y = PLINTH_TOP;          // standing on the plinth
+
+      // Brutalist read = light driven by the room (hard key + AO), NOT a per-board
+      // brightness fade. We keep neighbours fully material; only a mild recede via
+      // fog + the raking shadow. Featured board glides forward into the shaft.
+      const farOff = Math.abs(rel) > 8;
+      b.group.visible = !farOff;
+    }
+
+    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.__BC_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, PLINTH_TOP, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Hanging ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__BC__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__BC_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":"V15","label":"V15 — Brutalist Concrete","elements":[
+  {"n":1,"label":"Board-formed concrete hall — procedural normal/roughness, wood-grain form lines"},
+  {"n":2,"label":"One hard directional key light, crisp raking shadows"},
+  {"n":3,"label":"Strong baked ambient occlusion in the recesses + form-tie holes"},
+  {"n":4,"label":"Monochrome grey/charcoal restraint — the pattern is the only colour"},
+  {"n":5,"label":"Thin steel-framed boards swivel open to a clean full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>
diff --git a/public/proto/v17-tatami.html b/public/proto/v17-tatami.html
new file mode 100644
index 0000000..c361f5d
--- /dev/null
+++ b/public/proto/v17-tatami.html
@@ -0,0 +1,723 @@
+<!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>TATAMI · China Seas</title>
+<style>
+  :root { --sumi:#2a2620; --washi:#efe7d6; --tea:#8a7a5c; }
+  * { box-sizing:border-box; margin:0; padding:0; }
+  html,body { height:100%; overflow:hidden; background:#e9e2d2; color:var(--sumi);
+    font-family:"Hiragino Mincho ProN","Yu Mincho",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:20; padding:46px 34px 60px;
+    pointer-events:none; text-align:center;
+    background:linear-gradient(180deg, rgba(233,226,210,.78) 0%, rgba(233,226,210,0) 100%); }
+  .masthead .kicker { font-size:11px; letter-spacing:.5em; text-transform:uppercase;
+    color:#9a8a68; font-weight:600; }
+  .masthead h1 { font-size:clamp(26px,4.4vw,46px); font-weight:400; letter-spacing:.34em;
+    line-height:1; margin-top:11px; color:#33302a; padding-left:.34em; }
+  .masthead h1 b { font-weight:600; color:#6f5f43; }
+  .masthead .sub { margin-top:12px; font-size:12.5px; letter-spacing:.05em; color:#7a715e;
+    max-width:560px; margin-left:auto; margin-right:auto; font-style:italic; }
+
+  /* ---- focused board caption ---- */
+  #caption { position:fixed; left:50%; bottom:92px; transform:translateX(-50%) translateY(14px);
+    z-index:22; text-align:center; opacity:0; transition:opacity .55s, transform .55s cubic-bezier(.2,.8,.2,1);
+    pointer-events:none; }
+  #caption.show { opacity:1; transform:translateX(-50%) translateY(0); }
+  #caption .cn { font-size:23px; letter-spacing:.04em; color:#34302a;
+    text-shadow:0 1px 10px rgba(255,250,238,.7); }
+  #caption .cc { margin-top:6px; font-size:10.5px; letter-spacing:.38em; text-transform:uppercase; color:#8a7a5c; }
+  #caption .cspec { margin-top:9px; font-size:11.5px; letter-spacing:.05em; color:#766c59; }
+  #caption a.cta { pointer-events:auto; display:inline-block; margin-top:15px; padding:10px 28px;
+    border:1px solid rgba(122,108,82,.5); border-radius:2px; color:#5d5038; font-size:10.5px;
+    letter-spacing:.26em; text-transform:uppercase; text-decoration:none; font-weight:600;
+    background:rgba(255,250,238,.4); transition:background .15s, transform .15s; }
+  #caption a.cta:hover { background:rgba(255,250,238,.85); transform:translateY(-1px); }
+
+  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
+    font-size:11px; letter-spacing:.32em; text-transform:uppercase; color:rgba(80,70,52,.5);
+    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:#7a6c4e; background:rgba(247,242,232,.66); border:1px solid rgba(122,108,82,.28); border-radius:6px;
+    padding:6px 10px; letter-spacing:.04em; cursor:pointer; -webkit-user-select:none; user-select:none; }
+  #fps.off { opacity:.34; }
+
+  /* ---- loading ---- */
+  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
+    align-items:center; justify-content:center; background:#e9e2d2; transition:opacity .9s; }
+  #boot .ring { width:44px; height:44px; border-radius:50%;
+    border:2px solid rgba(122,108,82,.22); border-top-color:#8a7a5c; animation:spin 1.1s linear infinite; }
+  #boot .lbl { margin-top:18px; font-size:11px; letter-spacing:.4em; text-transform:uppercase; color:#8a7a5c;
+    font-style:italic; }
+  @keyframes spin { to { transform:rotate(360deg); } }
+
+  @media (max-width:720px){ .masthead .sub{display:none;} .masthead{padding-top:40px;} }
+</style>
+</head>
+<body>
+<div id="app"></div>
+
+<div class="masthead">
+  <div class="kicker">Designer Wallcoverings · 茶室 The Tea House</div>
+  <h1>TATAMI<b>畳</b></h1>
+  <div class="sub">China Seas, presented in the stillness of a shoji-lit tea room. Soft north light
+    glows through the paper screens; one board steps forward at a time — full pattern, edge to edge.
+    Drag to slide the bank; tap a board to bring it to center.</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">Quieting the room…</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>
+"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.08;          // calm, slightly lifted for the bright tea room
+  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
+  renderer.shadowMap.enabled = true;
+  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
+  renderer.setClearColor(0xe9e2d2, 1);
+  app.appendChild(renderer.domElement);
+  const MAX_ANISO = renderer.capabilities.getMaxAnisotropy();
+
+  const scene = new THREE.Scene();
+  scene.background = new THREE.Color(0xe7e0d0);
+  // very soft warm haze for depth — the air of a quiet room, not murk
+  scene.fog = new THREE.FogExp2(0xe5ddcb, 0.014);
+
+  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) — soft IBL so wood/paper read with
+  // gentle reflected light rather than flat ambient.
+  // -------------------------------------------------------------------------
+  (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 — serene diffuse NORTH light. A broad cool-warm key glows through
+  // the shoji from the left; gentle warm fill; the shoji panels themselves are
+  // softly emissive so the paper appears lit from behind.
+  // -------------------------------------------------------------------------
+  scene.add(new THREE.HemisphereLight(0xf6efe0, 0x9c8e72, 0.55));   // sky/ground bounce
+  scene.add(new THREE.AmbientLight(0xfff6e6, 0.20));
+
+  // soft diffuse north key — comes through the left screen wall, low intensity,
+  // big soft shadow (the calm raking light of an overcast north window)
+  const key = new THREE.DirectionalLight(0xfff4e2, 0.62);
+  key.position.set(-6, 8, 7);
+  key.castShadow = true;
+  key.shadow.mapSize.set(2048, 2048);
+  key.shadow.camera.near = 1; key.shadow.camera.far = 44;
+  key.shadow.camera.left = -11; key.shadow.camera.right = 11;
+  key.shadow.camera.top = 12; key.shadow.camera.bottom = -4;
+  key.shadow.bias = -0.0004; key.shadow.radius = 6;       // very soft penumbra
+  scene.add(key);
+
+  // warm fill from the right so the far side of each board isn't dead
+  const fill = new THREE.DirectionalLight(0xffe9c8, 0.20);
+  fill.position.set(7, 4, 5); scene.add(fill);
+
+  // a low warm bounce from the tatami floor
+  const floorBounce = new THREE.DirectionalLight(0xf2e2bf, 0.10);
+  floorBounce.position.set(0, -3, 6); scene.add(floorBounce);
+
+  // -------------------------------------------------------------------------
+  // PROCEDURAL TEXTURES (canvas → texture, $0 local)
+  // -------------------------------------------------------------------------
+  // tatami weave: tight horizontal/vertical rush-grass weave, soft green-gold
+  function makeTatamiTexture(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#cdbf92"; x.fillRect(0,0,256,256);
+    // fine vertical rush lines
+    for(let i=0;i<256;i+=2){
+      const v = 150 + ((Math.random()*40)|0);
+      x.strokeStyle = `rgba(${v-10},${v},${v-40},0.45)`;
+      x.beginPath(); x.moveTo(i+0.5,0); x.lineTo(i+0.5,256); x.stroke();
+    }
+    // subtle horizontal weave banding
+    for(let j=0;j<256;j+=1){
+      x.fillStyle = `rgba(120,110,70,${(Math.sin(j*0.7)*0.04+0.04).toFixed(3)})`;
+      x.fillRect(0,j,256,1);
+    }
+    const t = new THREE.CanvasTexture(c);
+    t.wrapS = t.wrapT = THREE.RepeatWrapping; t.anisotropy = MAX_ANISO;
+    return t;
+  }
+  // tatami roughness — slightly varied so it isn't a flat mat
+  function makeTatamiRough(){
+    const c = document.createElement("canvas"); c.width = c.height = 128;
+    const x = c.getContext("2d"); x.fillStyle="#b0b0b0"; x.fillRect(0,0,128,128);
+    const img = x.getImageData(0,0,128,128); const d=img.data;
+    for(let i=0;i<d.length;i+=4){ const n=(Math.random()*40)|0; d[i]=d[i+1]=d[i+2]=160+n; }
+    x.putImageData(img,0,0);
+    const t=new THREE.CanvasTexture(c); t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
+  }
+  // shoji paper (washi) — soft mottled fibrous off-white, warm, faint grain
+  function makeWashiTexture(){
+    const c = document.createElement("canvas"); c.width = c.height = 256;
+    const x = c.getContext("2d");
+    x.fillStyle = "#f3ecdc"; x.fillRect(0,0,256,256);
+    // fibrous flecks
+    for(let i=0;i<2200;i++){
+      const px=(Math.random()*256)|0, py=(Math.random()*256)|0;
+      const a=(Math.random()*0.05).toFixed(3);
+      x.fillStyle = Math.random()<0.5 ? `rgba(210,196,168,${a})` : `rgba(255,252,244,${a})`;
+      x.fillRect(px,py,1+(Math.random()*2|0),1);
+    }
+    const t = new THREE.CanvasTexture(c);
+    t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
+  }
+  // light wood grain (hinoki / cedar post-and-beam) — warm, straight grain
+  function makeWoodTexture(){
+    const c = document.createElement("canvas"); c.width=64; c.height=256;
+    const x = c.getContext("2d");
+    const g = x.createLinearGradient(0,0,64,0);
+    g.addColorStop(0,"#caa978"); g.addColorStop(0.5,"#d8bd92"); g.addColorStop(1,"#bd9b6c");
+    x.fillStyle=g; x.fillRect(0,0,64,256);
+    for(let i=0;i<26;i++){
+      const xx = Math.random()*64;
+      x.strokeStyle = `rgba(120,92,56,${(Math.random()*0.18+0.05).toFixed(3)})`;
+      x.lineWidth = Math.random()*1.4+0.3;
+      x.beginPath(); x.moveTo(xx, 0);
+      for(let yy=0; yy<256; yy+=12){ x.lineTo(xx + Math.sin(yy*0.05)*2, yy); }
+      x.stroke();
+    }
+    const t = new THREE.CanvasTexture(c);
+    t.wrapS=t.wrapT=THREE.RepeatWrapping; t.anisotropy=MAX_ANISO; return t;
+  }
+
+  const TATAMI_MAP = makeTatamiTexture();
+  const TATAMI_ROUGH = makeTatamiRough();
+  const WASHI_MAP = makeWashiTexture();
+  const WOOD_MAP = makeWoodTexture();
+
+  // -------------------------------------------------------------------------
+  // ROOM — tatami floor, shoji screen walls (softly emissive paper held in a
+  // wood lattice), warm post-and-beam frame, low ceiling. Serene & minimal.
+  // -------------------------------------------------------------------------
+  const FLOOR_Y = 0;
+  const ROOM_HALF = 11;        // ~20-22' room half-width
+  const WALL_Z = -7.6;
+  const CEIL_Y = 7.2;
+
+  function woodMat(rep){
+    const m = WOOD_MAP.clone(); m.needsUpdate = true; m.repeat.set(rep[0], rep[1]);
+    return new THREE.MeshPhysicalMaterial({ map:m, color:0xffffff, roughness:0.62, metalness:0.0,
+      clearcoat:0.06, clearcoatRoughness:0.7, envMapIntensity:0.5 });
+  }
+
+  (function buildRoom(){
+    // TATAMI FLOOR — large mats, woven rush, soft green-gold, low spec
+    const tMap = TATAMI_MAP.clone(); tMap.needsUpdate = true; tMap.repeat.set(14, 14);
+    const tRough = TATAMI_ROUGH.clone(); tRough.needsUpdate = true; tRough.repeat.set(14,14);
+    const floorMat = new THREE.MeshPhysicalMaterial({
+      map:tMap, roughnessMap:tRough, roughness:0.92, metalness:0.0, envMapIntensity:0.35
+    });
+    const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 34), floorMat);
+    floor.rotation.x = -Math.PI/2; floor.position.y = FLOOR_Y; floor.receiveShadow = true;
+    scene.add(floor);
+
+    // black tatami-mat border seams (a few thin dark strips) for the woven-mat read
+    const seamMat = new THREE.MeshStandardMaterial({ color:0x2c281f, roughness:0.85, metalness:0.0 });
+    for(let i=-2;i<=2;i++){
+      const seam = new THREE.Mesh(new THREE.PlaneGeometry(0.06, 34), seamMat);
+      seam.rotation.x = -Math.PI/2; seam.position.set(i*4.0, 0.002, 0); seam.receiveShadow = true;
+      scene.add(seam);
+    }
+    const seamH = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 0.06), seamMat);
+    seamH.rotation.x = -Math.PI/2; seamH.position.set(0, 0.002, -2.0); scene.add(seamH);
+
+    // SHOJI SCREEN WALL builder — a softly back-lit washi panel inside a wood
+    // lattice grid. The paper is emissive (low) so it reads as glowing with the
+    // diffuse north light behind it. Transmission/translucency handled via a
+    // gentle emissive + high roughness physical material (cheap & stable on r128).
+    function buildShojiWall(width, height, cx, cy, cz, ry){
+      const wall = new THREE.Group();
+      // the glowing paper panel
+      const paperMap = WASHI_MAP.clone(); paperMap.needsUpdate = true;
+      paperMap.repeat.set(Math.max(2, width/2), Math.max(2, height/2));
+      const paperMat = new THREE.MeshPhysicalMaterial({
+        map:paperMap, color:0xf6efe0,
+        roughness:0.95, metalness:0.0,
+        transmission:0.35, thickness:0.4, ior:1.2,
+        emissive:0xfff3df, emissiveIntensity:0.42,    // soft glow = north light through paper
+        envMapIntensity:0.4, transparent:true, opacity:1.0
+      });
+      const paper = new THREE.Mesh(new THREE.PlaneGeometry(width, height), paperMat);
+      paper.position.set(0,0,0); paper.receiveShadow = false;
+      wall.add(paper);
+
+      // a faint light-card behind the paper to fake the glowing window beyond
+      const glow = new THREE.Mesh(new THREE.PlaneGeometry(width*0.98, height*0.98),
+        new THREE.MeshBasicMaterial({ color:0xfff8ec, transparent:true, opacity:0.5 }));
+      glow.position.z = -0.18; wall.add(glow);
+
+      // wood lattice (kumiko) — thin instanced bars over the paper. Vertical +
+      // horizontal mullions create the classic shoji grid.
+      const barMat = woodMat([1,4]);
+      const VCOLS = Math.max(3, Math.round(width/0.95));
+      const HROWS = Math.max(2, Math.round(height/0.95));
+      const vGeo = new THREE.BoxGeometry(0.035, height, 0.05);
+      const hGeo = new THREE.BoxGeometry(width, 0.035, 0.05);
+      const vMesh = new THREE.InstancedMesh(vGeo, barMat, VCOLS+1);
+      const hMesh = new THREE.InstancedMesh(hGeo, barMat, HROWS+1);
+      const dum = new THREE.Object3D();
+      for(let i=0;i<=VCOLS;i++){
+        dum.position.set(-width/2 + (i/VCOLS)*width, 0, 0.04); dum.rotation.set(0,0,0);
+        dum.updateMatrix(); vMesh.setMatrixAt(i, dum.matrix);
+      }
+      for(let j=0;j<=HROWS;j++){
+        dum.position.set(0, -height/2 + (j/HROWS)*height, 0.04);
+        dum.updateMatrix(); hMesh.setMatrixAt(j, dum.matrix);
+      }
+      vMesh.castShadow = false; hMesh.castShadow = false;
+      wall.add(vMesh, hMesh);
+
+      // heavy outer wood frame (post-and-beam stiles)
+      const frame = woodMat([1,2]);
+      const stileL = new THREE.Mesh(new THREE.BoxGeometry(0.14, height+0.14, 0.16), frame);
+      stileL.position.set(-width/2-0.07, 0, 0.03); stileL.castShadow=true;
+      const stileR = stileL.clone(); stileR.position.x = width/2+0.07;
+      const railT = new THREE.Mesh(new THREE.BoxGeometry(width+0.28, 0.14, 0.16), frame);
+      railT.position.set(0, height/2+0.07, 0.03); railT.castShadow=true;
+      const railB = railT.clone(); railB.position.y = -height/2-0.07;
+      wall.add(stileL, stileR, railT, railB);
+
+      wall.position.set(cx, cy, cz); wall.rotation.y = ry;
+      scene.add(wall);
+      return wall;
+    }
+
+    // BACK shoji wall (the glowing north light directly behind the bank)
+    buildShojiWall(ROOM_HALF*2+2, CEIL_Y-0.4, 0, (CEIL_Y-0.4)/2, WALL_Z, 0);
+    // LEFT shoji wall — strongest light source side
+    buildShojiWall(28, CEIL_Y-0.4, -ROOM_HALF-0.2, (CEIL_Y-0.4)/2, 2, Math.PI/2);
+    // RIGHT shoji wall (dimmer side, no extra glow card emphasis but same build)
+    buildShojiWall(28, CEIL_Y-0.4, ROOM_HALF+0.2, (CEIL_Y-0.4)/2, 2, -Math.PI/2);
+
+    // CEILING — warm wood plank ceiling with exposed beams
+    const ceilMap = WOOD_MAP.clone(); ceilMap.needsUpdate = true; ceilMap.repeat.set(10, 4);
+    const ceil = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_HALF*2+6, 34),
+      new THREE.MeshStandardMaterial({ map:ceilMap, color:0xbfa97f, roughness:0.8, metalness:0.0 }));
+    ceil.position.set(0, CEIL_Y, 0); ceil.rotation.x = Math.PI/2; scene.add(ceil);
+
+    // exposed dark beams across the ceiling
+    const beamMat = new THREE.MeshStandardMaterial({ map:WOOD_MAP.clone(), color:0x8f7551, roughness:0.7, metalness:0.0 });
+    beamMat.map.repeat.set(1,6); beamMat.map.needsUpdate = true;
+    const beamGeo = new THREE.BoxGeometry(ROOM_HALF*2+4, 0.22, 0.34);
+    for(let i=-3;i<=3;i++){
+      const beam = new THREE.Mesh(beamGeo, beamMat);
+      beam.position.set(0, CEIL_Y-0.12, i*3.4); beam.castShadow = true;
+      scene.add(beam);
+    }
+
+    // CORNER POSTS — warm post-and-beam columns at the room corners
+    const postGeo = new THREE.BoxGeometry(0.28, CEIL_Y, 0.28);
+    const postMat = woodMat([1,5]);
+    [[-ROOM_HALF+0.2, WALL_Z+0.3],[ROOM_HALF-0.2, WALL_Z+0.3],
+     [-ROOM_HALF+0.2, 6.5],[ROOM_HALF-0.2, 6.5]].forEach(([px,pz])=>{
+      const post = new THREE.Mesh(postGeo, postMat);
+      post.position.set(px, CEIL_Y/2, pz); post.castShadow = true; post.receiveShadow = true;
+      scene.add(post);
+    });
+
+    // a low wooden floor-runner / step beneath the bank to ground it
+    const runner = new THREE.Mesh(new THREE.BoxGeometry(ROOM_HALF*2, 0.10, 1.4),
+      new THREE.MeshStandardMaterial({ map:WOOD_MAP.clone(), color:0xb89a6e, roughness:0.62, metalness:0.0 }));
+    runner.material.map.repeat.set(8,1); runner.material.map.needsUpdate = true;
+    runner.position.set(0, 0.05, WALL_Z+1.5); runner.castShadow = true; runner.receiveShadow = true;
+    scene.add(runner);
+  })();
+
+  // -------------------------------------------------------------------------
+  // THE WING BANK — thin light-wood-framed boards, ~2" apart, side swivel.
+  // (mechanic mirrors the established v3 reference; reskinned for the tea room)
+  // -------------------------------------------------------------------------
+  const BOARD_W = 1.46;       // ~30" wide board face
+  const BOARD_H = 3.55;       // ~6' tall
+  const FRAME_T = 0.045;
+  const FRAME_D = 0.05;
+  const GAP = 0.10;           // ~2" between closed boards
+  const PITCH = BOARD_W + GAP;
+  const BANK_Z = -4.0;
+  const N_TARGET = 50;
+
+  const boards = [];
+  let products = [];
+  let focusIdx = 0;
+
+  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  // light hinoki-wood frame, warm, low metalness (tea-house, not bronze)
+  const frameMap = WOOD_MAP.clone(); frameMap.repeat.set(1,4); frameMap.needsUpdate = true;
+  const frameMat = new THREE.MeshPhysicalMaterial({
+    map:frameMap, color:0xd9c197, roughness:0.55, metalness:0.0,
+    clearcoat:0.08, clearcoatRoughness:0.6, envMapIntensity:0.5
+  });
+
+  function placeholderFace(){
+    return new THREE.MeshPhysicalMaterial({ color:0xe8e0cf, roughness:0.7, metalness:0.0, envMapIntensity:0.4 });
+  }
+
+  function buildBoard(idx){
+    const g = new THREE.Group();
+    // thin backing board (pale wood)
+    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:0xe7ddc8, roughness:0.8, metalness:0.0 })
+    );
+    backing.position.set(BOARD_W/2, BOARD_H/2 + 0.02, -0.02);
+    backing.castShadow = true; backing.receiveShadow = true;
+    g.add(backing);
+
+    const faceMat = placeholderFace();
+    const face = new THREE.Mesh(faceGeo, faceMat);
+    face.position.set(BOARD_W/2, BOARD_H/2 + 0.02, 0.012);
+    face.castShadow = false; face.receiveShadow = true;
+    face.userData.idx = idx;
+    g.add(face);
+
+    // four thin light-wood frame bars (per-board so they swivel with the hinge)
+    const fb = new THREE.BoxGeometry(BOARD_W + FRAME_T*2, FRAME_T, FRAME_D);
+    const fv = new THREE.BoxGeometry(FRAME_T, BOARD_H + FRAME_T*2, FRAME_D);
+    const top = new THREE.Mesh(fb, frameMat); top.position.set(BOARD_W/2, BOARD_H + FRAME_T/2 + 0.02, 0.02); top.castShadow = true;
+    const bot = new THREE.Mesh(fb, frameMat); bot.position.set(BOARD_W/2, FRAME_T/2 + 0.02, 0.02); bot.castShadow = true;
+    const lf  = new THREE.Mesh(fv, frameMat); lf.position.set(-FRAME_T/2, BOARD_H/2 + 0.02, 0.02); lf.castShadow = true;
+    const rt  = new THREE.Mesh(fv, frameMat); rt.position.set(BOARD_W + FRAME_T/2, BOARD_H/2 + 0.02, 0.02); rt.castShadow = true;
+    g.add(top, bot, lf, rt);
+
+    scene.add(g);
+    return { group:g, face, faceMat, idx, data:null, texLoaded:false, swivel:0 };
+  }
+
+  // -------------------------------------------------------------------------
+  // FLOOR REFLECTION — a soft dimmed mirror of the featured face on the tatami.
+  // Tatami is matte, so this is very subtle (a faint settled glow).
+  // -------------------------------------------------------------------------
+  const reflGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
+  const reflMat = new THREE.MeshBasicMaterial({ transparent:true, opacity:0.0, depthWrite:false, side:THREE.FrontSide });
+  const reflMesh = new THREE.Mesh(reflGeo, reflMat);
+  reflMesh.scale.y = -1; reflMesh.renderOrder = 1;
+  scene.add(reflMesh);
+
+  // -------------------------------------------------------------------------
+  // TEXTURE LOADER — lazy window around the featured board
+  // -------------------------------------------------------------------------
+  const texCache = new Map();
+  function imgURL(u){
+    if(!u) return null;
+    if(/cdn\.shopify\.com/.test(u) || /^\//.test(u)) return u;
+    return "/api/proxy/image?url=" + encodeURIComponent(u);
+  }
+  function loadTexture(url){
+    if(texCache.has(url)) return Promise.resolve(texCache.get(url));
+    return new Promise((resolve)=>{
+      const img = new Image(); img.crossOrigin = "anonymous";
+      img.onload = ()=>{
+        const tex = new THREE.Texture(img);
+        if("colorSpace" in tex) tex.colorSpace = THREE.SRGBColorSpace;
+        else tex.encoding = THREE.sRGBEncoding;
+        tex.anisotropy = MAX_ANISO;
+        tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
+        tex.repeat.set(1, 1.0);     // one clean full motif edge-to-edge
+        tex.needsUpdate = true;
+        texCache.set(url, tex);
+        resolve(tex);
+      };
+      img.onerror = ()=>resolve(null);
+      img.src = url;
+    });
+  }
+  function ensureBoardTexture(b){
+    if(b.texLoaded || !b.data) return;
+    const url = imgURL(b.data.image || b.data.tile);
+    if(!url){ b.texLoaded = true; return; }
+    b.texLoaded = true;
+    loadTexture(url).then(tex=>{
+      if(!tex) return;
+      const m = new THREE.MeshPhysicalMaterial({
+        map:tex, roughness:0.62, metalness:0.0, clearcoat:0.06, clearcoatRoughness:0.6, envMapIntensity:0.3
+      });
+      b.face.material.dispose && b.face.material.dispose();
+      b.face.material = m; b.faceMat = m;
+    });
+  }
+  function refreshTextureWindow(){
+    const WIN = 4;
+    for(let i=0;i<boards.length;i++){
+      const d = Math.abs(i-focusIdx);
+      if(d <= WIN) ensureBoardTexture(boards[i]);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // LAYOUT
+  // -------------------------------------------------------------------------
+  function arcZ(x){ return BANK_Z - Math.abs(x)*0.016; }    // gentle concavity
+
+  // -------------------------------------------------------------------------
+  // INTERACTION
+  // -------------------------------------------------------------------------
+  let pan = 0, targetFocus = 0;
+  const ray = new THREE.Raycaster();
+  const ndc = new THREE.Vector2();
+
+  function setFocus(i){
+    targetFocus = clamp(i, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+    refreshTextureWindow();
+    showCaption(boards[focusIdx]);
+  }
+
+  let dragging=false, dragX=0, focusStart=0, moved=0;
+  renderer.domElement.addEventListener("pointerdown",(e)=>{
+    dragging=true; moved=0; dragX=e.clientX; focusStart=targetFocus;
+    renderer.domElement.setPointerCapture(e.pointerId);
+  });
+  renderer.domElement.addEventListener("pointermove",(e)=>{
+    if(!dragging) return;
+    const dx = e.clientX - dragX; moved += Math.abs(dx);
+    targetFocus = clamp(focusStart - dx/120, 0, boards.length-1);
+    focusIdx = Math.round(targetFocus);
+  });
+  renderer.domElement.addEventListener("pointerup",(e)=>{
+    dragging=false;
+    if(moved < 6){ tryPick(e); }
+    else { setFocus(Math.round(targetFocus)); }
+  });
+  window.addEventListener("wheel",(e)=>{
+    e.preventDefault();
+    const dom = (Math.abs(e.deltaX)>Math.abs(e.deltaY))?e.deltaX:e.deltaY;
+    setFocus(Math.round(targetFocus) + (dom>0?1:-1));
+  }, { passive:false });
+
+  function tryPick(e){
+    const r = renderer.domElement.getBoundingClientRect();
+    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
+    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
+    ray.setFromCamera(ndc, camera);
+    const meshes = boards.map(b=>b.face);
+    const hits = ray.intersectObjects(meshes, false);
+    if(hits.length){
+      const idx = hits[0].object.userData.idx;
+      if(idx===focusIdx){ const d=boards[idx].data; if(d && d.store_url) window.open(d.store_url,"_blank","noopener"); }
+      else setFocus(idx);
+    }
+  }
+
+  // -------------------------------------------------------------------------
+  // CAPTION
+  // -------------------------------------------------------------------------
+  const capEl = document.getElementById("caption");
+  function showCaption(b){
+    if(!b || !b.data){ capEl.classList.remove("show"); return; }
+    const d = b.data;
+    document.getElementById("cap_name").textContent = d.pattern_name || d.title || "Untitled";
+    document.getElementById("cap_coll").textContent = (d.collection || d.vendor || "China Seas");
+    const bits = [];
+    if(d.width) bits.push(`${d.width}" wide`);
+    if(d.repeat) bits.push(`${d.repeat}" repeat`);
+    if(d.match_type) bits.push(d.match_type);
+    document.getElementById("cap_spec").textContent = bits.join("  ·  ") || "China Seas";
+    const cta = document.getElementById("cap_cta");
+    if(d.store_url){ cta.href = d.store_url; cta.style.display="inline-block"; }
+    else cta.style.display="none";
+    capEl.classList.add("show");
+  }
+
+  // -------------------------------------------------------------------------
+  // RESIZE
+  // -------------------------------------------------------------------------
+  window.addEventListener("resize", ()=>{
+    camera.aspect = W()/H(); camera.updateProjectionMatrix();
+    renderer.setSize(W(), H());
+  });
+
+  // -------------------------------------------------------------------------
+  // FPS readout + pause on hidden
+  // -------------------------------------------------------------------------
+  const fpsEl = document.getElementById("fps");
+  let fpsOn = true, frames=0, fpsT0=performance.now(), fpsVal=0;
+  fpsEl.addEventListener("click", ()=>{ fpsOn=!fpsOn; fpsEl.classList.toggle("off",!fpsOn); });
+  let running = true;
+  document.addEventListener("visibilitychange", ()=>{
+    running = !document.hidden;
+    if(running){ t0=performance.now(); requestAnimationFrame(tick); }
+  });
+
+  // -------------------------------------------------------------------------
+  // RENDER LOOP
+  // -------------------------------------------------------------------------
+  let t0 = performance.now();
+  function tick(now){
+    if(!running) return;
+    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;
+
+    pan += (targetFocus - pan) * Math.min(1, dt*7.0);
+    const eased = pan;
+
+    for(let i=0;i<boards.length;i++){
+      const b = boards[i];
+      const rel = i - eased;
+      const openAmt = clamp(1 - Math.abs(rel)*1.35, 0, 1);
+
+      const targetSwivel = (rel<0 ? 1 : -1) * (1-openAmt) * 1.0;
+      b.swivel += (targetSwivel - b.swivel)*Math.min(1,dt*9);
+      b.group.rotation.y = b.swivel;
+
+      const slotX = rel * PITCH;
+      const targetX = slotX - (BOARD_W/2) * Math.cos(b.swivel);
+      const targetZ = arcZ(slotX) + openAmt*1.45;
+
+      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;
+
+      // gentle calm tonal recede — neighbours sit a touch softer than the
+      // featured board, but never plunge into darkness (this is a bright room)
+      const litTarget = 0.74 + 0.26*Math.pow(openAmt, 1.2);
+      if(b._lit===undefined) b._lit = litTarget;
+      b._lit += (litTarget - b._lit)*Math.min(1,dt*6);
+      if(b.faceMat){ b.faceMat.color.setScalar(b._lit); }
+
+      const farOff = Math.abs(rel) > 8;
+      b.group.visible = !farOff;
+    }
+
+    // soft tatami reflection tracks the featured board
+    const front = boards[focusIdx];
+    if(front){
+      const fx = front.group.position.x + BOARD_W/2;
+      if(front.faceMat && front.faceMat.map){
+        reflMat.map = front.faceMat.map;
+        reflMat.opacity += (0.07 - reflMat.opacity)*Math.min(1,dt*5);   // faint on matte mat
+        reflMesh.position.set(fx, -BOARD_H/2 - 0.02, front.group.position.z + 0.02);
+      } else {
+        reflMat.opacity += (0 - reflMat.opacity)*Math.min(1,dt*5);
+      }
+    }
+
+    renderer.render(scene, camera);
+
+    frames++;
+    if(now - fpsT0 >= 500){
+      fpsVal = Math.round(frames*1000/(now-fpsT0)); frames=0; fpsT0=now;
+      if(fpsOn) fpsEl.textContent = fpsVal + " fps";
+      window.__TT_FPS = fpsVal;
+    }
+    requestAnimationFrame(tick);
+  }
+
+  // -------------------------------------------------------------------------
+  // BOOT
+  // -------------------------------------------------------------------------
+  let HAS_PRODUCTS = false;
+  async function start(){
+    try {
+      const res = await fetch(API);
+      const json = await res.json();
+      products = (json.products||[]).slice(0, N_TARGET);
+    } catch(e){
+      bootlbl.textContent = "Could not reach the catalog.";
+      console.error("fetch failed", e);
+      return;
+    }
+    if(!products.length){ bootlbl.textContent = "No products returned."; return; }
+    HAS_PRODUCTS = true;
+
+    for(let i=0;i<products.length;i++){
+      const b = buildBoard(i);
+      b.data = products[i];
+      b.group.position.set(i*PITCH, 0, BANK_Z);
+      boards.push(b);
+    }
+    bootlbl.textContent = `Setting ${boards.length} boards…`;
+
+    focusIdx = Math.min(2, boards.length-1);
+    pan = targetFocus = focusIdx;
+    refreshTextureWindow();
+
+    const featUrl = imgURL(boards[focusIdx].data.image || boards[focusIdx].data.tile);
+    await loadTexture(featUrl).catch(()=>null);
+    ensureBoardTexture(boards[focusIdx]);
+    showCaption(boards[focusIdx]);
+
+    window.__TT__ = {
+      boards: ()=>boards.length,
+      textured: ()=>boards.filter(b=>b.faceMat && b.faceMat.map).length,
+      focus: ()=>focusIdx,
+      fps: ()=>window.__TT_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":"V17","label":"V17 — Japanese Tatami","elements":[
+  {"n":1,"label":"Tea-house room — woven tatami floor, warm post-and-beam wood"},
+  {"n":2,"label":"Shoji screen walls glowing with soft diffuse north light"},
+  {"n":3,"label":"Thin light-wood framed boards, ~2\" apart on a side swivel"},
+  {"n":4,"label":"Calm minimal neutral palette, subtle paper translucency"},
+  {"n":5,"label":"Click a board → carousel-shift to center → full pattern face"}
+]};</script>
+<script src="./proto-chrome.js"></script>
+</body>
+</html>

← fad82ea Register V12 Gallery Noir + V16 Art-Deco Salon + V21 Garcia  ·  back to Quadrille Showroom  ·  High-end versions batch 2: V13 Atrium Daylight + V14 Velvet c4480af →