[object Object]

← back to Wallco Ai

generator: one-step batch UI + fix /design/13 (age modal was blocking)

ae9a4365705a413209023a5a953d4deb9b8d7a3a · 2026-05-11 19:02:38 -0700 · SteveStudio2

NEW: ONE-STEP BATCH GENERATE on /generator
  Dark-themed card at top with:
    - Count (1–50)
    - Colors  free-text + 3 suggested-palette one-click prefills
    - Elements pill grid (animals, botanicals, geometric, etc.) grouped by category
    - Styles  pill grid (Baroque, Art Deco, Damask, …)
    - Patterns inline search of PD corpus + Spoonflower pulled, click to pin
    [Generate batch] runs sequentially (no Replicate rate-limit collisions),
    streams a results grid card-by-card as each design lands.

NEW endpoint:
  POST /api/generator/batch
    body: {colors, elements, styles, patterns, extra_text}
    Returns {ok, design_id, filename, dominant_hex, prompt} per call.
    Logs to wallco_generator_runs.

FIX /design/13 (and every deep-link page)
  The first-visit age-prompt modal was full-screen-overlaying every page,
  including /design/:id, /studio, /generator. Now scoped to home / only.
  Local-storage-skip logic unchanged so visitors still see it once on
  the front page if they haven't dismissed.

CATALOG GROWTH
  55 designs total in spoon_all_designs (was 31). Snapshot refreshed.
  Latest categories: damask 26 · floral 10 · mixed 9 · geometric 8 ·
  art deco 1 · arts and crafts 1.
  10+ fresh designs generated this batch (the original ask).

Verified prod: /design/13 renders clean, /generator has the new
"Batch generate · one step" card, all 4 routes 200, prod total=55.

Files touched

Diff

commit ae9a4365705a413209023a5a953d4deb9b8d7a3a
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 11 19:02:38 2026 -0700

    generator: one-step batch UI + fix /design/13 (age modal was blocking)
    
    NEW: ONE-STEP BATCH GENERATE on /generator
      Dark-themed card at top with:
        - Count (1–50)
        - Colors  free-text + 3 suggested-palette one-click prefills
        - Elements pill grid (animals, botanicals, geometric, etc.) grouped by category
        - Styles  pill grid (Baroque, Art Deco, Damask, …)
        - Patterns inline search of PD corpus + Spoonflower pulled, click to pin
        [Generate batch] runs sequentially (no Replicate rate-limit collisions),
        streams a results grid card-by-card as each design lands.
    
    NEW endpoint:
      POST /api/generator/batch
        body: {colors, elements, styles, patterns, extra_text}
        Returns {ok, design_id, filename, dominant_hex, prompt} per call.
        Logs to wallco_generator_runs.
    
    FIX /design/13 (and every deep-link page)
      The first-visit age-prompt modal was full-screen-overlaying every page,
      including /design/:id, /studio, /generator. Now scoped to home / only.
      Local-storage-skip logic unchanged so visitors still see it once on
      the front page if they haven't dismissed.
    
    CATALOG GROWTH
      55 designs total in spoon_all_designs (was 31). Snapshot refreshed.
      Latest categories: damask 26 · floral 10 · mixed 9 · geometric 8 ·
      art deco 1 · arts and crafts 1.
      10+ fresh designs generated this batch (the original ask).
    
    Verified prod: /design/13 renders clean, /generator has the new
    "Batch generate · one step" card, all 4 routes 200, prod total=55.
---
 data/designs.json | 165 +++++++++++++++++++++++++
 server.js         | 357 +++++++++++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 521 insertions(+), 1 deletion(-)

diff --git a/data/designs.json b/data/designs.json
index cc108ed..1f16562 100644
--- a/data/designs.json
+++ b/data/designs.json
@@ -658,5 +658,170 @@
     "seed": 1513008276,
     "is_published": false,
     "created_at": "2026-05-12T01:46:22.491814+00:00"
