[object Object]

← back to Quadrille Showroom

auto-save: 2026-07-02T08:46:35 (30 files) — data/thumb-cache/099160b17f710e570c5aa9ac.jpg data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg data/thumb-cache/44fbcf466a13d44c920bf876.jpg

aec52fe2b669415f639267da85789cafbb5b804d · 2026-07-02 08:46:38 -0700 · Steve Abrams

Files touched

Diff

commit aec52fe2b669415f639267da85789cafbb5b804d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 08:46:38 2026 -0700

    auto-save: 2026-07-02T08:46:35 (30 files) — data/thumb-cache/099160b17f710e570c5aa9ac.jpg data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg data/thumb-cache/44fbcf466a13d44c920bf876.jpg
---
 data/thumb-cache/099160b17f710e570c5aa9ac.jpg | Bin 7955 -> 0 bytes
 data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg | Bin 5346 -> 0 bytes
 data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg | Bin 5457 -> 0 bytes
 data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg | Bin 6804 -> 0 bytes
 data/thumb-cache/44fbcf466a13d44c920bf876.jpg | Bin 5683 -> 0 bytes
 data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg | Bin 5606 -> 0 bytes
 data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg | Bin 7271 -> 0 bytes
 data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg | Bin 8106 -> 0 bytes
 data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg | Bin 6036 -> 0 bytes
 data/thumb-cache/7ee3f79366d62966a5295da4.jpg | Bin 7253 -> 0 bytes
 data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg | Bin 7125 -> 0 bytes
 data/thumb-cache/8e080de9798485fa3badb5f6.jpg | Bin 6499 -> 0 bytes
 data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg | Bin 5544 -> 0 bytes
 data/thumb-cache/946174e77373830644fdcce4.jpg | Bin 5357 -> 0 bytes
 data/thumb-cache/962c93a220c69af4f237020e.jpg | Bin 6684 -> 0 bytes
 data/thumb-cache/a575f374084aef6f484e37e0.jpg | Bin 7643 -> 0 bytes
 data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg | Bin 7221 -> 0 bytes
 data/thumb-cache/b77ee76df67378944ef41b72.jpg | Bin 6854 -> 0 bytes
 data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg | Bin 6446 -> 0 bytes
 data/thumb-cache/c8f0693350b1df2a1926bec0.jpg | Bin 7369 -> 0 bytes
 data/thumb-cache/c987c8b51e74d135d6c0670b.jpg | Bin 5939 -> 0 bytes
 data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg | Bin 6048 -> 0 bytes
 data/thumb-cache/d9462876e1e7c57a8d51e235.jpg | Bin 7492 -> 0 bytes
 data/thumb-cache/ded5027936e45e8f614fe792.jpg | Bin 7760 -> 0 bytes
 data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg | Bin 6359 -> 0 bytes
 data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg | Bin 4056 -> 0 bytes
 public/js/furniture-styles.js                 | 116 ++++++++
 public/js/showroom.js                         | 392 ++++++++++++++++++++------
 public/showroom.html                          |   2 +
 server.js                                     |  38 ++-
 30 files changed, 454 insertions(+), 94 deletions(-)

