[object Object]

← back to Wallco Ai

Joint-fix gallery viewer

b50dfe2972eec1b32c020f95362d36d9ff91fbc8 · 2026-05-24 23:28:41 -0700 · Steve Abrams

public/joint-fix-gallery.html — side-by-side before/after gallery for the
558 joint-fix outputs from scripts/joint-fix-batch.js. Filters: category
dropdown (auto-populated from src_category distribution), sort (newest /
src-id / category), limit (60 / 120 / 240 / all). Source IDs link with
?asis=1 to bypass the parent_design_id auto-redirect so the broken
original is viewable for comparison; fix IDs link normally.

server.js — new /api/joint-fix-gallery endpoint reads
data/joint-fix-log.jsonl, returns OK rows only (skips errors), dedupes
src_id. Sister to /api/designs but scoped to the fix audit trail.

Both pieces are read-only against the existing artifacts — no rerunning
of any fix, no Gemini cost. Pure viewer over the joint-fix-log.jsonl
already on disk.

Files touched

Diff

commit b50dfe2972eec1b32c020f95362d36d9ff91fbc8
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun May 24 23:28:41 2026 -0700

    Joint-fix gallery viewer
    
    public/joint-fix-gallery.html — side-by-side before/after gallery for the
    558 joint-fix outputs from scripts/joint-fix-batch.js. Filters: category
    dropdown (auto-populated from src_category distribution), sort (newest /
    src-id / category), limit (60 / 120 / 240 / all). Source IDs link with
    ?asis=1 to bypass the parent_design_id auto-redirect so the broken
    original is viewable for comparison; fix IDs link normally.
    
    server.js — new /api/joint-fix-gallery endpoint reads
    data/joint-fix-log.jsonl, returns OK rows only (skips errors), dedupes
    src_id. Sister to /api/designs but scoped to the fix audit trail.
    
    Both pieces are read-only against the existing artifacts — no rerunning
    of any fix, no Gemini cost. Pure viewer over the joint-fix-log.jsonl
    already on disk.
---
 public/joint-fix-gallery.html | 128 ++++++++++++++++++++++++++++++++++++++++++
 server.js                     |  30 ++++++++++
 2 files changed, 158 insertions(+)

