[object Object]

← back to Wallco Ai

cactus-curator: add Unpublish action (kbd 6) + button

9d534444d5130ae6fff07e758c7dca6f8b5d1bcc · 2026-05-29 09:34:53 -0700 · Steve Abrams

new 5th cactus verdict 'unpublish' — soft take-down that sets
is_published=FALSE + web_viewer=FALSE only. NO quarantine, NO
user_removed flag, NO tagging. Fully reversible via the existing
'live' (Publish) action (will re-gate).

use case (Steve 2026-05-29): the haiku-weekend smoke caught designs
that were published-but-shouldn't-be (#53651 mural-misclass,
#53908 ghost). 'Bad' is too destructive (quarantines PNG), 'Digital'
mis-tags them as etsy-digital. 'Unpublish' is the clean take-down.

server.js: new branch in applyCactusDecision, both single-id +
bulk action validators updated, dropFromIndex condition extended
so the SKU leaves the live in-memory index immediately.

cactus-curator.html: 3 new buttons (per-card, bulk-bar, modal-acts),
slate-gray .unpub color class to distinguish from destructive .bad,
keyboard shortcut '6', optimistic state-patch in both decide() and
bulk(), bulk-confirm dialog. status-pill labels for unpublish action.

orphan node process on Mac2 was holding port 9905 — killed PID 83855
to let pm2 take over with the new code.

verified locally: bogus action returns 'must be bad|digital|fix|live|unpublish';
unpublish on a non-existent id returns 'design not found' (validator
passes, lookup fails as expected).

NOT deployed to prod yet — needs surgical-port per
[[feedback_wallco_ai_mac2_prod_serverjs_drift]] (server.js drift).

Files touched

Diff

commit 9d534444d5130ae6fff07e758c7dca6f8b5d1bcc
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 09:34:53 2026 -0700

    cactus-curator: add Unpublish action (kbd 6) + button
    
    new 5th cactus verdict 'unpublish' — soft take-down that sets
    is_published=FALSE + web_viewer=FALSE only. NO quarantine, NO
    user_removed flag, NO tagging. Fully reversible via the existing
    'live' (Publish) action (will re-gate).
    
    use case (Steve 2026-05-29): the haiku-weekend smoke caught designs
    that were published-but-shouldn't-be (#53651 mural-misclass,
    #53908 ghost). 'Bad' is too destructive (quarantines PNG), 'Digital'
    mis-tags them as etsy-digital. 'Unpublish' is the clean take-down.
    
    server.js: new branch in applyCactusDecision, both single-id +
    bulk action validators updated, dropFromIndex condition extended
    so the SKU leaves the live in-memory index immediately.
    
    cactus-curator.html: 3 new buttons (per-card, bulk-bar, modal-acts),
    slate-gray .unpub color class to distinguish from destructive .bad,
    keyboard shortcut '6', optimistic state-patch in both decide() and
    bulk(), bulk-confirm dialog. status-pill labels for unpublish action.
    
    orphan node process on Mac2 was holding port 9905 — killed PID 83855
    to let pm2 take over with the new code.
    
    verified locally: bogus action returns 'must be bad|digital|fix|live|unpublish';
    unpublish on a non-existent id returns 'design not found' (validator
    passes, lookup fails as expected).
    
    NOT deployed to prod yet — needs surgical-port per
    [[feedback_wallco_ai_mac2_prod_serverjs_drift]] (server.js drift).
