[object Object]

← back to Wallco Ai

user-feedback: rate panel + soft-remove + 150 dpi spec

d5cb7654fac59613d4a093c0320c9aaa83ba9593 · 2026-05-13 10:44:26 -0700 · SteveStudio2

Per-design rating UI on /design/:id — feeds prompt tuning + curation.

DB additions to spoon_all_designs:
  user_removed       BOOLEAN DEFAULT FALSE
  user_color_good    BOOLEAN
  user_style_good    BOOLEAN
  user_scale_good    BOOLEAN
  user_stars         SMALLINT CHECK (1..5)
  user_rated_at      TIMESTAMPTZ
  + partial index on user_removed=TRUE

Endpoints (localhost-admin):
  POST /api/design/:id/rate {color_good?, style_good?, scale_good?, stars?}
    — each axis tri-state (true / false / null=unset). Mirrors writes
      back into in-memory DESIGNS so grid sort sees changes immediately.
  POST /api/design/:id/remove [?undo=1]
    — soft-remove. Splices out of DESIGNS so grid + sort hide it
      immediately. ?undo=1 puts it back.

UI on /design/:id detail page (above scale-preview):
  Color combo [Good][No] · Style [Good][No] · Scale [Good][No] · ★★★★★
  + Remove design (confirm)
  Buttons toggle state (clicking the active choice clears it). Stars
  click-to-set; click same star to clear. Status pill on the right
  shows saving/saved/error.

Hot-path /design/:id PG fallback SELECT now includes user_* cols so
freshly-baked designs surface their state without a snapshot refresh.

loadDesigns() filters out user_removed so the grid never shows them.
refresh_designs_snapshot.py mirrors user_* cols.

Print spec: mural-scenic moved from 300 dpi → 150 dpi (per Steve).
Hero block updated to read '20ft × 11ft · 150 dpi · five-color'.

Smoke-tested: design #3203 rate set+persisted, remove+undo round-trip ok.

Files touched

Diff

commit d5cb7654fac59613d4a093c0320c9aaa83ba9593
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Wed May 13 10:44:26 2026 -0700

    user-feedback: rate panel + soft-remove + 150 dpi spec
    
    Per-design rating UI on /design/:id — feeds prompt tuning + curation.
    
    DB additions to spoon_all_designs:
      user_removed       BOOLEAN DEFAULT FALSE
      user_color_good    BOOLEAN
      user_style_good    BOOLEAN
      user_scale_good    BOOLEAN
      user_stars         SMALLINT CHECK (1..5)
      user_rated_at      TIMESTAMPTZ
      + partial index on user_removed=TRUE
    
    Endpoints (localhost-admin):
      POST /api/design/:id/rate {color_good?, style_good?, scale_good?, stars?}
        — each axis tri-state (true / false / null=unset). Mirrors writes
          back into in-memory DESIGNS so grid sort sees changes immediately.
      POST /api/design/:id/remove [?undo=1]
        — soft-remove. Splices out of DESIGNS so grid + sort hide it
          immediately. ?undo=1 puts it back.
    
    UI on /design/:id detail page (above scale-preview):
      Color combo [Good][No] · Style [Good][No] · Scale [Good][No] · ★★★★★
      + Remove design (confirm)
      Buttons toggle state (clicking the active choice clears it). Stars
      click-to-set; click same star to clear. Status pill on the right
      shows saving/saved/error.
    
    Hot-path /design/:id PG fallback SELECT now includes user_* cols so
    freshly-baked designs surface their state without a snapshot refresh.
    
    loadDesigns() filters out user_removed so the grid never shows them.
    refresh_designs_snapshot.py mirrors user_* cols.
    
    Print spec: mural-scenic moved from 300 dpi → 150 dpi (per Steve).
    Hero block updated to read '20ft × 11ft · 150 dpi · five-color'.
    
    Smoke-tested: design #3203 rate set+persisted, remove+undo round-trip ok.
---
 scripts/mural_scenic_batch.js       |   9 +-
 scripts/refresh_designs_snapshot.py |  10 +-
 server.js                           | 209 ++++++++++++++++++++++++++++++++++--
 3 files changed, 216 insertions(+), 12 deletions(-)

diff --git a/scripts/mural_scenic_batch.js b/scripts/mural_scenic_batch.js
index 416c4ea..08d3aac 100644
--- a/scripts/mural_scenic_batch.js
+++ b/scripts/mural_scenic_batch.js
@@ -6,11 +6,10 @@
  * exact match for the 20ft wide × 11ft tall print spec). Stored in PG with
  *   kind='mural', width_in=20, height_in=11, category='mural-scenic'.
  *
