[object Object]

← back to Wallco Ai

chat panel UI polish: extract to /css/chat.css using site design tokens (--bg/ink/serif/gold) · dark-mode aware · animated thinking dots · slide-in panel · NEW badge on variant cards · mobile fullscreen breakpoint

cd268791e2a12061ebb6f248b8e9e82aabb650df · 2026-05-11 17:33:08 -0700 · Steve

Files touched

Diff

commit cd268791e2a12061ebb6f248b8e9e82aabb650df
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 17:33:08 2026 -0700

    chat panel UI polish: extract to /css/chat.css using site design tokens (--bg/ink/serif/gold) · dark-mode aware · animated thinking dots · slide-in panel · NEW badge on variant cards · mobile fullscreen breakpoint
---
 .deploy.conf                           |   3 +
 ecosystem.config.js                    |  35 ++--
 public/css/chat.css                    | 224 +++++++++++++++++++++++++
 public/js/chat.js                      |  65 +++-----
 scripts/fetch_recent_orders_palette.js | 140 ++++++++++++++++
 scripts/generator_tick.js              | 151 +++++++++++++++++
 server.js                              | 291 +++++++++++++++++++++++++++++++++
 src/review.js                          |  10 +-
 8 files changed, 849 insertions(+), 70 deletions(-)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..a91c7df
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,3 @@
+PROJECT_NAME="wallco-ai"
+DEPLOY_PATH="/root/public-projects/wallco-ai"
+HEALTH_URL="https://wallco.ai/health"
diff --git a/ecosystem.config.js b/ecosystem.config.js
index 4c5e4ac..038a20a 100644
--- a/ecosystem.config.js
+++ b/ecosystem.config.js
@@ -1,25 +1,16 @@
 module.exports = {
-  apps: [
-    {
-      // Production customer-facing site (Kamatera port 9905)
-      name: 'wallco-ai',
-      cwd: '/root/public-projects/wallco-ai',
-      script: 'server.js',
-      env: {
-        NODE_ENV: 'production',
-        PORT: 9905
-      },
-      max_memory_restart: '256M',
-      error_file: '/root/public-projects/wallco-ai/logs/err.log',
-      out_file:   '/root/public-projects/wallco-ai/logs/out.log'
+  apps: [{
+    name: 'wallco-ai',
+    script: 'server.js',
+    cwd: __dirname,
+    env: {
+      NODE_ENV: 'production',
+      PORT: '9905',
     },
-    {
-      // Local admin viewer (Mac2 only — never deployed to Kamatera)
-      name: 'wallco-ai-viewer',
-      cwd: '/Users/stevestudio2/Projects/wallco-ai',
-      script: 'server-admin.js',
-      env: { PORT: 9792 },
-      max_memory_restart: '256M'
-    }
-  ]
+    max_memory_restart: '500M',
+    error_file: 'logs/pm2.err.log',
+    out_file: 'logs/pm2.out.log',
+    merge_logs: true,
+    time: true,
+  }],
 };
diff --git a/public/css/chat.css b/public/css/chat.css
new file mode 100644
index 0000000..e1393f8
--- /dev/null
+++ b/public/css/chat.css
@@ -0,0 +1,224 @@
+/* wallco.ai chat panel — matches site design tokens (--bg, --ink, --serif, --sans, --gold).
+   Dark-mode aware. Inherits the page's theme. */
+
+/* ── floating button (FAB) ───────────────────────────────────────── */
+#wallco-chat-fab {
+  position: fixed;
+  bottom: 22px;
+  right: 22px;
+  z-index: 9998;
+  background: var(--accent, #0d0d0d);
+  color: var(--bg, #faf7f2);
+  border: 0;
+  border-radius: 999px;
+  padding: 14px 22px 14px 18px;
+  font-family: var(--sans);
+  font-size: 13px;
+  font-weight: 500;
+  letter-spacing: 0.02em;
+  cursor: pointer;
+  box-shadow: 0 10px 30px -8px rgba(0,0,0,.35), 0 2px 8px rgba(0,0,0,.18);
+  transition: transform .18s ease, box-shadow .18s ease;
+  display: inline-flex; align-items: center; gap: 8px;
+}
+#wallco-chat-fab:hover { transform: translateY(-1px); box-shadow: 0 14px 40px -8px rgba(0,0,0,.45), 0 4px 12px rgba(0,0,0,.22); }
+#wallco-chat-fab .fab-spark { font-family: var(--serif); font-size: 17px; color: var(--gold, #c9a14b); line-height: 1; }
+
+/* ── panel ───────────────────────────────────────────────────────── */
+#wallco-chat-panel {
+  position: fixed;
+  bottom: 22px;
+  right: 22px;
+  width: 400px; max-width: calc(100vw - 32px);
+  height: 600px; max-height: calc(100vh - 44px);
+  background: var(--card-bg, #fff);
+  color: var(--ink, #1a1714);
+  border: 1px solid var(--line, #d8cec0);
+  border-radius: 16px;
+  box-shadow: 0 30px 80px -16px rgba(0,0,0,.45), 0 8px 24px rgba(0,0,0,.18);
+  z-index: 9999;
+  display: none;
+  flex-direction: column;
+  overflow: hidden;
+  font-family: var(--sans);
+  transform: translateY(8px); opacity: 0;
+  transition: transform .22s ease, opacity .22s ease;
+}
+#wallco-chat-panel.is-open { display: flex; transform: translateY(0); opacity: 1; }
+
+#wallco-chat-panel > header {
+  padding: 18px 20px 14px;
+  border-bottom: 1px solid var(--line);
+  background: linear-gradient(180deg, var(--header-bg, #faf7f2), transparent);
+  display: flex; align-items: flex-start; gap: 12px;
+}
+#wallco-chat-panel .h-title {
+  font-family: var(--serif);
+  font-weight: 400;
+  font-size: 19px;
+  line-height: 1.1;
+  letter-spacing: 0.01em;
+  color: var(--ink);
+  margin: 0;
+}
+#wallco-chat-panel .h-sub {
+  font-size: 11px;
+  color: var(--ink-faint, #9a8e82);
+  margin-top: 4px;
+  letter-spacing: 0.05em;
+  text-transform: uppercase;
+}
+#wallco-chat-panel .h-close {
+  margin-left: auto; background: transparent; border: 0; cursor: pointer;
+  color: var(--ink-faint);
+  width: 28px; height: 28px; border-radius: 6px;
+  font-size: 20px; line-height: 1;
+  display: inline-flex; align-items: center; justify-content: center;
+  transition: background .12s, color .12s;
+}
+#wallco-chat-panel .h-close:hover { background: var(--bg); color: var(--ink); }
+
+/* ── log / messages ──────────────────────────────────────────────── */
+#wallco-chat-log {
+  flex: 1; overflow-y: auto; padding: 18px 20px;
+  display: flex; flex-direction: column; gap: 12px;
+  background: var(--bg);
+}
+#wallco-chat-log::-webkit-scrollbar { width: 6px; }
+#wallco-chat-log::-webkit-scrollbar-thumb { background: var(--line); border-radius: 6px; }
+
+.wc-msg {
+  max-width: 86%;
+  padding: 10px 13px;
+  border-radius: 14px;
+  font-size: 13.5px;
+  line-height: 1.5;
+  white-space: pre-wrap;
+  word-wrap: break-word;
+  animation: wc-fade .16s ease;
+}
+@keyframes wc-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
+.wc-msg.user      { background: var(--accent); color: var(--bg); align-self: flex-end; border-bottom-right-radius: 4px; }
+.wc-msg.assistant { background: var(--card-bg); color: var(--ink); border: 1px solid var(--line); align-self: flex-start; border-bottom-left-radius: 4px; }
+.wc-msg.thinking  { background: transparent; color: var(--ink-faint); align-self: flex-start; font-style: italic; padding-left: 4px; }
+.wc-msg.thinking::after { content: ''; display: inline-block; width: 24px; }
+.wc-msg.thinking .dot { display: inline-block; width: 6px; height: 6px; margin: 0 1px; background: var(--ink-faint); border-radius: 50%; opacity: 0.5; animation: wc-bounce 1s infinite; }
+.wc-msg.thinking .dot:nth-child(2) { animation-delay: .15s; }
+.wc-msg.thinking .dot:nth-child(3) { animation-delay: .3s; }
+@keyframes wc-bounce { 0%,100% { transform: translateY(0); opacity: .3; } 50% { transform: translateY(-3px); opacity: 1; } }
+.wc-msg.error     { background: #fde6e1; color: #7a2118; align-self: flex-start; border: 1px solid #f3c2b6; }
+
+/* ── search result grid (existing designs) ───────────────────────── */
+.wc-actions {
+  align-self: stretch;
+  display: grid;
+  grid-template-columns: repeat(3, 1fr);
+  gap: 10px;
+  margin-top: 2px;
+}
+.wc-card {
+  background: var(--card-bg);
+  border: 1px solid var(--line);
+  border-radius: 8px;
+  overflow: hidden;
+  cursor: pointer;
+  text-decoration: none;
+  color: inherit;
+  transition: transform .18s, border-color .18s, box-shadow .18s;
+}
+.wc-card:hover { transform: translateY(-2px); border-color: var(--gold); box-shadow: 0 8px 24px -8px rgba(0,0,0,.18); }
+.wc-card img { width: 100%; aspect-ratio: 1; object-fit: cover; display: block; background: var(--bg); }
+.wc-card .cap { padding: 6px 8px 8px; font-size: 11px; color: var(--ink-soft); line-height: 1.3; }
+
+/* ── new / variant design (full-width card) ──────────────────────── */
+.wc-new {
+  align-self: stretch;
+  background: var(--card-bg);
+  border: 1px solid var(--gold);
+  border-radius: 12px;
+  padding: 14px;
+  display: flex; gap: 14px;
+  box-shadow: 0 8px 24px -10px rgba(201, 161, 75, .35);
+  position: relative;
+  overflow: hidden;
+}
+.wc-new::before {
+  content: 'NEW';
+  position: absolute; top: 0; right: 0;
+  background: var(--gold); color: var(--accent);
+  font-family: var(--sans); font-size: 9px; font-weight: 600;
+  letter-spacing: 0.12em;
+  padding: 3px 10px;
+  border-bottom-left-radius: 8px;
+}
+.wc-new img { width: 96px; height: 96px; object-fit: cover; border-radius: 6px; flex-shrink: 0; }
+.wc-new .meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
+.wc-new .meta .title { font-family: var(--serif); font-size: 16px; font-weight: 400; line-height: 1.2; color: var(--ink); }
+.wc-new .meta .swatch { display: inline-flex; align-items: center; gap: 6px; font-size: 11px; color: var(--ink-soft); }
+.wc-new .meta .swatch .dot { width: 12px; height: 12px; border-radius: 50%; border: 1px solid rgba(0,0,0,.12); }
+.wc-new .meta .link { font-size: 12px; color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--gold); padding-bottom: 1px; align-self: flex-start; margin-top: auto; font-weight: 500; }
+.wc-new .meta .link:hover { color: var(--gold); }
+
+/* ── suggestions ──────────────────────────────────────────────────── */
+.wc-suggest {
+  display: flex; gap: 6px; flex-wrap: wrap;
+  padding: 0 16px 12px;
+  background: var(--card-bg);
+  border-top: 1px solid var(--line);
+  padding-top: 10px;
+}
+.wc-suggest button {
+  font-family: var(--sans);
+  font-size: 11px;
+  color: var(--ink-soft);
+  background: transparent;
+  border: 1px solid var(--line);
+  padding: 6px 11px;
+  border-radius: 999px;
+  cursor: pointer;
+  transition: background .15s, color .15s, border-color .15s;
+}
+.wc-suggest button:hover { background: var(--accent); color: var(--bg); border-color: var(--accent); }
+
+/* ── input form ───────────────────────────────────────────────────── */
+#wallco-chat-form {
+  padding: 12px 14px 14px;
+  background: var(--card-bg);
+  border-top: 1px solid var(--line);
+  display: flex; gap: 8px; align-items: flex-end;
+}
+#wallco-chat-form textarea {
+  flex: 1;
+  border: 1px solid var(--line);
+  border-radius: 10px;
+  padding: 10px 12px;
+  font-family: var(--sans);
+  font-size: 13.5px;
+  line-height: 1.4;
+  color: var(--ink);
+  background: var(--bg);
+  resize: none;
+  min-height: 40px;
+  max-height: 120px;
+  transition: border-color .15s, box-shadow .15s;
+}
+#wallco-chat-form textarea:focus { outline: 0; border-color: var(--gold); box-shadow: 0 0 0 3px rgba(201,161,75,.15); }
+#wallco-chat-form textarea::placeholder { color: var(--ink-faint); }
+#wallco-chat-form button {
+  background: var(--accent);
+  color: var(--bg);
+  border: 0; border-radius: 10px;
+  padding: 0 16px; height: 42px;
+  font-family: var(--sans); font-size: 13px; font-weight: 500;
+  cursor: pointer;
+  transition: background .15s, transform .12s;
+}
+#wallco-chat-form button:hover:not(:disabled) { transform: translateY(-1px); }
+#wallco-chat-form button:disabled { background: var(--ink-faint); cursor: wait; opacity: 0.7; }
+
+/* ── mobile ───────────────────────────────────────────────────────── */
+@media (max-width: 480px) {
+  #wallco-chat-panel { right: 8px; left: 8px; width: auto; bottom: 8px; height: calc(100vh - 16px); border-radius: 12px; }
+  #wallco-chat-fab { bottom: 14px; right: 14px; padding: 12px 18px; }
+  .wc-actions { grid-template-columns: repeat(2, 1fr); }
+}
diff --git a/public/js/chat.js b/public/js/chat.js
index b961117..a0c52aa 100644
--- a/public/js/chat.js
+++ b/public/js/chat.js
@@ -21,44 +21,18 @@
   const designId = isDesign ? CTX_KEY.split(':')[1] : null;
   const ENDPOINT = isDesign ? `/api/chat/design/${designId}` : '/api/chat/catalog';
 
