[object Object]

← back to Wallco Ai

seam-fix curator: heal seams of good roots as is_published=FALSE children + per-root picker

fe0b0b1c959bd8e583ddf15cb348a1eebecfe778 · 2026-06-11 13:26:56 -0700 · Steve Abrams

Steve's narrow ask: the roots are good, only the seams need fixing. This is NOT
the luxe aesthetic transform (de Gournay/1838) — each variant is a SEAM-FIXED
copy of the SAME design, composition preserved exactly (round-1 sacred).

- scripts/seam-fix-variants.py: for given root ids, mints 2 composition-preserving
  seam-fix children (v1 fix-seam shift-feather, v2 per-box circular-wrap heal),
  re-scores each with seam-defect-boxes (before/after overall_max), inserts as
  parent_design_id=root, is_published=FALSE, generator=seam-fix-v1|v2, and
  appends to data/seam-fix-queue.jsonl. ComfyUI reroll deliberately excluded
  (DTD 3/0: a reroll drifts composition).
- server.js: GET /api/seam-fix-curator (isAdmin) + POST /api/seam-fix-curator/pick
  (requireAdmin). Pick publishes the chosen child, unpublishes seam-fix siblings,
  and NEVER touches the root. Pick validates the child is a real seam-fix child
  of the root before publishing.
- public/seam-fix-curator.html: luxe-curator-style per-root picker — root + seam-fix
  candidates side by side, 2x2-tile preview to reveal the seam, before/after seam
  scores + verdict per tile, cleanest fix starred, keyboard picks.

Sample: 5 drunk-animals roots (2810,2778,43186,43198,2662), all FAIL seam gate.
v2 per-box heal is the consistent winner (FAIL -> WARN/PASS, 50-82% lower max).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files touched

Diff

commit fe0b0b1c959bd8e583ddf15cb348a1eebecfe778
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jun 11 13:26:56 2026 -0700

    seam-fix curator: heal seams of good roots as is_published=FALSE children + per-root picker
    
    Steve's narrow ask: the roots are good, only the seams need fixing. This is NOT
    the luxe aesthetic transform (de Gournay/1838) — each variant is a SEAM-FIXED
    copy of the SAME design, composition preserved exactly (round-1 sacred).
    
    - scripts/seam-fix-variants.py: for given root ids, mints 2 composition-preserving
      seam-fix children (v1 fix-seam shift-feather, v2 per-box circular-wrap heal),
      re-scores each with seam-defect-boxes (before/after overall_max), inserts as
      parent_design_id=root, is_published=FALSE, generator=seam-fix-v1|v2, and
      appends to data/seam-fix-queue.jsonl. ComfyUI reroll deliberately excluded
      (DTD 3/0: a reroll drifts composition).
    - server.js: GET /api/seam-fix-curator (isAdmin) + POST /api/seam-fix-curator/pick
      (requireAdmin). Pick publishes the chosen child, unpublishes seam-fix siblings,
      and NEVER touches the root. Pick validates the child is a real seam-fix child
      of the root before publishing.
    - public/seam-fix-curator.html: luxe-curator-style per-root picker — root + seam-fix
      candidates side by side, 2x2-tile preview to reveal the seam, before/after seam
      scores + verdict per tile, cleanest fix starred, keyboard picks.
    
    Sample: 5 drunk-animals roots (2810,2778,43186,43198,2662), all FAIL seam gate.
    v2 per-box heal is the consistent winner (FAIL -> WARN/PASS, 50-82% lower max).
    
    Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
 data/seam-fix-queue.jsonl    |  10 ++
 public/seam-fix-curator.html | 266 +++++++++++++++++++++++++++++++++++++++++++
 scripts/seam-fix-variants.py | 233 +++++++++++++++++++++++++++++++++++++
 server.js                    | 128 +++++++++++++++++++++
 4 files changed, 637 insertions(+)

