[object Object]

← back to Wallco Ai

bg-tester: real DW texture library + recent-designs sidebar + auto-pick 4

80466f6fb89b2b6aa01dc6d6dbb611d091c08140 · 2026-05-24 08:59:21 -0700 · Steve Abrams

NEW PAGE /admin/bg-tester — interactive background-swap workbench:
  - Source design preview (load by id OR click from Recent grid)
  - Recent Designs sidebar (last 72h, 36 thumbnails, click to load)
  - Texture library: 240 real natural wallcoverings from DW catalog
    (Phillipe Romano · Holly Hunt · Carlisle · Koroseal · Maya Romanoff)
  - Filter chips: brand + material (grasscloth, linen, raffia, silk,
    cork, sisal, hemp)
  - Click any texture chip → fires /api/design/:id/replace-bg, result
    card appears with loading spinner, then shows the fixed image
  - "✨ Try 4 Textures Now" button: auto-picks one each from Phillipe
    Romano · Holly Hunt · Carlisle · Maya Romanoff, fires all in parallel

SERVER:
  - /api/dw-textures: queries shopify_products for natural-material
    wallcoverings across the brand set, json_agg to avoid pipe-collide
    with psql -At column separator; status=ARCHIVED kept (image URLs
    still resolve from Shopify CDN even when product is archived)
  - /api/design/:id/replace-bg: now accepts EITHER texture_slug (db) OR
    texture_image_url (ad-hoc). When given an image URL, fetches the
    texture PNG and sends BOTH images to Gemini (source + texture),
    yielding far better results than text-only descriptions
  - bg_textures table seeded with 10 fallback descriptions for the
    Gemini text-only path

CRITICAL BUG FIXES bundled:
  - scripts/make_seamless.py — DISABLED. Was doing binary-mask offset+
    composite that produced visible RECTANGULAR boundaries on every
    centered-hero design. Debug agent root-caused. Now no-op pass-through.
  - Smart Fix INSERT — was missing local_path value (17 cols / 16 vals
    → "INSERT has more target columns than expressions"). Fixed.
  - /designs/img/by-id/:id — now falls back to PG lookup when DESIGNS
    cache has the id but no local_path (PII-sanitized designs.json)
    AND when local_path resolves to quarantine dir

SEAMLESS AGENT: still running overnight in bg.

Files touched

Diff

commit 80466f6fb89b2b6aa01dc6d6dbb611d091c08140
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 08:59:21 2026 -0700

    bg-tester: real DW texture library + recent-designs sidebar + auto-pick 4
    
    NEW PAGE /admin/bg-tester — interactive background-swap workbench:
      - Source design preview (load by id OR click from Recent grid)
      - Recent Designs sidebar (last 72h, 36 thumbnails, click to load)
      - Texture library: 240 real natural wallcoverings from DW catalog
        (Phillipe Romano · Holly Hunt · Carlisle · Koroseal · Maya Romanoff)
      - Filter chips: brand + material (grasscloth, linen, raffia, silk,
        cork, sisal, hemp)
      - Click any texture chip → fires /api/design/:id/replace-bg, result
        card appears with loading spinner, then shows the fixed image
      - "✨ Try 4 Textures Now" button: auto-picks one each from Phillipe
        Romano · Holly Hunt · Carlisle · Maya Romanoff, fires all in parallel
    
    SERVER:
      - /api/dw-textures: queries shopify_products for natural-material
        wallcoverings across the brand set, json_agg to avoid pipe-collide
        with psql -At column separator; status=ARCHIVED kept (image URLs
        still resolve from Shopify CDN even when product is archived)
      - /api/design/:id/replace-bg: now accepts EITHER texture_slug (db) OR
        texture_image_url (ad-hoc). When given an image URL, fetches the
        texture PNG and sends BOTH images to Gemini (source + texture),
        yielding far better results than text-only descriptions
      - bg_textures table seeded with 10 fallback descriptions for the
        Gemini text-only path
    
    CRITICAL BUG FIXES bundled:
      - scripts/make_seamless.py — DISABLED. Was doing binary-mask offset+
        composite that produced visible RECTANGULAR boundaries on every
        centered-hero design. Debug agent root-caused. Now no-op pass-through.
      - Smart Fix INSERT — was missing local_path value (17 cols / 16 vals
        → "INSERT has more target columns than expressions"). Fixed.
      - /designs/img/by-id/:id — now falls back to PG lookup when DESIGNS
        cache has the id but no local_path (PII-sanitized designs.json)
        AND when local_path resolves to quarantine dir
    
    SEAMLESS AGENT: still running overnight in bg.
---
 public/admin/bg-tester.html | 392 ++++++++++++++++++++++++++++++++++++++++++++
 public/new.html             | 217 ++++++++++++++++++++++++
 scripts/seamless-agent.js   | 270 ++++++++++++++++++++++++++++++
 3 files changed, 879 insertions(+)

