[object Object]

← back to Interiordesignershowroom

Room Builder v4 layout: hamburger-collapsible products panel + generated-rooms gallery, styled

27d7ce4935fa5075b8fdee3cb84796c47e2f6adb · 2026-08-01 20:57:07 -0700 · Steve Abrams

Files touched

Diff

commit 27d7ce4935fa5075b8fdee3cb84796c47e2f6adb
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:57:07 2026 -0700

    Room Builder v4 layout: hamburger-collapsible products panel + generated-rooms gallery, styled
---
 public/css/site.css | 81 ++++++++++++++++++++++++++++++++++++++++++++++++++---
 public/js/build.js  | 74 +++++++++++++++++++++---------------------------
 2 files changed, 109 insertions(+), 46 deletions(-)

diff --git a/public/css/site.css b/public/css/site.css
index 8d53d04..593d552 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -167,9 +167,28 @@ h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid v
 .rb-ideas-h{margin:18px 0 8px;font-family:var(--serif)}
 .rb-ideas{display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:10px}
 
-.rb-right{position:sticky;top:64px}
-.rb-right>input{width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;margin-bottom:10px;font-size:.88rem}
-.rb-products{display:grid;grid-template-columns:1fr 1fr;gap:10px;max-height:76vh;overflow:auto;padding-right:4px}
+/* ---- rb-right: slide-in product panel ---- */
+.rb-right{
+  position:fixed;top:0;right:0;height:100vh;width:340px;z-index:40;
+  background:#fff;border-left:1px solid var(--line);
+  box-shadow:-6px 0 28px rgba(30,20,10,.14);
+  display:flex;flex-direction:column;
+  transform:translateX(0);transition:transform .28s cubic-bezier(.4,0,.2,1);
+  overflow:hidden;
+}
+.rb-right.collapsed{transform:translateX(110%)}
+.rb-right-head{
+  display:flex;align-items:center;justify-content:space-between;
+  padding:14px 16px 12px;border-bottom:1px solid var(--line);
+  font-family:var(--serif);font-weight:600;font-size:1rem;flex-shrink:0;
+}
+.rb-ham-close{
+  background:none;border:none;cursor:pointer;font-size:1.1rem;line-height:1;
+  color:var(--muted);padding:4px 6px;border-radius:4px;
+}
+.rb-ham-close:hover{color:var(--ink);background:#f3ede3}
+.rb-right>input{width:calc(100% - 32px);margin:12px 16px 8px;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font-size:.88rem;flex-shrink:0}
+.rb-products{display:grid;grid-template-columns:1fr 1fr;gap:10px;flex:1;overflow-y:auto;padding:0 16px 16px;}
 .rbp{background:#fff;border:1px solid var(--line);border-radius:6px;overflow:hidden;display:flex;flex-direction:column;cursor:grab}
 .rbp:active{cursor:grabbing}
 .rbp>img,.rbp .noimg{aspect-ratio:1/1;object-fit:cover;width:100%;background:#efe8dd}
@@ -179,7 +198,61 @@ h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid v
 .rbp-add:hover{border-color:var(--accent);color:var(--accent)}
 .empty{color:var(--muted);font-size:.85rem;grid-column:1/-1}
 .noimg{display:grid;place-items:center;color:var(--muted);font-size:.7rem}
-@media(max-width:980px){.rb-body{grid-template-columns:1fr}.rb-left,.rb-right{position:static}.rb-products{grid-template-columns:repeat(auto-fill,minmax(110px,1fr));max-height:none}}
+/* rb-ham: hamburger pill — top-right of the builder, only shows when panel is closed */
+.rb-ham{
+  position:fixed;top:18px;right:18px;z-index:50;
+  display:inline-flex;align-items:center;gap:7px;
+  background:#221e19;color:#f3ede3;
+  border:none;border-radius:40px;padding:9px 18px;font-size:.85rem;font-weight:600;
+  cursor:pointer;box-shadow:0 4px 16px rgba(30,20,10,.22);
+  transition:opacity .2s,background .2s;
+}
+.rb-ham:hover{background:var(--accent)}
+/* hide the ham button while the panel is open */
+.rb-right:not(.collapsed) ~ .rb-ham,
+body:has(.rb-right:not(.collapsed)) .rb-ham{ opacity:0;pointer-events:none }
+
+/* rb-body: 3-col when panel is open, 2-col (left + mid) when collapsed */
+.rb-body{display:grid;grid-template-columns:220px 1fr;gap:16px;margin-top:14px;align-items:start;transition:grid-template-columns .28s}
+/* right panel is fixed/overlay, not a grid column */
+
+/* rb-renders: responsive gallery grid for AI room cards */
+.rb-renders{
+  display:grid;
+  grid-template-columns:repeat(auto-fill,minmax(260px,1fr));
+  gap:14px;
+  min-height:240px;
+}
+.rb-render-ph{
+  grid-column:1/-1;display:flex;align-items:center;justify-content:center;
+  min-height:240px;color:var(--muted);text-align:center;font-family:var(--serif);font-size:1.05rem;
+  background:#f3ede2;border:1px solid var(--line);border-radius:10px;padding:24px;
+}
+
+/* rb-gen: individual AI room card */
+.rb-gen{
+  aspect-ratio:4/3;border-radius:8px;overflow:hidden;border:2px solid transparent;
+  background:#efe8dd;position:relative;cursor:pointer;
+  transition:border-color .18s,box-shadow .18s;
+}
+.rb-gen img{width:100%;height:100%;object-fit:cover;display:block}
+.rb-gen:hover{box-shadow:0 6px 24px rgba(30,20,10,.14)}
+.rb-gen.sel{border-color:var(--accent);box-shadow:0 0 0 2px var(--accent)}
+.rb-gen.loading{background:#e8e0d5;display:flex;align-items:center;justify-content:center}
+
+/* rb-gen-spin: centered loading indicator inside a .rb-gen.loading card */
+.rb-gen-spin{
+  text-align:center;color:var(--muted);font-family:var(--serif);font-size:.95rem;line-height:1.6;
+}
+.rb-gen-spin small{font-size:.78rem}
+
+@media(max-width:980px){
+  .rb-body{grid-template-columns:1fr}
+  .rb-left{position:static}
+  .rb-right{width:100%;border-left:none;border-top:1px solid var(--line)}
+  .rb-products{grid-template-columns:repeat(auto-fill,minmax(110px,1fr))}
+  .rb-ham{top:12px;right:12px;padding:8px 14px;font-size:.8rem}
+}
 
 /* Room Builder — render, hover popup, tray images bar */
 .rb-render-bar{display:flex;align-items:center;gap:12px;margin-bottom:10px}
diff --git a/public/js/build.js b/public/js/build.js
index 72143df..130526e 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -1,11 +1,10 @@
-// Room Builder client (v3). Left: Trending + Style/Hue/Theme/Period tabs +
-// 12-color multi-select swatches + Categories + Brand (all collapsed). Top tray:
-// chips + dragged product images + GO. Middle: photoreal AI render + ideas.
-// Right: draggable served products. Hover any item for a detail popup.
+// Room Builder client (v4). Left: dimension tabs + 12-color swatches. Top tray:
+// vibe chips + dragged product images + GO. Middle: ONLY generated room settings
+// (AI renders, gallery). Right: products, collapsed under a ☰ hamburger.
 (function () {
   var $ = function (id) { return document.getElementById(id); };
   var sel = { style: '', hue: '', theme: '', period: '' };
-  var swatches = {};       // name -> { bucket, hex }  (multi-select colors)
+  var swatches = {};                 // name -> {bucket,hex}
   var room = '', cat = '', brand = '';
   var board = {}, wallPaint = null, lastRows = {}, lastScene = null;
 
@@ -18,9 +17,9 @@
   var money = function (p) { return p == null ? '' : '$' + Number(p).toLocaleString('en-US', { maximumFractionDigits: 0 }); };
   var buckets = function () { return Array.from(new Set(Object.keys(swatches).map(function (n) { return swatches[n].bucket; }))); };
 
-  function card(p, drag) {
+  function card(p) {
     lastRows[p.id] = p;
-    return '<div class="rbp" data-id="' + p.id + '"' + (drag ? ' draggable="true"' : '') + '>' +
+    return '<div class="rbp" data-id="' + p.id + '" draggable="true">' +
       (p.image_url ? '<img loading="lazy" src="' + p.image_url + '" alt="">' : '<div class="noimg"></div>') +
       '<div class="rbp-t">' + (p.title || '').slice(0, 40) + '</div>' +
       '<div class="rbp-m">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div>' +
@@ -35,14 +34,7 @@
     if (cat) parts.push('<span class="rb-chip" data-rmk="cat">' + cat + ' ✕</span>');
     if (brand) parts.push('<span class="rb-chip" data-rmk="brand">' + brand + ' ✕</span>');
     Object.keys(board).forEach(function (id) { var p = board[id]; parts.push('<span class="rb-chip prod" data-rm="' + id + '">' + (p.image_url ? '<img src="' + p.image_url + '">' : '') + (p.title || '').slice(0, 16) + ' ✕</span>'); });
-    $('rbChips').innerHTML = parts.join('') || '<span class="rb-hint">Pick a vibe on the left, drag products from the right →</span>';
-  }
-
-  function preview() {
-    var ids = Object.keys(board), el = $('rbRender');
-    if (lastScene) return; // keep the rendered scene once generated
-    if (!ids.length) { el.innerHTML = '<div class="rb-render-ph">Your room renders here.<br><small>Pick a vibe + drag pieces, then <b>Render</b> for a photoreal scene — or hit <b>GO</b> to save.</small></div>'; return; }
-    el.innerHTML = '<div class="rb-scene">' + ids.map(function (id) { return '<div class="rb-scene-item" data-id="' + id + '"><img src="' + board[id].image_url + '" alt=""></div>'; }).join('') + '</div>';
+    $('rbChips').innerHTML = parts.join('') || '<span class="rb-hint">Pick a vibe on the left, ☰ Products to add pieces →</span>';
   }
 
   function query() {
@@ -53,25 +45,23 @@
     var s = $('rbSearch').value.trim(); if (s) q.set('q', s);
     return q.toString();
   }
-
   function loadProducts() {
     $('rbProducts').innerHTML = '<p class="empty">Loading…</p>';
     fetch('/api/catalog/search?' + query()).then(function (r) { return r.json(); }).then(function (rows) {
-      $('rbProducts').innerHTML = rows.length ? rows.map(function (p) { return card(p, true); }).join('') : '<p class="empty">No matches.</p>';
-      var ideas = rows.filter(function (p) { return !board[p.id]; }).slice(0, 8);
-      $('rbIdeas').innerHTML = ideas.length ? ideas.map(function (p) { return card(p, false); }).join('') : '<p class="empty">Adjust your vibe for ideas.</p>';
+      $('rbProducts').innerHTML = rows.length ? rows.map(card).join('') : '<p class="empty">No matches.</p>';
     });
   }
   function loadPaints() { fetch('/api/paints').then(function (r) { return r.json(); }).then(function (rows) { $('rbPaints').innerHTML = rows.map(function (p) { return '<button class="paint" data-id="' + p.id + '" title="' + (p.title || '').replace(/"/g, '') + '"><img src="' + p.image_url + '" alt=""></button>'; }).join(''); }); }
   function loadBrands() { fetch('/api/brands').then(function (r) { return r.json(); }).then(function (rows) { $('rbBrands').innerHTML = rows.map(function (b) { return '<button class="rb-opt" data-brand="' + b.advertiser.replace(/"/g, '') + '">' + b.advertiser + ' <span class="subtle">' + b.n + '</span></button>'; }).join('') || '<span class="empty">—</span>'; }); }
 
   function selOne(attr, val) { Array.prototype.forEach.call(document.querySelectorAll('[' + attr + ']'), function (b) { b.classList.toggle('sel', b.getAttribute(attr) === val); }); }
-  function add(id) { var p = lastRows[id]; if (p) { board[id] = p; chips(); if (!lastScene) preview(); refresh(); } }
+  function add(id) { var p = lastRows[id]; if (p) { board[id] = p; chips(); refresh(); } }
   function refresh() { Array.prototype.forEach.call(document.querySelectorAll('.rbp-add'), function (b) { b.textContent = board[b.getAttribute('data-id')] ? '✓ Added' : '+ Add'; }); }
 
   document.addEventListener('click', function (e) {
     var sw = e.target.closest('.rb-swatch');
     if (sw) { var nm = sw.getAttribute('data-name'), bk = sw.getAttribute('data-color'); if (swatches[nm]) { delete swatches[nm]; sw.classList.remove('sel'); } else { swatches[nm] = { bucket: bk, hex: sw.style.backgroundColor || sw.style.background }; sw.classList.add('sel'); } chips(); loadProducts(); return; }
+    var gen = e.target.closest('.rb-gen'); if (gen && gen.dataset.url) { Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (g) { g.classList.remove('sel'); }); gen.classList.add('sel'); lastScene = gen.dataset.url; return; }
     var opt = e.target.closest('.rb-opt');
     if (opt) {
       if (opt.dataset.trend) { var m = TREND[opt.dataset.trend] || {}; sel.style = m.style || ''; selOne('data-val', sel.style);
@@ -83,48 +73,48 @@
       if (opt.dataset.dim) { var d = opt.dataset.dim, v = opt.dataset.val; sel[d] = (sel[d] === v) ? '' : v; Array.prototype.forEach.call(document.querySelectorAll('.rb-opt[data-dim="' + d + '"]'), function (b) { b.classList.toggle('sel', b.getAttribute('data-val') === sel[d]); }); chips(); loadProducts(); return; }
     }
     var addb = e.target.closest('.rbp-add'); if (addb) { add(addb.getAttribute('data-id')); return; }
-    var rm = e.target.closest('[data-rm]'); if (rm) { delete board[rm.getAttribute('data-rm')]; chips(); if (!lastScene) preview(); refresh(); return; }
+    var rm = e.target.closest('[data-rm]'); if (rm) { delete board[rm.getAttribute('data-rm')]; chips(); refresh(); return; }
     var rmd = e.target.closest('[data-rmdim]'); if (rmd) { var dd = rmd.getAttribute('data-rmdim'); sel[dd] = ''; Array.prototype.forEach.call(document.querySelectorAll('.rb-opt[data-dim="' + dd + '"]'), function (b) { b.classList.remove('sel'); }); chips(); loadProducts(); return; }
     var rmsw = e.target.closest('[data-rmsw]'); if (rmsw) { var n = rmsw.getAttribute('data-rmsw'); delete swatches[n]; var sb = document.querySelector('.rb-swatch[data-name="' + n + '"]'); if (sb) sb.classList.remove('sel'); chips(); loadProducts(); return; }
     var rmk = e.target.closest('[data-rmk]'); if (rmk) { var k = rmk.getAttribute('data-rmk'); if (k === 'room') { room = ''; $('rbRoom').value = ''; } if (k === 'cat') { cat = ''; selOne('data-cat', ''); } if (k === 'brand') { brand = ''; selOne('data-brand', ''); } chips(); loadProducts(); return; }
     var pt = e.target.closest('.paint'); if (pt) { Array.prototype.forEach.call(document.querySelectorAll('.paint'), function (x) { x.classList.remove('sel'); }); pt.classList.add('sel'); wallPaint = { id: pt.getAttribute('data-id') }; return; }
   });
 
+  // hamburger: open/close the right products panel
+  $('rbHam').addEventListener('click', function () { $('rbRight').classList.remove('collapsed'); });
+  $('rbHamClose').addEventListener('click', function () { $('rbRight').classList.add('collapsed'); });
+
+  // drag products into the tray
   document.addEventListener('dragstart', function (e) { var c = e.target.closest('.rbp'); if (c) e.dataTransfer.setData('text/id', c.getAttribute('data-id')); });
-  ['rbTray', 'rbMid'].forEach(function (zid) { var z = $(zid);
-    z.addEventListener('dragover', function (e) { e.preventDefault(); z.classList.add('drop'); });
-    z.addEventListener('dragleave', function () { z.classList.remove('drop'); });
-    z.addEventListener('drop', function (e) { e.preventDefault(); z.classList.remove('drop'); var id = e.dataTransfer.getData('text/id'); if (id) add(id); });
-  });
+  var tray = $('rbTray');
+  tray.addEventListener('dragover', function (e) { e.preventDefault(); tray.classList.add('drop'); });
+  tray.addEventListener('dragleave', function () { tray.classList.remove('drop'); });
+  tray.addEventListener('drop', function (e) { e.preventDefault(); tray.classList.remove('drop'); var id = e.dataTransfer.getData('text/id'); if (id) add(id); });
 
   $('rbRoom').addEventListener('change', function () { room = this.value; chips(); loadProducts(); });
   $('rbSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProducts(); });
 
+  // Generate a room setting -> add to the middle gallery
   $('rbRenderBtn').addEventListener('click', function () {
-    var btn = this, cost = $('rbCost'), rr = $('rbRender');
-    btn.textContent = 'Rendering…'; btn.disabled = true;
-    rr.innerHTML = '<div class="rb-render-ph">🎨 Painting your room…<br><small>~10–20s</small></div>';
+    var btn = this, cost = $('rbCost'), g = $('rbRenders');
+    if (g.querySelector('.rb-render-ph')) g.innerHTML = '';
+    var ph = document.createElement('div'); ph.className = 'rb-gen loading'; ph.innerHTML = '<div class="rb-gen-spin">🎨 Painting…<br><small>~10–20s</small></div>'; g.insertBefore(ph, g.firstChild);
+    btn.disabled = true; btn.textContent = 'Generating…';
     fetch('/api/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
       body: JSON.stringify({ style: sel.style, color: Object.keys(swatches).join(' and '), theme: sel.theme, period: sel.period, room_type: room, wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
       .then(function (r) { return r.json(); }).then(function (d) {
-        btn.textContent = '🎨 Render photoreal room'; btn.disabled = false;
-        if (d.url) { lastScene = d.url; rr.innerHTML = '<img class="rb-scene-img" src="' + d.url + '" alt="rendered room">'; cost.textContent = 'rendered ✓ · cost $' + Number(d.cost).toFixed(3); }
-        else { rr.innerHTML = '<div class="rb-render-ph">Render failed: ' + (d.error || '') + '</div>'; }
-      }).catch(function () { btn.textContent = '🎨 Render photoreal room'; btn.disabled = false; rr.innerHTML = '<div class="rb-render-ph">Render error.</div>'; });
+        btn.disabled = false; btn.textContent = '✨ Generate room setting';
+        if (d.url) { Array.prototype.forEach.call(document.querySelectorAll('.rb-gen'), function (x) { x.classList.remove('sel'); }); ph.className = 'rb-gen sel'; ph.dataset.url = d.url; ph.innerHTML = '<img src="' + d.url + '" alt="room setting">'; lastScene = d.url; cost.textContent = 'last render $' + Number(d.cost).toFixed(3); }
+        else { ph.remove(); cost.textContent = 'render failed: ' + (d.error || ''); }
+      }).catch(function () { btn.disabled = false; btn.textContent = '✨ Generate room setting'; ph.remove(); });
   });
 
   // hover popup with item details
   var pop = document.createElement('div'); pop.className = 'rb-pop'; pop.style.display = 'none'; document.body.appendChild(pop);
-  document.addEventListener('mouseover', function (e) {
-    var c = e.target.closest('.rbp,.rb-scene-item,.card'); if (!c) return;
-    var p = lastRows[c.getAttribute('data-id')]; if (!p) return;
-    pop.innerHTML = '<img src="' + (p.image_url || '') + '" alt=""><div class="rb-pop-b"><b>' + (p.title || '') + '</b>' +
-      '<div class="rb-pop-p">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div>' +
-      '<div class="rb-pop-t">' + [p.room, p.style, p.color].filter(Boolean).join(' · ') + '</div></div>';
-    pop.style.display = 'block';
-  });
+  document.addEventListener('mouseover', function (e) { var c = e.target.closest('.rbp,.card'); if (!c) return; var p = lastRows[c.getAttribute('data-id')]; if (!p) return;
+    pop.innerHTML = '<img src="' + (p.image_url || '') + '" alt=""><div class="rb-pop-b"><b>' + (p.title || '') + '</b><div class="rb-pop-p">' + (p.sale_price ? money(p.sale_price) : money(p.price)) + ' · ' + (p.advertiser || p.brand || '') + '</div><div class="rb-pop-t">' + [p.room, p.style, p.color].filter(Boolean).join(' · ') + '</div></div>'; pop.style.display = 'block'; });
   document.addEventListener('mousemove', function (e) { if (pop.style.display !== 'block') return; var x = e.clientX + 16, y = e.clientY + 16; if (x > window.innerWidth - 270) x = e.clientX - 262; if (y > window.innerHeight - 180) y = e.clientY - 176; pop.style.left = x + 'px'; pop.style.top = y + 'px'; });
-  document.addEventListener('mouseout', function (e) { if (e.target.closest('.rbp,.rb-scene-item,.card')) pop.style.display = 'none'; });
+  document.addEventListener('mouseout', function (e) { if (e.target.closest('.rbp,.card')) pop.style.display = 'none'; });
 
   $('rbGo').addEventListener('click', function () {
     var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true;

← bcddff0 Room Builder: 12-color multi-select swatches (Alabaster→Blus  ·  back to Interiordesignershowroom  ·  Add Herman Miller + Steelcase brand ingest for testing 61d7a3c →