---
 public/admin/cactus-curator.html | 46 +++++++++++++++++++++++-----------------
 1 file changed, 27 insertions(+), 19 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 36cf594..a91f998 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -101,11 +101,12 @@
   .acts button { font:600 11px/1.2 inherit; padding:7px 4px; border-radius:7px; border:1px solid var(--line);
                  background:#1d2023; color:var(--ink); cursor:pointer; transition:filter .1s; }
   .acts button:hover { filter:brightness(1.25); }
-  .acts .bad  { border-color:#5a2222; color:#ff9a9a; }
-  .acts .dig  { border-color:#3d2f63; color:#cbb8ff; }
-  .acts .fix  { border-color:#5a4410; color:#ffd98a; }
-  .acts .live { border-color:#1f4d2a; color:#9ff0a6; }
-  .acts .etsy { border-color:#5a3f17; color:#ffb86b; }
+  .acts .bad   { border-color:#5a2222; color:#ff9a9a; }
+  .acts .dig   { border-color:#3d2f63; color:#cbb8ff; }
+  .acts .fix   { border-color:#5a4410; color:#ffd98a; }
+  .acts .live  { border-color:#1f4d2a; color:#9ff0a6; }
+  .acts .etsy  { border-color:#5a3f17; color:#ffb86b; }
+  .acts .unpub { border-color:#3a3f44; color:#a8b3bc; }
   .card.moved-to-etsy { opacity:0; transform:scale(.92); transition:opacity .3s ease, transform .3s ease; }
   .acts button kbd { font:inherit; opacity:.55; }
 
@@ -115,7 +116,7 @@
   #bulk.show { transform:none; }
   #bulk b { color:var(--accent); }
   #bulk button { font:600 13px inherit; padding:8px 14px; border-radius:8px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
-  #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;}
+  #bulk .bad{color:#ff9a9a;} #bulk .dig{color:#cbb8ff;} #bulk .fix{color:#ffd98a;} #bulk .live{color:#9ff0a6;} #bulk .etsy{color:#ffb86b;} #bulk .unpub{color:#a8b3bc;}
   #bulk .clear{margin-left:auto;color:var(--mut);}
   #empty,#err { padding:60px 16px; text-align:center; color:var(--mut); }
   #err { color:#ff9a9a; }
@@ -137,6 +138,7 @@
   .modal-acts button { font:600 12px inherit; padding:8px 13px; border-radius:7px; border:1px solid var(--line); background:#1d2023; color:var(--ink); cursor:pointer; }
   .modal-acts .bad{color:#ff9a9a;border-color:#5a2222;} .modal-acts .dig{color:#cbb8ff;border-color:#3d2f63;}
   .modal-acts .fix{color:#ffd98a;border-color:#5a4410;} .modal-acts .live{color:#9ff0a6;border-color:#1f4d2a;}
+  .modal-acts .etsy{color:#ffb86b;border-color:#5a3f17;} .modal-acts .unpub{color:#a8b3bc;border-color:#3a3f44;}
   .legend { font-size:11px; color:var(--mut); }
   .legend code { background:var(--panel); padding:1px 5px; border-radius:4px; border:1px solid var(--line); color:var(--ink); }
 </style>
@@ -197,6 +199,7 @@
   <button class="fix"  data-a="fix">3 · Needs fixing</button>
   <button class="live" data-a="live">4 · Publish</button>
   <button class="etsy" data-a="etsy">🛒 5 · Etsy bucket</button>
+  <button class="unpub" data-a="unpublish">📴 6 · Unpublish</button>
   <button class="clear" id="bulkclear">clear selection</button>
 </div>
 
@@ -408,11 +411,12 @@ function card(d, idx){
       <div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)}</div>
     </div>
     <div class="acts">
-      <button class="bad"  data-a="bad"     title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
-      <button class="dig"  data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
-      <button class="fix"  data-a="fix"     title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
-      <button class="live" data-a="live"    title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
-      <button class="etsy" data-a="etsy"    title="Send to Etsy bucket (5) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
+      <button class="bad"   data-a="bad"       title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
+      <button class="dig"   data-a="digital"   title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
+      <button class="fix"   data-a="fix"       title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
+      <button class="live"  data-a="live"      title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
+      <button class="etsy"  data-a="etsy"      title="Send to Etsy bucket (5) — removes from new-design pool, queues for digital download listing">🛒 Etsy <kbd>5</kbd></button>
+      <button class="unpub" data-a="unpublish" title="Take down from live catalog (6) — sets is_published=FALSE, reversible via Publish">📴 Unpublish <kbd>6</kbd></button>
     </div>`;
 
   el.querySelector('.sel-box').addEventListener('click', e => { e.stopPropagation(); toggleSel(d.id, el); });
@@ -578,7 +582,7 @@ function toggleSel(id, el){
 async function decide(id, action, el){
   el?.classList.add('decided');
   const st = el?.querySelector('.state');
-  if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'})[action]; st.style.display='block'; }
+  if(st){ st.textContent = ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'})[action]; st.style.display='block'; }
   decided.set(id, action);
   // patch local model so filters/sorts stay consistent without a full reload
   const d = ALL.find(x=>x.id===id);
@@ -588,6 +592,7 @@ async function decide(id, action, el){
     else if(action==='fix'){ d.needs_fixing_at=today(); }
     else if(action==='live'){ d.is_published=true; d.web_viewer=true; d.user_removed=false; d.needs_fixing_at=null; d.digital_file_at=null; }
     else if(action==='etsy'){ d.in_etsy_bucket=true; }
+    else if(action==='unpublish'){ d.is_published=false; d.web_viewer=false; }
   }
   updateStat();
   // Etsy uses its own endpoint (separate table); short-circuit here.
@@ -643,13 +648,14 @@ async function bulk(action){
   const ids = [...selected];
   if(!ids.length) return;
   if(action==='bad' && !confirm(`Remove ${ids.length} designs from all surfaces? (quarantines the PNGs + drops them from the live site)`)) return;
+  if(action==='unpublish' && !confirm(`Unpublish ${ids.length} designs from the live catalog? (sets is_published=FALSE; fully reversible via Publish — no quarantine, no tagging)`)) return;
 
   // ONE batched request — not N un-awaited decide() calls. The old loop fired a
   // fetch per id; each spawned a server-side psql process, so a few-hundred-card
   // bulk-remove became a psql fork storm that timed out and half-applied. The
   // /api/cactus-decision/bulk endpoint now collapses bad/digital/fix to a single
   // SQL UPDATE and drops the removed designs from the live index server-side.
-  const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET'};
+  const labels = {bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED',etsy:'🛒 ETSY BUCKET',unpublish:'📴 UNPUBLISHED'};
   ids.forEach(id=>{ const el=grid.querySelector(`.card[data-id="${id}"]`); el?.classList.add('decided');
     const st=el?.querySelector('.state'); if(st){ st.textContent=labels[action]; st.style.display='block'; } });
 
@@ -669,6 +675,7 @@ async function bulk(action){
       decided.set(id, action);
       if(action==='bad'){      if(d){d.is_published=false;d.user_removed=true;d.web_viewer=false;}  el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
       else if(action==='digital'){ if(d){d.is_published=false;d.web_viewer=false;d.digital_file_at=today();} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
+      else if(action==='unpublish'){ if(d){d.is_published=false;d.web_viewer=false;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
       else if(action==='etsy'){    if(d){d.in_etsy_bucket=true;} el?.classList.add('moved-to-etsy'); setTimeout(()=>el?.remove(),300); }
       else if(action==='fix'){     if(d){d.needs_fixing_at=today();} }
       else if(action==='live'){    if(d){d.is_published=true;d.web_viewer=true;d.user_removed=false;d.needs_fixing_at=null;d.digital_file_at=null;} }
@@ -706,7 +713,7 @@ document.addEventListener('keydown', e => {
   if(e.key==='Escape'){ closeDetail(); return; }
   if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
   if($('modal').classList.contains('show')) return;   // don't fire grid keys behind an open modal
-  const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy'};
+  const map = {'1':'bad','2':'digital','3':'fix','4':'live','5':'etsy','6':'unpublish'};
   if(hovered && map[e.key]){ const el = grid.querySelector(`.card[data-id="${hovered}"]`); decide(hovered, map[e.key], el); }
   else if(hovered && (e.key==='x'||e.key==='X')){ toggleSel(hovered, grid.querySelector(`.card[data-id="${hovered}"]`)); }
 });
@@ -988,11 +995,12 @@ load();
         }).join('')}
       </div>
       <div class="acts">
-        <button class="bad"  data-a="bad"     title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
-        <button class="dig"  data-a="digital" title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
-        <button class="fix"  data-a="fix"     title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
-        <button class="live" data-a="live"    title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
-        <button class="etsy" data-a="etsy"    title="Send to Etsy bucket (5)">🛒 Etsy <kbd>5</kbd></button>
+        <button class="bad"   data-a="bad"       title="Bad pattern — remove from all (1)">✕ Bad <kbd>1</kbd></button>
+        <button class="dig"   data-a="digital"   title="Unpublish, sell as digital file (2)">⬇ Digital <kbd>2</kbd></button>
+        <button class="fix"   data-a="fix"       title="Keep but needs fixing (3)">⚠ Fix <kbd>3</kbd></button>
+        <button class="live"  data-a="live"      title="Publish — go live in the web viewer (4)">✓ Publish <kbd>4</kbd></button>
+        <button class="etsy"  data-a="etsy"      title="Send to Etsy bucket (5)">🛒 Etsy <kbd>5</kbd></button>
+        <button class="unpub" data-a="unpublish" title="Take down from live catalog (6)">📴 Unpublish <kbd>6</kbd></button>
       </div>
     </div>`;
   }

← 77af6a2 feat(contact-sheet): room-setting thumbnails + wallco.ai foo  ·  back to Wallco Ai  ·  audit: unpublish 7 designs caught by haiku-weekend goods-smo 61fa817 →