← back to Wallco Ai
cactus-curator: SHIFT+drag to box bad areas (dotted red boxes, stored in bad_examples.boxes per design — id BAD elements so we never repeat); red FAIL reason chip (Seams/Blurry); fix Unpublished filter showing removed. edges-review: status sort + human failure reason. server: /api/admin/cactus/:id/annotations endpoint, bad_examples.boxes, fix-decision/bulk route un-shadowed
0d0cca4fe45d9aa160b8ff40fdf0ee1313d16f90 · 2026-05-27 09:53:43 -0700 · Steve Abrams
Files touched
M public/admin/cactus-curator.htmlM server.js
Diff
commit 0d0cca4fe45d9aa160b8ff40fdf0ee1313d16f90
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 27 09:53:43 2026 -0700
cactus-curator: SHIFT+drag to box bad areas (dotted red boxes, stored in bad_examples.boxes per design — id BAD elements so we never repeat); red FAIL reason chip (Seams/Blurry); fix Unpublished filter showing removed. edges-review: status sort + human failure reason. server: /api/admin/cactus/:id/annotations endpoint, bad_examples.boxes, fix-decision/bulk route un-shadowed
---
public/admin/cactus-curator.html | 88 +++++++++++++++++++++++++++++++++++++++-
server.js | 40 ++++++++++++++++++
2 files changed, 126 insertions(+), 2 deletions(-)
diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index ccea431..282211c 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -38,13 +38,25 @@
.b.pub { background:#2e7d32; } .b.unpub { background:#5a5f63; }
.b.fix { background:#c98a00; } .b.dig { background:#7a5bd6; } .b.web { background:#1565c0; }
.b.pass { background:#2e7d32; } .b.warn { background:#b07a00; } .b.fail { background:#a13030; } .b.none{background:#555;}
+ .b.reason { background:#e11d2a; color:#fff; font-weight:800; letter-spacing:.02em; box-shadow:0 1px 4px rgba(0,0,0,.4); }
.state { position:absolute; bottom:0; left:0; right:0; padding:18px 6px 5px; font-size:11px; font-weight:700; text-align:center;
color:#fff; background:linear-gradient(transparent,rgba(0,0,0,.8)); z-index:2; display:none; }
+ /* Seam-mismatch callout — the common repeat-break error */
+ .seam-mismatch { position:absolute; top:6px; left:6px; right:auto; z-index:4; max-width:78%;
+ background:#b3261e; color:#fff; font-size:10.5px; font-weight:700; line-height:1.2;
+ padding:4px 7px; border-radius:4px; box-shadow:0 1px 4px rgba(0,0,0,.4); }
.meta { padding:7px 9px 4px; }
.cw { font-size:12px; color:var(--ink); font-weight:600; text-transform:capitalize; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.scores { display:flex; align-items:center; gap:8px; margin-top:3px; color:var(--mut); font-size:11px; }
.dot { width:11px; height:11px; border-radius:50%; border:1px solid rgba(255,255,255,.25); display:inline-block; vertical-align:-1px; }
.when { margin-top:3px; color:var(--mut); font-size:10.5px; font-variant-numeric:tabular-nums; }
+ /* SHIFT-drag "bad area" annotation boxes — id BAD elements for fixing */
+ .bad-box { position:absolute; border:2px dashed #ff3b30; background:rgba(255,59,48,.14); z-index:5; box-sizing:border-box; border-radius:2px; }
+ .bad-box.drawing { pointer-events:none; }
+ .bad-box .x { position:absolute; top:-9px; right:-9px; width:17px; height:17px; line-height:15px; text-align:center;
+ background:#ff3b30; color:#fff; border-radius:50%; font:800 12px sans-serif; cursor:pointer; z-index:6; }
+ .bad-box .lbl { position:absolute; bottom:-1px; left:-1px; background:#ff3b30; color:#fff; font:800 9px sans-serif; padding:1px 5px; border-radius:0 3px 0 0; }
+ body.shifting .thumb { cursor:crosshair; }
/* marquee rubber-band selection */
#marquee { position:fixed; z-index:40; border:1.5px solid #4aa3ff; background:rgba(74,163,255,.16); pointer-events:none; display:none; border-radius:3px; }
body.dragging { user-select:none; cursor:crosshair; }
@@ -121,7 +133,7 @@
<label class="ctl">Density
<input type="range" id="density" min="2" max="9" step="1" value="5">
</label>
- <span class="legend"><b>drag</b> across images to select · <b>shift+drag</b> adds · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
+ <span class="legend"><b>drag</b> across images to select · <b>shift+drag on an image</b> = ▢ box a bad area (red) · <b>shift+click</b> a card to add one · keys on hover: <code>1</code>bad <code>2</code>digital <code>3</code>fix <code>4</code>publish <code>x</code>select</span>
</div>
</header>
@@ -184,7 +196,7 @@ function passesFilter(d, f){
switch(f){
case 'all': return true;
case 'published': return d.is_published && act!=='bad' && act!=='digital';
- case 'unpublished': return !d.is_published || act==='digital';
+ case 'unpublished': return !d.is_published && !(d.user_removed || act==='bad');
case 'web': return d.web_viewer || act==='live';
case 'fix': return !!d.needs_fixing_at || act==='fix';
case 'digital': return !!d.digital_file_at || act==='digital';
@@ -199,6 +211,16 @@ function colorway(cat){
return cat.includes('·') ? cat.split('·').slice(1).join('·').trim() : cat;
}
function verdictClass(v){ return v==='PASS'?'pass':v==='WARN'?'warn':v==='FAIL'?'fail':'none'; }
+// Short red defect label shown on the card front when a design FAILs — says WHY.
+function failReason(d){
+ const reasons = [];
+ const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
+ if (d.seam_verdict === 'FAIL' || (hmid != null && hmid > 12) || (vmid != null && vmid > 12)) reasons.push('Seams');
+ const vis = num(d.vision_score);
+ if (vis != null && vis < 40) reasons.push('Blurry'); // low vision-quality ≈ soft/blurry/artifacted
+ if (d.vision_flags && /blur|soft/i.test(String(d.vision_flags))) reasons.push('Blurry');
+ return [...new Set(reasons)];
+}
function card(d){
const el = document.createElement('div');
@@ -209,6 +231,17 @@ function card(d){
if(selected.has(d.id)) el.classList.add('sel');
const rank = num(d.rank_score), vis = num(d.vision_score), seam = num(d.seam_score);
+ // Seam-mismatch callout — the common repeat-break error. h-mid raw > tolerance
+ // = the pattern doesn't connect across the horizontal midline (top/bottom halves
+ // don't tile); v-mid = vertical line. Tolerance 12 (WALLCO_SEAMLESS_TOLERANCE).
+ const SEAM_TOL = 12;
+ const hmid = num(d.seam_hmid_raw), vmid = num(d.seam_vmid_raw);
+ const seamLines = [];
+ if (hmid != null && hmid > SEAM_TOL) seamLines.push('horizontal line');
+ if (vmid != null && vmid > SEAM_TOL) seamLines.push('vertical line');
+ const seamCallout = seamLines.length
+ ? `<div class="seam-mismatch" title="h-mid ΔE ${hmid??'—'} · v-mid ΔE ${vmid??'—'} (tolerance ${SEAM_TOL})">⚠ Seam Mismatch — ${seamLines.join(' & ')}</div>`
+ : '';
const stateLabel = act ? ({bad:'✕ REMOVED',digital:'⬇ DIGITAL FILE',fix:'⚠ NEEDS FIXING',live:'✓ PUBLISHED'})[act]
: d.web_viewer ? '✓ PUBLISHED' : d.needs_fixing_at ? '⚠ NEEDS FIXING'
: d.digital_file_at ? '⬇ DIGITAL FILE' : d.user_removed ? '✕ REMOVED' : '';
@@ -221,8 +254,10 @@ function card(d){
<span class="b ${d.is_published?'pub':'unpub'}">${d.is_published?'PUB':'unpub'}</span>
${d.web_viewer?'<span class="b web">WEB</span>':''}
<span class="b ${verdictClass(d.seam_verdict)}">${d.seam_verdict||'no scan'}</span>
+ ${failReason(d).map(r=>`<span class="b reason" title="failed: ${r}">${r}</span>`).join('')}
</div>
<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="cactus ${d.id}">
+ ${seamCallout}
<div class="state" style="${stateLabel?'display:block':''}">${stateLabel}</div>
</div>
<div class="meta">
@@ -251,11 +286,56 @@ function card(d){
});
el.querySelectorAll('.acts button').forEach(b =>
b.addEventListener('click', () => decide(d.id, b.dataset.a, el)));
+ // SHIFT+drag on the image → draw a dotted "bad area" box (id BAD elements for fixing)
+ const thumb = el.querySelector('.thumb');
+ thumb.addEventListener('mousedown', e => {
+ if (!e.shiftKey || e.target.classList.contains('x') || e.target.classList.contains('sel-box')) return;
+ e.preventDefault(); e.stopPropagation(); // block marquee-select; this is a draw
+ const rect = thumb.getBoundingClientRect();
+ const sx = (e.clientX-rect.left)/rect.width, sy = (e.clientY-rect.top)/rect.height;
+ const box = document.createElement('div'); box.className = 'bad-box drawing'; thumb.appendChild(box);
+ let cur = null;
+ const mv = ev => {
+ const cx = Math.max(0,Math.min(1,(ev.clientX-rect.left)/rect.width));
+ const cy = Math.max(0,Math.min(1,(ev.clientY-rect.top)/rect.height));
+ cur = { x:Math.min(sx,cx), y:Math.min(sy,cy), w:Math.abs(cx-sx), h:Math.abs(cy-sy) };
+ box.style.cssText = `left:${cur.x*100}%;top:${cur.y*100}%;width:${cur.w*100}%;height:${cur.h*100}%`;
+ };
+ const up = () => {
+ window.removeEventListener('mousemove', mv); window.removeEventListener('mouseup', up);
+ box.remove();
+ if (cur && cur.w > 0.02 && cur.h > 0.02) {
+ suppressClick = true; setTimeout(()=>{ suppressClick=false; }, 80);
+ addAnnotation(d, { ...cur, label:'bad' }, el);
+ }
+ };
+ window.addEventListener('mousemove', mv); window.addEventListener('mouseup', up);
+ });
+ renderBoxes(d, el);
el.addEventListener('mouseenter', () => hovered = d.id);
el.addEventListener('mouseleave', () => { if(hovered===d.id) hovered=null; });
return el;
}
+// Render the stored bad-area boxes on a card's thumb (idempotent).
+function renderBoxes(d, el){
+ const thumb = el.querySelector('.thumb');
+ thumb.querySelectorAll('.bad-box').forEach(n => n.remove());
+ (d.annotations || []).forEach((b, i) => {
+ const div = document.createElement('div'); div.className = 'bad-box';
+ div.style.cssText = `left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%`;
+ div.innerHTML = `<span class="x" title="remove">×</span>${b.label?`<span class="lbl">${b.label}</span>`:''}`;
+ div.querySelector('.x').addEventListener('click', ev => { ev.stopPropagation(); removeAnnotation(d, i, el); });
+ thumb.appendChild(div);
+ });
+}
+function addAnnotation(d, box, el){ d.annotations = d.annotations || []; d.annotations.push(box); renderBoxes(d, el); saveAnnotations(d); }
+function removeAnnotation(d, i, el){ (d.annotations||[]).splice(i,1); renderBoxes(d, el); saveAnnotations(d); }
+function saveAnnotations(d){
+ fetch(q(`/api/admin/cactus/${d.id}/annotations`), { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ boxes: d.annotations||[] }) })
+ .catch(()=>{});
+}
+
let hovered = null;
let suppressClick = false; // true briefly after a marquee drag so the click doesn't open detail
@@ -320,6 +400,10 @@ async function bulk(action){
$('bulk').querySelectorAll('button[data-a]').forEach(b => b.addEventListener('click', ()=>bulk(b.dataset.a)));
$('bulkclear').addEventListener('click', ()=>{ selected.forEach(id=>grid.querySelector(`.card[data-id="${id}"]`)?.classList.remove('sel')); selected.clear(); $('bulk').classList.remove('show'); });
+document.addEventListener('keydown', e => { if(e.key==='Shift') document.body.classList.add('shifting'); });
+document.addEventListener('keyup', e => { if(e.key==='Shift') document.body.classList.remove('shifting'); });
+window.addEventListener('blur', () => document.body.classList.remove('shifting'));
+
document.addEventListener('keydown', e => {
if(e.key==='Escape'){ closeDetail(); return; }
if(e.target.tagName==='SELECT'||e.target.tagName==='INPUT') return;
diff --git a/server.js b/server.js
index 8ad162c..5d1203e 100644
--- a/server.js
+++ b/server.js
@@ -1187,6 +1187,7 @@ app.get('/api/admin/cactus/list', (req, res) => {
"to_char(d.needs_fixing_at,'YYYY-MM-DD') AS needs_fixing_at, " +
"d.created_at, " +
"r.seam_score, r.seam_verdict, r.seam_hmid_raw, r.seam_vmid_raw, r.vision_score, " +
+ "be.boxes AS annotations, " +
"CASE " +
"WHEN r.vision_score IS NOT NULL AND r.seam_score IS NOT NULL " +
"THEN round(0.6*r.vision_score + 0.4*r.seam_score, 1) " +
@@ -1195,6 +1196,7 @@ app.get('/api/admin/cactus/list', (req, res) => {
"ELSE NULL END AS rank_score " +
"FROM all_designs d " +
"LEFT JOIN wallco_cactus_rank r ON r.design_id = d.id " +
+ "LEFT JOIN bad_examples be ON be.design_id = d.id " +
"WHERE d.category ILIKE '%cactus%' " +
// Exclude seam-healer derivatives (midheal_/edgeheal_/smartfix_): they all
// carry the old destructive blur (horizontal/vertical seam line + fuzzy
@@ -1314,6 +1316,44 @@ app.post('/api/cactus-decision/bulk', express.json({ limit: '64kb' }), (req, res
res.json({ ok: true, total: ids.length, applied: ok, errored: err, action, results });
});
+// POST /api/admin/cactus/:id/annotations body: { boxes:[{x,y,w,h,label}] }
+// Manual shift-drag "bad area" boxes from the curator. Stored on bad_examples
+// (boxes col) so a boxed design becomes a labeled anti-pattern with its exact
+// bad regions — "id BAD elements so we never create the same error". x/y/w/h
+// are fractions (0..1) of the image. Empty boxes[] clears the annotation.
+app.post('/api/admin/cactus/:id/annotations', express.json({ limit: '32kb' }), (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 raw = Array.isArray(req.body?.boxes) ? req.body.boxes : [];
+ const clamp01 = n => Math.max(0, Math.min(1, Number(n) || 0));
+ const boxes = raw.slice(0, 50).map(b => ({
+ x: clamp01(b.x), y: clamp01(b.y), w: clamp01(b.w), h: clamp01(b.h),
+ label: String(b.label || 'bad').slice(0, 40),
+ })).filter(b => b.w > 0.005 && b.h > 0.005);
+ try {
+ if (boxes.length === 0) {
+ psqlExecLocal(`UPDATE bad_examples SET boxes=NULL,
+ defect_tags=array_remove(defect_tags,'manual_bad_region'), recorded_at=now()
+ WHERE design_id=${id};`);
+ return res.json({ ok: true, id, boxes: [] });
+ }
+ const bj = JSON.stringify(boxes).replace(/'/g, "''");
+ psqlExecLocal(
+`INSERT INTO bad_examples (design_id, defect_tags, category, prompt, negative_prompt, seed, generator,
+ width_in, height_in, dominant_hex, parent_design_id, local_path, boxes, source, design_created_at)
+ SELECT s.id, ARRAY['manual_bad_region']::text[], s.category, s.prompt, s.negative_prompt, s.seed, s.generator,
+ s.width_in, s.height_in, s.dominant_hex, s.parent_design_id, s.local_path, '${bj}'::jsonb,
+ 'curator-annotation', s.created_at
+ FROM spoon_all_designs s WHERE s.id=${id}
+ ON CONFLICT (design_id) DO UPDATE SET
+ boxes='${bj}'::jsonb,
+ defect_tags=(SELECT array_agg(DISTINCT t) FROM unnest(bad_examples.defect_tags || ARRAY['manual_bad_region']) t),
+ source='curator-annotation', recorded_at=now();`);
+ res.json({ ok: true, id, boxes });
+ } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
// ── /admin/elements — curated library of "solid opaque middle" designs.
// Browse-first surface for composing new designs from quality-vetted
// existing tiles. Per the elements skill (~/.claude/skills/elements).
← 05fa1bc Quality benchmark = top-20 unified-db lines + DW Bespoke; ca
·
back to Wallco Ai
·
cactus-curator: surface bad_examples.defect_tags as red reas 65bb871 →