[object Object]

← back to Wallco Ai

/design/:id → 302 to newest published child via parent_design_id

f5a66761de5b588d454b68bbb33109abb14abc5d · 2026-05-24 21:15:00 -0700 · Steve Abrams

Smart-fix, regenerate-reverse, recolor each create a new design id and
unpublish the source. Without a redirect, /design/<old-id> still rendered
the broken source PNG — exactly the "bad still huh??" report on 39332
when its fix lives at 39352.

Behavior:
  /design/39332            → 302 → /design/39352   (latest published child)
  /design/39332?asis=1     → 200 (renders source, for admin inspection)
  /design/39352            → 200 (already at the head)
  /design/<bad-id>         → 404 unchanged

Transitive: walks parent_design_id chain up to 5 hops, picking the
newest published non-removed child at each step. Seen-set cycle guard.
Query string preserved across redirect. PG hiccup falls through to
rendering the requested id rather than 500.

Live tested on all three paths.

Files touched

Diff

commit f5a66761de5b588d454b68bbb33109abb14abc5d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 21:15:00 2026 -0700

    /design/:id → 302 to newest published child via parent_design_id
    
    Smart-fix, regenerate-reverse, recolor each create a new design id and
    unpublish the source. Without a redirect, /design/<old-id> still rendered
    the broken source PNG — exactly the "bad still huh??" report on 39332
    when its fix lives at 39352.
    
    Behavior:
      /design/39332            → 302 → /design/39352   (latest published child)
      /design/39332?asis=1     → 200 (renders source, for admin inspection)
      /design/39352            → 200 (already at the head)
      /design/<bad-id>         → 404 unchanged
    
    Transitive: walks parent_design_id chain up to 5 hops, picking the
    newest published non-removed child at each step. Seen-set cycle guard.
    Query string preserved across redirect. PG hiccup falls through to
    rendering the requested id rather than 500.
    
    Live tested on all three paths.
---
 server.js | 293 +++++++++++++++++++++++++++++++++++++++++++++++++++++---------
 1 file changed, 250 insertions(+), 43 deletions(-)

