← 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
M data/chip-chats.jsonM data/moodboards.jsonM data/reviews.jsonM server.js
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, '"').replace(/</g,'<'); }
+ // ── 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 →