← 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
A public/luxe-c-gallery.htmlA scripts/roll-luxe-c.jsM server.js
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 →