- * SDXL output is 1408×768 = ~1.1 megapixels. For the 300dpi 20ft×11ft print
- * spec (72,000×39,600px), a downstream Real-ESRGAN x4 upscale (then a
- * second pass) is needed — flagged in the design's `notes`. The point of
- * THIS step is to nail the art direction; print-grade upscaling comes after
- * Steve picks favorites.
+ * SDXL output is 1408×768 = ~1.1 megapixels. For the 150dpi 20ft×11ft print
+ * spec (36,000×19,800px), a downstream Real-ESRGAN x4 + x4 upscale is needed.
+ * The point of THIS step is to nail the art direction; print-grade upscaling
+ * comes after Steve picks favorites.
  *
  * Usage:  node scripts/mural_scenic_batch.js [N]   (default 30)
  *         REPLICATE_WIDTH/REPLICATE_HEIGHT set inline.
diff --git a/scripts/refresh_designs_snapshot.py b/scripts/refresh_designs_snapshot.py
index 131b79c..980b8cc 100644
--- a/scripts/refresh_designs_snapshot.py
+++ b/scripts/refresh_designs_snapshot.py
@@ -73,7 +73,8 @@ def title_for(cat, hex_, id_):
 rows = psql_json(
     "SELECT COALESCE(json_agg(t),'[]'::json) FROM ("
     "SELECT id, kind, category, dominant_hex, prompt, generator, seed, local_path, "
-    "is_published, created_at, motifs, room_mockups "
+    "is_published, created_at, motifs, room_mockups, "
+    "user_removed, user_color_good, user_style_good, user_scale_good, user_stars "
     "FROM spoon_all_designs WHERE generator='replicate' ORDER BY id) t;"
 )
 
@@ -96,6 +97,13 @@ for r in rows:
         "is_published": bool(r['is_published']),
         "created_at": r['created_at'],
         "motifs": r.get('motifs') or [],
+        # User-feedback fields — used by sorts (highest-stars-first), grid
+        # badges, and the rating UI on /design/:id.
+        "user_removed":    bool(r.get('user_removed')),
+        "user_color_good": r.get('user_color_good'),
+        "user_style_good": r.get('user_style_good'),
+        "user_scale_good": r.get('user_scale_good'),
+        "user_stars":      r.get('user_stars'),
         # Render-ready URL list — server-side disk paths flipped to /designs/room/:id/:room.
         # Snapshot only stores room types; server resolves the file from disk at request time.
         "room_mockups": sorted(list((r.get('room_mockups') or {}).keys()))
