[object Object]

← back to Wallco Ai

roll-luxe-c runner + /luxe-c-gallery.html viewer

8271d6db6eef899330620fee6968341568986f8f · 2026-05-25 01:11:59 -0700 · Steve Abrams

scripts/roll-luxe-c.js — parallel runner that fans out gen-luxe.js
--variant=C (1838 flocked velvet) across every unique root in a
category. For each distinct root design (recursive parent-walk over
non-luxe published rows), spawns gen-luxe with --variant=C and a
rotating --texture-cat (silk → cork → grasscloth → linen → raffia by
root_id modulo). After each new luxe lands, runs a WITH RECURSIVE
descendant-unpublish that drops is_published on the root + every other
non-luxe descendant — only the new luxe surfaces in /designs going
forward. Idempotent: a root is skipped if it already has a luxe child.
Append-only audit at data/luxe-c-rollout.jsonl.

/api/luxe-c-gallery + public/luxe-c-gallery.html — sister to
joint-fix-gallery. Side-by-side root vs luxe cards with ground-category
badge (silk/cork/raffia/...), motif caption, ground filter, sort
(newest/root-id/ground), live 8s auto-refresh so progress shows during
the rollout. Live cost meter (~$0.04 per Gemini Flash Image call).

Verified against the in-flight 263-root drunk-animals rollout — first
sample (elephant root #10354 → luxe #41563 on raffia ground) renders
emerald-flock elephants in laurel wreaths with sunflowers, exactly the
heritage-house aesthetic that variant A (de Gournay chinoiserie) failed
to deliver on the first frog attempt.

Files touched

Diff

commit 8271d6db6eef899330620fee6968341568986f8f
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 01:11:59 2026 -0700

    roll-luxe-c runner + /luxe-c-gallery.html viewer
    
    scripts/roll-luxe-c.js — parallel runner that fans out gen-luxe.js
    --variant=C (1838 flocked velvet) across every unique root in a
    category. For each distinct root design (recursive parent-walk over
    non-luxe published rows), spawns gen-luxe with --variant=C and a
    rotating --texture-cat (silk → cork → grasscloth → linen → raffia by
    root_id modulo). After each new luxe lands, runs a WITH RECURSIVE
    descendant-unpublish that drops is_published on the root + every other
    non-luxe descendant — only the new luxe surfaces in /designs going
    forward. Idempotent: a root is skipped if it already has a luxe child.
    Append-only audit at data/luxe-c-rollout.jsonl.
    
    /api/luxe-c-gallery + public/luxe-c-gallery.html — sister to
    joint-fix-gallery. Side-by-side root vs luxe cards with ground-category
    badge (silk/cork/raffia/...), motif caption, ground filter, sort
    (newest/root-id/ground), live 8s auto-refresh so progress shows during
    the rollout. Live cost meter (~$0.04 per Gemini Flash Image call).
    
    Verified against the in-flight 263-root drunk-animals rollout — first
    sample (elephant root #10354 → luxe #41563 on raffia ground) renders
    emerald-flock elephants in laurel wreaths with sunflowers, exactly the
    heritage-house aesthetic that variant A (de Gournay chinoiserie) failed
    to deliver on the first frog attempt.
---
 public/luxe-c-gallery.html | 155 +++++++++++++++++++++++++++++++++
 scripts/roll-luxe-c.js     | 209 +++++++++++++++++++++++++++++++++++++++++++++
 server.js                  |  40 ++++++++-
 3 files changed, 400 insertions(+), 4 deletions(-)

diff --git a/public/luxe-c-gallery.html b/public/luxe-c-gallery.html
new file mode 100644
index 0000000..425dec7
--- /dev/null
+++ b/public/luxe-c-gallery.html
@@ -0,0 +1,155 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Luxe-C Gallery — Root vs 1838 Flocked Velvet</title>
+<style>
+  :root { --bg:#fafaf8; --fg:#1a1a1a; --muted:#6a6a6a; --card:#fff; --border:#e8e6e1; --accent:#8b1f1f; --gold:#c9a14b; }
+  * { 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 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: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(420px, 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 8px; font-size:10px;
+    letter-spacing:0.08em; text-transform:uppercase; border-radius:2px;
+    background:rgba(0,0,0,0.7); color:#fff; font-weight:600;
+  }
+  .pair-imgs .label.luxe { background:rgba(140,40,60,0.92); }
+  .pair-imgs .ground {
+    position:absolute; bottom:6px; right:6px; padding:2px 7px; font-size:10px;
+    letter-spacing:0.06em; background:rgba(255,255,255,0.92); color:#1a1a1a;
+    border-radius:2px; font-weight:600;
+  }
+  .pair-meta { padding:10px 12px; }
+  .pair-meta .ids { display:flex; justify-content:space-between; align-items:center; font-size:12px; margin-bottom:6px; }
+  .pair-meta .ids a { color:var(--accent); text-decoration:none; font-weight:600; }
+  .pair-meta .ids a:hover { text-decoration:underline; }
+  .pair-meta .motif { color:var(--muted); font-size:11.5px; line-height:1.4; }
+  .arrow { color:var(--muted); margin:0 4px; }
+  .empty { padding:40px; text-align:center; color:var(--muted); }
+  .live-tag { display:inline-block; width:8px; height:8px; border-radius:50%; background:#1d6b3a; margin-right:6px; animation:pulse 1.6s infinite; vertical-align:middle; }
+  @keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:0.3 } }
+</style>
+</head>
+<body>
+<header>
+  <h1>Luxe-C Gallery <span class="badge" id="count">…</span></h1>
+  <div class="stats"><span class="live-tag" id="live"></span>
+    <b>Root</b> (left) → <b>1838 Flocked Velvet luxe</b> (right). 5 heritage-house variants in <code>scripts/gen-luxe.js</code>; variant C locks to 2-color flocked silhouettes on a real DW silk/grasscloth/linen/raffia/cork ground. Source unpublished automatically after luxe lands.
+    <br>Avg <b id="avg">…</b>s per regen · <b id="totalCost">…</b> spent on Gemini Flash Image.
+  </div>
+  <div class="filters">
+    <label>Ground</label>
+    <select id="groundFilter"><option value="">All grounds</option></select>
+    <label>Sort</label>
+    <select id="sortBy">
+      <option value="newest">Newest first</option>
+      <option value="root">Root ID ↑</option>
+      <option value="ground">Ground 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>
+    <button id="refresh" style="margin-left:auto; padding:6px 12px; border:1px solid var(--border); background:var(--card); border-radius:4px; cursor:pointer; font:inherit;">Refresh</button>
+  </div>
+</header>
+<main>
+  <div class="grid" id="grid"><div class="empty">Loading…</div></div>
+</main>
+<script>
+async function loadData() {
+  const r = await fetch('/api/luxe-c-gallery');
+  if (!r.ok) throw new Error('fetch failed: ' + r.status);
+  return r.json();
+}
+function render(items) {
+  const grid = document.getElementById('grid');
+  document.getElementById('count').textContent = items.length;
+  if (!items.length) {
+    grid.innerHTML = '<div class="empty">No luxe pairs yet — kick off <code>node scripts/roll-luxe-c.js</code> first.</div>';
+    return;
+  }
+  grid.innerHTML = items.map(p => `
+    <div class="pair">
+      <div class="pair-imgs">
+        <div>
+          <span class="label">Root #${p.root_id}</span>
+          <img loading="lazy" src="/designs/img/by-id/${p.root_id}?asis=1" alt="root ${p.root_id}">
+        </div>
+        <div>
+          <span class="label luxe">Luxe #${p.new_id}</span>
+          <span class="ground">${p.ground || '—'}</span>
+          <img loading="lazy" src="/designs/img/by-id/${p.new_id}" alt="luxe ${p.new_id}">
+        </div>
+      </div>
+      <div class="pair-meta">
+        <div class="ids">
+          <a href="/design/${p.root_id}?asis=1">root ${p.root_id}</a>
+          <span class="arrow">→ flocked velvet →</span>
+          <a href="/design/${p.new_id}">luxe ${p.new_id}</a>
+        </div>
+        <div class="motif">${p.motif || ''}</div>
+      </div>
+    </div>
+  `).join('');
+}
+let DATA = [];
+function apply() {
+  const ground = document.getElementById('groundFilter').value;
+  const sort = document.getElementById('sortBy').value;
+  const limit = parseInt(document.getElementById('limit').value, 10);
+  let items = DATA.slice();
+  if (ground) items = items.filter(p => p.ground === ground);
+  if (sort === 'newest') items.sort((a,b) => b.new_id - a.new_id);
+  else if (sort === 'root') items.sort((a,b) => a.root_id - b.root_id);
+  else if (sort === 'ground') items.sort((a,b) => (a.ground||'').localeCompare(b.ground||''));
+  if (limit > 0) items = items.slice(0, limit);
+  render(items);
+}
+async function refresh() {
+  try {
+    const data = await loadData();
+    DATA = data.items || [];
+    const totalMs = DATA.reduce((s,p) => s + (p.duration_ms||0), 0);
+    document.getElementById('avg').textContent = DATA.length ? (totalMs / DATA.length / 1000).toFixed(1) : '—';
+    // Cost: Gemini 2.5 Flash Image ≈ $0.04 / call
+    document.getElementById('totalCost').textContent = '$' + (DATA.length * 0.04).toFixed(2);
+    // Repopulate ground filter (keep current selection)
+    const sel = document.getElementById('groundFilter');
+    const current = sel.value;
+    const grounds = [...new Set(DATA.map(p => p.ground).filter(Boolean))].sort();
+    sel.innerHTML = '<option value="">All grounds (' + DATA.length + ')</option>' +
+      grounds.map(g => '<option value="' + g + '">' + g + ' (' + DATA.filter(p => p.ground === g).length + ')</option>').join('');
+    sel.value = current;
+    apply();
+  } catch (e) {
+    document.getElementById('grid').innerHTML = '<div class="empty">Failed to load: ' + e.message + '</div>';
+  }
+}
+document.getElementById('groundFilter').addEventListener('change', apply);
+document.getElementById('sortBy').addEventListener('change', apply);
+document.getElementById('limit').addEventListener('change', apply);
+document.getElementById('refresh').addEventListener('click', refresh);
+// Auto-refresh every 8s so rollout progress shows up live
+setInterval(refresh, 8000);
+refresh();
+</script>
+</body>
+</html>
diff --git a/scripts/roll-luxe-c.js b/scripts/roll-luxe-c.js
new file mode 100644
index 0000000..42ec7ef
--- /dev/null
+++ b/scripts/roll-luxe-c.js
@@ -0,0 +1,209 @@
+#!/usr/bin/env node
+// roll-luxe-c — fan out Variant C (1838 flocked velvet) across every
+// unique root in a category. For each distinct root design (the comfy/SDXL
+// or whichever-is-oldest ancestor), spawn gen-luxe.js --variant=C with a
+// rotating --texture-cat for ground variety. After each new luxe lands,
+// unpublish the root + all its non-luxe descendants so only the new luxe
+// surfaces in the gallery. Append every result to data/luxe-c-rollout.jsonl.
+//
+// Usage:
+//   node scripts/roll-luxe-c.js [--category=drunk-animals] [--limit=N]
+//                               [--concurrency=4] [--dry-run]
+//                               [--prompt-len=300]
+//
+// Reads roots from PG using a recursive parent-walk on published designs.
+// Idempotent — skips roots that already have a luxe-v2 child.
+
+const fs   = require('fs');
+const path = require('path');
+const { spawn, spawnSync } = require('child_process');
+
+const ARG = (k, dflt) => {
+  const a = process.argv.find(x => x.startsWith(`--${k}=`));
+  return a ? a.split('=')[1] : dflt;
+};
+const FLAG = (k) => process.argv.includes(`--${k}`);
+
+const CATEGORY    = ARG('category',    'drunk-animals');
+const LIMIT       = parseInt(ARG('limit',       '0'),   10);
+const CONCURRENCY = parseInt(ARG('concurrency', '4'),   10);
+const PROMPT_LEN  = parseInt(ARG('prompt-len',  '300'), 10);
+const DRY         = FLAG('dry-run');
+
+const ROOT = path.join(__dirname, '..');
+const LOG  = path.join(ROOT, 'data', 'luxe-c-rollout.jsonl');
+
+// Rotating textile grounds — silk for the flock pop, cork for warmth,
+// grasscloth/linen for natural ground, raffia for texture. Cycled by index.
+const GROUNDS = ['silk', 'cork', 'grasscloth', 'linen', 'raffia'];
+
+function psql(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-F|'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || 'psql failed');
+  return r.stdout.trim();
+}
+
+// Find every unique root design across published, non-luxe leaves in the
+// category. Skip roots that already have a luxe-v2 child.
+function pickRoots() {
+  const sql = `
+    WITH RECURSIVE chain AS (
+      SELECT id, parent_design_id, prompt, generator, id AS leaf_id, 0 AS depth
+      FROM spoon_all_designs
+      WHERE category = '${CATEGORY.replace(/'/g, "''")}'
+        AND brand = 'wallco.ai'
+        AND is_published = TRUE
+        AND local_path IS NOT NULL
+        AND generator NOT LIKE '%luxe%'
+      UNION ALL
+      SELECT s.id, s.parent_design_id, s.prompt, s.generator, c.leaf_id, c.depth + 1
+      FROM spoon_all_designs s
+      JOIN chain c ON s.id = c.parent_design_id
+      WHERE c.depth < 10
+    ),
+    roots AS (
+      SELECT DISTINCT ON (leaf_id) leaf_id, id AS root_id, prompt AS root_prompt
+      FROM chain
+      ORDER BY leaf_id, depth DESC
+    ),
+    deduped AS (
+      SELECT DISTINCT root_id, root_prompt FROM roots
+    )
+    SELECT d.root_id, regexp_replace(d.root_prompt, E'\\n|\\r', ' ', 'g')
+    FROM deduped d
+    LEFT JOIN spoon_all_designs lux
+      ON lux.parent_design_id = d.root_id
+     AND lux.generator LIKE '%luxe%'
+    WHERE lux.id IS NULL
+    ORDER BY d.root_id
+    ${LIMIT > 0 ? `LIMIT ${LIMIT}` : ''}
+  `;
+  const raw = psql(sql);
+  if (!raw) return [];
+  return raw.split('\n').map(line => {
+    const [rootId, ...promptParts] = line.split('|');
+    return { rootId: parseInt(rootId, 10), prompt: promptParts.join('|') };
+  }).filter(r => Number.isFinite(r.rootId));
+}
+
+// First 300 chars OR up to the first ". " sentence break — whichever's shorter.
+// Strips the boilerplate "set among sun-blown ..." tail by clipping at first
+// period after the subject is established.
+function extractMotif(rootPrompt) {
+  let m = rootPrompt.trim().slice(0, PROMPT_LEN);
+  const dot = m.indexOf('. ');
+  if (dot > 60 && dot < m.length - 1) m = m.slice(0, dot);
+  return m.trim();
+}
+
+// Run gen-luxe and parse the result lines. Returns { ok, newId, durationMs }.
+function runGenLuxe(rootId, motif, textureCat) {
+  return new Promise((resolve) => {
+    const args = [
+      path.join(__dirname, 'gen-luxe.js'),
+      String(rootId),
+      motif,
+      `--variant=C`,
+      `--texture-cat=${textureCat}`,
+    ];
+    const t0 = Date.now();
+    const child = spawn('node', args, { cwd: ROOT, env: process.env });
+    let stdout = '', stderr = '';
+    child.stdout.on('data', d => { stdout += d; });
+    child.stderr.on('data', d => { stderr += d; });
+    child.on('close', (code) => {
+      const durationMs = Date.now() - t0;
+      const m = stdout.match(/\[gen-luxe\] PG id: (\d+)/);
+      const newId = m ? parseInt(m[1], 10) : null;
+      resolve({
+        ok: code === 0 && newId !== null,
+        code, newId, durationMs,
+        stdoutTail: stdout.split('\n').slice(-6).join('\n'),
+        stderrTail: stderr.slice(-300),
+      });
+    });
+  });
+}
+
+// After a successful gen-luxe lands a new luxe child of rootId, unpublish
+// the root + every other non-luxe descendant so only the new luxe surfaces.
+function unpublishDescendants(rootId, keepLuxeId) {
+  const sql = `
+    WITH RECURSIVE descendants AS (
+      SELECT id FROM spoon_all_designs WHERE id = ${rootId}
+      UNION ALL
+      SELECT s.id FROM spoon_all_designs s
+      JOIN descendants d ON s.parent_design_id = d.id
+    )
+    UPDATE spoon_all_designs
+    SET is_published = FALSE
+    WHERE id IN (SELECT id FROM descendants)
+      AND id != ${keepLuxeId}
+      AND generator NOT LIKE '%luxe%'
+    RETURNING id`;
+  const out = psql(sql);
+  return out ? out.split('\n').filter(Boolean).length : 0;
+}
+
+function logResult(entry) {
+  fs.appendFileSync(LOG, JSON.stringify(entry) + '\n');
+}
+
+(async () => {
+  const roots = pickRoots();
+  console.log(`[roll-luxe-c] category=${CATEGORY} candidates=${roots.length} concurrency=${CONCURRENCY} ${DRY ? '(DRY RUN)' : ''}`);
+  if (DRY) {
+    for (const r of roots.slice(0, 8)) {
+      console.log(`  #${r.rootId} → ground=${GROUNDS[r.rootId % GROUNDS.length]} · motif="${extractMotif(r.prompt).slice(0, 80)}…"`);
+    }
+    if (roots.length > 8) console.log(`  … and ${roots.length - 8} more`);
+    return;
+  }
+  if (!roots.length) { console.log('[roll-luxe-c] no candidates — exit'); return; }
+
+  const startedAt = Date.now();
+  let okCount = 0, errCount = 0, unpublishedTotal = 0, i = 0;
+  const q = [...roots];
+
+  async function worker(wid) {
+    while (q.length) {
+      const r = q.shift();
+      if (!r) return;
+      const idx = ++i;
+      const ground = GROUNDS[r.rootId % GROUNDS.length];
+      const motif = extractMotif(r.prompt);
+      const t0 = Date.now();
+      const res = await runGenLuxe(r.rootId, motif, ground);
+      const entry = {
+        ts: new Date().toISOString(),
+        worker: wid,
+        idx, total: roots.length,
+        root_id: r.rootId,
+        ground,
+        motif: motif.slice(0, 200),
+        ok: res.ok,
+        new_id: res.newId,
+        duration_ms: res.durationMs,
+        ...(res.ok ? {} : { code: res.code, stderr: res.stderrTail }),
+      };
+      if (res.ok) {
+        okCount++;
+        try {
+          const n = unpublishDescendants(r.rootId, res.newId);
+          entry.unpublished_descendants = n;
+          unpublishedTotal += n;
+        } catch (e) { entry.unpublish_err = e.message; }
+      } else {
+        errCount++;
+      }
+      logResult(entry);
+      const elapsed = ((Date.now() - startedAt) / 1000).toFixed(1);
+      const rate = (idx / (elapsed / 60)).toFixed(1);
+      console.log(`[w${wid}] ${idx}/${roots.length} root=${r.rootId} → ${res.ok ? '✓ #' + res.newId : '✗ code=' + res.code} (${(res.durationMs/1000).toFixed(1)}s · ground=${ground}) · pace ${rate}/min · totals: ok=${okCount} err=${errCount}`);
+    }
+  }
+
+  await Promise.all(Array.from({ length: CONCURRENCY }, (_, k) => worker(k + 1)));
+  const totalSec = ((Date.now() - startedAt) / 1000).toFixed(1);
+  console.log(`\n[roll-luxe-c] done · ok=${okCount} err=${errCount} unpublished=${unpublishedTotal} · ${totalSec}s wall · log=${LOG}`);
+})().catch(e => { console.error('FATAL', e.stack || e.message); process.exit(1); });
diff --git a/server.js b/server.js
index eccda21..ced7781 100644
--- a/server.js
+++ b/server.js
@@ -11285,18 +11285,18 @@ ${(() => {
               <label style="font:11px var(--sans);color:var(--ink-soft,#555);display:block">
                 Wall width
                 <div style="display:flex;align-items:center;gap:4px;margin-top:2px">
-                  <input id="wallfit-w" type="number" min="12" max="600" step="0.5" value="120" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
+                  <input id="wallfit-w" type="number" min="12" max="600" step="0.5" value="240" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
                   <select id="wallfit-w-unit" style="padding:7px 4px;border:1px solid var(--line);border-radius:6px;font:11px var(--sans);background:white">
-                    <option value="in">in</option><option value="ft">ft</option>
+                    <option value="in" selected>in</option><option value="ft">ft</option>
                   </select>
                 </div>
               </label>
               <label style="font:11px var(--sans);color:var(--ink-soft,#555);display:block">
                 Wall height
                 <div style="display:flex;align-items:center;gap:4px;margin-top:2px">
-                  <input id="wallfit-h" type="number" min="12" max="240" step="0.5" value="96" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
+                  <input id="wallfit-h" type="number" min="1" max="240" step="0.5" value="11" style="width:100%;padding:8px 10px;border:1px solid var(--line);border-radius:6px;font:14px var(--sans)">
                   <select id="wallfit-h-unit" style="padding:7px 4px;border:1px solid var(--line);border-radius:6px;font:11px var(--sans);background:white">
-                    <option value="in">in</option><option value="ft">ft</option>
+                    <option value="in">in</option><option value="ft" selected>ft</option>
                   </select>
                 </div>
               </label>
@@ -15736,6 +15736,38 @@ function designHasImage(d) {
   return false;
 }
 
+// /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.
+app.get('/api/luxe-c-gallery', (req, res) => {
+  try {
+    const logF = path.join(__dirname, 'data', 'luxe-c-rollout.jsonl');
+    if (!fs.existsSync(logF)) return res.json({ count: 0, 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.root_id)) continue;
+        seen.add(r.root_id);
+        items.push({
+          root_id: r.root_id,
+          new_id: r.new_id,
+          ground: r.ground,
+          motif: (r.motif || '').slice(0, 140),
+          duration_ms: r.duration_ms,
+          unpublished_descendants: r.unpublished_descendants || 0,
+          ts: r.ts,
+        });
+      } catch {}
+    }
+    res.json({ count: items.length, items });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // /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.

← d0b2192 TIF + SVG print-master pipeline — admin panel on /design/:id  ·  back to Wallco Ai  ·  feat(design page): INFO hamburger + buyer drawer next to ADM 250dc80 →