[object Object]

← back to Interiordesignershowroom

Room Builder: 12-color multi-select swatches (Alabaster→Blush, 1-to-many, maps to catalog color buckets); include color in search results for hover popup

bcddff03d727638f576eb58c368725a7a6233c91 · 2026-08-01 20:43:55 -0700 · Steve Abrams

Files touched

Diff

commit bcddff03d727638f576eb58c368725a7a6233c91
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:43:55 2026 -0700

    Room Builder: 12-color multi-select swatches (Alabaster→Blush, 1-to-many, maps to catalog color buckets); include color in search results for hover popup
---
 lib/rooms.js        |  5 ++--
 public/css/site.css |  7 ++++++
 public/js/build.js  | 67 ++++++++++++++++++++++++-----------------------------
 server.js           | 18 +++++++++++---
 4 files changed, 55 insertions(+), 42 deletions(-)

diff --git a/lib/rooms.js b/lib/rooms.js
index 2ddb923..25ed04f 100644
--- a/lib/rooms.js
+++ b/lib/rooms.js
@@ -4,19 +4,20 @@ const db = require('./db');
 
 const slugify = (s) => (s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60) || 'room';
 
-async function searchProducts({ q, room, style, color, advertiser, cat, max, limit = 40 } = {}) {
+async function searchProducts({ q, room, style, color, colors, advertiser, cat, max, limit = 40 } = {}) {
   const where = ['in_stock', 'is_wall_paint = FALSE', 'image_url IS NOT NULL'];
   const params = [];
   if (room) { params.push(room); where.push(`room = $${params.length}`); }
   if (style) { params.push(style); where.push(`style = $${params.length}`); }
   if (color) { params.push(color); where.push(`color = $${params.length}`); }
+  if (colors && colors.length) { params.push(colors); where.push(`color = ANY($${params.length})`); }
   if (advertiser) { params.push(advertiser); where.push(`advertiser = $${params.length}`); }
   if (cat) { params.push('%' + cat + '%'); where.push(`title ILIKE $${params.length}`); }
   if (max) { params.push(max); where.push(`COALESCE(sale_price, price) <= $${params.length}`); }
   if (q) { params.push('%' + q + '%'); where.push(`(title ILIKE $${params.length} OR brand ILIKE $${params.length} OR advertiser ILIKE $${params.length})`); }
   params.push(Math.min(limit, 80));
   const { rows } = await db.query(
-    `SELECT id,title,brand,advertiser,price,sale_price,image_url,room,style,affiliate_url
+    `SELECT id,title,brand,advertiser,price,sale_price,image_url,room,style,color,affiliate_url
      FROM products WHERE ${where.join(' AND ')} ORDER BY featured DESC, created_at DESC LIMIT $${params.length}`, params);
   return rows;
 }
diff --git a/public/css/site.css b/public/css/site.css
index bc066ed..8d53d04 100644
--- a/public/css/site.css
+++ b/public/css/site.css
@@ -195,3 +195,10 @@ h2{font-size:1.4rem;margin:0 0 16px;padding-bottom:8px;border-bottom:1px solid v
 .rb-pop-b b{font-size:.85rem;line-height:1.25;display:block}
 .rb-pop-p{color:var(--ink);font-weight:600;margin-top:4px}
 .rb-pop-t{color:var(--muted);font-size:.72rem;text-transform:capitalize;margin-top:2px}
+
+/* 12-color multi-select swatches */
+.rb-swatch{width:30px;height:30px;border-radius:50%;border:2px solid #fff;box-shadow:0 0 0 1px var(--line);cursor:pointer;padding:0;position:relative}
+.rb-swatch:hover{transform:scale(1.08)}
+.rb-swatch.sel{box-shadow:0 0 0 2px var(--ink)}
+.rb-swatch.sel::after{content:'✓';position:absolute;inset:0;display:grid;place-items:center;color:#fff;font-size:.8rem;text-shadow:0 1px 2px rgba(0,0,0,.6)}
+.sw-dot{width:11px;height:11px;border-radius:50%;display:inline-block;box-shadow:0 0 0 1px rgba(255,255,255,.4)}
diff --git a/public/js/build.js b/public/js/build.js
index 5de1065..72143df 100644
--- a/public/js/build.js
+++ b/public/js/build.js
@@ -1,11 +1,13 @@
-// Room Builder client (v2). Left: Trending presets + dimension tabs
-// (style/hue/color/theme/period) + Categories + Brand, all collapsed. Top tray:
-// chips + dragged products + GO. Middle: live preview + ideas. Right: products.
+// 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.
 (function () {
   var $ = function (id) { return document.getElementById(id); };
-  var sel = { style: '', hue: '', color: '', theme: '', period: '' };
+  var sel = { style: '', hue: '', theme: '', period: '' };
+  var swatches = {};       // name -> { bucket, hex }  (multi-select colors)
   var room = '', cat = '', brand = '';
-  var board = {}, wallPaint = null, lastRows = {};
+  var board = {}, wallPaint = null, lastRows = {}, lastScene = null;
 
   var TREND = {
     'Quiet Luxury': { style: 'glam', color: 'neutral' }, 'Japandi': { style: 'scandinavian', color: 'neutral' },
@@ -14,6 +16,7 @@
     'Boho': { style: 'boho' }, 'Scandinavian': { style: 'scandinavian' }, 'Maximalist': { style: 'glam', color: 'red' },
   };
   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) {
     lastRows[p.id] = p;
@@ -27,7 +30,8 @@
   function chips() {
     var parts = [];
     if (room) parts.push('<span class="rb-chip" data-rmk="room">' + room + ' ✕</span>');
-    Object.keys(sel).forEach(function (d) { if (sel[d]) parts.push('<span class="rb-chip" data-rmdim="' + d + '">' + sel[d] + ' ✕</span>'); });
+    ['style', 'hue', 'theme', 'period'].forEach(function (d) { if (sel[d]) parts.push('<span class="rb-chip" data-rmdim="' + d + '">' + sel[d] + ' ✕</span>'); });
+    Object.keys(swatches).forEach(function (n) { parts.push('<span class="rb-chip" data-rmsw="' + n + '"><span class="sw-dot" style="background:' + swatches[n].hex + '"></span>' + n + ' ✕</span>'); });
     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>'); });
@@ -36,13 +40,15 @@
 
   function preview() {
     var ids = Object.keys(board), el = $('rbRender');
-    if (!ids.length) { el.innerHTML = '<div class="rb-render-ph">Your room preview builds here.<br><small>Pick a style + drag pieces, or just hit <b>GO</b> to generate from your vibe.</small></div>'; return; }
+    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>';
   }
 
   function query() {
     var q = new URLSearchParams();
-    if (room) q.set('room', room); if (sel.style) q.set('style', sel.style); if (sel.color) q.set('color', sel.color);
+    if (room) q.set('room', room); if (sel.style) q.set('style', sel.style);
+    var bk = buckets(); if (bk.length) q.set('colors', bk.join(','));
     if (cat) q.set('cat', cat); if (brand) q.set('brand', brand);
     var s = $('rbSearch').value.trim(); if (s) q.set('q', s);
     return q.toString();
@@ -56,35 +62,30 @@
       $('rbIdeas').innerHTML = ideas.length ? ideas.map(function (p) { return card(p, false); }).join('') : '<p class="empty">Adjust your vibe for ideas.</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 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) { // single-select toggle within a group
-    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(); preview(); refresh(); } }
+  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 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 opt = e.target.closest('.rb-opt');
     if (opt) {
-      if (opt.dataset.trend) { var m = TREND[opt.dataset.trend] || {}; sel.style = m.style || ''; sel.color = m.color || ''; selOne('data-val', sel.style); if (sel.color) selOne('data-val', sel.color); chips(); loadProducts(); return; }
+      if (opt.dataset.trend) { var m = TREND[opt.dataset.trend] || {}; sel.style = m.style || ''; selOne('data-val', sel.style);
+        swatches = {}; Array.prototype.forEach.call(document.querySelectorAll('.rb-swatch'), function (b) { b.classList.remove('sel'); });
+        if (m.color) { var b = document.querySelector('.rb-swatch[data-color="' + m.color + '"]'); if (b) { swatches[b.getAttribute('data-name')] = { bucket: m.color, hex: b.style.backgroundColor || b.style.background }; b.classList.add('sel'); } }
+        chips(); loadProducts(); return; }
       if (opt.dataset.cat) { cat = (cat === opt.dataset.cat) ? '' : opt.dataset.cat; selOne('data-cat', cat); chips(); loadProducts(); return; }
       if (opt.dataset.brand) { brand = (brand === opt.dataset.brand) ? '' : opt.dataset.brand; selOne('data-brand', brand); chips(); loadProducts(); return; }
       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(); preview(); refresh(); return; }
+    var rm = e.target.closest('[data-rm]'); if (rm) { delete board[rm.getAttribute('data-rm')]; chips(); if (!lastScene) preview(); 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; }
   });
@@ -99,14 +100,12 @@
   $('rbRoom').addEventListener('change', function () { room = this.value; chips(); loadProducts(); });
   $('rbSearch').addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProducts(); });
 
-  // AI photoreal render (paid — cost shown)
-  var lastScene = null;
   $('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>';
     fetch('/api/render', { method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ style: sel.style, color: sel.color, theme: sel.theme, period: sel.period, room_type: room, wall_paint_id: wallPaint && wallPaint.id, product_ids: Object.keys(board).map(Number) }) })
+      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); }
@@ -124,20 +123,14 @@
       '<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('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'; });
 
   $('rbGo').addEventListener('click', function () {
     var go = $('rbGo'); go.textContent = 'Building…'; go.disabled = true;
     fetch('/api/rooms', { method: 'POST', headers: { 'Content-Type': 'application/json' },
-      body: JSON.stringify({ title: $('rbTitle').value.trim(), room_type: room, style: sel.style, color: sel.color,
-        note: [sel.hue, sel.theme, sel.period].filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, product_ids: Object.keys(board).map(Number) }) })
+      body: JSON.stringify({ title: $('rbTitle').value.trim(), room_type: room, style: sel.style, color: (buckets()[0] || ''),
+        note: [sel.hue, sel.theme, sel.period].concat(Object.keys(swatches)).filter(Boolean).join(', '), wall_paint_id: wallPaint && wallPaint.id, scene_image: lastScene, product_ids: Object.keys(board).map(Number) }) })
       .then(function (r) { return r.json(); }).then(function (d) { if (d.url) window.location = d.url; else { go.textContent = 'GO ▶'; go.disabled = false; alert(d.error || 'Error'); } })
       .catch(function () { go.textContent = 'GO ▶'; go.disabled = false; });
   });
diff --git a/server.js b/server.js
index 64bf7e9..9615165 100644
--- a/server.js
+++ b/server.js
@@ -206,7 +206,8 @@ const ROOM_TYPES = [['living-room', 'Living Room'], ['bedroom', 'Bedroom'], ['di
 app.get('/api/catalog/search', async (req, res, next) => {
   try {
     const r = await rooms.searchProducts({
-      q: req.query.q, room: req.query.room, style: req.query.style, color: req.query.color,
+      q: req.query.q, room: req.query.room, style: req.query.style,
+      colors: req.query.colors ? String(req.query.colors).split(',').filter(Boolean) : null,
       advertiser: req.query.brand, cat: req.query.cat,
       max: req.query.max ? parseInt(req.query.max, 10) : null, limit: 48,
     });
@@ -248,10 +249,19 @@ app.post('/api/rooms', async (req, res, next) => {
 const DIMENSIONS = {
   style: STYLES,
   hue: ['warm', 'cool', 'neutral', 'bold', 'muted', 'earthy'],
-  color: ['neutral', 'gray', 'black', 'brown', 'blue', 'green', 'pink', 'yellow', 'red'],
+  color: [], // rendered as swatches (COLORS_12), handled specially
   theme: ['minimalist', 'cozy', 'luxe', 'eclectic', 'organic', 'moody', 'airy', 'playful'],
   period: ['mid-century', 'art deco', 'victorian', 'contemporary', '70s retro', 'bauhaus', 'traditional'],
 };
+// 12 color swatches, multi-select (1-to-many). `bucket` maps to the catalog color field.
+const COLORS_12 = [
+  { name: 'Alabaster', hex: '#f2ede3', bucket: 'neutral' }, { name: 'Greige', hex: '#c9bca4', bucket: 'neutral' },
+  { name: 'Gray', hex: '#9a948c', bucket: 'gray' }, { name: 'Charcoal', hex: '#33302b', bucket: 'black' },
+  { name: 'Walnut', hex: '#6b4a2f', bucket: 'brown' }, { name: 'Camel', hex: '#c2a06a', bucket: 'brown' },
+  { name: 'Navy', hex: '#2c4a6e', bucket: 'blue' }, { name: 'Sky', hex: '#6f9fc4', bucket: 'blue' },
+  { name: 'Sage', hex: '#8a9a78', bucket: 'green' }, { name: 'Emerald', hex: '#2f6f57', bucket: 'green' },
+  { name: 'Ochre', hex: '#caa23e', bucket: 'yellow' }, { name: 'Blush', hex: '#d9a7a9', bucket: 'pink' },
+];
 
 // Cost guard: cap paid renders (global + per-IP, rolling hour) so a public
 // render button can't run up an unbounded Gemini bill.
@@ -293,7 +303,9 @@ app.get('/build', (_req, res) => {
   // left panel: ALL tabs start collapsed
   const tabs =
     tab('🔥 Trending', TRENDING.map(t => `<button class="rb-opt trend" data-trend="${t}">${t}</button>`).join('')) +
-    Object.entries(DIMENSIONS).map(([dim, vals]) => tab(cap(dim), vals.map(v => `<button class="rb-opt" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
+    Object.entries(DIMENSIONS).map(([dim, vals]) => dim === 'color'
+      ? tab('Color <span class="subtle">pick 1+</span>', COLORS_12.map(c => `<button class="rb-swatch" data-color="${c.bucket}" data-name="${c.name}" title="${c.name}" style="background:${c.hex}"></button>`).join(''))
+      : tab(cap(dim), vals.map(v => `<button class="rb-opt" data-dim="${dim}" data-val="${v}">${cap(v)}</button>`).join(''))).join('') +
     tab('Categories', CATS.map(c => `<button class="rb-opt" data-cat="${c}">${c}</button>`).join('')) +
     `<details class="rb-tab"><summary>Brand</summary><div class="rb-opts" id="rbBrands"><span class="empty">Loading…</span></div></details>`;
 

← 628a4c7 Room Builder: AI photoreal render (Gemini nano-banana, feeds  ·  back to Interiordesignershowroom  ·  Room Builder v4 layout: hamburger-collapsible products panel 27d7ce4 →