[object Object]

← 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

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 &amp; 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+      .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+  }
+
+  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 →