[object Object]

← 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

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)">&times;</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 →