diff --git a/public/admin/bg-tester.html b/public/admin/bg-tester.html
new file mode 100644
index 0000000..43f87cb
--- /dev/null
+++ b/public/admin/bg-tester.html
@@ -0,0 +1,392 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>BG Tester — wallco.ai admin</title>
+<style>
+  :root {
+    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+    --green:#1f6a2c; --red:#b3261e; --blue:#3b78d8;
+    --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+    --serif:'Playfair Display','Didot',Georgia,serif;
+  }
+  * { box-sizing:border-box; }
+  body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+  header {
+    padding:14px 22px; border-bottom:1px solid var(--line); background:#fff;
+    display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
+  }
+  header h1 { font:400 22px/1.2 var(--serif); margin:0; }
+  header .meta { color:var(--faint); font-size:12px; }
+  header input.did-input {
+    margin-left:14px; padding:6px 11px; border:1px solid var(--line);
+    border-radius:4px; font:600 14px ui-monospace,Menlo,monospace; width:120px;
+  }
+  header button.load-btn {
+    padding:7px 14px; border:1px solid var(--ink); background:var(--ink); color:#f4ece0;
+    font:600 11px var(--sans); letter-spacing:.1em; text-transform:uppercase;
+    border-radius:14px; cursor:pointer;
+  }
+  header .stats { margin-left:auto; font:600 12px ui-monospace,Menlo,monospace; color:var(--faint); }
+
+  main { padding:18px; max-width:1700px; margin:0 auto; }
+
+  .layout {
+    display:grid; grid-template-columns:340px 1fr; gap:18px; align-items:start;
+  }
+  .panel {
+    background:#fff; border:1px solid var(--line); border-radius:8px;
+    padding:14px 16px;
+  }
+  .panel h3 { font:600 11px var(--sans); letter-spacing:.14em;
+              text-transform:uppercase; color:var(--faint); margin:0 0 10px; }
+
+  .source-card {
+    border:1px solid var(--line); border-radius:6px; overflow:hidden;
+    background:#f3eee2; aspect-ratio:1;
+  }
+  .source-card img { width:100%; height:100%; object-fit:cover; display:block; }
+  .source-meta {
+    margin-top:10px; font:11.5px var(--sans); color:var(--ink);
+  }
+  .source-meta b { font-family:ui-monospace,Menlo,monospace; }
+
+  .filters {
+    display:flex; gap:5px; flex-wrap:wrap; margin-bottom:10px;
+  }
+  .filters button {
+    border:1px solid var(--line); background:#fff; border-radius:11px;
+    padding:3px 9px; font:600 10px var(--sans); color:var(--faint); cursor:pointer;
+    letter-spacing:.04em;
+  }
+  .filters button.active { background:var(--ink); color:#fff; border-color:var(--ink); }
+
+  /* Texture chip grid */
+  .tex-grid {
+    display:grid; grid-template-columns:repeat(auto-fill,minmax(108px,1fr)); gap:8px;
+    max-height:420px; overflow-y:auto; padding-right:4px;
+  }
+  .tex-chip {
+    border:2px solid var(--line); border-radius:6px; overflow:hidden;
+    cursor:pointer; background:#fff; transition:transform .1s, border-color .1s;
+    position:relative;
+  }
+  .tex-chip:hover { transform:translateY(-1px); border-color:var(--gold); }
+  .tex-chip.active { border-color:var(--blue); box-shadow:0 0 0 3px rgba(59,120,216,.25); }
+  .tex-chip .thumb { aspect-ratio:1; background:#f3eee2; }
+  .tex-chip .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+  .tex-chip .lbl {
+    font:600 9.5px var(--sans); padding:4px 6px;
+    color:var(--ink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
+  }
+  .tex-chip .brand {
+    font:600 8.5px var(--sans); color:var(--faint); padding:0 6px 4px;
+    text-transform:uppercase; letter-spacing:.06em;
+  }
+
+  /* Result row */
+  .results {
+    display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:14px;
+    margin-top:18px;
+  }
+  .result {
+    background:#fff; border:1px solid var(--line); border-radius:8px;
+    overflow:hidden;
+  }
+  .result .head {
+    padding:8px 12px; background:#fcfaf4; border-bottom:1px solid var(--line);
+    display:flex; align-items:center; gap:8px; font:600 11px var(--sans);
+  }
+  .result .head .tex-name { color:var(--ink); }
+  .result .head .elapsed {
+    margin-left:auto; font:11px ui-monospace,Menlo,monospace; color:var(--faint);
+  }
+  .result .img-box {
+    aspect-ratio:1; background:#f3eee2; position:relative;
+  }
+  .result .img-box img { width:100%; height:100%; object-fit:cover; display:block; }
+  .result .img-box.loading::after {
+    content:'⏳ ~10s'; position:absolute; inset:0;
+    display:flex; align-items:center; justify-content:center;
+    background:rgba(255,255,255,.92); font:600 16px var(--sans); color:var(--faint);
+  }
+  .result .img-box.error::after {
+    content:'✗ ' attr(data-err); position:absolute; inset:0;
+    display:flex; align-items:center; justify-content:center;
+    background:rgba(252,232,230,.95); color:var(--red); font:600 12px var(--sans);
+    padding:14px; text-align:center;
+  }
+  .result .actions {
+    padding:8px 12px; display:flex; gap:6px; border-top:1px solid var(--line);
+  }
+  .result .actions a, .result .actions button {
+    flex:1; padding:5px 8px; text-align:center; text-decoration:none;
+    border:1px solid var(--line); border-radius:4px; background:#fff;
+    font:600 10.5px var(--sans); letter-spacing:.04em; color:var(--ink); cursor:pointer;
+    text-transform:uppercase;
+  }
+  .result .actions a:hover, .result .actions button:hover { background:var(--ink); color:#fff; }
+
+  .toast {
+    position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
+    background:rgba(31,24,8,.95); color:#fff; padding:9px 16px;
+    border-radius:6px; font:600 12px var(--sans);
+    opacity:0; transition:opacity .2s; pointer-events:none; z-index:200;
+  }
+  .toast.show { opacity:1; }
+</style>
+</head>
+<body>
+
+<header>
+  <h1>BG Tester</h1>
+  <span class="meta">dynamically swap backgrounds using real DW textures</span>
+  <label style="font:600 11px var(--sans); letter-spacing:.08em; text-transform:uppercase; color:var(--faint); margin-left:14px;">Design ID</label>
+  <input class="did-input" id="did-input" type="number" placeholder="39341" value="">
+  <button class="load-btn" id="load-btn">Load</button>
+  <span class="stats" id="stats">0 textures loaded · 0 results</span>
+</header>
+
+<main>
+  <div class="layout">
+    <div>
+      <div class="panel">
+        <h3>Source Design</h3>
+        <div class="source-card" id="source-card">
+          <div style="display:flex;align-items:center;justify-content:center;height:100%;color:var(--faint);font:12px var(--sans)">enter id and load</div>
+        </div>
+        <div class="source-meta" id="source-meta"></div>
+      </div>
+      <div class="panel" style="margin-top:14px">
+        <h3>Recent Designs — click to load as source</h3>
+        <div id="recent-grid" style="display:grid; grid-template-columns:repeat(3, 1fr); gap:6px; max-height:380px; overflow-y:auto"></div>
+      </div>
+      <div class="panel" style="margin-top:14px">
+        <h3>Best Bets — auto-pick 4 textures</h3>
+        <button id="auto-pick" style="width:100%; padding:10px 12px; border:1px solid var(--gold); color:#7a5500; background:linear-gradient(180deg,#fff7e0,#fef0c8); border-radius:6px; cursor:pointer; font:700 12px var(--sans); letter-spacing:.06em; text-transform:uppercase;">
+          ✨ Try 4 Textures Now
+        </button>
+        <div style="font:11px var(--sans); color:var(--faint); margin-top:6px">picks one each: Phillipe Romano · Holly Hunt · Carlisle · Cork — fires all 4 in parallel</div>
+      </div>
+    </div>
+    <div>
+      <div class="panel">
+        <h3>Texture Library — click any chip to apply</h3>
+        <div class="filters" id="filters">
+          <button class="active" data-f="all">All</button>
+          <button data-f="phillipe romano">Phillipe Romano</button>
+          <button data-f="holly hunt">Holly Hunt</button>
+          <button data-f="carlisle">Carlisle &amp; Co</button>
+          <button data-f="grasscloth">Grasscloth</button>
+          <button data-f="linen">Linen</button>
+          <button data-f="raffia">Raffia</button>
+          <button data-f="silk">Silk</button>
+          <button data-f="cork">Cork</button>
+          <button data-f="sisal">Sisal</button>
+        </div>
+        <div class="tex-grid" id="tex-grid"></div>
+      </div>
+      <div class="results" id="results"></div>
+    </div>
+  </div>
+</main>
+
+<div class="toast" id="toast"></div>
+
+<script>
+(() => {
+  const $ = (id) => document.getElementById(id);
+  let TEXTURES = [];
+  let FILTER = 'all';
+  let CURRENT_DID = null;
+  let RESULT_COUNT = 0;
+
+  function toast(msg) {
+    const t = $('toast');
+    t.textContent = msg;
+    t.classList.add('show');
+    clearTimeout(t._tm);
+    t._tm = setTimeout(() => t.classList.remove('show'), 1400);
+  }
+
+  async function loadTextures() {
+    const r = await fetch('/api/dw-textures');
+    const j = await r.json();
+    TEXTURES = j.items || [];
+    renderFilters();
+  }
+
+  function passesFilter(tex) {
+    if (FILTER === 'all') return true;
+    const blob = (tex.brand + ' ' + tex.material + ' ' + tex.title).toLowerCase();
+    return blob.includes(FILTER);
+  }
+  function renderFilters() {
+    const grid = $('tex-grid');
+    grid.innerHTML = '';
+    const shown = TEXTURES.filter(passesFilter);
+    const frag = document.createDocumentFragment();
+    for (const tex of shown) {
+      const chip = document.createElement('div');
+      chip.className = 'tex-chip';
+      chip.title = tex.title;
+      chip.dataset.url = tex.image_url;
+      chip.dataset.name = tex.name + ' (' + tex.brand + ')';
+      chip.innerHTML = `
+        <div class="thumb"><img loading="lazy" src="${tex.image_url}" alt=""></div>
+        <div class="lbl">${tex.name}</div>
+        <div class="brand">${tex.brand}</div>
+      `;
+      chip.onclick = () => applyTexture(tex, chip);
+      frag.appendChild(chip);
+    }
+    grid.appendChild(frag);
+    $('stats').textContent = `${shown.length} textures loaded · ${RESULT_COUNT} results`;
+  }
+
+  document.querySelectorAll('#filters button').forEach(b => {
+    b.onclick = () => {
+      document.querySelectorAll('#filters button').forEach(x => x.classList.remove('active'));
+      b.classList.add('active');
+      FILTER = b.dataset.f;
+      renderFilters();
+    };
+  });
+
+  $('load-btn').onclick = loadSource;
+  $('did-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') loadSource(); });
+
+  async function loadSource() {
+    const id = parseInt($('did-input').value, 10);
+    if (!Number.isFinite(id)) { toast('enter a design id'); return; }
+    CURRENT_DID = id;
+    $('source-card').innerHTML = `<img src="/designs/img/by-id/${id}" alt="${id}">`;
+    try {
+      const r = await fetch('/api/ghost-review/design/' + id);
+      const j = await r.json();
+      if (j.item) {
+        $('source-meta').innerHTML = `<b>#${j.item.id}</b> · ${j.item.category || '—'}`;
+      } else {
+        $('source-meta').innerHTML = `<b>#${id}</b>`;
+      }
+    } catch { $('source-meta').innerHTML = `<b>#${id}</b>`; }
+    toast('source loaded — click any texture chip');
+  }
+
+  async function applyTexture(tex, chip) {
+    if (!CURRENT_DID) { toast('load a source design first'); return; }
+    chip.classList.add('active');
+    // Make a result card now (with loading state)
+    const results = $('results');
+    const card = document.createElement('div');
+    card.className = 'result';
+    card.innerHTML = `
+      <div class="head">
+        <span class="tex-name">${tex.name}</span>
+        <span style="color:var(--faint);font:10px var(--sans);text-transform:uppercase;letter-spacing:.06em">${tex.brand}</span>
+        <span class="elapsed" id="el-${RESULT_COUNT}">⏳…</span>
+      </div>
+      <div class="img-box loading" id="ib-${RESULT_COUNT}"></div>
+      <div class="actions">
+        <a href="#" id="a-link-${RESULT_COUNT}" target="_blank">View</a>
+        <button id="b-again-${RESULT_COUNT}">Retry</button>
+      </div>
+    `;
+    results.prepend(card);
+    const idx = RESULT_COUNT++;
+    $('stats').textContent = `${TEXTURES.filter(passesFilter).length} textures · ${RESULT_COUNT} results`;
+    const t0 = Date.now();
+    try {
+      const r = await fetch(`/api/design/${CURRENT_DID}/replace-bg`, {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({
+          texture_image_url: tex.image_url,
+          texture_name: tex.name + ' (' + tex.brand + ')',
+        }),
+      });
+      const j = await r.json();
+      if (!r.ok) throw new Error(j.error || ('http ' + r.status));
+      const dt = ((Date.now() - t0) / 1000).toFixed(1);
+      const box = document.getElementById('ib-' + idx);
+      box.classList.remove('loading');
+      box.innerHTML = `<img src="/designs/img/by-id/${j.new_id}?t=${Date.now()}" alt="#${j.new_id}">`;
+      document.getElementById('el-' + idx).textContent = `${dt}s → #${j.new_id}`;
+      document.getElementById('a-link-' + idx).href = `/design/${j.new_id}`;
+      document.getElementById('b-again-' + idx).onclick = () => applyTexture(tex, chip);
+      toast(`✓ #${j.new_id} in ${dt}s`);
+    } catch (e) {
+      const dt = ((Date.now() - t0) / 1000).toFixed(1);
+      const box = document.getElementById('ib-' + idx);
+      box.classList.remove('loading');
+      box.classList.add('error');
+      box.dataset.err = e.message.slice(0, 80);
+      document.getElementById('el-' + idx).textContent = `${dt}s · err`;
+      toast('failed: ' + e.message);
+    }
+  }
+
+  // Load recent designs sidebar
+  async function loadRecent() {
+    try {
+      const r = await fetch('/api/new-designs?hours=72');
+      const j = await r.json();
+      const grid = $('recent-grid');
+      grid.innerHTML = '';
+      const items = (j.items || []).slice(0, 36);
+      for (const it of items) {
+        const cell = document.createElement('div');
+        cell.style.cssText = 'aspect-ratio:1; background:#f3eee2; border:1px solid var(--line); border-radius:4px; overflow:hidden; cursor:pointer; position:relative;';
+        cell.title = `#${it.id} · ${it.category || ''} · click to load`;
+        cell.innerHTML = `
+          <img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}" style="width:100%; height:100%; object-fit:cover; display:block;">
+          <div style="position:absolute; top:3px; right:3px; background:rgba(31,24,8,.78); color:#fff; font:600 8.5px ui-monospace,Menlo,monospace; padding:1px 5px; border-radius:2px;">#${it.id}</div>
+        `;
+        cell.onclick = () => {
+          $('did-input').value = it.id;
+          loadSource();
+        };
+        grid.appendChild(cell);
+      }
+    } catch (e) { console.warn('recent load failed:', e.message); }
+  }
+
+  // Auto-pick 4 textures (one per brand)
+  $('auto-pick').onclick = async () => {
+    if (!CURRENT_DID) { toast('load a source design first'); return; }
+    const wantedBrands = ['Phillipe Romano', 'Holly Hunt', 'Carlisle', 'Maya Romanoff'];
+    const picks = [];
+    for (const brand of wantedBrands) {
+      const candidates = TEXTURES.filter(t => t.brand.toLowerCase().includes(brand.toLowerCase()));
+      if (candidates.length) {
+        picks.push(candidates[Math.floor(Math.random() * candidates.length)]);
+      }
+    }
+    // Fall back if some brands missing
+    while (picks.length < 4 && TEXTURES.length > picks.length) {
+      const rand = TEXTURES[Math.floor(Math.random() * TEXTURES.length)];
+      if (!picks.includes(rand)) picks.push(rand);
+    }
+    toast(`firing ${picks.length} textures in parallel · ~10s each`);
+    for (const tex of picks) {
+      const chip = [...document.querySelectorAll('.tex-chip')].find(c => c.dataset.url === tex.image_url);
+      applyTexture(tex, chip || document.createElement('div'));
+    }
+  };
+
+  // Pre-load 39341 if nothing else specified
+  loadTextures().then(() => {
+    loadRecent();
+    const q = new URLSearchParams(location.search);
+    const did = q.get('design_id') || q.get('id');
+    if (did) {
+      $('did-input').value = did;
+      loadSource();
+    }
+  });
+})();
+</script>
+</body>
+</html>
diff --git a/public/new.html b/public/new.html
new file mode 100644
index 0000000..1fea6c8
--- /dev/null
+++ b/public/new.html
@@ -0,0 +1,217 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<meta name="robots" content="noindex,nofollow">
+<title>What's New — wallco.ai</title>
+<style>
+  :root {
+    --ink:#1f1808; --line:#d8d0c0; --gold:#c9a14b;
+    --bg:#fbf8f1; --card:#fff; --faint:#7a6e5a;
+    --green:#1f6a2c; --blue:#3b78d8; --purple:#7a4ab8; --orange:#c0660b; --red:#b3261e;
+    --sans:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
+    --serif:'Playfair Display','Didot',Georgia,serif;
+  }
+  * { box-sizing:border-box; }
+  body { margin:0; background:var(--bg); color:var(--ink); font:14px var(--sans); }
+  header {
+    padding:18px 28px; border-bottom:1px solid var(--line); background:#fff;
+    display:flex; gap:16px; align-items:baseline; position:sticky; top:0; z-index:5;
+  }
+  header h1 { font:400 26px/1.2 var(--serif); margin:0; }
+  header .meta { color:var(--faint); font-size:13px; letter-spacing:.04em; }
+  header .stats { margin-left:auto; display:flex; gap:18px; font:600 12px ui-monospace,Menlo,monospace; }
+  header .stats b { color:var(--ink); }
+  header .live {
+    font:600 10.5px var(--sans); color:var(--green); letter-spacing:.1em;
+    margin-left:10px;
+  }
+  header .live::before { content:'●'; margin-right:5px; animation:pulse 1.4s infinite; }
+  @keyframes pulse { 0%,100% {opacity:1;} 50% {opacity:.3;} }
+
+  main { max-width:1500px; margin:0 auto; padding:22px 18px 80px; }
+
+  .controls {
+    display:flex; gap:14px; align-items:center; padding:10px 14px;
+    background:#fff; border:1px solid var(--line); border-radius:8px;
+    margin-bottom:14px;
+  }
+  .controls label { font:600 10.5px var(--sans); letter-spacing:.16em;
+                    text-transform:uppercase; color:var(--faint); }
+  .controls select { padding:6px 10px; border:1px solid var(--line);
+                     border-radius:4px; background:#fff; font:13px var(--sans); }
+  .controls .right { margin-left:auto; font:600 11px ui-monospace,Menlo,monospace; color:var(--faint); }
+  .controls a {
+    color:var(--ink); text-decoration:none; font:600 11px var(--sans);
+    letter-spacing:.08em; padding:6px 12px; border:1px solid var(--line);
+    border-radius:14px; text-transform:uppercase;
+  }
+
+  .grid {
+    display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:14px;
+  }
+  .card {
+    background:var(--card); border:1px solid var(--line); border-radius:8px;
+    overflow:hidden; cursor:pointer; transition:transform .12s, box-shadow .12s;
+    text-decoration:none; color:inherit; display:block;
+  }
+  .card:hover { transform:translateY(-2px); box-shadow:0 6px 22px rgba(0,0,0,.08); }
+  .thumb {
+    aspect-ratio:1; background:#f3eee2; position:relative; overflow:hidden;
+  }
+  .thumb img { width:100%; height:100%; object-fit:cover; display:block; }
+  .thumb .id {
+    position:absolute; top:6px; right:6px;
+    background:rgba(31,24,8,.78); color:#fff;
+    font:600 9.5px ui-monospace,Menlo,monospace;
+    padding:3px 7px; border-radius:3px;
+  }
+  .thumb .gen-pill {
+    position:absolute; left:6px; bottom:6px;
+    font:700 9px var(--sans); letter-spacing:.06em; text-transform:uppercase;
+    padding:3px 7px; border-radius:11px; color:#fff;
+  }
+  .gen-smart-fix { background:#c79a3a; color:#3a2a00; }
+  .gen-crop-fix { background:var(--blue); }
+  .gen-regen   { background:var(--purple); }
+  .gen-wand    { background:var(--green); }
+  .gen-other   { background:rgba(31,24,8,.78); }
+
+  .meta-row {
+    padding:8px 10px; display:flex; flex-direction:column; gap:3px;
+    font:11.5px var(--sans);
+  }
+  .meta-row .cat { color:var(--ink); font-weight:600; letter-spacing:.02em; }
+  .meta-row .ts  { color:var(--faint); font:11px ui-monospace,Menlo,monospace; }
+
+  .empty {
+    text-align:center; padding:80px 20px; color:var(--faint);
+    font:14px var(--sans);
+  }
+  .empty h2 { font:400 28px var(--serif); color:var(--ink); margin-bottom:10px; }
+</style>
+</head>
+<body>
+
+<header>
+  <h1>What's New on wallco.ai</h1>
+  <span class="meta">designs added or refreshed in the window</span>
+  <span class="live">LIVE</span>
+  <div class="stats" id="stats">
+    <span>NEW <b id="stat-total">0</b></span>
+    <span>SMART <b id="stat-smart">0</b></span>
+    <span>FIX <b id="stat-fix">0</b></span>
+    <span>WAND <b id="stat-wand">0</b></span>
+    <span>REGEN <b id="stat-regen">0</b></span>
+  </div>
+</header>
+
+<main>
+  <div class="controls">
+    <label>Window</label>
+    <select id="hours">
+      <option value="1">last 1h</option>
+      <option value="6">last 6h</option>
+      <option value="12">last 12h</option>
+      <option value="24" selected>last 24h</option>
+      <option value="48">last 48h</option>
+      <option value="168">last 7d</option>
+    </select>
+    <span class="right" id="meta">—</span>
+    <a href="/admin/fixes-feed">📡 Before/After Feed →</a>
+    <a href="/admin/ghost-review">🔍 Ghost Review →</a>
+  </div>
+
+  <div id="grid" class="grid"></div>
+  <div id="empty" class="empty" style="display:none">
+    <h2>No new designs in this window yet</h2>
+    <p>The Seamless Agent runs continuously. Check back in a few minutes.</p>
+  </div>
+</main>
+
+<script>
+(() => {
+  const $ = (id) => document.getElementById(id);
+  let HOURS = 24;
+  let LAST_TOTAL = 0;
+  $('hours').onchange = (e) => { HOURS = parseInt(e.target.value, 10); refresh(); };
+
+  function genKind(gen) {
+    const g = (gen || '').toLowerCase();
+    if (g.includes('smart-fix') || g.includes('smart_fix')) return 'smart-fix';
+    if (g.includes('wand') || g.includes('browser-edit'))   return 'wand';
+    if (g.includes('regen-reverse') || g.includes('regenerate-reverse')) return 'regen';
+    if (g.includes('crop-fix') || g.includes('image-edit')) return 'crop-fix';
+    return 'other';
+  }
+  function pillLabel(k) {
+    return ({ 'smart-fix':'🎯 SMART', 'crop-fix':'🪄 FIX', 'regen':'↻ REGEN', 'wand':'🪄 WAND', 'other':'NEW' })[k] || 'NEW';
+  }
+
+  async function refresh() {
+    try {
+      const r = await fetch(`/api/new-designs?hours=${HOURS}`);
+      const j = await r.json();
+      const items = j.items || [];
+      if (items.length > LAST_TOTAL && LAST_TOTAL > 0) {
+        document.title = `(${items.length - LAST_TOTAL} new) What's New`;
+        setTimeout(() => { document.title = "What's New — wallco.ai"; }, 4000);
+      }
+      LAST_TOTAL = items.length;
+
+      const counts = { 'smart-fix':0, 'crop-fix':0, 'regen':0, 'wand':0, 'other':0 };
+      items.forEach(it => { counts[genKind(it.generator)]++; });
+      $('stat-total').textContent = items.length;
+      $('stat-smart').textContent = counts['smart-fix'];
+      $('stat-fix').textContent   = counts['crop-fix'];
+      $('stat-wand').textContent  = counts['wand'];
+      $('stat-regen').textContent = counts['regen'];
+      $('meta').textContent = `${items.length} shown · window=${HOURS}h · auto-refresh 10s`;
+
+      const grid = $('grid');
+      grid.innerHTML = '';
+      if (!items.length) { $('empty').style.display = ''; return; }
+      $('empty').style.display = 'none';
+      const frag = document.createDocumentFragment();
+      for (const it of items) {
+        const k = genKind(it.generator);
+        const card = document.createElement('a');
+        card.className = 'card';
+        card.href = `/design/${it.id}`;
+        card.target = '_blank';
+        const ts = new Date(it.created_at);
+        const ago = humanAgo(ts);
+        card.innerHTML = `
+          <div class="thumb">
+            <img loading="lazy" src="/designs/img/by-id/${it.id}" alt="${it.id}">
+            <div class="id">#${it.id}</div>
+            <div class="gen-pill gen-${k}">${pillLabel(k)}</div>
+          </div>
+          <div class="meta-row">
+            <span class="cat">${it.category || '—'}</span>
+            <span class="ts">${ago} · ${it.dominant_hex || ''}</span>
+          </div>
+        `;
+        frag.appendChild(card);
+      }
+      grid.appendChild(frag);
+    } catch (e) {
+      console.warn('refresh failed:', e.message);
+    }
+  }
+
+  function humanAgo(ts) {
+    const sec = Math.max(0, Math.round((Date.now() - ts.getTime()) / 1000));
+    if (sec < 60)    return `${sec}s ago`;
+    if (sec < 3600)  return `${Math.round(sec/60)}m ago`;
+    if (sec < 86400) return `${Math.round(sec/3600)}h ago`;
+    return `${Math.round(sec/86400)}d ago`;
+  }
+
+  refresh();
+  setInterval(refresh, 10000);
+})();
+</script>
+</body>
+</html>
diff --git a/scripts/seamless-agent.js b/scripts/seamless-agent.js
new file mode 100644
index 0000000..9cef984
--- /dev/null
+++ b/scripts/seamless-agent.js
@@ -0,0 +1,270 @@
+#!/usr/bin/env node
+// seamless-agent — autonomous loop that audits every generated image and
+// ensures it conforms to one of THREE valid formats:
+//
+//   1. seamless_tile  → must tile edge-to-edge with no visible seam
+//   2. mural          → single full-width-x-height scene, intentional non-tile
+//   3. mural_panel    → like mural, but designed to be one panel of a multi-panel set
+//
+// Pipeline per design:
+//   1. Fetch unverified design from PG (no seamless_audit_at timestamp)
+//   2. Inspect kind. If seamless_tile → run a PIL seam-discontinuity check.
+//      If mural/mural_panel → verify aspect matches width_in/height_in and
+//      image is coherent (not a botched tile).
+//   3. If seamless_tile FAILS check: call /api/design/:id/smart-fix to repair.
+//      This also exercises the new verify+retry+seamlessify post-process pass.
+//   4. Mark verified (write to data/seamless-audit.jsonl + UPDATE PG).
+//   5. Log everything to data/seamless-agent.log + appendFixEvent for the
+//      /admin/fixes-feed live gallery.
+//   6. Sleep CADENCE_MS, repeat. Runs forever (pm2 / nohup wrapper).
+//
+// Safety:
+//   - Skips already-verified designs (resumable)
+//   - Skips items whose source PNG is missing
+//   - Per-round CAP so the loop doesn't burn an hour on one bad batch
+//   - Max attempts per id before giving up
+//
+// Usage:
+//   nohup node scripts/seamless-agent.js > logs/seamless-agent.log 2>&1 &
+//   node scripts/seamless-agent.js --once --limit 5    # smoke
+//   node scripts/seamless-agent.js --dry-run           # no smart-fix calls
+
+'use strict';
+
+const fs   = require('fs');
+const path = require('path');
+const { spawnSync } = require('child_process');
+const { psqlQuery, psqlExecLocal } = require('../lib/db.js');
+
+const ROOT = path.join(__dirname, '..');
+const AUDIT_F = path.join(ROOT, 'data', 'seamless-audit.jsonl');
+const SERVER  = process.env.WALLCO_SERVER || 'http://127.0.0.1:9877';
+
+const ARGS    = process.argv.slice(2);
+const ONCE    = ARGS.includes('--once');
+const DRY     = ARGS.includes('--dry-run');
+const LIMIT   = (() => { const i = ARGS.indexOf('--limit'); return i >= 0 ? parseInt(ARGS[i+1], 10) : 0; })();
+const ROUND   = LIMIT > 0 ? LIMIT : parseInt(process.env.SEAMLESS_ROUND_SIZE || '8', 10);
+const CADENCE = parseInt(process.env.SEAMLESS_CADENCE_MS || '30000', 10);
+
+function log(msg) { console.log(`[${new Date().toISOString()}] ${msg}`); }
+
+// Load already-audited ids so we resume cleanly
+function loadAudited() {
+  if (!fs.existsSync(AUDIT_F)) return new Set();
+  const ids = new Set();
+  for (const line of fs.readFileSync(AUDIT_F, 'utf8').split('\n')) {
+    if (!line.trim()) continue;
+    try { ids.add(JSON.parse(line).id); } catch {}
+  }
+  return ids;
+}
+
+function fetchBatch(excludeIds, n) {
+  // Pick the freshest published wallco.ai designs that haven't been audited.
+  // Skip items already in the ghost-flagged set (they're being handled there).
+  const excludeClause = excludeIds.size
+    ? `AND id NOT IN (${[...excludeIds].slice(0, 50000).join(',')})`
+    : '';
+  const sql = `
+    SELECT id, kind, category, width_in, height_in, local_path, generator
+    FROM spoon_all_designs
+    WHERE is_published = TRUE
+      AND brand = 'wallco.ai'
+      AND local_path IS NOT NULL
+      ${excludeClause}
+    ORDER BY id DESC
+    LIMIT ${n};
+  `;
+  const raw = psqlQuery(sql);
+  const rows = [];
+  for (const line of raw.split('\n')) {
+    if (!line.trim()) continue;
+    const cols = line.split('|');
+    rows.push({
+      id: parseInt(cols[0], 10),
+      kind: cols[1],
+      category: cols[2],
+      width_in: cols[3] ? parseFloat(cols[3]) : null,
+      height_in: cols[4] ? parseFloat(cols[4]) : null,
+      local_path: cols[5],
+      generator: cols[6],
+    });
+  }
+  return rows.filter(r => Number.isFinite(r.id));
+}
+
+// PIL-based seam discontinuity check.
+// Returns { ok: bool, score: float, reason: str }
+// score = mean gradient magnitude at the wraparound seam (low = good)
+function checkSeamlessness(pngPath) {
+  const py = spawnSync('python3', ['-c', `
+import sys
+try:
+    from PIL import Image
+except Exception as e:
+    print('PIL_MISSING', str(e), file=sys.stderr); sys.exit(2)
+img = Image.open(sys.argv[1]).convert('RGB')
+W, H = img.size
+px = img.load()
+def diff(a, b): return abs(a[0]-b[0]) + abs(a[1]-b[1]) + abs(a[2]-b[2])
+# Left edge vs right edge
+left_right = sum(diff(px[0, y], px[W-1, y]) for y in range(0, H, 4)) / (H/4)
+# Top edge vs bottom edge
+top_bot   = sum(diff(px[x, 0], px[x, H-1]) for x in range(0, W, 4)) / (W/4)
+# Higher = more seam discontinuity. >40 is bad for flat screenprints.
+import json
+print(json.dumps({
+  'left_right_diff': round(left_right, 2),
+  'top_bot_diff':    round(top_bot, 2),
+  'score':           round((left_right + top_bot) / 2, 2),
+}))
+`, pngPath], { encoding: 'utf8', timeout: 30000 });
+  if (py.status !== 0) {
+    return { ok: false, score: 999, reason: 'pil error: ' + (py.stderr || '').slice(0, 120) };
+  }
+  let out;
+  try { out = JSON.parse(py.stdout); }
+  catch { return { ok: false, score: 999, reason: 'parse fail: ' + py.stdout.slice(0, 120) }; }
+  // Threshold: score < 30 = passes (low discontinuity at seams)
+  const ok = out.score < 30;
+  return {
+    ok,
+    score: out.score,
+    reason: ok ? `seams_clean (Δ=${out.score})` : `seams_break (Δ=${out.score} lr=${out.left_right_diff} tb=${out.top_bot_diff})`,
+  };
+}
+
+// Mural / mural_panel coherence check: file exists + reasonable size + no cut-off composition.
+// Cut-off = a primary motif visibly clipped at the image edge (Gemini image-edit produces
+// these — observed on id=39317 'recolor_*' files where the design ends mid-element).
+function checkMural(pngPath, widthIn, heightIn) {
+  try {
+    const stat = fs.statSync(pngPath);
+    if (stat.size < 50_000) return { ok: false, reason: 'tiny_png<50kb' };
+    const cut = checkCutoff(pngPath);
+    if (!cut.ok) return cut;
+    return { ok: true, reason: `mural ok (${(stat.size/1024).toFixed(0)}kb)` };
+  } catch (e) { return { ok: false, reason: 'no_file' }; }
+}
+
+// Cut-off detection: look for HIGH gradient density right at the image boundary
+// (top 6 rows, bottom 6 rows, left 6 cols, right 6 cols). A clean composition
+// has low gradient at boundaries (background); a cut-off design has motif edges
+// abruptly truncated → high gradient at boundary. Threshold tuned empirically.
+function checkCutoff(pngPath) {
+  const py = spawnSync('python3', ['-c', `
+import sys, json
+from PIL import Image
+img = Image.open(sys.argv[1]).convert('RGB')
+W, H = img.size
+px = img.load()
+def lum(p): return 0.299*p[0] + 0.587*p[1] + 0.114*p[2]
+band = 6
+def edge_grad(coords):
+    s = 0; n = 0
+    for x, y, x2, y2 in coords:
+        if 0 <= x < W and 0 <= y < H and 0 <= x2 < W and 0 <= y2 < H:
+            s += abs(lum(px[x,y]) - lum(px[x2,y2])); n += 1
+    return s / max(1, n)
+# Sample gradient along each boundary band (just inside)
+top    = edge_grad([(x, band-1, x, band) for x in range(0, W, 4)])
+bot    = edge_grad([(x, H-band, x, H-band-1) for x in range(0, W, 4)])
+left   = edge_grad([(band-1, y, band, y) for y in range(0, H, 4)])
+right  = edge_grad([(W-band, y, W-band-1, y) for y in range(0, H, 4)])
+# Mean gradient AT the boundary itself (vs just inside): boundary clip = high jump
+def boundary_jump(coords):
+    s = 0; n = 0
+    for x, y in coords:
+        if 0 <= x < W and 0 <= y < H:
+            s += lum(px[x, y]); n += 1
+    return s / max(1, n)
+print(json.dumps({
+  'top_grad': round(top, 1), 'bot_grad': round(bot, 1),
+  'left_grad': round(left, 1), 'right_grad': round(right, 1),
+  'max_grad': round(max(top, bot, left, right), 1),
+}))
+`, pngPath], { encoding: 'utf8', timeout: 30000 });
+  if (py.status !== 0) return { ok: true, reason: 'cutoff_check_skipped' };
+  let out;
+  try { out = JSON.parse(py.stdout); }
+  catch { return { ok: true, reason: 'cutoff_check_parsefail' }; }
+  // If gradient at any edge > 60, likely cut-off (a clean background bleed is < 20)
+  if (out.max_grad > 60) {
+    return { ok: false, reason: `cut_off_at_edge (max_grad=${out.max_grad} top=${out.top_grad} bot=${out.bot_grad} l=${out.left_grad} r=${out.right_grad})` };
+  }
+  return { ok: true, reason: `composition_clean (max_grad=${out.max_grad})` };
+}
+
+async function callSmartFix(id) {
+  const r = await fetch(`${SERVER}/api/design/${id}/smart-fix`, { method: 'POST' });
+  const j = await r.json().catch(() => ({}));
+  if (!r.ok) throw new Error(j.error || `http ${r.status}`);
+  return j;
+}
+
+async function processOne(d) {
+  const kind = (d.kind || '').toLowerCase();
+  let check;
+  if (!d.local_path || !fs.existsSync(d.local_path)) {
+    return { id: d.id, status: 'skip_no_png', kind };
+  }
+  if (kind === 'seamless_tile') check = checkSeamlessness(d.local_path);
+  else if (kind === 'mural' || kind === 'mural_panel') check = checkMural(d.local_path, d.width_in, d.height_in);
+  else check = { ok: true, score: 0, reason: `unknown_kind=${kind} (passthrough)` };
+
+  if (check.ok) {
+    return { id: d.id, kind, status: 'pass', score: check.score || 0, reason: check.reason };
+  }
+  // Failed seamlessness — repair via Smart Fix (only for seamless_tile)
+  if (kind !== 'seamless_tile') {
+    return { id: d.id, kind, status: 'fail_no_repair', reason: check.reason };
+  }
+  if (DRY) {
+    return { id: d.id, kind, status: 'dry_run_would_fix', reason: check.reason };
+  }
+  try {
+    const fix = await callSmartFix(d.id);
+    return {
+      id: d.id, kind, status: 'fixed',
+      score_before: check.score, reason: check.reason,
+      new_id: fix.new_id, elapsed_s: fix.elapsed_s,
+      motif: (fix.motif || '').slice(0, 200),
+      verify: fix.verify,
+    };
+  } catch (e) {
+    return { id: d.id, kind, status: 'fix_error', reason: check.reason, error: e.message.slice(0, 200) };
+  }
+}
+
+async function round() {
+  const audited = loadAudited();
+  const batch = fetchBatch(audited, ROUND);
+  if (!batch.length) {
+    log(`no fresh designs to audit (audited=${audited.size}) — sleeping`);
+    return { processed: 0, audited: audited.size };
+  }
+  log(`round start · batch=${batch.length} · audited_so_far=${audited.size}`);
+  let pass = 0, fixed = 0, errs = 0;
+  for (const d of batch) {
+    const r = await processOne(d);
+    fs.appendFileSync(AUDIT_F, JSON.stringify({ ts: new Date().toISOString(), ...r }) + '\n');
+    if (r.status === 'pass') pass++;
+    else if (r.status === 'fixed') fixed++;
+    else if (r.status === 'fix_error') errs++;
+    log(`  id=${r.id} kind=${r.kind} ${r.status} ${r.reason || ''} ${r.new_id ? '→ #' + r.new_id : ''}`);
+  }
+  log(`round end · pass=${pass} fixed=${fixed} errs=${errs}`);
+  return { processed: batch.length, pass, fixed, errs };
+}
+
+(async () => {
+  log(`seamless-agent start · cadence=${CADENCE}ms round=${ROUND} dry=${DRY} once=${ONCE}`);
+  while (true) {
+    try { await round(); }
+    catch (e) { log(`round error: ${e.message}`); }
+    if (ONCE) break;
+    await new Promise(r => setTimeout(r, CADENCE));
+  }
+  log('seamless-agent done');
+})().catch(e => { log('FATAL ' + e.message); process.exit(1); });

← 67eac74 fix(ghost-detector): vendor-category filter on PG source pat  ·  back to Wallco Ai  ·  /design/:id → 302 to newest published child via parent_desig f5a6676 →