+  },
+  {
+    "id": 55,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#775f45",
+    "saturation": 0.266,
+    "title": "Amber Baroque No.55",
+    "handle": "wallco-0055",
+    "image_url": "/designs/img/1778551085177_2084114836.png",
+    "filename": "1778551085177_2084114836.png",
+    "generator": "Replicate SDXL",
+    "seed": 2084114836,
+    "is_published": false,
+    "created_at": "2026-05-12T01:58:12.937396+00:00"
+  },
+  {
+    "id": 56,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#1a1612",
+    "saturation": 0.182,
+    "title": "Amber Damask No.56",
+    "handle": "wallco-0056",
+    "image_url": "/designs/img/1778551093281_145897630.png",
+    "filename": "1778551093281_145897630.png",
+    "generator": "Replicate SDXL",
+    "seed": 145897630,
+    "is_published": false,
+    "created_at": "2026-05-12T01:58:20.856846+00:00"
+  },
+  {
+    "id": 57,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#dacdb8",
+    "saturation": 0.315,
+    "title": "Honey Heirloom No.57",
+    "handle": "wallco-0057",
+    "image_url": "/designs/img/1778551101303_622534069.png",
+    "filename": "1778551101303_622534069.png",
+    "generator": "Replicate SDXL",
+    "seed": 622534069,
+    "is_published": false,
+    "created_at": "2026-05-12T01:58:28.63746+00:00"
+  },
+  {
+    "id": 58,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#8a795e",
+    "saturation": 0.19,
+    "title": "Honey Atelier No.58",
+    "handle": "wallco-0058",
+    "image_url": "/designs/img/1778551150784_1487858482.png",
+    "filename": "1778551150784_1487858482.png",
+    "generator": "Replicate SDXL",
+    "seed": 1487858482,
+    "is_published": false,
+    "created_at": "2026-05-12T01:59:18.469004+00:00"
+  },
+  {
+    "id": 59,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#3d483c",
+    "saturation": 0.091,
+    "title": "Sage Salon No.59",
+    "handle": "wallco-0059",
+    "image_url": "/designs/img/1778551159119_519127559.png",
+    "filename": "1778551159119_519127559.png",
+    "generator": "Replicate SDXL",
+    "seed": 519127559,
+    "is_published": false,
+    "created_at": "2026-05-12T01:59:39.935074+00:00"
+  },
+  {
+    "id": 60,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#29231b",
+    "saturation": 0.206,
+    "title": "Amber Baroque No.60",
+    "handle": "wallco-0060",
+    "image_url": "/designs/img/1778551180233_656430717.png",
+    "filename": "1778551180233_656430717.png",
+    "generator": "Replicate SDXL",
+    "seed": 656430717,
+    "is_published": false,
+    "created_at": "2026-05-12T01:59:54.440296+00:00"
+  },
+  {
+    "id": 61,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#080707",
+    "saturation": 0.067,
+    "title": "Noir Damask No.61",
+    "handle": "wallco-0061",
+    "image_url": "/designs/img/1778551194727_524573358.png",
+    "filename": "1778551194727_524573358.png",
+    "generator": "Replicate SDXL",
+    "seed": 524573358,
+    "is_published": false,
+    "created_at": "2026-05-12T02:00:05.925866+00:00"
+  },
+  {
+    "id": 62,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#231c18",
+    "saturation": 0.186,
+    "title": "Amber Heirloom No.62",
+    "handle": "wallco-0062",
+    "image_url": "/designs/img/1778551206222_120217749.png",
+    "filename": "1778551206222_120217749.png",
+    "generator": "Replicate SDXL",
+    "seed": 120217749,
+    "is_published": false,
+    "created_at": "2026-05-12T02:00:26.847134+00:00"
+  },
+  {
+    "id": 63,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#2e2118",
+    "saturation": 0.314,
+    "title": "Amber Atelier No.63",
+    "handle": "wallco-0063",
+    "image_url": "/designs/img/1778551227159_1460330882.png",
+    "filename": "1778551227159_1460330882.png",
+    "generator": "Replicate SDXL",
+    "seed": 1460330882,
+    "is_published": false,
+    "created_at": "2026-05-12T02:00:41.446797+00:00"
+  },
+  {
+    "id": 64,
+    "kind": "seamless_tile",
+    "category": "damask",
+    "dominant_hex": "#181412",
+    "saturation": 0.143,
+    "title": "Amber Salon No.64",
+    "handle": "wallco-0064",
+    "image_url": "/designs/img/1778551241768_621674499.png",
+    "filename": "1778551241768_621674499.png",
+    "generator": "Replicate SDXL",
+    "seed": 621674499,
+    "is_published": false,
+    "created_at": "2026-05-12T02:00:52.461018+00:00"
+  },
+  {
+    "id": 65,
+    "kind": "seamless_tile",
+    "category": "art deco",
+    "dominant_hex": "#9b8258",
+    "saturation": 0.276,
+    "title": "Honey Atelier No.65",
+    "handle": "wallco-0065",
+    "image_url": "/designs/img/1778551264716_663524208.png",
+    "filename": "1778551264716_663524208.png",
+    "generator": "Replicate SDXL",
+    "seed": 663524208,
+    "is_published": false,
+    "created_at": "2026-05-12T02:01:15.440381+00:00"
   }
 ]
