[object Object]

← back to Wallco Ai

generator batch: attribute run to actual created IDs from log (not global max id); mark error when 0 survive quality gate

ca9ff25201b3f995ba40a89a1174148988ffb99a · 2026-05-31 16:55:49 -0700 · Steve Abrams

Files touched

Diff

commit ca9ff25201b3f995ba40a89a1174148988ffb99a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 31 16:55:49 2026 -0700

    generator batch: attribute run to actual created IDs from log (not global max id); mark error when 0 survive quality gate
---
 server.js | 190 ++++++++++++++++++++++++++++++++++++++++++++------------------
 1 file changed, 137 insertions(+), 53 deletions(-)

diff --git a/server.js b/server.js
index a861235..19c3c94 100644
--- a/server.js
+++ b/server.js
@@ -20674,19 +20674,30 @@ app.post('/api/generator/batch', (req, res) => {
          stdio: ['ignore', out, out], timeout: Math.max(180000, n * 150000) });
 
     child.on('exit', (code) => {
-      // Reconcile: the N freshest spoon_all_designs rows are this batch's
-      // output. Record the newest design_id on the run row and mark done/error.
-      let designId = null;
+      // Reconcile from the LOG, not the global max id. generate_designs.js
+      // prints "Created X/N designs · IDs: a,b,c" — parse that so we record
+      // THIS batch's actual output, not whatever an unrelated concurrent
+      // generator (e.g. the drunk_animals cron) wrote last. Marking by global
+      // max id mis-attributed another job's design to this run.
+      let createdIds = [];
       try {
-        const row = psqlQuery(`SELECT id FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`);
-        designId = parseInt(row, 10) || null;
+        const logText = fs.readFileSync(logFile, 'utf8');
+        const m = logText.match(/Created\s+\d+\/\d+\s+designs\s*·?\s*IDs?:\s*([0-9,\s]+)/i);
+        if (m) createdIds = m[1].split(',').map(s => parseInt(s.trim(), 10)).filter(Number.isFinite);
       } catch {}
-      const ok = code === 0;
+      // "done" iff the child exited clean AND at least one design landed.
+      // code 1 from generate_designs.js means 0 created (every render was
+      // rejected by the ghost/frame/aesthetic gate) — that's a real failure
+      // worth surfacing, not a silent success.
+      const ok = code === 0 && createdIds.length > 0;
+      const firstId = createdIds[0] || null;
       try {
         psqlQuery(`UPDATE wallco_generator_runs SET
           status='${ok ? 'done' : 'error'}',
-          design_id=${designId || 'NULL'},
-          error=${ok ? 'NULL' : `'batch exited code ${code}'`},
+          design_id=${firstId || 'NULL'},
+          error=${ok ? 'NULL' : `'${(createdIds.length === 0
+              ? `0/${n} designs survived the quality gate (code ${code})`
+              : `batch exited code ${code}, ${createdIds.length}/${n} created`).replace(/'/g,"''")}'`},
           finished_at=now() WHERE id=${runId};`);
       } catch {}
     });
