[object Object]

← 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

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 →