[object Object]

← back to Wallco Ai

wallco.ai: 'Pair this with' block on /design/:id — 3 nearest-palette siblings in CIE Lab space · POST-less GET /api/design/:id/pairings · client-rendered grid below CTA

ccd3f38bc078491e0430782e3e22cf46803ad8e5 · 2026-05-11 18:54:27 -0700 · Steve

Files touched

Diff

commit ccd3f38bc078491e0430782e3e22cf46803ad8e5
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon May 11 18:54:27 2026 -0700

    wallco.ai: 'Pair this with' block on /design/:id — 3 nearest-palette siblings in CIE Lab space · POST-less GET /api/design/:id/pairings · client-rendered grid below CTA
---
 data/chip-chats.json |  15 +-----
 data/moodboards.json |  17 +------
 data/reviews.json    |  13 +----
 server.js            | 133 +++++++++++++++++++++++++++++++++++++++++++++++++++
 4 files changed, 137 insertions(+), 41 deletions(-)

diff --git a/data/chip-chats.json b/data/chip-chats.json
index eec8b78..0967ef4 100644
--- a/data/chip-chats.json
+++ b/data/chip-chats.json
@@ -1,14 +1 @@
-{
-  "13:cat-floral": [
-    {
-      "role": "user",
-      "content": "What scale of room does this work in?",
-      "ts": "2026-05-12T01:38:04.429Z"
-    },
-    {
-      "role": "assistant",
-      "content": "\"Honey Garden No.13\" works well in medium to large rooms due to its floral scale—bold, stylized blooms in #87774c (warm terracotta) add visual depth without overwhelming space. For smaller rooms, consider reducing floral size or lightening the palette to avoid claustrophobia.",
-      "ts": "2026-05-12T01:38:04.430Z"
-    }
-  ]
-}
\ No newline at end of file
+{}
diff --git a/data/moodboards.json b/data/moodboards.json
index 872b901..0967ef4 100644
--- a/data/moodboards.json
+++ b/data/moodboards.json
@@ -1,16 +1 @@
-{
-  "14": [
-    {
-      "pin_key": "d:12",
-      "type": "design",
-      "design_id": 12,
-      "label": "Honey Blossom No.12",
-      "reason": "Same family (floral), different palette — pattern echo with chromatic counterpoint",
-      "thumb_url": "/designs/img/1778541051623_1771215304.png",
-      "hex": "#a09279",
-      "idx": 0,
-      "pinned": false,
-      "pinned_at": "2026-05-12T01:39:15.311Z"
-    }
-  ]
-}
\ No newline at end of file
+{}
diff --git a/data/reviews.json b/data/reviews.json
index 3815ead..a2bf57a 100644
--- a/data/reviews.json
+++ b/data/reviews.json
@@ -5,16 +5,7 @@
     "color": 5,
     "style": 5,
     "decision": "keep",
-    "why": "The muted, earthy color palette lacks vibrancy and contrast, diminishing the floral design's visual impact despite its structured compositio",
-    "updated_at": "2026-05-12T01:36:34.661Z"
-  },
-  "12": {
-    "id": "12",
-    "design": 5,
-    "color": 5,
-    "style": 5,
-    "decision": "reject",
-    "why": "The design lacks visual vibrancy and distinctiveness, resulting in a flat, unengaging pattern that fails to capture attention or stand out i",
-    "updated_at": "2026-05-12T01:37:16.898Z"
+    "why": "The design is appealing but lacks vibrancy and distinctiveness in color and style, which detract from its overall impact despite its solid c",
+    "updated_at": "2026-05-12T01:54:12.033Z"
   }
 }
