← back to Quadrille Showroom
public/proto/sample-table.html
477 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>SAMPLE TABLE — China Seas</title>
<style>
:root{
--ink:#1a1206; --paper:#f0e6cf;
--plaster:#e7e1d6; --stone:#cfc7b8; --greige:#b8afa0;
--brass:#b9933f; --brass-lt:#c9a96e;
--oak:#9a7a52; --oak-dk:#6f5536; --oak-lt:#b58e5c;
--charcoal:#1c1c22;
--rail-h:184px;
}
*{ box-sizing:border-box; -webkit-tap-highlight-color:transparent; }
html,body{ margin:0; height:100%; overflow:hidden; background:#15110c;
font-family:"Cormorant Garamond","Hoefler Text",Georgia,"Times New Roman",serif;
color:var(--paper); }
button{ font-family:inherit; }
/* ───────── the room behind the table (limewash wall) ───────── */
#scene{ position:fixed; inset:0; overflow:hidden;
/* warm limewash plaster wall up top, warm oak floor below the table line */
background:
radial-gradient(120% 90% at 50% -8%, rgba(255,247,231,.10), rgba(0,0,0,0) 55%),
linear-gradient(180deg,
#d8d0c2 0%, #cdc4b4 30%, #c2b8a6 46%,
#8a6c47 46.001%, #7a5e3c 64%, #5e4830 100%); }
/* faint plaster mottle */
#scene::before{ content:""; position:absolute; inset:0;
background:
radial-gradient(40% 30% at 22% 20%, rgba(255,255,255,.05), rgba(0,0,0,0) 70%),
radial-gradient(46% 34% at 78% 14%, rgba(0,0,0,.04), rgba(0,0,0,0) 70%);
mix-blend-mode:overlay; pointer-events:none; }
/* picture-light graze + soft vignette */
#scene::after{ content:""; position:absolute; inset:0; pointer-events:none;
background:
radial-gradient(80% 40% at 50% 4%, rgba(255,236,200,.16), rgba(0,0,0,0) 60%),
radial-gradient(120% 120% at 50% 60%, rgba(0,0,0,0) 55%, rgba(20,16,11,.42) 100%); }
/* brass picture rail across the wall behind */
.wallrail{ position:absolute; left:0; right:0; top:18%; height:4px; z-index:1;
background:linear-gradient(180deg,#e7cf97,#b9933f 55%,#7d6228);
box-shadow:0 1px 0 rgba(255,255,255,.18), 0 6px 14px -6px rgba(0,0,0,.5); }
/* ───────── the consultation table (CSS-3D, perspective floor) ─────────
The whole stage sits ABOVE the rail; the table's near edge tucks just
behind the rail so a seated person looks ACROSS the oak. A moderate
rotateX (not extreme) keeps the top reading as a real receding surface. */
#stage{ position:fixed; left:0; right:0; bottom:0; top:0; z-index:2;
perspective:1150px; perspective-origin:50% 36%; pointer-events:none; }
/* the oval oak tabletop laid flat in perspective, filling the foreground */
#table{ position:absolute; left:50%; top:30%;
width:175vw; height:96vh; max-width:2100px;
transform:translate(-50%,0) rotateX(54deg);
transform-style:preserve-3d; pointer-events:auto; }
.tabletop{ position:absolute; inset:0; border-radius:50%/46%;
background:
radial-gradient(70% 80% at 50% 22%, rgba(255,238,210,.20), rgba(0,0,0,0) 58%),
repeating-linear-gradient(91deg,
#9c7c54 0 30px, #8f7049 30px 60px, #a3815770 60px 92px),
linear-gradient(178deg,#ab8a5e 0%,#977654 42%,#6f5536 100%);
box-shadow:
inset 0 14px 40px rgba(255,240,214,.12),
inset 0 0 160px 50px rgba(48,32,15,.5),
inset 0 -40px 90px rgba(40,26,12,.45),
0 70px 110px -24px rgba(0,0,0,.65);
border:1px solid rgba(58,40,20,.5); }
/* satin sheen sweep + a darker far-edge so the oval reads as a solid object */
.tabletop::after{ content:""; position:absolute; inset:0; border-radius:50%/46%;
background:
linear-gradient(116deg, rgba(255,246,228,.22) 0%, rgba(255,246,228,0) 30%),
radial-gradient(58% 50% at 50% 86%, rgba(255,238,212,.10), rgba(0,0,0,0) 60%);
mix-blend-mode:screen; pointer-events:none; }
/* the layer the sample squares live on — SAME 3D plane as the tabletop */
#surface{ position:absolute; inset:0; transform-style:preserve-3d; }
/* a sample square sitting flat on the table (already in the rotated plane) */
.sample{ position:absolute; width:210px; height:210px; margin:-105px 0 0 -105px;
transform-style:preserve-3d; cursor:grab; will-change:transform,opacity;
pointer-events:auto; }
.sample .paper{ position:absolute; inset:0; border-radius:3px;
background:#fbf7ee; padding:7px 7px 26px;
box-shadow:
0 1px 0 rgba(255,255,255,.7) inset,
0 28px 36px -12px rgba(20,14,6,.55),
0 8px 14px -6px rgba(20,14,6,.5);
border:1px solid rgba(120,96,60,.35); }
.sample .swatch{ position:absolute; left:7px; right:7px; top:7px; bottom:26px;
border-radius:2px; background-size:cover; background-position:center;
box-shadow:inset 0 0 0 1px rgba(0,0,0,.06); }
.sample .plate{ position:absolute; left:7px; right:7px; bottom:6px; height:16px;
display:flex; align-items:center; justify-content:space-between; gap:6px;
font-family:"Cormorant Garamond",Georgia,serif; }
.sample .pname{ font-size:11px; line-height:1; color:#2b2114; letter-spacing:.2px;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:74%; }
.sample .pcol{ font-size:8.5px; letter-spacing:1px; text-transform:uppercase;
color:#9a7a52; white-space:nowrap; }
/* a soft contact shadow puddle under each square, on the table plane */
.sample .contact{ position:absolute; left:6%; right:6%; top:14%; bottom:-6%;
border-radius:50%; background:rgba(20,12,4,.34);
filter:blur(11px); transform:translateZ(-2px); z-index:-1; }
/* remove (×) handle — appears on hover/touch */
.sample .rm{ position:absolute; top:-13px; right:-13px; width:30px; height:30px;
border-radius:50%; border:1.5px solid #fff; background:#1c1c22; color:#fff;
font:700 16px/1 -apple-system,Segoe UI,sans-serif; cursor:pointer;
display:none; align-items:center; justify-content:center;
box-shadow:0 6px 16px -4px rgba(0,0,0,.6); transform:translateZ(28px); }
.sample:hover .rm{ display:flex; }
.sample.lifted{ cursor:grabbing; }
@keyframes flyIn{
0% { opacity:0; }
12% { opacity:1; }
100% { opacity:1; }
}
/* ───────── browse rail (the swatch book strip) ───────── */
.rail{ position:fixed; left:0; right:0; bottom:0; height:var(--rail-h); z-index:6;
display:flex; gap:14px; align-items:stretch; padding:14px 18px 16px;
overflow-x:auto; overflow-y:hidden;
background:linear-gradient(to top, rgba(18,14,9,.96) 60%, rgba(18,14,9,.78));
border-top:1px solid rgba(201,169,110,.35);
box-shadow:0 -18px 40px -16px rgba(0,0,0,.7);
scrollbar-width:thin; }
.rail::-webkit-scrollbar{ height:8px; }
.rail::-webkit-scrollbar-thumb{ background:rgba(201,169,110,.4); border-radius:4px; }
.card{ flex:0 0 auto; width:128px; display:flex; flex-direction:column; gap:7px; }
.card .chip{ position:relative; width:128px; height:88px; border-radius:5px;
background:#2a2620 center/cover no-repeat; border:1px solid rgba(201,169,110,.3);
box-shadow:0 6px 16px -8px rgba(0,0,0,.6); }
.card .nm{ font-size:13.5px; line-height:1.15; color:#efe6d0; letter-spacing:.2px;
height:31px; overflow:hidden; }
.card .add{ width:100%; min-height:46px; border-radius:9px; cursor:pointer;
border:1.5px solid var(--brass); color:#1a1206; letter-spacing:.4px;
font:700 16px/1 "Cormorant Garamond",Georgia,serif;
background:linear-gradient(180deg,#e3cb95,#c19a44);
box-shadow:0 7px 18px -8px rgba(0,0,0,.55); transition:transform .1s,filter .1s; }
.card .add:hover{ filter:brightness(1.05); transform:translateY(-1px); }
.card .add:active{ transform:translateY(1px); }
/* ───────── top brand bar ───────── */
.topbar{ position:fixed; top:0; left:0; right:0; z-index:7;
display:flex; align-items:center; justify-content:space-between;
padding:16px 22px 28px; pointer-events:none;
background:linear-gradient(to bottom, rgba(20,16,11,.55), rgba(20,16,11,0)); }
.brand{ letter-spacing:.34em; font-size:12px; text-transform:uppercase;
opacity:.92; font-family:-apple-system,Segoe UI,sans-serif; font-weight:600; }
.brand b{ color:var(--brass-lt); font-weight:700; }
/* ───────── basket / count card (right) ───────── */
.basket{ position:fixed; right:18px; top:64px; z-index:8; width:230px;
background:rgba(16,13,9,.92); border:1.5px solid rgba(201,169,110,.45);
border-radius:14px; padding:14px 15px 15px; backdrop-filter:blur(10px);
box-shadow:0 18px 50px -14px rgba(0,0,0,.7); }
.basket .eyebrow{ font:600 10.5px/1 -apple-system,Segoe UI,sans-serif;
letter-spacing:2.4px; text-transform:uppercase; color:#a7a08f; }
.basket .count{ display:flex; align-items:baseline; gap:8px; margin:6px 0 12px; }
.basket .count b{ font:400 40px/1 "Cormorant Garamond",Georgia,serif; color:#efe6d0; }
.basket .count span{ font-size:14px; color:#bdb39c; letter-spacing:.5px; }
.basket .req{ width:100%; min-height:50px; border-radius:11px; cursor:pointer;
border:1.5px solid var(--brass); color:#1a1206; letter-spacing:.4px;
font:700 16px/1.1 "Cormorant Garamond",Georgia,serif;
background:linear-gradient(180deg,#e3cb95,#c19a44);
transition:filter .12s,transform .1s; }
.basket .req:disabled{ filter:grayscale(.6) brightness(.85); cursor:default;
border-color:rgba(201,169,110,.4); }
.basket .req:not(:disabled):hover{ filter:brightness(1.05); }
.basket .req:not(:disabled):active{ transform:translateY(1px); }
.basket .hint{ margin-top:9px; font-size:11px; color:#9a937f; line-height:1.4;
font-family:-apple-system,Segoe UI,sans-serif; }
/* table caption — engraved placard feel */
.caption{ position:fixed; left:50%; top:9%; transform:translateX(-50%); z-index:5;
text-align:center; pointer-events:none; }
.caption .k{ font:600 10.5px/1 -apple-system,Segoe UI,sans-serif;
letter-spacing:3px; text-transform:uppercase; color:#7c6a48; }
.caption .t{ margin-top:6px; font-size:26px; color:#efe6d0; letter-spacing:.5px;
text-shadow:0 2px 14px rgba(0,0,0,.4); }
/* toast when basket requested */
.toast{ position:fixed; left:50%; bottom:calc(var(--rail-h) + 22px);
transform:translateX(-50%) translateY(20px); z-index:9; opacity:0;
pointer-events:none; transition:opacity .3s,transform .3s;
background:rgba(20,16,11,.95); border:1.5px solid var(--brass);
color:#efe6d0; padding:14px 22px; border-radius:30px; font-size:16px;
letter-spacing:.4px; box-shadow:0 14px 40px -10px rgba(0,0,0,.7); }
.toast.show{ opacity:1; transform:translateX(-50%) translateY(0); }
.loading{ position:fixed; inset:0; z-index:30; display:flex; gap:14px;
align-items:center; justify-content:center; flex-direction:column;
background:#15110c; transition:opacity .5s; }
.loading.gone{ opacity:0; pointer-events:none; }
.ring{ width:32px; height:32px; border:2px solid rgba(240,230,207,.2);
border-top-color:#c9a96e; border-radius:50%; animation:spin 1s linear infinite; }
@keyframes spin{ to{ transform:rotate(360deg); } }
.loading p{ letter-spacing:.28em; text-transform:uppercase; font-size:11px;
color:#bdb39c; font-family:-apple-system,Segoe UI,sans-serif; }
@media(max-width:760px){
.basket{ width:170px; top:58px; right:12px; }
.basket .count b{ font-size:32px; }
#table{ width:188vw; }
}
</style>
</head>
<body>
<div id="scene"><div class="wallrail"></div></div>
<div id="stage">
<div id="table">
<div class="tabletop"></div>
<div id="surface"></div>
</div>
</div>
<div class="topbar">
<div class="brand"><b>The Quadrille House</b> · Sample Table</div>
<div></div>
</div>
<div class="caption">
<div class="k">China Seas · Consultation</div>
<div class="t">Lay your samples on the table</div>
</div>
<div class="basket">
<div class="eyebrow">Samples on table</div>
<div class="count"><b id="count">0</b><span id="countWord">samples</span></div>
<button class="req" id="req" disabled>Request these samples</button>
<div class="hint">Tap <b>+ Add Sample</b> below. Drag a square to move it, or hover and tap × to put it back.</div>
</div>
<div class="rail" id="rail"></div>
<div class="toast" id="toast"></div>
<div class="loading" id="loading"><div class="ring"></div><p>Setting the table…</p></div>
<!-- version-picker integration (Back button + numbered-element legend) -->
<script>
window.PROTO_META = {
key: 'V11', label: 'Sample Table',
elements: [
{ n: 1, label: 'Sit at a warm oak consultation table (POV)' },
{ n: 2, label: '"+ Add Sample" drops the square swatch ONTO the table' },
{ n: 3, label: 'Samples land flat-in-perspective + spread naturally' },
{ n: 4, label: 'Drag to rearrange · × to remove a sample' },
{ n: 5, label: 'Running count + "Request these samples" basket' }
]
};
</script>
<script src="./proto-chrome.js"></script>
<script>
(function(){
"use strict";
var API = '/api/showroom/products?limit=200';
var rail = document.getElementById('rail');
var surface= document.getElementById('surface');
var tableEl= document.getElementById('table');
var countEl= document.getElementById('count');
var wordEl = document.getElementById('countWord');
var reqBtn = document.getElementById('req');
var toastEl= document.getElementById('toast');
// table-local coordinate frame. The #table element is rotateX(64deg); the
// #surface fills it, so any child placed in surface px-coords inherits the
// SAME perspective — squares are genuinely lying on the tabletop plane, not
// pinned to the screen. We place samples within a centred ellipse so they
// pool on the visible top of the table.
var placed = []; // {el, sku} list of samples on the table
var slotIndex = 0; // drives the natural spiral-ish spread
function tableSize(){ return { w: tableEl.clientWidth, h: tableEl.clientHeight }; }
// a pseudo-random-but-stable spread: golden-angle spiral from table centre,
// biased toward the near (lower) half of the oval where a seated person
// would actually lay swatches. Returns {x,y} in #surface px + a rotation.
function nextSlot(){
var s = tableSize();
// Because the table is steeply foreshortened, the screen-visible band of
// the oak (above the rail, in front of the seated viewer) maps to the
// UPPER-MIDDLE of the table's own surface coordinates. Pool the swatches
// there so they land where the eye actually sees the tabletop.
var cx = s.w*0.5, cy = s.h*0.40;
var i = slotIndex++;
var ang = i * 2.399963; // golden angle (rad)
var rad = Math.min(s.w,s.h) * 0.072 * Math.sqrt(i+0.6);
rad = Math.min(rad, Math.min(s.w,s.h)*0.22);
// jitter so it never looks like a perfect math spiral
var jx = ((Math.sin(i*12.9898)*43758.5453) % 1) * 80 - 40;
var jy = ((Math.sin(i*78.233 )*12543.1234) % 1) * 44 - 22;
var x = cx + Math.cos(ang)*rad*1.25 + jx; // spread wider than deep
var y = cy + Math.sin(ang)*rad*0.55 + jy; // squash y → oval pooling
var rot = (((Math.sin(i*3.1)*1000)) % 1)*36 - 18; // ±18° lay rotation
return { x:x, y:y, rot:rot };
}
function escapeHtml(s){ return String(s||'').replace(/[&<>"']/g,function(m){
return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[m]; }); }
function imgUrl(u){
if(!u) return '';
if(u.charAt(0)==='/') return u; // local /gen-tiles etc.
return '/api/proxy/image?url=' + encodeURIComponent(u);
}
function colorLabel(p){
if(p.color_name) return p.color_name;
if(p.color_bucket) return p.color_bucket;
// derive a short colorway from the pattern name tail (" on White" etc.)
var m = /\bon\s+([A-Za-z ]{3,22})$/.exec((p.pattern_name||'').replace(/\s*Screen.*$/i,''));
return m ? m[1].trim() : (p.collection || 'China Seas');
}
function shortName(p){
var n = (p.pattern_name||p.title||'').replace(/\s*-\s*Off-White$/i,'')
.replace(/\bScreen Printed\b/i,'').replace(/\s+on\s+.*$/i,'').trim();
return n || 'China Seas';
}
function updateCount(){
var n = placed.length;
countEl.textContent = n;
wordEl.textContent = n===1 ? 'sample' : 'samples';
reqBtn.disabled = n===0;
}
function toast(msg){
toastEl.textContent = msg; toastEl.classList.add('show');
clearTimeout(toast._t); toast._t = setTimeout(function(){ toastEl.classList.remove('show'); }, 2600);
}
// ───────── drop a sample onto the table ─────────
function addSample(p, fromEl){
var slot = nextSlot();
var el = document.createElement('div');
el.className = 'sample';
el.dataset.sku = p.sku || '';
el.innerHTML =
'<div class="contact"></div>' +
'<div class="paper">' +
'<div class="swatch" style="background-image:url(\'' + imgUrl(p.image||p.tile) + '\')"></div>' +
'<div class="plate">' +
'<span class="pname">' + escapeHtml(shortName(p)) + '</span>' +
'<span class="pcol">' + escapeHtml(colorLabel(p)) + '</span>' +
'</div>' +
'</div>' +
'<button class="rm" title="Remove">×</button>';
// start the square small, "in the air" above its slot (translateZ lifts it
// off the table plane), then settle it flat. Because it lives INSIDE the
// rotated #surface, the lift + landing happen in the table's own 3D space.
var land = 'translate3d('+slot.x+'px,'+slot.y+'px,0) rotateZ('+slot.rot.toFixed(1)+'deg)';
var lift = 'translate3d('+slot.x+'px,'+(slot.y-70)+'px,150px) rotateZ('+(slot.rot-22).toFixed(1)+'deg) scale(1.06)';
el.style.transform = lift;
el.style.opacity = '0';
surface.appendChild(el);
// force reflow then animate to the landed transform
// eslint-disable-next-line no-unused-expressions
el.offsetWidth;
el.style.transition = 'transform .62s cubic-bezier(.22,1.08,.36,1), opacity .3s ease-out';
el.style.opacity = '1';
el.style.transform = land;
// tiny settle bounce after landing
setTimeout(function(){
el.style.transition = 'transform .16s ease-out';
el.style.transform = 'translate3d('+slot.x+'px,'+(slot.y+3)+'px,0) rotateZ('+slot.rot.toFixed(1)+'deg)';
setTimeout(function(){
el.style.transform = land;
el.dataset.x = slot.x; el.dataset.y = slot.y; el.dataset.rot = slot.rot;
}, 160);
}, 600);
// remove handle
el.querySelector('.rm').addEventListener('click', function(ev){
ev.stopPropagation();
el.style.transition = 'transform .4s ease-in, opacity .4s ease-in';
el.style.transform = 'translate3d('+slot.x+'px,'+(slot.y-90)+'px,180px) scale(.9)';
el.style.opacity = '0';
setTimeout(function(){ el.remove(); }, 380);
placed = placed.filter(function(r){ return r.el!==el; });
updateCount();
});
enableDrag(el);
placed.push({ el:el, sku:p.sku });
updateCount();
// little nudge on the source button
if(fromEl){ fromEl.style.transform='translateY(2px)';
setTimeout(function(){ fromEl.style.transform=''; }, 120); }
}
// ───────── drag a placed sample around the table plane ─────────
// Pointer moves are in SCREEN px; the table plane is rotateX(64°), so a
// screen-Y delta maps to a larger table-Y delta. We divide vertical movement
// by cos(64°) so dragging tracks the surface, keeping it glued to the table.
var COS = Math.cos(54*Math.PI/180); // ≈ 0.588 — matches table rotateX
function enableDrag(el){
var sx,sy,ox,oy,rot,active=false;
function down(e){
if(e.target.classList.contains('rm')) return;
active=true; el.classList.add('lifted');
var pt = e.touches?e.touches[0]:e;
sx=pt.clientX; sy=pt.clientY;
ox=parseFloat(el.dataset.x)||0; oy=parseFloat(el.dataset.y)||0;
rot=parseFloat(el.dataset.rot)||0;
// bring to front + a slight lift off the table while held
surface.appendChild(el);
el.style.transition='transform .12s ease-out';
el.style.transform='translate3d('+ox+'px,'+oy+'px,40px) rotateZ('+rot+'deg) scale(1.04)';
e.preventDefault();
window.addEventListener('mousemove',move); window.addEventListener('mouseup',up);
window.addEventListener('touchmove',move,{passive:false}); window.addEventListener('touchend',up);
}
function move(e){
if(!active) return;
var pt = e.touches?e.touches[0]:e;
var nx = ox + (pt.clientX - sx);
var ny = oy + (pt.clientY - sy)/COS; // un-foreshorten vertical drag
el.style.transition='none';
el.style.transform='translate3d('+nx+'px,'+ny+'px,40px) rotateZ('+rot+'deg) scale(1.04)';
el.dataset.x=nx; el.dataset.y=ny;
e.preventDefault();
}
function up(){
if(!active) return; active=false; el.classList.remove('lifted');
var nx=parseFloat(el.dataset.x)||0, ny=parseFloat(el.dataset.y)||0;
el.style.transition='transform .2s cubic-bezier(.22,1,.36,1)';
el.style.transform='translate3d('+nx+'px,'+ny+'px,0) rotateZ('+rot+'deg)'; // settle flat
window.removeEventListener('mousemove',move); window.removeEventListener('mouseup',up);
window.removeEventListener('touchmove',move); window.removeEventListener('touchend',up);
}
el.addEventListener('mousedown',down);
el.addEventListener('touchstart',down,{passive:false});
}
// ───────── build the browse rail ─────────
function buildRail(products){
var frag = document.createDocumentFragment();
products.forEach(function(p){
var card = document.createElement('div'); card.className='card';
var chip = document.createElement('div'); chip.className='chip';
chip.style.backgroundImage = "url('"+imgUrl(p.image||p.tile)+"')";
var nm = document.createElement('div'); nm.className='nm';
nm.textContent = shortName(p) + ' · ' + colorLabel(p);
var add = document.createElement('button'); add.className='add';
add.textContent = '+ Add Sample';
add.addEventListener('click', function(){ addSample(p, add); });
card.appendChild(chip); card.appendChild(nm); card.appendChild(add);
frag.appendChild(card);
});
rail.appendChild(frag);
}
reqBtn.addEventListener('click', function(){
if(!placed.length) return;
var skus = placed.map(function(r){ return r.sku; }).filter(Boolean);
try{ localStorage.setItem('qh_sample_basket', JSON.stringify(skus)); }catch(e){}
toast('Memo samples requested — ' + placed.length + ' on their way ✦');
});
// ───────── load data ─────────
fetch(API).then(function(r){ return r.json(); }).then(function(d){
var products = (d.products||d||[]).filter(function(p){ return p.image||p.tile; });
buildRail(products);
document.getElementById('loading').classList.add('gone');
}).catch(function(err){
console.error('sample-table load failed', err);
document.getElementById('loading').querySelector('p').textContent = 'Could not load samples';
});
})();
</script>
</body>
</html>