-  // ─── styles
-  const css = `
-  #wallco-chat-fab { position:fixed; bottom:24px; right:24px; z-index:9998; background:#1a1a1a; color:#fff; border:0; border-radius:999px; padding:14px 20px; font:14px/1 -apple-system,system-ui,sans-serif; font-weight:500; cursor:pointer; box-shadow:0 8px 28px rgba(0,0,0,.3); }
-  #wallco-chat-fab:hover { background:#2a2a2a; }
-  #wallco-chat-panel { position:fixed; bottom:24px; right:24px; width:380px; max-width:92vw; height:560px; max-height:80vh; background:#fff; border-radius:14px; box-shadow:0 24px 64px rgba(0,0,0,.35); z-index:9999; display:none; flex-direction:column; overflow:hidden; font:14px/1.45 -apple-system,system-ui,sans-serif; color:#1a1a1a; }
-  #wallco-chat-panel.is-open { display:flex; }
-  #wallco-chat-panel header { padding:14px 18px; border-bottom:1px solid #ede8df; background:#faf8f5; display:flex; align-items:center; gap:10px; }
-  #wallco-chat-panel header .title { font-weight:500; font-size:13px; }
-  #wallco-chat-panel header .sub { font-size:11px; color:#888; }
-  #wallco-chat-panel header .close { margin-left:auto; background:transparent; border:0; cursor:pointer; font-size:18px; color:#888; }
-  #wallco-chat-log { flex:1; padding:14px 18px; overflow-y:auto; display:flex; flex-direction:column; gap:10px; }
-  .wc-msg { max-width:85%; padding:9px 12px; border-radius:10px; font-size:13px; line-height:1.45; }
-  .wc-msg.user { background:#1a1a1a; color:#fff; align-self:flex-end; border-bottom-right-radius:3px; }
-  .wc-msg.assistant { background:#f3eee5; color:#1a1a1a; align-self:flex-start; border-bottom-left-radius:3px; }
-  .wc-msg.thinking { background:#f3eee5; color:#888; align-self:flex-start; font-style:italic; }
-  .wc-msg.error { background:#fde6e1; color:#7a2118; align-self:flex-start; }
-  .wc-actions { align-self:stretch; display:grid; grid-template-columns:repeat(auto-fill, minmax(100px,1fr)); gap:8px; margin-top:4px; }
-  .wc-card { background:#fff; border:1px solid #ede8df; border-radius:6px; overflow:hidden; cursor:pointer; transition:transform .15s, border-color .15s; }
-  .wc-card:hover { transform:translateY(-2px); border-color:#1a1a1a; }
-  .wc-card img { width:100%; aspect-ratio:1; object-fit:cover; display:block; }
-  .wc-card .cap { padding:4px 6px; font-size:10px; color:#666; }
-  .wc-new { align-self:stretch; background:#fff; border:1px solid #d2b15c; border-radius:8px; padding:12px; display:flex; gap:10px; align-items:center; }
-  .wc-new img { width:80px; height:80px; object-fit:cover; border-radius:4px; }
-  .wc-new .meta { flex:1; font-size:12px; }
-  .wc-new .meta .title { font-weight:500; margin-bottom:2px; }
-  .wc-new .meta a { color:#1a1a1a; text-decoration:underline; font-size:12px; }
-  #wallco-chat-form { padding:12px 14px; border-top:1px solid #ede8df; display:flex; gap:8px; background:#faf8f5; }
-  #wallco-chat-form textarea { flex:1; border:1px solid #d0c8b8; border-radius:6px; padding:8px 10px; font:inherit; resize:none; height:38px; max-height:120px; }
-  #wallco-chat-form button { background:#1a1a1a; color:#fff; border:0; border-radius:6px; padding:0 14px; font-weight:500; cursor:pointer; }
-  #wallco-chat-form button:disabled { background:#999; cursor:wait; }
-  .wc-suggest { display:flex; gap:6px; flex-wrap:wrap; padding:0 14px 10px; background:#faf8f5; }
-  .wc-suggest button { font:11px -apple-system,system-ui; background:transparent; border:1px solid #d0c8b8; color:#555; padding:5px 9px; border-radius:999px; cursor:pointer; }
-  .wc-suggest button:hover { background:#fff; color:#1a1a1a; }`;
-  const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style);
+  // ─── inject stylesheet
+  if (!document.querySelector('link[href="/css/chat.css"]')) {
+    const link = document.createElement('link');
+    link.rel = 'stylesheet';
+    link.href = '/css/chat.css';
+    document.head.appendChild(link);
+  }
 
   // ─── DOM
   const fab = document.createElement('button');