diff --git a/data/seam-fix-queue.jsonl b/data/seam-fix-queue.jsonl
new file mode 100644
index 0000000..87c0995
--- /dev/null
+++ b/data/seam-fix-queue.jsonl
@@ -0,0 +1,10 @@
+{"ts": "2026-06-11T20:22:40.035606Z", "root_id": 2810, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 15.31, "after_verdict": "FAIL", "after_max": 15.07, "improved": true, "ok": true, "new_id": 57869, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2810_v1.png"}
+{"ts": "2026-06-11T20:22:40.182546Z", "root_id": 2810, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 15.31, "after_verdict": "WARN", "after_max": 5.68, "improved": true, "ok": true, "new_id": 57870, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2810_v2.png"}
+{"ts": "2026-06-11T20:22:40.432864Z", "root_id": 2778, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 12.2, "after_verdict": "FAIL", "after_max": 12.2, "improved": false, "ok": true, "new_id": 57871, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2778_v1.png"}
+{"ts": "2026-06-11T20:22:40.637114Z", "root_id": 2778, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 12.2, "after_verdict": "WARN", "after_max": 5.33, "improved": true, "ok": true, "new_id": 57872, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2778_v2.png"}
+{"ts": "2026-06-11T20:22:40.819425Z", "root_id": 43186, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 20.6, "after_verdict": "FAIL", "after_max": 13.3, "improved": true, "ok": true, "new_id": 57873, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43186_v1.png"}
+{"ts": "2026-06-11T20:22:40.959106Z", "root_id": 43186, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 20.6, "after_verdict": "WARN", "after_max": 5.77, "improved": true, "ok": true, "new_id": 57874, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43186_v2.png"}
+{"ts": "2026-06-11T20:22:41.126774Z", "root_id": 43198, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 26.92, "after_verdict": "FAIL", "after_max": 19.39, "improved": true, "ok": true, "new_id": 57875, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43198_v1.png"}
+{"ts": "2026-06-11T20:22:41.258869Z", "root_id": 43198, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 26.92, "after_verdict": "PASS", "after_max": 4.81, "improved": true, "ok": true, "new_id": 57876, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/43198_v2.png"}
+{"ts": "2026-06-11T20:22:41.470605Z", "root_id": 2662, "variant": "v1", "generator": "seam-fix-v1", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 22.01, "after_verdict": "FAIL", "after_max": 22.0, "improved": true, "ok": true, "new_id": 57877, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2662_v1.png"}
+{"ts": "2026-06-11T20:22:41.638429Z", "root_id": 2662, "variant": "v2", "generator": "seam-fix-v2", "category": "drunk-animals", "before_verdict": "FAIL", "before_max": 22.01, "after_verdict": "WARN", "after_max": 11.13, "improved": true, "ok": true, "new_id": 57878, "out": "/Users/stevestudio2/Projects/wallco-ai/data/seam-fix/2662_v2.png"}
diff --git a/public/seam-fix-curator.html b/public/seam-fix-curator.html
new file mode 100644
index 0000000..2ae2941
--- /dev/null
+++ b/public/seam-fix-curator.html
@@ -0,0 +1,266 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Seam-Fix Curator — heal the seams, keep the root</title>
+<style>
+  :root {
+    --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff;
+    --border:#e8e6e1; --root:#2a4d6e;
+    --pick:#1d6b3a; --reject:#7a7a7a;
+    --pass:#1d6b3a; --warn:#b8860b; --fail:#a02020;
+    --sans:-apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
+  }
+  * { box-sizing:border-box; }
+  body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 var(--sans); }
+  header { padding:18px 28px 14px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
+  header h1 { margin:0 0 4px; font-size:18px; font-weight:600; letter-spacing:-0.01em; }
+  header h1 .badge { display:inline-block; padding:2px 8px; font-size:11px; font-weight:600; border-radius:10px; background:#1a1a1a; color:#fff; margin-left:8px; vertical-align:middle; }
+  header .stats { color:var(--muted); font-size:12.5px; }
+  header .stats b { color:var(--fg); }
+  header .keyhelp { font-size:11px; color:var(--muted); margin-top:6px; }
+  header .keyhelp kbd { display:inline-block; padding:1px 5px; font-size:10px; background:#f1efe9; border:1px solid var(--border); border-radius:3px; font-family:ui-monospace,Menlo,monospace; margin:0 2px; }
+  header label.tilechk { font-size:11px; color:var(--muted); margin-left:14px; cursor:pointer; }
+  main { padding:22px 28px 80px; }
+  .row {
+    display:grid; grid-template-columns:repeat(3, 1fr); gap:14px;
+    margin-bottom:18px; background:var(--card); border:2px solid var(--border);
+    border-radius:10px; padding:14px; transition:opacity .25s, border-color .15s, box-shadow .15s;
+  }
+  .row.decided { opacity:0.4; pointer-events:none; }
+  .row.focused { border-color:var(--root); box-shadow:0 0 0 4px rgba(42,77,110,0.12); }
+  .row.focused .meta::after {
+    content:'⌨ focused  ·  2/3 pick a seam-fix  ·  1/K keep root (reject fixes)  ·  J/↓ next  ·  Shift+K/↑ prev';
+    display:block; font:10px ui-monospace,Menlo,monospace; color:var(--root); margin-top:4px; letter-spacing:.02em;
+  }
+  .tile { position:relative; aspect-ratio:1; background:#f0eee8; overflow:hidden; border-radius:6px; cursor:pointer; transition:transform .12s, box-shadow .12s; border:3px solid transparent; }
+  .tile:hover { transform:scale(1.02); box-shadow:0 4px 14px rgba(0,0,0,0.08); }
+  .tile.picked { border-color:var(--pick); box-shadow:0 0 0 2px rgba(29,107,58,0.18); }
+  .tile.isroot { cursor:default; }
+  .tile.isroot:hover { transform:none; box-shadow:none; }
+  /* tiled mode: show the image as a 2x2 repeat so the SEAM is visible */
+  .tile .img { width:100%; height:100%; background-position:center; background-size:cover; background-repeat:no-repeat; display:block; }
+  body.tiled .tile .img { background-size:50% 50%; background-repeat:repeat; }
+  .tile .label {
+    position:absolute; top:6px; left:6px; padding:2px 8px; font-size:10px;
+    letter-spacing:0.08em; text-transform:uppercase; border-radius:2px;
+    background:rgba(0,0,0,0.78); color:#fff; font-weight:600; z-index:2;
+  }
+  .tile .label.root { background:var(--root); }
+  .tile .label.v1   { background:#7a5a30; }
+  .tile .label.v2   { background:#30607a; }
+  .tile .score {
+    position:absolute; bottom:6px; left:6px; right:6px; padding:3px 7px; font-size:10.5px;
+    background:rgba(255,255,255,0.94); color:#1a1a1a; border-radius:3px;
+    font-weight:600; font-variant-numeric:tabular-nums; z-index:2;
+    display:flex; justify-content:space-between; align-items:center; gap:6px;
+  }
+  .verdict { font-weight:700; letter-spacing:.04em; }
+  .verdict.PASS { color:var(--pass); }
+  .verdict.WARN { color:var(--warn); }
+  .verdict.FAIL { color:var(--fail); }
+  .best { position:absolute; top:6px; right:6px; padding:2px 7px; font-size:9.5px; font-weight:700; letter-spacing:.05em; background:var(--pick); color:#fff; border-radius:10px; z-index:2; }
+  .meta {
+    grid-column:1/-1; display:flex; justify-content:space-between; align-items:center;
+    font-size:11.5px; color:var(--muted); padding:4px 4px 0;
+  }
+  .meta .info { flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; padding-right:8px; }
+  .meta .pick-status { font-weight:600; }
+  .meta button.keep-root {
+    padding:5px 12px; border:1px solid var(--border); background:var(--card);
+    border-radius:14px; font:11px var(--sans); cursor:pointer; color:var(--reject);
+    text-transform:uppercase; letter-spacing:.06em; font-weight:600;
+  }
+  .meta button.keep-root:hover { background:#f1efe9; border-color:var(--reject); }
+  .empty { padding:60px; text-align:center; color:var(--muted); font-size:14px; }
+  .empty code { background:#f1efe9; padding:2px 6px; border-radius:3px; font-size:12px; }
+  .toast { position:fixed; bottom:20px; left:50%; transform:translateX(-50%); padding:8px 16px; background:#1a1a1a; color:#fff; border-radius:20px; font-size:12.5px; opacity:0; transition:opacity .2s; pointer-events:none; z-index:99; }
+  .toast.show { opacity:1; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Seam-Fix Curator <span class="badge" id="count">…</span></h1>
+  <div class="stats">
+    <b id="pendingCount">…</b> roots pending · <b id="decidedCount">…</b> decided.
+    Same design, same aesthetic — only the tile <b>seams</b> healed. Click the seam-fix that tiles cleanest →
+    publishes that child + leaves the <b>root untouched</b> (round-1 sacred). <b>"keep root"</b> rejects all fixes.
+    <label class="tilechk"><input type="checkbox" id="tileToggle" checked> show as 2×2 tile (reveals the seam)</label>
+  </div>
+  <div class="keyhelp">
+    <kbd>1</kbd>/<kbd>K</kbd> keep root  ·  <kbd>2</kbd> pick best seam-fix  ·  <kbd>3</kbd> pick other  ·  <kbd>J</kbd>/<kbd>↓</kbd> next  ·  <kbd>Shift+K</kbd>/<kbd>↑</kbd> prev
+  </div>
+</header>
+<main>
+  <div id="rows"><div class="empty">Loading…</div></div>
+</main>
+<div class="toast" id="toast"></div>
+<script>
+let DATA = [];
+let FOCUS = 0;
+
+async function loadData() {
+  const r = await fetch('/api/seam-fix-curator');
+  if (!r.ok) throw new Error('fetch failed: ' + r.status);
+  return r.json();
+}
+
+function fmtDate(iso) {
+  if (!iso) return '—';
+  const d = new Date(iso); if (isNaN(d)) return '—';
+  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+function toast(msg) {
+  const t = document.getElementById('toast');
+  t.textContent = msg; t.classList.add('show');
+  clearTimeout(t._tm); t._tm = setTimeout(() => t.classList.remove('show'), 1700);
+}
+function vClass(v){ return v==='PASS'?'PASS':v==='WARN'?'WARN':'FAIL'; }
+
+function render() {
+  const main = document.getElementById('rows');
+  document.getElementById('count').textContent = DATA.length;
+  document.getElementById('pendingCount').textContent = DATA.length;
+  if (!DATA.length) {
+    main.innerHTML = '<div class="empty">No pending roots. Mint candidates with '
+      + '<code>python3 scripts/seam-fix-variants.py --ids 2810,2778,43186,43198,2662</code></div>';
+    return;
+  }
+  main.innerHTML = DATA.map((p, i) => {
+    // best variant = lowest after_max
+    const bestId = p.variants.length ? p.variants[0].new_id : null;
+    const variantTiles = p.variants.map(v => `
+      <div class="tile" data-pick="variant" data-id="${v.new_id}" data-variant="${v.variant}" data-row="${i}"
+           title="Pick ${v.variant} (${v.generator}) — seam ${p.before_max}→${v.after_max}">
+        <div class="img" style="background-image:url('/designs/img/by-id/${v.new_id}')"></div>
+        <span class="label ${v.variant}">${v.variant==='v1'?'v1 · shift-feather':'v2 · per-box heal'}</span>
+        ${v.new_id===bestId ? '<span class="best">★ cleanest</span>' : ''}
+        <span class="score">
+          <span>Δ ${p.before_max} → <b>${v.after_max}</b></span>
+          <span class="verdict ${vClass(v.after_verdict)}">${v.after_verdict}</span>
+        </span>
+      </div>`).join('');
+    return `
+      <div class="row" data-row="${i}" data-root="${p.root_id}" id="row-${i}">
+        <div class="tile isroot" title="Root #${p.root_id} — kept regardless of pick">
+          <div class="img" style="background-image:url('/designs/img/by-id/${p.root_id}')"></div>
+          <span class="label root">Root #${p.root_id}</span>
+          <span class="score">
+            <span>seam ${p.before_max}</span>
+            <span class="verdict ${vClass(p.before_verdict)}">${p.before_verdict}</span>
+          </span>
+        </div>
+        ${variantTiles}
+        <div class="meta">
+          <span class="info" title="root #${p.root_id} · ${p.category||''}">
+            #${p.root_id} · ${p.category||''} · 🕓 ${fmtDate(p.created_at)}
+          </span>
+          <span class="pick-status" id="status-${i}"></span>
+          <button class="keep-root" data-pick="keep" data-row="${i}">Keep root — reject fixes</button>
+        </div>
+      </div>`;
+  }).join('');
+  main.querySelectorAll('[data-pick]').forEach(el => {
+    el.addEventListener('click', () => {
+      const kind = el.dataset.pick;
+      const row = parseInt(el.dataset.row, 10);
+      if (kind === 'variant') pickVariant(row, parseInt(el.dataset.id, 10), el.dataset.variant);
+      else if (kind === 'keep') keepRoot(row);
+    });
+  });
+}
+
+async function pickVariant(rowIdx, childId, variant) {
+  const p = DATA[rowIdx]; if (!p) return;
+  try {
+    const r = await fetch('/api/seam-fix-curator/pick', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ root_id:p.root_id, picked_variant:variant, picked_id:childId }),
+    });
+    const j = await r.json();
+    if (!j.ok) throw new Error(j.error || 'pick failed');
+    const row = document.getElementById('row-' + rowIdx);
+    row.querySelectorAll('.tile').forEach(t => t.classList.remove('picked'));
+    row.querySelector(`[data-id="${childId}"]`)?.classList.add('picked');
+    document.getElementById('status-' + rowIdx).textContent =
+      `✓ ${variant} published (#${childId}) · ${j.unpublished_siblings} sibling(s) unpublished · root kept`;
+    row.classList.add('decided');
+    toast(`Seam-fix ${variant} published for root #${p.root_id}`);
+    advance(rowIdx);
+  } catch (e) { toast('ERR: ' + e.message); }
+}
+
+async function keepRoot(rowIdx) {
+  const p = DATA[rowIdx]; if (!p) return;
+  try {
+    const r = await fetch('/api/seam-fix-curator/pick', {
+      method:'POST', headers:{'Content-Type':'application/json'},
+      body: JSON.stringify({ root_id:p.root_id, picked_variant:'NONE' }),
+    });
+    const j = await r.json();
+    if (!j.ok) throw new Error(j.error || 'keep failed');
+    document.getElementById('status-' + rowIdx).textContent =
+      `✗ seam fixes rejected — root kept (${j.unpublished_siblings} unpublished)`;
+    document.getElementById('row-' + rowIdx).classList.add('decided');
+    toast(`Root #${p.root_id} kept (fixes rejected)`);
+    advance(rowIdx);
+  } catch (e) { toast('ERR: ' + e.message); }
+}
+
+function setFocus(rowIdx) {
+  document.querySelectorAll('.row.focused').forEach(r => r.classList.remove('focused'));
+  const row = document.getElementById('row-' + rowIdx);
+  if (row) { row.classList.add('focused'); row.scrollIntoView({behavior:'smooth', block:'center'}); FOCUS = rowIdx; }
+}
+function advance(rowIdx) {
+  for (let i = rowIdx + 1; i < DATA.length; i++) {
+    const row = document.getElementById('row-' + i);
+    if (row && !row.classList.contains('decided')) { setFocus(i); return; }
+  }
+}
+document.addEventListener('keydown', (e) => {
+  if (e.target.matches('input,textarea')) return;
+  const p = DATA[FOCUS]; if (!p) return;
+  const key = e.key.toLowerCase();
+  if ((key === 'k' && e.shiftKey) || key === 'arrowup') {
+    for (let i = FOCUS - 1; i >= 0; i--) {
+      if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
+    }
+    e.preventDefault(); return;
+  }
+  if (key === 'j' || key === 'arrowdown') {
+    for (let i = FOCUS + 1; i < DATA.length; i++) {
+      if (!document.getElementById('row-' + i).classList.contains('decided')) { setFocus(i); break; }
+    }
+    e.preventDefault(); return;
+  }
+  if (key === '1' || key === 'k') { keepRoot(FOCUS); e.preventDefault(); return; }
+  if (key === '2' || key === '3') {
+    const v = p.variants[parseInt(key,10) - 2];
+    if (v) { pickVariant(FOCUS, v.new_id, v.variant); e.preventDefault(); }
+    return;
+  }
+});
+function focusFirstUndecided() {
+  for (let i = 0; i < DATA.length; i++) {
+    const row = document.getElementById('row-' + i);
+    if (row && !row.classList.contains('decided')) { setFocus(i); break; }
+  }
+}
+document.getElementById('tileToggle').addEventListener('change', e => {
+  document.body.classList.toggle('tiled', e.target.checked);
+});
+
+loadData().then(d => {
+  DATA = d.items || [];
+  document.getElementById('decidedCount').textContent = d.decided || 0;
+  document.body.classList.add('tiled');
+  render();
+  focusFirstUndecided();
+}).catch(e => {
+  document.getElementById('rows').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
+});
+</script>
+</body>
+</html>
diff --git a/scripts/seam-fix-variants.py b/scripts/seam-fix-variants.py
new file mode 100644
index 0000000..b1f0fa0
--- /dev/null
+++ b/scripts/seam-fix-variants.py
@@ -0,0 +1,233 @@
+#!/usr/bin/env python3
+"""
+seam-fix-variants — for a list of GOOD root design ids that fail the seam gate,
+produce 2 COMPOSITION-PRESERVING seam-fix candidates each and land them as NEW
+children (parent_design_id=root, is_published=FALSE) for the seam-fix picker.
+
+Variants (both preserve every subject pixel — round-1 sacred):
+  v1  shift-feather   — scripts/fix-seam.py shift_and_heal (seam → center +
+                        thin gaussian band). Whole-tile, robust on edge-wrap.
+  v2  heal-region     — per-box targeted heal (v_mid/h_mid band-average +
+                        edge-wrap mirror-average) borrowing the heal math from
+                        scripts/heal-seam-region.py, on the boxes that
+                        scripts/seam-defect-boxes.py flags for this root.
+
+For each variant we re-score with seam-defect-boxes and record before/after.
+A variant that does not improve overall_max is still landed (Steve decides in
+the picker) but flagged improved=false.
+
+NEW children are inserted into all_designs with:
+  parent_design_id=<root>, is_published=FALSE, user_removed=FALSE,
+  generator='seam-fix-v1'|'seam-fix-v2', category=<root.category>,
+  tags += {'seam-fixed','seamfix-from-<root>'}, image_url='/designs/img/by-id/<id>'.
+
+A queue entry per (root,variant) is appended to data/seam-fix-queue.jsonl, which
+backs /api/seam-fix-curator and /seam-fix-curator.html.
+
+DB: resolves DATABASE_URL from .env (psql). Never hard-codes the password.
+
+Usage:
+  python3 scripts/seam-fix-variants.py --ids 2810,2778,43186,43198,2662
+  python3 scripts/seam-fix-variants.py --ids 2810 --dry-run
+"""
+import argparse, json, os, subprocess, sys
+from pathlib import Path
+from urllib.parse import urlparse
+
+ROOT = os.path.expanduser('~/Projects/wallco-ai')
+OUTDIR = os.path.join(ROOT, 'data', 'seam-fix')
+QUEUE = os.path.join(ROOT, 'data', 'seam-fix-queue.jsonl')
+
+sys.path.insert(0, os.path.join(ROOT, 'scripts'))
+from importlib import import_module
+seam = import_module('seam-defect-boxes')
+fixseam = import_module('fix-seam')
+
+
+def _database_url():
+    if os.environ.get('DATABASE_URL'):
+        return os.environ['DATABASE_URL']
+    envf = os.path.join(ROOT, '.env')
+    if os.path.exists(envf):
+        for line in open(envf):
+            if line.startswith('DATABASE_URL='):
+                return line.split('=', 1)[1].strip().strip('"')
+    raise RuntimeError('no DATABASE_URL')
+
+
+DB = _database_url()
+
+
+def psql(sql):
+    r = subprocess.run(['psql', DB, '-At', '-q', '-c', sql],
+                       capture_output=True, text=True, timeout=30)
+    if r.returncode != 0:
+        raise RuntimeError(f'psql failed: {r.stderr.strip()}')
+    return r.stdout.strip()
+
+
+def root_meta(rid):
+    row = psql(f"SELECT row_to_json(t) FROM (SELECT id, category, local_path FROM all_designs WHERE id={rid}) t;")
+    if not row:
+        raise RuntimeError(f'no design {rid}')
+    return json.loads(row)
+
+
+# ---- v2 heal math (path-side reimplementation of heal-seam-region) ----
+BLEND_PX = 6
+BAND_PX = 32
+
+
+def heal_band_mid(arr, axis, idx, start, end):
+    import numpy as np
+    H, W = arr.shape[:2]
+    if axis == 'v':
+        x0 = max(0, idx - BAND_PX // 2); x1 = min(W, idx + BAND_PX // 2)
+        left = arr[start:end, max(0, idx - BLEND_PX - 1):idx, :].mean(axis=1)
+        right = arr[start:end, idx:min(W, idx + BLEND_PX), :].mean(axis=1)
+        avg = ((left + right) / 2)
+        for x in range(x0, x1):
+            w = max(0.0, min(1.0, 1 - abs(x - idx) / (BAND_PX / 2)))
+            arr[start:end, x, :] = (avg * w + arr[start:end, x, :] * (1 - w)).round().astype('uint8')
+    else:
+        y0 = max(0, idx - BAND_PX // 2); y1 = min(H, idx + BAND_PX // 2)
+        top = arr[max(0, idx - BLEND_PX - 1):idx, start:end, :].mean(axis=0)
+        bot = arr[idx:min(H, idx + BLEND_PX), start:end, :].mean(axis=0)
+        avg = ((top + bot) / 2)
+        for y in range(y0, y1):
+            w = max(0.0, min(1.0, 1 - abs(y - idx) / (BAND_PX / 2)))
+            arr[y, start:end, :] = (avg * w + arr[y, start:end, :] * (1 - w)).round().astype('uint8')
+
+
+def heal_edge_wrap(arr, kind, start, end):
+    import numpy as np
+    H, W = arr.shape[:2]
+    S = 16
+    if kind in ('top_edge', 'bottom_edge'):
+        x0, x1 = start, end
+        top = arr[0:S, x0:x1, :].astype('float32')
+        bottom = arr[H - S:H, x0:x1, :].astype('float32')
+        bottom_f = bottom[::-1, :, :]
+        blend = ((top + bottom_f) / 2).astype('uint8')
+        arr[0:S, x0:x1, :] = blend
+        arr[H - S:H, x0:x1, :] = blend[::-1, :, :]
+    elif kind in ('left_edge', 'right_edge'):
+        y0, y1 = start, end
+        left = arr[y0:y1, 0:S, :].astype('float32')
+        right = arr[y0:y1, W - S:W, :].astype('float32')
+        right_f = right[:, ::-1, :]
+        blend = ((left + right_f) / 2).astype('uint8')
+        arr[y0:y1, 0:S, :] = blend
+        arr[y0:y1, W - S:W, :] = blend[:, ::-1, :]
+
+
+def make_v2_heal(src_path, boxes, out_path):
+    from PIL import Image
+    import numpy as np
+    img = Image.open(src_path).convert('RGB')
+    arr = np.asarray(img).copy()
+    H, W = arr.shape[:2]
+    for b in boxes:
+        k = b['kind']; x, y, w, h = b['x'], b['y'], b['w'], b['h']
+        if k == 'v_mid':
+            heal_band_mid(arr, 'v', W // 2, y, y + h)
+        elif k == 'h_mid':
+            heal_band_mid(arr, 'h', H // 2, x, x + w)
+        elif k in ('top_edge', 'bottom_edge'):
+            heal_edge_wrap(arr, k, x, x + w)
+        elif k in ('left_edge', 'right_edge'):
+            heal_edge_wrap(arr, k, y, y + h)
+    Image.fromarray(arr).save(out_path, optimize=True)
+
+
+def make_v1_shift(src_path, out_path):
+    from PIL import Image
+    im = Image.open(src_path).convert('RGB')
+    fixed = fixseam.shift_and_heal(im)
+    fixed.save(out_path, optimize=True)
+
+
+def insert_child(root, generator, new_path):
+    new_id = int(psql(
+        "INSERT INTO all_designs "
+        "(category, kind, prompt, negative_prompt, local_path, dominant_hex, "
+        " width_in, height_in, seed, tags, is_published, user_removed, generator, "
+        " parent_design_id, source_dw_sku) "
+        "SELECT category, kind, prompt, negative_prompt, "
+        f"  '{new_path}', dominant_hex, width_in, height_in, seed, "
+        f"  (COALESCE(tags, ARRAY[]::text[])) || ARRAY['seam-fixed','seamfix-from-{root}']::text[], "
+        f"  FALSE, FALSE, '{generator}', {root}, source_dw_sku "
+        f"FROM all_designs WHERE id={root} RETURNING id;"))
+    psql(f"UPDATE all_designs SET image_url='/designs/img/by-id/' || id WHERE id={new_id};")
+    return new_id
+
+
+def process(root, dry_run=False):
+    os.makedirs(OUTDIR, exist_ok=True)
+    meta = root_meta(root)
+    src = meta.get('local_path')
+    if not src or not os.path.exists(src):
+        return [{'root_id': root, 'ok': False, 'error': 'no source file'}]
+    scan_root = seam.scan_path(Path(src))
+    before = {'verdict': scan_root['verdict'], 'scores': scan_root['scores']}
+    boxes = scan_root.get('boxes', [])
+    results = []
+
+    plan = [
+        ('seam-fix-v1', 'v1', lambda outp: make_v1_shift(src, outp)),
+        ('seam-fix-v2', 'v2', lambda outp: make_v2_heal(src, boxes, outp) if boxes else None),
+    ]
+    for generator, vlabel, fn in plan:
+        outp = os.path.join(OUTDIR, f'{root}_{vlabel}.png')
+        entry = {'ts': None, 'root_id': root, 'variant': vlabel, 'generator': generator,
+                 'category': meta.get('category'),
+                 'before_verdict': before['verdict'], 'before_max': before['scores']['overall_max']}
+        if vlabel == 'v2' and not boxes:
+            entry.update({'ok': False, 'skip': 'no-boxes'})
+            results.append(entry); continue
+        produced = fn(outp)  # noqa
+        if not os.path.exists(outp):
+            entry.update({'ok': False, 'error': 'fixer produced no file'})
+            results.append(entry); continue
+        scan_after = seam.scan_path(Path(outp))
+        entry['after_verdict'] = scan_after['verdict']
+        entry['after_max'] = scan_after['scores']['overall_max']
+        entry['improved'] = scan_after['scores']['overall_max'] < before['scores']['overall_max']
+        if dry_run:
+            entry.update({'ok': True, 'dry_run': True, 'out': outp})
+            results.append(entry); continue
+        new_id = insert_child(root, generator, outp)
+        import datetime
+        entry.update({'ok': True, 'new_id': new_id, 'out': outp,
+                      'ts': datetime.datetime.utcnow().isoformat() + 'Z'})
+        results.append(entry)
+    return results
+
+
+def main():
+    ap = argparse.ArgumentParser()
+    ap.add_argument('--ids', required=True)
+    ap.add_argument('--dry-run', action='store_true')
+    args = ap.parse_args()
+    ids = [int(x) for x in args.ids.split(',') if x.strip()]
+    all_results = []
+    for rid in ids:
+        rs = process(rid, dry_run=args.dry_run)
+        all_results.extend(rs)
+        for r in rs:
+            if r.get('ok') and not r.get('dry_run'):
+                with open(QUEUE, 'a') as q:
+                    q.write(json.dumps(r) + '\n')
+        for r in rs:
+            tag = r.get('variant', '?')
+            if r.get('ok'):
+                print(f"  ✓ root {rid} {tag}: {r.get('before_max')}({r.get('before_verdict')}) "
+                      f"→ {r.get('after_max')}({r.get('after_verdict')}) "
+                      f"improved={r.get('improved')} new_id={r.get('new_id','dry')}")
+            else:
+                print(f"  ✗ root {rid} {tag}: {r.get('error') or r.get('skip')}")
+    print(json.dumps({'results': all_results}, default=str))
+
+
+if __name__ == '__main__':
+    main()
diff --git a/server.js b/server.js
index 55b71f1..c28b919 100644
--- a/server.js
+++ b/server.js
@@ -21386,6 +21386,134 @@ app.post('/api/luxe-curator/pick', requireAdmin, express.json({ limit: '4kb' }),
   } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
 });
 
+// ───────────────────────── SEAM-FIX CURATOR ─────────────────────────
+// Steve's narrow ask (2026-06-11): the roots are good — ONLY the seams need
+// healing. This is NOT the luxe (de Gournay / 1838) aesthetic transform; the
+// variant per root is a SEAM-FIXED copy of the SAME design (composition
+// preserved exactly). Children are minted by scripts/seam-fix-variants.py as
+// parent_design_id=<root>, is_published=FALSE, generator='seam-fix-v1|v2'.
+//
+// /api/seam-fix-curator — backing data for /seam-fix-curator.html. Reads
+// data/seam-fix-queue.jsonl (the audit trail from seam-fix-variants.py), groups
+// by root, and surfaces every root that has seam-fix candidates AND no decision
+// yet. Each row = { root_id, category, before_max, before_verdict,
+//   variants: [{ variant, new_id, generator, after_max, after_verdict, improved }] }.
+app.get('/api/seam-fix-curator', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    const logF = path.join(__dirname, 'data', 'seam-fix-queue.jsonl');
+    if (!fs.existsSync(logF)) return res.json({ count: 0, items: [], decided: 0 });
+    const byRoot = new Map();
+    for (const line of fs.readFileSync(logF, 'utf8').split('\n')) {
+      if (!line.trim()) continue;
+      try {
+        const r = JSON.parse(line);
+        if (!r.ok || !r.new_id) continue;
+        if (!byRoot.has(r.root_id)) {
+          byRoot.set(r.root_id, {
+            root_id: r.root_id, category: r.category || null,
+            before_max: r.before_max, before_verdict: r.before_verdict,
+            created_at: r.ts || null, variantMap: new Map(),
+          });
+        }
+        // latest entry for a given (root,variant) wins
+        byRoot.get(r.root_id).variantMap.set(r.variant, {
+          variant: r.variant, new_id: r.new_id, generator: r.generator,
+          after_max: r.after_max, after_verdict: r.after_verdict, improved: r.improved,
+        });
+        if (r.ts) {
+          const cur = byRoot.get(r.root_id).created_at;
+          if (!cur || r.ts < cur) byRoot.get(r.root_id).created_at = r.ts;
+        }
+      } catch {}
+    }
+    let decided = new Set();
+    try {
+      const out = psqlQuery(`SELECT root_id FROM seam_fix_choices`);
+      if (out) decided = new Set(out.split('\n').filter(Boolean).map(s => parseInt(s, 10)));
+    } catch { /* table may not exist yet */ }
+    const items = [...byRoot.values()]
+      .filter(r => !decided.has(r.root_id))
+      .map(r => ({
+        root_id: r.root_id, category: r.category,
+        before_max: r.before_max, before_verdict: r.before_verdict,
+        created_at: r.created_at,
+        // best-first so the cleanest seam fix sorts to the left
+        variants: [...r.variantMap.values()].sort((a, b) => (a.after_max ?? 99) - (b.after_max ?? 99)),
+      }))
+      .sort((a, b) => a.root_id - b.root_id);
+    res.json({ count: items.length, decided: decided.size, items });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
+// POST /api/seam-fix-curator/pick — record Steve's per-root seam-fix decision.
+// Body: { root_id, picked_variant: 'v1'|'v2'|'NONE', picked_id?: <child id> }
+//   picked_variant='NONE' means "none of the seam fixes are good — keep the
+//   root as-is, reject all candidates".
+// Effects (ROOT IS NEVER TOUCHED — round-1 sacred):
+//   - Insert into seam_fix_choices (idempotent on root_id).
+//   - If a variant was picked: publish that child (is_published=TRUE) and
+//     unpublish the OTHER seam-fix siblings of this root. The root keeps its
+//     own is_published state untouched.
+//   - If NONE: unpublish all seam-fix children of this root, leave root alone.
+app.post('/api/seam-fix-curator/pick', requireAdmin, express.json({ limit: '4kb' }), (req, res) => {
+  const rootId = parseInt(req.body?.root_id, 10);
+  const picked = String(req.body?.picked_variant || '').toLowerCase();
+  const childId = parseInt(req.body?.picked_id, 10);
+  if (!Number.isFinite(rootId) || !['v1', 'v2', 'none'].includes(picked)) {
+    return res.status(400).json({ ok: false, error: 'bad input' });
+  }
+  if (picked !== 'none' && !Number.isFinite(childId)) {
+    return res.status(400).json({ ok: false, error: 'picked_id required when picking a variant' });
+  }
+  try {
+    psqlExecLocal(`
+      CREATE TABLE IF NOT EXISTS seam_fix_choices (
+        root_id        BIGINT PRIMARY KEY,
+        picked_variant TEXT NOT NULL,
+        picked_id      BIGINT,
+        decided_at     TIMESTAMPTZ NOT NULL DEFAULT NOW()
+      )`);
+    // Safety: confirm the picked child is genuinely a seam-fix child of this
+    // root before publishing it (defense against a forged picked_id).
+    if (picked !== 'none') {
+      const ok = psqlQuery(`SELECT 1 FROM all_designs WHERE id=${childId} AND parent_design_id=${rootId} AND generator LIKE 'seam-fix-%' LIMIT 1`);
+      if (!ok || !ok.trim()) {
+        return res.status(400).json({ ok: false, error: 'picked_id is not a seam-fix child of this root' });
+      }
+    }
+    psqlExecLocal(`
+      INSERT INTO seam_fix_choices (root_id, picked_variant, picked_id, decided_at)
+      VALUES (${rootId}, '${picked}', ${Number.isFinite(childId) ? childId : 'NULL'}, NOW())
+      ON CONFLICT (root_id) DO UPDATE
+        SET picked_variant = EXCLUDED.picked_variant,
+            picked_id      = EXCLUDED.picked_id,
+            decided_at     = NOW()`);
+    let publishedId = null, unpublishedSiblings = 0;
+    if (picked !== 'none') {
+      psqlExecLocal(`UPDATE all_designs SET is_published=TRUE WHERE id=${childId}`);
+      publishedId = childId;
+      const out = psqlQuery(`
+        UPDATE all_designs SET is_published=FALSE
+        WHERE parent_design_id=${rootId}
+          AND generator LIKE 'seam-fix-%'
+          AND id <> ${childId}
+        RETURNING id`);
+      unpublishedSiblings = out ? out.split('\n').filter(Boolean).length : 0;
+    } else {
+      const out = psqlQuery(`
+        UPDATE all_designs SET is_published=FALSE
+        WHERE parent_design_id=${rootId}
+          AND generator LIKE 'seam-fix-%'
+        RETURNING id`);
+      unpublishedSiblings = out ? out.split('\n').filter(Boolean).length : 0;
+    }
+    res.json({ ok: true, root_id: rootId, picked, published_id: publishedId, unpublished_siblings: unpublishedSiblings });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
 // /api/luxe-c-gallery — backing data for /luxe-c-gallery.html.
 // Reads data/luxe-c-rollout.jsonl (the audit trail from scripts/roll-luxe-c.js)
 // and returns OK rows for the root→luxe gallery view, deduped on root_id.

← bda8252 id-collision resolutions re-run 1..14999 (125 live: 77 F,F s  ·  back to Wallco Ai  ·  TODO: log seam-fix curator sample + Steve pick step 4a70b85 →