[object Object]

← back to Wallco Ai

cactus-curator: click-any-image → full-screen heal modal (drag-to-mark numbered red dotted boxes, ⚕ Heal calls Replicate SDXL inpaint with manual_boxes, ↺ Back pops history stack to revert, ✓ Accept promotes via /heal/approve, ✕ Close/Esc discards); by-base family cards add 🕓 created date pill; seam-debug also gains drag-to-add manual heal regions (M1, M2…) merged into heal endpoint via new manual_boxes payload; python normalizes scanner pixel-coords + accepts --manual fraction boxes

09b88ed6ff582c37e86e2b5636c18064eafa4b58 · 2026-05-28 07:20:21 -0700 · Steve Abrams

Files touched

Diff

commit 09b88ed6ff582c37e86e2b5636c18064eafa4b58
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 07:20:21 2026 -0700

    cactus-curator: click-any-image → full-screen heal modal (drag-to-mark numbered red dotted boxes, ⚕ Heal calls Replicate SDXL inpaint with manual_boxes, ↺ Back pops history stack to revert, ✓ Accept promotes via /heal/approve, ✕ Close/Esc discards); by-base family cards add 🕓 created date pill; seam-debug also gains drag-to-add manual heal regions (M1, M2…) merged into heal endpoint via new manual_boxes payload; python normalizes scanner pixel-coords + accepts --manual fraction boxes
---
 public/admin/cactus-curator.html | 174 +++++++++++++++++++++++++++++++++++++++
 public/admin/seam-debug.html     |  57 ++++++++++++-
 scripts/heal-region-inpaint.py   |  24 ++++--
 server.js                        |  22 +++--
 4 files changed, 262 insertions(+), 15 deletions(-)

diff --git a/public/admin/cactus-curator.html b/public/admin/cactus-curator.html
index 6bf2807..0c0add9 100644
--- a/public/admin/cactus-curator.html
+++ b/public/admin/cactus-curator.html
@@ -848,6 +848,7 @@ load();
           <span class="dot" style="background:${dot}"></span>
           <span>${all.length} cw</span>
         </div>
+        <div class="when" title="created ${fam.created_at||''}">🕓 ${(typeof fmtDate==='function' ? fmtDate(fam.created_at) : (fam.created_at||'').slice(0,16).replace('T',' '))}</div>
       </div>
       <div class="bbstrip" style="display:flex;gap:3px;overflow-x:auto;padding:0 8px 9px;scrollbar-width:thin">
         ${all.map(c => {
@@ -981,6 +982,179 @@ load();
     if (localStorage.getItem(LS_KEY) === '1') setMode(true);
   }).observe(document.body, { childList: true, subtree: true });
 })();
