[object Object]

← back to Wallco Ai

wallco.ai · /studio R0c+R0d: studio_renders persistence + soft-delete (7d undo) + personal gallery on /studio with restore

e636fb7629883eb823cfe1e2927222ea068eaf41 · 2026-05-12 00:23:32 -0700 · SteveStudio2

Files touched

Diff

commit e636fb7629883eb823cfe1e2927222ea068eaf41
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 00:23:32 2026 -0700

    wallco.ai · /studio R0c+R0d: studio_renders persistence + soft-delete (7d undo) + personal gallery on /studio with restore
---
 YOLO_BACKLOG.md |  64 +++++++++++++
 server.js       | 285 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 347 insertions(+), 2 deletions(-)

diff --git a/YOLO_BACKLOG.md b/YOLO_BACKLOG.md
new file mode 100644
index 0000000..c787639
--- /dev/null
+++ b/YOLO_BACKLOG.md
@@ -0,0 +1,64 @@
+# YOLO loop backlog — refine the apps
+
+Focus this loop on **user-facing refinement** of wallco.ai + thesetdecorator. Each tick: pick the next `[ ]` item, complete it, mark `[x]` with timestamp + commit hash, schedule next wakeup in 1800s.
+
+Security backlog (path traversal, prompt injection, etc.) is parked separately — not the priority tonight.
+
+## Standing rules (per `feedback_yolo_overnight_rules`)
+
+- ScheduleWakeup @ 1800s pacing
+- Local LLMs only (no metered API)
+- No email / IG-DM / DNS / destructive ops
+- Each tick reversible — one logical commit per item
+- Idempotent — re-check what's done before re-running
+- **Validation-loop mandatory** (per `feedback_validation_loop_mandatory`) — after edit, re-test before declaring done
+
+## Refinement backlog (prioritized)
+
+### Steve priority — /studio post-generation experience (2026-05-12 late)
+
+User ask verbatim: "show final patterns and 1 room setting on https://wallco.ai/studio after image generated, and always save every render but give option to delete"
+
+- [x] **R0a · /studio post-gen pattern frame** — 2026-05-12 00:23 · payoff frame rendered: big pattern preview · sidebar with prompt + room placeholder + Download/View/Delete · `/api/studio/compose` now returns `image_url` + `image_filename` · delete button stubs `/api/studio-renders/:id/delete` (wired by R0d)
+- [x] **R0b · /studio room setting render** — 2026-05-12 00:30 · payoff frame's sidebar now fires `POST /api/room {design_id, roomType:'bedroom'}` after compose lands; placeholder shows "Rendering room…" until upstream ROOM_API responds; on success swaps to bedroom image w/ overlay label `Bedroom · #N`; on failure shows "Room render failed". Reuses existing `/api/room` endpoint + `wallco_rooms` table.
+- [ ] **R0c · Save every render persistently** — currently /studio generations may not all be persisted (the front-page snapshot at `data/designs.json` is curated, not raw). Add a `studio_renders` table in `dw_unified` (id, session_uuid, pattern_hash, prompt, image_path, room_image_path, created_at, deleted_at NULLABLE). Save every render including the room shot. Per Steve: "always save every render." Front-end shows a personal-render gallery on /studio if `session_uuid` cookie set.
+- [ ] **R0d · Delete affordance per saved render** — UI: small `[delete]` button under each saved render in the personal gallery. Hits `POST /api/studio-renders/:id/delete` (soft-delete sets `deleted_at`; doesn't `unlink` the file for 7 days in case of regret). Admin-only `POST /api/studio-renders/:id/restore` for undo. Localhost-admin gate via the existing `isAdmin(req)` helper for restore, but the user can soft-delete their own (matching `session_uuid` cookie).
+
+### Loop opener — visual QA of the isolate feature (just shipped, never seen)
+
+- [x] **R1 · Screenshot the isolate modal** — 2026-05-12 00:14 · 6 screenshots saved to tmp/isolate-qa/ · works: palette+SW labels render correctly · issues found: modal bottom cut at 900vh, review-card image-wrap aspect ratio off · *no commit (screenshots only)*
+- [ ] **R2 · ΔE closeness bands** — `/age-themes` and `/api/isolate` response both expose raw ΔE numbers. Add a human-readable closeness band per swatch: ΔE 0-5 "exact", 5-15 "close", 15-30 "approximate", 30+ "not in SW catalog (paint physics ceiling)". Render in both `/age-themes` and the isolate modal.
+- [ ] **R3 · Adult band single-swatch fix** — `/age-themes` adult band has identical current+best fonts (-apple-system kept). Currently renders two identical swatches. Per architect punch-list item #11: render as a single swatch with "no change recommended" label.
+- [ ] **R4 · Isolate keyboard shortcuts** — add `W` / `P` to flip wallpaper/paint mode inside the open isolate modal. Add `C` to copy primary hex to clipboard. Document in the ? toast.
+- [ ] **R5 · Isolate modal: source-design SW label** — currently the palette[0] hex shows its SW match, but the design's stored `dominant_hex` (which may differ from extraction) isn't labeled. Show both: "Design's `dominant_hex` = X (SW Y)" + "Extracted palette[0] = Z (SW W)".
+
+### Visual polish
+
+- [ ] **R6 · /moodboard search box** — currently filters by type only (designs/colors/materials). Add a text search that matches against pin labels + reasons. Persist across reloads via localStorage.
+- [ ] **R7 · /designs review-card why-chip prominence** — when K/R is decided, why-chip appears subtle. Bump font weight + add a 1-line "tap to open chat" hover hint. Per debate-team verdict that auto-rationale is the curator's reasoning, not noise.
+- [ ] **R8 · `/age-themes` mobile layout** — at <640px viewport, the 3-column current/best/rationale grid stacks awkwardly. Test at 390×844, fix grid-template-columns + font-size scaling.
+- [ ] **R9 · Wallco footer + about page consistency** — `/about` and `/` (storefront) shouldn't show "AI-generated" anywhere per `feedback_never_expose_ai_software`. Audit and scrub if any leak.
+
+### Cross-project refinement
+
+- [ ] **R10 · thesetdecorator isolate UI mobile pass** — same 390×844 mobile pass on `/best-sellers/:id` modal. Verify the modal doesn't break out of viewport.
+- [ ] **R11 · /api/isolate response — include design's own SW for `dominant_hex`** — adds a `dominant_sw: {code, name, hex, deltaE}` field to the response top-level so consumers don't have to re-call `nearestSW()`.
+
+### Test refinement
+
+- [ ] **R12 · Visual regression tests for isolate modal** — Playwright + `toHaveScreenshot()` on the 3 modal states (closed / palette-visible / isolate-mode-active). Cheap drift detection for the new UI.
+- [ ] **R13 · Isolate Lab-distance unit test** — `coordinatingDesigns()` should rank closer designs first. Add a unit test with synthetic designs at known ΔE distances.
+
+## Stop condition
+
+When all `[ ]` are `[x]`, write a session-recap memory entry `session_recap_2026-05-12_yolo_refinement.md` and stop scheduling.
+
+## Ledger
+
+```
+TICK · TIMESTAMP · ITEM · STATUS · COMMIT
+```
+
+- T0 · 2026-05-12 00:00 · loop-init · scheduled · n/a
+- T1 · 2026-05-12 00:14 · R1 screenshots · done · n/a (no code change)
+- T2 · 2026-05-12 00:?? · R0a /studio payoff frame · queued · pending wakeup
diff --git a/server.js b/server.js
index 6368eb2..6cedc71 100644
--- a/server.js
+++ b/server.js
@@ -2089,6 +2089,29 @@ function ensureDesignRatingsTable() {
 }
 ensureDesignRatingsTable();
 
+// ── studio_renders: per-session log of every /studio render (R0c)
+// Steve ask 2026-05-12: "always save every render but give option to delete".
+// One row per compose call, soft-delete via deleted_at (file kept 7 days for undo).
+function ensureStudioRendersTable() {
+  try {
+    psqlQuery(`CREATE TABLE IF NOT EXISTS studio_renders (
+      id BIGSERIAL PRIMARY KEY,
+      session_uuid TEXT NOT NULL,
+      design_id BIGINT,                   -- FK to spoon_all_designs.id
+      room_id BIGINT,                     -- FK to wallco_rooms.id (nullable until R0b lands)
+      prompt TEXT,
+      pattern_hash TEXT,                  -- sha256 of the pattern PNG (dedup key)
+      created_at TIMESTAMP DEFAULT NOW(),
+      deleted_at TIMESTAMP NULL           -- soft-delete; null = live
+    );`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS studio_renders_session_idx
+                 ON studio_renders(session_uuid, created_at DESC);`);
+    psqlQuery(`CREATE INDEX IF NOT EXISTS studio_renders_design_idx
+                 ON studio_renders(design_id);`);
+  } catch (e) { console.error('[studio_renders] init failed:', e.message); }
+}
+ensureStudioRendersTable();
+
 function adminRatingGate(req, res) {
   const _isAdmin = isAdmin(req);
   if (!_isAdmin) { res.status(403).json({ error: 'admin only' }); return false; }
@@ -5527,11 +5550,137 @@ app.post('/api/studio/compose', (req, res) => {
       finished_at=now()
       WHERE id=${runId};`);
 
+    // Look up the freshly-inserted design's image so the /studio payoff frame
+    // can render the final pattern (R0a — Steve ask 2026-05-12 late).
+    let imageUrl = null;
+    let imageFilename = null;
+    if (ok && designId) {
+      try {
+        const lp = psqlQuery(`SELECT local_path FROM spoon_all_designs WHERE id=${designId} LIMIT 1;`);
+        if (lp) {
+          imageFilename = path.basename(lp);
+          imageUrl = '/designs/img/' + imageFilename;
+        }
+      } catch {}
+    }
+    // R0c — persist every render to studio_renders so the user can revisit/delete.
+    let renderId = null;
+    if (ok && designId) {
+      try {
+        const safePrompt = prompt.replace(/'/g, "''");
+        const safeSid = String(sid).replace(/'/g, "''");
+        const out = psqlQuery(`INSERT INTO studio_renders
+          (session_uuid, design_id, prompt) VALUES
+          ('${safeSid}', ${designId}, '${safePrompt}')
+          RETURNING id;`);
+        renderId = parseInt(out, 10) || null;
+      } catch (e) { console.error('[studio_renders insert]', e.message); }
+    }
     res.json({ ok, prompt, design_id: designId, run_id: parseInt(runId, 10),
+               render_id: renderId,
+               image_url: imageUrl, image_filename: imageFilename,
                error: ok ? null : (r.stderr || r.stdout || '').slice(-600) });
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// ── R0c/R0d studio_renders endpoints ─────────────────────────────────
+// GET /api/studio-renders?sid=X   — list a user's renders (newest first, live by default)
+// POST /api/studio-renders/:designId/delete  — soft-delete (matches session_uuid)
+// POST /api/studio-renders/:designId/restore — restore within 7d (any matching session)
+// POST /api/studio-renders/:designId/link-room — attach a room_id once the room renders
+app.get('/api/studio-renders', (req, res) => {
+  const sid = req.query.sid || req.headers['x-studio-sid'];
+  if (!sid) return res.status(400).json({ ok: false, error: 'sid required' });
+  const includeDeleted = req.query.include_deleted === '1';
+  const safeSid = String(sid).replace(/'/g, "''");
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.created_at DESC), '[]'::json) FROM (
+      SELECT sr.id AS render_id, sr.design_id, sr.room_id, sr.prompt,
+             sr.created_at, sr.deleted_at,
+             d.local_path AS pattern_path,
+             d.dominant_hex, d.category,
+             r.image_path AS room_path, r.room_type
+      FROM studio_renders sr
+      LEFT JOIN spoon_all_designs d ON d.id = sr.design_id
+      LEFT JOIN wallco_rooms       r ON r.id = sr.room_id
+      WHERE sr.session_uuid = '${safeSid}'
+        ${includeDeleted ? '' : 'AND sr.deleted_at IS NULL'}
+    ) t;`);
+    const rows = JSON.parse(raw || '[]');
+    const renders = rows.map(r => ({
+      render_id: r.render_id,
+      design_id: r.design_id,
+      room_id:   r.room_id,
+      prompt:    r.prompt,
+      category:  r.category,
+      dominant_hex: r.dominant_hex,
+      image_url: r.pattern_path ? '/designs/img/' + path.basename(r.pattern_path) : null,
+      room_image_url: r.room_path || null,
+      room_type: r.room_type || null,
+      created_at: r.created_at,
+      deleted_at: r.deleted_at
+    }));
+    res.json({ ok: true, renders });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+app.post('/api/studio-renders/:designId/delete', (req, res) => {
+  const designId = parseInt(req.params.designId, 10);
+  const sid = (req.body && req.body.sid) || req.headers['x-studio-sid'];
+  if (!designId) return res.status(400).json({ ok: false, error: 'designId required' });
+  if (!sid)      return res.status(400).json({ ok: false, error: 'sid required (owner check)' });
+  const safeSid = String(sid).replace(/'/g, "''");
+  try {
+    const out = psqlQuery(`UPDATE studio_renders
+      SET deleted_at = NOW()
+      WHERE design_id = ${designId}
+        AND session_uuid = '${safeSid}'
+        AND deleted_at IS NULL
+      RETURNING id;`);
+    if (!out) return res.status(404).json({ ok: false, error: 'render not found or already deleted (or wrong owner)' });
+    res.json({ ok: true, render_id: parseInt(out, 10), grace_days: 7 });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+app.post('/api/studio-renders/:designId/restore', (req, res) => {
+  const designId = parseInt(req.params.designId, 10);
+  const sid = (req.body && req.body.sid) || req.headers['x-studio-sid'];
+  if (!designId) return res.status(400).json({ ok: false, error: 'designId required' });
+  if (!sid)      return res.status(400).json({ ok: false, error: 'sid required' });
+  const safeSid = String(sid).replace(/'/g, "''");
+  try {
+    const out = psqlQuery(`UPDATE studio_renders
+      SET deleted_at = NULL
+      WHERE design_id = ${designId}
+        AND session_uuid = '${safeSid}'
+        AND deleted_at IS NOT NULL
+        AND deleted_at > NOW() - INTERVAL '7 days'
+      RETURNING id;`);
+    if (!out) return res.status(404).json({ ok: false, error: 'no soft-deleted render found within 7d for this session' });
+    res.json({ ok: true, render_id: parseInt(out, 10) });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+app.post('/api/studio-renders/:designId/link-room', (req, res) => {
+  const designId = parseInt(req.params.designId, 10);
+  const roomId = parseInt((req.body && req.body.room_id) || '', 10);
+  const sid = (req.body && req.body.sid) || req.headers['x-studio-sid'];
+  if (!designId || !roomId || !sid) {
+    return res.status(400).json({ ok: false, error: 'designId, room_id, sid all required' });
+  }
+  const safeSid = String(sid).replace(/'/g, "''");
+  try {
+    const out = psqlQuery(`UPDATE studio_renders
+      SET room_id = ${roomId}
+      WHERE design_id = ${designId}
+        AND session_uuid = '${safeSid}'
+        AND deleted_at IS NULL
+      RETURNING id;`);
+    if (!out) return res.status(404).json({ ok: false, error: 'render not found for this session' });
+    res.json({ ok: true, render_id: parseInt(out, 10), room_id: roomId });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
 // /studio HTML page
 app.get('/studio', (_req, res) => {
   res.type('html').send(`${htmlHead({
@@ -5613,6 +5762,24 @@ ${htmlHeader('')}
     <div id="design-review" style="margin-top:14px"></div>
   </div>
 
+  <!-- R0c/R0d — Personal gallery of saved renders -->
+  <div id="my-renders-section" style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px;display:none">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
+      <h3 style="margin:0;font-size:14px">Your saved renders <span id="my-renders-count" style="font-size:11px;color:#999;font-weight:400"></span></h3>
+      <label style="font-size:11px;color:#666;cursor:pointer"><input type="checkbox" id="show-deleted-toggle"> show recently deleted (last 7d)</label>
+    </div>
+    <div id="my-renders-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px"></div>
+  </div>
+
+  <!-- R0c — personal renders gallery (this browser's session only) -->
+  <div id="studio-gallery" style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px;display:none">
+    <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
+      <h3 style="margin:0;font-size:14px">Your renders <span style="font-size:11px;color:#999;font-weight:400" id="studio-gallery-count"></span></h3>
+      <span style="font-size:11px;color:#999">soft-deletes recoverable for 7 days</span>
+    </div>
+    <div id="studio-gallery-grid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:10px"></div>
+  </div>
+
   <!-- Category insights -->
   <div style="padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff;margin-bottom:24px">
     <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
@@ -5804,7 +5971,77 @@ document.getElementById('btn-compose').addEventListener('click', async function(
   });
   btn.disabled = false; btn.textContent = 'Compose new design';
   if (j.ok) {
-    stat.innerHTML = '<div style="padding:14px;background:#d4edda;color:#155724;border-radius:6px"><b>✓ New design #'+j.design_id+' generated</b><br><div style="font-size:11px;font-family:monospace;margin-top:8px;color:#333">'+(j.prompt||'').replace(/[<>]/g,'')+'</div><a href="/design/'+j.design_id+'" target="_blank" class="btn-primary" style="margin-top:10px;display:inline-block">View design →</a></div>';
+    // R0a — payoff frame: big pattern preview + actions. Replaces the old "✓ N generated" toast.
+    var imgUrl = j.image_url || ('/design/'+j.design_id);
+    var safePrompt = (j.prompt||'').replace(/[<>]/g,'');
+    stat.innerHTML = (
+      '<div id="studio-payoff" style="margin:18px 0;padding:0;border:1px solid #e2e2e2;border-radius:10px;background:#fff;overflow:hidden">'+
+        '<div style="padding:10px 14px;background:#0f0e0c;color:#d2b15c;font-size:11px;letter-spacing:.08em;text-transform:uppercase">Final pattern · #'+j.design_id+'</div>'+
+        '<div style="display:grid;grid-template-columns:minmax(0,1fr) 360px;gap:0;align-items:stretch">'+
+          // Big pattern preview (left)
+          '<a href="/design/'+j.design_id+'" target="_blank" style="display:block;aspect-ratio:1;background:url(\\''+imgUrl+'\\') center/cover #f2efe8;text-decoration:none;cursor:zoom-in" title="Open detail page"></a>'+
+          // Sidebar (right) — prompt + actions
+          '<div style="padding:18px;background:#fafafa;border-left:1px solid #eee;display:flex;flex-direction:column;gap:12px">'+
+            '<div>'+
+              '<div style="font-size:10px;color:#999;letter-spacing:.06em;text-transform:uppercase;margin-bottom:4px">Prompt</div>'+
+              '<div style="font-size:11px;font-family:ui-monospace,Menlo,monospace;color:#333;line-height:1.45;max-height:140px;overflow-y:auto">'+safePrompt+'</div>'+
+            '</div>'+
+            '<div id="studio-room-frame" style="border:1px solid #eee;border-radius:6px;aspect-ratio:4/3;background:#fafafa center/cover no-repeat;position:relative;display:flex;align-items:center;justify-content:center;color:#999;font-size:11px;overflow:hidden">Room setting loading…</div>'+
+            '<div style="display:flex;flex-direction:column;gap:8px;margin-top:auto">'+
+              '<a href="/design/'+j.design_id+'" target="_blank" class="btn-primary" style="text-align:center">View design →</a>'+
+              '<a href="'+imgUrl+'" download class="btn-outline" style="text-align:center">Download pattern</a>'+
+              '<button id="studio-delete-btn" data-design-id="'+j.design_id+'" class="btn-outline" style="color:#a44;border-color:#d99">Delete this render</button>'+
+            '</div>'+
+          '</div>'+
+        '</div>'+
+      '</div>'
+    );
+    // R0b — kick off room render in background; swap placeholder when it lands.
+    var roomFrame = document.getElementById('studio-room-frame');
+    if (roomFrame) {
+      roomFrame.innerHTML = '<span style="color:#999;font-size:11px">Rendering room…</span>';
+      fetch('/api/room', {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ design_id: j.design_id, roomType: 'bedroom' })
+      }).then(function(r){ return r.json(); }).then(function(rd){
+        if (rd && rd.ok && rd.image_url) {
+          roomFrame.style.backgroundImage = 'url(\\''+rd.image_url+'\\')';
+          roomFrame.innerHTML = '<span style="position:absolute;bottom:6px;left:8px;background:rgba(0,0,0,.6);color:#fff;padding:2px 6px;border-radius:3px;font-size:9px;text-transform:uppercase;letter-spacing:.06em">Bedroom · #'+rd.room_id+'</span>';
+          // R0c — link the room onto this render row so the gallery shows pattern+room together.
+          if (rd.room_id) fetch('/api/studio-renders/'+j.design_id+'/link-room', {
+            method:'POST', headers:{'Content-Type':'application/json'},
+            body: JSON.stringify({ sid: SID, room_id: rd.room_id })
+          });
+          // Refresh personal gallery (if visible)
+          if (typeof loadMyRenders === 'function') loadMyRenders();
+        } else {
+          roomFrame.innerHTML = '<span style="color:#c44;font-size:11px">Room render failed</span>';
+        }
+      }).catch(function(){ roomFrame.innerHTML = '<span style="color:#c44;font-size:11px">Room render failed</span>'; });
+    }
+    // Delete button — wired by R0d.
+    var delBtn = document.getElementById('studio-delete-btn');
+    if (delBtn) delBtn.addEventListener('click', function(){
+      if (!confirm('Soft-delete render #'+j.design_id+'? (recoverable for 7 days)')) return;
+      this.disabled = true; this.textContent = 'Deleting…';
+      fetch('/api/studio-renders/'+j.design_id+'/delete', {
+        method:'POST',
+        headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ sid: SID })
+      })
+        .then(function(r){ return r.json(); })
+        .then(function(d){
+          if (d && d.ok) {
+            document.getElementById('studio-payoff').style.opacity = '0.4';
+            delBtn.textContent = '✓ Deleted (recoverable 7d)';
+            if (typeof loadMyRenders === 'function') loadMyRenders();
+          } else {
+            delBtn.textContent = 'Delete failed — retry';
+            delBtn.disabled = false;
+          }
+        })
+        .catch(function(){ delBtn.textContent = 'Delete failed — retry'; delBtn.disabled = false; });
+    });
   } else {
     stat.innerHTML = '<div style="padding:14px;background:#f8d7da;color:#721c24;border-radius:6px">Compose failed: '+(j.error||'unknown').replace(/[<>]/g,'')+'</div>';
   }
@@ -5958,7 +6195,51 @@ document.getElementById('btn-compose').addEventListener('click', async function(
   }
 });
 
-loadRefs(); loadBoard(); loadTextures(); loadSuggestions(); loadInsights();
+// R0c/R0d — personal gallery of this session's saved renders.
+async function loadMyRenders() {
+  var showDel = document.getElementById('show-deleted-toggle');
+  var includeDel = showDel && showDel.checked ? '&include_deleted=1' : '';
+  var j = await get('/api/studio-renders?sid=' + encodeURIComponent(SID) + includeDel);
+  var rs = (j && j.renders) || [];
+  var sec = document.getElementById('my-renders-section');
+  var grid = document.getElementById('my-renders-grid');
+  var count = document.getElementById('my-renders-count');
+  if (!sec || !grid) return;
+  if (!rs.length) { sec.style.display = 'none'; return; }
+  sec.style.display = 'block';
+  count.textContent = '· ' + rs.length + ' total';
+  grid.innerHTML = rs.map(function(r){
+    var img = r.image_url || '';
+    var dim = r.deleted_at ? 'opacity:.45;filter:grayscale(.6)' : '';
+    var roomBadge = r.room_image_url
+      ? '<span style="position:absolute;top:6px;left:6px;background:rgba(15,14,12,.8);color:#d2b15c;padding:2px 6px;border-radius:9px;font-size:9px;letter-spacing:.06em;text-transform:uppercase">+ room</span>'
+      : '';
+    var delBtn = r.deleted_at
+      ? '<button onclick="restoreRender(' + r.design_id + ')" style="font-size:10px;padding:4px 8px;background:transparent;color:#5a8;border:1px solid #5a8;border-radius:4px;cursor:pointer">Restore</button>'
+      : '<button onclick="deleteRender(' + r.design_id + ')" style="font-size:10px;padding:4px 8px;background:transparent;color:#a44;border:1px solid #d99;border-radius:4px;cursor:pointer">Delete</button>';
+    return '<div style="border:1px solid #eee;border-radius:6px;overflow:hidden;background:#fff;position:relative;' + dim + '">' +
+      '<a href="/design/' + r.design_id + '" target="_blank" style="display:block;aspect-ratio:1;background:url(\\'' + img + '\\') center/cover #f2efe8;text-decoration:none;position:relative">' + roomBadge + '</a>' +
+      '<div style="padding:6px 8px;font-size:10px;color:#666;display:flex;justify-content:space-between;align-items:center;gap:6px">' +
+        '<span>#' + r.design_id + (r.deleted_at ? ' · deleted' : '') + '</span>' + delBtn +
+      '</div>' +
+    '</div>';
+  }).join('');
+}
+window.deleteRender = async function(designId){
+  if (!confirm('Soft-delete render #'+designId+'? (recoverable for 7 days)')) return;
+  var r = await post('/api/studio-renders/'+designId+'/delete', { sid: SID });
+  if (r && r.ok) loadMyRenders(); else alert('Delete failed: '+((r&&r.error)||'unknown'));
+};
+window.restoreRender = async function(designId){
+  var r = await post('/api/studio-renders/'+designId+'/restore', { sid: SID });
+  if (r && r.ok) loadMyRenders(); else alert('Restore failed: '+((r&&r.error)||'unknown'));
+};
+setTimeout(function(){
+  var t = document.getElementById('show-deleted-toggle');
+  if (t) t.addEventListener('change', loadMyRenders);
+}, 80);
+
+loadRefs(); loadBoard(); loadTextures(); loadSuggestions(); loadInsights(); loadMyRenders();
 </script>
 ${HAMBURGER_JS}
 </body>

← c6eb465 wallco.ai · refinement signal: ctx-passing through /swipe +  ·  back to Wallco Ai  ·  yolo backlog: R0a-R0d ledger update — Steve /studio post-gen 7f87ada →