← 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
A public/joint-fix-gallery.htmlM server.js
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 →