\ No newline at end of file
diff --git a/server.js b/server.js
index 1f3c926..66adf50 100644
--- a/server.js
+++ b/server.js
@@ -1130,6 +1130,84 @@ ${isAdmin ? `
   }
   function escapeAttr(s) { return String(s||'').replace(/"/g, '&quot;').replace(/</g,'&lt;'); }
 
+  // ── Keyboard shortcuts ─────────────────────────────────────────
+  //   K/R  → keep/reject focused card
+  //   P    → open Pair modal for focused card
+  //   ←/→  → navigate cards left/right
+  //   1-9  → set focused slider to that value (Design when no slider focused; else current slider)
+  //   ?    → show keymap toast
+  let FOCUSED_CARD = null;
+  function setFocused(card) {
+    if (FOCUSED_CARD === card) return;
+    if (FOCUSED_CARD) FOCUSED_CARD.classList.remove('kbd-focus');
+    FOCUSED_CARD = card;
+    if (card) {
+      card.classList.add('kbd-focus');
+      card.scrollIntoView({ block: 'nearest', behavior: 'smooth' });
+    }
+  }
+  // mouse hover sets focus
+  document.addEventListener('mouseover', e => {
+    const c = e.target.closest('.design-card.review-card');
+    if (c) setFocused(c);
+  });
+
+  function showToast(msg, ms = 1800) {
+    let t = document.getElementById('kbd-toast');
+    if (!t) {
+      t = document.createElement('div');
+      t.id = 'kbd-toast';
+      t.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);padding:8px 16px;background:#1a1816;border:1px solid #d2b15c;border-radius:3px;color:#d2b15c;font:12px/1.4 -apple-system,system-ui,sans-serif;z-index:9999;box-shadow:0 4px 12px rgba(0,0,0,.4);transition:opacity .2s;';
+      document.body.appendChild(t);
+    }
+    t.textContent = msg;
+    t.style.opacity = '1';
+    clearTimeout(t._fade);
+    t._fade = setTimeout(() => { t.style.opacity = '0'; }, ms);
+  }
+
+  document.addEventListener('keydown', e => {
+    // ignore when typing in input/textarea or modals open with input focused
+    if (e.target.matches('input, textarea, select')) return;
+    if (document.querySelector('#chat-modal.open, #pair-modal.open')) return; // modal handles its own keys
+    const cards = Array.from(document.querySelectorAll('.design-card.review-card'));
+    if (!cards.length) return;
+    let idx = FOCUSED_CARD ? cards.indexOf(FOCUSED_CARD) : 0;
+    const key = e.key.toLowerCase();
+    if (key === 'k' && FOCUSED_CARD) {
+      e.preventDefault();
+      FOCUSED_CARD.querySelector('.btn-keep').click();
+      showToast('Keep — ' + (FOCUSED_CARD.querySelector('.card-title').textContent || '').slice(0,40));
+    } else if (key === 'r' && FOCUSED_CARD) {
+      e.preventDefault();
+      FOCUSED_CARD.querySelector('.btn-reject').click();
+      showToast('Reject — ' + (FOCUSED_CARD.querySelector('.card-title').textContent || '').slice(0,40));
+    } else if (key === 'p' && FOCUSED_CARD) {
+      e.preventDefault();
+      FOCUSED_CARD.querySelector('.btn-pair').click();
+    } else if (key === 'arrowright' || key === 'arrowdown') {
+      e.preventDefault();
+      setFocused(cards[Math.min(cards.length - 1, idx + 1)]);
+    } else if (key === 'arrowleft' || key === 'arrowup') {
+      e.preventDefault();
+      setFocused(cards[Math.max(0, idx - 1)]);
+    } else if (/^[1-9]$/.test(e.key) && FOCUSED_CARD) {
+      e.preventDefault();
+      const designSlider = FOCUSED_CARD.querySelector('.sliders input[type=range][data-axis=design]');
+      designSlider.value = e.key;
+      designSlider.dispatchEvent(new Event('input'));
+      designSlider.dispatchEvent(new Event('change'));
+      showToast('Design score = ' + e.key);
+    } else if (key === '?' || (e.shiftKey && key === '/')) {
+      e.preventDefault();
+      showToast('K=keep · R=reject · P=pair · ←→=nav · 1-9=design score · Esc=close', 4000);
+    }
+  });
+  // Add focus ring style
+  const kbdStyle = document.createElement('style');
+  kbdStyle.textContent = '.design-card.review-card.kbd-focus { outline: 2px solid #d2b15c; outline-offset: 2px; }';
+  document.head.appendChild(kbdStyle);
+
   boot();
 })();
 </script>
@@ -1273,6 +1351,32 @@ ${htmlHeader('/designs')}
         <a href="/designs" class="btn-outline">Back to Designs</a>
       </div>
 
+      <!-- Pair-with sibling palette neighbors -->
+      <div class="pair-with-block" style="margin-top:32px">
+        <h3 style="font-family:var(--serif);font-weight:300;font-size:20px;margin:0 0 4px">Pair this with</h3>
+        <p style="font-size:12px;color:var(--ink-faint);margin:0 0 14px;letter-spacing:.04em">Sibling designs by palette · nearest neighbors in CIE Lab space</p>
+        <div id="pair-with-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:10px;min-height:120px">
+          <div style="grid-column:1/-1;color:var(--ink-faint);font-size:12px">Loading pairings…</div>
+        </div>
+      </div>
+      <script>
+      (function(){
+        var id = ${design.id};
+        fetch('/api/design/' + id + '/pairings').then(r=>r.json()).then(function(d){
+          var g = document.getElementById('pair-with-grid');
+          if (!d.designs || !d.designs.length) { g.innerHTML = '<div style="grid-column:1/-1;color:var(--ink-faint);font-size:12px">No close pairings yet — catalog is still seeding.</div>'; return; }
+          g.innerHTML = d.designs.map(function(p){
+            return '<a href="/design/' + p.id + '" style="text-decoration:none;color:inherit;display:block">'
+              + '<div style="aspect-ratio:1;background:#0a0907 no-repeat center/cover;background-image:url(\\'' + p.image_url + '\\');border-radius:6px;border:1px solid var(--line);transition:transform .15s;"></div>'
+              + '<div style="margin-top:6px;display:flex;align-items:center;gap:6px"><span style="width:9px;height:9px;border-radius:50%;background:' + p.dominant_hex + ';border:1px solid rgba(0,0,0,.12)"></span><span style="font-size:11px;color:var(--ink-soft)">Lab Δ ' + p.distance.toFixed(1) + '</span></div>'
+              + '</a>';
+          }).join('');
+        }).catch(function(e){
+          document.getElementById('pair-with-grid').innerHTML = '<div style="grid-column:1/-1;color:var(--ink-faint);font-size:12px">Pairings unavailable.</div>';
+        });
+      })();
+      </script>
+
       <!-- Tools (admin / preview) -->
       <div class="tool-group" style="margin-top:18px;display:flex;flex-wrap:wrap;gap:10px">
         <button type="button" class="btn-outline" id="btn-room"        data-id="${design.id}">See in a Room</button>
@@ -3147,6 +3251,35 @@ ${HAMBURGER_JS}
 </html>`);
 });
 
