[object Object]

← back to Wallco Ai

add botanical-wonders substitute generator + curator (12 designs for retired Clarke & Clarke DWKK-127831/127832 samples)

71a12b217d939fc1d449f5c0504129be81427f89 · 2026-05-28 14:12:29 -0700 · Steve Abrams

Files touched

Diff

commit 71a12b217d939fc1d449f5c0504129be81427f89
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu May 28 14:12:29 2026 -0700

    add botanical-wonders substitute generator + curator (12 designs for retired Clarke & Clarke DWKK-127831/127832 samples)
---
 public/admin/botanical-wonders-curator.html | 217 +++++++++++++++++++++++
 scripts/generate-botanical-wonders.js       | 261 ++++++++++++++++++++++++++++
 server.js                                   |  26 +++
 3 files changed, 504 insertions(+)

diff --git a/public/admin/botanical-wonders-curator.html b/public/admin/botanical-wonders-curator.html
new file mode 100644
index 0000000..ec437bc
--- /dev/null
+++ b/public/admin/botanical-wonders-curator.html
@@ -0,0 +1,217 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Botanical Wonders — Substitute Curator</title>
+<style>
+  :root { --cols: 4; --bg:#14110e; --panel:#1d1915; --ink:#ece4d6; --muted:#9a9082;
+          --line:#332c24; --pass:#5fbf7a; --warn:#d8a23a; --fail:#d4574f; --accent:#c9b07a; }
+  * { box-sizing: border-box; }
+  body { margin:0; background:var(--bg); color:var(--ink);
+         font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif; }
+  header { position:sticky; top:0; z-index:20; background:rgba(20,17,14,.96);
+           backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 22px; }
+  h1 { margin:0 0 2px; font-size:20px; font-weight:600; letter-spacing:.02em; }
+  h1 small { color:var(--muted); font-weight:400; font-size:13px; margin-left:8px; }
+  .bar { display:flex; flex-wrap:wrap; gap:14px; align-items:center; margin-top:10px; }
+  .bar label { color:var(--muted); font-size:12px; text-transform:uppercase; letter-spacing:.06em; }
+  select, button { font:inherit; color:var(--ink); background:var(--panel);
+           border:1px solid var(--line); border-radius:8px; padding:7px 11px; cursor:pointer; }
+  button.primary { background:var(--accent); color:#1a1611; border-color:var(--accent); font-weight:600; }
+  button.ghost { background:transparent; }
+  button:disabled { opacity:.4; cursor:not-allowed; }
+  input[type=range] { accent-color:var(--accent); width:150px; vertical-align:middle; }
+  .stat { color:var(--muted); font-size:13px; }
+  .stat b { color:var(--ink); }
+  main { padding:18px 22px 90px; }
+  .family { margin:0 0 36px; }
+  .family h2 { font-size:17px; font-weight:600; margin:0 0 4px;
+              display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
+  .family h2 .cnt { color:var(--muted); font-size:12px; font-weight:400; }
+  .family h2 .parent { color:var(--accent); font-size:11px; font-weight:600; letter-spacing:.06em;
+                       background:rgba(201,176,122,.1); border:1px solid var(--line); padding:2px 7px; border-radius:5px; }
+  .family .acts { font-size:12px; }
+  .family .acts a { color:var(--accent); cursor:pointer; margin-right:12px; }
+  .family .sub { color:var(--muted); font-size:12px; font-style:italic; margin:0 0 8px; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:14px; margin-top:10px; }
+  .chip { position:relative; border-radius:10px; overflow:hidden; border:2px solid var(--line);
+          background:#0d0b09; aspect-ratio:1/1; cursor:pointer; transition:border-color .12s, opacity .12s; }
+  .chip img { width:100%; height:100%; object-fit:cover; display:block; }
+  .chip.keep { border-color:var(--pass); }
+  .chip.reject { opacity:.34; border-color:var(--line); }
+  .chip .motif { position:absolute; top:6px; left:6px; font-size:10.5px; font-weight:600;
+          padding:3px 7px; border-radius:5px; background:rgba(0,0,0,.6); color:var(--ink); letter-spacing:.02em; }
+  .chip .live { position:absolute; top:6px; right:6px; font-size:10px; font-weight:700;
+          padding:2px 6px; border-radius:5px; background:var(--pass); color:#0c1a10; display:none; }
+  .chip.published .live { display:block; }
+  .chip .pick { position:absolute; bottom:24px; right:6px; width:24px; height:24px; border-radius:50%;
+          background:#000a; display:flex; align-items:center; justify-content:center; font-size:14px; font-weight:700; }
+  .chip.keep .pick { background:var(--pass); color:#0c1a10; }
+  .chip .when { position:absolute; left:0; right:0; bottom:0; padding:3px 7px; background:rgba(13,11,9,.82);
+          color:var(--muted); font-size:10.5px; font-variant-numeric:tabular-nums; letter-spacing:.02em;
+          white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
+  footer { position:fixed; bottom:0; left:0; right:0; background:rgba(20,17,14,.97);
+           border-top:1px solid var(--line); padding:12px 22px; display:flex; gap:12px; align-items:center; z-index:30; }
+  footer .grow { flex:1; }
+  .toast { position:fixed; bottom:70px; left:50%; transform:translateX(-50%); background:var(--panel);
+           border:1px solid var(--accent); border-radius:8px; padding:10px 18px; opacity:0; transition:opacity .2s; }
+  .toast.show { opacity:1; }
+  .note { color:var(--muted); font-size:11.5px; max-width:520px; line-height:1.45; }
+  .note b { color:var(--accent); font-weight:600; }
+</style>
+</head>
+<body>
+<header>
+  <h1>Botanical Wonders — Substitute Curator <small id="sub">loading…</small></h1>
+  <div class="bar">
+    <span><label>Sort</label>
+      <select id="sort">
+        <option value="family">Family + Motif</option>
+        <option value="newest">Newest</option>
+        <option value="color">Color (dominant hue)</option>
+      </select></span>
+    <span><label>Density</label>
+      <input type="range" id="density" min="2" max="8" step="1" value="4">
+      <span id="densval" class="stat">4</span></span>
+    <span class="stat">Kept&nbsp;<b id="cKeep">0</b>&nbsp;·&nbsp;Live&nbsp;<b id="cLive">0</b>&nbsp;of&nbsp;<b id="cTotal">0</b></span>
+    <span class="grow"></span>
+    <span class="note">In-house wallco.ai substitutes for 2 retired Clarke &amp; Clarke "Wonderlust" sample SKUs.
+      Settlement gate: <b>OK</b> (logged 2026-05-27).
+      Pick which to publish; <b>publish flip still requires TIF archive</b> per CLAUDE.md.</span>
+  </div>
+</header>
+
+<main id="main"></main>
+
+<footer>
+  <span class="stat">Kept: <b id="fKeep">0</b> · Live now: <b id="fLive">0</b></span>
+  <span class="grow"></span>
+  <button class="ghost" id="selAll">Keep all</button>
+  <button class="ghost" id="selClear">Clear picks</button>
+  <button class="primary" id="publish">Publish kept →</button>
+  <button class="ghost" id="unpublish">Unpublish kept</button>
+</footer>
+<div class="toast" id="toast"></div>
+
+<script>
+const state = { items: [], keep: new Set() };
+const $ = s => document.querySelector(s);
+const FAMILY_LABEL = { 'dove-multi':'Dove Multi', 'teal-multi':'Teal Multi' };
+const PARENT_SKU = { 'dove-multi':'DWKK-127831-Sample', 'teal-multi':'DWKK-127832-Sample' };
+const MOTIF_NAME = {
+  'dragonfly-fern':'Dragonfly & Fern', 'bee-honeysuckle':'Bee & Honeysuckle',
+  'beetle-leaf':'Beetle & Leaf', 'moth-grass':'Moth & Grass',
+  'ladybug-clover':'Ladybugs & Clover', 'mantis-stem':'Mantis on Stem',
+};
+const familyOf = c => (c || '').replace(/^botanical-wonders · /, '');
+function motifOf(prompt) {
+  const m = (prompt || '').match(/\[var:([a-z0-9-]+)\]/);
+  return m ? m[1] : '';
+}
+function fmtDate(iso){
+  if(!iso) return '—';
+  const d = new Date(iso); if(isNaN(d)) return '—';
+  return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
+}
+function hueOf(hex){ if(!/^#?[0-9a-f]{6}$/i.test(hex||'')) return 999;
+  const n=hex.replace('#',''); const r=parseInt(n.slice(0,2),16)/255,g=parseInt(n.slice(2,4),16)/255,b=parseInt(n.slice(4,6),16)/255;
+  const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; let h=0;
+  if(d){ if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if(h<0)h+=360; } return h; }
+
+async function load(){
+  const r = await fetch('/api/admin/botanical-wonders/list', {credentials:'same-origin'});
+  if(!r.ok){ $('#sub').textContent='admin auth required'; return; }
+  const j = await r.json();
+  state.items = (j.items || []).map(d => ({...d, _motif: motifOf(d.prompt), _family: familyOf(d.category)}));
+  state.items.forEach(d => { if(d.is_published) state.keep.add(d.id); });
+  render();
+}
+
+function render(){
+  const sort = $('#sort').value;
+  let items = state.items.slice();
+  const cmp = {
+    family:(a,b)=> a._family.localeCompare(b._family) || a._motif.localeCompare(b._motif) || a.id-b.id,
+    newest:(a,b)=> b.id-a.id,
+    color: (a,b)=> hueOf(a.dominant_hex)-hueOf(b.dominant_hex) || a.id-b.id,
+  }[sort];
+  items.sort(cmp);
+
+  const groups = {};
+  items.forEach(d => { (groups[d._family] ||= []).push(d); });
+  const main = $('#main'); main.innerHTML='';
+  Object.keys(groups).sort().forEach(fam => {
+    const sec = document.createElement('section'); sec.className='family';
+    const rows = groups[fam];
+    const label = FAMILY_LABEL[fam] || fam;
+    const parent = PARENT_SKU[fam] || '';
+    sec.innerHTML = `<h2>${label}
+        <span class="parent">substitutes for ${parent}</span>
+        <span class="cnt">${rows.length} designs</span>
+        <span class="acts"><a data-keep="${fam}">keep all</a><a data-rej="${fam}">reject all</a></span></h2>
+      <p class="sub">Six botanical motifs — dragonfly, bee, beetle, moth, ladybug, mantis — each rendered as a single-ink solid screen-print on the ${label.toLowerCase()} ground.</p>
+      <div class="grid"></div>`;
+    const grid = sec.querySelector('.grid');
+    rows.forEach(d => grid.appendChild(chip(d)));
+    main.appendChild(sec);
+  });
+  $('#cKeep').textContent=state.keep.size;
+  $('#fKeep').textContent=state.keep.size;
+  $('#cTotal').textContent=state.items.length;
+  $('#cLive').textContent=state.items.filter(d=>d.is_published).length;
+  $('#fLive').textContent=state.items.filter(d=>d.is_published).length;
+  $('#sub').textContent=`${state.items.length} designs · 2 colorway families · is_published=FALSE awaiting curation`;
+}
+
+function chip(d){
+  const el=document.createElement('div');
+  el.className='chip '+(state.keep.has(d.id)?'keep':'reject')+(d.is_published?' published':'');
+  const motifLabel = MOTIF_NAME[d._motif] || d._motif || '?';
+  el.innerHTML=`<img loading="lazy" src="/designs/img/by-id/${d.id}" alt="#${d.id}">
+    <span class="motif">${motifLabel}</span>
+    <span class="live">LIVE</span>
+    <span class="pick">${state.keep.has(d.id)?'✓':''}</span>
+    <div class="when" title="created ${d.created_at||''}">🕓 ${fmtDate(d.created_at)} · #${d.id}</div>`;
+  el.title=`#${d.id} · ${motifLabel} · ${FAMILY_LABEL[d._family]||d._family}${d.is_published?' · LIVE':''}`;
+  el.onclick=()=>{ if(state.keep.has(d.id))state.keep.delete(d.id); else state.keep.add(d.id);
+    el.classList.toggle('keep'); el.classList.toggle('reject');
+    el.querySelector('.pick').textContent=state.keep.has(d.id)?'✓':'';
+    $('#cKeep').textContent=state.keep.size; $('#fKeep').textContent=state.keep.size; };
+  return el;
+}
+
+function toast(m){ const t=$('#toast'); t.textContent=m; t.classList.add('show'); setTimeout(()=>t.classList.remove('show'),2400); }
+
+async function bulk(action){
+  const ids=[...state.keep]; if(!ids.length){ toast('No designs kept'); return; }
+  const r=await fetch('/api/designs/bulk-action',{method:'POST',credentials:'same-origin',
+    headers:{'Content-Type':'application/json'},body:JSON.stringify({ids,action})});
+  const j=await r.json();
+  if(j.ok){ state.items.forEach(d=>{ if(state.keep.has(d.id)) d.is_published=(action==='publish'); });
+    toast(`${action==='publish'?'Published':'Unpublished'} ${j.affected} designs`); render(); }
+  else toast('Error: '+(j.error||'failed'));
+}
+
+$('#sort').onchange=()=>{ localStorage.bwSort=$('#sort').value; render(); };
+$('#density').oninput=e=>{ document.documentElement.style.setProperty('--cols',e.target.value);
+  $('#densval').textContent=e.target.value; localStorage.bwCols=e.target.value; };
+$('#selAll').onclick=()=>{ state.items.forEach(d=>state.keep.add(d.id)); render(); };
+$('#selClear').onclick=()=>{ state.keep.clear(); render(); };
+$('#publish').onclick=()=>bulk('publish');
+$('#unpublish').onclick=()=>bulk('unpublish');
+document.addEventListener('click',e=>{
+  const k=e.target.getAttribute&&e.target.getAttribute('data-keep');
+  const r=e.target.getAttribute&&e.target.getAttribute('data-rej');
+  if(k){ state.items.filter(d=>d._family===k).forEach(d=>state.keep.add(d.id)); render(); }
+  if(r){ state.items.filter(d=>d._family===r).forEach(d=>state.keep.delete(d.id)); render(); }
+});
+
+if(localStorage.bwSort) $('#sort').value=localStorage.bwSort;
+const cols=localStorage.bwCols||'4';
+$('#density').value=cols; $('#densval').textContent=cols;
+document.documentElement.style.setProperty('--cols',cols);
+load();
+</script>
+</body>
+</html>
diff --git a/scripts/generate-botanical-wonders.js b/scripts/generate-botanical-wonders.js
new file mode 100644
index 0000000..4eb8f11
--- /dev/null
+++ b/scripts/generate-botanical-wonders.js
@@ -0,0 +1,261 @@
+#!/usr/bin/env node
+// generate-botanical-wonders.js — wallco.ai in-house substitutes for 2 retired
+// Clarke & Clarke "Wonderlust" sample SKUs (DWKK-127831-Sample Dove Multi,
+// DWKK-127832-Sample Teal Multi). Steve directive 2026-05-27 — "create my 6
+// new versions of each as substitutes, we want our own versions, not a copy."
+//
+// 6 botanical motifs × 2 colorway families = 12 single-ink solid-screen-print
+// designs. Botanical-wonders aesthetic: leaves/flowers/branches + benign
+// insect motifs (dragonfly, bee, beetle, moth, ladybug, mantis). NO
+// butterflies / birds / bananas / grapes (Settlement Part B). Settlement
+// gate cleared prompt-side 2026-05-27 (audit log:
+// data/settlement-audit/orchestrator-log.jsonl) — post-gen vision pass still
+// mandatory before any is_published=TRUE.
+//
+// Pipeline per design (mirrors generate-dogs.js):
+//   buildRepeatPrompt → gemini-2.5-flash-image → gateComposition (>=3
+//   instances) → Pillow palette extract → INSERT spoon_all_designs
+//   (category='botanical-wonders · <colorway>', source_dw_sku=<parent>,
+//   is_published=FALSE).
+//
+// Idempotent: a `[parent:<sku>][var:<n>]` marker in the stored prompt is the
+// dedupe key; a killed batch resumes cleanly.
+//
+// Usage:
+//   node scripts/generate-botanical-wonders.js                # full 12
+//   node scripts/generate-botanical-wonders.js --motifs 1     # 2-design smoke test (1 motif × 2 cw)
+//   node scripts/generate-botanical-wonders.js --only dragonfly-fern
+
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { spawnSync } = require('child_process');
+const { buildRepeatPrompt } = require('../lib/repeat-prompt');
+const { gateComposition } = require('../lib/composition-detector');
+
+const ROOT = path.join(__dirname, '..');
+const GEN_DIR = path.join(ROOT, 'data', 'generated');
+fs.mkdirSync(GEN_DIR, { recursive: true });
+
+// ── 6 botanical-wonders motifs (benign insects + flora, NO butterflies) ─────
+// Each motif is ONE small vignette repeated as a wallpaper field. Insects
+// chosen are explicitly NOT on Settlement Part B's prohibited list.
+const MOTIFS = [
+  { slug: 'dragonfly-fern',     name: 'Dragonfly & Fern',
+    desc: 'a single dragonfly with long slender body and four flat decorative wings hovering above one curling fern frond — wings extended flat in elegant heraldic profile, the fern arching gracefully beneath. A single solid-color silhouette, no interior detail, no wing veining, no anatomical line work.' },
+  { slug: 'bee-honeysuckle',    name: 'Bee & Honeysuckle',
+    desc: 'a worker bee in side profile alighting on a single sprig of honeysuckle bearing two trumpet flowers and two pointed leaves — the bee tucked against the central flower. A single solid-color silhouette, no interior detail, no stripes, no wing veining.' },
+  { slug: 'beetle-leaf',        name: 'Beetle & Leaf',
+    desc: 'a single rose-chafer beetle in three-quarter view perched on one broad oval leaf, with two small unopened wildflower buds rising from the leaf stem beside it — heraldic herbarium-plate arrangement. A single solid-color silhouette, no carapace texture, no inner outline.' },
+  { slug: 'moth-grass',         name: 'Moth & Grass',
+    desc: 'a large garden moth at rest with both wings spread fully open and feathered antennae extended, settled on one arched blade of grass with a small grain-head detail at the tip — symmetric and calm. A single solid-color silhouette, no wing pattern, no eye spots.' },
+  { slug: 'ladybug-clover',     name: 'Ladybugs & Clover',
+    desc: 'two ladybugs (a small pair) on a single sprig of three clover leaflets with one tiny rounded blossom rising from the center — the pair facing each other on the lower leaflets. A single solid-color silhouette, no spots, no segmented outline.' },
+  { slug: 'mantis-stem',        name: 'Mantis on Stem',
+    desc: 'a praying mantis in tall vertical profile clinging upright to one straight stem with two narrow opposing leaves halfway up and a single tightly-furled bud at the very top — the mantis\'s forelegs raised in its iconic praying posture. A single solid-color silhouette, no joint detail, no leg banding.' },
+];
+
+// ── 2 colorway families × 6 ink shades each (paired motif↔ink position) ─────
+// Each individual design = single ink on single ground (Steve's hard rule —
+// solid screen-print colors only, NEVER gradient/transparency/ghost layer).
+// The "Multi" naming refers to the family of 6 inks across the collection,
+// not multi-ink within a single design. Together each 6-pack reads like a
+// curated Clarke & Clarke-style colorway story.
+const COLORWAYS = {
+  'dove-multi': {
+    label: 'Dove Multi',
+    inks: [
+      { hex: '#1F1B17', desc: 'charcoal noir' },        // dragonfly-fern
+      { hex: '#7A6A58', desc: 'warm taupe' },           // bee-honeysuckle
+      { hex: '#A8857A', desc: 'dusty blush rose' },     // beetle-leaf
+      { hex: '#7E8A6D', desc: 'soft sage' },            // moth-grass
+      { hex: '#9A8770', desc: 'putty stone' },          // ladybugs-clover
+      { hex: '#6B4F38', desc: 'fawn brown' },           // mantis-stem
+    ],
+    ground_hex: '#F2EAD7', ground_desc: 'warm ivory cream',
+  },
+  'teal-multi': {
+    label: 'Teal Multi',
+    inks: [
+      { hex: '#F0E6D2', desc: 'champagne ivory' },      // dragonfly-fern
+      { hex: '#9CC7B5', desc: 'soft sea mint' },        // bee-honeysuckle
+      { hex: '#D5DDC9', desc: 'pale sage' },            // beetle-leaf
+      { hex: '#E3CBA0', desc: 'antique brass' },        // moth-grass
+      { hex: '#C9D8D5', desc: 'dove blue-grey' },       // ladybugs-clover
+      { hex: '#F5EFE0', desc: 'oyster cream' },         // mantis-stem
+    ],
+    ground_hex: '#1F4A4A', ground_desc: 'deep teal',
+  },
+};
+
+// Parent SKU map (which colorway substitutes which retired vendor sample)
+const PARENT_SKU = {
+  'dove-multi': 'DWKK-127831-Sample',
+  'teal-multi': 'DWKK-127832-Sample',
+};
+
+// ── args ────────────────────────────────────────────────────────────────────
+function parseArgs() {
+  const a = process.argv.slice(2);
+  const o = { motifs: MOTIFS.length, only: null, families: Object.keys(COLORWAYS) };
+  for (let i = 0; i < a.length; i++) {
+    if (a[i] === '--motifs') o.motifs = parseInt(a[++i], 10);
+    else if (a[i] === '--only') o.only = a[++i].split(',').map(s => s.trim()).filter(Boolean);
+    else if (a[i] === '--family') o.families = [a[++i]];
+  }
+  return o;
+}
+
+// ── gemini image ─────────────────────────────────────────────────────────────
+function readKey() {
+  if (process.env.GEMINI_API_KEY) return process.env.GEMINI_API_KEY;
+  const env = fs.readFileSync(path.join(ROOT, '.env'), 'utf8');
+  const m = env.match(/^GEMINI_API_KEY=(.+)$/m);
+  if (!m) throw new Error('GEMINI_API_KEY missing');
+  return m[1].trim();
+}
+const GEMINI_KEY = readKey();
+
+async function geminiImage(prompt) {
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-image:generateContent?key=${GEMINI_KEY}`;
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({
+      contents: [{ parts: [{ text: prompt }] }],
+      generationConfig: { responseModalities: ['IMAGE'] },
+    }),
+  });
+  if (!r.ok) throw new Error(`gemini ${r.status}: ${(await r.text()).slice(0, 200)}`);
+  const j = await r.json();
+  const part = j?.candidates?.[0]?.content?.parts?.find(p => p.inline_data || p.inlineData);
+  const b64 = part?.inline_data?.data || part?.inlineData?.data;
+  if (!b64) throw new Error('gemini returned no image');
+  return b64;
+}
+
+// ── psql helpers ─────────────────────────────────────────────────────────────
+function psql(sql) {
+  const r = spawnSync('psql', ['dw_unified', '-At', '-q', '-v', 'ON_ERROR_STOP=1'], { input: sql, encoding: 'utf8' });
+  if (r.status !== 0) throw new Error(r.stderr || r.stdout || 'psql failed');
+  return r.stdout.trim();
+}
+function alreadyExists(category, motifSlug) {
+  const sql = `SELECT 1 FROM spoon_all_designs WHERE category='${category.replace(/'/g, "''")}' AND prompt LIKE '%[var:${motifSlug}]%' LIMIT 1;`;
+  return psql(sql) === '1';
+}
+function extractPalette(localPath) {
+  const py = `
+from PIL import Image
+from collections import Counter
+import json, sys
+img = Image.open(sys.argv[1]).convert('RGB'); img.thumbnail((300,300))
+pal = img.quantize(colors=5, method=Image.Quantize.MEDIANCUT).convert('RGB')
+px = list(pal.getdata()); cnt = Counter(px); total = sum(cnt.values())
+print(json.dumps([{'hex':'#{:02x}{:02x}{:02x}'.format(*rgb),'pct':round(n/total*100,2)} for rgb,n in cnt.most_common(5)]))
+`;
+  const r = spawnSync('python3', ['-c', py, localPath], { encoding: 'utf8' });
+  try { const p = JSON.parse(r.stdout.trim()); return { palette: p, dominant: p[0]?.hex || null }; }
+  catch { return { palette: [], dominant: null }; }
+}
+
+// ── one design ───────────────────────────────────────────────────────────────
+async function genOne(motif, motifIdx, familySlug) {
+  const family = COLORWAYS[familySlug];
+  const ink = family.inks[motifIdx];
+  const category = `botanical-wonders · ${familySlug}`;
+  const parentSku = PARENT_SKU[familySlug];
+
+  if (alreadyExists(category, motif.slug)) {
+    console.log(`  · skip ${motif.slug}/${familySlug} (already in PG)`);
+    return { skipped: true };
+  }
+  const motifDesc = `${motif.desc} The entire vignette is a single flat solid ${ink.desc} (${ink.hex}) silhouette — no fur texture, no facial detail, no outline, no interior shading, no second ink color anywhere within the motif.`;
+
+  const MAX = 3;
+  let candidate = null, gate = null, usedAttempt = 0;
+  for (let attempt = 0; attempt < MAX; attempt++) {
+    const prompt = buildRepeatPrompt({
+      motifDescription: motifDesc,
+      groundHex: family.ground_hex,
+      groundDesc: family.ground_desc,
+      density: 'medium',
+      attempt,
+    });
+    const b64 = await geminiImage(prompt);
+    gate = await gateComposition(b64, { minInstances: 3 }).catch(e => ({ ok: false, reason: 'gate-error: ' + e.message, result: {} }));
+    if (gate.ok) { candidate = b64; usedAttempt = attempt; break; }
+    process.stdout.write(`    retry (${gate.reason}) `);
+  }
+  if (!candidate) {
+    const fb = buildRepeatPrompt({ motifDescription: motifDesc, groundHex: family.ground_hex, groundDesc: family.ground_desc, density: 'medium', attempt: MAX - 1 });
+    candidate = await geminiImage(fb);
+    usedAttempt = MAX - 1;
+  }
+  const seed = crypto.randomInt(1, 2 ** 31 - 1);
+  const filename = `botanical_wonders_${motif.slug}_${familySlug}_${Date.now()}_${seed}.png`;
+  const outPath = path.join(GEN_DIR, filename);
+  fs.writeFileSync(outPath, Buffer.from(candidate, 'base64'));
+  const { palette, dominant } = extractPalette(outPath);
+
+  const storedPrompt = `Botanical Wonders — ${motif.name}, ${family.label} colorway [parent:${parentSku}][var:${motif.slug}]. Ground ${family.ground_desc} ${family.ground_hex}, single-ink ${ink.desc} ${ink.hex}. ${motif.desc}`;
+  const notes = `Substitute for retired vendor sample ${parentSku} (originally Clarke & Clarke Wonderlust — ${family.label}). In-house wallco.ai variant.`;
+  const esc = v => v == null ? 'NULL' : "'" + String(v).replace(/'/g, "''") + "'";
+  const sql = `
+INSERT INTO spoon_all_designs
+  (kind, brand, width_in, height_in, generator, prompt, seed, image_url, local_path,
+   dominant_hex, palette, category, product_line, source_dw_sku, notes, is_published)
+VALUES
+  ('seamless_tile', 'wallco.ai', 24, 24,
+   'gemini-2.5-flash-image-repeat-v2',
+   ${esc(storedPrompt)}, ${seed},
+   '/designs/img/by-id/__NEW__', ${esc(outPath)},
+   ${esc(dominant)}, ${esc(JSON.stringify(palette))}::jsonb,
+   ${esc(category)}, ${esc('Studio')}, ${esc(parentSku)}, ${esc(notes)}, FALSE)
+RETURNING id;`;
+  const newId = parseInt(psql(sql), 10);
+  if (!Number.isFinite(newId)) throw new Error('insert returned no id');
+  psql(`UPDATE spoon_all_designs SET image_url='/designs/img/by-id/${newId}' WHERE id=${newId};`);
+  return { id: newId, attempt: usedAttempt, gate: gate?.result };
+}
+
+// ── main ─────────────────────────────────────────────────────────────────────
+(async () => {
+  const opt = parseArgs();
+  let motifs = MOTIFS.slice(0, opt.motifs);
+  if (opt.only) motifs = MOTIFS.filter(m => opt.only.includes(m.slug));
+  const families = opt.families;
+  const total = motifs.length * families.length;
+  console.log(`generate-botanical-wonders: ${motifs.length} motifs × ${families.length} colorway families = ${total} designs`);
+  console.log(`engine: gemini-2.5-flash-image-repeat-v2 · is_published=FALSE (curate-then-publish)\n`);
+
+  const t0 = Date.now();
+  let ok = 0, skip = 0, fail = 0, retries = 0;
+  const made = [];
+  for (const family of families) {
+    console.log(`\n# ${COLORWAYS[family].label}  (parent: ${PARENT_SKU[family]})`);
+    for (let i = 0; i < motifs.length; i++) {
+      const motif = motifs[i];
+      const motifIdx = MOTIFS.findIndex(m => m.slug === motif.slug); // ink index follows canonical motif order
+      try {
+        const r = await genOne(motif, motifIdx, family);
+        if (r.skipped) { skip++; continue; }
+        if (r.attempt > 0) retries += r.attempt;
+        ok++;
+        made.push(r.id);
+        console.log(`  ✓ #${r.id} ${motif.slug} (attempt ${r.attempt}, n=${r.gate?.instance_count ?? '?'})`);
+      } catch (e) {
+        fail++;
+        console.log(`  ✗ ${motif.slug}: ${e.message}`);
+      }
+    }
+  }
+  const dur = ((Date.now() - t0) / 1000 / 60).toFixed(1);
+  const billable = ok + retries;
+  console.log(`\n=== done in ${dur} min ===`);
+  console.log(`generated ${ok}, skipped ${skip}, failed ${fail}, gate-retries ${retries}`);
+  console.log(`billable gemini image calls ≈ ${billable} (≈ $${(billable * 0.039).toFixed(2)} @ $0.039/img)`);
+  if (made.length) console.log(`new ids: ${made.join(', ')}`);
+  console.log(`\nnext: settlement-post-gen-vision + edges-agent on each, then curator pass before any is_published=TRUE`);
+})().catch(e => { console.error('FATAL', e.message); process.exit(1); });
diff --git a/server.js b/server.js
index f29f36c..685cd95 100644
--- a/server.js
+++ b/server.js
@@ -1119,6 +1119,32 @@ app.get('/api/admin/dogs/list', (req, res) => {
   }
 });
 
+// ── /admin/botanical-wonders-curator ───────────────────────────────────────
+// 12 single-ink solid-screen-print substitutes (6 motifs × 2 colorway families)
+// for 2 retired Clarke & Clarke "Wonderlust" sample SKUs (DWKK-127831/127832).
+// Generated 2026-05-27 via scripts/generate-botanical-wonders.js (Gemini
+// repeat-v2, ~$0.47). Settlement gate cleared prompt-side; post-gen vision
+// + TIF archive still required before any is_published=TRUE.
+app.get('/admin/botanical-wonders-curator', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).type('html').send('<h1>404</h1>');
+  res.sendFile(path.join(__dirname, 'public', 'admin', 'botanical-wonders-curator.html'));
+});
+app.get('/api/admin/botanical-wonders/list', (req, res) => {
+  if (!isAdmin(req)) return res.status(404).json({ error: 'not found' });
+  try {
+    const rawJson = psqlQuery(
+      "SELECT COALESCE(json_agg(t ORDER BY t.category, t.id), '[]'::json) FROM (" +
+        "SELECT id, category, dominant_hex, is_published, created_at, prompt, source_dw_sku " +
+        "FROM spoon_all_designs WHERE category LIKE 'botanical-wonders · %' " +
+        "AND local_path NOT LIKE '%midheal_%' AND local_path NOT LIKE '%edgeheal_%' AND local_path NOT LIKE '%smartfix_%'" +
+      ") t;"
+    );
+    res.json({ ok: true, items: JSON.parse(rawJson || '[]') });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── /admin/cactus-curator ─────────────────────────────────────────────────
 // Grid + density slider over EVERY cactus design (incl. unpublished), ranked
 // "most like our dw_unified catalog patterns" = a composite of vision-quality

← d43deaf generate_room_mockups: add --all-published, --kind-filter, -  ·  back to Wallco Ai  ·  feat(design page): theme1/theme2 toggle, default theme2 onlo 4ad23bc →