diff --git a/data/thumb-cache/099160b17f710e570c5aa9ac.jpg b/data/thumb-cache/099160b17f710e570c5aa9ac.jpg
deleted file mode 100644
index c6a5012..0000000
Binary files a/data/thumb-cache/099160b17f710e570c5aa9ac.jpg and /dev/null differ
diff --git a/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg b/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg
deleted file mode 100644
index 4eeb7e9..0000000
Binary files a/data/thumb-cache/0b9c68a67796dd1ccce90a5a.jpg and /dev/null differ
diff --git a/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg b/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg
deleted file mode 100644
index 77052ce..0000000
Binary files a/data/thumb-cache/2ba7e03368ff27763d2ef6a1.jpg and /dev/null differ
diff --git a/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg b/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg
deleted file mode 100644
index dedc65b..0000000
Binary files a/data/thumb-cache/3f441bb0f30a6e1d2e049c99.jpg and /dev/null differ
diff --git a/data/thumb-cache/44fbcf466a13d44c920bf876.jpg b/data/thumb-cache/44fbcf466a13d44c920bf876.jpg
deleted file mode 100644
index b4b347f..0000000
Binary files a/data/thumb-cache/44fbcf466a13d44c920bf876.jpg and /dev/null differ
diff --git a/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg b/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg
deleted file mode 100644
index 291b046..0000000
Binary files a/data/thumb-cache/4a03f0dff059f9cb74a8f68b.jpg and /dev/null differ
diff --git a/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg b/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg
deleted file mode 100644
index f593e54..0000000
Binary files a/data/thumb-cache/6ac3f77fa6efe0dbca83526e.jpg and /dev/null differ
diff --git a/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg b/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg
deleted file mode 100644
index 665bbd9..0000000
Binary files a/data/thumb-cache/6de50aa4eecf76ec8b46608f.jpg and /dev/null differ
diff --git a/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg b/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg
deleted file mode 100644
index f6243d7..0000000
Binary files a/data/thumb-cache/79e9655d90d2707d0f3cc98c.jpg and /dev/null differ
diff --git a/data/thumb-cache/7ee3f79366d62966a5295da4.jpg b/data/thumb-cache/7ee3f79366d62966a5295da4.jpg
deleted file mode 100644
index 5dcd4d0..0000000
Binary files a/data/thumb-cache/7ee3f79366d62966a5295da4.jpg and /dev/null differ
diff --git a/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg b/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg
deleted file mode 100644
index a7071d2..0000000
Binary files a/data/thumb-cache/83bf2a3012a67ceae0264aa9.jpg and /dev/null differ
diff --git a/data/thumb-cache/8e080de9798485fa3badb5f6.jpg b/data/thumb-cache/8e080de9798485fa3badb5f6.jpg
deleted file mode 100644
index 07e170f..0000000
Binary files a/data/thumb-cache/8e080de9798485fa3badb5f6.jpg and /dev/null differ
diff --git a/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg b/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg
deleted file mode 100644
index 796cf60..0000000
Binary files a/data/thumb-cache/90b97a1dae394bf1bd6d4902.jpg and /dev/null differ
diff --git a/data/thumb-cache/946174e77373830644fdcce4.jpg b/data/thumb-cache/946174e77373830644fdcce4.jpg
deleted file mode 100644
index 2287228..0000000
Binary files a/data/thumb-cache/946174e77373830644fdcce4.jpg and /dev/null differ
diff --git a/data/thumb-cache/962c93a220c69af4f237020e.jpg b/data/thumb-cache/962c93a220c69af4f237020e.jpg
deleted file mode 100644
index 5914d57..0000000
Binary files a/data/thumb-cache/962c93a220c69af4f237020e.jpg and /dev/null differ
diff --git a/data/thumb-cache/a575f374084aef6f484e37e0.jpg b/data/thumb-cache/a575f374084aef6f484e37e0.jpg
deleted file mode 100644
index c22c066..0000000
Binary files a/data/thumb-cache/a575f374084aef6f484e37e0.jpg and /dev/null differ
diff --git a/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg b/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg
deleted file mode 100644
index 139cdf2..0000000
Binary files a/data/thumb-cache/ab3c0d5c3b90d148bb357ac9.jpg and /dev/null differ
diff --git a/data/thumb-cache/b77ee76df67378944ef41b72.jpg b/data/thumb-cache/b77ee76df67378944ef41b72.jpg
deleted file mode 100644
index a8267c7..0000000
Binary files a/data/thumb-cache/b77ee76df67378944ef41b72.jpg and /dev/null differ
diff --git a/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg b/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg
deleted file mode 100644
index b2b0d9d..0000000
Binary files a/data/thumb-cache/bf1537e14a5bbcd16b6927b0.jpg and /dev/null differ
diff --git a/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg b/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg
deleted file mode 100644
index 0a0b6f2..0000000
Binary files a/data/thumb-cache/c8f0693350b1df2a1926bec0.jpg and /dev/null differ
diff --git a/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg b/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg
deleted file mode 100644
index 5b299fb..0000000
Binary files a/data/thumb-cache/c987c8b51e74d135d6c0670b.jpg and /dev/null differ
diff --git a/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg b/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg
deleted file mode 100644
index 6b9fa43..0000000
Binary files a/data/thumb-cache/d549f8a7ec69ed6523a12bcc.jpg and /dev/null differ
diff --git a/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg b/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg
deleted file mode 100644
index 6e4401d..0000000
Binary files a/data/thumb-cache/d9462876e1e7c57a8d51e235.jpg and /dev/null differ
diff --git a/data/thumb-cache/ded5027936e45e8f614fe792.jpg b/data/thumb-cache/ded5027936e45e8f614fe792.jpg
deleted file mode 100644
index 2352cf3..0000000
Binary files a/data/thumb-cache/ded5027936e45e8f614fe792.jpg and /dev/null differ
diff --git a/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg b/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg
deleted file mode 100644
index a4072b2..0000000
Binary files a/data/thumb-cache/e3d05b503f89a0a1ff8ed456.jpg and /dev/null differ
diff --git a/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg b/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg
deleted file mode 100644
index c759c53..0000000
Binary files a/data/thumb-cache/eb550c8e11ad1aa92c6c6c79.jpg and /dev/null differ
diff --git a/public/js/furniture-styles.js b/public/js/furniture-styles.js
new file mode 100644
index 0000000..58a6889
--- /dev/null
+++ b/public/js/furniture-styles.js
@@ -0,0 +1,116 @@
+/* ============================================================================
+ * furniture-styles.js — The Quadrille House: FURNITURE STYLE SELECTOR.
+ *
+ * A bottom-left chip-tray with the four procedural furniture styles
+ * (Contemporary / Traditional / Transitional / Brutalist). Clicking a chip calls
+ * window._qh.swapFurniture(styleKey, { includeExtras }). A "+ Decor" toggle opts
+ * the set into its optional decor extras (onload stays minimal = table + chair).
+ * The chosen style persists in localStorage (via swapFurniture) so it survives a
+ * reload; the tray reflects the active style read back from window._qh.
+ *
+ * Matches the hamburger-dock visual language (gold #c9a96e on smoked glass,
+ * Georgia eyebrow). Its own #qh-style-tray id is NOT in dock.js's stow lists, so
+ * it stays permanently visible like the guided pager.
+ * ========================================================================== */
+(function () {
+  'use strict';
+
+  var LS_EXTRAS = 'qh_furniture_extras_ui';   // remembers ONLY the toggle's UI state
+  var extrasOn = false;
+
+  function injectStyles() {
+    if (document.getElementById('qh-style-styles')) return;
+    var st = document.createElement('style');
+    st.id = 'qh-style-styles';
+    st.textContent = [
+      '#qh-style-tray{position:fixed;left:16px;bottom:16px;z-index:8800;',
+        'display:flex;flex-direction:column;gap:7px;padding:10px 11px;',
+        'background:rgba(14,14,20,0.90);border:1px solid rgba(201,169,110,0.30);border-radius:14px;',
+        'backdrop-filter:blur(12px);box-shadow:0 18px 50px -12px rgba(0,0,0,0.8);',
+        'font-family:Georgia,serif;max-width:250px;}',
+      '.qh-style-eyebrow{font-size:10px;letter-spacing:1.5px;text-transform:uppercase;',
+        'color:#8c8678;font-weight:700;padding:0 2px 1px;}',
+      '.qh-style-chips{display:flex;flex-wrap:wrap;gap:6px;}',
+      '.qh-style-chip{cursor:pointer;padding:7px 12px;border-radius:9px;',
+        'background:rgba(255,255,255,0.04);border:1px solid rgba(201,169,110,0.20);',
+        'color:#e8e2d4;font-size:12.5px;font-weight:600;letter-spacing:.2px;',
+        'transition:background .12s,border-color .12s,color .12s;white-space:nowrap;}',
+      '.qh-style-chip:hover{background:rgba(201,169,110,0.18);border-color:#c9a96e;}',
+      '.qh-style-chip.active{background:rgba(201,169,110,0.28);border-color:#c9a96e;color:#fbf4e3;}',
+      '.qh-style-decor{display:flex;align-items:center;justify-content:space-between;gap:8px;',
+        'margin-top:1px;padding:6px 10px;border-radius:9px;cursor:pointer;',
+        'background:rgba(255,255,255,0.03);border:1px solid rgba(201,169,110,0.16);',
+        'color:#cfc7b4;font-size:11.5px;font-weight:600;letter-spacing:.3px;',
+        'transition:background .12s,border-color .12s,color .12s;}',
+      '.qh-style-decor:hover{border-color:#c9a96e;color:#f0e6cf;}',
+      '.qh-style-decor.on{background:rgba(201,169,110,0.22);border-color:#c9a96e;color:#fbf4e3;}',
+      '.qh-style-decor .qh-decor-dot{width:9px;height:9px;border-radius:50%;flex:0 0 auto;',
+        'background:rgba(201,169,110,0.35);transition:background .12s;}',
+      '.qh-style-decor.on .qh-decor-dot{background:#c9a96e;}'
+    ].join('\n');
+    document.head.appendChild(st);
+  }
+
+  var tray;
+
+  function reflect() {
+    if (!tray || !window._qh) return;
+    var active = window._qh.furnitureStyle;
+    tray.querySelectorAll('.qh-style-chip').forEach(function (c) {
+      c.classList.toggle('active', c.getAttribute('data-style') === active);
+    });
+    var decor = tray.querySelector('.qh-style-decor');
+    if (decor) decor.classList.toggle('on', extrasOn);
+  }
+
+  function build() {
+    var order = (window._qh && window._qh.FURNITURE_STYLE_ORDER) || [];
+    injectStyles();
+    tray = document.createElement('div');
+    tray.id = 'qh-style-tray';
+
+    var html = '<div class="qh-style-eyebrow">Furniture Style</div><div class="qh-style-chips">';
+    order.forEach(function (key) {
+      var label = window._qh.furnitureStyleLabel ? window._qh.furnitureStyleLabel(key) : key;
+      html += '<button class="qh-style-chip" data-style="' + key + '">' + label + '</button>';
+    });
+    html += '</div>' +
+      '<div class="qh-style-decor" role="button" tabindex="0">' +
+        '<span>Add Decor</span><span class="qh-decor-dot"></span></div>';
+    tray.innerHTML = html;
+    document.body.appendChild(tray);
+
+    tray.querySelector('.qh-style-chips').addEventListener('click', function (e) {
+      var chip = e.target.closest('.qh-style-chip'); if (!chip) return;
+      window._qh.swapFurniture(chip.getAttribute('data-style'), { includeExtras: extrasOn });
+      reflect();
+    });
+
+    var decor = tray.querySelector('.qh-style-decor');
+    function toggleDecor() {
+      extrasOn = !extrasOn;
+      try { localStorage.setItem(LS_EXTRAS, extrasOn ? '1' : '0'); } catch (e) {}
+      window._qh.swapFurniture(window._qh.furnitureStyle, { includeExtras: extrasOn });
+      reflect();
+    }
+    decor.addEventListener('click', toggleDecor);
+    decor.addEventListener('keydown', function (e) {
+      if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDecor(); }
+    });
+
+    reflect();
+  }
+
+  function boot(tries) {
+    tries = tries || 0;
+    // Wait until showroom.js has booted its furniture API + built the first set.
+    if (!window._qh || !window._qh.FURNITURE_STYLE_ORDER || !window._qh.furniturePresent) {
+      if (tries > 200) return;                       // give up after ~12s
+      return setTimeout(function () { boot(tries + 1); }, 60);
+    }
+    build();
+  }
+
+  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', function () { boot(0); });
+  else boot(0);
+})();
diff --git a/public/js/showroom.js b/public/js/showroom.js
index 7e1494b..ef3b152 100644
--- a/public/js/showroom.js
+++ b/public/js/showroom.js
@@ -965,35 +965,307 @@ function enableStaticShadows() {
 // FURNITURE — simplified
 // ============================================================
 function buildFurniture() {
-  const W = CONFIG.room.width, D = CONFIG.room.depth;
+  // ONLOAD MINIMAL (collection-layout-overhaul, Option A): the scene spawns with ONLY
+  // the essentials — one style's table + chair — of the saved (or default) style. The old
+  // always-on consultation nook (round table + two bouclé chairs + olive tree + swatch
+  // cards) is gone from boot; decor pieces appear only when the visitor opts into extras
+  // via the style-selector's "+ Decor" toggle. swapFurniture() owns build + layout + the
+  // memory-safe teardown; buildFurniture just seeds the first set.
+  let saved = null;
+  try { saved = localStorage.getItem(FURNITURE_LS_KEY); } catch (e) {}
+  const key = FURNITURE_STYLES[saved] ? saved : FURNITURE_STYLE_ORDER[0];
+  swapFurniture(key, { includeExtras: false });
+}
 
-  // ============================================================
-  // CONSULTATION NOOK (PJ Dallas reference) — a round warm-wood pedestal table, TWO
-  // cream bouclé armchairs, a potted plant, swatch cards on the table. Sits in the
-  // FOREGROUND of the arc bay so the establishing shot reads: rack behind, nook in front.
-  // In the 20'×20' room the viewer is fixed at centre facing the arc (toward -z). The
-  // consultation nook is tucked to the RIGHT side, pulled FORWARD toward the wings (more
-  // negative z) and low, so it sits INSIDE the tightened ±72° yaw clamp at ~yaw +66°
-  // (atan2(1.78, 0.78) ≈ 66.3°): a spin-right + look-down reveals the sample table without
-  // walking, and the 72° clamp still reaches it. Relocated proportionally for the 20'×20'
-  // room (Chunk A′): old (1.25,-0.55) × 6.10/4.27 ≈ (1.78,-0.78) — same yaw angle, farther out.
-  // ============================================================
-  // Pulled a touch left + forward (was 1.78,-0.78) so the table + bouclé chairs read as
-  // part of the furnished 20'×20' scene — a gentle right-turn from the spawn lands on them,
-  // and they stay clear of the centred open book (x=0, z≈-2.4). Steve 2026-06-29.
-  const nookX = 1.55, nookZ = -1.1;
-  buildConsultationNook(nookX, nookZ);
+// ============================================================
+// FURNITURE STYLE REGISTRY (Option A — DTD-committed) — procedural, swappable sets.
+// ------------------------------------------------------------
+// One disposable furniture root group holds the ACTIVE style's table + chair (the
+// "essentials") plus optional decor extras. FURNITURE_STYLES keys each style to build
+// fns that emit parametric THREE meshes; swapFurniture() tears down the old group with
+// full memory hygiene (geometry.dispose + material.dispose across material arrays +
+// dispose of any UNIQUE textures — shared canvas textures are tagged __shared and left
+// alone), builds the new group, and runs applyLayout() so EVERY style obeys the same
+// grounding + room-bounds rules. The four styles read as genuinely distinct IN FORM
+// (leg geometry + slab mass), not just colour.
+// ============================================================
+let furnitureRoot = null;            // the ONE active furniture group (also window._qhNookGroup)
+let furnitureStyleKey = null;        // currently active style key
+let furnitureExtras = false;         // whether the active set includes decor extras
+const FURNITURE_STYLE_ORDER = ['contemporary', 'traditional', 'transitional', 'brutalist'];
+const FURNITURE_LS_KEY = 'qh_furniture_style';
+// Where the set sits: right + forward of the centred viewer, inside the ±72° yaw clamp
+// (yaw to anchor ≈ atan2(1.5, 1.7) ≈ 41°) — a gentle right-turn + look-down lands on it.
+const FURNITURE_ANCHOR = { x: 1.5, z: -1.0 };
+const FURNITURE_MARGIN = 0.35;       // keep-clear from every wall (matches wing.wallMargin feel)
+
+function fmat(color, rough, metal) {
+  return new THREE.MeshStandardMaterial({ color, roughness: rough == null ? 0.6 : rough, metalness: metal || 0.0, envMapIntensity: 0.3 });
+}
+
+// Local contact shadow parented to the furniture group (LOCAL coords) so teardown
+// disposes it with the set. The map is the SHARED contact texture (__shared) — never disposed.
+function furnitureShadow(parent, lx, lz, w, d, opacity) {
+  const mat = new THREE.MeshBasicMaterial({
+    map: contactShadowTexture(), transparent: true, opacity: opacity == null ? 0.6 : opacity,
+    depthWrite: false, polygonOffset: true, polygonOffsetFactor: -1, polygonOffsetUnits: -1
+  });
+  const m = new THREE.Mesh(new THREE.PlaneGeometry(w, d), mat);
+  m.rotation.x = -Math.PI / 2; m.position.set(lx, 0.006, lz); m.renderOrder = 1;
+  parent.add(m);
+  return m;
+}
 
-  // (Removed: boxy TV + bezel + slideshow screen — the wallcoverings are the only
-  // loud thing in the room per the gallery art-direction.) The TV-slideshow helpers
-  // null-guard on window._tvScreen, so leaving it unset makes them no-op.
+// A lathe-turned leg (traditional) — stacked varying-radius cylinders from floor to the
+// table underside at (x,z). `h` = table top-surface height; legs reach ~h-0.04.
+function buildTurnedLeg(parent, x, z, mat, h) {
+  const leg = new THREE.Group(); leg.position.set(x, 0, z);
+  const segs = [[0.048, 0.10], [0.028, 0.06], [0.052, 0.10], [0.030, h - 0.36], [0.046, 0.06]];
+  let y = 0;
+  segs.forEach(([r, sh]) => {
+    const c = new THREE.Mesh(new THREE.CylinderGeometry(r, r, sh, 14), mat);
+    c.position.y = y + sh / 2; c.castShadow = true; leg.add(c); y += sh;
+  });
+  parent.add(leg);
+}
+
+// A tapered square post leg (transitional) — 4-sided cylinder, narrower at the foot.
+function buildTaperedLeg(parent, x, z, mat, h) {
+  const leg = new THREE.Mesh(new THREE.CylinderGeometry(0.028, 0.046, h - 0.04, 4), mat);
+  leg.position.set(x, (h - 0.04) / 2, z); leg.rotation.y = Math.PI / 4; leg.castShadow = true;
+  parent.add(leg);
+}
+
+const FURNITURE_STYLES = {
+  // CONTEMPORARY — clean, low, rectilinear; pale oak top on minimal charcoal blade legs.
+  contemporary: {
+    label: 'Contemporary',
+    buildTable(g) {
+      const oak = fmat(0xc2ad86, 0.5, 0.0), blade = fmat(0x35352f, 0.7, 0.05);
+      const top = new THREE.Mesh(new THREE.BoxGeometry(1.12, 0.05, 0.70), oak);
+      top.position.set(0, 0.72, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
+      [[-0.50, -0.30], [0.50, -0.30], [-0.50, 0.30], [0.50, 0.30]].forEach(([lx, lz]) => {
+        const leg = new THREE.Mesh(new THREE.BoxGeometry(0.045, 0.70, 0.045), blade);
+        leg.position.set(lx, 0.35, lz); leg.castShadow = true; g.add(leg);
+      });
+      furnitureShadow(g, 0, 0, 1.35, 0.95, 0.6);
+    },
+    buildChair(g) {
+      const seatMat = fmat(0xcfcabf, 0.9, 0.0), blade = fmat(0x35352f, 0.7, 0.05);
+      const c = new THREE.Group(); c.position.set(0, 0, 0.68);   // faces -z (toward table/boards)
+      const seat = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.08, 0.46), seatMat);
+      seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+      const back = new THREE.Mesh(new THREE.BoxGeometry(0.48, 0.40, 0.06), seatMat);
+      back.position.set(0, 0.64, -0.20); back.castShadow = true; c.add(back);   // low back, behind seat
+      [[-0.20, -0.20], [0.20, -0.20], [-0.20, 0.20], [0.20, 0.20]].forEach(([lx, lz]) => {
+        const leg = new THREE.Mesh(new THREE.BoxGeometry(0.04, 0.40, 0.04), blade);
+        leg.position.set(lx, 0.20, lz); leg.castShadow = true; c.add(leg);
+      });
+      g.add(c);
+      furnitureShadow(g, 0, 0.68, 0.65, 0.65, 0.55);
+    },
+    buildExtras(g) {
+      // A single slim matte vase on the table + a low ceramic dish — minimalist.
+      const vaseMat = fmat(0x8b8f8a, 0.85, 0.0);
+      const vase = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.26, 20), vaseMat);
+      vase.position.set(-0.28, 0.745 + 0.13, 0.06); vase.castShadow = true; g.add(vase);
+      const dish = new THREE.Mesh(new THREE.CylinderGeometry(0.11, 0.09, 0.03, 24), fmat(0xd8d3c8, 0.8, 0.0));
+      dish.position.set(0.22, 0.745 + 0.015, -0.02); dish.castShadow = true; g.add(dish);
+    }
+  },
 