+// ── Pairings: 3 nearest-palette neighbors in CIE Lab space.
+app.get('/api/design/:id/pairings', (req, res) => {
+  try {
+    const id = parseInt(req.params.id, 10);
+    const target = psqlQuery(`SELECT dominant_hex FROM spoon_all_designs WHERE id=${id};`);
+    if (!target || !/^#[0-9a-f]{6}$/i.test(target)) return res.json({ designs: [] });
+    // Compute in JS — straightforward Lab Euclidean
+    const all = JSON.parse(psqlQuery(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (SELECT id, dominant_hex, local_path FROM spoon_all_designs WHERE id <> ${id} AND dominant_hex IS NOT NULL AND generator='replicate') t;`) || '[]');
+    function hexToRgb(h){h=h.replace('#','');return[parseInt(h.slice(0,2),16),parseInt(h.slice(2,4),16),parseInt(h.slice(4,6),16)];}
+    function srgbToLinear(c){c/=255;return c<=0.04045?c/12.92:Math.pow((c+0.055)/1.055,2.4);}
+    function rgbToLab(rgb){const[r,g,b]=rgb.map(srgbToLinear);
+      const x=r*0.4124+g*0.3576+b*0.1805, y=r*0.2126+g*0.7152+b*0.0722, z=r*0.0193+g*0.1192+b*0.9505;
+      const xn=0.95047,yn=1,zn=1.08883;
+      const f=t=>t>0.008856?Math.cbrt(t):(7.787*t+16/116);
+      return[116*f(y/yn)-16, 500*(f(x/xn)-f(y/yn)), 200*(f(y/yn)-f(z/zn))];
+    }
+    const tLab = rgbToLab(hexToRgb(target));
+    const scored = all.map(p => {
+      const pLab = rgbToLab(hexToRgb(p.dominant_hex));
+      const d = Math.sqrt((tLab[0]-pLab[0])**2+(tLab[1]-pLab[1])**2+(tLab[2]-pLab[2])**2);
+      return { id: p.id, dominant_hex: p.dominant_hex, distance: d,
+        image_url: '/designs/img/' + (p.local_path||'').split('/').pop() };
+    }).sort((a,b)=>a.distance-b.distance).slice(0,3);
+    res.json({ designs: scored });
+  } catch (e) {
+    res.status(500).json({ error: e.message });
+  }
+});
+
 // ── HEALTH
 app.get('/health', (_req, res) => {
   res.json({ ok: true, site: SITE, count: DESIGNS.length, port: PORT });

← 6debe83 Shopify: bump all wallco/SP scripts to API 2026-01 · add rea  ·  back to Wallco Ai  ·  Add keyboard shortcuts to /designs (K/R/P/arrows/1-9/?) + np 5e65545 →