diff --git a/public/joint-fix-gallery.html b/public/joint-fix-gallery.html
new file mode 100644
index 0000000..51ffdab
--- /dev/null
+++ b/public/joint-fix-gallery.html
@@ -0,0 +1,128 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Joint-Fix Gallery — Source vs Fix</title>
+<style>
+  :root { --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff; --border:#e8e6e1; --accent:#8b1f1f; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:var(--fg); font:14px/1.5 -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif; }
+  header { padding:24px 32px 16px; border-bottom:1px solid var(--border); background:var(--card); position:sticky; top:0; z-index:10; }
+  header h1 { margin:0 0 4px; font-size:20px; font-weight:600; letter-spacing:-0.01em; }
+  header .stats { color:var(--muted); font-size:13px; }
+  header .stats b { color:var(--fg); font-weight:600; }
+  .filters { margin-top:12px; display:flex; gap:8px; flex-wrap:wrap; align-items:center; }
+  .filters select, .filters input { padding:6px 10px; border:1px solid var(--border); border-radius:4px; background:var(--card); font:inherit; }
+  .filters label { font-size:12px; color:var(--muted); margin-right:4px; }
+  main { padding:24px 32px 64px; }
+  .grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(360px, 1fr)); gap:24px; }
+  .pair { background:var(--card); border:1px solid var(--border); border-radius:8px; overflow:hidden; }
+  .pair-imgs { display:grid; grid-template-columns:1fr 1fr; gap:0; }
+  .pair-imgs > div { position:relative; aspect-ratio:1; background:#f0eee8; overflow:hidden; }
+  .pair-imgs img { width:100%; height:100%; object-fit:cover; display:block; }
+  .pair-imgs .label { position:absolute; top:6px; left:6px; padding:2px 6px; background:rgba(0,0,0,0.7); color:#fff; font-size:10px; letter-spacing:0.06em; text-transform:uppercase; border-radius:2px; }
+  .pair-imgs .label.fix { background:rgba(40,140,90,0.85); }
+  .pair-meta { padding:10px 12px; display:flex; justify-content:space-between; align-items:center; font-size:12px; }
+  .pair-meta .cat { color:var(--muted); }
+  .pair-meta .ids a { color:var(--accent); text-decoration:none; font-weight:600; }
+  .pair-meta .ids a:hover { text-decoration:underline; }
+  .arrow { color:var(--muted); margin:0 4px; }
+  .badge { display:inline-block; padding:1px 6px; font-size:10px; font-weight:600; border-radius:10px; background:#e8f4ec; color:#1d6b3a; margin-left:6px; }
+  .empty { padding:40px; text-align:center; color:var(--muted); }
+</style>
+</head>
+<body>
+<header>
+  <h1>Joint-Fix Gallery <span class="badge" id="count">…</span></h1>
+  <div class="stats">Source design (left) → joint-fix output (right). 95% of source preserved, edges repainted to tile seamlessly. Gemini-2.5-flash-image-edit · avg <b>7.6s</b> per fix.</div>
+  <div class="filters">
+    <label>Category</label>
+    <select id="catFilter"><option value="">All categories</option></select>
+    <label>Sort</label>
+    <select id="sortBy">
+      <option value="newest">Newest first</option>
+      <option value="src">Source ID ↑</option>
+      <option value="cat">Category</option>
+    </select>
+    <label>Show</label>
+    <select id="limit">
+      <option value="60">60</option>
+      <option value="120">120</option>
+      <option value="240">240</option>
+      <option value="0">All</option>
+    </select>
+  </div>
+</header>
+<main>
+  <div class="grid" id="grid"><div class="empty">Loading…</div></div>
+</main>
+<script>
+async function loadData() {
+  const r = await fetch('/api/joint-fix-gallery');
+  if (!r.ok) throw new Error('fetch failed: ' + r.status);
+  return r.json();
+}
+function categoryBase(c) {
+  if (!c) return '';
+  return c.split('·')[0].trim();
+}
+function render(items, allCats) {
+  const grid = document.getElementById('grid');
+  document.getElementById('count').textContent = items.length;
+  if (!items.length) { grid.innerHTML = '<div class="empty">No fixes match filters.</div>'; return; }
+  grid.innerHTML = items.map(p => `
+    <div class="pair">
+      <div class="pair-imgs">
+        <div>
+          <span class="label">Source #${p.src_id}</span>
+          <img loading="lazy" src="/designs/img/by-id/${p.src_id}?asis=1" alt="src ${p.src_id}">
+        </div>
+        <div>
+          <span class="label fix">Fix #${p.new_id}</span>
+          <img loading="lazy" src="/designs/img/by-id/${p.new_id}?asis=1" alt="fix ${p.new_id}">
+        </div>
+      </div>
+      <div class="pair-meta">
+        <div class="cat">${p.src_category || '?'}</div>
+        <div class="ids">
+          <a href="/design/${p.src_id}?asis=1">${p.src_id}</a>
+          <span class="arrow">→</span>
+          <a href="/design/${p.new_id}">${p.new_id}</a>
+        </div>
+      </div>
+    </div>
+  `).join('');
+}
+let DATA = [];
+function apply() {
+  const cat = document.getElementById('catFilter').value;
+  const sort = document.getElementById('sortBy').value;
+  const limit = parseInt(document.getElementById('limit').value, 10);
+  let items = DATA.slice();
+  if (cat) items = items.filter(p => categoryBase(p.src_category) === cat);
+  if (sort === 'newest') items.sort((a,b) => b.new_id - a.new_id);
+  else if (sort === 'src') items.sort((a,b) => a.src_id - b.src_id);
+  else if (sort === 'cat') items.sort((a,b) => (categoryBase(a.src_category)||'').localeCompare(categoryBase(b.src_category)||''));
+  if (limit > 0) items = items.slice(0, limit);
+  render(items);
+}
+loadData().then(data => {
+  DATA = data.items || [];
+  const cats = [...new Set(DATA.map(p => categoryBase(p.src_category)).filter(Boolean))].sort();
+  const sel = document.getElementById('catFilter');
+  for (const c of cats) {
+    const o = document.createElement('option');
+    o.value = c; o.textContent = c + ' (' + DATA.filter(p => categoryBase(p.src_category) === c).length + ')';
+    sel.appendChild(o);
+  }
+  document.getElementById('catFilter').addEventListener('change', apply);
+  document.getElementById('sortBy').addEventListener('change', apply);
+  document.getElementById('limit').addEventListener('change', apply);
+  apply();
+}).catch(e => {
+  document.getElementById('grid').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
+});
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index cf4a50e..247659d 100644
--- a/server.js
+++ b/server.js
@@ -14585,6 +14585,36 @@ function designHasImage(d) {
   return false;
 }
 
+// /api/joint-fix-gallery — backing data for /joint-fix-gallery.html.
+// Reads data/joint-fix-log.jsonl (the audit trail from scripts/joint-fix-batch.js)
+// and returns the OK rows for the source→fix gallery view.
+app.get('/api/joint-fix-gallery', (req, res) => {
+  try {
+    const logF = path.join(__dirname, 'data', 'joint-fix-log.jsonl');
+    if (!fs.existsSync(logF)) return res.json({ items: [] });
+    const items = [];
+    const seen = new Set();
+    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 || seen.has(r.src_id)) continue;
+        seen.add(r.src_id);
+        items.push({
+          src_id: r.src_id,
+          new_id: r.new_id,
+          src_category: r.src_category,
+          elapsed_s: r.elapsed_s,
+          ts: r.ts,
+        });
+      } catch {}
+    }
+    res.json({ count: items.length, items });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 app.get('/api/designs', (req, res) => {
   const sort = req.query.sort || 'newest';
   const cat  = req.query.cat  || '';

← b4c58f1 fix(deploy): drop single-quotes from RSYNC_EXTRA_EXCLUDES pa  ·  back to Wallco Ai  ·  feat(ticks): rsync-pull prod bad-aesthetic-patterns.jsonl at 75c5fe0 →