← 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,'"'))+'" 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,'"')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'"')+')" 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,'"')+')" onclick="addTextureToBoard('+JSON.stringify(t).replace(/"/g,'"')+')" 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 →