+
+// ===== Click-to-Heal modal ===================================================
+// Click any card image → large lightbox; drag to mark heal regions (numbered);
+// ⚕ Heal → calls inpaint, pushes current onto history, swaps in the new image;
+// ↺ Back reverts to the previous version; ✓ Accept promotes the latest (via
+// /api/seam-debug/heal/approve, original gets user_removed=TRUE + logged); ✕
+// Close discards (heal-children remain as unpublished siblings, recoverable).
+(() => {
+  let modal = null, original = null, current = null, history = [], manual = [];
+  const adm = new URLSearchParams(location.search).get('admin');
+  const q = u => adm ? u + (u.includes('?') ? '&' : '?') + 'admin=' + encodeURIComponent(adm) : u;
+
+  function ensureModal() {
+    if (modal) return modal;
+    modal = document.createElement('div'); modal.id = 'healmodal';
+    modal.style.cssText = 'position:fixed;inset:0;z-index:300;background:rgba(8,9,11,.97);display:none;flex-direction:column;color:#eee;font:13px ui-monospace,monospace';
+    modal.innerHTML = `
+      <header style="display:flex;align-items:center;gap:10px;padding:10px 14px;border-bottom:1px solid #333">
+        <button id="hmback" style="background:#2a2a2a;color:#eee;border:1px solid #444;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer" disabled>↺ Back</button>
+        <span id="hmtitle" style="flex:1;text-align:center;color:#aaa">—</span>
+        <span id="hmsel" style="color:#ffd0d0">0 regions</span>
+        <button id="hmheal" style="background:#3b2e08;color:#f0b400;border:1px solid #5a4710;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer">⚕ Heal selected</button>
+        <button id="hmaccept" style="background:#2a6b35;color:#fff;border:0;padding:7px 16px;font-weight:800;border-radius:8px;cursor:pointer" disabled>✓ Accept</button>
+        <button id="hmclose" style="background:#3a2222;color:#ffdada;border:1px solid #663;padding:7px 14px;font-weight:700;border-radius:8px;cursor:pointer">✕ Close</button>
+      </header>
+      <main style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;padding:12px">
+        <div id="hmwrap" style="position:relative;background:#000;line-height:0">
+          <img id="hmimg" style="display:block;max-width:88vw;max-height:78vh;object-fit:contain;background:#fff;-webkit-user-drag:none" draggable="false">
+          <div id="hmovl" style="position:absolute;inset:0;cursor:crosshair"></div>
+        </div>
+      </main>
+      <footer id="hmfoot" style="padding:8px 14px;color:#888;border-top:1px solid #333;text-align:center;font-size:11px">drag on image to mark a heal region · click × to remove · ⚕ Heal → ↺ Back to revert or ✓ Accept to replace the original</footer>`;
+    document.body.appendChild(modal);
+    modal.querySelector('#hmclose').onclick = closeModal;
+    modal.querySelector('#hmback').onclick = back;
+    modal.querySelector('#hmheal').onclick = heal;
+    modal.querySelector('#hmaccept').onclick = accept;
+    wireDraw();
+    return modal;
+  }
+
+  function updTitle() {
+    modal.querySelector('#hmtitle').textContent =
+      `#${current}  ·  original #${original}  ·  history ${history.length}${history.length?'  (Back to revert)':''}`;
+    modal.querySelector('#hmsel').textContent = `${manual.length} regions`;
+    modal.querySelector('#hmback').disabled = history.length === 0;
+    modal.querySelector('#hmaccept').disabled = current === original;   // no heal applied yet
+  }
+  function drawBoxes() {
+    const ovl = modal.querySelector('#hmovl');
+    ovl.innerHTML = manual.map((b, i) =>
+      `<div data-idx="${i}" style="position:absolute;left:${b.x*100}%;top:${b.y*100}%;width:${b.w*100}%;height:${b.h*100}%;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);box-sizing:border-box;cursor:pointer">
+        <b style="position:absolute;top:-2px;left:-2px;background:#ff3b3b;color:#fff;font:800 11px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;min-width:18px;text-align:center">${i+1}</b>
+        <span class="rm" style="position:absolute;top:-9px;right:-9px;width:18px;height:18px;line-height:16px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif;cursor:pointer">×</span>
+      </div>`).join('');
+    ovl.querySelectorAll('.rm').forEach(x => x.onclick = e => { e.stopPropagation(); const i=+x.parentElement.dataset.idx; manual.splice(i,1); drawBoxes(); updTitle(); });
+  }
+  function wireDraw() {
+    const ovl = modal.querySelector('#hmovl');
+    ovl.addEventListener('mousedown', e => {
+      if (e.target.closest('div[data-idx]')) return;
+      const rect = ovl.getBoundingClientRect();
+      const x0 = (e.clientX - rect.left)/rect.width, y0 = (e.clientY - rect.top)/rect.height;
+      if (x0<0||x0>1||y0<0||y0>1) return; e.preventDefault();
+      const g = document.createElement('div');
+      g.style.cssText = 'position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box';
+      ovl.appendChild(g);
+      const mv = ev => {
+        const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+        const L=Math.min(x0,x),T=Math.min(y0,y),W=Math.abs(x-x0),H=Math.abs(y-y0);
+        g.style.cssText += `;left:${L*100}%;top:${T*100}%;width:${W*100}%;height:${H*100}%`;
+      };
+      const up = ev => {
+        document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
+        const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+        const L=Math.max(0,Math.min(x0,x)), T=Math.max(0,Math.min(y0,y));
+        const W=Math.min(1-L,Math.abs(x-x0)), H=Math.min(1-T,Math.abs(y-y0));
+        g.remove();
+        if (W>0.01 && H>0.01) {
+          manual.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+W.toFixed(4),h:+H.toFixed(4),label:'manual-'+(manual.length+1)});
+          drawBoxes(); updTitle();
+        }
+      };
+      document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
+    });
+  }
+
+  function showId(id) {
+    current = id;
+    modal.querySelector('#hmimg').src = `/designs/img/by-id/${id}` + (adm?'?admin='+encodeURIComponent(adm):'');
+    manual = []; drawBoxes(); updTitle();
+  }
+  function openModal(id) {
+    ensureModal();
+    original = id; current = id; history = []; manual = [];
+    modal.style.display = 'flex';
+    showId(id);
+  }
+  function closeModal() {
+    if (modal) modal.style.display = 'none';
+    original = current = null; history = []; manual = [];
+  }
+  async function heal() {
+    if (!manual.length) { flash('drag on image to mark a region first'); return; }
+    const btn = modal.querySelector('#hmheal'); const orig = btn.textContent;
+    btn.disabled = true; btn.textContent = `⚕ healing ${manual.length}…`;
+    const status = modal.querySelector('#hmfoot');
+    status.textContent = `inpainting ${manual.length} region(s) on #${current} via Replicate (~3-8s)…`;
+    try {
+      const r = await fetch(q(`/api/seam-debug/${current}/heal`), {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ box_ids: [], manual_boxes: manual }),
+      });
+      const j = await r.json();
+      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+      history.push({ id: current, boxes: manual.slice() });
+      showId(j.new_id);
+      status.textContent = `✓ heal #${j.new_id} ready — ⚕ heal again to refine, ↺ Back to revert, or ✓ Accept to replace original #${original}`;
+    } catch (e) {
+      status.textContent = '✗ heal failed: ' + e.message;
+    } finally {
+      btn.disabled = false; btn.textContent = orig;
+    }
+  }
+  function back() {
+    const prev = history.pop(); if (!prev) return;
+    showId(prev.id);
+    modal.querySelector('#hmfoot').textContent = `reverted to #${prev.id} — draw new regions and ⚕ heal again`;
+  }
+  async function accept() {
+    if (current === original) { flash('no heal applied yet'); return; }
+    const status = modal.querySelector('#hmfoot');
+    status.textContent = `approving #${current} as the replacement for #${original}…`;
+    try {
+      const r = await fetch(q('/api/seam-debug/heal/approve'), {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ src_id: original, heal_id: current }),
+      });
+      const j = await r.json();
+      if (!r.ok || !j.ok) throw new Error(j.error || ('HTTP ' + r.status));
+      status.textContent = `✓ accepted — #${original} marked removed, #${current} ${j.promoted_to_published?'now live on storefront':'kept unpublished'}. reloading page…`;
+      setTimeout(() => location.reload(), 700);
+    } catch (e) { status.textContent = '✗ accept failed: ' + e.message; }
+  }
+  function flash(msg) {
+    const f = modal.querySelector('#hmfoot'); const orig = f.textContent;
+    f.textContent = msg; f.style.color = '#ffd0d0';
+    setTimeout(() => { f.textContent = orig; f.style.color = '#888'; }, 2000);
+  }
+
+  // Intercept clicks on any card image in the catalog (flat grid or by-base
+  // layout). Anchor wrappers go to /admin/seam-debug — we open the modal here
+  // instead and let them shift-click for the new-tab nav.
+  addEventListener('click', e => {
+    if (e.shiftKey || e.metaKey || e.ctrlKey) return;     // power users open in tab
+    const img = e.target.closest('.card img, .card.bbfam img, .bbstrip img');
+    if (!img) return;
+    const id = (() => {
+      const m = img.closest('a[href*="/admin/seam-debug/"]');
+      if (m) { const x = m.getAttribute('href').match(/\/(\d+)/); if (x) return parseInt(x[1], 10); }
+      const c = img.closest('[data-id],[data-base]');
+      return c ? parseInt(c.dataset.id || c.dataset.base, 10) : null;
+    })();
+    if (!Number.isFinite(id)) return;
+    e.preventDefault(); e.stopPropagation();
+    openModal(id);
+  }, true);
+  addEventListener('keydown', e => {
+    if (!modal || modal.style.display !== 'flex') return;
+    if (e.key === 'Escape') closeModal();
+    else if (e.key === 'Backspace' && history.length) back();
+  });
+})();
 </script>
 </body>
 </html>