-  // ONE restrained olive in a honed-stone planter — quiet, expensive, beside the nook
-  // (like the PJ photo's potted plant tucked by the arc). Off to the left of the table.
-  buildOliveTree(nookX - 0.7, 0, nookZ - 0.2);
-  addContactShadow(nookX - 0.7, nookZ - 0.2, 0.6, 0.6, 0.85);
+  // TRADITIONAL — warm walnut, lathe-turned legs, curved-back upholstered chair.
+  traditional: {
+    label: 'Traditional',
+    buildTable(g) {
+      const walnut = fmat(0x5a3a24, 0.45, 0.0);
+      const top = new THREE.Mesh(new THREE.BoxGeometry(1.06, 0.06, 0.70), walnut);
+      top.position.set(0, 0.74, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
+      // a subtle apron under the top for weight
+      const apron = new THREE.Mesh(new THREE.BoxGeometry(0.96, 0.07, 0.60), fmat(0x4d3120, 0.5, 0.0));
+      apron.position.set(0, 0.685, 0); apron.castShadow = true; g.add(apron);
+      [[-0.44, -0.28], [0.44, -0.28], [-0.44, 0.28], [0.44, 0.28]].forEach(([lx, lz]) =>
+        buildTurnedLeg(g, lx, lz, walnut, 0.72));
+      furnitureShadow(g, 0, 0, 1.30, 0.95, 0.6);
+    },
+    buildChair(g) {
+      const walnut = fmat(0x5a3a24, 0.5, 0.0), damask = fmat(0xe6d8bd, 0.85, 0.0);
+      const c = new THREE.Group(); c.position.set(0, 0, 0.70);
+      const seat = new THREE.Mesh(new THREE.BoxGeometry(0.50, 0.11, 0.48), damask);
+      seat.position.set(0, 0.46, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+      // Curved, upholstered back — a partial cylinder shell wrapping the sitter's back.
+      const back = new THREE.Mesh(
+        new THREE.CylinderGeometry(0.30, 0.30, 0.52, 20, 1, true, Math.PI * 0.72, Math.PI * 0.56), damask);
+      back.position.set(0, 0.76, -0.14); back.castShadow = true; c.add(back);
+      // crown rail + turned back posts for the traditional silhouette
+      const crown = new THREE.Mesh(new THREE.CylinderGeometry(0.03, 0.03, 0.44, 12), walnut);
+      crown.rotation.z = Math.PI / 2; crown.position.set(0, 1.02, -0.14); crown.castShadow = true; c.add(crown);
+      [-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, -0.20, walnut, 0.46));   // front-facing turned legs read fine
+      [-1, 1].forEach(s => buildTurnedLeg(c, s * 0.20, 0.20, walnut, 0.46));
+      g.add(c);
+      furnitureShadow(g, 0, 0.70, 0.70, 0.70, 0.55);
+    },
+    buildExtras(g) {
+      // A short stack of swatch books + a slim brass candlestick.
+      const bookTones = [0x7a5a3a, 0x8a4a3a, 0x5a5a44];
+      bookTones.forEach((t, k) => {
+        const bk = new THREE.Mesh(new THREE.BoxGeometry(0.30, 0.035, 0.22), fmat(t, 0.8, 0.0));
+        bk.position.set(-0.26, 0.77 + 0.018 + k * 0.036, 0.05); bk.rotation.y = (k - 1) * 0.06;
+        bk.castShadow = true; g.add(bk);
+      });
+      const brass = fmat(0xb8975a, 0.35, 0.6);
+      const stick = new THREE.Mesh(new THREE.CylinderGeometry(0.018, 0.03, 0.20, 14), brass);
+      stick.position.set(0.28, 0.77 + 0.10, -0.02); stick.castShadow = true; g.add(stick);
+      const cup = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.02, 0.03, 14), brass);
+      cup.position.set(0.28, 0.77 + 0.205, -0.02); g.add(cup);
+    }
+  },
 