-  fab.id = 'wallco-chat-fab'; fab.type = 'button'; fab.textContent = '✦ ' + CTA;
+  fab.id = 'wallco-chat-fab'; fab.type = 'button';
+  fab.innerHTML = `<span class="fab-spark">✦</span> <span>${CTA}</span>`;
   document.body.appendChild(fab);
 
   const panel = document.createElement('div');
@@ -66,20 +40,20 @@
   panel.innerHTML = `
     <header>
       <div>
-        <div class="title">${isDesign ? 'Vary this design' : 'wallco.ai designer'}</div>
-        <div class="sub">${isDesign ? 'Ask for a variant — color, scale, mood' : 'Browse, filter, or generate new'}</div>
+        <h3 class="h-title">${isDesign ? 'Vary this design' : 'Design concierge'}</h3>
+        <div class="h-sub">${isDesign ? 'Color · scale · mood' : 'Browse · filter · generate'}</div>
       </div>
-      <button class="close" type="button" aria-label="Close">×</button>
+      <button class="h-close" type="button" aria-label="Close">×</button>
     </header>
     <div id="wallco-chat-log"></div>
     <div class="wc-suggest">
       ${(isDesign
         ? ['Make it more muted', 'Swap gold for silver', 'Tighter pattern']
-        : ['Show me sapphire damasks', 'Olive florals please', 'Make me an art deco fan in brass']
+        : ['Show me sapphire damasks', 'Olive florals please', 'Art deco fan in brass']
       ).map(s => `<button type="button">${s}</button>`).join('')}
     </div>
     <form id="wallco-chat-form">
-      <textarea placeholder="${isDesign ? 'How should we change it?' : 'What are you looking for? Or ask us to make one.'}" rows="1"></textarea>
+      <textarea placeholder="${isDesign ? 'How should we change it?' : 'Search or describe a new design…'}" rows="1"></textarea>
       <button type="submit">Send</button>
     </form>`;
   document.body.appendChild(panel);
