← 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
M scripts/mural_scenic_batch.jsM scripts/refresh_designs_snapshot.pyM server.js
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 →