[object Object]

← back to Quadrille Showroom

auto-save: 2026-06-30T21:43:31 (2 files) — public/js/showroom.js public/js/viewmodes.js

0fb3c6b9bc6d32ccbd2f6077af9a7d60947a528c · 2026-06-30 21:43:34 -0700 · Steve Abrams

Files touched

Diff

commit 0fb3c6b9bc6d32ccbd2f6077af9a7d60947a528c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Tue Jun 30 21:43:34 2026 -0700

    auto-save: 2026-06-30T21:43:31 (2 files) — public/js/showroom.js public/js/viewmodes.js
---
 public/js/showroom.js  | 239 ++++++++++++++++++++++++++++++++++
 public/js/viewmodes.js | 347 ++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 584 insertions(+), 2 deletions(-)

diff --git a/public/js/showroom.js b/public/js/showroom.js
index 87227ed..ab23ebc 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -1295,6 +1295,155 @@ function updateAvatarRig() {
   avatarRig.rotation.y = spinYaw;   // yaw with the look; ignore spinPitch entirely
 }
 
+// ============================================================
+// PHASE 3 — SEATED THIRD-PERSON AVATAR ("At the Table" view)
+// ------------------------------------------------------------
+// The first-person rig above (buildAvatar) is HEADLESS-forward — you look OUT of it.
+// The table view is THIRD-PERSON (camera over the seated figure's shoulder toward the
+// boards), so it needs a SEATED figure WITH a visible head/face. This reuses the same
+// primitive vocabulary (capsuleMesh limbs, charcoal sweater + indigo jeans + skin +
+// shoe materials) but hinges the hips + knees ~90° so the figure sits on a chair,
+// torso upright, hands resting near the table edge, head/face looking at the boards.
+//
+// Gated to the 'table' view mode only: seatedAvatarWanted (separate from avatarWanted,
+// which gates the first-person body). setTableSeatVisible() flips the whole set (figure +
+// furniture); buildTableSeat() lazy-builds it the first time the table mode is entered.
+// ============================================================
+let seatedAvatarRig = null;      // the whole seated figure group
+let seatedAvatarWanted = false;  // visible only in the 'table' mode
+
+function buildSeatedAvatar(sx, sz, faceYaw) {
+  // Clean rebuild safety.
+  if (seatedAvatarRig) { scene.remove(seatedAvatarRig); seatedAvatarRig = null; }
+
+  const P = ageRigParams(28);   // a clean adult figure (identity anchor)
+  const rig = new THREE.Group();
+  rig.name = 'seatedAvatarRig';
+  // Origin = the point on the floor directly under the seated hips. faceYaw aims the
+  // figure toward the boards (typically -z ≈ Math.PI when seated in front of them).
+  rig.position.set(sx, 0, sz);
+  rig.rotation.y = (typeof faceYaw === 'number') ? faceYaw : Math.PI;
+
+  // Materials — same look as the first-person rig (charcoal knit / indigo denim / skin / leather).
+  const sweater = new THREE.MeshStandardMaterial({ color: P.colors.sweater, roughness: 0.92, metalness: 0.0, envMapIntensity: 0.2 });
+  const jeans   = new THREE.MeshStandardMaterial({ color: P.colors.jeans,   roughness: 0.95, metalness: 0.0, envMapIntensity: 0.15 });
+  const skin    = new THREE.MeshStandardMaterial({ color: P.colors.skin,    roughness: P.colors.skinRough, metalness: 0.0, envMapIntensity: 0.2 });
+  const shoe    = new THREE.MeshStandardMaterial({ color: P.colors.shoe,    roughness: 0.55, metalness: 0.05, envMapIntensity: 0.3 });
+  const hair    = new THREE.MeshStandardMaterial({ color: 0x2a231c, roughness: 0.85, metalness: 0.0 });
+
+  // A chair seat sits ~0.44m off the floor (matches placeChair's seat height); the
+  // seated figure's hips rest just above it. Local +z is FORWARD (toward the boards
+  // once yawed), so the thighs extend +z and the shins drop down from the knees.
+  const SEAT_Y   = 0.46;                 // hip pivot height (on the chair cushion)
+  const THIGH_L  = 0.42;                 // hip → knee (runs forward, roughly level)
+  const SHIN_L   = 0.44;                 // knee → ankle (drops to the floor)
+  const shoulderHalf = P.shoulderHalf;
+
+  // ---- PELVIS / HIPS (denim) ----
+  const hips = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.15, 0.20, 16), jeans);
+  hips.position.set(0, SEAT_Y, 0);
+  hips.scale.set(1.15, 1.0, 0.9);
+  hips.castShadow = true; rig.add(hips);
+
+  // ---- TORSO (charcoal sweater) — upright from the hips to the shoulder line ----
+  const torsoH = 0.50;
+  const chestTopY = SEAT_Y + 0.10 + torsoH;   // shoulder line
+  const torso = capsuleMesh(0.17, torsoH - 0.10, sweater, 16);
+  torso.position.set(0, SEAT_Y + 0.12 + torsoH / 2, -0.02);
+  torso.scale.set(1.15, 1.0, 0.80);           // broad across, flat front-to-back
+  torso.castShadow = true; rig.add(torso);
+
+  // ---- NECK + HEAD (this figure HAS a face — it's third-person) ----
+  const neck = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.08, 12), skin);
+  neck.position.set(0, chestTopY + 0.02, -0.01);
+  neck.castShadow = true; rig.add(neck);
+
+  const headG = new THREE.Group();
+  headG.position.set(0, chestTopY + 0.16, 0.0);
+  // Head — a soft ovoid, slightly forward-tilted looking at the boards.
+  const head = new THREE.Mesh(new THREE.SphereGeometry(0.105, 20, 18), skin);
+  head.scale.set(0.92, 1.06, 0.98);
+  head.castShadow = true; headG.add(head);
+  // Hair cap — hemisphere over the crown + back.
+  const hairCap = new THREE.Mesh(new THREE.SphereGeometry(0.112, 18, 14, 0, Math.PI * 2, 0, Math.PI * 0.62), hair);
+  hairCap.position.set(0, 0.012, -0.006);
+  hairCap.scale.set(0.95, 1.05, 1.0);
+  hairCap.castShadow = true; headG.add(hairCap);
+  // Nose nub — a tiny cue of a face, pointing FORWARD (+z, toward the boards).
+  const nose = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 8), skin);
+  nose.position.set(0, -0.01, 0.10);
+  headG.add(nose);
+  headG.rotation.x = 0.06;   // slight downward gaze toward the table/boards
+  rig.add(headG);
+
+  // ---- SHOULDERS ----
+  [-1, 1].forEach(s => {
+    const sh = new THREE.Mesh(new THREE.SphereGeometry(0.10, 14, 12), sweater);
+    sh.position.set(s * shoulderHalf, chestTopY - 0.02, -0.02);
+    sh.scale.set(1.1, 0.85, 0.9);
+    sh.castShadow = true; rig.add(sh);
+  });
+
+  // ---- ARMS — upper arm drops from the shoulder, forearm swings FORWARD (+z) so the
+  // hands rest near the table edge (a seated consultation pose). ----
+  [-1, 1].forEach(s => {
+    const arm = new THREE.Group();
+    arm.position.set(s * (shoulderHalf + 0.02), chestTopY - 0.05, -0.02);
+
+    const upper = capsuleMesh(0.06, 0.22, sweater, 12);
+    upper.position.set(s * 0.02, -0.14, 0.02);
+    upper.rotation.z = s * 0.14;
+    upper.rotation.x = 0.32;               // elbow forward + down
+    arm.add(upper);
+
+    const fore = capsuleMesh(0.05, 0.22, sweater, 12);
+    fore.position.set(s * 0.03, -0.30, 0.20);
+    fore.rotation.z = -s * 0.10;
+    fore.rotation.x = 1.15;                // forearm runs forward toward the table
+    arm.add(fore);
+
+    const hand = new THREE.Mesh(new THREE.SphereGeometry(0.052, 12, 10), skin);
+    hand.scale.set(1.1, 0.7, 1.25);
+    hand.position.set(s * 0.02, -0.35, 0.36);   // resting near the table edge
+    hand.castShadow = true; arm.add(hand);
+
+    arm.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+    rig.add(arm);
+  });
+
+  // ---- LEGS — SEATED: thigh runs forward (+z) roughly level from the hip, shin drops
+  // straight down from the knee to a foot flat on the floor. ~90° at hip and knee. ----
+  [-1, 1].forEach(s => {
+    const kneeX = s * 0.12, kneeZ = THIGH_L;   // knee is forward of the hips
+    // Thigh: from the hip forward to the knee (nearly horizontal).
+    const thigh = capsuleMesh(0.085, THIGH_L - 0.14, jeans, 12);
+    thigh.position.set(kneeX * 0.5, SEAT_Y - 0.01, THIGH_L / 2);
+    thigh.rotation.x = Math.PI / 2 - 0.12;     // ~horizontal, tipped slightly down toward the knee
+    thigh.castShadow = true; rig.add(thigh);
+
+    // Shin: from the knee down to the ankle.
+    const shin = capsuleMesh(0.075, SHIN_L - 0.14, jeans, 12);
+    shin.position.set(kneeX, SEAT_Y - SHIN_L / 2 + 0.02, kneeZ - 0.02);
+    shin.rotation.x = 0.06;
+    shin.castShadow = true; rig.add(shin);
+
+    // Foot — flat on the floor, toe forward (+z).
+    const foot = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.07, 0.26), shoe);
+    foot.position.set(kneeX, 0.04, kneeZ + 0.06);
+    foot.castShadow = true; foot.receiveShadow = true; rig.add(foot);
+  });
+
+  rig.traverse(o => { if (o.isMesh) { o.castShadow = true; o.receiveShadow = true; } });
+  // Soft contact shadow under the seated figure.
+  addContactShadow(sx, sz + 0.2, 0.9, 0.9, 0.55);
+
+  scene.add(rig);
+  seatedAvatarRig = rig;
+  rig.visible = seatedAvatarWanted;
+  requestShadowUpdate(2);
+  return rig;
+}
+
 // Round warm-wood pedestal table + two cream bouclé armchairs + swatch cards — the
 // foreground consultation nook from the PJ Dallas showroom photo.
 function buildConsultationNook(cx, cz) {
@@ -1349,6 +1498,88 @@ function buildConsultationNook(cx, cz) {
   placeChair(cx - 0.05, cz - 0.78, 0);          // far chair, faces +z (toward viewer)
 }
 
+// ============================================================
+// PHASE 3 — "AT THE TABLE" consultation set. A dedicated round table + a single chair
+// with the SEATED avatar on it, positioned CENTRE-FRONT of the board wall (distinct
+// from the always-on right-side nook). Lazy-built the first time the table view is
+// entered, then just toggled visible/hidden. The seated figure faces the boards (-z),
+// so the camera parks behind its shoulder looking past the head at the wall of designs.
+// ============================================================
+let tableSeatGroup = null;   // the table-view furniture (table + chair) — toggled with the view
+
+function buildTableSeat() {
+  if (tableSeatGroup) return tableSeatGroup;   // build once
+  const backZ = -CONFIG.room.depth / 2 + 0.2;  // board-wall plane
+  // Place the table a comfortable distance IN FRONT of the boards, centred on x.
+  const tx = 0, tz = backZ + 1.9;              // ~1.9m off the wall
+
+  const g = new THREE.Group();
+  g.name = 'tableSeatGroup';
+
+  const woodTop  = new THREE.MeshStandardMaterial({ color: 0x9a7a52, roughness: 0.42, metalness: 0.0, envMapIntensity: 0.3 });
+  const woodDark = new THREE.MeshStandardMaterial({ color: 0x6e573a, roughness: 0.5,  metalness: 0.0 });
+  const boucle   = new THREE.MeshStandardMaterial({ color: 0xe8e0d0, roughness: 0.95, metalness: 0.0, envMapIntensity: 0.2 });
+
+  const TABLE_R = 0.58, TABLE_H = 0.74;
+  const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 44), woodTop);
+  top.position.set(tx, TABLE_H, tz); top.castShadow = true; top.receiveShadow = true; g.add(top);
+  const col = new THREE.Mesh(new THREE.CylinderGeometry(0.08, 0.10, TABLE_H - 0.05, 16), woodDark);
+  col.position.set(tx, (TABLE_H - 0.05) / 2, tz); col.castShadow = true; g.add(col);
+  const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.32, 0.36, 0.05, 24), woodDark);
+  foot.position.set(tx, 0.03, tz); foot.castShadow = true; foot.receiveShadow = true; g.add(foot);
+
+  // A single bouclé chair on the near (camera) side of the table, facing the boards (-z).
+  const chZ = tz + 0.92;
+  const chair = new THREE.Group(); chair.position.set(tx, 0, chZ); chair.rotation.y = 0;  // faces -z (toward boards)
+  const seat = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.16, 0.52), boucle);
+  seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; chair.add(seat);
+  const back = new THREE.Mesh(new THREE.BoxGeometry(0.54, 0.52, 0.14), boucle);
+  back.position.set(0, 0.72, 0.21); back.castShadow = true; chair.add(back);   // backrest behind the sitter (+z, toward camera)
+  [-0.27, 0.27].forEach(ax => {
+    const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.48), boucle);
+    arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; chair.add(arm);
+  });
+  [[-0.21,0.21],[0.21,0.21],[-0.21,-0.21],[0.21,-0.21]].forEach(([lx,lz]) => {
+    const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.02, 0.015, 0.36, 8), woodDark);
+    leg.position.set(lx, 0.18, lz); leg.castShadow = true; chair.add(leg);
+  });
+  g.add(chair);
+
+  scene.add(g);
+  addContactShadow(tx, tz, 1.0, 1.0, 0.6);
+  addContactShadow(tx, chZ, 0.7, 0.7, 0.55);
+  tableSeatGroup = g;
+
+  // Seat the avatar on the chair, facing the boards (-z). The chair seat is at ~0.44m;
+  // buildSeatedAvatar hips sit at ~0.46m, so the figure rests naturally on the cushion.
+  buildSeatedAvatar(tx, chZ, Math.PI);   // faceYaw = π → local +z (forward/hands/thighs) points to -z world (the boards)
+
+  g.visible = false;   // hidden until the table view turns it on
+  requestShadowUpdate(4);
+  return g;
+}
+
+// Show/hide the whole "At the Table" set (furniture + seated figure) together.
+function setTableSeatVisible(v) {
+  seatedAvatarWanted = !!v;
+  if (v) buildTableSeat();
+  if (tableSeatGroup) tableSeatGroup.visible = !!v;
+  if (seatedAvatarRig) seatedAvatarRig.visible = !!v;
+  if (typeof requestShadowUpdate === 'function') requestShadowUpdate(6);
+}
+// Where the camera should park to frame the seated figure over-the-shoulder toward the
+// boards. Returns {pos, target} in world space, derived from the built table/chair.
+function tableViewCamera() {
+  const backZ = -CONFIG.room.depth / 2 + 0.2;
+  const tz = backZ + 1.9;          // table centre z (mirror of buildTableSeat)
+  const chZ = tz + 0.92;           // chair/sitter z
+  const boardCY = CONFIG.wing.height / 2 + 0.04;
+  // Behind + above the seated figure's head, looking past it at the board wall.
+  const pos    = new THREE.Vector3(0.55, 1.85, chZ + 1.15);
+  const target = new THREE.Vector3(0.0, boardCY, backZ);
+  return { pos, target };
+}
+
 // A single olive/fig in a square honed-stone planter — soft layered canopy (not a
 // hard sphere), slim trunk, restrained. Built from a handful of low-poly meshes.
 function buildOliveTree(px, py, pz) {
@@ -4881,6 +5112,14 @@ window._qh = {
   // avatar's back across the whole frame. Persists across setAvatarAge() rebuilds.
   setAvatarVisible(v) { avatarWanted = !!v; if (avatarRig) avatarRig.visible = avatarWanted; },
   get avatarVisible() { return avatarWanted; },
+  // ---- PHASE 3 "At the Table" surface — the SEATED third-person figure + consultation
+  // set, and the over-the-shoulder camera pose. Lazy-built on first show. Gated to the
+  // 'table' view mode only (separate from the first-person avatarWanted).
+  setTableSeatVisible(v) { setTableSeatVisible(v); },
+  get seatedAvatarPresent() { return !!seatedAvatarRig; },
+  get tableSeatVisible() { return !!(tableSeatGroup && tableSeatGroup.visible); },
+  tableViewCamera() { return tableViewCamera(); },
+  buildTableSeat() { return buildTableSeat(); },
   get spinPitch()  { return spinPitch; },
   avatarState() {
     if (!avatarRig) return null;
diff --git a/public/js/viewmodes.js b/public/js/viewmodes.js
index f26d7c9..32c15cb 100644
--- a/public/js/viewmodes.js
+++ b/public/js/viewmodes.js
@@ -38,6 +38,8 @@ function boot() {
   let compareKeyHandler = null; // compare: keydown listener to cycle the 2nd design
   let compareSetup = false;   // compare mode: two boards staged
   let savedFocusIndex = 0;    // remember which board to return to
+  let tableSetUp = false;     // table mode: seated set + overlay staged
+  let tableClickHandler = null; // table mode: capture-phase board-pick click listener
 
   // The mode that the guided Next/Back/Auto-Tour buttons operate within.
   // Hero / Angled / Macro / Compare / Room re-frame the SAME focused board, so
@@ -252,10 +254,96 @@ function boot() {
         const tgt = new THREE.Vector3(c.x - 0.3, 1.35, backZ);
         park(pos, tgt, 56);
       }
+    },
+
+    // PHASE 3 — "At the Table" (DTD verdict A). A SEATED third-person avatar at a
+    // consultation table in front of the boards. Clicking a board still centres that
+    // design (reuses focusOnWing → _openCenteredBoard → cladWalls). A CSS-3D sample
+    // overlay (ported from proto/sample-table.html) lets the user drop the currently-
+    // centred product's swatch onto the table + drag/remove it; the basket persists to
+    // localStorage('qh_sample_basket').
+    table: {
+      label: 'At the Table', hint: 'Sit at the consultation table — add swatches, drag to arrange.', guided: true,
+      enter() {
+        // FIRST entry (not a guided-reframe re-entry): reset to the full rail of boards on
+        // neutral walls so the seated user surveys every design, none pre-selected. On a
+        // reframe (tableSetUp already true) we skip this so we don't unfocus the board the
+        // user just navigated to.
+        if (!tableSetUp) {
+          QH.restoreBoards();
+          if (QH.focusedWing) QH.unfocusWing(true);
+          QH.revertWalls();
+        }
+        // Reveal the seated set + table (idempotent) and park the over-the-shoulder camera.
+        if (QH.setTableSeatVisible) QH.setTableSeatVisible(true);
+        setWingDeg(0);
+        parkTableCamera();
+        // A table-mode click handler owns board selection: because parkTableCamera() locks
+        // the camera (so onCanvasClick early-returns and clicks don't snap the eye back to
+        // room-centre), we raycast boards ourselves, centre the design via the existing
+        // focusOnWing → _openCenteredBoard → cladWalls path, then re-park the table camera.
+        if (!tableClickHandler) {
+          tableClickHandler = (e) => {
+            if (currentMode !== 'table') return;
+            // Ignore clicks that land on the sample overlay / UI (they have their own handlers).
+            if (e.target && e.target.closest &&
+                e.target.closest('#vm-sample-stage, #vm-sample-add, #vm-panel, #vm-perf, #vm-banner, ' +
+                                 '#wing-detail, #window-bar, #dock, button, input, select, a')) return;
+            const idx = pickBoardAt(e.clientX, e.clientY);
+            if (idx < 0) return;
+            const b = QH.wingBoards[idx];
+            if (b) {
+              // We handled this click — stop it reaching showroom.onCanvasClick (which,
+              // during the ~0.8s before park() locks controls, would otherwise ALSO fire
+              // focusOnWing and snap the camera off the table pose). Then re-park.
+              e.stopPropagation();
+              QH.focusOnWing(b);
+              // focusOnWing synchronously snaps the eye to room-centre (its normal behaviour);
+              // immediately re-park so the camera glides straight back to the table pose
+              // instead of flashing through centre. A second re-park on next tick covers the
+              // async carousel settle that may re-touch the pose.
+              parkTableCamera();
+              setTimeout(parkTableCamera, 60);
+            }
+          };
+          window.addEventListener('click', tableClickHandler, true);
+        }
+        showSampleTable();
+        tableSetUp = true;
+      },
+      exit() {
+        tableSetUp = false;
+        hideSampleTable();
+        if (tableClickHandler) { window.removeEventListener('click', tableClickHandler, true); tableClickHandler = null; }
+        if (QH.setTableSeatVisible) QH.setTableSeatVisible(false);
+      }
     }
   };
 
-  const MODE_ORDER = ['walk','hero','gallery','carousel','macro','compare','room'];
+  // Park the camera behind + above the seated figure, looking past it at the boards.
+  function parkTableCamera() {
+    const cam = (QH.tableViewCamera && QH.tableViewCamera()) ||
+      { pos: new THREE.Vector3(0.55, 1.85, backZ + 3.2), target: new THREE.Vector3(0, boardCY, backZ) };
+    park(cam.pos.clone(), cam.target.clone(), 52);
+  }
+  // Raycast the board faces from the CURRENT (table) camera and return the hit board index,
+  // or -1. Mirrors showroom.onCanvasClick's raycast but works while the camera is locked.
+  let _tableRay = null, _tableMouse = null;
+  function pickBoardAt(clientX, clientY) {
+    if (!_tableRay) { _tableRay = new THREE.Raycaster(); _tableMouse = new THREE.Vector2(); }
+    _tableMouse.x = (clientX / window.innerWidth) * 2 - 1;
+    _tableMouse.y = -(clientY / window.innerHeight) * 2 + 1;
+    _tableRay.setFromCamera(_tableMouse, QH.camera);
+    const faces = [];
+    QH.wingBoards.forEach(p => { const ud = p.userData; if (ud.faceL) faces.push(ud.faceL); if (ud.faceR) faces.push(ud.faceR); });
+    const hits = _tableRay.intersectObjects(faces, false);
+    if (hits.length && hits[0].object.userData.parentPivot) {
+      return hits[0].object.userData.parentPivot.userData.index;
+    }
+    return -1;
+  }
+
+  const MODE_ORDER = ['walk','hero','gallery','carousel','macro','compare','room','table'];
 
   // Helper to drive the showroom's open-board angle (the Wing° slider value).
   function setWingDeg(deg) {
@@ -271,6 +359,257 @@ function boot() {
   function setBanner(txt) { const el = document.getElementById('vm-banner'); if (el) { el.textContent = txt; el.style.display = 'block'; } }
   function clearBanner() { const el = document.getElementById('vm-banner'); if (el) el.style.display = 'none'; }
 
+  // ===========================================================================
+  // PHASE 3 — "At the Table" CSS-3D SAMPLE OVERLAY.
+  // Ported from public/proto/sample-table.html (the proven CSS-3D interaction). A
+  // transparent overlay anchored over the lower-screen "table" plane: a "+ Add sample"
+  // affordance drops the currently-centred product's swatch as a small card lying flat
+  // in perspective; cards can be dragged to rearrange and removed with ×. The basket
+  // persists to localStorage('qh_sample_basket'). This coexists with the 3D scene (the
+  // seated figure + wood table live in Three.js; the draggable swatch cards are this
+  // lightweight CSS layer, exactly the low-risk path the spec preferred).
+  // ===========================================================================
+  const SAMPLE_COS = Math.cos(54 * Math.PI / 180);   // matches the proto table rotateX
+  let _sampleSlotIndex = 0;
+  let _samplePlaced = [];   // [{el, sku}]
+
+  function injectSampleTableStyles() {
+    if (document.getElementById('vm-sample-style')) return;
+    const s = document.createElement('style');
+    s.id = 'vm-sample-style';
+    s.textContent = [
+      '#vm-sample-stage{ position:fixed; inset:0; z-index:40; pointer-events:none;',
+        'perspective:1150px; perspective-origin:50% 40%; display:none; }',
+      '#vm-sample-stage.on{ display:block; }',
+      '#vm-sample-table{ position:absolute; left:50%; bottom:-6vh; width:170vw; height:78vh;',
+        'transform:translateX(-50%) rotateX(54deg); transform-style:preserve-3d; }',
+      '#vm-sample-surface{ position:absolute; inset:0; transform-style:preserve-3d; }',
+      '.vm-sample{ position:absolute; width:150px; height:150px; margin:-75px 0 0 -75px;',
+        'transform-style:preserve-3d; cursor:grab; will-change:transform,opacity; pointer-events:auto; }',
+      '.vm-sample .paper{ position:absolute; inset:0; border-radius:3px; background:#fbf7ee; padding:6px 6px 22px;',
+        'box-shadow:0 1px 0 rgba(255,255,255,.7) inset, 0 24px 30px -12px rgba(20,14,6,.55), 0 6px 12px -6px rgba(20,14,6,.5);',
+        'border:1px solid rgba(120,96,60,.35); }',
+      '.vm-sample .swatch{ position:absolute; left:6px; right:6px; top:6px; bottom:22px; border-radius:2px;',
+        'background-size:cover; background-position:center; box-shadow:inset 0 0 0 1px rgba(0,0,0,.06); }',
+      '.vm-sample .plate{ position:absolute; left:6px; right:6px; bottom:5px; height:14px; display:flex;',
+        'align-items:center; justify-content:space-between; gap:5px; font-family:"Cormorant Garamond",Georgia,serif; }',
+      '.vm-sample .pname{ font-size:10px; line-height:1; color:#2b2114; letter-spacing:.2px; white-space:nowrap;',
+        'overflow:hidden; text-overflow:ellipsis; max-width:74%; }',
+      '.vm-sample .pcol{ font-size:8px; letter-spacing:1px; text-transform:uppercase; color:#9a7a52; white-space:nowrap; }',
+      '.vm-sample .contact{ position:absolute; left:6%; right:6%; top:14%; bottom:-6%; border-radius:50%;',
+        'background:rgba(20,12,4,.34); filter:blur(10px); transform:translateZ(-2px); z-index:-1; }',
+      '.vm-sample .rm{ position:absolute; top:-12px; right:-12px; width:28px; height:28px; border-radius:50%;',
+        'border:1.5px solid #fff; background:#1c1c22; color:#fff; font:700 15px/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); }',
+      '.vm-sample:hover .rm{ display:flex; }',
+      '.vm-sample.lifted{ cursor:grabbing; }',
+      // "+ Add sample" affordance + running count, bottom-LEFT (clear of the centred
+      // #window-bar sort/density controls at bottom:54px).
+      '#vm-sample-add{ position:fixed; left:22px; bottom:22px; z-index:42;',
+        'display:none; align-items:center; gap:12px; }',
+      '#vm-sample-add.on{ display:flex; }',
+      '#vm-sample-addbtn{ min-height:44px; padding:0 22px; border-radius:11px; cursor:pointer;',
+        'border:1.5px solid #b9933f; 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; }',
+      '#vm-sample-addbtn:hover{ filter:brightness(1.05); transform:translateY(-1px); }',
+      '#vm-sample-addbtn:active{ transform:translateY(1px); }',
+      '#vm-sample-count{ font:600 12px/1 -apple-system,Segoe UI,sans-serif; letter-spacing:1.5px;',
+        'text-transform:uppercase; color:#cbb98e; background:rgba(16,13,9,.86); border:1px solid rgba(201,169,110,.4);',
+        'padding:10px 14px; border-radius:11px; backdrop-filter:blur(8px); }'
+    ].join('\n');
+    document.head.appendChild(s);
+  }
+
+  function buildSampleTableDom() {
+    if (document.getElementById('vm-sample-stage')) return;
+    injectSampleTableStyles();
+    const stage = document.createElement('div');
+    stage.id = 'vm-sample-stage';
+    stage.innerHTML = '<div id="vm-sample-table"><div id="vm-sample-surface"></div></div>';
+    document.body.appendChild(stage);
+
+    const add = document.createElement('div');
+    add.id = 'vm-sample-add';
+    add.innerHTML = '<button id="vm-sample-addbtn">+ Add sample to table</button>' +
+                    '<span id="vm-sample-count">0 samples</span>';
+    document.body.appendChild(add);
+
+    document.getElementById('vm-sample-addbtn').addEventListener('click', () => {
+      const p = currentCenteredProduct();
+      if (!p) { flashAddHint('Click a board to centre a design first'); return; }
+      addSampleToTable(p);
+    });
+    // Restore any previously-basketed skus as a count hint (cards aren't re-hydrated —
+    // the persisted basket is a request list, not a scene snapshot).
+    updateSampleCount();
+  }
+
+  function flashAddHint(msg) {
+    const c = document.getElementById('vm-sample-count');
+    if (!c) return;
+    c.textContent = msg;
+    clearTimeout(flashAddHint._t);
+    flashAddHint._t = setTimeout(() => { updateSampleCount(); }, 1800);
+  }
+
+  function currentCenteredProduct() {
+    const f = QH.focusedWing;
+    if (f && f.userData && f.userData.product) return f.userData.product;
+    return null;
+  }
+
+  function imgUrl(u) {
+    if (!u) return '';
+    if (u.charAt(0) === '/') return u;
+    return '/api/proxy/image?url=' + encodeURIComponent(u);
+  }
+  function escHtml(s) {
+    return String(s == null ? '' : s).replace(/[&<>"']/g, m =>
+      ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
+  }
+  function sampleName(p) { return p.pattern_name || p.name || p.title || p.sku || 'Sample'; }
+  function sampleColor(p) { return p.color || p.color_name || p.collection || p.vendor || ''; }
+
+  function tableSize() {
+    const t = document.getElementById('vm-sample-table');
+    return t ? { w: t.clientWidth, h: t.clientHeight } : { w: window.innerWidth, h: window.innerHeight };
+  }
+  // Golden-angle spiral spread biased to the visible near band of the table (from proto).
+  function nextSampleSlot() {
+    const s = tableSize();
+    const cx = s.w * 0.5, cy = s.h * 0.42;
+    const i = _sampleSlotIndex++;
+    const ang = i * 2.399963;                       // golden angle
+    let 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);
+    const jx = ((Math.sin(i * 12.9898) * 43758.5453) % 1) * 70 - 35;
+    const jy = ((Math.sin(i * 78.233) * 12543.1234) % 1) * 40 - 20;
+    const x = cx + Math.cos(ang) * rad * 1.25 + jx;
+    const y = cy + Math.sin(ang) * rad * 0.55 + jy;
+    const rot = (((Math.sin(i * 3.1) * 1000)) % 1) * 36 - 18;
+    return { x, y, rot };
+  }
+
+  function persistBasket() {
+    try {
+      const skus = _samplePlaced.map(r => r.sku).filter(Boolean);
+      localStorage.setItem('qh_sample_basket', JSON.stringify(skus));
+    } catch (e) {}
+  }
+
+  function updateSampleCount() {
+    const el = document.getElementById('vm-sample-count');
+    if (!el) return;
+    const n = _samplePlaced.length;
+    el.textContent = n + (n === 1 ? ' sample' : ' samples');
+  }
+
+  function addSampleToTable(p) {
+    const surface = document.getElementById('vm-sample-surface');
+    if (!surface) return;
+    // De-dupe: same SKU already on the table → don't stack, just nudge the count.
+    if (p.sku && _samplePlaced.some(r => r.sku === p.sku)) { flashAddHint('Already on the table'); return; }
+
+    const slot = nextSampleSlot();
+    const el = document.createElement('div');
+    el.className = 'vm-sample';
+    el.dataset.sku = p.sku || '';
+    el.innerHTML =
+      '<div class="contact"></div>' +
+      '<div class="paper">' +
+        '<div class="swatch" style="background-image:url(\'' + imgUrl(p.tile || p.image) + '\')"></div>' +
+        '<div class="plate">' +
+          '<span class="pname">' + escHtml(sampleName(p)) + '</span>' +
+          '<span class="pcol">' + escHtml(sampleColor(p)) + '</span>' +
+        '</div>' +
+      '</div>' +
+      '<button class="rm" title="Remove">×</button>';
+
+    const land = 'translate3d(' + slot.x + 'px,' + slot.y + 'px,0) rotateZ(' + slot.rot.toFixed(1) + 'deg)';
+    const lift = 'translate3d(' + slot.x + 'px,' + (slot.y - 60) + 'px,140px) rotateZ(' + (slot.rot - 20).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
+    void el.offsetWidth;
+    el.style.transition = 'transform .6s cubic-bezier(.22,1.08,.36,1), opacity .3s ease-out';
+    el.style.opacity = '1';
+    el.style.transform = land;
+    el.dataset.x = slot.x; el.dataset.y = slot.y; el.dataset.rot = slot.rot;
+
+    el.querySelector('.rm').addEventListener('click', (ev) => {
+      ev.stopPropagation();
+      el.style.transition = 'transform .38s ease-in, opacity .38s ease-in';
+      el.style.transform = 'translate3d(' + slot.x + 'px,' + (slot.y - 80) + 'px,170px) scale(.9)';
+      el.style.opacity = '0';
+      setTimeout(() => { el.remove(); }, 380);
+      _samplePlaced = _samplePlaced.filter(r => r.el !== el);
+      updateSampleCount();
+      persistBasket();
+    });
+
+    enableSampleDrag(el);
+    _samplePlaced.push({ el, sku: p.sku });
+    updateSampleCount();
+    persistBasket();
+  }
+
+  // Perspective-correct drag (from proto): screen-Y delta un-foreshortened by cos(54°).
+  function enableSampleDrag(el) {
+    const surface = document.getElementById('vm-sample-surface');
+    let sx, sy, ox, oy, rot, active = false;
+    function down(e) {
+      if (e.target.classList.contains('rm')) return;
+      active = true; el.classList.add('lifted');
+      const 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;
+      if (surface) surface.appendChild(el);   // bring to front
+      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;
+      const pt = e.touches ? e.touches[0] : e;
+      const nx = ox + (pt.clientX - sx);
+      const ny = oy + (pt.clientY - sy) / SAMPLE_COS;
+      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');
+      const 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)';
+      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 });
+  }
+
+  function showSampleTable() {
+    buildSampleTableDom();
+    const stage = document.getElementById('vm-sample-stage');
+    const add = document.getElementById('vm-sample-add');
+    if (stage) stage.classList.add('on');
+    if (add) add.classList.add('on');
+  }
+  function hideSampleTable() {
+    const stage = document.getElementById('vm-sample-stage');
+    const add = document.getElementById('vm-sample-add');
+    if (stage) stage.classList.remove('on');
+    if (add) add.classList.remove('on');
+  }
+
   // ===========================================================================
   // THEMES — lighting moods. Each tweaks exposure + light intensities + colour
   // temperature + fog + a CSS vignette class, WITHOUT crushing swatch legibility.
@@ -514,7 +853,7 @@ function boot() {
   // (Peruse) so it moves on its own until the user takes WASD control. A returning
   // user's saved board-presentation mode still restores; free-cam modes never do.
   const savedMode = localStorage.getItem('qh_viewmode');
-  const safeBootModes = { walk: 1, hero: 1, macro: 1, room: 1 };
+  const safeBootModes = { walk: 1, hero: 1, macro: 1, room: 1, table: 1 };
   const bootMode = (savedMode && MODES[savedMode] && safeBootModes[savedMode]) ? savedMode : 'walk';
   setMode(bootMode, true);
   if (bootMode === 'walk') {
@@ -532,6 +871,10 @@ function boot() {
     get current() { return currentMode; },
     get theme_() { return currentTheme; },
     get room_() { return currentRoom; },
+    // PHASE 3 "At the Table" hooks — verification/automation surface.
+    addSample: (p) => addSampleToTable(p || currentCenteredProduct()),
+    get sampleCount() { return _samplePlaced.length; },
+    get samplePlaced() { return _samplePlaced.slice(); },
     MODES, THEMES, MODE_ORDER, THEME_ORDER
   };
 

← 5388cf1 showroom P2: 5 room types (bedroom/office/living/lobby/galle  ·  back to Quadrille Showroom  ·  showroom P3: At-the-Table view — seated avatar + click-patte 8dfe072 →