[object Object]

← back to Quadrille Showroom

Sample Table: add-sample drops the square swatch onto the consultation table

f541de6e435aa103f31355cec0025d1c1be9c625 · 2026-06-28 09:11:28 -0700 · Steve

Files touched

Diff

commit f541de6e435aa103f31355cec0025d1c1be9c625
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Jun 28 09:11:28 2026 -0700

    Sample Table: add-sample drops the square swatch onto the consultation table
---
 proto/shots/sample-table-filled.png | Bin 0 -> 786343 bytes
 proto/shots/sample-table-spread.png | Bin 0 -> 815176 bytes
 proto/shots/sample-table.png        | Bin 0 -> 694908 bytes
 public/proto/sample-table.html      | 476 ++++++++++++++++++++++++++++++++++++
 scripts/verify-sample-table.js      |  54 ++++
 5 files changed, 530 insertions(+)

diff --git a/proto/shots/sample-table-filled.png b/proto/shots/sample-table-filled.png
new file mode 100644
index 0000000..3ad641b
Binary files /dev/null and b/proto/shots/sample-table-filled.png differ
diff --git a/proto/shots/sample-table-spread.png b/proto/shots/sample-table-spread.png
new file mode 100644
index 0000000..0600ebc
Binary files /dev/null and b/proto/shots/sample-table-spread.png differ
diff --git a/proto/shots/sample-table.png b/proto/shots/sample-table.png
new file mode 100644
index 0000000..41243a7
Binary files /dev/null and b/proto/shots/sample-table.png differ
diff --git a/public/proto/sample-table.html b/public/proto/sample-table.html
new file mode 100644
index 0000000..a9634c1
--- /dev/null
+++ b/public/proto/sample-table.html
@@ -0,0 +1,476 @@
+<!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> &nbsp;·&nbsp; 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 {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
diff --git a/scripts/verify-sample-table.js b/scripts/verify-sample-table.js
new file mode 100644
index 0000000..be9f1d6
--- /dev/null
+++ b/scripts/verify-sample-table.js
@@ -0,0 +1,54 @@
+const { chromium } = require('playwright');
+(async () => {
+  const errors = [];
+  const browser = await chromium.launch();
+  const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+  page.on('console', m => { if (m.type() === 'error') errors.push(m.text()); });
+  page.on('pageerror', e => errors.push('PAGEERROR: ' + e.message));
+
+  await page.goto('http://127.0.0.1:7690/proto/sample-table.html', { waitUntil: 'networkidle' });
+  await page.waitForSelector('.card .add', { timeout: 8000 });
+  const cards = await page.$$('.card .add');
+  console.log('rail cards:', cards.length);
+
+  await page.screenshot({ path: 'proto/shots/sample-table.png' });
+
+  // add 4 samples from spread-out cards
+  for (const i of [0, 3, 7, 12]) {
+    if (cards[i]) { await cards[i].click(); await page.waitForTimeout(900); }
+  }
+  await page.waitForTimeout(900);
+
+  const samples = await page.$$('#surface .sample');
+  const count = await page.textContent('#count');
+  console.log('samples on table:', samples.length, '| count badge:', count);
+
+  // assert samples actually sit inside the rotated table (not floating over rail)
+  const tableBox = await (await page.$('#table')).boundingBox();
+  let insideTable = 0;
+  for (const s of samples) {
+    const b = await s.boundingBox();
+    if (b && b.y > tableBox.y && b.y < tableBox.y + tableBox.height) insideTable++;
+  }
+  console.log('samples within table bounds:', insideTable + '/' + samples.length);
+
+  const reqDisabled = await page.getAttribute('#req', 'disabled');
+  console.log('request button enabled:', reqDisabled === null);
+
+  await page.screenshot({ path: 'proto/shots/sample-table-filled.png' });
+
+  // remove one to confirm pick-up/remove works (invoke the handler directly —
+  // the × handle can sit above the viewport since the table overscans the top)
+  await page.evaluate(() => {
+    const s = document.querySelector('#surface .sample');
+    s.querySelector('.rm').dispatchEvent(new MouseEvent('click', { bubbles: true }));
+  });
+  await page.waitForTimeout(600);
+  const after = await page.textContent('#count');
+  console.log('count after remove:', after);
+
+  console.log('CONSOLE ERRORS:', errors.length);
+  errors.forEach(e => console.log('  ', e));
+  await browser.close();
+  process.exit(errors.length ? 1 : 0);
+})();

← b271af4 V6 Color River: ground hue on enrichment.json; bridge the ne  ·  back to Quadrille Showroom  ·  auto-save: 2026-06-28T09:13:51 (4 files) — public/js/rack.js 206ff85 →