← back to Wallco Live Viewer
server.js
527 lines
#!/usr/bin/env node
// wallco-live-viewer — watch wallco.ai patterns get generated in real time,
// and ACT on them: per-card + bulk publish / unpublish / remove / room render /
// seam-regen / TIF build, proxied to the local wallco-ai server (127.0.0.1:9905)
// whose admin-gate grants full admin to on-box loopback requests.
// Zero deps. Tails ~/Projects/wallco-ai/data/generated for <ms>_<id>.png files.
const http = require('http');
const fs = require('fs');
const path = require('path');
const { spawnSync } = require('child_process');
const GEN_DIR = path.join(process.env.HOME, 'Projects/wallco-ai/data/generated');
const LOGS = {
'drunk-animals': path.join(process.env.HOME, 'Projects/wallco-ai/data/logs/drunk_animals_tick.log'),
'pattern': '/tmp/wallco-generator.log',
};
const PORT = process.env.PORT || 9788;
const WALLCO = { host: '127.0.0.1', port: 9905 };
// Gate-rejection audit log written by generate_designs.js (2026-06-09). Lets the
// "orphaned · no DB row" cards name the ACTUAL gate (ghost/seamless/frame) +
// reason instead of inferring it from absence. Cached, re-read only on mtime change.
const GATE_LOG = path.join(process.env.HOME, 'Projects/wallco-ai/data/logs/gate-rejections.jsonl');
let _gateCache = { mtime: 0, map: {} };
function gateReasons() {
try {
const st = fs.statSync(GATE_LOG);
if (st.mtimeMs === _gateCache.mtime) return _gateCache.map;
const map = {};
for (const line of fs.readFileSync(GATE_LOG, 'utf8').split('\n')) {
if (!line.trim()) continue;
try { const o = JSON.parse(line); if (o.file) map[o.file] = { gate: o.gate, reason: o.reason, ts: o.ts }; } catch {}
}
_gateCache = { mtime: st.mtimeMs, map };
return map;
} catch { return _gateCache.map; }
}
const NAME_RE = /^(\d{10,})_(\d+)\.png$/; // <ms>_<rand>.png, excludes *.original.png & derivatives
// Only these wallco-ai paths may be proxied — keeps /act from being an open relay.
const ACT_WHITELIST = [
/^\/api\/designs\/bulk-action$/, // {action: publish|unpublish|delete, ids:[..]}
/^\/api\/design\/\d+\/remove(\?undo=1)?$/, // single remove / undo
/^\/api\/admin\/design\/\d+\/build-tif$/, // TIF print master (sync, up to 5 min)
/^\/api\/seam-debug\/\d+\/regenerate$/, // regen from same prompt → NEW row
/^\/api\/room$/, // room mockup (~$0.10 metered)
/^\/api\/design\/\d+$/, // status lookup (GET)
];
function listDesigns(limit = 240) {
let files;
try { files = fs.readdirSync(GEN_DIR); } catch { return []; }
const out = [];
for (const f of files) {
const m = f.match(NAME_RE);
if (!m) continue;
let created = parseInt(m[1], 10);
if (!Number.isFinite(created) || created < 1e12) {
try { created = fs.statSync(path.join(GEN_DIR, f)).mtimeMs; } catch { continue; }
}
out.push({ file: f, seed: m[2], created });
}
out.sort((a, b) => b.created - a.created);
return out.slice(0, limit);
}
// ── file → DB row resolver ────────────────────────────────────────────────
// The PNG name is <ms>_<SDXL-seed>.png — the suffix is the seed, NOT the
// design id. spoon_all_designs.local_path carries the exact filename, so one
// batched psql query maps file → {id, is_published, user_removed}. The id
// mapping is immutable (cached); publish/removed state is re-read each call
// so card badges stay live.
const PSQL = '/opt/homebrew/opt/postgresql@14/bin/psql';
const idCache = new Map(); // file → id (permanent)
function resolveDbRows(items) {
const files = items.map((d) => d.file);
if (!files.length) return {};
const inList = files.map((f) => `'${f.replace(/'/g, "''")}'`).join(',');
const sql = `SELECT regexp_replace(local_path,'^.*/','') AS fn, id, is_published, COALESCE(user_removed,false)
FROM spoon_all_designs
WHERE regexp_replace(local_path,'^.*/','') IN (${inList});`;
const r = spawnSync(PSQL, ['-U', 'stevestudio2', '-d', 'dw_unified', '-tA', '-F', '|', '-c', sql],
{ encoding: 'utf8', timeout: 10000 });
const map = {};
if (r.status === 0) {
for (const line of (r.stdout || '').trim().split('\n')) {
if (!line) continue;
const [fn, id, pub, removed] = line.split('|');
map[fn] = { id: parseInt(id, 10), published: pub === 't', removed: removed === 't' };
idCache.set(fn, parseInt(id, 10));
}
}
return map;
}
// Is a generator actively producing right now? (tick log touched in last 90s)
function liveSignal() {
const now = Date.now();
const sig = { generating: false, lastLog: null, recentTickMs: null };
for (const [k, p] of Object.entries(LOGS)) {
try {
const st = fs.statSync(p);
if (!sig.recentTickMs || st.mtimeMs > sig.recentTickMs) {
sig.recentTickMs = st.mtimeMs; sig.lastLog = k;
}
if (now - st.mtimeMs < 90 * 1000) sig.generating = true;
} catch {}
}
return sig;
}
// ── settlement override publish ───────────────────────────────────────────
// The PG trigger settlement_publish_check() RAISEs on designs whose
// settlement verdict isn't OK. Its own hint documents the sanctioned escape:
// SET LOCAL settlement.allow_override='true' + document why in notes.
// This helper does exactly that — ONE design per call (no bulk override,
// deliberate friction), reason mandatory, reason appended to notes with a
// timestamp so the audit trail lives on the row itself.
function overridePublish(id, reason) {
const esc = String(reason).replace(/'/g, "''");
const sql = `BEGIN;
SET LOCAL settlement.allow_override = 'true';
UPDATE spoon_all_designs
SET is_published = TRUE,
notes = COALESCE(notes,'') || E'\\n[settlement-override ' || now()::timestamptz(0) || ' (Steve via live-viewer): ${esc}]'
WHERE id = ${id};
COMMIT;`;
return spawnSync(PSQL, ['-U', 'stevestudio2', '-d', 'dw_unified', '-v', 'ON_ERROR_STOP=1', '-c', sql],
{ encoding: 'utf8', timeout: 15000 });
}
// Server-side proxy to wallco-ai. Sends NO client headers, so the upstream
// socket peer is loopback with no x-forwarded-* → admin-gate grants admin.
function proxyToWallco(method, actPath, bodyObj, cb) {
const payload = bodyObj ? JSON.stringify(bodyObj) : '';
const req = http.request({
host: WALLCO.host, port: WALLCO.port, path: actPath, method,
headers: payload
? { 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }
: {},
timeout: 6 * 60 * 1000, // TIF build is sync and can take ~5 min
}, (up) => {
let buf = '';
up.on('data', (c) => buf += c);
up.on('end', () => cb(null, up.statusCode, buf));
});
req.on('timeout', () => { req.destroy(new Error('upstream timeout')); });
req.on('error', (e) => cb(e));
if (payload) req.write(payload);
req.end();
}
const HTML = `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>wallco.ai · live pattern generation</title>
<style>
:root{--cols:5;--bg:#0b0b0e;--card:#16161c;--ink:#f4f4f6;--mut:#9a9aa6;--ok:#22c55e;--hot:#f59e0b;--bad:#ef4444;--acc:#818cf8}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
header{position:sticky;top:0;z-index:5;background:linear-gradient(180deg,#0b0b0e,#0b0b0ee0);backdrop-filter:blur(6px);
padding:14px 18px;border-bottom:1px solid #23232b;display:flex;align-items:center;gap:14px;flex-wrap:wrap}
h1{font-size:16px;margin:0;font-weight:700;letter-spacing:.3px}
.dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-right:6px;vertical-align:middle}
.dot.live{background:var(--hot);box-shadow:0 0 0 0 rgba(245,158,11,.6);animation:pulse 1.3s infinite}
.dot.idle{background:var(--ok)}
@keyframes pulse{0%{box-shadow:0 0 0 0 rgba(245,158,11,.55)}70%{box-shadow:0 0 0 8px rgba(245,158,11,0)}100%{box-shadow:0 0 0 0 rgba(245,158,11,0)}}
.pill{background:#23232b;color:var(--mut);padding:4px 10px;border-radius:999px;font-size:12px}
.pill a{color:var(--acc);text-decoration:none}
.spacer{flex:1}
.ctrl{display:flex;align-items:center;gap:8px;color:var(--mut);font-size:12px}
select,input[type=range]{accent-color:var(--hot)}
select{background:#23232b;color:var(--ink);border:1px solid #32323c;border-radius:8px;padding:5px 8px}
main{padding:16px 18px}
.grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:14px}
.card{background:var(--card);border:1px solid #23232b;border-radius:12px;overflow:hidden;position:relative;transition:transform .15s,border-color .15s}
.card:hover{transform:translateY(-2px);border-color:#3a3a44}
.card.sel{border-color:var(--acc);box-shadow:0 0 0 1px var(--acc)}
.card img{width:100%;aspect-ratio:1/1;object-fit:cover;display:block;background:#0e0e12;cursor:zoom-in}
.meta{padding:8px 10px}
.when{color:var(--mut);font-size:11px;display:flex;align-items:center;gap:5px}
.id{color:var(--ink);font-size:12px;font-weight:600;margin-top:2px;display:flex;align-items:center;gap:8px}
.state{font-size:10px;padding:1px 7px;border-radius:999px;background:#23232b;color:var(--mut)}
.state.pub{background:#143a23;color:#4ade80}.state.unpub{background:#3a2914;color:#fbbf24}.state.gone{background:#3a1414;color:#f87171}
.new{position:absolute;top:8px;left:8px;background:var(--hot);color:#1a1205;font-weight:800;font-size:10px;
padding:2px 7px;border-radius:999px;letter-spacing:.5px;animation:fadeNew 6s forwards;z-index:2}
@keyframes fadeNew{0%,70%{opacity:1}100%{opacity:0}}
.pick{position:absolute;top:8px;right:8px;z-index:3;width:18px;height:18px;accent-color:var(--acc);cursor:pointer}
.acts{display:flex;flex-wrap:wrap;gap:5px;padding:0 10px 10px}
.acts button,.acts a{background:#23232b;border:1px solid #32323c;color:var(--ink);border-radius:7px;
font-size:11px;padding:4px 8px;cursor:pointer;text-decoration:none;line-height:1.3}
.acts button:hover,.acts a:hover{border-color:var(--acc);color:#c7d2fe}
.acts button:disabled{opacity:.4;cursor:wait}
.acts .danger:hover{border-color:var(--bad);color:#fca5a5}
.bulk{display:none;align-items:center;gap:10px;background:#1b1b30;border:1px solid #34345c;border-radius:10px;padding:6px 12px}
.bulk.on{display:flex}
.bulk button{background:#2b2b4a;border:1px solid #44447a;color:var(--ink);border-radius:7px;font-size:12px;padding:5px 10px;cursor:pointer}
.bulk button:hover{border-color:var(--acc)}
.bulk .danger:hover{border-color:var(--bad);color:#fca5a5}
.empty{color:var(--mut);text-align:center;padding:60px;font-size:15px}
#toasts{position:fixed;bottom:18px;right:18px;z-index:50;display:flex;flex-direction:column;gap:8px;max-width:380px}
.toast{background:#16161c;border:1px solid #32323c;border-left:4px solid var(--acc);border-radius:10px;padding:10px 14px;font-size:13px;
box-shadow:0 8px 24px rgba(0,0,0,.5);animation:tIn .2s}
.toast.ok{border-left-color:var(--ok)}.toast.err{border-left-color:var(--bad)}
@keyframes tIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
#lightbox{display:none;position:fixed;inset:0;background:#000c;z-index:40;align-items:center;justify-content:center;cursor:zoom-out}
#lightbox img{max-width:92vw;max-height:92vh;border-radius:8px}
</style></head><body>
<header>
<h1>wallco.ai <span style="color:var(--mut);font-weight:400">· live generation</span></h1>
<span class="pill" id="status"><span class="dot idle"></span><span id="statustxt">connecting…</span></span>
<span class="pill" id="count">—</span>
<span class="pill" id="latest">—</span>
<span class="pill">🌵 <a href="http://127.0.0.1:9905/admin/cactus-curator" target="_blank">curator</a></span>
<span class="pill">🔍 <a href="http://127.0.0.1:9905/admin/edges-review" target="_blank">edges</a></span>
<div class="bulk" id="bulk">
<span id="bulkn">0 selected</span>
<button onclick="bulkAct('publish')">✅ Publish</button>
<button onclick="bulkAct('unpublish')">🚫 Unpublish</button>
<button class="danger" onclick="bulkAct('delete')">🗑 Remove</button>
<button onclick="clearSel()">✕</button>
</div>
<span class="spacer"></span>
<div class="ctrl"><label><input type="checkbox" id="selall"> select all</label></div>
<span class="ctrl" style="color:var(--mut);font-size:11px" title="Drag a box across cards to select many · Shift-drag adds to the current selection">⇲ drag to select</span>
<div class="ctrl">view
<select id="view">
<option value="all">All</option>
<option value="pub">Published</option>
<option value="unpub">Staged</option>
<option value="gone">Removed</option>
<option value="norow">Awaiting DB</option>
</select>
</div>
<div class="ctrl">sort
<select id="sort">
<option value="newest">Newest</option>
<option value="oldest">Oldest</option>
<option value="id">ID ↑</option>
</select>
</div>
<div class="ctrl">density
<input type="range" id="cols" min="2" max="9" step="1">
</div>
</header>
<main><div class="grid" id="grid"></div><div class="empty" id="empty" style="display:none">No patterns yet — waiting for the next generator tick…</div></main>
<div id="toasts"></div>
<div id="lightbox" onclick="this.style.display='none'"><img id="lbimg"></div>
<script>
const grid=document.getElementById('grid'), gEmpty=document.getElementById('empty');
let seen=new Set(), first=true, last=[], sel=new Set(), states={};
const LS=k=>localStorage.getItem('wlv:'+k), setLS=(k,v)=>localStorage.setItem('wlv:'+k,v);
const colsEl=document.getElementById('cols'), sortEl=document.getElementById('sort'), viewEl=document.getElementById('view');
colsEl.value=LS('cols')||5; sortEl.value=LS('sort')||'newest'; viewEl.value=LS('view')||'all';
function applyCols(){document.documentElement.style.setProperty('--cols',colsEl.value);setLS('cols',colsEl.value);}
colsEl.oninput=applyCols; sortEl.onchange=()=>{setLS('sort',sortEl.value);render(last);}; applyCols();
viewEl.onchange=()=>{setLS('view',viewEl.value);render(last);};
function stateOf(d){return states[d.dbId]||d.state||'norow';}
function viewFilter(items){const v=viewEl.value;return v==='all'?items:items.filter(d=>stateOf(d)===v);}
function fmt(ms){return new Date(ms).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit',second:'2-digit'});}
function toast(msg,cls){const t=document.createElement('div');t.className='toast '+(cls||'');t.textContent=msg;
document.getElementById('toasts').appendChild(t);setTimeout(()=>t.remove(),6000);}
function sortItems(items){const s=sortEl.value;const a=[...items];
if(s==='oldest')a.sort((x,y)=>x.created-y.created);
else if(s==='id')a.sort((x,y)=>(x.dbId||1e15)-(y.dbId||1e15));
else a.sort((x,y)=>y.created-x.created); return a;}
async function act(path,body,method){
const r=await fetch('/act',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({path,body,method:method||'POST'})});
const d=await r.json().catch(()=>({error:'bad json'}));
if(!r.ok||d.error)throw new Error(d.error||('HTTP '+r.status));
return d;
}
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
function stateBadge(d){
const s=states[d.dbId]||d.state; // optimistic overlay until next tick
if(s==='pub')return '<span class="state pub">published</span>';
if(s==='unpub')return '<span class="state unpub">staged</span>';
if(s==='gone')return '<span class="state gone">removed</span>';
// A file with no DB row after 30 min was REJECTED by a generation-time
// quality gate (ghost / seamless / frame-overlay) — the generator skips the
// INSERT on purpose. Without this check rejects show "awaiting" forever.
if(d.created && (Date.now()-d.created) > 30*60*1000){
// If the generator logged WHICH gate killed it, name the gate on the badge.
if(d.gate&&d.gate.gate)
return '<span class="state gone" title="'+esc('Gate: '+d.gate.gate+(d.gate.reason?(' — '+d.gate.reason):''))+'">✗ '+esc(d.gate.gate)+'</span>';
return '<span class="state gone" title="Generated but never inserted — failed a quality gate (ghost/seamless/frame-overlay) at generation time. No reason was logged (pre-2026-06-09 run). Not waiting on anything.">gate-rejected</span>';
}
return '<span class="state">no row yet</span>';
}
function isSettlementErr(e){return /settlement/i.test(String(e&&e.message||e));}
async function offerOverride(id,errMsg){
const reason=prompt('⚖️ Settlement gate blocked #'+id+':\\n\\n'+errMsg.slice(0,400)+
'\\n\\nTo OVERRIDE (Steve approval), type the reason — it is appended to the design notes as a permanent audit trail. Cancel = leave blocked.');
if(!reason||reason.trim().length<5){if(reason!==null)toast('Override needs a real reason (min 5 chars)','err');return false;}
const r=await fetch('/override-publish',{method:'POST',headers:{'content-type':'application/json'},
body:JSON.stringify({id:+id,reason:reason.trim()})});
const d=await r.json().catch(()=>({error:'bad json'}));
if(!r.ok||d.error){toast('#'+id+' override failed: '+(d.error||r.status),'err');return false;}
states[id]='pub';toast('#'+id+' published via settlement override ⚖️ (reason logged to notes)','ok');return true;
}
async function doAct(btn,id,kind){
const card=btn.closest('.card');[...card.querySelectorAll('button')].forEach(b=>b.disabled=true);
try{
if(kind==='publish'){
try{await act('/api/designs/bulk-action',{action:'publish',ids:[+id]});states[id]='pub';toast('#'+id+' published ✅','ok');}
catch(e){if(isSettlementErr(e)){await offerOverride(id,e.message);}else throw e;}
}
else if(kind==='unpublish'){await act('/api/designs/bulk-action',{action:'unpublish',ids:[+id]});states[id]='unpub';toast('#'+id+' unpublished','ok');}
else if(kind==='remove'){if(!confirm('Remove #'+id+' from the catalog? (undoable)'))return;
await act('/api/design/'+id+'/remove',{});states[id]='gone';toast('#'+id+' removed 🗑 (click ↩ to undo)','ok');}
else if(kind==='undo'){await act('/api/design/'+id+'/remove?undo=1',{});delete states[id];toast('#'+id+' restored ↩','ok');}
else if(kind==='room'){if(!confirm('Render a room mockup for #'+id+'? (~10¢ via Gemini, ~30-60s)'))return;
toast('#'+id+' room render started…');const d=await act('/api/room',{design_id:+id,roomType:'living_room'});
toast('#'+id+' room done 🛋','ok');if(d.url||d.image)window.open(d.url||d.image,'_blank');}
else if(kind==='regen'){if(!confirm('Regenerate #'+id+' from its prompt? Creates a NEW design row (Round-1 originals stay sacred). May take minutes + AI cost.'))return;
toast('#'+id+' regen started…');const d=await act('/api/seam-debug/'+id+'/regenerate',{});
toast('regen → new design #'+(d.newId||d.new_id||'?')+' 🔁','ok');}
else if(kind==='tif'){if(!confirm('Build the TIF print master for #'+id+'? Sync — can take up to 5 min.'))return;
toast('#'+id+' TIF building…');const d=await act('/api/admin/design/'+id+'/build-tif',{});
toast('#'+id+' TIF '+(d.ok?'built 🏗':'FAILED: '+(d.stderr||'').slice(-120)),d.ok?'ok':'err');}
}catch(e){toast('#'+id+' '+kind+' failed: '+e.message,'err');}
finally{[...card.querySelectorAll('button')].forEach(b=>b.disabled=false);render(last);}
}
async function bulkAct(action){
const ids=[...sel].map(Number);
if(!ids.length)return;
const label={publish:'Publish',unpublish:'Unpublish',delete:'REMOVE'}[action];
if(!confirm(label+' '+ids.length+' design(s)? '+(action==='delete'?'(undoable per-card)':'')))return;
try{
await act('/api/designs/bulk-action',{action,ids});
ids.forEach(id=>states[id]=action==='publish'?'pub':action==='unpublish'?'unpub':'gone');
toast(label+' done for '+ids.length+' designs','ok');clearSel();
}catch(e){
// A settlement RAISE inside the bulk UPDATE rolls back the WHOLE statement.
// Fall back to per-id so clean designs still publish and blockers get named.
if(action==='publish'&&isSettlementErr(e)){
toast('Settlement gate hit in bulk — retrying one-by-one…');
const blocked=[];let okN=0;
for(const id of ids){
try{await act('/api/designs/bulk-action',{action:'publish',ids:[id]});states[id]='pub';okN++;}
catch(e2){if(isSettlementErr(e2))blocked.push(id);else toast('#'+id+' failed: '+e2.message,'err');}
}
toast(okN+' published ✅ · '+blocked.length+' blocked by settlement'+(blocked.length?': #'+blocked.join(' #')+' — use ✅ Publish on the card to review/override each':''),blocked.length?'err':'ok');
clearSel();
}else toast('Bulk '+action+' failed: '+e.message,'err');
}
render(last);
}
function clearSel(){sel.clear();document.getElementById('selall').checked=false;syncBulk();render(last);}
function syncBulk(){const b=document.getElementById('bulk');b.classList.toggle('on',sel.size>0);
document.getElementById('bulkn').textContent=sel.size+' selected';}
function togglePick(cb,id){cb.checked?sel.add(id):sel.delete(id);syncBulk();
cb.closest('.card').classList.toggle('sel',cb.checked);}
document.getElementById('selall').onchange=function(){
if(this.checked){viewFilter(last).forEach(d=>{if(d.dbId)sel.add(d.dbId);});}else sel.clear();
syncBulk();render(last);};
function render(items){last=items;
const visible=viewFilter(items);
const cts={pub:0,unpub:0,gone:0,norow:0};items.forEach(d=>cts[stateOf(d)]++);
document.getElementById('count').textContent=
visible.length+(viewEl.value==='all'?'':'/'+items.length)+' shown · '+
cts.pub+' pub · '+cts.unpub+' staged · '+cts.gone+' removed · '+cts.norow+' no-row';
if(!visible.length){grid.innerHTML='';gEmpty.style.display='block';
gEmpty.textContent=items.length?'Nothing matches this view filter.':'No patterns yet — waiting for the next generator tick…';
return;} gEmpty.style.display='none';
const sorted=sortItems(visible);
grid.innerHTML=sorted.map(d=>{
const isNew=!first&&!seen.has(d.file);
const id=d.dbId;
const picked=id&&sel.has(id);
const gone=(states[id]||d.state)==='gone';
return '<div class="card'+(picked?' sel':'')+'" data-id="'+(id||'')+'">'+(isNew?'<span class="new">NEW</span>':'')+
(id?'<input type="checkbox" class="pick" '+(picked?'checked ':'')+'onchange="togglePick(this,'+id+')">':'')+
'<img loading="lazy" src="/img/'+encodeURIComponent(d.file)+'" alt="design '+(id||d.seed)+'" onclick="lb(this.src)">'+
'<div class="meta"><div class="when" title="'+new Date(d.created).toISOString()+'">🕓 '+fmt(d.created)+'</div>'+
'<div class="id">'+(id?('#'+id):('seed '+d.seed))+' '+stateBadge(d)+'</div></div>'+
'<div class="acts">'+
(id
? '<a href="http://127.0.0.1:9905/design/'+id+'" target="_blank" title="open PDP">👁 PDP</a>'+
(gone
? '<button onclick="doAct(this,'+id+',\\'undo\\')">↩ Undo</button>'
: '<button onclick="doAct(this,'+id+',\\'publish\\')" title="settlement gate enforced server-side">✅ Publish</button>'+
'<button onclick="doAct(this,'+id+',\\'unpublish\\')">🚫 Unpub</button>'+
'<button onclick="doAct(this,'+id+',\\'room\\')" title="~10¢ metered">🛋 Room</button>'+
'<button onclick="doAct(this,'+id+',\\'regen\\')" title="new row, original kept">🔁 Regen</button>'+
'<button onclick="doAct(this,'+id+',\\'tif\\')" title="print master, up to 5 min">🏗 TIF</button>'+
'<button class="danger" onclick="doAct(this,'+id+',\\'remove\\')">🗑</button>')
: (d.created && (Date.now()-d.created) > 30*60*1000
? (d.gate&&d.gate.gate
? '<span style="color:var(--mut);font-size:11px" title="'+esc('Rejected by '+d.gate.gate+' gate'+(d.gate.reason?(': '+d.gate.reason):'')+'. Nothing retries orphans.')+'">✗ '+esc(d.gate.gate)+' gate · no DB row</span>'
: '<span style="color:var(--mut);font-size:11px" title="Generated but never inserted: timeout-killed or gate-skipped before the DB insert. No reason logged (pre-2026-06-09 run). Nothing retries orphans.">orphaned at gen time · no DB row</span>')
: '<span style="color:var(--mut);font-size:11px">awaiting DB row…</span>'))+
'</div></div>';
}).join('');
items.forEach(d=>seen.add(d.file)); first=false;}
/* ── drag-marquee multi-select ──────────────────────────────────────────
Drag across the grid to select 1-to-many cards (like the design-curator).
A plain click still opens the lightbox / fires the card buttons — the
marquee only engages past a 6px drag. Hold Shift to ADD to the current set.
Selection lives in the same sel Set, so live generator ticks that
re-render the grid preserve what was dragged. */
(function(){
const mq=document.createElement('div'); mq.id='marquee';
mq.style.cssText='position:fixed;border:1.5px solid var(--acc,#6cf);background:rgba(108,160,255,.16);'+
'z-index:35;pointer-events:none;display:none;border-radius:3px';
document.body.appendChild(mq);
let sx=0,sy=0,active=false,pending=false,baseSel=new Set(),suppress=false; const TH=6;
function cardsIn(r){const out=[];grid.querySelectorAll('.card[data-id]').forEach(c=>{
const id=+c.getAttribute('data-id'); if(!id)return; const b=c.getBoundingClientRect();
if(b.left<r.right&&b.right>r.left&&b.top<r.bottom&&b.bottom>r.top) out.push(id);}); return out;}
function applyLive(){grid.querySelectorAll('.card[data-id]').forEach(c=>{
const id=+c.getAttribute('data-id'); const on=sel.has(id);
if(on!==c.classList.contains('sel'))c.classList.toggle('sel',on);
const cb=c.querySelector('.pick'); if(cb&&cb.checked!==on)cb.checked=on;}); syncBulk();}
grid.addEventListener('mousedown',e=>{
if(e.button!==0||e.target.closest('button,a,input'))return; // let controls/links/checkboxes work
sx=e.clientX;sy=e.clientY;pending=true;active=false;baseSel=e.shiftKey?new Set(sel):new Set();});
window.addEventListener('mousemove',e=>{
if(!pending)return;
if(!active){if(Math.abs(e.clientX-sx)<TH&&Math.abs(e.clientY-sy)<TH)return;
active=true;mq.style.display='block';document.body.style.userSelect='none';}
e.preventDefault();
const x=Math.min(sx,e.clientX),y=Math.min(sy,e.clientY),w=Math.abs(e.clientX-sx),h=Math.abs(e.clientY-sy);
mq.style.left=x+'px';mq.style.top=y+'px';mq.style.width=w+'px';mq.style.height=h+'px';
sel=new Set(baseSel); cardsIn({left:x,top:y,right:x+w,bottom:y+h}).forEach(id=>sel.add(id)); applyLive();});
window.addEventListener('mouseup',()=>{
if(!pending)return; pending=false;
if(active){active=false;mq.style.display='none';document.body.style.userSelect='';
suppress=true;setTimeout(()=>suppress=false,0);render(last);}});
document.addEventListener('click',e=>{if(suppress){e.stopPropagation();e.preventDefault();}},true);
})();
function lb(src){document.getElementById('lbimg').src=src;document.getElementById('lightbox').style.display='flex';}
async function tick(){
try{
const r=await fetch('/api/recent');const d=await r.json();
const st=document.getElementById('status'),tx=document.getElementById('statustxt'),dot=st.querySelector('.dot');
if(d.live&&d.live.generating){dot.className='dot live';tx.textContent='generating now…';}
else{dot.className='dot idle';tx.textContent='idle (cron armed)';}
document.getElementById('latest').textContent=d.items.length?('latest '+fmt(d.items[0].created)):'—';
render(d.items);
}catch(e){document.getElementById('statustxt').textContent='offline';}
}
tick(); setInterval(tick,4000);
</script></body></html>`;
const server = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
if (u.pathname === '/' || u.pathname === '/index.html') {
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(HTML);
}
if (u.pathname === '/api/recent') {
const items = listDesigns();
const rows = resolveDbRows(items);
const gates = gateReasons();
for (const d of items) {
const row = rows[d.file];
d.dbId = row ? row.id : (idCache.get(d.file) || null);
d.state = row ? (row.removed ? 'gone' : row.published ? 'pub' : 'unpub') : null;
// orphan (no DB row) — attach the recorded gate-rejection reason if we have one
if (!d.dbId && gates[d.file]) d.gate = gates[d.file];
}
res.writeHead(200, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ items, live: liveSignal() }));
}
if (u.pathname === '/override-publish' && req.method === 'POST') {
let raw = '';
req.on('data', (c) => { raw += c; if (raw.length > 4096) req.destroy(); });
req.on('end', () => {
let p;
try { p = JSON.parse(raw || '{}'); } catch { res.writeHead(400); return res.end('{"error":"bad json"}'); }
const id = parseInt(p.id, 10);
const reason = String(p.reason || '').trim();
res.setHeader('content-type', 'application/json');
if (!Number.isFinite(id) || id < 1) { res.writeHead(400); return res.end('{"error":"bad id"}'); }
if (reason.length < 5) { res.writeHead(400); return res.end('{"error":"reason required (min 5 chars) — it goes into the design notes audit trail"}'); }
const r = overridePublish(id, reason);
if (r.status === 0) { res.writeHead(200); return res.end(JSON.stringify({ ok: true, id })); }
res.writeHead(500);
res.end(JSON.stringify({ error: (r.stderr || r.stdout || 'psql failed').trim().slice(-500) }));
});
return;
}
if (u.pathname === '/act' && req.method === 'POST') {
let raw = '';
req.on('data', (c) => { raw += c; if (raw.length > 65536) req.destroy(); });
req.on('end', () => {
let p;
try { p = JSON.parse(raw || '{}'); } catch { res.writeHead(400); return res.end('{"error":"bad json"}'); }
const actPath = String(p.path || '');
const method = p.method === 'GET' ? 'GET' : 'POST';
if (!ACT_WHITELIST.some((rx) => rx.test(actPath))) {
res.writeHead(403, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'path not allowed: ' + actPath }));
}
proxyToWallco(method, actPath, method === 'GET' ? null : (p.body || {}), (err, code, body) => {
if (err) {
res.writeHead(502, { 'content-type': 'application/json' });
return res.end(JSON.stringify({ error: 'wallco-ai unreachable: ' + err.message }));
}
res.writeHead(code, { 'content-type': 'application/json' });
res.end(body || '{}');
});
});
return;
}
if (u.pathname.startsWith('/img/')) {
const f = decodeURIComponent(u.pathname.slice(5));
if (!NAME_RE.test(f)) { res.writeHead(404); return res.end('nope'); } // path-traversal guard
const fp = path.join(GEN_DIR, f);
fs.readFile(fp, (e, buf) => {
if (e) { res.writeHead(404); return res.end('not found'); }
res.writeHead(200, { 'content-type': 'image/png', 'cache-control': 'public, max-age=86400' });
res.end(buf);
});
return;
}
res.writeHead(404); res.end('not found');
});
server.listen(PORT, '127.0.0.1', () => console.log('wallco-live-viewer → http://127.0.0.1:' + PORT + ' (watching ' + GEN_DIR + ', actions → ' + WALLCO.host + ':' + WALLCO.port + ')'));