← 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
A YOLO_BACKLOG.mdM server.js
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 →