← back to Wallco Ai
ghost-review: SHIFT+drag = KEEP element (green dotted) · fix by-id 404 · fix kind-enum insert failure
a9fbc70f6ababa4ea53204930f2d6728302fa9e2 · 2026-05-24 00:05:32 -0700 · Steve Abrams
UX — preserve specific elements through a Fix:
- SHIFT+drag draws a GREEN DASHED rect instead of a defect rect
- tag='keep' · not counted as a defect · doesn't gate verdict buttons
- Defect count vs keep count shown separately in rect-count
- Fix endpoint payload includes keep_regions[] alongside region
- Python compositor paints keep regions in GREEN on the source PNG so
Gemini visually sees what to preserve
- editPrompt grows a 'preserve green-outlined elements EXACTLY' clause
when keep_regions is non-empty
SERVER FIXES that unblock the Fixes Feed:
1) /designs/img/by-id/:id now PG-fallback-resolves designs created since
the last server reload (crop-fix · regenerate-reverse · save-edited
all insert into PG but didn't push into the in-memory DESIGNS array).
Lazy lookup + push so cold cache works immediately. This is why the
AFTER image was rendering as a black 404 in /admin/fixes-feed.
2) regenerate-reverse + save-edited used kind='tile_repeat' as the
fallback when source design had no kind — but spoon_all_designs.kind
CHECK constraint only allows best_seller_seed | mural | mural_panel |
seamless_tile. Insert silently rejected → empty RETURNING → 'failed
to insert new row'. Switched fallback to 'seamless_tile'.
Files touched
M public/admin/ghost-review.htmlM server.js
Diff
commit a9fbc70f6ababa4ea53204930f2d6728302fa9e2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun May 24 00:05:32 2026 -0700
ghost-review: SHIFT+drag = KEEP element (green dotted) · fix by-id 404 · fix kind-enum insert failure
UX — preserve specific elements through a Fix:
- SHIFT+drag draws a GREEN DASHED rect instead of a defect rect
- tag='keep' · not counted as a defect · doesn't gate verdict buttons
- Defect count vs keep count shown separately in rect-count
- Fix endpoint payload includes keep_regions[] alongside region
- Python compositor paints keep regions in GREEN on the source PNG so
Gemini visually sees what to preserve
- editPrompt grows a 'preserve green-outlined elements EXACTLY' clause
when keep_regions is non-empty
SERVER FIXES that unblock the Fixes Feed:
1) /designs/img/by-id/:id now PG-fallback-resolves designs created since
the last server reload (crop-fix · regenerate-reverse · save-edited
all insert into PG but didn't push into the in-memory DESIGNS array).
Lazy lookup + push so cold cache works immediately. This is why the
AFTER image was rendering as a black 404 in /admin/fixes-feed.
2) regenerate-reverse + save-edited used kind='tile_repeat' as the
fallback when source design had no kind — but spoon_all_designs.kind
CHECK constraint only allows best_seller_seed | mural | mural_panel |
seamless_tile. Insert silently rejected → empty RETURNING → 'failed
to insert new row'. Switched fallback to 'seamless_tile'.
---
public/admin/ghost-review.html | 49 +++++++++++++++------
server.js | 96 ++++++++++++++++++++++++++++++++++++------
2 files changed, 118 insertions(+), 27 deletions(-)
diff --git a/public/admin/ghost-review.html b/public/admin/ghost-review.html
index 8d77cdf..e07cacd 100644
--- a/public/admin/ghost-review.html
+++ b/public/admin/ghost-review.html
@@ -118,6 +118,7 @@
.stage .rect.r-ghost { border:2px solid #ff5252; background:rgba(255,82,82,.18); }
.stage .rect.r-seam { border:2px solid #ffae3a; background:rgba(255,174,58,.20); }
.stage .rect.r-other { border:2px solid #ffd54a; background:rgba(255,213,74,.20); }
+ .stage .rect.r-keep { border:3px dashed #38c779; background:rgba(56,199,121,.12); }
.stage .rect .label {
position:absolute; top:-22px; left:0;
color:#fff; font:700 10px var(--sans);
@@ -127,6 +128,7 @@
.stage .rect.r-ghost .label { background:#ff5252; }
.stage .rect.r-seam .label { background:#ffae3a; color:#3a2a00; }
.stage .rect.r-other .label { background:#ffd54a; color:#3a2a00; }
+ .stage .rect.r-keep .label { background:#38c779; }
.stage .rect .x {
position:absolute; top:-10px; right:-10px;
width:22px; height:22px; border-radius:50%;
@@ -380,6 +382,9 @@
<header>
<h1>Ghost Review</h1>
<span class="meta">draw rects · pick a defect · go fast</span>
+ <a href="/admin/fixes-feed" target="_blank" style="font:600 11px var(--sans); letter-spacing:.06em; color:var(--blue,#3b78d8); text-decoration:none; padding:5px 10px; border:1px solid var(--line); border-radius:14px; margin-left:10px;">
+ 📡 Before/After Feed →
+ </a>
<div class="stats">
<span class="clean">CLEAN <b id="stat-fp">0</b></span>
<span class="ghost">GHOST <b id="stat-ghost">0</b></span>
@@ -880,7 +885,7 @@
}
// ── Drawing ────────────────────────────────────────────────────────────
- let dragging = false, dragStart = null, dragGhost = null;
+ let dragging = false, dragStart = null, dragGhost = null, dragKeep = false;
stage.addEventListener('mousedown', (e) => {
if (WAND_ON) return; // wand uses click handler, not drag-rect
if (e.target.tagName === 'BUTTON') return;
@@ -890,9 +895,14 @@
if (e.clientX < wrapRect.left || e.clientX > wrapRect.right ||
e.clientY < wrapRect.top || e.clientY > wrapRect.bottom) return;
dragging = true;
+ dragKeep = !!e.shiftKey; // SHIFT-held drag = "keep this element" green dotted rect
dragStart = { x: e.clientX, y: e.clientY, wrap: wrapRect };
dragGhost = document.createElement('div');
dragGhost.className = 'drag-rect';
+ if (dragKeep) {
+ dragGhost.style.border = '3px dashed #38c779';
+ dragGhost.style.background = 'rgba(56,199,121,.18)';
+ }
dragGhost.style.left = (e.clientX - wrapRect.left) + 'px';
dragGhost.style.top = (e.clientY - wrapRect.top) + 'px';
dragGhost.style.width = '0px';
@@ -928,12 +938,14 @@
// so coords normalize to the visible frame, not the wrap padding.
const activeRect = activeImageEl().getBoundingClientRect();
if (activeRect.width <= 0) return;
+ const wasKeep = dragKeep;
+ dragKeep = false;
RECTS.push({
x: (x0 - activeRect.left) / activeRect.width,
y: (y0 - activeRect.top) / activeRect.height,
w: (x1 - x0) / activeRect.width,
h: (y1 - y0) / activeRect.height,
- tag: DRAW_MODE,
+ tag: wasKeep ? 'keep' : DRAW_MODE,
view: SEAM_VIEW ? 'seam-quadrant-swap' : 'original',
});
drawRects();
@@ -985,21 +997,24 @@
window.addEventListener('resize', () => { if (CURRENT !== null) drawRects(); });
function updateRectCount() {
- const n = RECTS.length;
const gn = RECTS.filter(r => r.tag === 'ghost').length;
const sn = RECTS.filter(r => r.tag === 'seam').length;
const on = RECTS.filter(r => r.tag === 'other').length;
- $('rect-count').innerHTML = n
- ? `<b class="has">${n}</b> region${n>1?'s':''} marked · ${gn} ghost / ${sn} seam / ${on} other`
- : 'no regions marked yet — drag on the image to mark defect zones';
+ const kn = RECTS.filter(r => r.tag === 'keep').length;
+ const defects = gn + sn + on;
+ $('rect-count').innerHTML = RECTS.length
+ ? `<b class="has">${RECTS.length}</b> mark${RECTS.length>1?'s':''} · ${gn} ghost / ${sn} seam / ${on} other` +
+ (kn ? ` · <span style="color:#1f6a2c">${kn} keep 🛡</span>` : '') +
+ '<br><span style="font:10px var(--sans); color:var(--faint)">drag = defect · <kbd style="background:#f3eee2;border:1px solid var(--line);padding:0 4px;border-radius:2px;font:600 9px ui-monospace,Menlo,monospace">⇧ Shift</kbd>+drag = keep this element (green dotted)</span>'
+ : 'no regions marked yet · <b>drag</b> to mark a defect · <kbd style="background:#f3eee2;border:1px solid var(--line);padding:0 4px;border-radius:2px;font:600 9px ui-monospace,Menlo,monospace">⇧ Shift</kbd>+drag to mark an element to KEEP';
$('bn-ghost').textContent = gn;
$('bn-seam').textContent = sn;
$('bn-other').textContent = on;
$('btn-ghost').classList.toggle('gated', gn === 0);
$('btn-seam').classList.toggle('gated', sn === 0);
$('btn-other').classList.toggle('gated', on === 0);
- $('btn-fix').classList.toggle('gated', n === 0);
- $('btn-remove').classList.toggle('gated', n === 0);
+ $('btn-fix').classList.toggle('gated', defects === 0);
+ $('btn-remove').classList.toggle('gated', defects === 0);
}
$('undo-btn').onclick = () => { if (RECTS.length) { RECTS.pop(); drawRects(); updateRectCount(); } };
@@ -1580,17 +1595,21 @@
return;
}
const item = VIEW[CURRENT];
- // Pick fix_kind by the most-marked tag OR explicit override (remove-fill)
+ // Defect rects only (keep rects are negative regions — preserve them)
+ const defectRects = RECTS.filter(r => r.tag !== 'keep');
+ const keepRects = RECTS.filter(r => r.tag === 'keep');
+ if (!defectRects.length) { toast('mark at least one defect region (non-keep)'); return; }
+ // Pick fix_kind by the most-marked DEFECT tag OR explicit override
const tagCounts = { ghost: 0, seam: 0, other: 0 };
- RECTS.forEach(r => { tagCounts[r.tag] = (tagCounts[r.tag] || 0) + 1; });
+ defectRects.forEach(r => { tagCounts[r.tag] = (tagCounts[r.tag] || 0) + 1; });
const dominantTag = Object.entries(tagCounts).sort((a,b) => b[1] - a[1])[0][0];
const fixKind = kindOverride
|| { ghost: 'ghost-layer', seam: 'overlap', other: 'composition' }[dominantTag];
const verdict = { ghost: 'tp_ghost', seam: 'tp_seam', other: 'tp_other' }[dominantTag];
- // Union bounding box across all rects
+ // Union bounding box across DEFECT rects (excluding keep rects)
let minX = 1, minY = 1, maxX = 0, maxY = 0;
- for (const r of RECTS) {
+ for (const r of defectRects) {
if (r.x < minX) minX = r.x;
if (r.y < minY) minY = r.y;
if (r.x + r.w > maxX) maxX = r.x + r.w;
@@ -1626,9 +1645,13 @@
stat.textContent = `Sending region (${region.x.toFixed(1)}%, ${region.y.toFixed(1)}%, ${region.w.toFixed(1)}%, ${region.h.toFixed(1)}%) to Gemini for ${fixKind} edit…`;
const t0 = Date.now();
try {
+ // Map keep rects from [0..1] to PERCENT [0..100] for the server
+ const keep_regions = keepRects.map(k => ({
+ x: k.x * 100, y: k.y * 100, w: k.w * 100, h: k.h * 100,
+ }));
const r = await fetch(`/api/design/${item.id}/crop-fix`, {
method:'POST', headers:{'Content-Type':'application/json'},
- body: JSON.stringify({ region, fix_kind: fixKind }),
+ body: JSON.stringify({ region, fix_kind: fixKind, keep_regions }),
});
const j = await r.json();
if (!r.ok) throw new Error(j.error || ('http ' + r.status));
diff --git a/server.js b/server.js
index 4fdf25f..fbd6cb7 100644
--- a/server.js
+++ b/server.js
@@ -904,6 +904,35 @@ app.get('/api/design/:id/inspired-by-badge', (req, res) => {
}
});
+// ── Fixes feed (before/after audit trail) ───────────────────────────────
+// Append-only log of every successful fix (Surgical / Remove+Fill / Reverse-
+// Regenerate / Magic-Wand-Apply). Each row links the source id (before) to
+// the new id (after). Served as a live gallery at /admin/fixes-feed.
+function appendFixEvent(row) {
+ try {
+ const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
+ fs.appendFileSync(f, JSON.stringify({ ts: new Date().toISOString(), ...row }) + '\n');
+ } catch (e) { console.error('[fix-event] write fail', e.message); }
+}
+
+app.get('/admin/fixes-feed', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+ res.sendFile(path.join(__dirname, 'public', 'admin', 'fixes-feed.html'));
+});
+app.get('/api/fixes-feed', (req, res) => {
+ if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+ const f = path.join(__dirname, 'data', 'fixes-feed.jsonl');
+ if (!fs.existsSync(f)) return res.json({ ok: true, items: [] });
+ const lines = fs.readFileSync(f, 'utf8').split('\n').filter(Boolean);
+ // Latest first
+ const items = [];
+ for (let i = lines.length - 1; i >= 0; i--) {
+ try { items.push(JSON.parse(lines[i])); } catch {}
+ if (items.length >= 500) break;
+ }
+ res.json({ ok: true, items, total: lines.length });
+});
+
// ── Ghost Review (admin-only) ───────────────────────────────────────────
// Viewer + label-collection for the ghost-detector training set. Reads
// the flagged set from data/ghost-scan-flagged.jsonl; appends per-item
@@ -1153,12 +1182,25 @@ document.getElementById('btn-merge').addEventListener('click', function(){
app.get('/designs/img/by-id/:id', (req, res) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) return res.status(404).end();
- const d = DESIGNS.find(x => x.id === id);
+ let d = DESIGNS.find(x => x.id === id);
+ // Cold-cache fallback: designs created via crop-fix / regenerate-reverse /
+ // save-edited / magic-wand land in PG but aren't in the in-memory DESIGNS
+ // array until reload. Lazy-lookup via PG so the Fixes Feed AFTER side
+ // resolves immediately, not after the next server reboot.
+ if (!d) {
+ try {
+ const raw = psqlQuery(`SELECT row_to_json(t) FROM (SELECT id, image_url, local_path FROM spoon_all_designs WHERE id=${id} LIMIT 1) t;`);
+ if (raw && raw.length > 2) {
+ const row = JSON.parse(raw);
+ if (row && row.local_path && fs.existsSync(row.local_path)) {
+ DESIGNS.push({ id: row.id, image_url: row.image_url, local_path: row.local_path });
+ d = DESIGNS[DESIGNS.length - 1];
+ }
+ }
+ } catch { /* non-fatal */ }
+ }
if (!d) return res.status(404).end();
- // Resolve via the existing helper — filename (PII-free) → IMG_DIR/<filename>.
- // Falls back to image_url path if filename is missing. NEVER reads
- // d.local_path because designs.json no longer carries it (PII).
- const finalPath = localPathForDesign(d);
+ const finalPath = localPathForDesign(d) || d.local_path;
if (!finalPath || !fs.existsSync(finalPath)) return res.status(404).end();
res.sendFile(finalPath);
});
@@ -6766,6 +6808,14 @@ app.post('/api/design/:id/crop-fix', express.json({ limit: '16kb' }), async (req
return res.status(400).json({ error: 'region dims must be 1-100 (percent)' });
}
const fixKind = String(req.body?.fix_kind || 'ghost-layer');
+ // keep_regions: array of {x,y,w,h} as PERCENT — areas to PRESERVE inside or
+ // overlapping the fix region. Drawn as GREEN boxes on the composited PNG so
+ // Gemini can see them; reinforced in the prompt with explicit preserve text.
+ let keepRegions = Array.isArray(req.body?.keep_regions) ? req.body.keep_regions : [];
+ keepRegions = keepRegions.filter(k =>
+ k && ['x','y','w','h'].every(f => Number.isFinite(k[f])) &&
+ k.w >= 0.5 && k.h >= 0.5 && k.w <= 100 && k.h <= 100
+ ).slice(0, 12);
try {
// Load source row (PG → in-memory cache fallback)
@@ -6788,25 +6838,33 @@ app.post('/api/design/:id/crop-fix', express.json({ limit: '16kb' }), async (req
// Composite a red bbox onto a copy of the source. Python+PIL stays simple
// (we already use PIL elsewhere for palette extraction).
+ // Also paint any keep_regions in GREEN so Gemini visually sees them.
const srcBuf = fs.readFileSync(d.local_path);
const composedPath = path.join(require('os').tmpdir(), `cf_${id}_${Date.now()}.png`);
const py = require('child_process').spawnSync('python3', ['-c', `
-import sys, os
+import sys, os, json
from PIL import Image, ImageDraw
src = Image.open(sys.argv[1]).convert('RGBA')
w, h = src.size
-import json
r = json.loads(sys.argv[2])
+keeps = json.loads(sys.argv[4]) if len(sys.argv) > 4 else []
+draw = ImageDraw.Draw(src)
left = int(round(r['x']/100.0 * w))
top = int(round(r['y']/100.0 * h))
right = int(round((r['x']+r['w'])/100.0 * w))
bottom = int(round((r['y']+r['h'])/100.0 * h))
-draw = ImageDraw.Draw(src)
for off in range(4):
draw.rectangle([left-off, top-off, right+off, bottom+off], outline=(220, 28, 28, 255), width=1)
+for k in keeps:
+ kl = int(round(k['x']/100.0 * w))
+ kt = int(round(k['y']/100.0 * h))
+ kr = int(round((k['x']+k['w'])/100.0 * w))
+ kb = int(round((k['y']+k['h'])/100.0 * h))
+ for off in range(3):
+ draw.rectangle([kl-off, kt-off, kr+off, kb+off], outline=(56, 199, 121, 255), width=1)
src.save(sys.argv[3], 'PNG')
-print('OK', left, top, right, bottom)
-`, d.local_path, JSON.stringify(region), composedPath], { encoding: 'utf8' });
+print('OK', left, top, right, bottom, 'keeps=', len(keeps))
+`, d.local_path, JSON.stringify(region), composedPath, JSON.stringify(keepRegions)], { encoding: 'utf8' });
if (py.status !== 0) {
return res.status(500).json({ error: 'bbox composite failed: ' + (py.stderr || py.stdout || '?').slice(0, 240) });
}
@@ -6821,10 +6879,17 @@ print('OK', left, top, right, bottom)
'remove-fill': 'ERASE everything inside the red rectangle and fill it back in with the BACKGROUND pattern / negative space that surrounds it. Use the same palette and the same flat single-layer aesthetic. Do NOT add any new motifs in the erased region — only the surrounding background, ground tone, and continuation of any pattern (e.g. continue stripes, continue plain field, continue ground texture). The result should look like the defective content was never there.',
})[fixKind] || 'Regenerate the area inside the red rectangle as a flat clean silhouette in the existing palette.';
+ const keepPromptPart = keepRegions.length ? (
+ ` ADDITIONAL PRESERVE CONSTRAINT: there are ${keepRegions.length} GREEN-OUTLINED region(s) inside or overlapping the red region. ` +
+ `Those green-marked elements MUST be preserved EXACTLY — same silhouette, same color, same position, same orientation. ` +
+ `Even if they sit inside the red fix region, leave them untouched and rebuild ONLY the surrounding area around them. ` +
+ `Remove all green outlines from the output — they are guide markers too.`
+ ) : '';
+
const editPrompt = (
`Fix the marked region of this hand-painted scenic wallcovering. ${kindPrompt} ` +
`CRITICAL: Preserve EVERYTHING outside the red rectangle EXACTLY as it is — same composition, same silhouettes, same palette, same positions. ` +
- `Only the area INSIDE the red rectangle should change. ` +
+ `Only the area INSIDE the red rectangle should change.` + keepPromptPart + ' ' +
`Remove the red rectangle outline from the output — it is a guide marker, not part of the final design. ` +
`Keep the flat hand-painted-silk aesthetic: hard razor-sharp silhouette edges, single-layer screenprint, NO gradient, NO atmospheric perspective, NO half-tones. ` +
`Existing palette: ground tone is the dominant background color; figure tone is the silhouette color. Use them only — no new colors. ` +
@@ -6888,7 +6953,8 @@ RETURNING id`);
psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
- return res.json({ ok: true, new_id: newId, elapsed_s: elapsed, src_id: id, fix_kind: fixKind });
+ appendFixEvent({ kind: 'crop-fix', fix_kind: fixKind, src_id: id, new_id: newId, elapsed_s: elapsed, region, keep_count: keepRegions.length });
+ return res.json({ ok: true, new_id: newId, elapsed_s: elapsed, src_id: id, fix_kind: fixKind, keep_count: keepRegions.length });
} catch (e) {
console.error('[crop-fix]', e);
return res.status(500).json({ error: e.message });
@@ -6990,7 +7056,7 @@ INSERT INTO spoon_all_designs
image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
parent_design_id)
VALUES
- (${esc(d.kind || 'tile_repeat')}, 'wallco.ai',
+ (${esc(d.kind || 'seamless_tile')}, 'wallco.ai',
${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
'gemini-2.5-flash-image-regen-reverse',
${esc(`Reverse-regen of #${id}: ${newPrompt.slice(0, 1500)}`)},
@@ -7010,6 +7076,7 @@ RETURNING id`);
psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
const elapsed = ((Date.now() - t0) / 1000).toFixed(1);
+ appendFixEvent({ kind: 'regenerate-reverse', src_id: id, new_id: newId, elapsed_s: elapsed, new_prompt: newPrompt.slice(0, 400) });
return res.json({ ok: true, new_id: newId, new_prompt: newPrompt, elapsed_s: elapsed, src_id: id });
} catch (e) {
console.error('[regenerate-reverse]', e);
@@ -7061,7 +7128,7 @@ INSERT INTO spoon_all_designs
image_url, local_path, dominant_hex, palette, motifs, tags, category, is_published,
parent_design_id)
VALUES
- (${esc(d.kind || 'tile_repeat')}, 'wallco.ai',
+ (${esc(d.kind || 'seamless_tile')}, 'wallco.ai',
${d.width_in || 'NULL'}, ${d.height_in || 'NULL'}, ${d.panels || 'NULL'},
'magic-wand-browser-edit',
${esc(`Magic-wand edit of #${id}: ${summary}`)},
@@ -7080,6 +7147,7 @@ RETURNING id`);
if (!newId) return res.status(500).json({ error: 'failed to insert new row' });
psqlExecLocal(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}', is_published=TRUE WHERE id=${newId}`);
psqlExecLocal(`UPDATE spoon_all_designs SET is_published=FALSE WHERE id=${id}`);
+ appendFixEvent({ kind: 'magic-wand', src_id: id, new_id: newId, bytes: buf.length, summary });
return res.json({ ok: true, new_id: newId, src_id: id, bytes: buf.length });
} catch (e) {
console.error('[save-edited]', e);
← 080cc68 admin: live before/after Fixes Feed for every repair event
·
back to Wallco Ai
·
ghost-detector: bump 429 retry budget 4→6 attempts 2d3c47c →