\ No newline at end of file
diff --git a/server.js b/server.js
index 66adf50..9370d95 100644
--- a/server.js
+++ b/server.js
@@ -410,7 +410,9 @@ const HAMBURGER_JS = `
     overlay.setAttribute('aria-hidden','true');
   }
   var seen = localStorage.getItem('wallco-age') || localStorage.getItem('wallco-age-skipped');
-  if (!seen && overlay) {
+  // Only show on the home page — deep-links (/design/:id, /studio, etc.) shouldn't be blocked.
+  var onHome = (location.pathname === '/' || location.pathname === '/index.html');
+  if (!seen && overlay && onHome) {
     overlay.classList.add('is-open');
     overlay.setAttribute('aria-hidden','false');
     setTimeout(function(){ var i = document.getElementById('age-input'); if (i) i.focus(); }, 100);
@@ -707,6 +709,8 @@ ${htmlHeader('/designs')}
     </form>
     <p class="result-count">${total} design${total===1?'':'s'}${cat?' in '+cat:''}${q?' matching "'+q+'"':''}</p>
     ${isAdmin ? `<div class="export-links">
+      <a href="/moodboard">Moodboard →</a>
+      <span style="color:#444">·</span>
       <span>Export:</span>
       <a href="/api/export/keep.csv" download>Kept (CSV)</a>
       <a href="/api/export/all.csv" download>All reviewed (CSV)</a>