-  // (Removed: framed abstract "art" on the right wall — the sample boards are the art.)
+  // TRANSITIONAL — a blend: simple frame + softened edges, mid-tone wood, greige seat.
+  transitional: {
+    label: 'Transitional',
+    buildTable(g) {
+      const wood = fmat(0x9a7a52, 0.5, 0.0);
+      const top = new THREE.Mesh(new THREE.BoxGeometry(1.06, 0.055, 0.70), wood);
+      top.position.set(0, 0.73, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
+      // softened edge: a thin inset trim just under the top reads as an eased/beveled edge
+      const trim = new THREE.Mesh(new THREE.BoxGeometry(1.00, 0.03, 0.64), fmat(0x876a46, 0.55, 0.0));
+      trim.position.set(0, 0.695, 0); trim.castShadow = true; g.add(trim);
+      [[-0.45, -0.29], [0.45, -0.29], [-0.45, 0.29], [0.45, 0.29]].forEach(([lx, lz]) =>
+        buildTaperedLeg(g, lx, lz, wood, 0.70));
+      furnitureShadow(g, 0, 0, 1.30, 0.95, 0.6);
+    },
+    buildChair(g) {
+      const wood = fmat(0x9a7a52, 0.55, 0.0), greige = fmat(0xd8cfc0, 0.88, 0.0);
+      const c = new THREE.Group(); c.position.set(0, 0, 0.69);
+      const seat = new THREE.Mesh(new THREE.BoxGeometry(0.49, 0.10, 0.47), greige);
+      seat.position.set(0, 0.45, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+      const back = new THREE.Mesh(new THREE.BoxGeometry(0.49, 0.42, 0.06), greige);
+      back.position.set(0, 0.68, -0.20); back.castShadow = true; c.add(back);
+      // softened top rail — a rounded cap along the backrest top
+      const cap = new THREE.Mesh(new THREE.CylinderGeometry(0.035, 0.035, 0.49, 14), wood);
+      cap.rotation.z = Math.PI / 2; cap.position.set(0, 0.89, -0.20); cap.castShadow = true; c.add(cap);
+      [[-0.20, -0.20], [0.20, -0.20], [-0.20, 0.20], [0.20, 0.20]].forEach(([lx, lz]) =>
+        buildTaperedLeg(c, lx, lz, wood, 0.45));
+      g.add(c);
+      furnitureShadow(g, 0, 0.69, 0.68, 0.68, 0.55);
+    },
+    buildExtras(g) {
+      // A fan of swatch cards + a compact potted plant — a working-consultation blend.
+      const cardTones = [0xd8c7a8, 0xc6d2c0, 0xcdbcae];
+      cardTones.forEach((cc, k) => {
+        const card = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.22), fmat(cc, 0.85, 0.0));
+        card.position.set(-0.24 + k * 0.11, 0.76 + 0.004, 0.04); card.rotation.y = (k - 1) * 0.30;
+        card.castShadow = true; g.add(card);
+      });
+      const pot = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.055, 0.10, 18), fmat(0xcac3b6, 0.85, 0.0));
+      pot.position.set(0.30, 0.76 + 0.05, -0.02); pot.castShadow = true; g.add(pot);
+      const foliage = new THREE.Mesh(new THREE.IcosahedronGeometry(0.10, 0), fmat(0x6f7a5a, 0.92, 0.0));
+      foliage.position.set(0.30, 0.76 + 0.17, -0.02); foliage.castShadow = true; g.add(foliage);
+    }
+  },
+
+  // BRUTALIST — heavy blocky slab on a monolithic plinth; solid concrete-look mass.
+  brutalist: {
+    label: 'Brutalist',
+    buildTable(g) {
+      const slab = fmat(0x8f8d88, 0.95, 0.0), mass = fmat(0x807e79, 0.96, 0.0);
+      const top = new THREE.Mesh(new THREE.BoxGeometry(1.18, 0.14, 0.78), slab);
+      top.position.set(0, 0.70, 0); top.castShadow = true; top.receiveShadow = true; g.add(top);
+      // one monolithic plinth base — a single solid block, not legs
+      const base = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.63, 0.52), mass);
+      base.position.set(0, 0.315, 0); base.castShadow = true; base.receiveShadow = true; g.add(base);
+      furnitureShadow(g, 0, 0, 1.35, 1.0, 0.68);
+    },
+    buildChair(g) {
+      const seatMat = fmat(0x7f7d78, 0.95, 0.0), backMat = fmat(0x6f6d68, 0.96, 0.0);
+      const c = new THREE.Group(); c.position.set(0, 0, 0.72);
+      // blocky monolithic seat plinth straight to the floor (no legs)
+      const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.44, 0.52), seatMat);
+      seat.position.set(0, 0.22, 0); seat.castShadow = true; seat.receiveShadow = true; c.add(seat);
+      // a thick angular slab back
+      const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.50, 0.14), backMat);
+      back.position.set(0, 0.67, -0.19); back.castShadow = true; c.add(back);
+      g.add(c);
+      furnitureShadow(g, 0, 0.72, 0.70, 0.70, 0.62);
+    },
+    buildExtras(g) {
+      // A heavy concrete monolith object on the slab — a brutalist paperweight.
+      const monolith = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.30, 0.16), fmat(0x9a9892, 0.95, 0.0));
+      monolith.position.set(-0.24, 0.77 + 0.15, 0.04); monolith.rotation.y = 0.4; monolith.castShadow = true; g.add(monolith);
+      const cube = new THREE.Mesh(new THREE.BoxGeometry(0.14, 0.14, 0.14), fmat(0x76746f, 0.96, 0.0));
+      cube.position.set(0.26, 0.77 + 0.07, -0.02); cube.rotation.y = 0.7; cube.castShadow = true; g.add(cube);
+    }
+  }
+};
+
+// Dispose a furniture group with full memory hygiene: remove from scene, then traverse
+// disposing every geometry + material (handling material arrays) + any UNIQUE texture.
+// Shared canvas textures are tagged __shared and left alone (killing them would break the
+// board / contact-shadow sprites elsewhere).
+const _FURN_TEX_SLOTS = ['map', 'normalMap', 'roughnessMap', 'metalnessMap', 'aoMap', 'emissiveMap', 'bumpMap', 'displacementMap', 'alphaMap'];
+function disposeFurniture(group) {
+  if (!group) return;
+  if (group.parent) group.parent.remove(group);
+  group.traverse(o => {
+    if (o.geometry) o.geometry.dispose();
+    const m = o.material;
+    if (!m) return;
+    (Array.isArray(m) ? m : [m]).forEach(mat => {
+      _FURN_TEX_SLOTS.forEach(k => { const t = mat[k]; if (t && !t.__shared) t.dispose(); });
+      mat.dispose();
+    });
+  });
+}
+
+// Shared layout constraints EVERY style obeys: ground the whole set on the floor (lowest
+// point → y=0) and keep its footprint inside the 20'×20' room minus the wall margin. Built
+// once here so no style can drift out of bounds or float / sink. Chair-to-table proximity
+// is baked into each style's chair position; grounding + bounds are enforced here.
+function applyLayout(group) {
+  group.updateWorldMatrix(true, true);
+  let box = new THREE.Box3().setFromObject(group);
+  if (isFinite(box.min.y)) group.position.y -= box.min.y;   // rest lowest point on the floor
+  group.updateWorldMatrix(true, true);
+  box = new THREE.Box3().setFromObject(group);
+  const halfW = CONFIG.room.width / 2 - FURNITURE_MARGIN;
+  const halfD = CONFIG.room.depth / 2 - FURNITURE_MARGIN;
+  if (box.max.x > halfW) group.position.x -= (box.max.x - halfW);
+  if (box.min.x < -halfW) group.position.x += (-halfW - box.min.x);
+  if (box.max.z > halfD) group.position.z -= (box.max.z - halfD);
+  if (box.min.z < -halfD) group.position.z += (-halfD - box.min.z);
+  group.updateWorldMatrix(true, true);
+}
+
+// Swap the active furniture set to `styleKey`. Disposes the old group (memory hygiene),
+// builds the new one from the registry, runs shared layout, and persists the choice.
+function swapFurniture(styleKey, opts) {
+  opts = opts || {};
+  if (!FURNITURE_STYLES[styleKey]) styleKey = FURNITURE_STYLE_ORDER[0];
+  const includeExtras = !!opts.includeExtras;
+
+  const wasVisible = furnitureRoot ? furnitureRoot.visible : true;
+  disposeFurniture(furnitureRoot);
+  furnitureRoot = null;
+
+  const g = new THREE.Group();
+  g.name = 'furnitureRoot';
+  g.position.set(FURNITURE_ANCHOR.x, 0, FURNITURE_ANCHOR.z);
+  const style = FURNITURE_STYLES[styleKey];
+  style.buildTable(g);
+  style.buildChair(g);
+  if (includeExtras && style.buildExtras) style.buildExtras(g);
+  scene.add(g);
+  applyLayout(g);
+
+  furnitureRoot = g;
+  furnitureRoot.visible = wasVisible;
+  furnitureStyleKey = styleKey;
+  furnitureExtras = includeExtras;
+  window._qhNookGroup = g;   // the "At the Table" view hides the set via this handle
+  try { localStorage.setItem(FURNITURE_LS_KEY, styleKey); } catch (e) {}
+  if (typeof requestShadowUpdate === 'function') requestShadowUpdate(6);
+  return g;
 }
 
 // ============================================================
