← back to Quadrille Showroom

public/proto/v21-garcia-room.html

736 lines

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
<title>GARCIA ROOM · China Seas</title>
<style>
  * { box-sizing:border-box; margin:0; padding:0; }
  html,body { height:100%; overflow:hidden; background:#0a0712; color:#f3ecdc;
    font-family:"Helvetica Neue",Helvetica,Arial,sans-serif; -webkit-font-smoothing:antialiased; }
  #app { position:fixed; inset:0; }
  canvas { display:block; }

  /* ---- masthead ---- */
  .masthead { position:fixed; top:0; left:0; right:0; z-index:20; padding:20px 30px 46px;
    pointer-events:none; text-align:center;
    background:linear-gradient(180deg, rgba(10,7,18,.78) 0%, rgba(10,7,18,0) 100%); }
  .masthead .kicker { font-size:11px; letter-spacing:.42em; text-transform:uppercase;
    color:#d8b9ff; font-weight:600; text-shadow:0 0 18px rgba(180,120,255,.5); }
  .masthead h1 { font-size:clamp(28px,5vw,52px); font-weight:300; letter-spacing:.04em;
    line-height:.96; margin-top:6px;
    background:linear-gradient(90deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a);
    -webkit-background-clip:text; background-clip:text; -webkit-text-fill-color:transparent;
    background-size:280% 100%; animation:hueslide 14s linear infinite; }
  @keyframes hueslide { to { background-position:280% 0; } }
  .masthead .sub { margin-top:8px; font-size:12.5px; letter-spacing:.05em; color:#b6a8c8;
    max-width:560px; margin-left:auto; margin-right:auto; }

  .hint { position:fixed; bottom:30px; left:50%; transform:translateX(-50%); z-index:20;
    font-size:11px; letter-spacing:.28em; text-transform:uppercase; color:rgba(240,230,255,.55);
    pointer-events:none; transition:opacity .6s; text-shadow:0 0 14px rgba(160,90,255,.4); }

  /* ---- FPS ---- */
  #fps { position:fixed; bottom:14px; left:16px; z-index:25; font:600 12px/1 "SF Mono",Menlo,monospace;
    color:#9be8c0; background:rgba(10,7,18,.55); border:1px solid rgba(155,232,192,.25);
    padding:6px 10px; border-radius:8px; letter-spacing:.06em; pointer-events:auto; cursor:pointer;
    backdrop-filter:blur(6px); }
  #fps.lo { color:#ffb14e; border-color:rgba(255,177,78,.4); }

  /* ---- detail card ---- */
  #detail { position:fixed; z-index:30; right:34px; top:50%; transform:translateY(-50%) translateX(40px);
    width:min(330px,40vw); background:rgba(18,12,28,.74); backdrop-filter:blur(18px);
    -webkit-backdrop-filter:blur(18px); border:1px solid rgba(200,150,255,.22);
    border-radius:6px; padding:24px 24px 26px; opacity:0; pointer-events:none;
    box-shadow:0 24px 60px -16px rgba(120,40,200,.5);
    transition:opacity .45s ease, transform .45s cubic-bezier(.2,.8,.2,1); }
  #detail.show { opacity:1; transform:translateY(-50%) translateX(0); pointer-events:auto; }
  #detail .swatchchip { width:38px; height:38px; border-radius:50%; border:1px solid rgba(255,255,255,.25);
    margin-bottom:14px; box-shadow:0 4px 22px rgba(0,0,0,.55); }
  #detail .pname { font-size:21px; font-weight:600; line-height:1.12; letter-spacing:.01em; }
  #detail .pcollection { margin-top:4px; font-size:11px; letter-spacing:.26em; text-transform:uppercase; color:#c9b4e0; }
  #detail .specrow { display:flex; justify-content:space-between; padding:9px 0;
    border-bottom:1px solid rgba(255,255,255,.08); font-size:12.5px; }
  #detail .specrow:first-of-type { margin-top:16px; border-top:1px solid rgba(255,255,255,.08); }
  #detail .specrow .k { color:#a89ab8; letter-spacing:.04em; }
  #detail .specrow .v { color:#efece5; text-align:right; }
  #detail a.cta { display:block; margin-top:18px; text-align:center; padding:12px;
    background:linear-gradient(90deg,#c77dff,#ff7eb0); color:#180a22; font-size:11px; letter-spacing:.22em;
    text-transform:uppercase; font-weight:700; text-decoration:none; border-radius:3px;
    transition:transform .15s, filter .15s; }
  #detail a.cta:hover { transform:translateY(-1px); filter:brightness(1.08); }
  #detail .closex { position:absolute; top:12px; right:14px; font-size:18px; color:#b6a8c8;
    cursor:pointer; line-height:1; }
  #detail .closex:hover { color:#fff; }

  /* ---- boot ---- */
  #boot { position:fixed; inset:0; z-index:50; display:flex; flex-direction:column;
    align-items:center; justify-content:center; background:#0a0712; transition:opacity .9s; }
  #boot .ring { width:48px; height:48px; border-radius:50%;
    border:3px solid rgba(255,255,255,.12);
    border-top-color:#ff6ec7; border-right-color:#6ec7ff; border-bottom-color:#c7ff6e;
    animation:spin 1.1s linear infinite; }
  #boot .lbl { margin-top:20px; font-size:11px; letter-spacing:.34em; text-transform:uppercase; color:#a99cc0; }
  @keyframes spin { to { transform:rotate(360deg); } }

  @media (max-width:720px){
    #detail { right:14px; left:14px; width:auto; top:auto; bottom:84px; transform:translateY(20px); }
    #detail.show { transform:translateY(0); }
    .masthead .sub { display:none; }
  }