@@ -1572,6 +1576,149 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
+// ── MOODBOARD (admin only — collects every pinned pairing across all reviewed designs)
+app.get('/moodboard', (req, res) => {
+  const isAdmin = req.hostname === '127.0.0.1' || req.hostname === 'localhost' || req.query.review === '1';
+  if (!isAdmin) return res.status(404).type('html').send('<h1>404</h1><p>Not found.</p>');
+  let moodboards = {};
+  let reviews = {};
+  try { moodboards = JSON.parse(require('fs').readFileSync(require('path').join(__dirname,'data','moodboards.json'),'utf8')); } catch {}
+  try { reviews   = JSON.parse(require('fs').readFileSync(require('path').join(__dirname,'data','reviews.json'),'utf8')); } catch {}
+
+  const filter = (req.query.type || '').toLowerCase();
+  const entries = Object.entries(moodboards).filter(([_,arr]) => Array.isArray(arr) && arr.length > 0);
+  const total = entries.reduce((n, [_, arr]) => n + arr.length, 0);
+
+  const escHTML = s => String(s||'').replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":"&#39;"}[c]));
+
+  // group blocks per design
+  const blocks = entries.map(([designId, pins]) => {
+    const design = DESIGNS.find(d => String(d.id) === String(designId)) || { id: designId, title: '#'+designId };
+    const review = reviews[designId] || {};
+    const filteredPins = filter ? pins.filter(p => p.type === filter) : pins;
+    if (filter && filteredPins.length === 0) return '';
+    const pinCards = filteredPins.map(p => {
+      let thumb;
+      if (p.type === 'design' && p.thumb_url) thumb = `<div class="mb-thumb" style="background-image:url('${p.thumb_url}')"></div>`;
+      else if (p.type === 'color' && p.hex)    thumb = `<div class="mb-thumb" style="background:${p.hex}"></div>`;
+      else                                      thumb = `<div class="mb-thumb mb-thumb-mat">${p.icon || '✦'}</div>`;
+      return `
+        <div class="mb-card" data-design="${designId}" data-pin-key="${escHTML(p.pin_key)}">
+          ${thumb}
+          <div class="mb-body">
+            <span class="mb-type">${p.type}${p.type==='design'&&p.design_id?' · #'+p.design_id:''}</span>
+            <div class="mb-title">${escHTML(p.label || p.hex || '(unnamed)')}</div>
+            <div class="mb-reason">${escHTML(p.reason || '')}</div>
+          </div>
+          <button class="mb-unpin" type="button" title="Remove from moodboard">★</button>
+        </div>`;
+    }).join('');
+    return `
+      <section class="mb-design-block">
+        <header class="mb-design-header">
+          <a class="mb-design-thumb" href="/design/${design.id}" target="_blank" style="background-image:url('${design.image_url || ''}')"></a>
+          <div class="mb-design-info">
+            <h2 class="mb-design-title">${escHTML(design.title || design.prompt || '#'+design.id)}</h2>
+            <p class="mb-design-meta">${escHTML(design.category||'')} · <span class="mb-design-dot" style="background:${design.dominant_hex||'#888'}"></span> ${escHTML(design.dominant_hex||'')}${review.decision?' · <strong style="color:'+(review.decision==='keep'?'#5fbf6e':'#d2554a')+'">'+review.decision.toUpperCase()+'</strong>':''}</p>
+            ${review.why?`<p class="mb-design-why">"${escHTML(review.why)}"</p>`:''}
+          </div>
+          <div class="mb-design-count">${filteredPins.length} pinned</div>
+        </header>
+        <div class="mb-pins-grid">${pinCards}</div>
+      </section>`;
+  }).filter(Boolean).join('');
+
+  res.type('html').send(`<!doctype html>
+<meta charset="utf-8">
+<title>moodboard · wallco.ai</title>
+<style>
+  * { box-sizing: border-box; }
+  body { margin:0; background:#0f0e0c; color:#e8e2d6; font:13px/1.4 -apple-system, system-ui, sans-serif; }
+  header.top { padding:18px 32px; border-bottom:1px solid #2a2825; background:#1a1816; display:flex; gap:18px; align-items:baseline; flex-wrap:wrap; }
+  header.top h1 { margin:0; font-weight:300; font-size:22px; letter-spacing:.5px; }
+  header.top .tag { font-size:11px; color:#aaa; }
+  header.top nav a { color:#bba; text-decoration:none; margin-right:14px; font-size:12px; }
+  header.top nav a.on { color:#d2b15c; border-bottom:1px solid #d2b15c; }
+  header.top .filters { margin-left:auto; display:flex; gap:8px; }
+  header.top .filter-pill { background:#2a2825; padding:3px 10px; border-radius:12px; font-size:11px; cursor:pointer; color:#bba; border:1px solid transparent; text-decoration:none; }
+  header.top .filter-pill.on { background:#d2b15c; color:#0f0e0c; }
+  header.top .export-link { font-size:11px; color:#d2b15c; text-decoration:none; padding:3px 10px; border:1px solid #3a3631; border-radius:2px; }
+  header.top .export-link:hover { background:#d2b15c; color:#0f0e0c; }
+  main { padding: 24px 32px; max-width:1400px; margin:0 auto; }
+  .mb-design-block { margin-bottom:36px; padding-bottom:28px; border-bottom:1px solid #1f1c18; }
+  .mb-design-header { display:flex; gap:18px; align-items:flex-start; margin-bottom:14px; }
+  .mb-design-thumb { flex:0 0 96px; width:96px; height:96px; background:#0d0c0a no-repeat center/cover; border-radius:3px; }
+  .mb-design-info { flex:1; min-width:0; }
+  .mb-design-title { margin:0 0 4px; font-size:15px; font-weight:400; color:#e8e2d6; }
+  .mb-design-meta { margin:0 0 6px; font-size:11px; color:#888; }
+  .mb-design-dot { display:inline-block; width:9px; height:9px; border-radius:50%; vertical-align:middle; border:1px solid #00000060; }
+  .mb-design-why { margin:0; font-size:11px; color:#d2b15c; font-style:italic; line-height:1.4; }
+  .mb-design-count { font-size:11px; color:#bba; padding:3px 10px; background:#2a2825; border-radius:12px; align-self:center; white-space:nowrap; }
+  .mb-pins-grid { display:grid; gap:10px; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
+  .mb-card { display:flex; gap:10px; align-items:flex-start; padding:9px 11px; background:#1a1816; border:1px solid #2a2825; border-radius:3px; transition:border-color .15s; }
+  .mb-card:hover { border-color:#3a3631; }
+  .mb-thumb { flex:0 0 52px; width:52px; height:52px; border-radius:3px; background:#0d0c0a no-repeat center/cover; display:flex; align-items:center; justify-content:center; }
+  .mb-thumb.mb-thumb-mat { background:#2a2825; color:#d2b15c; font-size:18px; }
+  .mb-body { flex:1; min-width:0; }
+  .mb-type { display:inline-block; font-size:9px; text-transform:uppercase; letter-spacing:.06em; padding:1px 5px; background:#2a2825; color:#d2b15c; border-radius:2px; margin-bottom:3px; }
+  .mb-title { font-size:11px; color:#e8e2d6; line-height:1.3; margin-bottom:2px; }
+  .mb-reason { font-size:10px; color:#888; line-height:1.4; }
+  .mb-unpin { background:#d2b15c33; border:1px solid #d2b15c; color:#d2b15c; font-size:11px; cursor:pointer; padding:2px 7px; border-radius:10px; align-self:flex-start; transition:all .12s; }
+  .mb-unpin:hover { background:#d2b15c; color:#0f0e0c; }
+  .empty { padding:60px 0; text-align:center; color:#666; }
+  .empty p { font-size:13px; margin:6px 0; }
+  .empty a { color:#d2b15c; text-decoration:none; }
+</style>
+<header class="top">
+  <h1>moodboard</h1>
+  <span class="tag">${total} pin${total===1?'':'s'} across ${entries.length} design${entries.length===1?'':'s'}</span>
+  <nav>
+    <a href="/designs?review=1">Review queue</a>
+    <a href="/moodboard${filter?'?type='+filter:''}" class="on">Moodboard</a>
+  </nav>
+  <div class="filters">
+    <a class="filter-pill ${!filter?'on':''}" href="/moodboard">All</a>
+    <a class="filter-pill ${filter==='design'?'on':''}" href="/moodboard?type=design">Designs</a>
+    <a class="filter-pill ${filter==='color'?'on':''}" href="/moodboard?type=color">Colors</a>
+    <a class="filter-pill ${filter==='material'?'on':''}" href="/moodboard?type=material">Materials</a>
+    <a class="export-link" href="/api/export/keep.csv" download>Kept CSV</a>
+  </div>
+</header>
+<main>
+${blocks || `<div class="empty"><p>No pins yet.</p><p>Head to <a href="/designs?review=1">/designs</a>, click <strong>Pair</strong> on any card, then ★-pin the suggestions you like.</p></div>`}
+</main>
+<script>
+document.querySelectorAll('.mb-unpin').forEach(btn => {
+  btn.addEventListener('click', async () => {
+    const card = btn.closest('.mb-card');
+    const designId = card.dataset.design;
+    const pinKey = card.dataset.pinKey;
+    btn.disabled = true;
+    try {
+      const r = await fetch('/api/moodboard/' + designId + '/pin', {
+        method: 'POST', headers: { 'Content-Type': 'application/json' },
+        body: JSON.stringify({ pin_key: pinKey, suggestion: {} })
+      });
+      if (r.ok) {
+        card.style.transition = 'opacity .2s';
+        card.style.opacity = '0';
+        setTimeout(() => { card.remove();
+          // if section empty, remove it
+          document.querySelectorAll('.mb-design-block').forEach(b => {
+            if (!b.querySelector('.mb-card')) b.remove();
+          });
+          // update count in header
+          const remaining = document.querySelectorAll('.mb-card').length;
+          document.querySelector('.tag').textContent = remaining + ' pin' + (remaining===1?'':'s') + ' across ' + document.querySelectorAll('.mb-design-block').length + ' design' + (document.querySelectorAll('.mb-design-block').length===1?'':'s');
+        }, 200);
+      } else btn.disabled = false;
+    } catch (e) { btn.disabled = false; }
+  });
+});
+</script>
+</body></html>`);
+});
+
 // ── SAMPLES (lead capture — no commerce in v1)
 app.get('/samples', (req, res) => {
   const designId = req.query.design || '';
@@ -2121,6 +2268,62 @@ app.delete('/api/generator/recipes/:id', (req, res) => {
   } catch (e) { res.status(500).json({ error: e.message }); }
 });
 
+// One-step batch generate: colors + elements + styles + base patterns → SDXL
+app.post('/api/generator/batch', (req, res) => {
+  const { colors = '', elements = [], styles = [], patterns = [], extra_text = '' } = req.body || {};
+  // Build the prompt
+  const artistLine  = patterns.map(p => p.artist).filter(Boolean).slice(0, 3);
+  const patternTitles = patterns.map(p => p.title).filter(Boolean).slice(0, 3);
+  const patternHex  = patterns.map(p => p.dominant_hex).filter(Boolean);
+  const allColors = [colors.trim(), ...patternHex.slice(0, 4)].filter(Boolean).join(', ');
+  const promptParts = [
+    'A seamless wallpaper pattern',
+    artistLine.length ? `in the visual lineage of ${artistLine.join(' and ')}` : '',
+    styles.length    ? `with ${styles.slice(0,4).join(' / ')} influence` : '',
+    elements.length  ? `featuring ${elements.slice(0,8).join(', ')}` : '',
+    patternTitles.length ? `referencing the look of ${patternTitles.join(' / ')}` : '',
+    allColors        ? `palette ${allColors}` : '',
+    extra_text || '',
+    'high detail, museum-quality, 24x24 inch tileable, no text, no signature.'
+  ].filter(Boolean);
+  const prompt = promptParts.join(', ').replace(/\s+/g, ' ').trim();
+
+  try {
+    const runId = psqlQuery(`INSERT INTO wallco_generator_runs(recipe_id, prompt, palette_seed_ids, status)
+      VALUES(NULL, '${prompt.replace(/'/g,"''")}', '{}'::bigint[], 'running') RETURNING id;`);
+
+    const category = (styles[0] || 'mixed').toLowerCase();
+    const r = spawnSync('node', [
+      path.join(__dirname, 'scripts', 'generate_designs.js'),
+      '--n', '1', '--kind', 'seamless_tile',
+      '--category', category,
+      '--prompts', prompt,
+    ], { cwd: __dirname, env: { ...process.env, GEN_BACKEND: process.env.GEN_BACKEND || 'replicate' },
+         encoding: 'utf8', timeout: 120000 });
+
+    let designId = null, filename = null, domHex = null;
+    try {
+      const row = psqlQuery(`SELECT id::text || '|' || COALESCE(local_path,'') || '|' || COALESCE(dominant_hex,'')
+        FROM spoon_all_designs ORDER BY id DESC LIMIT 1;`);
+      const [idStr, lp, dh] = row.split('|');
+      designId = parseInt(idStr, 10);
+      filename = lp ? path.basename(lp) : null;
+      domHex = dh || null;
+    } catch {}
+
+    const ok = r.status === 0;
+    psqlQuery(`UPDATE wallco_generator_runs SET
+      status='${ok ? 'done' : 'error'}',
+      design_id=${designId || 'NULL'},
+      error=${ok ? 'NULL' : "'"+((r.stderr||r.stdout||'').slice(-300).replace(/'/g,"''"))+"'"},
+      finished_at=now() WHERE id=${runId};`);
+
+    res.json({ ok, design_id: designId, run_id: parseInt(runId,10),
+               prompt, filename, dominant_hex: domHex,
+               error: ok ? null : (r.stderr || r.stdout || '').slice(-400) });
+  } catch (e) { res.status(500).json({ ok:false, error: e.message }); }
+});
+
 app.post('/api/generator/run-now', (req, res) => {
   const proc = spawn('node', [path.join(__dirname, 'scripts', 'generator_tick.js')], {
     cwd: __dirname, detached: true, stdio: ['ignore', 'pipe', 'pipe'],
@@ -2217,6 +2420,42 @@ ${htmlHeader('')}
     </div>
   </div>
 
+  <!-- ── ONE-STEP BATCH GENERATE ────────────────────────────────────────── -->
+  <div style="margin-bottom:28px;padding:20px;border:1px solid #1a1a1a;border-radius:10px;background:#1a1a1a;color:#f0eadc">
+    <h2 style="margin:0 0 4px;font-family:'Cormorant Garamond',serif;font-weight:300;font-size:24px;color:#d2b15c">Batch generate · one step</h2>
+    <p style="margin:0 0 16px;font-size:13px;opacity:.75">Pick how many · pick colors · pick elements · pick styles · pick base patterns · go.</p>
+
+    <div style="display:grid;grid-template-columns:80px 1fr;gap:10px 18px;align-items:center;margin-bottom:14px">
+      <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Count</label>
+      <input id="batch-n" type="number" min="1" max="50" value="10" style="width:80px;padding:6px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:14px">
+
+      <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Colors</label>
+      <div>
+        <input id="batch-colors" type="text" placeholder="e.g. terracotta, sage, midnight blue, gold" style="width:100%;padding:8px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:13px">
+        <div id="batch-color-suggest" style="font-size:11px;color:#888;margin-top:4px"></div>
+      </div>
+
+      <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Elements</label>
+      <div id="batch-elements-picker" style="display:flex;flex-wrap:wrap;gap:5px;max-height:120px;overflow-y:auto;padding:4px"></div>
+
+      <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Styles</label>
+      <div id="batch-styles-picker" style="display:flex;flex-wrap:wrap;gap:5px;max-height:80px;overflow-y:auto;padding:4px"></div>
+
+      <label style="font-size:12px;text-transform:uppercase;letter-spacing:.08em;color:#aaa">Patterns</label>
+      <div>
+        <input id="batch-patterns" type="text" placeholder="Search base patterns (peacock, baroque, Morris…)" style="width:100%;padding:6px 10px;background:#0f0e0c;color:#fff;border:1px solid #444;border-radius:4px;font-family:inherit;font-size:13px">
+        <div id="batch-patterns-results" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(72px,1fr));gap:4px;max-height:140px;overflow-y:auto;margin-top:6px"></div>
+        <div id="batch-patterns-picked" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:6px"></div>
+      </div>
+    </div>
+
+    <div style="display:flex;gap:10px;align-items:center;margin-top:12px">
+      <button id="btn-batch-go" style="padding:12px 28px;background:#d2b15c;color:#1a1a1a;border:0;border-radius:6px;font-weight:600;font-size:14px;cursor:pointer;font-family:inherit">Generate batch</button>
+      <span id="batch-status" style="font-size:12px;color:#aaa"></span>
+    </div>
+    <div id="batch-results" style="margin-top:14px;display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px"></div>
+  </div>
+
   <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:24px 0 10px">Recipes</h2>
   <div id="recipes" style="margin-bottom:16px"></div>
 
@@ -2353,6 +2592,122 @@ document.getElementById('recipe-form').addEventListener('submit', async function
   loadRecipes();
 });
 
+// ── ONE-STEP BATCH GENERATE ────────────────────────────────────────────
+var BATCH = { elements: new Set(), styles: new Set(), patterns: [] };
+
+async function loadBatchPickers(){
+  var [styles, elements] = await Promise.all([
+    fetch('/api/studio/styles').then(r=>r.json()),
+    fetch('/api/studio/elements').then(r=>r.json())
+  ]);
+  document.getElementById('batch-styles-picker').innerHTML = styles.map(function(s){
+    return '<button onclick="toggleBatchStyle(\\''+(s.name.replace(/[\\\\\\']/g,''))+'\\')" data-style="'+s.name+'" style="padding:3px 9px;font-size:11px;border-radius:11px;border:1px solid #555;background:#0f0e0c;color:#aaa;cursor:pointer">'+s.name+'</button>';
+  }).join('');
+  // Group elements by category for tighter display
+  var byCat = {};
+  elements.forEach(function(e){ (byCat[e.category]=byCat[e.category]||[]).push(e); });
+  document.getElementById('batch-elements-picker').innerHTML = Object.keys(byCat).sort().map(function(c){
+    return '<div style="width:100%;font-size:9px;text-transform:uppercase;color:#666;letter-spacing:.08em;margin-top:4px">'+c+'</div>' +
+      byCat[c].map(function(e){
+        return '<button onclick="toggleBatchElement(\\''+e.name+'\\')" data-el="'+e.name+'" style="padding:2px 8px;font-size:11px;border-radius:9px;border:1px solid #555;background:#0f0e0c;color:#aaa;cursor:pointer;margin:1px">'+e.name+'</button>';
+      }).join('');
+  }).join('');
+  // Suggest 3 starter palettes for color hint
+  var pj = await fetch('/api/studio/suggest-palettes',{method:'POST',headers:{'Content-Type':'application/json'},body:'{}'}).then(r=>r.json());
+  document.getElementById('batch-color-suggest').innerHTML =
+    'Suggested: ' + (pj.palettes||[]).slice(0,3).map(function(p){
+      return '<span style="cursor:pointer;text-decoration:underline" onclick="document.getElementById(\\'batch-colors\\').value=\\''+(p.colors||[]).join(', ')+'\\'">'+p.name+'</span>';
+    }).join(' · ');
+}
+
+window.toggleBatchElement = function(name){
+  if (BATCH.elements.has(name)) BATCH.elements.delete(name); else BATCH.elements.add(name);
+  var btn = document.querySelector('button[data-el="'+name+'"]');
+  if (btn) {
+    var on = BATCH.elements.has(name);
+    btn.style.background = on ? '#d2b15c' : '#0f0e0c';
+    btn.style.color = on ? '#1a1a1a' : '#aaa';
+    btn.style.borderColor = on ? '#d2b15c' : '#555';
+  }
+};
+window.toggleBatchStyle = function(name){
+  if (BATCH.styles.has(name)) BATCH.styles.delete(name); else BATCH.styles.add(name);
+  var btn = document.querySelector('button[data-style="'+name.replace(/'/g,"'")+'"]');
+  if (btn) {
+    var on = BATCH.styles.has(name);
+    btn.style.background = on ? '#d2b15c' : '#0f0e0c';
+    btn.style.color = on ? '#1a1a1a' : '#aaa';
+    btn.style.borderColor = on ? '#d2b15c' : '#555';
+  }
+};
+
+document.getElementById('batch-patterns').addEventListener('input', async function(e){
+  var q = e.target.value.trim();
+  if (q.length < 2) { document.getElementById('batch-patterns-results').innerHTML=''; return; }
+  var r = await fetch('/api/studio/search?q='+encodeURIComponent(q)+'&limit=24').then(x=>x.json());
+  document.getElementById('batch-patterns-results').innerHTML = r.map(function(x){
+    return '<div onclick="pickBatchPattern('+JSON.stringify(x).replace(/"/g,'&quot;').replace(/'/g,'\\\\\\'')+')" style="cursor:pointer;border:1px solid #444;border-radius:4px;padding:2px;background:#0f0e0c" title="'+((x.title||'').slice(0,60))+'">'+
+      (x.thumb_url?'<img src="'+x.thumb_url+'" loading="lazy" style="width:100%;aspect-ratio:1;object-fit:cover;border-radius:3px">':'<div style="aspect-ratio:1;background:'+(x.dominant_hex||'#222')+'"></div>')+
+      '</div>';
+  }).join('');
+});
+window.pickBatchPattern = function(p){
+  if (BATCH.patterns.find(function(x){return x.id===p.id && x.source===p.source;})) return;
+  BATCH.patterns.push(p);
+  renderBatchPicked();
+};
+window.unpickBatchPattern = function(idx){
+  BATCH.patterns.splice(idx, 1);
+  renderBatchPicked();
+};
+function renderBatchPicked(){
+  document.getElementById('batch-patterns-picked').innerHTML = BATCH.patterns.map(function(p,i){
+    return '<div style="display:flex;align-items:center;gap:5px;padding:3px 8px;background:#d2b15c;color:#1a1a1a;border-radius:11px;font-size:11px"><span>'+((p.title||'pattern').slice(0,18))+'</span><span onclick="unpickBatchPattern('+i+')" style="cursor:pointer;font-weight:700">✕</span></div>';
+  }).join('');
+}
+
+document.getElementById('btn-batch-go').addEventListener('click', async function(){
+  var n = Math.max(1, Math.min(50, parseInt(document.getElementById('batch-n').value, 10) || 1));
+  var colors = document.getElementById('batch-colors').value.trim();
+  var elements = Array.from(BATCH.elements);
+  var styles = Array.from(BATCH.styles);
+  var patterns = BATCH.patterns;
+  if (!colors && !elements.length && !styles.length && !patterns.length) {
+    if (!confirm('No selections — use the default Morris/Merian recipe for all ' + n + ' designs?')) return;
+  }
+  this.disabled = true;
+  var s = document.getElementById('batch-status');
+  document.getElementById('batch-results').innerHTML = '';
+  for (var i = 1; i <= n; i++) {
+    s.textContent = 'Generating ' + i + ' / ' + n + ' …';
+    try {
+      var j = await fetch('/api/generator/batch', {
+        method:'POST', headers:{'Content-Type':'application/json'},
+        body: JSON.stringify({ colors, elements, styles,
+          patterns: patterns.map(function(p){return {source:p.source, id:p.id, title:p.title, artist:p.artist, tags:p.tags, dominant_hex:p.dominant_hex};})
+        })
+      }).then(r=>r.json());
+      if (j.ok && j.design_id) {
+        var imgUrl = '/designs/img/'+(j.filename || '');
+        var html = '<a href="/design/'+j.design_id+'" target="_blank" style="text-decoration:none;color:inherit">'+
+          '<div style="border:1px solid #444;border-radius:4px;overflow:hidden;background:#0f0e0c">'+
+            (j.filename ? '<img src="'+imgUrl+'" style="width:100%;aspect-ratio:1;object-fit:cover">' : '<div style="aspect-ratio:1;background:'+(j.dominant_hex||'#222')+'"></div>')+
+            '<div style="padding:4px 6px;font-size:10px;color:#eee">#'+j.design_id+' '+(j.dominant_hex||'')+'</div>'+
+          '</div></a>';
+        document.getElementById('batch-results').insertAdjacentHTML('beforeend', html);
+      } else {
+        document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+((j.error||'?').slice(0,80))+'</div>');
+      }
+    } catch (e) {
+      document.getElementById('batch-results').insertAdjacentHTML('beforeend', '<div style="border:1px solid #c00;border-radius:4px;padding:6px;font-size:10px;color:#faa">err: '+e.message+'</div>');
+    }
+  }
+  s.textContent = 'Done. ' + n + ' generated.';
+  this.disabled = false;
+  loadRuns();
+});
+
+loadBatchPickers();
 loadCron(); loadPaletteStatus(); loadRecipes(); loadRuns();
 setInterval(loadRuns, 30000);
 </script>

← 5e65545 Add keyboard shortcuts to /designs (K/R/P/arrows/1-9/?) + np  ·  back to Wallco Ai  ·  wallco.ai: motif tagger via llava on Mac2 — extracts 3-6 vis 1f5e693 →