@@ -1445,65 +1717,9 @@ function buildSeatedAvatar(sx, sz, faceYaw) {
   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) {
-  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 });
-
-  // Wrap the whole nook in a group so the table view can hide it (Steve 2026-07-01) —
-  // otherwise the side nook + the centre table both show in "At the Table" (clutter).
-  const nk = new THREE.Group();
-  scene.add(nk);
-  window._qhNookGroup = nk;
-
-  const TABLE_R = 0.52, TABLE_H = 0.74;
-  // Round top
-  const top = new THREE.Mesh(new THREE.CylinderGeometry(TABLE_R, TABLE_R, 0.05, 40), woodTop);
-  top.position.set(cx, TABLE_H, cz); top.castShadow = true; top.receiveShadow = true; nk.add(top);
-  // Pedestal column
-  const col = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.09, TABLE_H - 0.05, 16), woodDark);
-  col.position.set(cx, (TABLE_H - 0.05) / 2, cz); col.castShadow = true; nk.add(col);
-  // Splayed pedestal foot
-  const foot = new THREE.Mesh(new THREE.CylinderGeometry(0.30, 0.34, 0.05, 24), woodDark);
-  foot.position.set(cx, 0.03, cz); foot.castShadow = true; foot.receiveShadow = true; nk.add(foot);
-  addContactShadow(cx, cz, 0.85, 0.85, 0.7);
-
-  // A few swatch cards fanned on the table (small thin tilted planes, warm tones).
-  const cardTones = [0xd8c7a8, 0xc6d2c0, 0xcdbcae];
-  cardTones.forEach((c, k) => {
-    const card = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.004, 0.22),
-      new THREE.MeshStandardMaterial({ color: c, roughness: 0.85, metalness: 0.0 }));
-    card.position.set(cx - 0.12 + k * 0.12, TABLE_H + 0.03, cz - 0.05);
-    card.rotation.y = (k - 1) * 0.35; card.castShadow = true; nk.add(card);
-  });
-
-  // TWO cream bouclé armchairs — one near (toward camera), one far (toward the arc),
-  // both pulled up to the round table. Built from soft rounded boxes.
-  const placeChair = (chx, chz, faceYaw) => {
-    const g = new THREE.Group(); g.position.set(chx, 0, chz); g.rotation.y = faceYaw;
-    const seat = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.16, 0.50), boucle);
-    seat.position.set(0, 0.44, 0); seat.castShadow = true; seat.receiveShadow = true; g.add(seat);
-    const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.50, 0.14), boucle);
-    back.position.set(0, 0.70, -0.20); back.castShadow = true; g.add(back);
-    // rounded arms
-    [-0.26, 0.26].forEach(ax => {
-      const arm = new THREE.Mesh(new THREE.BoxGeometry(0.10, 0.22, 0.46), boucle);
-      arm.position.set(ax, 0.56, 0.0); arm.castShadow = true; g.add(arm);
-    });
-    // short tapered legs
-    [[-0.20,0.20],[0.20,0.20],[-0.20,-0.20],[0.20,-0.20]].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; g.add(leg);
-    });
-    nk.add(g);
-    addContactShadow(chx, chz, 0.7, 0.7, 0.6);
-  };
-  // Chair toward the camera (faces the arc, back to viewer) + chair on the far side.
-  placeChair(cx + 0.08, cz + 0.78, Math.PI);   // near chair, faces -z (toward arc)
-  placeChair(cx - 0.05, cz - 0.78, 0);          // far chair, faces +z (toward viewer)
-}
+// (Removed: buildConsultationNook — the always-on round-table + two-bouclé-chairs nook.
+// Replaced by the swappable FURNITURE_STYLES registry above; window._qhNookGroup is now
+// the active furniture root so the "At the Table" view still hides the side set.)
 
 // ============================================================
 // PHASE 3 — "AT THE TABLE" consultation set. A dedicated round table + a single chair