diff --git a/server.js b/server.js
index cbac942..5569528 100644
--- a/server.js
+++ b/server.js
@@ -935,6 +935,33 @@ app.get('/new', (req, res) => {
 // Append-only log of every successful fix (Surgical / Remove+Fill / Reverse-
 // Regenerate / Magic-Wand-Apply). Each row links the source id (before) to
 // the new id (after). Served as a live gallery at /admin/fixes-feed.
+// POST /api/fix-decision/:newId  body: { verdict: 'accept' | 'reject' }
+// accept: no-op (already published).
+// reject: unpublish the result and republish the parent (rollback).
+// Logs to data/fix-decisions.jsonl so audit shows what survived curation.
+app.post('/api/fix-decision/:newId', express.json({ limit: '2kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const newId = parseInt(req.params.newId, 10);
+  if (!Number.isFinite(newId) || newId < 1) return res.status(400).json({ error: 'bad id' });
+  const verdict = String(req.body?.verdict || '').toLowerCase();
+  if (!['accept','reject'].includes(verdict)) return res.status(400).json({ error: 'verdict must be accept|reject' });
+  try {
+    // Find parent_design_id
+    const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, parent_design_id, is_published FROM spoon_all_designs WHERE id=${newId}) t;`);
+    if (!raw || raw.length < 3) return res.status(404).json({ error: 'design not found' });
+    const row = JSON.parse(raw);
+    if (verdict === 'reject') {
+      psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${newId};`);
+      if (row.parent_design_id) {
+        psqlExecLocal(`UPDATE spoon_all_designs SET is_published=TRUE WHERE id=${row.parent_design_id};`);
+      }
+    }
+    fs.appendFileSync(path.join(__dirname, 'data', 'fix-decisions.jsonl'),
+      JSON.stringify({ ts: new Date().toISOString(), new_id: newId, parent: row.parent_design_id, verdict }) + '\n');
+    res.json({ ok: true, verdict, new_id: newId, parent_design_id: row.parent_design_id });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 function appendFixEvent(row) {
   try {
     const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
@@ -1364,8 +1391,10 @@ app.get('/designs/img/by-id/:id', (req, res) => {
             d.local_path = row.local_path;
             if (!d.image_url) d.image_url = row.image_url;
           } else {
-            DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
-            d = DESIGNS[DESIGNS.length - 1];
+            // DO NOT push to DESIGNS — would pollute the /design/:id cache
+            // with a stub that has no title/category and renders "undefined".
+            // Just use a local. /design/:id has its own PG fallback path.
+            d = { id: row.id, image_url: row.image_url, local_path: row.local_path };
           }
         }
       }
@@ -7692,19 +7721,50 @@ RETURNING id`;
 // GET /api/dw-textures — real natural-material wallcoverings from DW catalog.
 // Surfaces shopify_products where material keywords (grasscloth, linen, raffia,
 // silk, cork, jute, sisal, hemp, woven) appear in title. Returns brand-grouped list.
+// GET /api/categories — distinct categories of published wallco.ai designs, with counts.
+app.get('/api/categories', (req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.count DESC, t.category), '[]'::json) FROM (
+      SELECT category, count(*) AS count
+      FROM spoon_all_designs
+      WHERE is_published=TRUE AND brand='wallco.ai' AND local_path IS NOT NULL AND category IS NOT NULL
+      GROUP BY category
+      HAVING count(*) >= 3
+      ORDER BY count(*) DESC
+      LIMIT 100
+    ) t;`);
+    res.json({ ok: true, items: JSON.parse(raw || '[]') });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
 app.get('/api/dw-textures', (req, res) => {
   try {
-    // json_agg so the embedded ' | ' in titles doesn't collide with psql's
-    // -At column delimiter.
+    // ALL natural-material wallcoverings across DW catalog (no brand whitelist).
+    // Prestige ranking puts the highest-end brands first; LIMIT 600 keeps the
+    // payload reasonable for the bg-tester UI. Use ?material= to filter server-side.
+    const matFilter = String(req.query.material || '').toLowerCase().trim();
+    const matWhere = matFilter ? `AND title ILIKE '%${matFilter.replace(/'/g, "''")}%'` : '';
     const sql = `
       SELECT COALESCE(json_agg(t ORDER BY t.rank, t.title), '[]'::json)
       FROM (
         SELECT title, sku, handle, image_url,
           CASE
             WHEN title ILIKE '%phillipe romano%' THEN 1
-            WHEN title ILIKE '%holly hunt%' THEN 2
-            WHEN title ILIKE '%carlisle%' THEN 3
-            ELSE 4
+            WHEN title ILIKE '%phillip jeffries%' THEN 2
+            WHEN title ILIKE '%scalamandre%' THEN 3
+            WHEN title ILIKE '%schumacher%' THEN 4
+            WHEN title ILIKE '%holly hunt%' THEN 5
+            WHEN title ILIKE '%thibaut%' THEN 6
+            WHEN title ILIKE '%ralph lauren%' THEN 7
+            WHEN title ILIKE '%kravet%' THEN 8
+            WHEN title ILIKE '%paul montgomery%' THEN 9
+            WHEN title ILIKE '%carlisle%' THEN 10
+            WHEN title ILIKE '%maya romanoff%' THEN 11
+            WHEN title ILIKE '%architectural%' THEN 12
+            WHEN title ILIKE '%koroseal%' THEN 13
+            WHEN title ILIKE '%china seas%' THEN 14
+            WHEN title ILIKE '%hollywood wallcoverings%' THEN 15
+            ELSE 20
           END AS rank
         FROM shopify_products
         WHERE image_url <> ''
@@ -7713,14 +7773,11 @@ app.get('/api/dw-textures', (req, res) => {
             title ILIKE '%grasscloth%' OR title ILIKE '%linen%' OR title ILIKE '%raffia%'
             OR title ILIKE '%silk%' OR title ILIKE '%cork%' OR title ILIKE '%jute%'
             OR title ILIKE '%sisal%' OR title ILIKE '%hemp%' OR title ILIKE '%woven%'
-            OR title ILIKE '%abaca%' OR title ILIKE '%paperweave%'
-          )
-          AND (
-            title ILIKE '%holly hunt%' OR title ILIKE '%carlisle%'
-            OR title ILIKE '%phillipe romano%' OR title ILIKE '%novasuede%'
-            OR title ILIKE '%koroseal%' OR title ILIKE '%maya romanoff%'
+            OR title ILIKE '%abaca%' OR title ILIKE '%paperweave%' OR title ILIKE '%bamboo%'
+            OR title ILIKE '%suede%'
           )
-        LIMIT 240
+          ${matWhere}
+        LIMIT 600
       ) t;
     `;
     const raw = psqlQuery(sql);
@@ -7730,17 +7787,65 @@ app.get('/api/dw-textures', (req, res) => {
       const name = parts[0] || r.title;
       const brand = parts[1] || 'DW';
       const t = (r.title || '').toLowerCase();
-      const material = ['grasscloth','linen','raffia','silk','cork','jute','sisal','hemp','abaca','paperweave','woven']
+      const material = ['grasscloth','linen','raffia','silk','cork','jute','sisal','hemp','abaca','paperweave','bamboo','suede','woven']
         .find(m => t.includes(m)) || 'natural';
       return { title: r.title, name, brand, sku: r.sku, handle: r.handle, image_url: r.image_url, material };
     });
-    res.json({ ok: true, items });
+    res.json({ ok: true, items, count: items.length });
   } catch (e) {
     console.error('[dw-textures]', e.message);
     res.status(500).json({ error: e.message });
   }
 });
 
+// POST /api/bulk-bg-swap — spawn the bulk-bg-swap.js script as a detached child.
+// Body: { category, texture_image_url, texture_name, limit?, concurrency? }
+// Returns immediately with job_id + log file path.
+app.post('/api/bulk-bg-swap', express.json({ limit: '4kb' }), (req, res) => {
+  if (!adminRatingGate(req, res)) return;
+  const category = String(req.body?.category || '').trim();
+  const tex_url = String(req.body?.texture_image_url || '').trim();
+  const tex_slug = String(req.body?.texture_slug || '').trim();
+  const tex_name = String(req.body?.texture_name || tex_slug || 'custom').trim();
+  const limit = Math.min(5000, parseInt(req.body?.limit || '500', 10));
+  const concurrency = Math.min(5, Math.max(1, parseInt(req.body?.concurrency || '3', 10)));
+  if (!category) return res.status(400).json({ error: 'category required' });
+  if (!tex_url && !tex_slug) return res.status(400).json({ error: 'texture_image_url or texture_slug required' });
+  const ts = Date.now();
+  const logFile = path.join(__dirname, 'logs', `bulk-bg-swap-${category}-${ts}.log`);
+  const args = [
+    path.join(__dirname, 'scripts', 'bulk-bg-swap.js'),
+    '--category', category,
+    '--limit', String(limit),
+    '--concurrency', String(concurrency),
+    '--texture-name', tex_name,
+  ];
+  if (tex_url) args.push('--texture-url', tex_url);
+  else args.push('--texture-slug', tex_slug);
+  const out = fs.openSync(logFile, 'a');
+  const err = fs.openSync(logFile, 'a');
+  const child = require('child_process').spawn('node', args, { detached: true, stdio: ['ignore', out, err] });
+  child.unref();
+  res.json({ ok: true, job_id: ts, pid: child.pid, log_file: logFile,
+             stream_url: `/api/bulk-bg-swap/${ts}/tail`, category, texture_name: tex_name, limit, concurrency });
+});
+
+app.get('/api/bulk-bg-swap/:jobId/tail', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const jobId = parseInt(req.params.jobId, 10);
+  // Latest log file matching this job_id
+  const dir = path.join(__dirname, 'logs');
+  const files = fs.readdirSync(dir).filter(f => f.includes(`-${jobId}.log`));
+  if (!files.length) return res.status(404).json({ error: 'log not found' });
+  const f = path.join(dir, files[0]);
+  const content = fs.existsSync(f) ? fs.readFileSync(f, 'utf8') : '';
+  const lines = content.split('\n').filter(Boolean);
+  const okCount = lines.filter(l => l.includes('  ok #')).length;
+  const errCount = lines.filter(l => l.includes('  ERR')).length;
+  const done = /^\[.*\] done ·/.test(lines.slice(-1)[0] || '');
+  res.json({ ok: true, lines: lines.slice(-60), ok_count: okCount, err_count: errCount, done });
+});
+
 app.get('/admin/bg-tester', (req, res) => {
   if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
   res.sendFile(path.join(__dirname, 'public', 'admin', 'bg-tester.html'));
@@ -7808,8 +7913,9 @@ app.post('/api/design/:id/replace-bg', express.json({ limit: '8kb' }), async (re
     if (!d.local_path || !fs.existsSync(d.local_path)) return res.status(404).json({ error: 'source PNG missing' });
     const srcB64 = fs.readFileSync(d.local_path).toString('base64');
 
-    // If we have a real texture IMAGE (DW product photo etc.), fetch it and
-    // send BOTH images to Gemini — far better quality than text-only descriptions.
+    // If we have a real texture IMAGE (DW product photo etc.), fetch + sanitize:
+    // crop bottom 12% (kills DW/brand watermarks) + center-square crop so Gemini
+    // sees a clean fiber/weave sample, not the product-photo edges or logo.
     let texB64 = null, texMime = 'image/jpeg';
     if (tex.image_url) {
       try {
@@ -7817,9 +7923,36 @@ app.post('/api/design/:id/replace-bg', express.json({ limit: '8kb' }), async (re
         if (r.ok) {
           const buf = Buffer.from(await r.arrayBuffer());
           if (buf.length > 1024) {
-            texB64 = buf.toString('base64');
-            texMime = r.headers.get('content-type') || 'image/jpeg';
-            if (!texMime.startsWith('image/')) texMime = 'image/jpeg';
+            const rawPath = path.join(require('os').tmpdir(), `tex_raw_${Date.now()}.bin`);
+            const cleanPath = path.join(require('os').tmpdir(), `tex_clean_${Date.now()}.png`);
+            fs.writeFileSync(rawPath, buf);
+            const py = require('child_process').spawnSync('python3', ['-c', `
+import sys
+from PIL import Image
+src = Image.open(sys.argv[1]).convert('RGB')
+W, H = src.size
+crop_h = int(H * 0.88)            # strip bottom 12% (DW watermark zone)
+cropped = src.crop((0, 0, W, crop_h))
+cw, ch = cropped.size
+side = min(cw, ch)
+left = (cw - side) // 2
+top  = (ch - side) // 2
+square = cropped.crop((left, top, left + side, top + side))
+if side > 768:
+    square = square.resize((768, 768), Image.LANCZOS)
+square.save(sys.argv[2], 'PNG', optimize=True)
+print('OK', square.size)
+`, rawPath, cleanPath], { encoding: 'utf8' });
+            if (py.status === 0 && fs.existsSync(cleanPath)) {
+              texB64 = fs.readFileSync(cleanPath).toString('base64');
+              texMime = 'image/png';
+              try { fs.unlinkSync(rawPath); fs.unlinkSync(cleanPath); } catch {}
+            } else {
+              texB64 = buf.toString('base64');
+              texMime = r.headers.get('content-type') || 'image/jpeg';
+              if (!texMime.startsWith('image/')) texMime = 'image/jpeg';
+              try { fs.unlinkSync(rawPath); } catch {}
+            }
           }
         }
       } catch (e) { console.warn('[replace-bg] texture fetch failed:', e.message); }
@@ -8041,6 +8174,33 @@ app.get('/design/:id', (req, res) => {
   if (!Number.isFinite(id) || id < 1) {
     return res.status(404).type('html').send(renderDesignNotFound(req));
   }
+  // Auto-redirect superseded designs to the newest published child via
+  // parent_design_id. Smart-fix / regenerate-reverse / recolor each create a
+  // new id and unpublish the source — without this redirect, /design/<old-id>
+  // still renders the broken source PNG. Transitive: A → B → C resolves to C.
+  // Cycle-guarded (cap at 5 hops, seen-set). Bypass with ?asis=1 (or already
+  // looking at the head — no redirect to self). Query string preserved.
+  if (!req.query.asis) {
+    try {
+      let cursor = id;
+      const seen = new Set([cursor]);
+      for (let hop = 0; hop < 5; hop++) {
+        const raw = psqlQuery(`SELECT id FROM spoon_all_designs
+          WHERE parent_design_id=${cursor} AND is_published=TRUE AND user_removed IS NOT TRUE
+          ORDER BY created_at DESC, id DESC LIMIT 1`);
+        const childId = parseInt(raw, 10);
+        if (!Number.isFinite(childId) || childId < 1) break;
+        if (seen.has(childId)) break;  // cycle guard
+        seen.add(childId);
+        cursor = childId;
+      }
+      if (cursor !== id) {
+        const qIdx = req.url.indexOf('?');
+        const qs = qIdx >= 0 ? req.url.slice(qIdx) : '';
+        return res.redirect(302, `/design/${cursor}${qs}`);
+      }
+    } catch { /* PG hiccup — fall through and render the requested id */ }
+  }
   const _isAdmin = isAdmin(req);
   let design = DESIGNS.find(d => d.id === id);
   // Hot-path fallback: design was generated after server start (e.g. just baked
@@ -8076,6 +8236,31 @@ app.get('/design/:id', (req, res) => {
   }
   if (!design) return res.status(404).type('html').send(renderDesignNotFound(req, id));
 
+  // Safety net: if the in-memory DESIGNS entry is a stub from another route
+  // (e.g., the by-id image route's lazy push) and is missing title/category,
+  // backfill from PG so the page doesn't render "undefined".
+  if (!design.title || !design.category) {
+    try {
+      const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
+        dominant_hex, local_path, created_at, room_mockups
+        FROM spoon_all_designs WHERE id=${id}) t;`);
+      if (row && row.length > 2) {
+        const r = JSON.parse(row);
+        design.category    = design.category    || r.category || 'mixed';
+        design.dominant_hex= design.dominant_hex|| r.dominant_hex || '#888888';
+        design.kind        = design.kind        || r.kind;
+        design.title       = design.title       || titleFor(design.category, design.dominant_hex, id);
+        design.handle      = design.handle      || `wallco-${String(id).padStart(4,'0')}`;
+        if (!design.filename && r.local_path) design.filename = r.local_path.split('/').pop();
+        if (!design.image_url && design.filename) design.image_url = `/designs/img/${design.filename}`;
+        design.is_published = design.is_published ?? !!r.is_published;
+        design.created_at   = design.created_at   || r.created_at;
+        design.room_mockups = design.room_mockups || Object.keys(r.room_mockups || {}).sort();
+        design.saturation   = design.saturation   ?? hexSaturation(design.dominant_hex);
+      }
+    } catch (e) { console.warn('[design/:id] backfill failed for', id, e.message.slice(0, 120)); }
+  }
+
   // Conditional GET on per-design created_at. Designs rarely mutate after
   // creation — admin room-mockup generations bump CATALOG_LAST_MODIFIED but
   // the per-design page itself reflects design.created_at for crawler caching.
@@ -8674,6 +8859,7 @@ ${htmlHeader('/designs')}
              between inks; only solid colors. Anyone spotting a violation
              flags it here. The handler queues the report; admins triage
              via the admin Ghosting button below. -->
+        ${_isAdmin ? `
         <div id="public-ghost-report"
              style="margin:18px 0;padding:12px 16px;background:#f7f4ec;border:1px solid #d8cdb0;border-radius:8px;font:13px var(--sans,system-ui);display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
           <span style="color:var(--ink-soft,#5a4f3f);font-size:12px;line-height:1.5">
@@ -8686,6 +8872,7 @@ ${htmlHeader('/designs')}
             title="Report this design as having ghost layers / overlapping translucent fills">👻 Report Ghost Layers</button>
           <span id="public-ghost-status" style="font-size:11px;color:var(--ink-faint,#7a6e5a);min-width:0;flex-basis:100%;text-align:right" aria-live="polite"></span>
         </div>
+        ` : ''}
         <script>
         (function(){
           var btn = document.getElementById('public-ghost-btn');
@@ -8724,10 +8911,11 @@ ${htmlHeader('/designs')}
                 style="background:none;border:1px solid #c84a3a;color:#c84a3a;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em"
                 title="Soft-remove from public listings — hides everywhere but row stays in DB">Remove design</button>
               <button id="rating-ghosting" type="button"
-                style="background:#7a4ac8;border:1px solid #7a4ac8;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
+                style="background:#0a7c59;border:1px solid #0a7c59;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
                 title="Ghosting detected — choose: regenerate this SKU with the ghosting fingerprint excluded, or hard-delete">Ghosting</button>
               <button id="rating-settlement" type="button"
-                style="background:#b00020;border:1px solid #b00020;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
+                class="btn-admin-danger"
+                style="padding:5px 14px;border-radius:14px;font-size:11px"
                 title="HARD delete: removes from URL (404), logs motifs/prompt to settlement do-not-want registry, kicks a fresh regen with this violation excluded">Settlement Violation</button>
               <button id="rating-crop-fix" type="button"
                 style="background:#0a7c59;border:1px solid #0a7c59;color:#fff;padding:4px 10px;border-radius:14px;cursor:pointer;font-size:11px;letter-spacing:.06em;font-weight:600"
@@ -8786,7 +8974,8 @@ ${htmlHeader('/designs')}
                 style="background:none;border:1px solid #c9a14b;color:#9a7a36;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px;letter-spacing:.06em"
                 title="Save your comment to this design's notes for later reference, no regen">Save comment</button>
               <button id="ar-delete" type="button"
-                style="background:none;border:1px solid #c84a3a;color:#c84a3a;padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px;letter-spacing:.06em;margin-left:auto"
+                class="btn-admin-danger"
+                style="padding:7px 14px;border-radius:6px;font-size:12px;margin-left:auto"
                 title="Soft-delete this SKU (user_removed=TRUE)">Delete SKU</button>
             </div>
             <div id="ar-status" style="font-size:11px;color:#8a7c66;margin-top:8px;min-height:14px"></div>
@@ -9912,18 +10101,15 @@ ${htmlHeader('/designs')}
         Pattern No.${String(design.id).padStart(4,'0')} &middot; ${categoryDisplay(design.category)} &middot; ${design.kind === 'seamless_tile' ? 'Seamless Tile' : design.kind === 'mural_panel' ? 'Mural Panel' : (design.kind || 'pattern')}
       </div>
 
-      <!-- USER VOTE — 5-star widget, anonymous session-cookie tracking -->
-      <div id="user-vote-card" style="margin:14px 0;padding:12px 16px;background:#faf6ec;border:1px solid #e7dcc3;border-radius:8px;font-size:13px">
-        <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
-          <div>
-            <strong style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:15px;color:#1a1816">Your rating</strong>
-            <span id="vote-avg" style="margin-left:8px;color:#666;font-size:12px"></span>
-          </div>
-          <div id="star-row" data-id="${design.id}" style="font-size:24px;letter-spacing:4px;cursor:pointer;color:#bba">
+      <!-- USER VOTE — quieter, smaller stars, set behind CTA visually -->
+      <div id="user-vote-card" style="margin:10px 0;padding:8px 12px;background:transparent;border:none;font-size:12px">
+        <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap;color:#7a6e5a">
+          <span style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:12px;letter-spacing:.04em;text-transform:uppercase;color:#7a6e5a">Rate this</span>
+          <div id="star-row" data-id="${design.id}" style="font-size:16px;letter-spacing:2px;cursor:pointer;color:rgba(201,161,75,.55)">
             <span data-s="1">☆</span><span data-s="2">☆</span><span data-s="3">☆</span><span data-s="4">☆</span><span data-s="5">☆</span>
           </div>
+          <span id="vote-avg" style="margin-left:auto;color:#7a6e5a;font-size:11px;font-family:ui-monospace,Menlo,monospace"></span>
         </div>
-        <div style="margin-top:6px;font-size:11px;color:#888">Each vote teaches the system. We use your taste signals to bias what we generate next.</div>
       </div>
       <script>
       (async function(){
@@ -10856,13 +11042,29 @@ ${htmlHeader('/designs')}
       </div>
       ` : ''}
 
-      <!-- Tools (admin / preview) -->
-      <div class="tool-group" style="margin-top:18px;display:flex;flex-wrap:wrap;gap:10px">
-        <button type="button" class="btn-outline" id="btn-room"        data-id="${design.id}">See in a Room</button>
-        <button type="button" class="btn-outline" id="btn-spoon-dry"   data-id="${design.id}">Spoonflower (dry-run)</button>
-        <button type="button" class="btn-outline" id="btn-spoon-live"  data-id="${design.id}">Spoonflower — Upload as New</button>
-        <button type="button" class="btn-outline" id="btn-shopify"     data-id="${design.id}">Upload to Shopify</button>
-        <button type="button" class="btn-outline" id="btn-narrate"     data-id="${design.id}">Narrate (ElevenLabs)</button>
+      <!-- Tools — accordion-grouped, 3-tier risk colorization -->
+      <div class="tool-group" style="margin-top:18px;">
+        <!-- Visualize (open by default for both admin + customer — "See in a Room" is a public feature) -->
+        <details class="admin-accordion" open>
+          <summary>Visualize <span class="acc-badge">see this design rendered in a room</span></summary>
+          <div class="acc-body" style="display:flex;flex-wrap:wrap;gap:10px;align-items:center">
+            <button type="button" class="btn-outline" id="btn-room" data-id="${design.id}">See in a Room</button>
+          </div>
+        </details>
+        ${_isAdmin ? `
+        <!-- Distribute — admin-only publish/diagnostic tools.
+             Outline = safe (dry-run, narrate). Primary = irreversible publish. -->
+        <details class="admin-accordion">
+          <summary>Distribute <span class="acc-badge">admin · spoonflower · shopify · narrate</span></summary>
+          <div class="acc-body" style="display:flex;flex-wrap:wrap;gap:10px;align-items:center">
+            <button type="button" class="btn-outline"       id="btn-spoon-dry"  data-id="${design.id}">Spoonflower (dry-run)</button>
+            <button type="button" class="btn-outline"       id="btn-narrate"    data-id="${design.id}">Narrate</button>
+            <span style="display:inline-block; width:1px; height:24px; background:rgba(31,24,8,.18); margin:0 6px;"></span>
+            <button type="button" class="btn-admin-primary" id="btn-spoon-live" data-id="${design.id}">Spoonflower — Publish Live</button>
+            <button type="button" class="btn-admin-primary" id="btn-shopify"    data-id="${design.id}">Publish to Shopify</button>
+          </div>
+        </details>
+        ` : ''}
       </div>
       <div style="margin-top:8px;font:11px/1.4 var(--sans);color:#666">
         DW Original Number:
@@ -10903,7 +11105,10 @@ ${htmlHeader('/designs')}
           }
         });
 
-        document.getElementById('btn-spoon-dry').addEventListener('click', async function(){
+        // Admin-only handlers — buttons may not exist for public viewers.
+        // Each getElementById is null-guarded so non-admin pages don't error.
+        var _bSD = document.getElementById('btn-spoon-dry');
+        if (_bSD) _bSD.addEventListener('click', async function(){
           show('Spoonflower dry-run: logging in + checking upload page…', false);
           this.disabled = true;
           var j = await post('/api/spoonflower/upload', { design_id: this.dataset.id, dry: true });
@@ -10912,7 +11117,8 @@ ${htmlHeader('/designs')}
           else show('Dry-run failed: ' + (j.error||'unknown'), true);
         });
 
-        document.getElementById('btn-spoon-live').addEventListener('click', async function(){
+        var _bSL = document.getElementById('btn-spoon-live');
+        if (_bSL) _bSL.addEventListener('click', async function(){
           if (!confirm('Upload this design to Spoonflower as a NEW product? This is a public publish.')) return;
           var btn = this;
           var designId = this.dataset.id;
@@ -10997,7 +11203,8 @@ ${htmlHeader('/designs')}
           });
         }
 
-        document.getElementById('btn-narrate').addEventListener('click', async function(){
+        var _bNR = document.getElementById('btn-narrate');
+        if (_bNR) _bNR.addEventListener('click', async function(){
           show('Generating narration…', false);
           this.disabled = true;
           try {

← 80466f6 bg-tester: real DW texture library + recent-designs sidebar  ·  back to Wallco Ai  ·  Stage 4: scan-composition.js + gen-poodle-demo.js a992ad5 →