[object Object]

← back to Wallco Ai

T3: regenerate-THIS-variant button on /luxe-curator.html

05d4109c8c5fea66af154bb7dcc0045bb10d8a3c · 2026-05-25 03:03:16 -0700 · Steve Abrams

POST /api/luxe-curator/regen {root_id, variant} — recovers motif +
ground from the prior queue entry for that (root_id, variant) pair,
spawns scripts/gen-luxe.js --curator-mode --variant=X --texture-cat=Y,
waits for the new luxe row, appends a regen entry to
data/luxe-curator-queue.jsonl, returns the new_id.

GET /api/luxe-curator now dedups variants per (root_id, variant) with
latest-wins (was first-wins on root_id only). New regen entries
naturally bubble up to the surface on next refresh.

UI: hover-revealed ↻ button in the top-right corner of every variant
tile. Click → optimistic "regenerating…" overlay → on response the tile
image swaps in-place with a cache-bust querystring. Failure shows
toast + restores the original. Click-bubbling guarded so the regen
button never accidentally triggers the parent tile's pick handler.

Validated:
  - Bad input → 400
  - No prior entry → 404
  - Real regen on root #10310 variant A → #41841 → #42023 (34s wall —
    composition gate retried 2× before passing, normal variance for
    locked-variant runs where MAX=3)

Files touched

Diff

commit 05d4109c8c5fea66af154bb7dcc0045bb10d8a3c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Mon May 25 03:03:16 2026 -0700

    T3: regenerate-THIS-variant button on /luxe-curator.html
    
    POST /api/luxe-curator/regen {root_id, variant} — recovers motif +
    ground from the prior queue entry for that (root_id, variant) pair,
    spawns scripts/gen-luxe.js --curator-mode --variant=X --texture-cat=Y,
    waits for the new luxe row, appends a regen entry to
    data/luxe-curator-queue.jsonl, returns the new_id.
    
    GET /api/luxe-curator now dedups variants per (root_id, variant) with
    latest-wins (was first-wins on root_id only). New regen entries
    naturally bubble up to the surface on next refresh.
    
    UI: hover-revealed ↻ button in the top-right corner of every variant
    tile. Click → optimistic "regenerating…" overlay → on response the tile
    image swaps in-place with a cache-bust querystring. Failure shows
    toast + restores the original. Click-bubbling guarded so the regen
    button never accidentally triggers the parent tile's pick handler.
    
    Validated:
      - Bad input → 400
      - No prior entry → 404
      - Real regen on root #10310 variant A → #41841 → #42023 (34s wall —
        composition gate retried 2× before passing, normal variance for
        locked-variant runs where MAX=3)
---
 public/luxe-curator.html | 70 ++++++++++++++++++++++++++++++++++++++++
 server.js                | 84 ++++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 151 insertions(+), 3 deletions(-)

diff --git a/public/luxe-curator.html b/public/luxe-curator.html
index 41a74b0..878d02b 100644
--- a/public/luxe-curator.html
+++ b/public/luxe-curator.html
@@ -46,6 +46,23 @@
     letter-spacing:0.05em; background:rgba(255,255,255,0.92); color:#1a1a1a;
     border-radius:2px; font-weight:600;
   }