@@ -5236,6 +5452,14 @@ window._qh = {
   FOV_DEFAULT, FOV_HERO,
   setTargetFov(f) { targetFov = f; },
   requestShadowUpdate,
+  // ---- FURNITURE STYLE REGISTRY (Option A) — the style-selector UI drives these.
+  FURNITURE_STYLE_ORDER,
+  swapFurniture(key, opts) { return swapFurniture(key, opts); },
+  get furnitureStyle() { return furnitureStyleKey; },
+  get furnitureExtras() { return furnitureExtras; },
+  get furniturePresent() { return !!furnitureRoot; },
+  get furnitureChildCount() { return furnitureRoot ? furnitureRoot.children.length : 0; },
+  furnitureStyleLabel(key) { return (FURNITURE_STYLES[key] || {}).label || key; },
   // World-position helper for the numbered-element overlay: returns the world-space
   // centre of wing board i (or the rail centre if out of range).
   boardWorldPos(i) {
diff --git a/public/showroom.html b/public/showroom.html
index eefb87e..3fafdbb 100644
--- a/public/showroom.html
+++ b/public/showroom.html
@@ -454,5 +454,7 @@
   <!-- Hamburger dock: stows every panel on load, re-launches each as a movable modal -->
   <script src="/js/modal-rig.js"></script>
   <script src="/js/dock.js"></script>
+  <!-- Furniture style selector: swaps the procedural table+chair set (Option A) -->
+  <script src="/js/furniture-styles.js"></script>
 </body>
 </html>
diff --git a/server.js b/server.js
index dec3305..f0b2348 100644
--- a/server.js
+++ b/server.js
@@ -261,6 +261,20 @@ try { sharp = require('sharp'); } catch (e) { console.warn('[thumb] sharp not av
 const THUMB_CACHE = path.join(DATA_DIR, 'thumb-cache');
 try { fs.mkdirSync(THUMB_CACHE, { recursive: true }); } catch (e) {}
 
+// Cap concurrent UPSTREAM image fetches. A cold-cache page load fires ~24 sliver thumbs at
+// once; hitting the CDN with 24 simultaneous connections trips transient rate-limit 502s.
+// The disk cache makes every repeat load free, so throttling only the first cold burst
+// costs a beat, not UX. Root-cause fix for the 5x-caught cold-burst 502s.
+const UPSTREAM_MAX = 6;
+let _upstreamActive = 0; const _upstreamQ = [];
+function acquireUpstream() {
+  return new Promise(resolve => {
+    const run = () => { if (_upstreamActive < UPSTREAM_MAX) { _upstreamActive++; resolve(); } else _upstreamQ.push(run); };
+    run();
+  });
+}
+function releaseUpstream() { _upstreamActive--; if (_upstreamQ.length) _upstreamQ.shift()(); }
+
 function sendThumb(res, buf, key) {
   res.setHeader('Content-Type', 'image/jpeg');
   res.setHeader('Cache-Control', 'public, max-age=604800');
@@ -312,18 +326,22 @@ app.get('/api/thumb', async (req, res) => {
         req.on('error', reject);
         req.setTimeout(8000, () => req.destroy(new Error('upstream timeout')));
       });
-      // One retry w/ small backoff — a cold-cache burst of ~24 sliver thumbs can trip a
-      // TRANSIENT upstream hiccup (5xx/timeout) that shouldn't surface as a hard 502.
-      // Never retry a real 4xx (dead URL) — only transient network errors / 5xx.
+      // Retry w/ exponential backoff behind a concurrency cap — a cold-cache burst of ~24
+      // sliver thumbs can trip a TRANSIENT upstream hiccup (5xx/timeout) that shouldn't
+      // surface as a hard 502. The cap (acquireUpstream) prevents the burst; retries mop up
+      // any straggler. Never retry a real 4xx (dead URL) — only transient network errors/5xx.
+      await acquireUpstream();
       let buf;
-      for (let attempt = 0; ; attempt++) {
-        try { buf = await fetchBuf(url); break; }
-        catch (e) {
-          const transient = !e.status || e.status >= 500;
-          if (attempt >= 1 || !transient) throw e;
-          await new Promise(r => setTimeout(r, 250));
+      try {
+        for (let attempt = 0; ; attempt++) {
+          try { buf = await fetchBuf(url); break; }
+          catch (e) {
+            const transient = !e.status || e.status >= 500;
+            if (attempt >= 2 || !transient) throw e;
+            await new Promise(r => setTimeout(r, 250 * (attempt + 1)));
+          }
         }
-      }
+      } finally { releaseUpstream(); }
       return sendThumb(res, await downscale(buf));
     }
     return res.status(400).send('Missing source');

← d9cf3b2 5x sweep 1: retry transient upstream in /api/thumb (cold-cac  ·  back to Quadrille Showroom  ·  5x sweeps 2-5: upstream concurrency cap + placeholder fail-s b6f8daf →