@@ -88,7 +62,7 @@
   const form    = panel.querySelector('#wallco-chat-form');
   const ta      = form.querySelector('textarea');
   const submit  = form.querySelector('button');
-  const closeBtn= panel.querySelector('.close');
+  const closeBtn= panel.querySelector('.h-close');
   fab.addEventListener('click', () => { panel.classList.add('is-open'); fab.style.display = 'none'; ta.focus(); });
   closeBtn.addEventListener('click', () => { panel.classList.remove('is-open'); fab.style.display = ''; });
 
@@ -119,7 +93,8 @@
   }
   function addThinking() {
     const d = document.createElement('div');
-    d.className = 'wc-msg thinking'; d.textContent = '…';
+    d.className = 'wc-msg thinking';
+    d.innerHTML = '<span class="dot"></span><span class="dot"></span><span class="dot"></span>';
     log.appendChild(d); log.scrollTop = log.scrollHeight;
     return d;
   }
@@ -137,9 +112,9 @@
     const w = document.createElement('div'); w.className = 'wc-new';
     w.innerHTML = `<img src="${design.image_url}" alt="generated">
       <div class="meta">
-        <div class="title">${label || 'Just generated'}</div>
-        <div>Color: <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${design.dominant_hex};vertical-align:middle"></span> ${design.dominant_hex}</div>
-        <a href="/design/${design.id}">View this design →</a>
+        <div class="title">${label || 'Original'}</div>
+        <div class="swatch"><span class="dot" style="background:${design.dominant_hex}"></span>${design.dominant_hex}</div>
+        <a href="/design/${design.id}" class="link">View design →</a>
       </div>`;
     log.appendChild(w); log.scrollTop = log.scrollHeight;
   }