diff --git a/public/admin/seam-debug.html b/public/admin/seam-debug.html
index 36180f5..7e5f510 100644
--- a/public/admin/seam-debug.html
+++ b/public/admin/seam-debug.html
@@ -139,7 +139,54 @@ function redraw(){
       ovl.appendChild(d);
     });
   }
+  // Manual user-drawn boxes — always rendered (independent of t_box toggle) so a
+  // hand-marked region is always visible while the user is curating it.
+  MANUAL.forEach((m,i)=>{
+    const d=document.createElement('div'); d.className='box manual';
+    d.style.cssText=`left:${m.x*iw}px;top:${m.y*ih}px;width:${m.w*iw}px;height:${m.h*ih}px;border:2px dashed #ff3b3b;background:rgba(255,59,59,.14);position:absolute;box-sizing:border-box;cursor:pointer;z-index:5`;
+    d.innerHTML=`<span class="lbl" style="background:#ff3b3b;color:#fff;font:800 10px sans-serif;padding:1px 6px;border-radius:3px 0 3px 0;position:absolute;top:-2px;left:-2px">M${i+1}</span>
+                 <span class="x" style="position:absolute;top:-9px;right:-9px;width:17px;height:17px;line-height:15px;text-align:center;background:#ff3b3b;color:#fff;border-radius:50%;font:800 12px sans-serif">×</span>`;
+    d.title=`manual mark M${i+1} — click × to remove · always healed`;
+    d.querySelector('.x').onclick=ev=>{ ev.stopPropagation(); MANUAL.splice(i,1); redraw(); updHeal(); };
+    ovl.appendChild(d);
+  });
 }