+  .row .tile .regen-btn {
+    position:absolute; top:6px; right:6px; width:24px; height:24px;
+    border-radius:50%; background:rgba(255,255,255,0.92); border:none;
+    color:#1a1a1a; font-size:14px; cursor:pointer; display:flex;
+    align-items:center; justify-content:center; opacity:0;
+    transition:opacity .15s, transform .15s; line-height:1; padding:0;
+  }
+  .row .tile:hover .regen-btn { opacity:1; }
+  .row .tile .regen-btn:hover { background:#fff; transform:scale(1.1); }
+  .row .tile .regen-btn:disabled { cursor:wait; }
+  .row .tile.regenerating { position:relative; }
+  .row .tile.regenerating::after {
+    content:'⟳ regenerating…'; position:absolute; inset:0;
+    background:rgba(255,255,255,0.86); display:flex; align-items:center; justify-content:center;
+    font:600 11px var(--sans); color:var(--root); letter-spacing:.04em;
+    text-transform:uppercase; z-index:5;
+  }
   .row .meta {
     grid-column:1/-1; display:flex; justify-content:space-between; align-items:center;
     font-size:11.5px; color:var(--muted); padding:4px 4px 0;
@@ -113,6 +130,7 @@ function render() {
         <img loading="lazy" src="/designs/img/by-id/${v.new_id}" alt="${v.variant}">
         <span class="label ${v.variant}">Variant ${v.variant}</span>
         <span class="ground">${v.ground}</span>
+        <button class="regen-btn" data-regen="${v.variant}" data-row="${i}" title="Regenerate this variant (~10s, ~$0.04)">↻</button>
       </div>
     `).join('');
     return `
@@ -133,6 +151,8 @@ function render() {
   // Wire clicks
   main.querySelectorAll('[data-pick]').forEach(el => {
     el.addEventListener('click', (e) => {
+      // Regen button click bubbles up — let the regen handler claim it instead
+      if (e.target.matches('[data-regen]')) return;
       const kind = el.dataset.pick;
       const row = parseInt(el.dataset.row, 10);
       if (kind === 'variant') {
@@ -142,6 +162,56 @@ function render() {
       }
     });
   });
+  // Regen buttons
+  main.querySelectorAll('[data-regen]').forEach(btn => {
+    btn.addEventListener('click', (e) => {
+      e.stopPropagation();
+      const row = parseInt(btn.dataset.row, 10);
+      const variant = btn.dataset.regen;
+      regenVariant(row, variant);
+    });
+  });
+}
+
+async function regenVariant(rowIdx, variant) {
+  const p = DATA[rowIdx];
+  if (!p) return;
+  // Mark the tile as regenerating
+  const tile = document.querySelector(`#row-${rowIdx} [data-variant="${variant}"]`);
+  if (tile) {
+    tile.classList.add('regenerating');
+    tile.querySelector('.regen-btn')?.setAttribute('disabled', 'true');
+  }
+  toast(`Regenerating ${variant} for root #${p.root_id} (~10s)`);
+  try {
+    const r = await fetch('/api/luxe-curator/regen', {
+      method: 'POST', headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify({ root_id: p.root_id, variant }),
+    });
+    const j = await r.json();
+    if (!j.ok) throw new Error(j.error || 'regen failed');
+    // Swap the image to the new luxe id without a full reload — update DATA + re-render this row
+    const vIdx = p.variants.findIndex(v => v.variant === variant);
+    if (vIdx >= 0) {
+      p.variants[vIdx].new_id = j.new_id;
+      p.variants[vIdx].duration_ms = j.duration_ms;
+    }
+    // Re-render just this row
+    if (tile) {
+      const img = tile.querySelector('img');
+      if (img) img.src = `/designs/img/by-id/${j.new_id}?t=${Date.now()}`;
+      tile.dataset.luxeId = String(j.new_id);
+      tile.classList.remove('regenerating');
+      tile.querySelector('.regen-btn')?.removeAttribute('disabled');
+    }
+    toast(`Variant ${variant} regenerated → #${j.new_id} (${(j.duration_ms/1000).toFixed(1)}s)`);
+  } catch (e) {
+    if (tile) {
+      tile.classList.remove('regenerating');
+      tile.querySelector('.regen-btn')?.removeAttribute('disabled');
+    }
+    toast('Regen failed: ' + e.message);
+  }
 }
 
 async function pickVariant(rowIdx, luxeId, variant) {
diff --git a/server.js b/server.js
index 5452e31..13f7fe0 100644
--- a/server.js
+++ b/server.js
@@ -15984,14 +15984,21 @@ app.get('/api/luxe-curator', (req, res) => {
   try {
     const logF = path.join(__dirname, 'data', 'luxe-curator-queue.jsonl');
     if (!fs.existsSync(logF)) return res.json({ count: 0, items: [], decided: 0 });
+    // Build a (root_id → variant → latest-entry) map so regen entries
+    // replace prior ones on the same (root_id, variant) key.
     const byRoot = new Map();
     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) continue;
-        if (!byRoot.has(r.root_id)) byRoot.set(r.root_id, { root_id: r.root_id, motif: r.motif, variants: [] });
-        byRoot.get(r.root_id).variants.push({ variant: r.variant, new_id: r.new_id, ground: r.ground, duration_ms: r.duration_ms });
+        if (!byRoot.has(r.root_id)) byRoot.set(r.root_id, { root_id: r.root_id, motif: r.motif, variantMap: new Map() });
+        // Overwrite previous entry for this variant — latest wins
+        byRoot.get(r.root_id).variantMap.set(r.variant, { variant: r.variant, new_id: r.new_id, ground: r.ground, duration_ms: r.duration_ms });
+        // Keep the longest motif we've seen — regen entries may have a truncated one
+        if (r.motif && r.motif.length > (byRoot.get(r.root_id).motif || '').length) {
+          byRoot.get(r.root_id).motif = r.motif;
+        }
       } catch {}
     }
     let decided = new Set();
@@ -16001,7 +16008,11 @@ app.get('/api/luxe-curator', (req, res) => {
     } catch { /* table may not exist yet */ }
     const pending = [...byRoot.values()]
       .filter(r => !decided.has(r.root_id))
-      .map(r => ({ ...r, variants: r.variants.sort((a, b) => a.variant.localeCompare(b.variant)) }))
+      .map(r => ({
+        root_id: r.root_id,
+        motif: r.motif,
+        variants: [...r.variantMap.values()].sort((a, b) => a.variant.localeCompare(b.variant)),
+      }))
       .sort((a, b) => a.root_id - b.root_id);
     res.json({ count: pending.length, decided: decided.size, items: pending });
   } catch (e) {
@@ -16009,6 +16020,73 @@ app.get('/api/luxe-curator', (req, res) => {
   }
 });
 
+// POST /api/luxe-curator/regen — re-run gen-luxe.js for a specific
+// (root_id, variant) cell. Looks up the prior entry to recover the
+// motif + ground, spawns gen-luxe in curator-mode, waits for the new
+// luxe row, appends a new entry to luxe-curator-queue.jsonl, and
+// returns the new luxe_id. The /api/luxe-curator endpoint dedups on
+// (root_id, variant) with latest-wins so the UI surfaces the new image
+// on next refresh.
+//
+// Body: { root_id, variant: 'A'|'B'|'C'|'D'|'E' }
+// Returns: { ok, new_id, duration_ms } or { ok: false, error }
+app.post('/api/luxe-curator/regen', express.json({ limit: '4kb' }), async (req, res) => {
+  const rootId = parseInt(req.body?.root_id, 10);
+  const variant = String(req.body?.variant || '').toUpperCase();
+  if (!Number.isFinite(rootId) || !['A','B','C','D','E'].includes(variant)) {
+    return res.status(400).json({ ok: false, error: 'bad input' });
+  }
+  try {
+    // Find motif + ground for this (root_id, variant) from the queue log
+    const logF = path.join(__dirname, 'data', 'luxe-curator-queue.jsonl');
+    if (!fs.existsSync(logF)) return res.status(404).json({ ok: false, error: 'no queue log' });
+    let motif = '', ground = '';
+    for (const line of fs.readFileSync(logF, 'utf8').split('\n')) {
+      if (!line.trim()) continue;
+      try {
+        const r = JSON.parse(line);
+        if (r.root_id === rootId && r.variant === variant && r.motif) {
+          motif = r.motif; ground = r.ground || '';
+        }
+      } catch {}
+    }
+    if (!motif) return res.status(404).json({ ok: false, error: 'no prior entry for this (root,variant)' });
+    // Spawn gen-luxe.js
+    const { spawn } = require('child_process');
+    const args = [
+      path.join(__dirname, 'scripts', 'gen-luxe.js'),
+      String(rootId), motif,
+      `--variant=${variant}`,
+      `--texture-cat=${ground || 'silk'}`,
+      `--curator-mode`,
+    ];
+    const t0 = Date.now();
+    const child = spawn('node', args, { cwd: __dirname, env: process.env });
+    let stdout = '', stderr = '';
+    child.stdout.on('data', d => { stdout += d; });
+    child.stderr.on('data', d => { stderr += d; });
+    const code = await new Promise(resolve => child.on('close', resolve));
+    const duration_ms = Date.now() - t0;
+    const m = stdout.match(/\[gen-luxe\] PG id: (\d+)/);
+    const newId = m ? parseInt(m[1], 10) : null;
+    if (code !== 0 || !newId) {
+      return res.status(500).json({ ok: false, error: 'gen-luxe failed', code, stderr: stderr.slice(-300) });
+    }
+    // Append regen entry to queue log so dedup picks it up
+    fs.appendFileSync(logF, JSON.stringify({
+      ts: new Date().toISOString(),
+      regen: true,
+      root_id: rootId,
+      variant, ground,
+      motif: motif.slice(0, 200),
+      ok: true,
+      new_id: newId,
+      duration_ms,
+    }) + '\n');
+    res.json({ ok: true, new_id: newId, duration_ms });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
 // POST /api/luxe-curator/pick — record Steve's per-root decision.
 // Body: { root_id, picked_variant: 'A'|'C'|'E'|'NONE', picked_luxe_id?: <id> }
 //   picked_variant='NONE' means "keep the root, reject all variants".

← e7bc7e1 T2: subject-mismatch scan + gallery — flags dropped-subject  ·  back to Wallco Ai  ·  T4: keyboard R = regen focused row; visual focus ring; K-key c2332ef →