diff --git a/scripts/fetch_recent_orders_palette.js b/scripts/fetch_recent_orders_palette.js
new file mode 100644
index 0000000..c2b39f1
--- /dev/null
+++ b/scripts/fetch_recent_orders_palette.js
@@ -0,0 +1,140 @@
+#!/usr/bin/env node
+/**
+ * Fetch the last N DW Shopify orders → for each line-item product:
+ *   - GET its primary image
+ *   - sips-extract dominant hex
+ *   - upsert into wallco_recent_orders_palette
+ *
+ * Feeds the wallco-generator with real-world DW palette + style hints.
+ *
+ * Reads SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN from .env (or
+ * ~/Projects/secrets-manager/.env via dotenv if not present locally).
+ *
+ * Usage: node scripts/fetch_recent_orders_palette.js [--limit 50]
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const fs = require('fs');
+const path = require('path');
+const { execSync } = require('child_process');
+
+const DB = 'dw_unified';
+const SHOP = process.env.SHOPIFY_STORE || process.env.SHOPIFY_DOMAIN;
+const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
+
+if (!SHOP || !TOKEN) {
+  // Fallback to secrets-manager/.env
+  const p = path.join(require('os').homedir(), 'Projects', 'secrets-manager', '.env');
+  if (fs.existsSync(p)) {
+    for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
+      const m = line.match(/^(SHOPIFY_(?:STORE|ADMIN_TOKEN|DOMAIN))=(.+)$/);
+      if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^['"]|['"]$/g, '');
+    }
+  }
+}
+const STORE = process.env.SHOPIFY_STORE || process.env.SHOPIFY_DOMAIN;
+const ADMIN = process.env.SHOPIFY_ADMIN_TOKEN;
+if (!STORE || !ADMIN) {
+  console.error('Need SHOPIFY_STORE + SHOPIFY_ADMIN_TOKEN');
+  process.exit(2);
+}
+
+const SHOPIFY_BASE = STORE.startsWith('http') ? STORE : `https://${STORE}`;
+const TMP = path.join(__dirname, '..', 'data', 'order-thumbs');
+fs.mkdirSync(TMP, { recursive: true });
+
+function psql(sql) {
+  return execSync(`psql ${DB} -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+function sqlStr(v) {
+  if (v == null) return 'NULL';
+  return "'" + String(v).replace(/'/g, "''") + "'";
+}
+
+async function shopifyJSON(path) {
+  const r = await fetch(`${SHOPIFY_BASE}/admin/api/2024-04${path}`, {
+    headers: { 'X-Shopify-Access-Token': ADMIN, 'Accept': 'application/json' },
+  });
+  if (!r.ok) throw new Error(`Shopify ${path} → ${r.status} ${(await r.text()).slice(0, 200)}`);
+  return r.json();
+}
+
+async function getImageDominantHex(imageUrl) {
+  // Download to /tmp + run sips to extract dominant color
+  const fname = path.join(TMP, path.basename(imageUrl.split('?')[0]));
+  try {
+    const r = await fetch(imageUrl);
+    if (!r.ok) return null;
+    const buf = Buffer.from(await r.arrayBuffer());
+    fs.writeFileSync(fname, buf);
+    // sips -s format png -Z 64 to downsample, then read pixel via Python
+    const out = execSync(`sips -Z 32 "${fname}" --out "${fname}.32.png" 2>/dev/null && python3 -c "
+from PIL import Image
+img = Image.open('${fname}.32.png').convert('RGB')
+pixels = list(img.getdata())
+n = len(pixels)
+r = sum(p[0] for p in pixels) // n
+g = sum(p[1] for p in pixels) // n
+b = sum(p[2] for p in pixels) // n
+print(f'#{r:02x}{g:02x}{b:02x}')
+" 2>/dev/null`, { encoding: 'utf8', timeout: 10000 }).trim();
+    return out.match(/^#[0-9a-f]{6}$/i) ? out : null;
+  } catch (e) {
+    return null;
+  } finally {
+    try { fs.unlinkSync(fname); fs.unlinkSync(fname + '.32.png'); } catch {}
+  }
+}
+
+async function main() {
+  const argv = process.argv.slice(2);
+  const limit = parseInt(argv[argv.indexOf('--limit') + 1] || '50', 10);
+
+  console.log(`Fetching last ${limit} orders from ${SHOPIFY_BASE}…`);
+  const orders = (await shopifyJSON(`/orders.json?status=any&limit=${Math.min(limit, 250)}`)).orders || [];
+  console.log(`Got ${orders.length} orders`);
+
+  let upserts = 0;
+  for (const o of orders) {
+    const ordId = String(o.id);
+    const orderedAt = o.created_at;
+    for (const li of (o.line_items || [])) {
+      if (!li.product_id || !li.sku) continue;
+      // Skip if already indexed
+      const exists = psql(`SELECT 1 FROM wallco_recent_orders_palette WHERE order_id=${sqlStr(ordId)} AND sku=${sqlStr(li.sku)} LIMIT 1;`);
+      if (exists) continue;
+
+      // Pull the product to get image
+      let imageUrl = null;
+      try {
+        const p = (await shopifyJSON(`/products/${li.product_id}.json`)).product || {};
+        imageUrl = p.image && p.image.src;
+      } catch { /* ignore */ }
+
+      let dom = null;
+      if (imageUrl) dom = await getImageDominantHex(imageUrl);
+
+      // Style tags from vendor / type heuristics
+      const styleTags = [];
+      const t = (li.title + ' ' + (li.vendor || '')).toLowerCase();
+      for (const [k, v] of Object.entries({
+        floral: 'floral', damask: 'damask', stripe: 'stripe', geometric: 'geometric',
+        grasscloth: 'natural', cork: 'natural', silk: 'silk', linen: 'linen',
+        toile: 'toile', botanical: 'floral', leaf: 'floral', trellis: 'geometric',
+      })) {
+        if (t.includes(k)) styleTags.push(v);
+      }
+
+      psql(`
+        INSERT INTO wallco_recent_orders_palette (order_id, ordered_at, shopify_product_id, sku, product_title, vendor, image_url, dominant_hex, style_tags)
+        VALUES (${sqlStr(ordId)}, ${sqlStr(orderedAt)}, ${li.product_id}, ${sqlStr(li.sku)}, ${sqlStr(li.title)}, ${sqlStr(li.vendor)}, ${sqlStr(imageUrl)}, ${sqlStr(dom)}, ARRAY[${styleTags.map(sqlStr).join(',') || ''}]::text[])
+        ON CONFLICT (order_id, sku) DO UPDATE SET dominant_hex=EXCLUDED.dominant_hex, style_tags=EXCLUDED.style_tags;
+      `);
+      upserts++;
+      if (upserts % 5 === 0) console.log(`  ${upserts} upserts… (last: ${li.sku} → ${dom || 'no-hex'})`);
+    }
+  }
+  console.log(`Done. ${upserts} new/updated palette rows.`);
+}
+
+main().catch(e => { console.error('ERROR:', e.message); process.exit(1); });
diff --git a/scripts/generator_tick.js b/scripts/generator_tick.js
new file mode 100644
index 0000000..0531a8f
--- /dev/null
+++ b/scripts/generator_tick.js
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+/**
+ * wallco-generator cron tick — runs every 30 min via launchd
+ * (com.steve.wallco-generator).
+ *
+ *   1. Pick an enabled recipe (weighted random; oldest last_run_at wins ties).
+ *   2. Pull 1-3 recent DW Shopify orders (palette + style hints).
+ *   3. Build a prompt from recipe.prompt_template using {artist}/{style}/{palette}.
+ *   4. Spawn generate_designs.js with --prompts so SDXL produces one design.
+ *   5. Log to wallco_generator_runs.
+ *
+ * No-op (logs SKIP) when no recipe is enabled — safe to leave the cron
+ * running indefinitely.
+ */
+'use strict';
+require('dotenv').config({ path: require('path').join(__dirname, '..', '.env') });
+const path = require('path');
+const { execSync, spawnSync } = require('child_process');
+
+const DB = 'dw_unified';
+const ROOT = path.join(__dirname, '..');
+
+function psql(sql) {
+  return execSync(`psql ${DB} -At -q`, { input: sql, encoding: 'utf8' }).trim();
+}
+
+function pickRecipe() {
+  // Weighted-random across enabled recipes, oldest last_run_at first
+  const raw = psql(`
+    SELECT row_to_json(t) FROM (
+      SELECT id, name, source_artists, style_tags, color_hex_seeds,
+             prompt_template, weight, last_run_at
+      FROM wallco_generator_recipe
+      WHERE enabled = TRUE
+      ORDER BY last_run_at NULLS FIRST, random()
+      LIMIT 1
+    ) t;
+  `);
+  return raw ? JSON.parse(raw) : null;
+}
+
+function recentPaletteSeeds(n = 3) {
+  const raw = psql(`
+    SELECT COALESCE(json_agg(t), '[]'::json) FROM (
+      SELECT id, sku, product_title, dominant_hex, style_tags, ordered_at
+      FROM wallco_recent_orders_palette
+      WHERE dominant_hex IS NOT NULL
+      ORDER BY ordered_at DESC NULLS LAST
+      LIMIT ${n}
+    ) t;
+  `);
+  return JSON.parse(raw || '[]');
+}
+
+function pick(arr) {
+  if (!arr || !arr.length) return null;
+  return arr[Math.floor(Math.random() * arr.length)];
+}
+
+function buildPrompt(recipe, paletteSeeds) {
+  const artist  = pick(recipe.source_artists) || 'a master textile designer';
+  const style   = pick(recipe.style_tags) || 'damask';
+  const palette = paletteSeeds.length
+    ? paletteSeeds.map(p => p.dominant_hex).join(', ')
+    : (pick(recipe.color_hex_seeds) || 'warm earth tones');
+  const tpl = recipe.prompt_template ||
+    'A seamless wallpaper pattern in the style of {artist}, with {style} motifs, palette {palette}. Tileable 24x24 inch.';
+  return tpl
+    .replace(/\{artist\}/gi, artist)
+    .replace(/\{style\}/gi, style)
+    .replace(/\{palette\}/gi, palette);
+}
+
+function recordRunStart(recipeId, prompt, seedIds) {
+  const seedArr = seedIds.length ? `ARRAY[${seedIds.join(',')}]::bigint[]` : `'{}'::bigint[]`;
+  return psql(`
+    INSERT INTO wallco_generator_runs(recipe_id, prompt, palette_seed_ids, status)
+    VALUES (${recipeId}, '${prompt.replace(/'/g, "''")}', ${seedArr}, 'running')
+    RETURNING id;
+  `);
+}
+
+function finishRun(runId, ok, designId, err) {
+  psql(`
+    UPDATE wallco_generator_runs
+       SET status='${ok ? 'done' : 'error'}',
+           design_id=${designId || 'NULL'},
+           error=${err ? `'${err.replace(/'/g, "''").slice(0, 500)}'` : 'NULL'},
+           finished_at=now()
+     WHERE id=${runId};
+  `);
+}
+
+function bumpRecipe(recipeId) {
+  psql(`
+    UPDATE wallco_generator_recipe
+       SET last_run_at = now(),
+           run_count = run_count + 1
+     WHERE id = ${recipeId};
+  `);
+}
+
+function main() {
+  const recipe = pickRecipe();
+  if (!recipe) {
+    console.log(JSON.stringify({ event: 'skip', reason: 'no enabled recipe' }));
+    return 0;
+  }
+  const seeds = recentPaletteSeeds(3);
+  const prompt = buildPrompt(recipe, seeds);
+  const seedIds = seeds.map(s => s.id);
+  const runId = recordRunStart(recipe.id, prompt, seedIds);
+  console.log(JSON.stringify({
+    event: 'tick_start', run_id: runId, recipe_id: recipe.id,
+    recipe_name: recipe.name, prompt, palette_count: seeds.length,
+  }));
+
+  // Spawn generator
+  const backend = process.env.GEN_BACKEND || 'replicate';
+  const env = { ...process.env, GEN_BACKEND: backend };
+  const r = spawnSync('node', [
+    path.join(ROOT, 'scripts', 'generate_designs.js'),
+    '--n', '1', '--kind', 'seamless_tile',
+    '--category', (recipe.style_tags && recipe.style_tags[0]) || 'mixed',
+    '--prompts', prompt,
+  ], { cwd: ROOT, env, encoding: 'utf8' });
+
+  if (r.status !== 0) {
+    const errOut = (r.stderr || r.stdout || '').slice(-1000);
+    finishRun(runId, false, null, errOut);
+    console.log(JSON.stringify({ event: 'tick_failed', run_id: runId, err: errOut }));
+    return 1;
+  }
+
+  // Find the design ID that just got inserted
+  let designId = null;
+  try {
+    designId = parseInt(psql(`
+      SELECT id FROM spoon_all_designs ORDER BY id DESC LIMIT 1;
+    `), 10);
+  } catch {}
+
+  finishRun(runId, true, designId, null);
+  bumpRecipe(recipe.id);
+  console.log(JSON.stringify({
+    event: 'tick_done', run_id: runId, design_id: designId,
+  }));
+  return 0;
+}
+
+process.exit(main());
diff --git a/server.js b/server.js
index 0adb65f..3268e80 100644
--- a/server.js
+++ b/server.js
@@ -1746,6 +1746,297 @@ app.get('/api/voice/preview', async (req, res) => {
   }
 });
 
+// ─────────────────────────────────────────────────────────────────────────
+// WALLCO GENERATOR (30-min cron + recipe picker)
+// ─────────────────────────────────────────────────────────────────────────
+app.get('/api/generator/recipes', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.id), '[]'::json) FROM (
+      SELECT id, name, source_artists, style_tags, color_hex_seeds,
+             prompt_template, weight, enabled, last_run_at, run_count, created_at
+      FROM wallco_generator_recipe) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/generator/recipes', (req, res) => {
+  const { id, name, source_artists = [], style_tags = [],
+          color_hex_seeds = [], prompt_template = null,
+          weight = 1, enabled = true } = req.body || {};
+  if (!name) return res.status(400).json({ error: 'name required' });
+  const arrLit = a => `ARRAY[${(a || []).map(v => "'" + String(v).replace(/'/g, "''") + "'").join(',') || ''}]::text[]`;
+  const tpl = prompt_template ? `'${prompt_template.replace(/'/g, "''")}'` : 'NULL';
+  try {
+    let result;
+    if (id) {
+      result = psqlQuery(`UPDATE wallco_generator_recipe SET
+        name='${name.replace(/'/g,"''")}',
+        source_artists=${arrLit(source_artists)},
+        style_tags=${arrLit(style_tags)},
+        color_hex_seeds=${arrLit(color_hex_seeds)},
+        prompt_template=${tpl},
+        weight=${parseInt(weight,10) || 1},
+        enabled=${enabled ? 'TRUE' : 'FALSE'}
+        WHERE id=${parseInt(id,10)} RETURNING id;`);
+    } else {
+      result = psqlQuery(`INSERT INTO wallco_generator_recipe
+        (name, source_artists, style_tags, color_hex_seeds, prompt_template, weight, enabled)
+        VALUES ('${name.replace(/'/g,"''")}',
+          ${arrLit(source_artists)}, ${arrLit(style_tags)}, ${arrLit(color_hex_seeds)},
+          ${tpl}, ${parseInt(weight,10) || 1}, ${enabled ? 'TRUE' : 'FALSE'})
+        RETURNING id;`);
+    }
+    res.json({ ok: true, id: parseInt(result, 10) });
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.delete('/api/generator/recipes/:id', (req, res) => {
+  try {
+    psqlQuery(`DELETE FROM wallco_generator_recipe WHERE id=${parseInt(req.params.id,10)};`);
+    res.json({ ok: true });
+  } catch (e) { res.status(500).json({ 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'],
+  });
+  let out = '';
+  proc.stdout.on('data', d => out += d);
+  proc.stderr.on('data', d => out += d);
+  proc.on('exit', code => res.json({ ok: code === 0, exit: code, output: out.slice(-2000) }));
+});
+
+app.post('/api/generator/refresh-orders', (req, res) => {
+  const { limit = 50 } = req.body || {};
+  const proc = spawn('node',
+    [path.join(__dirname, 'scripts', 'fetch_recent_orders_palette.js'), '--limit', String(limit)],
+    { cwd: __dirname, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
+  let out = '';
+  proc.stdout.on('data', d => out += d);
+  proc.stderr.on('data', d => out += d);
+  proc.on('exit', code => res.json({ ok: code === 0, output: out.slice(-3000) }));
+});
+
+app.get('/api/generator/runs', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.started_at DESC), '[]'::json) FROM (
+      SELECT r.id, r.recipe_id, rc.name AS recipe_name, r.design_id,
+             LEFT(r.prompt, 180) AS prompt, r.status, r.error,
+             r.started_at, r.finished_at
+      FROM wallco_generator_runs r
+      LEFT JOIN wallco_generator_recipe rc ON rc.id = r.recipe_id
+      ORDER BY r.started_at DESC LIMIT 30) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.get('/api/generator/palette', (_req, res) => {
+  try {
+    const raw = psqlQuery(`SELECT COALESCE(json_agg(t ORDER BY t.ordered_at DESC), '[]'::json) FROM (
+      SELECT id, sku, product_title, vendor, dominant_hex, style_tags, ordered_at, image_url
+      FROM wallco_recent_orders_palette
+      WHERE dominant_hex IS NOT NULL
+      ORDER BY ordered_at DESC LIMIT 40) t;`);
+    res.json(JSON.parse(raw || '[]'));
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+app.post('/api/generator/cron', (req, res) => {
+  const { action } = req.body || {};
+  const plist = `${require('os').homedir()}/Library/LaunchAgents/com.steve.wallco-generator.plist`;
+  if (!['start', 'stop', 'status'].includes(action)) {
+    return res.status(400).json({ error: 'action must be start|stop|status' });
+  }
+  try {
+    if (action === 'start') {
+      execSync(`launchctl bootstrap gui/$(id -u) "${plist}" 2>&1 || launchctl load "${plist}" 2>&1 || true`);
+      const listed = execSync(`launchctl list | grep com.steve.wallco-generator || echo "(not loaded)"`, { encoding: 'utf8' }).trim();
+      res.json({ ok: true, action, status: listed });
+    } else if (action === 'stop') {
+      execSync(`launchctl bootout gui/$(id -u)/com.steve.wallco-generator 2>&1 || launchctl unload "${plist}" 2>&1 || true`);
+      res.json({ ok: true, action });
+    } else {
+      const listed = execSync(`launchctl list | grep com.steve.wallco-generator || echo "(not loaded)"`, { encoding: 'utf8' }).trim();
+      res.json({ ok: true, action, status: listed });
+    }
+  } catch (e) { res.status(500).json({ error: e.message }); }
+});
+
+// ── /generator admin page
+app.get('/generator', (_req, res) => {
+  res.type('html').send(`${htmlHead({
+    title: 'Generator — wallco.ai admin',
+    description: '30-min recipe-driven pattern generator',
+    canonical: 'https://wallco.ai/generator'
+  })}
+<body>
+${htmlHeader('')}
+<main style="padding:32px 40px;max-width:1300px;margin:0 auto">
+  <h1 style="font-family:'Cormorant Garamond',serif;font-weight:300;font-size:32px;margin:0 0 4px">Pattern Generator</h1>
+  <p style="color:#666;margin:0 0 28px">Every 30 min, the cron picks a recipe → fetches a palette from recent DW Shopify orders → renders a new SDXL design.</p>
+
+  <div style="display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-bottom:28px">
+    <div style="padding:18px;border:1px solid #ddd;border-radius:8px;background:#fafafa">
+      <h2 style="margin:0 0 8px;font-size:16px">30-minute cron</h2>
+      <p id="cron-status" style="margin:0 0 10px;font-size:13px;color:#555">Checking…</p>
+      <div style="display:flex;gap:8px;flex-wrap:wrap">
+        <button class="btn-primary" id="btn-cron-start">Start cron</button>
+        <button class="btn-outline" id="btn-cron-stop">Stop cron</button>
+        <button class="btn-outline" id="btn-run-now">Run tick NOW</button>
+      </div>
+    </div>
+    <div style="padding:18px;border:1px solid #ddd;border-radius:8px;background:#fafafa">
+      <h2 style="margin:0 0 8px;font-size:16px">DW orders palette</h2>
+      <p id="palette-status" style="margin:0 0 10px;font-size:13px;color:#555">Loading…</p>
+      <button class="btn-outline" id="btn-refresh-orders">Refresh from Shopify (last 50 orders)</button>
+    </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>
+
+  <details style="margin-bottom:32px;padding:14px;border:1px solid #ddd;border-radius:8px;background:#fff">
+    <summary style="cursor:pointer;font-weight:500">+ Add new recipe</summary>
+    <form id="recipe-form" style="margin-top:14px;display:grid;gap:10px;max-width:680px">
+      <input name="name" placeholder="Recipe name (e.g., Botanical × Damask Mix)" required style="padding:10px;border:1px solid #ccc;border-radius:4px;font-size:14px">
+      <textarea name="source_artists" placeholder="Source artists, comma-separated" rows="2" style="padding:10px;border:1px solid #ccc;border-radius:4px;font-size:14px"></textarea>
+      <textarea name="style_tags" placeholder="Style tags, comma-separated (damask, floral, geometric, toile)" rows="2" style="padding:10px;border:1px solid #ccc;border-radius:4px;font-size:14px"></textarea>
+      <input name="color_hex_seeds" placeholder="Optional color hex seeds (#ab1234, #cd5678)" style="padding:10px;border:1px solid #ccc;border-radius:4px;font-size:14px">
+      <textarea name="prompt_template" placeholder="Prompt template (use {artist}, {style}, {palette})" rows="3" style="padding:10px;border:1px solid #ccc;border-radius:4px;font-size:14px;font-family:monospace"></textarea>
+      <button type="submit" class="btn-primary">Save recipe</button>
+    </form>
+  </details>
+
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:24px 0 10px">Recent runs</h2>
+  <div id="runs"></div>
+
+  <h2 style="font-family:'Cormorant Garamond',serif;font-weight:400;font-size:22px;margin:32px 0 10px">DW Orders palette (last 40)</h2>
+  <div id="palette" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px"></div>
+</main>
+${FOOTER}
+
+<script>
+async function get(u){ return (await fetch(u)).json(); }
+async function post(u,b){ return (await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})})).json(); }
+async function del(u){ return (await fetch(u,{method:'DELETE'})).json(); }
+
+async function loadCron() {
+  var j = await post('/api/generator/cron', { action: 'status' });
+  var el = document.getElementById('cron-status');
+  el.innerHTML = j.status && !j.status.includes('not loaded')
+    ? '<span style="color:#155724;font-weight:500">✓ LOADED</span> — <code style="font-size:11px">'+j.status+'</code>'
+    : '<span style="color:#856404;font-weight:500">○ STOPPED</span> — click Start cron to enable 30-min auto-generation.';
+}
+async function loadPaletteStatus() {
+  var p = await get('/api/generator/palette');
+  document.getElementById('palette-status').textContent = p.length + ' palette swatches indexed from recent DW orders';
+  document.getElementById('palette').innerHTML = p.map(function(x){
+    return '<div style="text-align:center" title="'+(x.sku||'')+'">' +
+      '<div style="aspect-ratio:1;background:'+x.dominant_hex+';border:1px solid rgba(0,0,0,.08);border-radius:6px"></div>' +
+      '<div style="font-size:10px;color:#666;margin-top:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+x.dominant_hex+'</div>' +
+      '<div style="font-size:9px;color:#999;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+(x.sku||'')+'</div>' +
+      '</div>';
+  }).join('') || '<p style="color:#999;grid-column:1/-1">No palette data yet. Click "Refresh from Shopify" above.</p>';
+}
+async function loadRecipes() {
+  var r = await get('/api/generator/recipes');
+  document.getElementById('recipes').innerHTML = r.map(function(x){
+    var artists = (x.source_artists||[]).join(', ');
+    var styles  = (x.style_tags||[]).join(', ');
+    var ran = x.last_run_at ? new Date(x.last_run_at).toLocaleString() : 'never';
+    return '<div style="padding:14px;border:1px solid #ddd;border-radius:6px;margin-bottom:10px;background:'+(x.enabled?'#fff':'#f5f5f5')+'">' +
+      '<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:14px">' +
+        '<div style="flex:1">' +
+          '<div style="font-weight:500;font-size:15px">'+x.name+(x.enabled?'':' <span style="color:#999;font-size:11px;font-weight:400">(disabled)</span>')+'</div>' +
+          '<div style="font-size:12px;color:#666;margin-top:4px"><b>Artists:</b> '+artists+'</div>' +
+          '<div style="font-size:12px;color:#666"><b>Styles:</b> '+styles+'</div>' +
+          (x.prompt_template ? '<div style="font-size:11px;color:#888;margin-top:6px;font-family:monospace;background:#fafafa;padding:6px;border-radius:3px">'+x.prompt_template.replace(/[<>]/g,'')+'</div>' : '') +
+          '<div style="font-size:11px;color:#999;margin-top:4px">Last run: '+ran+' · '+(x.run_count||0)+' runs</div>' +
+        '</div>' +
+        '<div style="display:flex;flex-direction:column;gap:4px">' +
+          '<button onclick="toggleRecipe('+x.id+','+(!x.enabled)+')" style="padding:4px 10px;font-size:12px">'+(x.enabled?'Disable':'Enable')+'</button>' +
+          '<button onclick="deleteRecipe('+x.id+')" style="padding:4px 10px;font-size:12px;color:#c00">Delete</button>' +
+        '</div>' +
+      '</div></div>';
+  }).join('') || '<p style="color:#999">No recipes yet — add one below.</p>';
+}
+async function loadRuns() {
+  var r = await get('/api/generator/runs');
+  document.getElementById('runs').innerHTML = r.length ? '<table style="width:100%;border-collapse:collapse;font-size:13px"><thead><tr style="text-align:left;border-bottom:1px solid #ddd"><th style="padding:6px">When</th><th>Recipe</th><th>Status</th><th>Design</th><th>Prompt</th></tr></thead><tbody>' +
+    r.map(function(x){
+      var when = x.started_at ? new Date(x.started_at).toLocaleString() : '?';
+      var statusBg = x.status==='done'?'#d4edda':x.status==='error'?'#f8d7da':'#fff3cd';
+      var statusFg = x.status==='done'?'#155724':x.status==='error'?'#721c24':'#856404';
+      return '<tr style="border-bottom:1px solid #f0f0f0"><td style="padding:8px 4px;color:#888">'+when+'</td><td>'+(x.recipe_name||'—')+'</td><td><span style="padding:2px 6px;border-radius:3px;background:'+statusBg+';color:'+statusFg+'">'+x.status+'</span></td><td>'+(x.design_id?'<a href="/design/'+x.design_id+'">#'+x.design_id+'</a>':'—')+'</td><td style="font-size:11px;color:#666;max-width:380px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(x.prompt||'').slice(0,80)+'</td></tr>';
+    }).join('') + '</tbody></table>' : '<p style="color:#999">No runs yet.</p>';
+}
+
+window.toggleRecipe = async function(id, on){
+  var rs = await get('/api/generator/recipes');
+  var r = rs.find(function(x){return x.id===id;});
+  if (!r) return;
+  r.enabled = on;
+  await post('/api/generator/recipes', r);
+  loadRecipes();
+};
+window.deleteRecipe = async function(id){
+  if (!confirm('Delete this recipe?')) return;
+  await del('/api/generator/recipes/'+id);
+  loadRecipes();
+};
+
+document.getElementById('btn-cron-start').addEventListener('click', async function(){
+  await post('/api/generator/cron', { action: 'start' });
+  setTimeout(loadCron, 600);
+});
+document.getElementById('btn-cron-stop').addEventListener('click', async function(){
+  await post('/api/generator/cron', { action: 'stop' });
+  setTimeout(loadCron, 600);
+});
+document.getElementById('btn-run-now').addEventListener('click', async function(){
+  this.disabled = true;
+  this.textContent = 'Running…';
+  var j = await post('/api/generator/run-now');
+  this.disabled = false;
+  this.textContent = 'Run tick NOW';
+  alert((j.ok?'OK':'FAIL')+'\\n\\n'+(j.output||'').slice(-800));
+  loadRuns();
+});
+document.getElementById('btn-refresh-orders').addEventListener('click', async function(){
+  this.disabled = true;
+  this.textContent = 'Fetching…';
+  var j = await post('/api/generator/refresh-orders', { limit: 50 });
+  this.disabled = false;
+  this.textContent = 'Refresh from Shopify (last 50 orders)';
+  alert((j.ok?'OK':'FAIL')+'\\n\\n'+(j.output||'').slice(-800));
+  loadPaletteStatus();
+});
+
+document.getElementById('recipe-form').addEventListener('submit', async function(e){
+  e.preventDefault();
+  var fd = new FormData(e.target);
+  var body = {
+    name: fd.get('name'),
+    source_artists: (fd.get('source_artists')||'').split(',').map(function(s){return s.trim();}).filter(Boolean),
+    style_tags: (fd.get('style_tags')||'').split(',').map(function(s){return s.trim();}).filter(Boolean),
+    color_hex_seeds: (fd.get('color_hex_seeds')||'').split(',').map(function(s){return s.trim();}).filter(Boolean),
+    prompt_template: fd.get('prompt_template') || null,
+    enabled: true
+  };
+  await post('/api/generator/recipes', body);
+  e.target.reset();
+  loadRecipes();
+});
+
+loadCron(); loadPaletteStatus(); loadRecipes(); loadRuns();
+setInterval(loadRuns, 30000);
+</script>
+${HAMBURGER_JS}
+</body>
+</html>`);
+});
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });
diff --git a/src/review.js b/src/review.js
index 1fb6852..1707793 100644
--- a/src/review.js
+++ b/src/review.js
@@ -1,6 +1,7 @@
 /**
  * wallco.ai review layer — sliders (design/color/style), keep/reject decision,
- * auto-generated descriptor chips, per-chip chat with local Ollama.
+ * auto-generated descriptor chips, per-chip chat with local Ollama, and
+ * per-design pairing suggestions (catalog matches + colors + materials).
  *
  * Wired into /designs grid. Persistence is JSON file (no native deps).
  *
@@ -11,13 +12,16 @@
  *   GET  /api/chip/:id/:chipKey/chat                  — chat history for a chip
  *   POST /api/chip/:id/:chipKey/chat                  — { message } → { reply }
  *   GET  /api/reviews/all                             — full review log (for admin)
+ *   GET  /api/pairings/:id                            — suggested combinations (cached)
+ *   POST /api/pairings/:id/refresh                    — force regenerate
  */
 'use strict';
 const fs = require('fs');
 const path = require('path');
 
-const REVIEWS_FILE = path.join(__dirname, '..', 'data', 'reviews.json');
-const CHATS_FILE   = path.join(__dirname, '..', 'data', 'chip-chats.json');
+const REVIEWS_FILE  = path.join(__dirname, '..', 'data', 'reviews.json');
+const CHATS_FILE    = path.join(__dirname, '..', 'data', 'chip-chats.json');
+const PAIRINGS_FILE = path.join(__dirname, '..', 'data', 'pairings.json');
 const OLLAMA = process.env.OLLAMA_URL || 'http://192.168.1.133:11434';
 const OLLAMA_MODEL = process.env.OLLAMA_REVIEW_MODEL || 'qwen3:14b';
 

← 78f6515 tick 6: tileability scored on all 304 PD images · verdict: 6  ·  back to Wallco Ai  ·  generator + go-live: 30-min pattern generator, prod deploy a 8269964 →