← 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
A .deploy.confM ecosystem.config.jsA public/css/chat.cssM public/js/chat.jsA scripts/fetch_recent_orders_palette.jsA scripts/generator_tick.jsM server.jsM src/review.js
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 →