← back to Dw Pairs Well
Design Coordinate v2: palette + SW/DE paint match per product+pair
72fbd05de732f28d1a0b27fbaddfbe6625d103e6 · 2026-05-13 09:07:22 -0700 · Steve Abrams
Files touched
A lib/color.jsA lib/paint.jsA public/preview.htmlM server.js
Diff
commit 72fbd05de732f28d1a0b27fbaddfbe6625d103e6
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Wed May 13 09:07:22 2026 -0700
Design Coordinate v2: palette + SW/DE paint match per product+pair
---
lib/color.js | 104 ++++++++++++++++++
lib/paint.js | 47 ++++++++
public/preview.html | 301 ++++++++++++++++++++++++++++++++++++++++++++++++++++
server.js | 115 +++++++++++++++++---
4 files changed, 550 insertions(+), 17 deletions(-)
diff --git a/lib/color.js b/lib/color.js
new file mode 100644
index 0000000..0820146
--- /dev/null
+++ b/lib/color.js
@@ -0,0 +1,104 @@
+// hex <-> CIE Lab + ΔE2000
+
+function hexToRgb(hex) {
+ if (!hex) return null;
+ var h = String(hex).trim().replace(/^#/, '');
+ if (h.length === 3) h = h.split('').map(function (c) { return c + c; }).join('');
+ if (!/^[0-9a-fA-F]{6}$/.test(h)) return null;
+ return {
+ r: parseInt(h.slice(0, 2), 16),
+ g: parseInt(h.slice(2, 4), 16),
+ b: parseInt(h.slice(4, 6), 16)
+ };
+}
+
+function rgbToXyz(rgb) {
+ function f(c) {
+ c = c / 255;
+ return c > 0.04045 ? Math.pow((c + 0.055) / 1.055, 2.4) : c / 12.92;
+ }
+ var r = f(rgb.r), g = f(rgb.g), b = f(rgb.b);
+ return {
+ x: (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) * 100,
+ y: (r * 0.2126729 + g * 0.7151522 + b * 0.0721750) * 100,
+ z: (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) * 100
+ };
+}
+
+function xyzToLab(xyz) {
+ // D65 reference white
+ var Xn = 95.0489, Yn = 100.0, Zn = 108.8840;
+ function f(t) { return t > 0.008856 ? Math.cbrt(t) : (7.787 * t) + (16 / 116); }
+ var fx = f(xyz.x / Xn), fy = f(xyz.y / Yn), fz = f(xyz.z / Zn);
+ return { L: (116 * fy) - 16, a: 500 * (fx - fy), b: 200 * (fy - fz) };
+}
+
+function hexToLab(hex) {
+ var rgb = hexToRgb(hex);
+ if (!rgb) return null;
+ return xyzToLab(rgbToXyz(rgb));
+}
+
+// ΔE2000 — perceptual color distance. Lower = more similar.
+function deltaE2000(lab1, lab2) {
+ if (!lab1 || !lab2) return Infinity;
+ var L1 = lab1.L, a1 = lab1.a, b1 = lab1.b;
+ var L2 = lab2.L, a2 = lab2.a, b2 = lab2.b;
+ var avgL = (L1 + L2) / 2;
+ var C1 = Math.sqrt(a1 * a1 + b1 * b1);
+ var C2 = Math.sqrt(a2 * a2 + b2 * b2);
+ var avgC = (C1 + C2) / 2;
+ var G = 0.5 * (1 - Math.sqrt(Math.pow(avgC, 7) / (Math.pow(avgC, 7) + Math.pow(25, 7))));
+ var a1p = (1 + G) * a1, a2p = (1 + G) * a2;
+ var C1p = Math.sqrt(a1p * a1p + b1 * b1), C2p = Math.sqrt(a2p * a2p + b2 * b2);
+ var avgCp = (C1p + C2p) / 2;
+ function hp(ap, bp) {
+ if (ap === 0 && bp === 0) return 0;
+ var h = Math.atan2(bp, ap) * 180 / Math.PI;
+ return h >= 0 ? h : h + 360;
+ }
+ var h1p = hp(a1p, b1), h2p = hp(a2p, b2);
+ var dhp;
+ if (C1p * C2p === 0) dhp = 0;
+ else {
+ dhp = h2p - h1p;
+ if (dhp > 180) dhp -= 360;
+ if (dhp < -180) dhp += 360;
+ }
+ var dLp = L2 - L1, dCp = C2p - C1p;
+ var dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * Math.PI / 180);
+ var avgHp;
+ if (C1p * C2p === 0) avgHp = h1p + h2p;
+ else if (Math.abs(h1p - h2p) <= 180) avgHp = (h1p + h2p) / 2;
+ else if (h1p + h2p < 360) avgHp = (h1p + h2p + 360) / 2;
+ else avgHp = (h1p + h2p - 360) / 2;
+ var T = 1
+ - 0.17 * Math.cos((avgHp - 30) * Math.PI / 180)
+ + 0.24 * Math.cos((2 * avgHp) * Math.PI / 180)
+ + 0.32 * Math.cos((3 * avgHp + 6) * Math.PI / 180)
+ - 0.20 * Math.cos((4 * avgHp - 63) * Math.PI / 180);
+ var dTheta = 30 * Math.exp(-Math.pow((avgHp - 275) / 25, 2));
+ var Rc = 2 * Math.sqrt(Math.pow(avgCp, 7) / (Math.pow(avgCp, 7) + Math.pow(25, 7)));
+ var Sl = 1 + ((0.015 * Math.pow(avgL - 50, 2)) / Math.sqrt(20 + Math.pow(avgL - 50, 2)));
+ var Sc = 1 + 0.045 * avgCp;
+ var Sh = 1 + 0.015 * avgCp * T;
+ var Rt = -Math.sin(2 * dTheta * Math.PI / 180) * Rc;
+ return Math.sqrt(
+ Math.pow(dLp / Sl, 2) +
+ Math.pow(dCp / Sc, 2) +
+ Math.pow(dHp / Sh, 2) +
+ Rt * (dCp / Sc) * (dHp / Sh)
+ );
+}
+
+function safeParseHexArray(value) {
+ if (!value) return [];
+ if (Array.isArray(value)) return value.filter(Boolean);
+ try {
+ var parsed = JSON.parse(value);
+ if (Array.isArray(parsed)) return parsed.filter(Boolean);
+ } catch (_) { /* not JSON */ }
+ return [];
+}
+
+module.exports = { hexToRgb, hexToLab, deltaE2000, safeParseHexArray };
diff --git a/lib/paint.js b/lib/paint.js
new file mode 100644
index 0000000..3f23d56
--- /dev/null
+++ b/lib/paint.js
@@ -0,0 +1,47 @@
+// Nearest Sherwin-Williams + Dunn-Edwards paint match for any hex.
+// Reads from color_reference (already populated with hex + Lab).
+
+const { hexToLab } = require('./color');
+
+const BRANDS = ['sherwin-williams', 'dunn-edwards'];
+const cache = new Map(); // hex -> { sherwin-williams: {...}, dunn-edwards: {...} }
+
+async function nearestPaints(pool, hex) {
+ if (!hex) return null;
+ var key = String(hex).toLowerCase();
+ if (cache.has(key)) return cache.get(key);
+
+ var lab = hexToLab(hex);
+ if (!lab) return null;
+
+ var sql = `
+ SELECT DISTINCT ON (brand) brand, code, name, hex,
+ ((lab_l - $1) * (lab_l - $1)
+ + (lab_a - $2) * (lab_a - $2)
+ + (lab_b - $3) * (lab_b - $3)) AS dist_sq
+ FROM color_reference
+ WHERE brand = ANY($4)
+ ORDER BY brand, dist_sq
+ `;
+ var r = await pool.query(sql, [lab.L, lab.a, lab.b, BRANDS]);
+ var out = {};
+ for (var i = 0; i < r.rows.length; i++) {
+ var row = r.rows[i];
+ out[row.brand] = { code: row.code, name: row.name, hex: row.hex };
+ }
+ cache.set(key, out);
+ return out;
+}
+
+// Bulk variant — pass an array of hexes, get back map hex -> paints
+async function nearestPaintsBatch(pool, hexes) {
+ var uniq = Array.from(new Set((hexes || []).filter(Boolean).map(function (h) { return String(h).toLowerCase(); })));
+ var result = {};
+ for (var i = 0; i < uniq.length; i++) {
+ var h = uniq[i];
+ result[h] = await nearestPaints(pool, h);
+ }
+ return result;
+}
+
+module.exports = { nearestPaints, nearestPaintsBatch, BRANDS };
diff --git a/public/preview.html b/public/preview.html
new file mode 100644
index 0000000..1156e78
--- /dev/null
+++ b/public/preview.html
@@ -0,0 +1,301 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Design Coordinate — local preview</title>
+<style>
+ :root { --gutter: 40px; --text: #1a1a1a; --muted: #777; --line: #e7e7e7; }
+ * { box-sizing: border-box; }
+ body {
+ margin: 0; color: var(--text); background: #fafafa;
+ font: 16px/1.45 -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
+ }
+ header.shop {
+ border-bottom: 1px solid var(--line); background: #fff;
+ padding: 14px var(--gutter); display: flex; justify-content: space-between; align-items: center;
+ }
+ header.shop .logo { font-weight: 600; letter-spacing: 0.04em; }
+ header.shop .preview-tag {
+ font-size: 11px; color: #b54708; background: #fff5d6;
+ padding: 4px 10px; border-radius: 999px; letter-spacing: 0.06em; text-transform: uppercase;
+ }
+ main { max-width: 1280px; margin: 0 auto; padding: 32px var(--gutter); }
+
+ .source-picker {
+ background: #fff; border: 1px solid var(--line); border-radius: 6px;
+ padding: 14px 18px; margin-bottom: 24px;
+ display: flex; gap: 10px; align-items: center; flex-wrap: wrap;
+ }
+ .source-picker label { font-size: 12px; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; }
+ .source-picker input { font: inherit; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; min-width: 180px; }
+ .source-picker button { font: inherit; padding: 8px 14px; background: #1a1a1a; color: #fff; border: 0; border-radius: 4px; cursor: pointer; }
+ .source-picker .quick { display: flex; gap: 6px; flex-wrap: wrap; }
+ .source-picker .quick button { background: #fff; color: #333; border: 1px solid var(--line); padding: 6px 10px; font-size: 12px; }
+ .source-picker .quick button:hover { background: #f4ede4; border-color: #c9b48a; }
+
+ .product {
+ display: grid; gap: 36px; grid-template-columns: 1.2fr 0.8fr;
+ background: #fff; border: 1px solid var(--line); border-radius: 6px;
+ padding: 32px; margin-bottom: 24px;
+ }
+ @media (max-width: 900px) { .product { grid-template-columns: 1fr; gap: 24px; } }
+ .product .photo { background: #f4f4f4; border-radius: 4px; overflow: hidden; aspect-ratio: 1/1; }
+ .product .photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .product .meta h1 { margin: 0 0 6px; font-size: 24px; font-weight: 500; line-height: 1.2; }
+ .product .meta .vendor { color: var(--muted); font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 20px; }
+ .product .meta .price { font-size: 20px; margin-bottom: 18px; }
+ .product .meta .add { display: block; width: 100%; padding: 14px; background: #111; color: #fff; border: 0; font-size: 12px; letter-spacing: 0.12em; text-transform: uppercase; cursor: pointer; border-radius: 4px; margin-bottom: 22px; }
+
+ /* Source-palette + paint table */
+ .src-palette { margin-top: 6px; }
+ .src-palette h4 { margin: 0 0 8px; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
+ .src-palette table { border-collapse: collapse; width: 100%; font-size: 12px; }
+ .src-palette th, .src-palette td { text-align: left; padding: 6px 8px; border-bottom: 1px solid #f0ebe2; }
+ .src-palette th { font-weight: 500; color: var(--muted); font-size: 11px; letter-spacing: 0.06em; text-transform: uppercase; }
+ .src-palette td.swatch { width: 28px; }
+ .swatch-dot { display: inline-block; width: 20px; height: 20px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.12); vertical-align: middle; }
+ .swatch-square { display: inline-block; width: 14px; height: 14px; border-radius: 3px; border: 1px solid rgba(0,0,0,0.12); vertical-align: middle; margin-right: 4px; }
+ .paint-code { color: var(--muted); font-variant-numeric: tabular-nums; font-size: 11px; }
+
+ /* === Design Coordinate block — EXACT copy of what's in product.liquid === */
+ #pairs-well-with { font-family: inherit; }
+ .pairs-well__cta {
+ display: flex; align-items: center; justify-content: space-between;
+ width: 100%; padding: 18px 24px; margin: 0;
+ background: #111; color: #fff;
+ border: 0; border-radius: 999px;
+ font-size: 15px; font-weight: 500; letter-spacing: 0.06em;
+ text-transform: uppercase; cursor: pointer;
+ transition: background 160ms ease, transform 120ms ease;
+ }
+ .pairs-well__cta:hover { background: #2a2a2a; }
+ .pairs-well__cta[aria-expanded="true"] .pairs-well__cta-icon { transform: rotate(45deg); }
+ .pairs-well__cta-icon { display: inline-block; font-size: 22px; line-height: 1; transition: transform 200ms ease; }
+ .pairs-well__grid {
+ display: grid; gap: 18px; margin-top: 18px;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ @media (max-width: 1100px) { .pairs-well__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+ @media (max-width: 600px) { .pairs-well__grid { grid-template-columns: 1fr; } }
+ .pairs-well__card {
+ display: block; text-decoration: none; color: inherit;
+ background: #fff; border: 1px solid #e7e7e7; border-radius: 6px;
+ overflow: hidden; transition: box-shadow 160ms ease, transform 160ms ease;
+ }
+ .pairs-well__card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); transform: translateY(-2px); }
+ .pairs-well__img-wrap { aspect-ratio: 1 / 1; background: #f4f4f4; overflow: hidden; }
+ .pairs-well__img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .pairs-well__meta { padding: 12px 14px 14px; }
+ .pairs-well__title {
+ margin: 0 0 4px; font-size: 14px; font-weight: 600; line-height: 1.3;
+ display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
+ }
+ .pairs-well__vendor {
+ margin: 0 0 8px; font-size: 11px; color: #777; letter-spacing: 0.04em; text-transform: uppercase;
+ }
+ .pairs-well__why {
+ display: inline-block; padding: 3px 8px; background: #f4ede4;
+ color: #5a4628; border-radius: 999px; font-size: 11px; line-height: 1.4;
+ margin-bottom: 10px;
+ }
+ /* palette + paints inside card */
+ .pairs-well__palette { display: flex; gap: 4px; margin: 0 0 10px; flex-wrap: wrap; }
+ .pairs-well__palette .swatch-dot { width: 14px; height: 14px; }
+ .pairs-well__paints {
+ border-top: 1px dashed #ebe4d8; margin-top: 6px; padding-top: 8px;
+ font-size: 11px; line-height: 1.5; color: #444;
+ }
+ .pairs-well__paints .label { color: #888; font-weight: 600; margin-right: 4px; letter-spacing: 0.04em; }
+ .pairs-well__paints .row { display: block; }
+
+ .pairs-well__skeleton {
+ background: linear-gradient(90deg, #eee 0%, #f6f6f6 50%, #eee 100%);
+ background-size: 200% 100%; animation: pwShim 1.2s linear infinite;
+ aspect-ratio: 1 / 1; border-radius: 6px;
+ }
+ @keyframes pwShim { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
+ .pairs-well__empty { padding: 18px; color: #777; font-size: 14px; }
+</style>
+</head>
+<body>
+<header class="shop">
+ <div class="logo">DESIGNER WALLCOVERINGS</div>
+ <div class="preview-tag">Local preview · service :9813</div>
+</header>
+
+<main>
+ <div class="source-picker">
+ <label for="sku">Source SKU</label>
+ <input id="sku" type="text" value="DWRW-74991" autocomplete="off">
+ <button id="load">Load</button>
+ <div class="quick">
+ <button data-sku="DWRW-74991">DWRW-74991 (Rebel Walls mural)</button>
+ <button data-sku="DWPT-200315">DWPT-200315 (1838 Floribunda Midnight)</button>
+ <button data-sku="DWPT-200312">DWPT-200312 (1838 Lavender Dream)</button>
+ <button data-sku="DWWC-508630">DWWC-508630 (Phillipe Romano Bongol)</button>
+ </div>
+ </div>
+
+ <article class="product">
+ <div class="photo">
+ <img id="product-photo" alt="" src="">
+ </div>
+ <div class="meta">
+ <h1 id="product-title">—</h1>
+ <p class="vendor" id="product-vendor">—</p>
+ <p class="price" id="product-price">From $4.25 / sample</p>
+ <button class="add">Add Sample to Cart</button>
+
+ <div class="src-palette" id="src-palette" hidden>
+ <h4>Color story — closest Sherwin-Williams & Dunn-Edwards paints</h4>
+ <table>
+ <thead><tr><th></th><th>Hex</th><th>Sherwin-Williams</th><th>Dunn-Edwards</th></tr></thead>
+ <tbody id="src-palette-body"></tbody>
+ </table>
+ </div>
+ </div>
+ </article>
+
+ <section id="pairs-well-with"
+ data-pairs-endpoint="http://127.0.0.1:9813/api/pairs"
+ data-product-handle=""
+ data-product-sku="DWRW-74991">
+ <button type="button" class="pairs-well__cta" aria-expanded="false" aria-controls="pairs-well-grid">
+ <span class="pairs-well__cta-text">Design Coordinate</span>
+ <span class="pairs-well__cta-icon" aria-hidden="true">+</span>
+ </button>
+ <div id="pairs-well-grid" class="pairs-well__grid" aria-live="polite" hidden></div>
+ </section>
+</main>
+
+<script>
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ var picker = document.getElementById('sku');
+ var loadBtn = document.getElementById('load');
+ var photoEl = document.getElementById('product-photo');
+ var titleEl = document.getElementById('product-title');
+ var vendorEl = document.getElementById('product-vendor');
+ var quickBtns = document.querySelectorAll('.quick button');
+ var section = document.getElementById('pairs-well-with');
+ var grid = section.querySelector('.pairs-well__grid');
+ var cta = section.querySelector('.pairs-well__cta');
+ var srcPalette = document.getElementById('src-palette');
+ var srcPaletteBody = document.getElementById('src-palette-body');
+ var endpoint = section.getAttribute('data-pairs-endpoint');
+
+ function renderSourcePalette(palette) {
+ if (!palette || !palette.length) { srcPalette.hidden = true; return; }
+ srcPaletteBody.innerHTML = palette.map(function (c) {
+ var sw = c.paints && c.paints.sw;
+ var de = c.paints && c.paints.de;
+ return '<tr>'
+ + '<td class="swatch"><span class="swatch-dot" style="background:' + esc(c.hex) + '"></span></td>'
+ + '<td><span class="paint-code">' + esc(c.hex) + '</span></td>'
+ + '<td>' + (sw
+ ? '<span class="swatch-square" style="background:' + esc(sw.hex) + '"></span>' + esc(sw.name) + ' <span class="paint-code">' + esc(sw.code) + '</span>'
+ : '—') + '</td>'
+ + '<td>' + (de
+ ? '<span class="swatch-square" style="background:' + esc(de.hex) + '"></span>' + esc(de.name) + ' <span class="paint-code">' + esc(de.code) + '</span>'
+ : '—') + '</td>'
+ + '</tr>';
+ }).join('');
+ srcPalette.hidden = false;
+ }
+
+ function renderCards(pairs) {
+ if (!pairs || !pairs.length) {
+ grid.innerHTML = '<div class="pairs-well__empty">No design coordinates found yet for this pattern.</div>';
+ return;
+ }
+ grid.innerHTML = pairs.map(function (p) {
+ var img = p.image_url ? esc(p.image_url) : '';
+ var why = (p.why && p.why[0]) ? esc(p.why[0]) : '';
+ var palette = (p.palette || []).map(function (c) {
+ var sw = c.paints && c.paints.sw, de = c.paints && c.paints.de;
+ var tip = c.hex + (sw ? ' · SW ' + sw.name + ' ' + sw.code : '') + (de ? ' · DE ' + de.name + ' ' + de.code : '');
+ return '<span class="swatch-dot" style="background:' + esc(c.hex) + '" title="' + esc(tip) + '"></span>';
+ }).join('');
+ var dom = (p.palette && p.palette[0]) ? p.palette[0] : null;
+ var paintsBlock = '';
+ if (dom && dom.paints) {
+ var sw = dom.paints.sw, de = dom.paints.de;
+ paintsBlock = '<div class="pairs-well__paints">'
+ + (sw ? '<span class="row"><span class="label">SW</span><span class="swatch-square" style="background:' + esc(sw.hex) + '"></span>' + esc(sw.name) + ' <span class="paint-code">' + esc(sw.code) + '</span></span>' : '')
+ + (de ? '<span class="row"><span class="label">DE</span><span class="swatch-square" style="background:' + esc(de.hex) + '"></span>' + esc(de.name) + ' <span class="paint-code">' + esc(de.code) + '</span></span>' : '')
+ + '</div>';
+ }
+ return '<a class="pairs-well__card" href="' + esc(p.url || ('#' + p.handle)) + '" title="' + esc((p.why || []).join(' · ')) + '">'
+ + '<div class="pairs-well__img-wrap">'
+ + (img ? '<img class="pairs-well__img" src="' + img + '" alt="' + esc(p.title) + '" loading="lazy">' : '')
+ + '</div>'
+ + '<div class="pairs-well__meta">'
+ + '<p class="pairs-well__title">' + esc(p.title || '') + '</p>'
+ + '<p class="pairs-well__vendor">' + esc(p.vendor || '') + '</p>'
+ + (why ? '<span class="pairs-well__why">' + why + '</span>' : '')
+ + (palette ? '<div class="pairs-well__palette">' + palette + '</div>' : '')
+ + paintsBlock
+ + '</div>'
+ + '</a>';
+ }).join('');
+ }
+
+ async function loadProduct(sku) {
+ picker.value = sku;
+ section.setAttribute('data-product-sku', sku);
+ cta.setAttribute('aria-expanded', 'false');
+ grid.hidden = true;
+ grid.innerHTML = '';
+ srcPalette.hidden = true;
+
+ try {
+ var r = await fetch(endpoint + '?dw_sku=' + encodeURIComponent(sku));
+ var d = await r.json();
+ if (d && d.source) {
+ photoEl.src = d.source.image_url || '';
+ photoEl.alt = d.source.title || '';
+ titleEl.textContent = d.source.title || '—';
+ vendorEl.textContent = d.source.vendor || '—';
+ renderSourcePalette(d.source.palette || []);
+ section._pendingPairs = d.pairs || [];
+ section._lastSku = sku;
+ } else {
+ titleEl.textContent = 'Not found: ' + sku;
+ vendorEl.textContent = '';
+ photoEl.removeAttribute('src');
+ }
+ } catch (e) {
+ titleEl.textContent = 'Service unreachable on :9813';
+ vendorEl.textContent = '';
+ }
+ }
+
+ loadBtn.addEventListener('click', function () { loadProduct(picker.value.trim()); });
+ picker.addEventListener('keydown', function (e) { if (e.key === 'Enter') loadProduct(picker.value.trim()); });
+ quickBtns.forEach(function (b) {
+ b.addEventListener('click', function () { loadProduct(b.getAttribute('data-sku')); });
+ });
+
+ cta.addEventListener('click', function () {
+ var open = cta.getAttribute('aria-expanded') === 'true';
+ if (open) {
+ cta.setAttribute('aria-expanded', 'false');
+ grid.hidden = true;
+ return;
+ }
+ cta.setAttribute('aria-expanded', 'true');
+ grid.hidden = false;
+ if (section._pendingPairs) {
+ renderCards(section._pendingPairs);
+ }
+ });
+
+ loadProduct('DWRW-74991');
+</script>
+</body>
+</html>
diff --git a/server.js b/server.js
index da5d29d..a76c58d 100644
--- a/server.js
+++ b/server.js
@@ -3,6 +3,8 @@ const express = require('express');
const { Pool } = require('pg');
const path = require('path');
const { parseTags, classify, score } = require('./lib/classify');
+const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
+const { nearestPaintsBatch } = require('./lib/paint');
const PORT = Number(process.env.PORT || 9799);
const DATABASE_URL = process.env.DATABASE_URL;
@@ -44,18 +46,25 @@ app.get('/healthz', async (_req, res) => {
const SAFE_KEY = /^[A-Za-z0-9._\-]{1,128}$/;
+const SOURCE_COLS = `
+ sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
+ sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
+`;
+
async function loadSource({ dw_sku, handle }) {
let q, params;
if (dw_sku) {
- q = `SELECT dw_sku, handle, title, vendor, image_url, tags, pattern_name
- FROM shopify_products
- WHERE dw_sku = $1
+ q = `SELECT ${SOURCE_COLS}
+ FROM shopify_products sp
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE sp.dw_sku = $1
LIMIT 1`;
params = [dw_sku];
} else if (handle) {
- q = `SELECT dw_sku, handle, title, vendor, image_url, tags, pattern_name
- FROM shopify_products
- WHERE handle = $1
+ q = `SELECT ${SOURCE_COLS}
+ FROM shopify_products sp
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE sp.handle = $1
LIMIT 1`;
params = [handle];
} else {
@@ -87,27 +96,81 @@ async function loadCandidates(source, limit = 400) {
params.push(limit);
const q = `
- SELECT dw_sku, handle, title, vendor, image_url, tags, pattern_name
+ SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
+ sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
FROM shopify_products AS sp
- WHERE status = 'ACTIVE'
- AND image_url IS NOT NULL
- AND handle IS NOT NULL
- AND dw_sku IS NOT NULL
- AND dw_sku <> $1
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE sp.status = 'ACTIVE'
+ AND sp.image_url IS NOT NULL
+ AND sp.handle IS NOT NULL
+ AND sp.dw_sku IS NOT NULL
+ AND sp.dw_sku <> $1
AND ${whereExtras}
- ORDER BY synced_at DESC NULLS LAST
+ ORDER BY (sce.dominant_hex IS NOT NULL) DESC, sp.synced_at DESC NULLS LAST
LIMIT $${params.length}
`;
const r = await pool.query(q, params);
return r.rows;
}
+function paletteOf(row) {
+ // Build a clean hex list: dominant first, then bg, then the rest of hex_codes,
+ // de-duped (case-insensitive), capped at 5.
+ var out = [];
+ var seen = new Set();
+ function push(h) {
+ if (!h) return;
+ var key = String(h).toLowerCase();
+ if (seen.has(key)) return;
+ seen.add(key); out.push(h);
+ }
+ push(row.dominant_hex);
+ push(row.background_hex);
+ safeParseHexArray(row.hex_codes).forEach(push);
+ return out.slice(0, 5);
+}
+
+function hexScore(sourceHex, candHex) {
+ // 0–6 bonus based on perceptual color similarity of dominant hex.
+ // ΔE2000 ≤ 10 = essentially the same color
+ // ΔE2000 ≤ 20 = clearly related
+ // ΔE2000 ≤ 35 = same color family
+ // ΔE2000 > 50 = different colors
+ var sL = hexToLab(sourceHex), cL = hexToLab(candHex);
+ if (!sL || !cL) return { bonus: 0, dE: null };
+ var dE = deltaE2000(sL, cL);
+ var bonus;
+ if (dE <= 10) bonus = 6;
+ else if (dE <= 20) bonus = 4;
+ else if (dE <= 35) bonus = 2;
+ else if (dE <= 50) bonus = 0;
+ else bonus = -2;
+ return { bonus: bonus, dE: dE };
+}
+
function pickPairs(source, candidates, k = 6) {
const sCls = classify(parseTags(source.tags));
+ const sourcePalette = paletteOf(source);
+ const sourceDom = sourcePalette[0] || null;
+
const scored = candidates.map(c => {
const cCls = classify(parseTags(c.tags));
- const { score: s, why } = score(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
- return { ...c, _score: s, _why: why };
+ const tagScore = score(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
+ const candPalette = paletteOf(c);
+ const candDom = candPalette[0] || null;
+ const hex = hexScore(sourceDom, candDom);
+ const totalScore = tagScore.score + hex.bonus;
+
+ const why = tagScore.why.slice();
+ if (hex.dE != null && candDom) {
+ const tag = hex.dE <= 10 ? 'identical color tone'
+ : hex.dE <= 20 ? 'closely related color'
+ : hex.dE <= 35 ? 'same color family'
+ : 'distant tone';
+ why.unshift(`${tag} (ΔE ${hex.dE.toFixed(1)})`);
+ }
+
+ return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: hex.dE };
});
scored.sort((a, b) => b._score - a._score);
@@ -151,6 +214,21 @@ app.get('/api/pairs', async (req, res) => {
const candidates = await loadCandidates(source);
const top = pickPairs(source, candidates, 6);
+ // Collect every hex we need paint matches for: source palette + each pair palette
+ const sourcePalette = paletteOf(source);
+ const allHexes = sourcePalette.slice();
+ for (const p of top) for (const h of p._palette) allHexes.push(h);
+ const paintMap = await nearestPaintsBatch(pool, allHexes);
+
+ function paintsFor(hex) {
+ const m = paintMap[String(hex || '').toLowerCase()] || null;
+ if (!m) return null;
+ return { sw: m['sherwin-williams'] || null, de: m['dunn-edwards'] || null };
+ }
+ function paletteWithPaints(hexList) {
+ return hexList.map(h => ({ hex: h, paints: paintsFor(h) }));
+ }
+
res.set('Cache-Control', 'public, max-age=300');
res.json({
ok: true,
@@ -159,7 +237,8 @@ app.get('/api/pairs', async (req, res) => {
handle: source.handle,
title: source.title,
vendor: source.vendor,
- image_url: source.image_url
+ image_url: source.image_url,
+ palette: paletteWithPaints(sourcePalette)
},
pairs: top.map(p => ({
dw_sku: p.dw_sku,
@@ -169,7 +248,9 @@ app.get('/api/pairs', async (req, res) => {
image_url: p.image_url,
url: `/products/${p.handle}`,
score: p._score,
- why: p._why
+ why: p._why,
+ delta_e: p._dE,
+ palette: paletteWithPaints(p._palette)
}))
});
} catch (e) {
← 776b365 deploy bundle: kamatera-bootstrap.sh + .deploy.conf + README
·
back to Dw Pairs Well
·
Design Coordinate v3: stripe/plaid/check filter + palette-vs 95a5934 →