← back to Wallco Ai
generator + go-live: 30-min pattern generator, prod deploy at wallco.ai
826996498956bdaf8b398fb2c18c3c5108f3cbf6 · 2026-05-11 17:34:59 -0700 · SteveStudio2
GENERATOR PIPELINE
- PG schema: wallco_generator_recipe / wallco_recent_orders_palette /
wallco_generator_runs
- scripts/generator_tick.js — picks an enabled recipe, fetches 3
recent-orders palette swatches, builds prompt via {artist}/{style}/
{palette} templates, spawns generate_designs.js for SDXL render
- scripts/fetch_recent_orders_palette.js — pulls last N DW Shopify
orders, extracts dominant hex per line-item image via sips, indexes
- launchd plist com.steve.wallco-generator (StartInterval=1800)
- /generator admin page: cron toggle, run-now button, recipes CRUD,
recent-runs table, DW orders palette grid
- API: /api/generator/{recipes,run-now,refresh-orders,runs,palette,cron}
PROD LAUNCH (wallco.ai live 2026-05-11)
- .deploy.conf → DEPLOY_PATH=/root/public-projects/wallco-ai (matches
existing pm2 process; /var/www/wallco.ai path was the wrong target)
- ecosystem.config.js: name=wallco-ai, PORT=9905, NODE_ENV=production
- GoDaddy A record wallco.ai → 45.61.58.125 (was: parking page IPs)
- www CNAME → @
- certbot --nginx successfully issued LE cert for wallco.ai +
www.wallco.ai (expires 2026-08-09)
- PG tables mirrored to prod via sudo -u postgres
- 9 routes verified 200 on public URL: /, /designs, /design/:id,
/spoonflower, /generator, /about, /samples, /murals, /health
Files touched
A data/pairings.jsonM server.jsM src/review.js
Diff
commit 826996498956bdaf8b398fb2c18c3c5108f3cbf6
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date: Mon May 11 17:34:59 2026 -0700
generator + go-live: 30-min pattern generator, prod deploy at wallco.ai
GENERATOR PIPELINE
- PG schema: wallco_generator_recipe / wallco_recent_orders_palette /
wallco_generator_runs
- scripts/generator_tick.js — picks an enabled recipe, fetches 3
recent-orders palette swatches, builds prompt via {artist}/{style}/
{palette} templates, spawns generate_designs.js for SDXL render
- scripts/fetch_recent_orders_palette.js — pulls last N DW Shopify
orders, extracts dominant hex per line-item image via sips, indexes
- launchd plist com.steve.wallco-generator (StartInterval=1800)
- /generator admin page: cron toggle, run-now button, recipes CRUD,
recent-runs table, DW orders palette grid
- API: /api/generator/{recipes,run-now,refresh-orders,runs,palette,cron}
PROD LAUNCH (wallco.ai live 2026-05-11)
- .deploy.conf → DEPLOY_PATH=/root/public-projects/wallco-ai (matches
existing pm2 process; /var/www/wallco.ai path was the wrong target)
- ecosystem.config.js: name=wallco-ai, PORT=9905, NODE_ENV=production
- GoDaddy A record wallco.ai → 45.61.58.125 (was: parking page IPs)
- www CNAME → @
- certbot --nginx successfully issued LE cert for wallco.ai +
www.wallco.ai (expires 2026-08-09)
- PG tables mirrored to prod via sudo -u postgres
- 9 routes verified 200 on public URL: /, /designs, /design/:id,
/spoonflower, /generator, /about, /samples, /murals, /health
---
data/pairings.json | 1 +
server.js | 99 ++++++++++++++++++++++++++++++++++
src/review.js | 153 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 252 insertions(+), 1 deletion(-)
diff --git a/data/pairings.json b/data/pairings.json
new file mode 100644
index 0000000..9e26dfe
--- /dev/null
+++ b/data/pairings.json
@@ -0,0 +1 @@
+{}
\ No newline at end of file
diff --git a/server.js b/server.js
index 3268e80..1c8e6e8 100644
--- a/server.js
+++ b/server.js
@@ -634,6 +634,7 @@ app.get('/designs', (req, res) => {
<div class="decision">
<button class="btn-keep" type="button">Keep</button>
<button class="btn-reject" type="button">Reject</button>
+ <button class="btn-pair" type="button" title="Suggest combinations">Pair</button>
</div>
<div class="why-chip" title="Click to chat about this verdict"></div>
<div class="chips"></div>
@@ -759,6 +760,30 @@ ${isAdmin ? `
.decision button:hover { background:#1f1c18; color:#fff; }
.design-card.review-card[data-decision=keep] .btn-keep { background:var(--keep); color:#0f0e0c; border-color:var(--keep); }
.design-card.review-card[data-decision=reject] .btn-reject { background:var(--reject); color:#fff; border-color:var(--reject); }
+ .decision .btn-pair { background:#16140f; color:#d2b15c; border-color:#3a3631; }
+ .decision .btn-pair:hover { background:#d2b15c; color:#0f0e0c; border-color:#d2b15c; }
+ /* Pair modal */
+ #pair-modal { position:fixed; top:0; right:0; bottom:0; width:480px; background:#16140f; border-left:1px solid #2a2825; display:none; flex-direction:column; z-index:1001; box-shadow:-8px 0 20px rgba(0,0,0,.4); color:#e8e2d6; font:13px/1.4 -apple-system, system-ui, sans-serif; }
+ #pair-modal.open { display:flex; }
+ #pair-modal header { padding:14px 18px; border-bottom:1px solid #2a2825; display:flex; gap:10px; align-items:center; }
+ #pair-modal header .ctx-main { flex:1; font-size:12px; color:#d2b15c; }
+ #pair-modal header .ctx-sub { font-size:10px; color:#888; margin-top:2px; }
+ #pair-modal header .refresh,#pair-modal header .close { background:none; border:1px solid #3a3631; color:#bba; font-size:13px; cursor:pointer; padding:4px 9px; border-radius:2px; }
+ #pair-modal header .refresh:hover,#pair-modal header .close:hover { color:#fff; border-color:#d2b15c; }
+ #pair-modal header .close { font-size:18px; line-height:1; padding:2px 8px; }
+ #pair-body { flex:1; overflow-y:auto; padding:14px 18px; display:flex; flex-direction:column; gap:12px; }
+ #pair-body .pair-empty { color:#888; font-style:italic; padding:24px 0; text-align:center; }
+ #pair-body .pair-loading { color:#888; font-style:italic; padding:30px 0; text-align:center; }
+ .pair-card { display:flex; gap:12px; align-items:flex-start; padding:10px 12px; background:#1a1816; border:1px solid #2a2825; border-radius:3px; }
+ .pair-card .pair-thumb { flex:0 0 64px; width:64px; height:64px; border-radius:3px; background:#0d0c0a no-repeat center/cover; display:flex; align-items:center; justify-content:center; font-size:22px; color:#0f0e0c; }
+ .pair-card .pair-thumb.material { background:#2a2825; color:#d2b15c; font-size:22px; }
+ .pair-card .pair-body { flex:1; min-width:0; }
+ .pair-card .pair-title { font-size:12px; color:#e8e2d6; margin-bottom:3px; line-height:1.3; }
+ .pair-card .pair-type { display:inline-block; font-size:9px; text-transform:uppercase; letter-spacing:.08em; padding:1px 6px; border-radius:2px; background:#2a2825; color:#d2b15c; margin-bottom:5px; }
+ .pair-card .pair-reason { font-size:11px; color:#bba; line-height:1.4; }
+ .pair-card .pair-hexcode { font-family: ui-monospace, monospace; font-size:10px; color:#888; margin-left:6px; }
+ .pair-card a.pair-link { color:inherit; text-decoration:none; display:flex; gap:12px; align-items:flex-start; }
+ .pair-card a.pair-link:hover .pair-title { color:#d2b15c; }
.why-chip { display:none; padding:6px 9px; background:#16140f; border:1px solid #3a3631; border-radius:14px; font-size:11px; color:#d2b15c; margin-bottom:8px; cursor:pointer; line-height:1.35; }
.why-chip.visible { display:block; }
.why-chip.loading::after { content:' …'; opacity:.6; }
@@ -801,6 +826,18 @@ ${isAdmin ? `
</form>
</div>
+<div id="pair-modal">
+ <header>
+ <div style="flex:1">
+ <div class="ctx-main" id="pair-design-title">Pairs well with…</div>
+ <div class="ctx-sub" id="pair-design-sub"></div>
+ </div>
+ <button class="refresh" id="pair-refresh" title="Force regenerate (Ollama)">↻</button>
+ <button class="close" id="pair-close" title="Close (Esc)">×</button>
+ </header>
+ <div id="pair-body"></div>
+</div>
+
<script>
(function(){
// ── State ──────────────────────────────────────────────────────
@@ -858,6 +895,12 @@ ${isAdmin ? `
card.dataset.decision = card.dataset.decision === 'reject' ? '' : 'reject';
saveReview(card);
});
+ // Pair button → open pairing modal
+ const pairBtn = card.querySelector('.btn-pair');
+ if (pairBtn) pairBtn.addEventListener('click', e => {
+ e.preventDefault(); e.stopPropagation();
+ openPairings(id, card.querySelector('.card-title').textContent || '#'+id);
+ });
// why-chip → chat
card.querySelector('.why-chip').addEventListener('click', () => {
const why = card.querySelector('.why-chip');
@@ -970,6 +1013,62 @@ ${isAdmin ? `
send.disabled = false; input.focus();
});
+ // ── Pair modal ─────────────────────────────────────────────────
+ const pairModal = $('#pair-modal');
+ const pairBody = $('#pair-body');
+ let CURRENT_PAIR_ID = null;
+ let CURRENT_PAIR_TITLE = '';
+ $('#pair-close').addEventListener('click', () => pairModal.classList.remove('open'));
+ $('#pair-refresh').addEventListener('click', () => { if (CURRENT_PAIR_ID) loadPairings(CURRENT_PAIR_ID, CURRENT_PAIR_TITLE, true); });
+ document.addEventListener('keydown', e => { if (e.key === 'Escape' && pairModal.classList.contains('open')) pairModal.classList.remove('open'); });
+
+ function openPairings(designId, title) { loadPairings(designId, title, false); }
+
+ async function loadPairings(designId, title, forceRefresh) {
+ CURRENT_PAIR_ID = designId; CURRENT_PAIR_TITLE = title;
+ $('#pair-design-title').textContent = 'Pairs well with this design';
+ $('#pair-design-sub').textContent = '#' + designId + ' · ' + title.slice(0,80);
+ pairBody.innerHTML = '<div class="pair-loading">' + (forceRefresh ? 'regenerating…' : 'loading…') + '</div>';
+ pairModal.classList.add('open');
+ try {
+ const url = '/api/pairings/' + designId + (forceRefresh ? '/refresh' : '');
+ const r = await fetch(url, { method: forceRefresh ? 'POST' : 'GET' });
+ const j = await r.json();
+ renderPairings(j);
+ } catch (e) {
+ pairBody.innerHTML = '<div class="pair-empty">error loading: ' + e.message + '</div>';
+ }
+ }
+
+ function renderPairings(data) {
+ if (!data.suggestions || !data.suggestions.length) {
+ pairBody.innerHTML = '<div class="pair-empty">No suggestions available.</div>';
+ return;
+ }
+ pairBody.innerHTML = data.suggestions.map(s => {
+ if (s.type === 'design') {
+ const thumb = s.thumb_url
+ ? '<div class="pair-thumb" style="background-image:url(\\''+s.thumb_url+'\\')"></div>'
+ : '<div class="pair-thumb material">◧</div>';
+ return '<div class="pair-card"><a class="pair-link" href="/design/' + s.design_id + '" target="_blank">' + thumb +
+ '<div class="pair-body"><span class="pair-type">design · #' + s.design_id + '</span>' +
+ '<div class="pair-title">' + escapeHTML(s.label) + (s.hex ? '<span class="pair-hexcode">'+s.hex+'</span>' : '') + '</div>' +
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></a></div>';
+ }
+ if (s.type === 'color') {
+ return '<div class="pair-card"><div class="pair-thumb" style="background:' + (s.hex||'#888') + '"></div>' +
+ '<div class="pair-body"><span class="pair-type">color swatch</span>' +
+ '<div class="pair-title">' + escapeHTML(s.label) + '</div>' +
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></div>';
+ }
+ // material / object
+ return '<div class="pair-card"><div class="pair-thumb material">' + (s.icon || '✦') + '</div>' +
+ '<div class="pair-body"><span class="pair-type">material</span>' +
+ '<div class="pair-title">' + escapeHTML(s.label) + '</div>' +
+ '<div class="pair-reason">' + escapeHTML(s.reason) + '</div></div></div>';
+ }).join('') + '<div style="text-align:right; font-size:10px; color:#666; margin-top:6px">generated ' + (data.generated_at ? new Date(data.generated_at).toLocaleString() : '—') + '</div>';
+ }
+
boot();
})();
</script>
diff --git a/src/review.js b/src/review.js
index 1707793..1d3e81f 100644
--- a/src/review.js
+++ b/src/review.js
@@ -118,11 +118,135 @@ function whySystemPrompt(design, decision, scores) {
return `You are a senior wallcovering creative director. Steve just rated this AI-generated design (title: "${design.title}", category: ${design.category}, dominant hex: ${design.dominant_hex}, kind: ${design.kind}) with: design ${scores.design}/10, color ${scores.color}/10, style ${scores.style}/10, decision: ${decision}. In exactly one short sentence (max 20 words), state the dominant reason for that verdict. No preamble.`;
}
+// ── pairings: catalog matches + complementary colors + material/object recs ───
+
+// hex math (HSL ⇄ hex), no deps
+function hexToRgb(hex) {
+ const m = (hex || '').replace('#','').match(/.{2}/g) || [];
+ return m.map(x => parseInt(x, 16));
+}
+function rgbToHex([r,g,b]) {
+ return '#' + [r,g,b].map(x => Math.max(0, Math.min(255, Math.round(x))).toString(16).padStart(2,'0')).join('');
+}
+function hslToRgb(h, s, l) {
+ h = ((h % 360) + 360) % 360;
+ const c = (1 - Math.abs(2*l - 1)) * s;
+ const x = c * (1 - Math.abs(((h/60) % 2) - 1));
+ const m = l - c/2;
+ let r=0,g=0,b=0;
+ if (h<60) [r,g,b]=[c,x,0];
+ else if (h<120) [r,g,b]=[x,c,0];
+ else if (h<180) [r,g,b]=[0,c,x];
+ else if (h<240) [r,g,b]=[0,x,c];
+ else if (h<300) [r,g,b]=[x,0,c];
+ else [r,g,b]=[c,0,x];
+ return [(r+m)*255, (g+m)*255, (b+m)*255];
+}
+function shiftHueHex(hex, deg) {
+ const { h, s, l } = hexToHsl(hex);
+ const rgb = hslToRgb(h + deg, Math.max(0.35, s), Math.max(0.32, Math.min(0.68, l)));
+ return rgbToHex(rgb);
+}
+
+// distance in HSL space — small = similar palette
+function hexDistance(a, b) {
+ const A = hexToHsl(a), B = hexToHsl(b);
+ const dh = Math.min(Math.abs(A.h - B.h), 360 - Math.abs(A.h - B.h)) / 180; // 0..1
+ const ds = Math.abs(A.s - B.s);
+ const dl = Math.abs(A.l - B.l);
+ return dh * 1.2 + ds * 0.6 + dl * 0.6;
+}
+
+// catalog match: pick best sibling + best sister
+function catalogMatches(design, designs) {
+ if (!designs || designs.length < 3) return [];
+ const others = designs.filter(d => String(d.id) !== String(design.id));
+ // sibling = same category, different hex
+ const sameCat = others.filter(d => d.category && d.category === design.category);
+ sameCat.sort((a, b) => hexDistance(b.dominant_hex || '#888', design.dominant_hex || '#888')
+ - hexDistance(a.dominant_hex || '#888', design.dominant_hex || '#888'));
+ const sibling = sameCat[0];
+ // sister = different category, similar hex
+ const diffCat = others.filter(d => !d.category || d.category !== design.category);
+ diffCat.sort((a, b) => hexDistance(a.dominant_hex || '#888', design.dominant_hex || '#888')
+ - hexDistance(b.dominant_hex || '#888', design.dominant_hex || '#888'));
+ const sister = diffCat[0];
+ const out = [];
+ if (sibling) out.push({
+ type: 'design', design_id: sibling.id,
+ label: (sibling.title || sibling.prompt || '#'+sibling.id).slice(0, 40),
+ reason: `Same family (${design.category}), different palette — pattern echo with chromatic counterpoint`,
+ thumb_url: sibling.image_url || (sibling.local_path ? '/img/' + sibling.local_path.replace(/^.*\/data\//, '') : null),
+ hex: sibling.dominant_hex,
+ });
+ if (sister) out.push({
+ type: 'design', design_id: sister.id,
+ label: (sister.title || sister.prompt || '#'+sister.id).slice(0, 40),
+ reason: `Different language (${sister.category || 'mixed'}), shared palette — connector for a layered room`,
+ thumb_url: sister.image_url || (sister.local_path ? '/img/' + sister.local_path.replace(/^.*\/data\//, '') : null),
+ hex: sister.dominant_hex,
+ });
+ return out;
+}
+
+// color swatches: complement + analogous
+function colorSwatches(hex) {
+ if (!hex) return [];
+ const compl = shiftHueHex(hex, 180);
+ const anal = shiftHueHex(hex, 30);
+ return [
+ { type: 'color', hex: compl, label: 'Complement · ' + compl, reason: 'Direct chromatic opposite — trim, hardware, or a single accent piece' },
+ { type: 'color', hex: anal, label: 'Analogous · ' + anal, reason: 'Neighboring hue — supporting fabric or upholstery, harmonious not loud' },
+ ];
+}
+
+// material/object suggestions via Ollama (catalog-agnostic, prompt-grounded)
+async function materialSuggestions(design) {
+ const sys = `You are a senior interior designer. Suggest exactly 2 specific material or finish pairings for a wallcovering with these properties:
+title: "${design.title || '(untitled)'}"
+category: ${design.category || 'unknown'}
+dominant hex: ${design.dominant_hex || 'unknown'}
+kind: ${design.kind || 'seamless tile'}
+Respond as JSON only, no preamble: [{"label":"specific material/finish (≤5 words)","reason":"why it pairs (≤16 words)"},{...}]. Be concrete (e.g. "Aged brass hardware", "Walnut wood paneling", "Unbleached linen drapery"). Never generic ("nice furniture").`;
+ try {
+ const raw = await ollamaCall({ system: sys, history: [], message: 'Give me the 2 pairings as a JSON array.' });
+ // extract first [...] block
+ const m = raw.match(/\[[\s\S]*?\]/);
+ if (!m) return [];
+ const arr = JSON.parse(m[0]);
+ return arr.slice(0, 2).map(x => ({
+ type: 'material',
+ label: String(x.label || '').slice(0, 60),
+ reason: String(x.reason || '').slice(0, 120),
+ icon: '✦',
+ })).filter(x => x.label);
+ } catch (e) {
+ return [
+ { type: 'material', label: 'Unbleached linen drapery', reason: '(Ollama unavailable — placeholder rec)', icon: '✦' },
+ { type: 'material', label: 'Aged brass hardware', reason: '(Ollama unavailable — placeholder rec)', icon: '✦' },
+ ];
+ }
+}
+
+async function generatePairings(design, allDesigns) {
+ const [matches, colors, materials] = await Promise.all([
+ Promise.resolve(catalogMatches(design, allDesigns)),
+ Promise.resolve(colorSwatches(design.dominant_hex)),
+ materialSuggestions(design),
+ ]);
+ return {
+ design_id: design.id,
+ generated_at: new Date().toISOString(),
+ suggestions: [...matches, ...colors, ...materials],
+ };
+}
+
// ── mount ─────────────────────────────────────────────────────────────
function mount(app, getDesigns) {
// ensure files exist
if (!fs.existsSync(REVIEWS_FILE)) saveJSON(REVIEWS_FILE, {});
if (!fs.existsSync(CHATS_FILE)) saveJSON(CHATS_FILE, {});
+ if (!fs.existsSync(PAIRINGS_FILE)) saveJSON(PAIRINGS_FILE, {});
app.get('/api/review/:id', (req, res) => {
const reviews = loadJSON(REVIEWS_FILE, {});
@@ -204,7 +328,34 @@ function mount(app, getDesigns) {
res.json(loadJSON(REVIEWS_FILE, {}));
});
- console.log(' Review layer mounted (sliders + chips + chip-chat on Ollama ' + OLLAMA_MODEL + ')');
+ // GET cached pairings — generate-on-miss
+ app.get('/api/pairings/:id', async (req, res) => {
+ const id = req.params.id;
+ const designs = getDesigns();
+ const design = designs.find(d => String(d.id) === String(id));
+ if (!design) return res.status(404).json({ error: 'design not found' });
+ const cache = loadJSON(PAIRINGS_FILE, {});
+ if (cache[id]) return res.json(cache[id]);
+ const pairings = await generatePairings(design, designs);
+ cache[id] = pairings;
+ saveJSON(PAIRINGS_FILE, cache);
+ res.json(pairings);
+ });
+
+ // POST refresh — invalidate cache
+ app.post('/api/pairings/:id/refresh', async (req, res) => {
+ const id = req.params.id;
+ const designs = getDesigns();
+ const design = designs.find(d => String(d.id) === String(id));
+ if (!design) return res.status(404).json({ error: 'design not found' });
+ const pairings = await generatePairings(design, designs);
+ const cache = loadJSON(PAIRINGS_FILE, {});
+ cache[id] = pairings;
+ saveJSON(PAIRINGS_FILE, cache);
+ res.json(pairings);
+ });
+
+ console.log(' Review layer mounted (sliders + chips + chat + pairings on Ollama ' + OLLAMA_MODEL + ')');
}
function clamp(n, min, max) {
← cd26879 chat panel UI polish: extract to /css/chat.css using site de
·
back to Wallco Ai
·
Add /designs pairing suggestions — Pair button + side modal 98dbbba →