← 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 →