[object Object]

← back to Wallco Ai

feat(contact-sheet): room-setting thumbnails + wallco.ai footer + 8-col cap

77af6a2f12177b4cdd27ee9e5004e255a515126a · 2026-05-29 09:28:17 -0700 · Steve Abrams

loadChain tries the living-room mockup first, falls back to the flat tile for
designs with no room render. Footer bar with centered wallco.ai wordmark + count.
Column cap 6 to 8 for wider sheets.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 77af6a2f12177b4cdd27ee9e5004e255a515126a
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri May 29 09:28:17 2026 -0700

    feat(contact-sheet): room-setting thumbnails + wallco.ai footer + 8-col cap
    
    loadChain tries the living-room mockup first, falls back to the flat tile for
    designs with no room render. Footer bar with centered wallco.ai wordmark + count.
    Column cap 6 to 8 for wider sheets.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 server.js | 28 ++++++++++++++++++++++------
 1 file changed, 22 insertions(+), 6 deletions(-)

diff --git a/server.js b/server.js
index 873a7ca..2ef6e00 100644
--- a/server.js
+++ b/server.js
@@ -1960,7 +1960,7 @@ function dropFromIndex(ids) {
   return n;
 }
 
-// Apply one cactus verdict. body { action: 'bad'|'digital'|'fix'|'live' }.
+// Apply one cactus verdict. body { action: 'bad'|'digital'|'fix'|'live'|'unpublish' }.
 function applyCactusDecision(id, action, opts = {}) {
   const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, local_path FROM all_designs WHERE id=${id}) t;`);
   if (!raw) throw new Error('design not found');
@@ -1990,6 +1990,17 @@ function applyCactusDecision(id, action, opts = {}) {
       SET needs_fixing_at=now(),
           tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing') || ARRAY['needs-fixing']::text[]
       WHERE id=${id};`);
+  } else if (action === 'unpublish') {
+    // 3b) Plain unpublish — soft take-down. Sets is_published=FALSE + web_viewer=FALSE
+    // but DOES NOT quarantine the PNG (unlike 'bad'), DOES NOT tag user_removed,
+    // DOES NOT tag digital-file. Fully reversible via the 'live' (Publish) action
+    // (which will re-gate). Use case: a published design slipped through the gate
+    // (e.g. Haiku-vision catches a ghost or mural-misclass after publish) and
+    // needs to come down now without prejudice. Steve 2026-05-29.
+    psqlExecLocal(`UPDATE all_designs
+      SET is_published=FALSE, web_viewer=FALSE
+      WHERE id=${id};`);
+    dropFromIndex([id]);   // SKU leaves the live public index immediately
   } else if (action === 'live') {
     // 4) Keep, go live in a web viewer — but ONLY through the 100% publish gate
     // (Steve "needs to be 100%"). When the gate fails WITHOUT force, do NOT throw
@@ -2030,8 +2041,8 @@ app.post('/api/cactus-decision/:id', express.json({ limit: '2kb' }), (req, res,
   const id = parseInt(req.params.id, 10);
   if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
   const action = String(req.body?.action || '').toLowerCase();
-  if (!['bad', 'digital', 'fix', 'live'].includes(action)) {
-    return res.status(400).json({ error: 'action must be bad|digital|fix|live' });
+  if (!['bad', 'digital', 'fix', 'live', 'unpublish'].includes(action)) {
+    return res.status(400).json({ error: 'action must be bad|digital|fix|live|unpublish' });
   }
   try {
     const r = applyCactusDecision(id, action, { force: req.body?.force === true });
@@ -2062,7 +2073,7 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '256kb' }), (req, re
   const ids = Array.isArray(req.body?.ids) ? req.body.ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
   const action = String(req.body?.action || '').toLowerCase();
   if (!ids.length) return res.status(400).json({ error: 'ids[] required' });
-  if (!['bad', 'digital', 'fix', 'live'].includes(action)) return res.status(400).json({ error: 'bad action' });
+  if (!['bad', 'digital', 'fix', 'live', 'unpublish'].includes(action)) return res.status(400).json({ error: 'bad action' });
 
   // 'live' — per-id, because each design runs its own publish gate.
   if (action === 'live') {
@@ -2106,10 +2117,15 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '256kb' }), (req, re
         SET needs_fixing_at=now(),
             tags = array_remove(COALESCE(tags, ARRAY[]::text[]), 'needs-fixing') || ARRAY['needs-fixing']::text[]
         WHERE id IN (${idList});`);
+    } else if (action === 'unpublish') {
+      // Soft unpublish — no quarantine, no tagging, fully reversible via Publish.
+      psqlExecLocal(`UPDATE all_designs
+        SET is_published=FALSE, web_viewer=FALSE
+        WHERE id IN (${idList});`);
     }
-    // bad + digital both unpublish → drop their SKUs from the live index now.
+    // bad + digital + unpublish all flip is_published off → drop their SKUs from the live index now.
     // 'fix' leaves publish state untouched, so it stays in the catalog.
-    const dropped = (action === 'bad' || action === 'digital') ? dropFromIndex(ids) : 0;
+    const dropped = (action === 'bad' || action === 'digital' || action === 'unpublish') ? dropFromIndex(ids) : 0;
     // Append one decision-log line per id (append-only audit trail).
     fs.appendFileSync(path.join(__dirname, 'data', 'cactus-decisions.jsonl'),
       ids.map(id => JSON.stringify({ ts: new Date().toISOString(), id, action })).join('\n') + '\n');

← 3778fde fix(cactus-curator): batch bulk-delete + drop removed SKUs f  ·  back to Wallco Ai  ·  cactus-curator: add Unpublish action (kbd 6) + button 9d53444 →