← back to Sister Parish Onboarding
tick 7: SP viewer × wallco PD bridge — 'PD inspirations →' button on every SP card opens modal with 3 nearest Lab-color PD matches; image proxy /pd-img/<source>/<file> reads from wallco-ai/data/images
2570b3026d25b478c547aadb177e70dae1fe85fc · 2026-05-11 17:47:39 -0700 · Steve
Files touched
Diff
commit 2570b3026d25b478c547aadb177e70dae1fe85fc
Author: Steve <steve@designerwallcoverings.com>
Date: Mon May 11 17:47:39 2026 -0700
tick 7: SP viewer × wallco PD bridge — 'PD inspirations →' button on every SP card opens modal with 3 nearest Lab-color PD matches; image proxy /pd-img/<source>/<file> reads from wallco-ai/data/images
---
server.js | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
1 file changed, 83 insertions(+)
diff --git a/server.js b/server.js
index 7c30c28..06af2a8 100644
--- a/server.js
+++ b/server.js
@@ -8,6 +8,7 @@
*/
require('dotenv').config();
const express = require('express');
+const path = require('path');
const { execSync } = require('child_process');
const app = express();
@@ -17,6 +18,10 @@ function psqlJson(sql) {
return execSync(`psql dw_unified -At -q`, { input: sql, encoding: 'utf8' }).trim();
}
+// Proxy PD-corpus images from wallco-ai's data dir so the SP viewer can render them
+const WALLCO_IMG_DIR = path.join(process.env.HOME, 'Projects', 'wallco-ai', 'data', 'images');
+app.use('/pd-img', express.static(WALLCO_IMG_DIR, { maxAge: '7d' }));
+
app.get('/api/products', (_req, res) => {
try {
const raw = psqlJson(`SELECT json_agg(t) FROM (SELECT sp_product_id, handle, title, primary_image, tags, variants->0->>'vendor_retail' AS vendor_retail, variants->0->>'dw_retail' AS dw_retail, specs, dominant_hex, colors, shopify_product_id, shopify_handle, source_url FROM sisterparish_catalog ORDER BY title) t;`);
@@ -26,6 +31,20 @@ app.get('/api/products', (_req, res) => {
}
});
+// PD inspirations for one SP product — 3 nearest in Lab color space
+app.get('/api/sp-pd-matches/:sp_id', (req, res) => {
+ try {
+ const id = parseInt(req.params.sp_id, 10);
+ if (!id) return res.status(400).json({ error: 'bad_id' });
+ const raw = psqlJson(`SELECT COALESCE(json_agg(t),'[]'::json) FROM (
+ SELECT pd_id, pd_source, pd_title, pd_hex, pd_source_url, pd_local_path, rank, lab_distance
+ FROM sp_pd_color_matches WHERE sp_product_id=${id} ORDER BY rank) t;`);
+ res.type('application/json').send(raw || '[]');
+ } catch (e) {
+ res.status(500).type('application/json').send(JSON.stringify({ error: e.message }));
+ }
+});
+
app.get('/', (_req, res) => {
res.type('html').send(`<!doctype html>
<meta charset="utf-8">
@@ -54,6 +73,22 @@ app.get('/', (_req, res) => {
.badge { display:inline-block; padding:1px 6px; font-size:10px; background:#e6f0e9; color:#2d6440; border-radius:2px; margin-left:6px; }
.badge.draft { background:#fdf2dd; color:#7a5a13; }
.dot { display:inline-block; width:10px; height:10px; border-radius:50%; border:1px solid #00000020; vertical-align:middle; margin-right:5px; }
+ .pd-btn { margin-top:8px; font-size:10px; background:transparent; border:1px solid #d8d2ca; color:#666; padding:4px 8px; border-radius:2px; cursor:pointer; letter-spacing:.04em; text-transform:uppercase; }
+ .pd-btn:hover { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
+ #pd-modal { position:fixed; inset:0; z-index:9999; background:rgba(0,0,0,.6); display:none; align-items:center; justify-content:center; }
+ #pd-modal.is-open { display:flex; }
+ #pd-modal .pd-card { background:#fff; border-radius:6px; max-width:880px; width:92vw; max-height:85vh; overflow-y:auto; padding:28px 32px; }
+ #pd-modal h2 { margin:0 0 4px; font-weight:300; font-size:18px; }
+ #pd-modal .pd-sub { font-size:11px; color:#888; margin-bottom:18px; letter-spacing:.08em; text-transform:uppercase; }
+ #pd-modal .pd-grid { display:grid; grid-template-columns:repeat(3, 1fr); gap:16px; }
+ #pd-modal .pd-item { border:1px solid #e6e0d8; border-radius:4px; overflow:hidden; }
+ #pd-modal .pd-item img { width:100%; aspect-ratio:1; object-fit:cover; background:#f5f0e8; }
+ #pd-modal .pd-meta { padding:10px 12px; font-size:11px; }
+ #pd-modal .pd-meta .pd-title { font-weight:500; margin-bottom:3px; color:#1a1a1a; }
+ #pd-modal .pd-meta .pd-src { color:#888; text-transform:uppercase; letter-spacing:.06em; font-size:9px; }
+ #pd-modal .pd-meta .pd-dist { color:#666; margin-top:4px; }
+ #pd-modal .pd-close { background:transparent; border:0; color:#888; font-size:24px; cursor:pointer; float:right; line-height:1; padding:0; }
+ #pd-modal .pd-empty { padding:32px; text-align:center; color:#888; }
.empty { padding:48px; text-align:center; color:#999; }
</style>
<header>
@@ -78,6 +113,14 @@ app.get('/', (_req, res) => {
</div>
</header>
<main><div class="grid" id="grid"></div></main>
+<div id="pd-modal" role="dialog" aria-hidden="true">
+ <div class="pd-card">
+ <button class="pd-close" aria-label="Close">×</button>
+ <h2 id="pd-title">PD inspirations</h2>
+ <div class="pd-sub">Closest public-domain patterns by CIE Lab color · Met / Wikimedia / Smithsonian</div>
+ <div class="pd-grid" id="pd-grid"></div>
+ </div>
+</div>
<script>
const sortSel = document.getElementById('sort');
const dens = document.getElementById('density');
@@ -158,9 +201,12 @@ function render() {
<div class="title">\${dot}\${p.title}\${badge}</div>
<div class="price">$\${(+p.dw_retail).toFixed(2)} <span class="ca">$\${(+p.vendor_retail).toFixed(2)}</span></div>
<div class="specs">\${specBits}</div>
+ <button class="pd-btn" data-sp-id="\${p.sp_product_id}" data-title="\${(p.title||'').replace(/"/g, '"')}">PD inspirations →</button>
</div>
</div>\`;
}).join('') || '<div class="empty">No products yet — run scripts/load_pg.sh</div>';
+ // Wire PD-inspiration buttons
+ grid.querySelectorAll('.pd-btn').forEach(b => b.addEventListener('click', () => showPdModal(b.dataset.spId, b.dataset.title)));
}
sortSel.addEventListener('change', () => { localStorage.setItem('sp.sort', sortSel.value); render(); });
@@ -170,6 +216,43 @@ dens.addEventListener('input', () => {
});
fetch('/api/products').then(r=>r.json()).then(d => { products = d || []; render(); });
+
+// PD inspirations modal
+const pdModal = document.getElementById('pd-modal');
+const pdGrid = document.getElementById('pd-grid');
+const pdTitleEl = document.getElementById('pd-title');
+pdModal.querySelector('.pd-close').addEventListener('click', () => pdModal.classList.remove('is-open'));
+pdModal.addEventListener('click', e => { if (e.target === pdModal) pdModal.classList.remove('is-open'); });
+document.addEventListener('keydown', e => { if (e.key === 'Escape') pdModal.classList.remove('is-open'); });
+
+async function showPdModal(spId, title) {
+ pdTitleEl.textContent = title + ' — PD inspirations';
+ pdGrid.innerHTML = '<div class="pd-empty">Loading…</div>';
+ pdModal.classList.add('is-open');
+ try {
+ const r = await fetch('/api/sp-pd-matches/' + spId);
+ const matches = await r.json();
+ if (!matches.length) { pdGrid.innerHTML = '<div class="pd-empty">No matches found.</div>'; return; }
+ pdGrid.innerHTML = matches.map(m => {
+ // Build proxied PD image URL: /pd-img/<source>/<basename>
+ const fname = (m.pd_local_path || '').split('/').pop();
+ const imgPath = fname ? '/pd-img/' + m.pd_source + '/' + fname : '';
+ return \`
+ <div class="pd-item">
+ <a href="\${m.pd_source_url}" target="_blank">
+ <img src="\${imgPath}" alt="\${m.pd_title}">
+ </a>
+ <div class="pd-meta">
+ <div class="pd-title">\${(m.pd_title || '').slice(0,60)}</div>
+ <div class="pd-src">\${m.pd_source} · \${m.pd_hex}</div>
+ <div class="pd-dist">Lab dist \${parseFloat(m.lab_distance).toFixed(1)}</div>
+ </div>
+ </div>\`;
+ }).join('');
+ } catch (e) {
+ pdGrid.innerHTML = '<div class="pd-empty">Error: ' + e.message + '</div>';
+ }
+}
</script>`);
});
← 134a484 SP color v2: center-crop 80% before palette extraction — nea
·
back to Sister Parish Onboarding
·
Shopify: bump to API 2026-01 52bd57d →