diff --git a/server.js b/server.js
index 7013e07..7023e1a 100644
--- a/server.js
+++ b/server.js
@@ -193,8 +193,9 @@ let DESIGNS = [];
 let CATALOG_LAST_MODIFIED = new Date();  // freshest design.created_at across the catalog
 function loadDesigns() {
   try {
-    DESIGNS = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
-    // Track max created_at so /designs etc. can emit a useful Last-Modified.
+    const raw = JSON.parse(fs.readFileSync(DATA_FILE, 'utf8'));
+    // Hide soft-removed designs from all listings/sorts.
+    DESIGNS = raw.filter(d => !d.user_removed);
     let max = 0;
     for (const d of DESIGNS) {
       const t = Date.parse(d.created_at || '');
@@ -729,6 +730,75 @@ app.post('/admin/reload-designs', (req, res) => {
   res.json({ ok: true, before, after: DESIGNS.length, catalog_last_modified: CATALOG_LAST_MODIFIED, by_color_cache_ver: _byColorCacheVer });
 });
 
+// ── User feedback endpoints — color/style/scale yes-or-no toggles, 1-5
+// star rating, and soft-remove. Persists to spoon_all_designs.user_*
+// columns (added 2026-05-13). Feedback fuels future prompt-tuning so the
+// LLM gets better at what we actually want.
+//
+// POST /api/design/:id/rate  body: { color_good?, style_good?, scale_good?, stars? }
+//   Localhost admin gate. Each field is optional — only what's sent gets
+//   updated. Boolean fields accept true/false/null (null = unset).
+function psqlExecLocal(sql) {
+  const { spawnSync } = require('child_process');
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-c', sql], { encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || 'psql failed');
+  return r.stdout.trim();
+}
+app.post('/api/design/:id/rate', express.json({ limit: '4kb' }), (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const b = req.body || {};
+  const sets = [];
+  function setBool(field, val) {
+    if (val === null) sets.push(`${field}=NULL`);
+    else if (val === true || val === false) sets.push(`${field}=${val}`);
+  }
+  setBool('user_color_good', b.color_good);
+  setBool('user_style_good', b.style_good);
+  setBool('user_scale_good', b.scale_good);
+  if (b.stars === null) sets.push('user_stars=NULL');
+  else if (Number.isInteger(b.stars) && b.stars >= 1 && b.stars <= 5) sets.push(`user_stars=${b.stars}`);
+  if (!sets.length) return res.status(400).json({ error: 'nothing to update' });
+  sets.push('user_rated_at=now()');
+  try {
+    psqlExecLocal(`UPDATE spoon_all_designs SET ${sets.join(', ')} WHERE id=${id} RETURNING id;`);
+    // Mirror back into the in-memory DESIGNS so the grid sort sees it
+    // immediately (no full snapshot refresh needed).
+    const d = DESIGNS.find(x => x.id === id);
+    if (d) {
+      if (b.color_good !== undefined) d.user_color_good = b.color_good;
+      if (b.style_good !== undefined) d.user_style_good = b.style_good;
+      if (b.scale_good !== undefined) d.user_scale_good = b.scale_good;
+      if (b.stars      !== undefined) d.user_stars      = b.stars;
+    }
+    res.json({ ok: true, id });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// POST /api/design/:id/remove  — soft-remove a design from public listings.
+// Sets user_removed=true. Reverse with ?undo=1.
+app.post('/api/design/:id/remove', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  const id = parseInt(req.params.id, 10);
+  if (!Number.isFinite(id) || id < 1) return res.status(400).json({ error: 'bad id' });
+  const undo = req.query.undo === '1' || req.query.undo === 'true';
+  const val = undo ? 'FALSE' : 'TRUE';
+  try {
+    psqlExecLocal(`UPDATE spoon_all_designs SET user_removed=${val} WHERE id=${id};`);
+    // Remove from / re-add to the in-memory list. Easiest path: filter.
+    if (!undo) {
+      const idx = DESIGNS.findIndex(x => x.id === id);
+      if (idx >= 0) DESIGNS.splice(idx, 1);
+    }
+    res.json({ ok: true, id, removed: !undo });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── TRADE LOGIN (separate from admin layer) — magic-link, no passwords.
 // Sister cookie to dw_auth; different name (`wallco_trade_session`), different
 // table (wallco_trade_users), so admin and trade tiers never collide.
@@ -2735,9 +2805,9 @@ ${cat === 'mural-animals' ? `
 ${cat === 'mural-scenic' ? `
 <section style="padding:56px 24px 32px;text-align:center;background:linear-gradient(180deg,#ece4d4 0%,#dfd1ba 50%,#c5b298 100%);color:#2a1f10;margin-bottom:0;border-bottom:1px solid #b59a72">
   <div style="max-width:780px;margin:0 auto">
-    <p style="font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:#7a5e34;margin:0 0 10px;font-weight:500">Bespoke scenic mural · 20ft × 11ft · five-color</p>
+    <p style="font-size:11px;letter-spacing:.32em;text-transform:uppercase;color:#7a5e34;margin:0 0 10px;font-weight:500">Bespoke scenic mural · 20ft × 11ft · 150 dpi · five-color</p>
     <h1 style="font-family:'Playfair Display','Didot',serif;font-weight:400;font-style:italic;font-size:clamp(36px,5vw,60px);line-height:1.05;margin:0 0 16px;color:#1f1808">The Scenic Panoramas</h1>
-    <p style="font-size:15px;line-height:1.7;color:#4a3520;margin:0 auto;max-width:600px">Full-room wall murals in the tradition of de Gournay, Iksel, Gracie, and Zuber et Cie. Single uninterrupted scenes — French formal gardens, Chinese chinoiserie valleys, English wildflower hills, Provence lavender vistas — animals as small naturalistic accents. 20 ft wide × 11 ft tall, target print 300 dpi after upscaling.</p>
+    <p style="font-size:15px;line-height:1.7;color:#4a3520;margin:0 auto;max-width:600px">Full-room wall murals in the tradition of de Gournay, Iksel, Gracie, and Zuber et Cie. Single uninterrupted scenes — French formal gardens, Chinese chinoiserie valleys, English wildflower hills, Provence lavender vistas — animals as small naturalistic accents. 20 ft wide × 11 ft tall, target print 150 dpi after upscaling.</p>
     <div style="margin-top:20px;display:flex;gap:10px;justify-content:center;flex-wrap:wrap;font-size:12px">
       <span style="padding:4px 12px;border:1px solid #7a5e34;border-radius:14px;color:#7a5e34"><strong>${total}</strong> panoramic panels</span>
       <span style="padding:4px 12px;border:1px solid #8a6f44;border-radius:14px;color:#8a6f44">single-panel, no repeat</span>
@@ -4402,7 +4472,8 @@ app.get('/design/:id', (req, res) => {
   if (!design) {
     try {
       const row = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, kind, category,
-        dominant_hex, prompt, seed, local_path, is_published, created_at, notes, room_mockups
+        dominant_hex, prompt, seed, local_path, is_published, created_at, notes, room_mockups,
+        user_color_good, user_style_good, user_scale_good, user_stars, user_removed
         FROM spoon_all_designs WHERE id=${id}) t;`);
       if (row) {
         const r = JSON.parse(row);
@@ -4417,7 +4488,10 @@ app.get('/design/:id', (req, res) => {
           generator: 'wallco.ai', seed: r.seed,
           is_published: !!r.is_published, created_at: r.created_at,
           notes: r.notes || null,
-          room_mockups: Object.keys(r.room_mockups || {}).sort()
+          room_mockups: Object.keys(r.room_mockups || {}).sort(),
+          user_color_good: r.user_color_good, user_style_good: r.user_style_good,
+          user_scale_good: r.user_scale_good, user_stars: r.user_stars,
+          user_removed: !!r.user_removed,
         };
         DESIGNS.push(design);  // lazy-extend so next request is fast
       }
@@ -4690,6 +4764,129 @@ ${htmlHeader('/designs')}
         <img src="${design.image_url}" alt="${design.title}" class="detail-img" id="detail-img" loading="eager" crossorigin="anonymous">
         <canvas id="detail-canvas" style="display:none"></canvas>
 
+        <!-- ── USER RATING PANEL ─────────────────────────────────────────
+             Feeds spoon_all_designs.user_color_good / style_good / scale_good /
+             stars / removed. Localhost-admin auto-allowed. Aggregated counts
+             will tune future prompt generation. -->
+        <div id="rating-panel" data-design-id="${design.id}"
+             style="margin:18px 0;padding:14px 16px;background:var(--card-bg,#f4f0e8);border:1px solid var(--line,#d8d0c0);border-radius:8px;font:13px var(--sans,system-ui)">
+          <div style="display:flex;flex-wrap:wrap;gap:14px;align-items:center;justify-content:space-between">
+            <strong style="font-size:11px;letter-spacing:.18em;text-transform:uppercase;color:var(--ink-faint,#7a6e5a)">Rate this design</strong>
+            <button id="rating-remove" type="button"
+              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>
+          </div>
+          <div style="display:flex;gap:18px;flex-wrap:wrap;margin-top:12px;align-items:center">
+            <div class="rate-axis" data-axis="color_good">
+              <span class="rate-label">Color combo</span>
+              <button class="rate-btn rate-yes" data-val="true">Good</button>
+              <button class="rate-btn rate-no"  data-val="false">No</button>
+            </div>
+            <div class="rate-axis" data-axis="style_good">
+              <span class="rate-label">Style</span>
+              <button class="rate-btn rate-yes" data-val="true">Good</button>
+              <button class="rate-btn rate-no"  data-val="false">No</button>
+            </div>
+            <div class="rate-axis" data-axis="scale_good">
+              <span class="rate-label">Scale</span>
+              <button class="rate-btn rate-yes" data-val="true">Good</button>
+              <button class="rate-btn rate-no"  data-val="false">No</button>
+            </div>
+            <div class="rate-axis" data-axis="stars" style="display:flex;flex-direction:column;gap:4px">
+              <span class="rate-label">Stars</span>
+              <span class="rate-stars" id="rate-stars">
+                ${[1,2,3,4,5].map(n => `<button class="rate-star" data-val="${n}" type="button" title="${n} star${n>1?'s':''}">★</button>`).join('')}
+              </span>
+            </div>
+            <span id="rating-status" style="font-size:11px;color:var(--ink-faint,#888);margin-left:auto"></span>
+          </div>
+        </div>
+        <style>
+          #rating-panel .rate-axis { display:inline-flex; flex-direction:column; gap:5px; }
+          #rating-panel .rate-label { font-size:10px; letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faint,#8a7c66); }
+          #rating-panel .rate-btn { background:transparent; border:1px solid var(--line,#c8bfac); color:var(--ink,#3a2818); padding:3px 10px; border-radius:14px; cursor:pointer; font-size:11px; letter-spacing:.04em; margin-right:4px; }
+          #rating-panel .rate-btn:hover { border-color:var(--ink,#3a2818); }
+          #rating-panel .rate-btn.is-on.rate-yes { background:#3a8a5a; border-color:#3a8a5a; color:#fff; }
+          #rating-panel .rate-btn.is-on.rate-no  { background:#c84a3a; border-color:#c84a3a; color:#fff; }
+          #rating-panel .rate-stars { display:inline-flex; gap:2px; }
+          #rating-panel .rate-star { background:transparent; border:0; color:#c8bfac; font-size:18px; padding:0 2px; cursor:pointer; line-height:1; }
+          #rating-panel .rate-star.is-on { color:#c9a14b; }
+          #rating-panel .rate-star:hover, #rating-panel .rate-star:hover ~ .rate-star { color:#c8bfac; }
+          #rating-panel .rate-star:hover { color:#c9a14b; }
+        </style>
+        <script>
+        (function(){
+          var panel = document.getElementById('rating-panel');
+          if (!panel) return;
+          var did = panel.dataset.designId;
+          var statusEl = document.getElementById('rating-status');
+          // Seed current state from injected design data (rendered server-side)
+          var state = {
+            color_good: ${design.user_color_good === true || design.user_color_good === false ? design.user_color_good : 'null'},
+            style_good: ${design.user_style_good === true || design.user_style_good === false ? design.user_style_good : 'null'},
+            scale_good: ${design.user_scale_good === true || design.user_scale_good === false ? design.user_scale_good : 'null'},
+            stars:      ${Number.isFinite(design.user_stars) ? design.user_stars : 'null'}
+          };
+          function paint() {
+            ['color_good','style_good','scale_good'].forEach(function(axis){
+              panel.querySelectorAll('[data-axis="'+axis+'"] .rate-btn').forEach(function(b){
+                b.classList.toggle('is-on', String(state[axis]) === b.dataset.val);
+              });
+            });
+            panel.querySelectorAll('.rate-star').forEach(function(s){
+              s.classList.toggle('is-on', state.stars != null && parseInt(s.dataset.val,10) <= state.stars);
+            });
+          }
+          paint();
+          function save(patch, doneText) {
+            statusEl.textContent = 'saving…';
+            fetch('/api/design/' + did + '/rate', {
+              method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'same-origin',
+              body: JSON.stringify(patch)
+            }).then(function(r){
+              if (!r.ok) throw new Error('save failed');
+              return r.json();
+            }).then(function(){
+              Object.assign(state, patch);
+              paint();
+              statusEl.textContent = doneText || 'saved';
+              setTimeout(function(){ statusEl.textContent=''; }, 1500);
+            }).catch(function(e){ statusEl.textContent = 'save error'; });
+          }
+          // Yes/No toggle clicks
+          panel.querySelectorAll('.rate-axis[data-axis$="_good"] .rate-btn').forEach(function(btn){
+            btn.addEventListener('click', function(){
+              var axis = btn.closest('.rate-axis').dataset.axis;
+              var clicked = btn.dataset.val === 'true';
+              var current = state[axis];
+              // Toggle off if clicking the already-on choice; otherwise set.
+              var next = (current === clicked) ? null : clicked;
+              var patch = {}; patch[axis.replace('_good','_good')] = next;
+              save({[axis]: next});
+            });
+          });
+          // Stars
+          panel.querySelectorAll('.rate-star').forEach(function(s){
+            s.addEventListener('click', function(){
+              var v = parseInt(s.dataset.val,10);
+              var next = state.stars === v ? null : v;
+              save({ stars: next });
+            });
+          });
+          // Remove
+          document.getElementById('rating-remove').addEventListener('click', function(){
+            if (!confirm('Soft-remove design #'+did+'? It will be hidden from the grid but stays in DB (reversible).')) return;
+            statusEl.textContent = 'removing…';
+            fetch('/api/design/' + did + '/remove', { method: 'POST', credentials:'same-origin' })
+              .then(function(r){ return r.json(); })
+              .then(function(j){
+                statusEl.textContent = 'removed';
+                setTimeout(function(){ location.href = '/designs'; }, 800);
+              }).catch(function(){ statusEl.textContent = 'remove error'; });
+          });
+        })();
+        </script>
+
         <div class="scale-preview" style="margin-top:18px">
           <div class="scale-controls" style="display:flex;align-items:center;gap:14px;margin-bottom:10px;flex-wrap:wrap">
             <label style="font:11px var(--sans);text-transform:uppercase;letter-spacing:.08em;color:var(--ink-faint);display:flex;align-items:center;gap:10px;flex:1;min-width:240px">

← 58f892e mural-scenic: panoramic 20ft x 11ft scenic murals (NOT seaml  ·  back to Wallco Ai  ·  mural-scenic: $950 infinite license + 150dpi download (Strip f2b894b →