[object Object]

← back to Wallco Live Viewer

viewer: surface the real gate-rejection reason on orphan cards

8312b956348b8206fdd2231a507ff7e5d23e5e6f · 2026-06-09 13:25:25 -0700 · SteveStudio2

Reads data/logs/gate-rejections.jsonl (mtime-cached), attaches {gate,reason} to
any orphan (no DB row) in /api/recent, and the badge + label now show e.g.
'✗ seamless-tile gate · no DB row' with the reason in the tooltip, instead of
the inferred 'orphaned at gen time'. Adds a client-side esc() (none existed).
Pre-2026-06-09 orphans with no logged reason keep the old label.

Files touched

Diff

commit 8312b956348b8206fdd2231a507ff7e5d23e5e6f
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue Jun 9 13:25:25 2026 -0700

    viewer: surface the real gate-rejection reason on orphan cards
    
    Reads data/logs/gate-rejections.jsonl (mtime-cached), attaches {gate,reason} to
    any orphan (no DB row) in /api/recent, and the badge + label now show e.g.
    '✗ seamless-tile gate · no DB row' with the reason in the tooltip, instead of
    the inferred 'orphaned at gen time'. Adds a client-side esc() (none existed).
    Pre-2026-06-09 orphans with no logged reason keep the old label.
---
 server.js | 71 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 1 file changed, 68 insertions(+), 3 deletions(-)

diff --git a/server.js b/server.js
index 8f1fd13..bc7a1a4 100644
--- a/server.js
+++ b/server.js
@@ -16,6 +16,24 @@ const LOGS = {
 };
 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.
@@ -252,6 +270,7 @@ async function act(path,body,method){
  return d;
 }
 
+function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>';
@@ -260,8 +279,12 @@ function stateBadge(d){
  // 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)
-  return '<span class="state gone" title="Generated but never inserted — failed a quality gate (ghost/seamless/frame-overlay) at generation time. Not waiting on anything.">gate-rejected</span>';
+ 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>';
 }
 
@@ -366,12 +389,51 @@ function render(items){last=items;
             '<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
-          ? '<span style="color:var(--mut);font-size:11px" title="Generated but never inserted: timeout-killed or gate-skipped before the DB insert. Nothing retries orphans.">orphaned at gen time · no DB row</span>'
+          ? (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 you've 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(){
@@ -395,10 +457,13 @@ const server = http.createServer((req, res) => {
   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() }));

← 0bf0b7b badge wording: timeout-orphans not gate-rejects (debugger ve  ·  back to Wallco Live Viewer  ·  feat(live-viewer): drag-marquee multi-select across the grid 69eb692 →