</style>
</head>
<body>
<div id="app"></div>

<div class="masthead">
  <div class="kicker">Designer Wallcoverings · Garcia Room</div>
  <h1>GARCIA ROOM</h1>
  <div class="sub">A liquid-light lounge bathed in a slow oil-projection color wash. Click a wing to swing
    the China Seas board to center — one clean full pattern face, no bars.</div>
</div>

<div class="hint">click a wing · the board swings open</div>
<div id="fps">— fps</div>

<div id="detail">
  <div class="closex">&times;</div>
  <div class="swatchchip" id="d_chip"></div>
  <div class="pname" id="d_name">—</div>
  <div class="pcollection" id="d_coll">China Seas</div>
  <div class="specrow"><span class="k">Dimensions</span><span class="v" id="d_dim">27" × 27"</span></div>
  <div class="specrow"><span class="k">Repeat</span><span class="v" id="d_rep">—</span></div>
  <div class="specrow"><span class="k">Match</span><span class="v" id="d_match">—</span></div>
  <div class="specrow"><span class="k">Finish</span><span class="v" id="d_finish">—</span></div>
  <div class="specrow"><span class="k">Material</span><span class="v" id="d_mat">—</span></div>
  <a class="cta" id="d_cta" href="#" target="_blank" rel="noopener noreferrer">View Pattern →</a>
</div>

<div id="boot"><div class="ring"></div><div class="lbl" id="bootlbl">Tuning the light show…</div></div>