+// Drag-to-draw a manual heal region on the image. Hand-marked boxes are added
+// to MANUAL[] as fractions and always sent to the heal endpoint (don't need to
+// be in SEL — they're a separate channel).
+(function wireManualDraw(){
+  const ovl=$('ovl');
+  ovl.addEventListener('mousedown', e => {
+    if(!CUR) return;
+    if(e.button!==0) return;
+    if(e.target.closest('.box')) return;          // clicking an existing box = toggle, not draw
+    const rect=ovl.getBoundingClientRect();
+    const x0=(e.clientX-rect.left)/rect.width, y0=(e.clientY-rect.top)/rect.height;
+    if(x0<0||x0>1||y0<0||y0>1) return;
+    e.preventDefault();
+    const ghost=document.createElement('div');
+    ghost.style.cssText='position:absolute;border:2px dashed #ff3b3b;background:rgba(255,59,59,.22);pointer-events:none;box-sizing:border-box;z-index:6';
+    ovl.appendChild(ghost);
+    const W=rect.width, H=rect.height;
+    const mv=ev=>{
+      const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+      const L=Math.min(x0,x),T=Math.min(y0,y),WW=Math.abs(x-x0),HH=Math.abs(y-y0);
+      ghost.style.cssText+=`;left:${L*W}px;top:${T*H}px;width:${WW*W}px;height:${HH*H}px`;
+    };
+    const up=ev=>{
+      document.removeEventListener('mousemove',mv); document.removeEventListener('mouseup',up);
+      const x=(ev.clientX-rect.left)/rect.width, y=(ev.clientY-rect.top)/rect.height;
+      const L=Math.max(0,Math.min(x0,x)),T=Math.max(0,Math.min(y0,y));
+      const WW=Math.min(1-L,Math.abs(x-x0)),HH=Math.min(1-T,Math.abs(y-y0));
+      ghost.remove();
+      if(WW>0.01 && HH>0.01){
+        MANUAL.push({x:+L.toFixed(4),y:+T.toFixed(4),w:+WW.toFixed(4),h:+HH.toFixed(4),label:'manual-'+(MANUAL.length+1)});
+        redraw(); updHeal();
+      }
+    };
+    document.addEventListener('mousemove',mv); document.addEventListener('mouseup',up);
+  });
+})();
 
 async function load(){
   $('err').style.display='none';
@@ -168,15 +215,17 @@ async function load(){
   }catch(e){ $('err').style.display='block'; $('err').textContent='Failed: '+e.message; }
 }
 async function healSelected(boxIds){
-  if(!boxIds.length){ alert('Click boxes to select what to heal.'); return; }
+  const manual = MANUAL.slice();
+  if(!boxIds.length && !manual.length){ alert('Click auto-detected boxes OR drag on the image to mark a region, then heal.'); return; }
   const id=$('idin').value.trim(); if(!id) return;
-  const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${boxIds.length}…`;
+  const total = boxIds.length + manual.length;
+  const btn=$('healbtn'); const orig=btn.textContent; btn.disabled=true; btn.textContent=`⚕ healing ${total}…`;
   try{
-    const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds})});
+    const r=await fetch(q(`/api/seam-debug/${id}/heal`),{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({box_ids:boxIds, manual_boxes:manual})});
     const j=await r.json();
     if(!r.ok||!j.ok){ alert('Heal failed: '+(j.error||r.status)+(j.stderr?'\n\n'+j.stderr:'')); btn.textContent=orig; updHeal(); return; }
     showApproveModal({ kind: 'heal', src_id: id, new_id: j.new_id, scan_before: j.scan_before, scan_after: j.scan_after });
-    SEL.clear(); redraw(); updHeal();
+    SEL.clear(); MANUAL = []; redraw(); updHeal();
   }catch(e){ alert('Heal failed: '+e.message); btn.textContent=orig; updHeal(); }
 }
 // === Preview/Approve modal ============================================
diff --git a/scripts/heal-region-inpaint.py b/scripts/heal-region-inpaint.py
index 8b1e451..783003a 100644
--- a/scripts/heal-region-inpaint.py
+++ b/scripts/heal-region-inpaint.py
@@ -135,6 +135,7 @@ def main():
     ap = argparse.ArgumentParser()
     ap.add_argument('--id', type=int, required=True)
     ap.add_argument('--boxes', required=True, help='JSON array of box INDICES into the scanner output')
+    ap.add_argument('--manual', default='[]', help='JSON array of {x,y,w,h,label} in fractions (user-drawn)')
     args = ap.parse_args()
 
     src = fetch_design(args.id)
@@ -145,14 +146,27 @@ def main():
     # scan once to enumerate defect boxes, then select the requested indices
     scan_before = scan(args.id)
     all_boxes = scan_before.get('boxes', []) if isinstance(scan_before, dict) else []
+    sw = scan_before.get('width') if isinstance(scan_before, dict) else None
+    sh = scan_before.get('height') if isinstance(scan_before, dict) else None
     sel_ix = set(json.loads(args.boxes))
-    boxes = [b for i, b in enumerate(all_boxes) if i in sel_ix]
-    if not boxes:
-        print(json.dumps({'ok': False, 'error': 'no boxes matched the requested indices', 'available': len(all_boxes)}))
-        sys.exit(1)
-
     src_img = Image.open(src['local_path']).convert('RGB')
     W, H = src_img.size
+    # Scanner emits PIXEL coords (x,y,w,h relative to scan_before.width/height).
+    # Manual user-drawn boxes come in as fractions (0..1). Normalize scanner
+    # boxes to fractions so build_mask (fraction-based) works for both, then
+    # merge into one list.
+    def px_to_frac(b):
+        ww = sw or W; hh = sh or H
+        return {'x': b['x']/ww, 'y': b['y']/hh, 'w': b['w']/ww, 'h': b['h']/hh, 'label': b.get('kind','auto')}
+    scanner_sel = [px_to_frac(b) for i, b in enumerate(all_boxes) if i in sel_ix]
+    manual_sel = []
+    try: manual_sel = json.loads(args.manual)
+    except Exception: pass
+    boxes = scanner_sel + manual_sel
+    if not boxes:
+        print(json.dumps({'ok': False, 'error': 'no boxes selected (scanner indices + manual both empty)',
+                          'available_scanner': len(all_boxes)}))
+        sys.exit(1)
     mask = build_mask(W, H, boxes)
 
     # persist the mask alongside for debugging / approval review
diff --git a/server.js b/server.js
index 1831754..85cc1c5 100644
--- a/server.js
+++ b/server.js
@@ -1280,17 +1280,27 @@ app.get('/admin/etsy-bucket', (req, res) => {
 // mirrored to the wrap-opposite edge so the result stays tileable. Saves the
 // heal as a NEW design (parent_design_id=src) so the original survives until
 // the curator hits Approve.
-app.post('/api/seam-debug/:id/heal', express.json({ limit: '16kb' }), (req, res) => {
+app.post('/api/seam-debug/:id/heal', express.json({ limit: '32kb' }), (req, res) => {
   if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
   const id = parseInt(req.params.id, 10);
   const boxIds = Array.isArray(req.body && req.body.box_ids) ? req.body.box_ids.map(n => parseInt(n, 10)).filter(Number.isFinite) : [];
+  // Manual user-drawn boxes — fractions 0..1 (x,y,w,h) + optional label. These
+  // are healed alongside the scanner-detected boxes; the curator draws them on
+  // any region the scanner missed (subtle tone-on-tone defects, structural
+  // breaks that don't trip the ΔE detector).
+  const manualRaw = Array.isArray(req.body && req.body.manual_boxes) ? req.body.manual_boxes : [];
+  const clamp01 = n => Math.max(0, Math.min(1, Number(n) || 0));
+  const manualBoxes = manualRaw.slice(0, 32).map(b => ({
+    x: clamp01(b.x), y: clamp01(b.y), w: clamp01(b.w), h: clamp01(b.h),
+    label: String(b.label || 'manual').slice(0, 40),
+  })).filter(b => b.w > 0.005 && b.h > 0.005);
   if (!Number.isFinite(id)) return res.status(400).json({ error: 'bad id' });
-  if (!boxIds.length) return res.status(400).json({ error: 'no boxes selected' });
+  if (!boxIds.length && !manualBoxes.length) return res.status(400).json({ error: 'no boxes selected' });
   try {
-    const out = _execFileSyncSeam('python3',
-      [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
-       '--id', String(id), '--boxes', JSON.stringify(boxIds)],
-      { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
+    const args = [path.join(__dirname, 'scripts', 'heal-region-inpaint.py'),
+                  '--id', String(id), '--boxes', JSON.stringify(boxIds)];
+    if (manualBoxes.length) { args.push('--manual', JSON.stringify(manualBoxes)); }
+    const out = _execFileSyncSeam('python3', args, { encoding: 'utf8', timeout: 180000, maxBuffer: 4 * 1024 * 1024 });
     res.json(JSON.parse(out));
   } catch (e) {
     res.status(500).json({ error: e.message, stderr: (e.stderr || '').toString().slice(0, 500) });

← ade5237 cactus-curator by-base: full selection + action parity with  ·  back to Wallco Ai  ·  etsy activation wizard: /admin/etsy-setup — single-page stat 02f7eab →