@@ -21963,40 +21974,59 @@ app.get('/designer-studio', (_req, res) => {
     .studio-3col { grid-template-columns: 1fr !important; }
     /* Trade-nav clip fix — pull Sign-In pill inside viewport on phones */
     #trade-nav { right: 56px !important; }
+    /* Board 6-across is unreadable on phones → 3×2; payoff + rooms stack */
+    #board { grid-template-columns: repeat(3, 1fr) !important; }
+    #studio-rooms-grid { grid-template-columns: 1fr !important; }
+    #studio-payoff > div[style*="grid-template-columns:minmax"] { grid-template-columns: 1fr !important; }
   }
 </style>
 <body>
 ${htmlHeader('')}
 <main style="padding:88px 18px 24px;max-width:1500px;margin:0 auto;overflow-x:clip;box-sizing:border-box">
   <h1 class="studio-display-title" style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:clamp(30px,6.4vw,48px);letter-spacing:-0.01em;margin:6px 0 4px;line-height:1.05">Designer Studio</h1>
-  <p style="color:#666;margin:0 0 22px;font-size:14px">Pick artists, styles, elements, or upload your own. Add up to 6 sources to the board → Compose generates a new pattern.</p>
+  <p style="color:var(--ink-soft);margin:0 0 18px;font-size:15px;line-height:1.55;font-style:italic">Pick artists, styles, elements, or upload your own. Add up to 6 sources to the board → Compose generates a new pattern.</p>
+
+  <!-- Sticky board strip — board state + Compose always visible regardless of scroll depth -->
+  <div id="board-strip" style="position:sticky;top:64px;z-index:90;background:var(--bg);border:1px solid var(--line);border-radius:10px;padding:9px 14px;margin-bottom:18px;display:flex;align-items:center;gap:12px;box-shadow:0 2px 10px rgba(15,14,12,.07)">
+    <div class="eyebrow" style="white-space:nowrap;margin:0">Board</div>
+    <div id="board-strip-slots" style="display:flex;gap:6px;flex:1;min-width:0;overflow:hidden"></div>
+    <button id="board-strip-compose" class="btn-primary" style="white-space:nowrap;padding:7px 16px" disabled>Compose →</button>
+  </div>
 
   <!-- Top: 3-column reference panels — auto-stacks below ~640px so chips never clip mid-word on mobile -->
   <div class="studio-3col" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:14px;margin-bottom:22px">
 
     <!-- Artists -->
-    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
-      <h3 style="margin:0 0 8px;font-size:14px">Famous Artists</h3>
-      <div id="artists-list" style="font-size:12px"></div>
+    <div style="padding:16px 18px;border:1px solid var(--line);border-radius:8px;background:var(--bg);max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
+      <h3 class="eyebrow" style="margin:0 0 10px">Famous Artists</h3>
+      <div id="artists-list" style="font-size:12px"><div style="color:var(--ink-faint);padding:18px 0;text-align:center;font-size:12px">Loading artists…</div></div>
     </div>
 
     <!-- Styles -->
-    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
-      <h3 style="margin:0 0 8px;font-size:14px">Styles</h3>
-      <div id="styles-list" style="font-size:12px"></div>
+    <div style="padding:16px 18px;border:1px solid var(--line);border-radius:8px;background:var(--bg);max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
+      <h3 class="eyebrow" style="margin:0 0 10px">Styles</h3>
+      <div id="styles-list" style="font-size:12px"><div style="color:var(--ink-faint);padding:18px 0;text-align:center;font-size:12px">Loading styles…</div></div>
     </div>
 
     <!-- Elements -->
-    <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fafafa;max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
-      <h3 style="margin:0 0 8px;font-size:14px">Elements <span style="font-size:10px;color:#999;font-weight:400">(animals, monograms, …)</span></h3>
-      <div id="elements-list" style="font-size:12px"></div>
+    <div style="padding:16px 18px;border:1px solid var(--line);border-radius:8px;background:var(--bg);max-height:320px;overflow-y:auto;min-width:0;overflow-wrap:anywhere">
+      <h3 class="eyebrow" style="margin:0 0 8px">Elements <span style="font-size:10px;color:var(--ink-faint);font-weight:400;text-transform:none;letter-spacing:0">— reusable motif cutouts</span></h3>
+      <div style="display:flex;gap:8px;align-items:center;margin-bottom:8px;flex-wrap:wrap">
+        <select id="el-sort" style="font:inherit;font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:4px;background:var(--card-bg)">
+          <option value="category">Category</option>
+          <option value="newest">Newest</option>
+          <option value="id">Motif # ↑</option>
+        </select>
+        <label style="font-size:11px;color:var(--ink-faint);display:flex;align-items:center;gap:4px">Size <input type="range" id="el-density" min="36" max="72" value="46" style="width:58px;accent-color:var(--ink)"></label>
+      </div>
+      <div id="elements-list" style="font-size:12px"><div style="color:var(--ink-faint);padding:18px 0;text-align:center;font-size:12px">Loading elements…</div></div>
     </div>
   </div>
 
   <!-- Textures grid (drag-droppable as bg or fg) -->
   <div style="margin-bottom:18px;padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff">
     <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
-      <h3 style="margin:0;font-size:14px">Textures <span style="font-size:11px;color:#999;font-weight:400">(drag onto an empty slot, or click to add as background)</span></h3>
+      <h3 class="eyebrow" style="margin:0">Textures <span style="font-size:11px;color:var(--ink-faint);font-weight:400;text-transform:none;letter-spacing:0">— drag onto a slot, or click to add</span></h3>
       <span style="font-size:11px;color:#666">Foreground opacity <input type="range" id="overlay-opacity" min="0" max="100" value="40" style="vertical-align:middle"> <span id="opacity-val">40%</span></span>
     </div>
     <div id="textures-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(86px,1fr));gap:6px;max-height:260px;overflow-y:auto"></div>
@@ -22014,21 +22044,23 @@ ${htmlHeader('')}
   <div id="search-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin-bottom:24px;max-height:340px;overflow-y:auto;border:1px solid #eee;border-radius:6px;padding:10px;background:#fff"></div>
 
   <!-- 6-slot board -->
-  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:20px;margin:24px 0 10px">Inspiration board (6 slots)</h2>
+  <div class="eyebrow" style="margin:30px 0 4px">Composition</div>
+  <h2 style="font-family:var(--serif);font-weight:400;font-size:clamp(22px,2.8vw,30px);letter-spacing:-0.02em;margin:0 0 12px">Inspiration board <span style="font-size:14px;color:var(--ink-faint)">· 6 slots</span></h2>
   <div id="board" style="display:grid;grid-template-columns:repeat(6,1fr);gap:10px;margin-bottom:22px"></div>
 
   <!-- 3-palette suggestions -->
   <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:22px">
     <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
-      <h3 style="margin:0;font-size:14px">3 Suggested palettes</h3>
+      <h3 class="eyebrow" style="margin:0">3 Suggested palettes</h3>
       <button id="btn-suggest" class="btn-outline" style="font-size:12px;padding:4px 10px">Refresh suggestions</button>
     </div>
     <div id="palette-suggestions" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px"></div>
   </div>
 
-  <!-- Extra elements + compose -->
-  <div style="padding:18px;border:1px solid #ddd;border-radius:8px;background:#fafafa;margin-bottom:22px">
-    <h3 style="margin:0 0 8px;font-size:14px">Add more elements to the prompt</h3>
+  <!-- Extra elements + compose — the destination of the whole workflow above -->
+  <div style="padding:22px;border:2px solid var(--ink);border-radius:10px;background:var(--card-bg);margin-bottom:22px;box-shadow:0 2px 14px rgba(15,14,12,.07)">
+    <h3 style="margin:0 0 4px;font-family:var(--serif);font-size:22px;font-weight:400;letter-spacing:-0.01em">Compose</h3>
+    <p style="margin:0 0 12px;font-size:12px;color:var(--ink-faint)">Your board drives the generation. Add optional motifs or prompt text below, then compose.</p>
     <div id="extra-elements-pills" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px"></div>
     <textarea id="extra-text" placeholder="Extra prompt text (optional)" rows="2" style="width:100%;padding:8px;border:1px solid #ccc;border-radius:4px;font-size:13px;font-family:inherit"></textarea>
     <div style="display:flex;gap:10px;align-items:center;margin-top:10px;flex-wrap:wrap">
@@ -22048,19 +22080,10 @@ ${htmlHeader('')}
     <div id="my-renders-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px"></div>
   </div>
 
-  <!-- R0c — personal renders gallery (this browser's session only) -->
-  <div id="studio-gallery" style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px;display:none">
-    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
-      <h3 style="margin:0;font-size:14px">Your renders <span style="font-size:11px;color:#999;font-weight:400" id="studio-gallery-count"></span></h3>
-      <span style="font-size:11px;color:#999">soft-deletes recoverable for 7 days</span>
-    </div>
-    <div id="studio-gallery-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px"></div>
-  </div>
-
   <!-- Category insights -->
   <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px">
     <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
-      <h3 style="margin:0;font-size:14px">Category insights — trends, best-sellers, ad signals</h3>
+      <h3 class="eyebrow" style="margin:0">Category insights — trends, best-sellers, ad signals</h3>
       <span style="font-size:11px;color:#666">Category <select id="cat-select" style="font:inherit;padding:4px 6px;border:1px solid #ccc;border-radius:3px">
         <option>floral</option><option>damask</option><option>geometric</option><option>botanical</option><option>baroque</option><option>art deco</option><option>chinoiserie</option><option>toile</option><option>maximalist</option><option>minimalist</option>
       </select></span>
@@ -22111,16 +22134,29 @@ async function loadRefs(){
 // grouped by category; selection holds the motif id. EL_BY_ID maps id->element
 // so the selected strip + compose can resolve the image.
 window.EL_BY_ID = {};
+function getElSize(){ var v=parseInt(localStorage.getItem('wallco.studio.el-size')||'46',10); return (v>=36&&v<=72)?v:46; }
+function getElSort(){ return localStorage.getItem('wallco.studio.el-sort')||'category'; }
+function elTile(e, c, sz){
+  window.EL_BY_ID[String(e.id)] = e;
+  var on = EXTRA.has(String(e.id));
+  return '<button data-extra="'+e.id+'" title="'+(c||'')+' · motif #'+e.id+'" onclick="toggleExtra(\\''+e.id+'\\')" style="width:'+sz+'px;height:'+sz+'px;padding:0;border-radius:8px;border:2px solid '+(on?'var(--ink)':'var(--line)')+';background:#faf8f2 url('+e.motif_url+') center/cover no-repeat;cursor:pointer;box-shadow:'+(on?'0 0 0 2px var(--ink) inset':'none')+';transition:all .12s"></button>';
+}
 function renderElementsList() {
   var byCat = window._ELEMENTS_BY_CAT || {};
   window.EL_BY_ID = {};
-  document.getElementById('elements-list').innerHTML = Object.keys(byCat).sort().map(function(c){
-    return '<div style="margin-bottom:8px"><div class="eyebrow" style="margin-bottom:3px">'+c+'</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+byCat[c].map(function(e){
-      window.EL_BY_ID[String(e.id)] = e;
-      var on = EXTRA.has(String(e.id));
-      return '<button data-extra="'+e.id+'" title="'+c+' · motif #'+e.id+'" onclick="toggleExtra(\\''+e.id+'\\')" style="width:46px;height:46px;padding:0;border-radius:8px;border:2px solid '+(on?'#1a1a1a':'#e0e0e0')+';background:#faf8f2 url('+e.motif_url+') center/cover no-repeat;cursor:pointer;box-shadow:'+(on?'0 0 0 2px #1a1a1a inset':'none')+';transition:all .12s"></button>';
-    }).join('')+'</div></div>';
-  }).join('');
+  var sz = getElSize(), sort = getElSort();
+  var host = document.getElementById('elements-list');
+  if (!host) return;
+  if (sort === 'category') {
+    host.innerHTML = Object.keys(byCat).sort().map(function(c){
+      return '<div style="margin-bottom:8px"><div class="eyebrow" style="margin-bottom:3px">'+c+'</div><div style="display:flex;flex-wrap:wrap;gap:4px">'+byCat[c].map(function(e){ return elTile(e,c,sz); }).join('')+'</div></div>';
+    }).join('');
+  } else {
+    var flat = [];
+    Object.keys(byCat).forEach(function(c){ byCat[c].forEach(function(e){ flat.push({e:e,c:c}); }); });
+    flat.sort(function(a,b){ return sort==='newest' ? (b.e.id||0)-(a.e.id||0) : (a.e.id||0)-(b.e.id||0); });
+    host.innerHTML = '<div style="display:flex;flex-wrap:wrap;gap:4px">'+flat.map(function(x){ return elTile(x.e,x.c,sz); }).join('')+'</div>';
+  }
 }
 
 function renderExtra() {
@@ -22151,16 +22187,48 @@ var BOARD = [];
 async function loadBoard(){
   BOARD = await get('/api/studio/board?sid='+encodeURIComponent(SID));
   renderBoard();
+  renderBoardStrip();
+}
+// Sticky strip mirror of the board — always-visible thumbnails + Compose button.
+function renderBoardStrip(){
+  var slots = document.getElementById('board-strip-slots');
+  var compBtn = document.getElementById('board-strip-compose');
+  if (!slots) return;
+  var html = '';
+  for (var i=0;i<6;i++){
+    var b = BOARD.find(function(x){return x.slot_idx===i;});
+    if (b){
+      var thumb = b.thumb_url || b.image_url || '';
+      var bg = thumb ? 'url(\\''+thumb+'\\') center/cover' : ('#'+(((b.color_hex_seeds||[])[0]||'#2a2a2a').replace('#','')));
+      html += '<div title="'+((b.title||'slot '+(i+1)).replace(/"/g,'&quot;'))+'" style="width:34px;height:34px;border-radius:5px;border:1px solid var(--gold);background:'+bg+';flex-shrink:0"></div>';
+    } else {
+      html += '<div style="width:34px;height:34px;border-radius:5px;border:2px dashed var(--line);background:var(--card-bg);flex-shrink:0"></div>';
+    }
+  }
+  slots.innerHTML = html;
+  if (compBtn) compBtn.disabled = BOARD.length === 0;
 }
 function nextEmptySlot(){
   for (var i=0;i<6;i++){ if (!BOARD.find(function(b){return b.slot_idx===i;})) return i; }
   return null;
 }
 async function addToBoardNextSlot(payload){
+  // De-dupe: same source already on the board → tell the user, don't double-add.
+  var dup = BOARD.find(function(b){ return b.source_type===payload.source_type && String(b.source_id)===String(payload.source_id); });
+  if (dup) { showToast('Already on the board (slot '+(dup.slot_idx+1)+')', '#856404', '#fff3cd'); return; }
   var idx = nextEmptySlot();
-  if (idx == null) { alert('Board is full (6 slots). Remove one first.'); return; }
+  if (idx == null) { showToast('Board is full — remove a slot to add more.', '#856404', '#fff3cd'); return; }
   await post('/api/studio/board/slot', Object.assign({sid:SID, slot_idx:idx}, payload));
   loadBoard();
+  showToast('\\u2713 Added to slot '+(idx+1), '#155724', '#d4edda');
+}
+// Non-blocking toast — replaces alert() so the thread never freezes.
+function showToast(msg, color, bg){
+  var t = document.createElement('div');
+  t.textContent = msg;
+  t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:9px 18px;border-radius:8px;background:'+(bg||'#0f0e0c')+';color:'+(color||'#fff')+';font-size:13px;z-index:9999;box-shadow:0 3px 14px rgba(0,0,0,.18);pointer-events:none';
+  document.body.appendChild(t);
+  setTimeout(function(){ t.style.transition='opacity .4s'; t.style.opacity='0'; setTimeout(function(){ t.remove(); }, 400); }, 2200);
 }
 window.removeSlot = async function(slot){
   await del('/api/studio/board/slot/'+slot+'?sid='+encodeURIComponent(SID));
@@ -22183,7 +22251,7 @@ function renderBoard(){
         '<button onclick="removeSlot('+i+')" style="position:absolute;top:4px;right:4px;background:rgba(0,0,0,.6);color:#fff;border:0;border-radius:50%;width:20px;height:20px;font-size:11px;cursor:pointer;line-height:1;padding:0">✕</button>'+
         '</div>';
     } else {
-      html += '<div style="border:2px dashed #ccc;border-radius:6px;padding:6px;background:#fafafa;display:flex;align-items:center;justify-content:center;aspect-ratio:1;color:#aaa;font-size:11px">Slot '+(i+1)+'<br>(empty)</div>';
+      html += '<div style="border:2px dashed var(--line);border-radius:6px;padding:6px;background:var(--card-bg);display:flex;flex-direction:column;align-items:center;justify-content:center;aspect-ratio:1;color:var(--ink-faint);font-size:10px;text-align:center;gap:3px"><span style="font-size:20px;line-height:1">+</span>Slot '+(i+1)+'</div>';
     }
   }
   document.getElementById('board').innerHTML = html;
@@ -22246,6 +22314,7 @@ document.getElementById('upload-input').addEventListener('change', async functio
 });
 
 document.getElementById('btn-compose').addEventListener('click', async function(){
+  if (BOARD.length === 0) { showToast('Add at least one artist, style, or image to the board first.', '#856404', '#fff3cd'); return; }
   this.disabled = true;
   var btn = this; btn.textContent = 'Composing (10-30s)…';
   var stat = document.getElementById('compose-result');
@@ -22442,7 +22511,7 @@ document.getElementById('btn-compose').addEventListener('click', async function(
 async function loadTextures() {
   var tx = await get('/api/studio/textures');
   document.getElementById('textures-grid').innerHTML = tx.map(function(t){
-    return '<div draggable="true" ondragstart="dragTexture(event,'+JSON.stringify(t).replace(/"/g,'&quot;')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'&quot;')+')" style="cursor:grab;text-align:center;border:1px solid #eee;border-radius:4px;padding:3px;background:#fff;font-size:9px" title="'+t.name+' — '+t.category+'">'+
+    return '<div draggable="true" ondragstart="dragTexture(event,'+JSON.stringify(t).replace(/"/g,'&quot;')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'&quot;')+')" style="cursor:grab;text-align:center;border:1px solid var(--line);border-radius:6px;padding:5px;background:var(--card-bg);font-size:11px" title="'+t.name+' — '+t.category+'">'+
       '<div style="aspect-ratio:1;background:url(\\''+t.image_url+'\\') center/cover, '+t.dominant_hex+';border-radius:3px"></div>'+
       '<div style="margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+t.name+'</div>'+
     '</div>';
@@ -22466,10 +22535,10 @@ window.addTextureToBoard = function(t) {
 // Drag-drop the texture / item onto a board slot
 function setupDropZones() {
   document.querySelectorAll('[data-slot-zone]').forEach(function(z){
-    z.addEventListener('dragover', function(e){ e.preventDefault(); this.style.background='#fffbe6'; });
-    z.addEventListener('dragleave', function(){ this.style.background=''; });
+    z.addEventListener('dragover', function(e){ e.preventDefault(); this.style.boxShadow='0 0 0 2px var(--gold) inset'; });
+    z.addEventListener('dragleave', function(){ this.style.boxShadow=''; });
     z.addEventListener('drop', async function(e){
-      e.preventDefault(); this.style.background='';
+      e.preventDefault(); this.style.boxShadow='';
       var slot = parseInt(this.getAttribute('data-slot-zone'),10);
       var t = e.dataTransfer.getData('application/x-texture');
       if (t) {
@@ -22504,14 +22573,18 @@ renderBoard = function(){
 };
 
 async function loadSuggestions(){
-  var j = await post('/api/studio/suggest-palettes', { sid: SID });
+  var btn = document.getElementById('btn-suggest');
   var div = document.getElementById('palette-suggestions');
+  if (btn) { btn.disabled = true; btn.textContent = 'Loading…'; }
+  div.innerHTML = '<div style="grid-column:1/-1;color:var(--ink-faint);font-size:12px;padding:20px 0;text-align:center">Generating palette suggestions…</div>';
+  var j = await post('/api/studio/suggest-palettes', { sid: SID });
+  if (btn) { btn.disabled = false; btn.textContent = 'Refresh suggestions'; }
   div.innerHTML = (j.palettes||[]).map(function(p,idx){
-    return '<div onclick="adoptPalette('+idx+')" style="cursor:pointer;padding:10px;border:1px solid #eee;border-radius:4px;background:#fafafa" onmouseover="this.style.background=\\'#fff\\'" onmouseout="this.style.background=\\'#fafafa\\'">'+
-      '<div style="font-size:12px;font-weight:500;margin-bottom:6px">'+p.name+'</div>'+
+    return '<div onclick="adoptPalette('+idx+')" style="cursor:pointer;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--bg);transition:box-shadow .12s" onmouseover="this.style.boxShadow=\\'0 2px 10px rgba(15,14,12,.08)\\'" onmouseout="this.style.boxShadow=\\'\\'">'+
+      '<div style="font-family:var(--serif);font-size:16px;font-weight:400;letter-spacing:-0.01em;margin-bottom:8px">'+p.name+'</div>'+
       '<div style="display:flex;gap:2px;margin-bottom:6px">'+(p.colors||[]).map(function(c){return '<div style="flex:1;height:32px;background:'+c+';border-radius:2px"></div>';}).join('')+'</div>'+
-      '<div style="font-size:10px;color:#666">'+p.rationale+'</div>'+
-      '<div style="font-size:9px;color:#aaa;margin-top:3px;font-family:monospace">'+(p.colors||[]).join(' ')+'</div>'+
+      '<div style="font-size:11px;color:var(--ink-faint)">'+p.rationale+'</div>'+
+      '<div style="font-size:9px;color:var(--ink-faint);margin-top:3px;font-family:monospace">'+(p.colors||[]).join(' ')+'</div>'+
     '</div>';
   }).join('');
   window.__palettes = j.palettes;
@@ -22536,7 +22609,7 @@ async function loadInsights(){
   var j = await get('/api/studio/category-insights?cat='+encodeURIComponent(cat));
   var div = document.getElementById('insights-body');
   var trendsHtml = '<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">' + (j.trends||[]).map(function(t){
-    return '<a href="'+t.link+'" target="_blank" style="padding:4px 10px;background:#eef6ff;color:#1a5298;border-radius:11px;text-decoration:none;font-size:11px" rel="noopener noreferrer">📈 '+t.term+'</a>';
+    return '<a href="'+t.link+'" target="_blank" style="padding:4px 12px;background:var(--bg);color:var(--ink);border:1px solid var(--line);border-radius:999px;text-decoration:none;font-size:11px" rel="noopener noreferrer">'+t.term+'</a>';
   }).join('') + '</div>';
   var bs = j.best_sellers || [];
   var bsHtml = bs.length ? '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:6px">' + bs.map(function(p){
@@ -22553,6 +22626,17 @@ setTimeout(function(){
   if (sel) sel.addEventListener('change', loadInsights);
   var op = document.getElementById('overlay-opacity');
   if (op) op.addEventListener('input', function(){ document.getElementById('opacity-val').textContent = this.value + '%'; });
+  // Elements sort + density (standing rule: every product grid gets both, persisted)
+  var elSort = document.getElementById('el-sort');
+  if (elSort) { elSort.value = getElSort(); elSort.addEventListener('change', function(){ localStorage.setItem('wallco.studio.el-sort', this.value); renderElementsList(); }); }
+  var elDen = document.getElementById('el-density');
+  if (elDen) { elDen.value = String(getElSize()); elDen.addEventListener('input', function(){ localStorage.setItem('wallco.studio.el-size', this.value); renderElementsList(); }); }
+  // Sticky-strip Compose proxies the real button + scrolls the result into view.
+  var stripCompose = document.getElementById('board-strip-compose');
+  if (stripCompose) stripCompose.addEventListener('click', function(){
+    var real = document.getElementById('btn-compose');
+    if (real && !real.disabled) { real.click(); var cr = document.getElementById('compose-result'); if (cr) cr.scrollIntoView({behavior:'smooth', block:'center'}); }
+  });
 }, 100);
 
 // Hook into compose: after success, optionally run interior-designer review

← c8fc9a6 Publish luxe-v2 batch L2 image-reviewed (41576,41844,41868,4  ·  back to Wallco Ai  ·  Colorway samples the GROUND not whole-image average: parse ' cba7409 →