<script src="./three.min.js"></script>
<script>
"use strict";
(function () {
  const API = "/api/showroom/products?limit=50";
  const TAU = Math.PI * 2;
  const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
  const lerp = (a,b,t)=>a+(b-a)*t;

  // ===================================================================== //
  //  RENDERER  (HIGH-END BASELINE)                                        //
  // ===================================================================== //
  const app = document.getElementById("app");
  const W = ()=>window.innerWidth, H = ()=>window.innerHeight;

  const renderer = new THREE.WebGLRenderer({ antialias:true, powerPreference:"high-performance" });
  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
  renderer.setSize(W(), H());
  renderer.outputEncoding = THREE.sRGBEncoding;
  renderer.toneMapping = THREE.ACESFilmicToneMapping;
  renderer.toneMappingExposure = 1.12;
  if ("physicallyCorrectLights" in renderer) renderer.physicallyCorrectLights = true;
  renderer.shadowMap.enabled = true;
  renderer.shadowMap.type = THREE.PCFSoftShadowMap;
  app.appendChild(renderer.domElement);
  const MAXANISO = renderer.capabilities.getMaxAnisotropy();

  const scene = new THREE.Scene();
  scene.fog = new THREE.FogExp2(0x140a22, 0.026);

  const camera = new THREE.PerspectiveCamera(50, W()/H(), 0.1, 200);
  // fixed viewer in the centre of the room, looking at the wing bank on the far wall
  camera.position.set(0, 1.65, 6.7);
  camera.lookAt(0, 1.55, -6);

  // ===================================================================== //
  //  PROCEDURAL TIE-DYE PMREM ENVIRONMENT  (real IBL, no external assets) //
  // ===================================================================== //
  function buildTieDyeEnv(){
    // render a fullscreen tie-dye shader into a cube target, then PMREM it
    const cubeRT = new THREE.WebGLCubeRenderTarget(256, {
      format:THREE.RGBAFormat, generateMipmaps:true, minFilter:THREE.LinearMipmapLinearFilter
    });
    const cubeCam = new THREE.CubeCamera(0.1, 10, cubeRT);
    const envScene = new THREE.Scene();
    const skyGeo = new THREE.SphereGeometry(8, 32, 24);
    const skyMat = new THREE.ShaderMaterial({
      side:THREE.BackSide,
      uniforms:{ uT:{value:0} },
      vertexShader:`
        varying vec3 vN;
        void main(){ vN = normalize(position); gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }
      `,
      fragmentShader:`
        precision highp float;
        varying vec3 vN; uniform float uT;
        vec3 hsl2rgb(float h,float s,float l){
          vec3 r = abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0;
          r = clamp(r,0.0,1.0);
          float c=(1.0-abs(2.0*l-1.0))*s;
          return l + c*(r-0.5);
        }
        void main(){
          // concentric tie-dye spiral driven by direction
          float a = atan(vN.z, vN.x);
          float r = acos(clamp(vN.y,-1.0,1.0));
          float spiral = sin(r*5.0 + a*3.0 + uT*0.6);
          float band = sin(r*9.0 - uT*0.4)*0.5 + 0.5;
          float hue = fract(a/6.2831853 + r*0.4 + uT*0.05 + spiral*0.08);
          float sat = 0.62 + 0.18*band;
          float lit = 0.30 + 0.22*band + 0.10*spiral;
          vec3 col = hsl2rgb(hue, sat, lit);
          gl_FragColor = vec4(col, 1.0);
        }
      `
    });
    const sky = new THREE.Mesh(skyGeo, skyMat);
    envScene.add(sky);
    skyMat.uniforms.uT.value = 1.7;
    cubeCam.update(renderer, envScene);

    const pmrem = new THREE.PMREMGenerator(renderer);
    pmrem.compileCubemapShader();
    const envMap = pmrem.fromCubemap(cubeRT.texture).texture;
    pmrem.dispose(); cubeRT.dispose();
    skyGeo.dispose(); skyMat.dispose();
    return envMap;
  }
  scene.environment = buildTieDyeEnv();

  // ===================================================================== //
  //  ROOM  — color-wash walls (hue-cycling shader) + floor                //
  // ===================================================================== //
  const ROOM_W = 12, ROOM_H = 5.2, ROOM_D = 13;
  const washUniforms = { uT:{ value:0 } };

  // Custom oil-projection liquid-light shader: cheap, animated via ONE uniform.
  function washShader(seed){
    return new THREE.ShaderMaterial({
      uniforms:{ uT:washUniforms.uT, uSeed:{value:seed} },
      vertexShader:`
        varying vec2 vUv; varying vec3 vWPos;
        void main(){ vUv=uv; vec4 wp=modelMatrix*vec4(position,1.0); vWPos=wp.xyz;
          gl_Position=projectionMatrix*viewMatrix*wp; }
      `,
      fragmentShader:`
        precision highp float;
        varying vec2 vUv; varying vec3 vWPos; uniform float uT; uniform float uSeed;
        vec3 hsl2rgb(float h,float s,float l){
          vec3 r=abs(mod(h*6.0+vec3(0.0,4.0,2.0),6.0)-3.0)-1.0; r=clamp(r,0.0,1.0);
          float c=(1.0-abs(2.0*l-1.0))*s; return l+c*(r-0.5);
        }
        // smooth value noise for liquid blobs
        float hash(vec2 p){ return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453); }
        float vnoise(vec2 p){
          vec2 i=floor(p), f=fract(p); f=f*f*(3.0-2.0*f);
          float a=hash(i), b=hash(i+vec2(1.0,0.0)), c=hash(i+vec2(0.0,1.0)), d=hash(i+vec2(1.0,1.0));
          return mix(mix(a,b,f.x),mix(c,d,f.x),f.y);
        }
        void main(){
          vec2 p = vUv*3.2 + uSeed;
          float t = uT*0.18;
          // two drifting noise fields = lava-lamp blobs
          float n1 = vnoise(p + vec2(t, -t*0.6));
          float n2 = vnoise(p*1.7 - vec2(t*0.5, t));
          float blob = smoothstep(0.35,0.85, n1*0.6 + n2*0.5);
          // slow global hue cycle + local blob hue offset = oil-wheel rainbow
          float hue = fract(uT*0.03 + uSeed*0.13 + blob*0.55 + n2*0.2);
          float lit = mix(0.16, 0.46, blob);          // dark ground, glowing blobs
          float sat = mix(0.45, 0.85, blob);
          vec3 col = hsl2rgb(hue, sat, lit);
          // gentle vignette toward floor/ceiling so walls read as a room not a flat poster
          float vig = smoothstep(0.0,0.35,vUv.y)*smoothstep(0.0,0.35,1.0-vUv.y);
          col *= mix(0.55,1.0,vig);
          gl_FragColor = vec4(col,1.0);
        }
      `,
      side:THREE.FrontSide
    });
  }

  const wallGeo = new THREE.PlaneGeometry(ROOM_W, ROOM_H, 1, 1);
  function addWall(x,y,z,ry,seed){
    const m = new THREE.Mesh(wallGeo, washShader(seed));
    m.position.set(x,y,z); m.rotation.y = ry;
    scene.add(m); return m;
  }
  // far wall (behind wing bank), left, right
  addWall(0, ROOM_H/2, -ROOM_D/2, 0, 0.0);
  addWall(-ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, Math.PI/2, 2.4);
  addWall( ROOM_W/2, ROOM_H/2, -ROOM_D/2 + ROOM_D/2, -Math.PI/2, 4.1);

  // ceiling — dim color wash so emissive bloom has somewhere to breathe
  const ceilMat = washShader(6.2); ceilMat.side = THREE.DoubleSide;
  const ceil = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), ceilMat);
  ceil.rotation.x = Math.PI/2; ceil.position.set(0, ROOM_H, -ROOM_D/2 + ROOM_D/2);
  scene.add(ceil);

  // floor — dark, slightly glossy, picks up the env reflections (PBR)
  const floorMat = new THREE.MeshPhysicalMaterial({
    color:0x120a1c, metalness:0.0, roughness:0.32, clearcoat:0.6, clearcoatRoughness:0.4,
    envMapIntensity:0.9, reflectivity:0.4
  });
  const floor = new THREE.Mesh(new THREE.PlaneGeometry(ROOM_W, ROOM_D), floorMat);
  floor.rotation.x = -Math.PI/2; floor.position.set(0, 0, -ROOM_D/2 + ROOM_D/2);
  floor.receiveShadow = true;
  scene.add(floor);

  // ===================================================================== //
  //  LIGHTING  — key + fill + warm rim, soft shadows                      //
  // ===================================================================== //
  scene.add(new THREE.HemisphereLight(0xffe6f5, 0x180a22, 0.55));
  const key = new THREE.DirectionalLight(0xffe9d2, 1.5);
  key.position.set(3.5, 6.5, 4.5);
  key.castShadow = true;
  key.shadow.mapSize.set(1024,1024);
  key.shadow.camera.near = 1; key.shadow.camera.far = 24;
  key.shadow.camera.left=-7; key.shadow.camera.right=7; key.shadow.camera.top=7; key.shadow.camera.bottom=-7;
  key.shadow.bias = -0.0006;
  scene.add(key);
  const fill = new THREE.DirectionalLight(0x9b6bff, 0.5); fill.position.set(-5,3,5); scene.add(fill);
  const rim  = new THREE.PointLight(0xff7eb0, 1.2, 22, 2); rim.position.set(0, 3.2, -2); scene.add(rim);
  const warm = new THREE.PointLight(0xffd28a, 0.9, 18, 2); warm.position.set(0, 2.2, 3); scene.add(warm);

  // ===================================================================== //
  //  EMISSIVE DECALS — dancing bears + lightning bolts (tasteful framed)  //
  // ===================================================================== //
  // Built procedurally onto a canvas → emissive texture in a thin gold frame.
  function decalTexture(kind, hue){
    const c = document.createElement("canvas"); c.width = c.height = 256;
    const g = c.getContext("2d");
    g.clearRect(0,0,256,256);
    g.fillStyle = "rgba(0,0,0,0)"; g.fillRect(0,0,256,256);
    const col = `hsl(${hue} 90% 62%)`;
    g.fillStyle = col; g.strokeStyle = col; g.lineWidth = 8; g.lineJoin="round";
    if(kind === "bear"){
      // stylised dancing bear silhouette
      g.save(); g.translate(128,150); g.scale(1.05,1.05);
      // body
      g.beginPath(); g.ellipse(0,10,42,52,0,0,TAU); g.fill();
      // head
      g.beginPath(); g.arc(0,-58,34,0,TAU); g.fill();
      // ears
      g.beginPath(); g.arc(-26,-82,13,0,TAU); g.fill();
      g.beginPath(); g.arc( 26,-82,13,0,TAU); g.fill();
      // arms (dancing — one up, one out)
      g.lineWidth=22; g.lineCap="round";
      g.beginPath(); g.moveTo(-30,-6); g.lineTo(-78,-44); g.stroke();
      g.beginPath(); g.moveTo( 30,-6); g.lineTo( 80,18);  g.stroke();
      // legs (mid-step)
      g.beginPath(); g.moveTo(-18,56); g.lineTo(-48,96); g.stroke();
      g.beginPath(); g.moveTo( 18,56); g.lineTo( 40,100); g.stroke();
      g.restore();
    } else { // lightning bolt
      g.save(); g.translate(128,30);
      g.beginPath();
      g.moveTo(18,0); g.lineTo(-34,120); g.lineTo(6,120);
      g.lineTo(-22,200); g.lineTo(58,80); g.lineTo(16,80);
      g.lineTo(54,0); g.closePath(); g.fill();
      g.restore();
    }
    const tex = new THREE.CanvasTexture(c);
    tex.encoding = THREE.sRGBEncoding;
    return tex;
  }
  function addDecal(kind, hue, x, y, z, ry, size){
    const grp = new THREE.Group();
    // thin gold frame
    const frame = new THREE.Mesh(
      new THREE.PlaneGeometry(size*1.18, size*1.18),
      new THREE.MeshPhysicalMaterial({ color:0x3a2d12, metalness:0.85, roughness:0.35,
        envMapIntensity:1.2, emissive:0x140d04, emissiveIntensity:0.3 })
    );
    const art = new THREE.Mesh(
      new THREE.PlaneGeometry(size, size),
      new THREE.MeshBasicMaterial({ map:decalTexture(kind,hue), transparent:true,
        toneMapped:false, depthWrite:false })
    );
    art.position.z = 0.02;
    grp.add(frame, art);
    grp.position.set(x,y,z); grp.rotation.y = ry;
    grp.userData.glow = art; grp.userData.hue0 = hue;
    scene.add(grp);
    return grp;
  }
  const decals = [];
  // side-wall framed art — kept small + classy, not garish
  decals.push(addDecal("bear", 20,  -ROOM_W/2+0.06, 2.4, -3.6,  Math.PI/2, 0.9));
  decals.push(addDecal("bolt", 50,  -ROOM_W/2+0.06, 2.4, -1.6,  Math.PI/2, 0.9));
  decals.push(addDecal("bear", 280,  ROOM_W/2-0.06, 2.4, -3.6, -Math.PI/2, 0.9));
  decals.push(addDecal("bolt", 140,  ROOM_W/2-0.06, 2.4, -1.6, -Math.PI/2, 0.9));

  // ===================================================================== //
  //  WING BANK  — sample boards in thin frames, ~2" apart, side-swivel    //
  // ===================================================================== //
  const BOARD_W = 0.62, BOARD_H = 2.35, GAP = 0.052; // 30"w x ~6'tall proportions
  const PITCH = BOARD_W*0.62 + GAP;                  // closed wings read as raked panels, not crushed slivers
  const FOCUS_Z = -4.9;                              // open board glides forward, full face
  const BANK_Z  = -6.6;                              // closed bank sits back
  const BANK_Y  = 1.55;
  const CLEAR   = BOARD_W*1.9;                        // how far neighbors part around the open board

  // shared geometries
  const faceGeo = new THREE.PlaneGeometry(BOARD_W, BOARD_H);
  const frameMat = new THREE.MeshPhysicalMaterial({
    color:0x1a1310, metalness:0.9, roughness:0.4, envMapIntensity:1.1,
    emissive:0x0a0604, emissiveIntensity:0.25
  });
  const frameBarGeo = new THREE.BoxGeometry(1,1,0.035); // scaled per use

  const wings = [];          // { pivot, faceMesh, data, idx, open, swing(target) }
  const pickFaces = [];      // raycast targets
  const loader = new THREE.TextureLoader(); loader.crossOrigin = "anonymous";
  const fallbackTex = (()=>{ // neutral until a real texture loads
    const c=document.createElement("canvas"); c.width=c.height=4;
    const g=c.getContext("2d"); g.fillStyle="#2a2030"; g.fillRect(0,0,4,4);
    const t=new THREE.CanvasTexture(c); return t;
  })();

  function imgURL(u){
    if(!u) return "";
    if(u.charAt(0) === "/") return u;            // local asset (/gen-tiles/*.png) — serve direct
    if(/cdn\.shopify\.com/.test(u)) return u;     // ACAO:* CDN — load directly
    return "/api/proxy/image?url=" + encodeURIComponent(u);
  }

  function makeWing(d, idx, n){
    const pivot = new THREE.Group();            // side-hinge: rotate about left edge
    const span = (n-1)*PITCH;
    const x = -span/2 + idx*PITCH;
    pivot.position.set(x - BOARD_W/2, BANK_Y, BANK_Z); // pivot at the LEFT edge (the hinge)

    const board = new THREE.Group();
    board.position.x = BOARD_W/2;               // shift face so hinge is at its left edge

    // face — the real China Seas wallcovering
    const faceMat = new THREE.MeshPhysicalMaterial({
      map:fallbackTex, roughness:0.62, metalness:0.0, envMapIntensity:0.7,
      clearcoat:0.12, clearcoatRoughness:0.6
    });
    const face = new THREE.Mesh(faceGeo, faceMat);
    face.castShadow = true; face.userData.wingIdx = idx;
    board.add(face);
    pickFaces.push(face);

    // thin frame — 4 emissive-gold bars around the face (real geometry, low cost)
    const fb = BOARD_W*0.035; // frame thickness
    function bar(w,h,px,py){
      const m = new THREE.Mesh(frameBarGeo, frameMat);
      m.scale.set(w,h,1); m.position.set(px,py,0.012);
      board.add(m);
    }
    bar(BOARD_W+fb*2, fb, 0,  BOARD_H/2);
    bar(BOARD_W+fb*2, fb, 0, -BOARD_H/2);
    bar(fb, BOARD_H, -BOARD_W/2, 0);
    bar(fb, BOARD_H,  BOARD_W/2, 0);

    pivot.add(board);
    scene.add(pivot);

    // load the real texture
    const url = imgURL(d.tile || d.image);
    const img = new Image(); img.crossOrigin = "anonymous";
    img.onload = ()=>{
      const tex = new THREE.Texture(img);
      tex.encoding = THREE.sRGBEncoding;
      tex.anisotropy = MAXANISO;
      tex.wrapS = tex.wrapT = THREE.RepeatWrapping;
      // 27" pattern across a 30" board → ~1.1 repeats tall to read as wallcovering
      tex.repeat.set(1, BOARD_H/(BOARD_W) * (27/27) * 0.5 + 0.5);
      tex.needsUpdate = true;
      faceMat.map = tex; faceMat.needsUpdate = true;
    };
    img.onerror = ()=>{};
    img.src = url;

    const wing = { pivot, board, face, faceMat, data:d, idx,
                   baseX:x, open:false, swing:0, swingTarget:0, dimT:0 };
    wings.push(wing);
    return wing;
  }

  // ===================================================================== //
  //  CHEAP ADDITIVE BLOOM  (self-contained postprocess, FPS-safe)         //
  // ===================================================================== //
  // Render scene → sceneRT. Bright-pass + 2 small blur RTs. Composite add.
  let sceneRT, brightRT, blurRTa, blurRTb, fsQuad, brightMat, blurMat, compMat, orthoCam, quadGeo;
  function makeRT(w,h){ return new THREE.WebGLRenderTarget(w,h,{ minFilter:THREE.LinearFilter,
    magFilter:THREE.LinearFilter, format:THREE.RGBAFormat, encoding:THREE.LinearEncoding }); }

  function buildBloom(){
    const w = Math.max(2, Math.floor(W()*Math.min(window.devicePixelRatio||1,2)));
    const h = Math.max(2, Math.floor(H()*Math.min(window.devicePixelRatio||1,2)));
    const bw = Math.floor(w/3), bh = Math.floor(h/3); // bloom at 1/3 res = cheap
    sceneRT = makeRT(w,h); sceneRT.texture.encoding = THREE.sRGBEncoding;
    brightRT = makeRT(bw,bh); blurRTa = makeRT(bw,bh); blurRTb = makeRT(bw,bh);
    orthoCam = new THREE.OrthographicCamera(-1,1,1,-1,0,1);
    quadGeo = new THREE.PlaneGeometry(2,2);

    brightMat = new THREE.ShaderMaterial({
      uniforms:{ tDiffuse:{value:null}, uThresh:{value:0.62} },
      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
      fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform float uThresh;
        void main(){ vec4 c=texture2D(tDiffuse,vUv);
          float l=dot(c.rgb,vec3(0.299,0.587,0.114));
          float k=smoothstep(uThresh,uThresh+0.35,l);
          gl_FragColor=vec4(c.rgb*k,1.0); }`
    });
    blurMat = new THREE.ShaderMaterial({
      uniforms:{ tDiffuse:{value:null}, uDir:{value:new THREE.Vector2(1,0)}, uRes:{value:new THREE.Vector2(bw,bh)} },
      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
      fragmentShader:`varying vec2 vUv; uniform sampler2D tDiffuse; uniform vec2 uDir; uniform vec2 uRes;
        void main(){ vec2 px=uDir/uRes; vec3 s=vec3(0.0);
          s+=texture2D(tDiffuse,vUv).rgb*0.227;
          s+=texture2D(tDiffuse,vUv+px*1.38).rgb*0.316;
          s+=texture2D(tDiffuse,vUv-px*1.38).rgb*0.316;
          s+=texture2D(tDiffuse,vUv+px*3.23).rgb*0.070;
          s+=texture2D(tDiffuse,vUv-px*3.23).rgb*0.070;
          gl_FragColor=vec4(s,1.0); }`
    });
    compMat = new THREE.ShaderMaterial({
      uniforms:{ tBase:{value:null}, tBloom:{value:null}, uStrength:{value:0.9} },
      vertexShader:`varying vec2 vUv; void main(){ vUv=uv; gl_Position=vec4(position.xy,0.0,1.0); }`,
      fragmentShader:`varying vec2 vUv; uniform sampler2D tBase; uniform sampler2D tBloom; uniform float uStrength;
        void main(){ vec3 b=texture2D(tBase,vUv).rgb; vec3 g=texture2D(tBloom,vUv).rgb;
          gl_FragColor=vec4(b + g*uStrength, 1.0); }`
    });
    fsQuad = new THREE.Mesh(quadGeo, brightMat);
  }
  function resizeBloom(){
    if(!sceneRT) return;
    const dpr = Math.min(window.devicePixelRatio||1,2);
    const w=Math.floor(W()*dpr), h=Math.floor(H()*dpr), bw=Math.floor(w/3), bh=Math.floor(h/3);
    sceneRT.setSize(w,h); brightRT.setSize(bw,bh); blurRTa.setSize(bw,bh); blurRTb.setSize(bw,bh);
    blurMat.uniforms.uRes.value.set(bw,bh);
  }
  function renderWithBloom(){
    // 1) scene → sceneRT
    renderer.setRenderTarget(sceneRT);
    renderer.clear();
    renderer.render(scene, camera);
    // 2) bright pass
    fsQuad.material = brightMat; brightMat.uniforms.tDiffuse.value = sceneRT.texture;
    renderer.setRenderTarget(brightRT); renderer.render(fsQuad, orthoCam);
    // 3) blur H then V (x2 light)
    fsQuad.material = blurMat;
    blurMat.uniforms.tDiffuse.value = brightRT.texture; blurMat.uniforms.uDir.value.set(1,0);
    renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
    blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1);
    renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
    blurMat.uniforms.tDiffuse.value = blurRTb.texture; blurMat.uniforms.uDir.value.set(1.7,0);
    renderer.setRenderTarget(blurRTa); renderer.render(fsQuad, orthoCam);
    blurMat.uniforms.tDiffuse.value = blurRTa.texture; blurMat.uniforms.uDir.value.set(0,1.7);
    renderer.setRenderTarget(blurRTb); renderer.render(fsQuad, orthoCam);
    // 4) composite to screen
    fsQuad.material = compMat;
    compMat.uniforms.tBase.value = sceneRT.texture; compMat.uniforms.tBloom.value = blurRTb.texture;
    renderer.setRenderTarget(null); renderer.render(fsQuad, orthoCam);
  }

  // ===================================================================== //
  //  PICKING / FOCUS                                                      //
  // ===================================================================== //
  const ray = new THREE.Raycaster();
  const ndc = new THREE.Vector2();
  let focused = null;

  function openWing(w){
    if(focused === w){ closeWing(); return; }
    wings.forEach(x=>{ x.open = false; x.swingTarget = 0; });
    w.open = true; w.swingTarget = 1;
    focused = w;
    showDetail(w);
  }
  function closeWing(){
    if(focused){ focused.open=false; focused.swingTarget=0; }
    focused = null; hideDetail();
  }

  function tryPick(e){
    const r = renderer.domElement.getBoundingClientRect();
    ndc.x = ((e.clientX-r.left)/r.width)*2 - 1;
    ndc.y = -((e.clientY-r.top)/r.height)*2 + 1;
    ray.setFromCamera(ndc, camera);
    const hits = ray.intersectObjects(pickFaces, false);
    if(hits.length){
      const idx = hits[0].object.userData.wingIdx;
      const w = wings.find(x=>x.idx===idx);
      if(w) openWing(w);
    } else if(focused){ closeWing(); }
  }

  // drag to slide the bank laterally + click to open
  let dragging=false, dragX=0, panStart=0, moved=0;
  let bankPan = 0, bankPanTarget = 0;
  const el = renderer.domElement;
  el.addEventListener("pointerdown",(e)=>{ dragging=true; moved=0; dragX=e.clientX; panStart=bankPanTarget;
    el.setPointerCapture(e.pointerId); });
  el.addEventListener("pointermove",(e)=>{ if(!dragging) return;
    const dx=e.clientX-dragX; moved+=Math.abs(dx);
    bankPanTarget = clamp(panStart + dx*0.012, -2.6, 2.6); });
  el.addEventListener("pointerup",(e)=>{ dragging=false; if(moved<6) tryPick(e); });
  window.addEventListener("wheel",(e)=>{ bankPanTarget = clamp(bankPanTarget - e.deltaY*0.0016, -2.6, 2.6); }, {passive:true});

  // ---- detail card ----
  const detail = document.getElementById("detail");
  document.querySelector("#detail .closex").addEventListener("click", closeWing);
  function showDetail(w){
    const d = w.data;
    document.getElementById("d_name").textContent = d.pattern_name || d.title || "Untitled";
    document.getElementById("d_coll").textContent = (d.collection || d.vendor || "China Seas");
    const ww = d.width||27;
    document.getElementById("d_dim").textContent = `${ww}" × ${ww}"`;
    document.getElementById("d_rep").textContent = d.repeat ? `${d.repeat}"` : "—";
    document.getElementById("d_match").textContent = d.match_type || "—";
    document.getElementById("d_finish").textContent = d.finish || "—";
    document.getElementById("d_mat").textContent = d.material || "—";
    const chip = document.getElementById("d_chip");
    chip.style.background = `conic-gradient(from 0deg,#ff5a7a,#ffb14e,#ffe14e,#4ee08f,#4ec3ff,#b46bff,#ff5a7a)`;
    const cta = document.getElementById("d_cta");
    if(d.store_url){ cta.href = d.store_url; cta.style.display="block"; }
    else cta.style.display="none";
    detail.classList.add("show");
  }
  function hideDetail(){ detail.classList.remove("show"); }

  // ===================================================================== //
  //  RESIZE / VISIBILITY                                                  //
  // ===================================================================== //
  let running = true;
  window.addEventListener("resize", ()=>{
    camera.aspect = W()/H(); camera.updateProjectionMatrix();
    renderer.setSize(W(), H()); resizeBloom();
  });
  document.addEventListener("visibilitychange", ()=>{
    running = !document.hidden;
    if(running){ t0 = performance.now(); requestAnimationFrame(tick); }
  });

  // ===================================================================== //
  //  FPS                                                                  //
  // ===================================================================== //
  const fpsEl = document.getElementById("fps");
  let fpsShown = true;
  fpsEl.addEventListener("click", ()=>{ fpsShown=!fpsShown; fpsEl.style.opacity = fpsShown?"1":"0.25"; });
  let frames=0, fpsAccum=0, fps=0;

  // ===================================================================== //
  //  DATA LOAD                                                            //
  // ===================================================================== //
  const bootlbl = document.getElementById("bootlbl");
  async function start(){
    let products = [];
    try {
      const res = await fetch(API);
      const json = await res.json();
      products = json.products || [];
    } catch(e){ bootlbl.textContent = "Could not reach the catalog."; console.error("fetch failed", e); }
    const n = Math.min(products.length, 28); // a focused wing bank, not a wall of 50 slivers
    for(let i=0;i<n;i++) makeWing(products[i], i, n);
    window.__GARCIA_READY = wings.length;
    buildBloom();
    finishBoot();
    t0 = performance.now();
    requestAnimationFrame(tick);
  }
  function finishBoot(){
    const boot = document.getElementById("boot");
    boot.style.opacity = "0";
    setTimeout(()=>boot.remove(), 950);
  }

  // ===================================================================== //
  //  ANIMATE                                                              //
  // ===================================================================== //
  let t0 = performance.now();
  function tick(now){
    if(!running) return;
    const dt = Math.min((now - t0)/1000, 0.05); t0 = now;

    // FPS
    frames++; fpsAccum += dt;
    if(fpsAccum >= 0.5){ fps = Math.round(frames/fpsAccum); frames=0; fpsAccum=0;
      if(fpsShown){ fpsEl.textContent = fps + " fps"; fpsEl.classList.toggle("lo", fps<55); } }

    // color-wash uniform — ONE float, drives every wall/ceiling shader (cheap)
    washUniforms.uT.value = now*0.001;

    // breathing rim/warm light for liquid-light pulse
    const pulse = (Math.sin(now*0.0011)*0.5+0.5);
    rim.intensity = 0.9 + pulse*0.9;
    rim.color.setHSL((now*0.00006)%1, 0.7, 0.6);
    warm.intensity = 0.7 + (1-pulse)*0.5;

    // decals: slow emissive glow shimmer + gentle hue drift (classy, not flashing)
    for(let i=0;i<decals.length;i++){
      const g = decals[i].userData.glow;
      const op = 0.78 + Math.sin(now*0.0015 + i*1.7)*0.18;
      g.material.opacity = op;
    }

    // bank lateral pan
    bankPan += (bankPanTarget - bankPan)*Math.min(1,dt*5);

    // wings: swivel open/closed; the focused board swings flat to camera + slides to center,
    // and PARTS its neighbors aside so NOTHING crosses the open full-pattern face (no gray bars).
    const fIdx = focused ? focused.idx : null;
    for(const w of wings){
      w.swing += (w.swingTarget - w.swing)*Math.min(1,dt*7);
      // closed wings rake ~58° (read as a packed bank); open wing → 0° (flat, full face to camera)
      const rake = lerp(-1.02, 0.0, w.swing);
      w.pivot.rotation.y = rake;

      // base bank x, plus a parting shove when a different wing is focused
      let wantX = w.baseX;
      if(fIdx !== null && w !== focused){
        const side = (w.idx < fIdx) ? -1 : 1;             // push left of focus left, right of focus right
        const part = CLEAR * (focused.swing);             // grows as the focus board opens
        wantX = w.baseX + side*part;
      }
      // focused board glides forward to FOCUS_Z and to x=0 (dead center)
      wantX = lerp(wantX, 0, w.swing);
      const wantZ = lerp(BANK_Z, FOCUS_Z, w.swing);
      const px = wantX - BOARD_W/2 + bankPan*(1-w.swing);
      w.pivot.position.x += (px - w.pivot.position.x)*Math.min(1,dt*6);
      w.pivot.position.z += (wantZ - w.pivot.position.z)*Math.min(1,dt*6);
      w.pivot.position.y = BANK_Y + Math.sin(now*0.0006 + w.idx)*0.012*(1-w.swing);

      // open board scales up to a generous full face; closed boards stay bank-size
      const sc = lerp(1.0, 1.32, w.swing);
      w.board.scale.set(sc, sc, 1);

      // dim non-focused faces when one is open so the open face reads clean
      const wantDim = (focused && w!==focused) ? 1 : 0;
      w.dimT += (wantDim - w.dimT)*Math.min(1,dt*6);
      w.faceMat.color.setScalar(lerp(1, 0.5, w.dimT));
      w.faceMat.envMapIntensity = lerp(0.7, 1.05, w.swing); // open face catches more env sheen
    }

    renderWithBloom();
    requestAnimationFrame(tick);
  }

  // probe for headless verification
  window.__GARCIA__ = {
    ready:()=>wings.length,
    fps:()=>fps,
    focused:()=>focused? focused.idx : -1,
    texturesLoaded:()=>wings.filter(w=>w.faceMat.map && w.faceMat.map!==fallbackTex).length
  };

  start();
})();
</script>

<script>window.PROTO_META={"key":"V21","label":"V21 — Garcia Room","elements":[
  {"n":1,"label":"Liquid-light color-wash walls (slow oil-projection hue cycle)"},
  {"n":2,"label":"Tie-dye PMREM environment lighting the room"},
  {"n":3,"label":"Wing bank of China Seas boards in thin gold frames, ~2\" apart"},
  {"n":4,"label":"Click a wing → it swivels to a clean full-pattern face, no bars"},
  {"n":5,"label":"Dancing-bear + lightning-bolt framed art with emissive bloom"}
]};</script>
<script src="./proto-chrome.js"></script>
</body>
</html>