[object Object]

← back to Dw Pairs Well

v6: all 7 paint brands + paste-in snippet for Shopify theme

1d2c3f32bc3ae27abe3abafb0f362abc96de8be2 · 2026-05-13 09:43:46 -0700 · Steve Abrams

- /api/pairs response now includes sw, de, bm, behr, ppg, fb, ral matches per hex
- preview.html: vendor-picker chips (default SW+DE+BM+F&B), localStorage persist, re-renders grid + source-palette table on toggle
- color_reference query batches all 7 brands in one call
- deploy/shopify/design-coordinate.liquid: standalone paste-in snippet
- deploy/shopify/PASTE-INTO-THEME.md: 3-min manual install guide (current token lacks write_themes scope, same gap as existing read_customers)

Files touched

Diff

commit 1d2c3f32bc3ae27abe3abafb0f362abc96de8be2
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 09:43:46 2026 -0700

    v6: all 7 paint brands + paste-in snippet for Shopify theme
    
    - /api/pairs response now includes sw, de, bm, behr, ppg, fb, ral matches per hex
    - preview.html: vendor-picker chips (default SW+DE+BM+F&B), localStorage persist, re-renders grid + source-palette table on toggle
    - color_reference query batches all 7 brands in one call
    - deploy/shopify/design-coordinate.liquid: standalone paste-in snippet
    - deploy/shopify/PASTE-INTO-THEME.md: 3-min manual install guide (current token lacks write_themes scope, same gap as existing read_customers)
---
 deploy/shopify/PASTE-INTO-THEME.md      |  53 +++++++++
 deploy/shopify/design-coordinate.liquid | 186 ++++++++++++++++++++++++++++++++
 lib/paint.js                            |  16 ++-
 public/preview.html                     |  97 +++++++++++++----
 scripts/backfill-palette.js             | 122 +++++++++++++++++++++
 server.js                               |  11 +-
 6 files changed, 464 insertions(+), 21 deletions(-)

diff --git a/deploy/shopify/PASTE-INTO-THEME.md b/deploy/shopify/PASTE-INTO-THEME.md
new file mode 100644
index 0000000..36bbcef
--- /dev/null
+++ b/deploy/shopify/PASTE-INTO-THEME.md
@@ -0,0 +1,53 @@
+# Adding "Design Coordinate" to the live Shopify theme
+
+The dw-pairs-well service is live at `https://pairs.designerwallcoverings.com`. To make the button appear on every product page on `designerwallcoverings.com`, paste one self-contained snippet into the theme.
+
+The Shopify admin token currently in our `.env` does **not** have `write_themes` scope (same as the existing `read_customers` gap noted in `CLAUDE.md`). So this step needs to be done by hand via the Shopify admin UI — or by minting a token with theme scopes.
+
+## Option A — paste via Shopify admin (3 minutes)
+
+1. Open <https://admin.shopify.com/store/designer-laboratory-sandbox/themes>
+2. On the **live theme**, click `…` → `Edit code`
+3. Open `sections/product-template.liquid` (or wherever your AI color-key block currently lives — search the codebase for `ai-color-key-container`)
+4. **Paste the contents of `design-coordinate.liquid`** (in this folder) directly AFTER the line:
+   ```liquid
+   <div id="ai-color-key-container" style="margin-top: 1em; margin-bottom: 1em;"></div>
+   ```
+   and BEFORE the line:
+   ```liquid
+   <div class="product-recommendations-wrapper--right" ...
+   ```
+5. Click **Save**
+6. Open any product page — the black "Design Coordinate +" pill appears below the image.
+
+## Option B — automate via API (if you mint a token)
+
+Mint a custom app token with these scopes:
+- `read_themes`, `write_themes`
+
+Then:
+```bash
+TOKEN=shpat_xxxxx
+STORE=designer-laboratory-sandbox.myshopify.com
+
+# Find the active (main) theme id
+THEME_ID=$(curl -sS "https://${STORE}/admin/api/2024-10/themes.json" \
+  -H "X-Shopify-Access-Token: $TOKEN" \
+  | jq -r '.themes[] | select(.role=="main") | .id')
+
+# Fetch the current product template
+curl -sS "https://${STORE}/admin/api/2024-10/themes/${THEME_ID}/assets.json?asset[key]=sections/product-template.liquid" \
+  -H "X-Shopify-Access-Token: $TOKEN" \
+  | jq -r '.asset.value' > /tmp/current.liquid
+
+# Splice in our snippet at the right anchor, then PUT back
+# (manual splice or use a sed-based wrapper)
+```
+
+## Rollback
+
+If anything looks wrong on the live page, edit the same template and delete the snippet block (between the `{%- comment -%} Design Coordinate` marker and the closing `</section>` of `#pairs-well-with`). The page returns to its prior state in seconds.
+
+## Internal-only access (without touching the theme)
+
+The service also serves a full preview UI at <https://pairs.designerwallcoverings.com/preview.html> — no Shopify needed. Use that as the internal Design Coordinate tool today.
diff --git a/deploy/shopify/design-coordinate.liquid b/deploy/shopify/design-coordinate.liquid
new file mode 100644
index 0000000..c47df0e
--- /dev/null
+++ b/deploy/shopify/design-coordinate.liquid
@@ -0,0 +1,186 @@
+{%- comment -%}
+    Design Coordinate — inline expanding grid of 6 coordinating patterns.
+    Internal aliases: "Pairs Well With This Design" (concept), pairs-well-with
+    (skill), dw-pairs-well (service). Endpoint configurable via
+    data-pairs-endpoint. The pairs-well__* CSS class names are kept as the
+    internal namespace — only the button TEXT is "Design Coordinate".
+  {%- endcomment -%}
+  <section
+    id="pairs-well-with"
+    data-pairs-endpoint="https://pairs.designerwallcoverings.com/api/pairs"
+    data-product-handle="{{ product.handle }}"
+    data-product-sku="{{ product.metafields.custom.manufacturer_sku | default: product.selected_or_first_available_variant.sku | escape }}"
+    style="margin: 1.5em 0 2em; clear: both;"
+    hidden
+  >
+    <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>
+
+  <style>
+    /* Pairs Well — inline grid below product image */
+    #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:active { transform: scale(0.995); }
+    .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: 16px; margin-top: 18px;
+      grid-template-columns: repeat(3, minmax(0, 1fr));
+    }
+    @media (max-width: 900px)  { .pairs-well__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+    @media (max-width: 540px)  { .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: 12px; 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;
+    }
+    .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>
+
+  <script>
+    (function () {
+      var root = document.getElementById('pairs-well-with');
+      if (!root) return;
+
+      var endpoint = root.getAttribute('data-pairs-endpoint');
+      var handle   = root.getAttribute('data-product-handle');
+      var sku      = root.getAttribute('data-product-sku');
+      var btn      = root.querySelector('.pairs-well__cta');
+      var grid     = root.querySelector('.pairs-well__grid');
+      var loaded   = false;
+
+      // Reveal the section only if we have a usable identifier
+      if ((sku && sku.length) || (handle && handle.length)) {
+        root.hidden = false;
+      } else {
+        return;
+      }
+
+      function renderSkeletons(n) {
+        var html = '';
+        for (var i = 0; i < n; i++) html += '<div class="pairs-well__skeleton"></div>';
+        grid.innerHTML = html;
+      }
+
+      function esc(s) {
+        return String(s == null ? '' : s)
+          .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
+      }
+
+      function renderCards(pairs) {
+        if (!pairs || !pairs.length) {
+          grid.innerHTML = '<div class="pairs-well__empty">No stripe or plaid 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]) : '';
+          return ''
+            + '<a class="pairs-well__card" href="' + esc(p.url || ('/products/' + 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>' : '')
+            +   '</div>'
+            + '</a>';
+        }).join('');
+      }
+
+      function buildUrl() {
+        var qs = [];
+        if (sku)    qs.push('dw_sku=' + encodeURIComponent(sku));
+        if (handle) qs.push('handle=' + encodeURIComponent(handle));
+        return endpoint + '?' + qs.join('&');
+      }
+
+      function load() {
+        if (loaded) return Promise.resolve();
+        renderSkeletons(6);
+        return fetch(buildUrl(), { credentials: 'omit' })
+          .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
+          .then(function (data) {
+            if (!data || data.ok === false) throw new Error((data && data.error) || 'bad payload');
+            renderCards(data.pairs || []);
+            loaded = true;
+            if (!(data.pairs || []).length) {
+              // No pairs available — hide the whole section so we never leave a dead button
+              root.hidden = true;
+            }
+          })
+          .catch(function () {
+            // Soft fail — hide the button rather than show a broken UI
+            root.hidden = true;
+          });
+      }
+
+      btn.addEventListener('click', function () {
+        var open = btn.getAttribute('aria-expanded') === 'true';
+        if (open) {
+          btn.setAttribute('aria-expanded', 'false');
+          grid.hidden = true;
+          return;
+        }
+        btn.setAttribute('aria-expanded', 'true');
+        grid.hidden = false;
+        load();
+      });
+    })();
+  </script>
diff --git a/lib/paint.js b/lib/paint.js
index 3f23d56..14f1e02 100644
--- a/lib/paint.js
+++ b/lib/paint.js
@@ -3,7 +3,19 @@
 
 const { hexToLab } = require('./color');
 
-const BRANDS = ['sherwin-williams', 'dunn-edwards'];
+// All seven paint brands stocked in color_reference. The frontend picks which
+// to display per request via ?brands=sw,de,bm — server always returns all so
+// switching is instant (no extra fetch).
+const BRANDS = ['sherwin-williams', 'dunn-edwards', 'benjamin-moore', 'behr', 'ppg', 'farrow-ball', 'ral'];
+const BRAND_LABELS = {
+  'sherwin-williams': 'SW',
+  'dunn-edwards':     'DE',
+  'benjamin-moore':   'BM',
+  'behr':             'Behr',
+  'ppg':              'PPG',
+  'farrow-ball':      'F&B',
+  'ral':              'RAL'
+};
 const cache = new Map();   // hex -> { sherwin-williams: {...}, dunn-edwards: {...} }
 
 async function nearestPaints(pool, hex) {
@@ -44,4 +56,4 @@ async function nearestPaintsBatch(pool, hexes) {
   return result;
 }
 
-module.exports = { nearestPaints, nearestPaintsBatch, BRANDS };
+module.exports = { nearestPaints, nearestPaintsBatch, BRANDS, BRAND_LABELS };
diff --git a/public/preview.html b/public/preview.html
index 54662d0..9d978c6 100644
--- a/public/preview.html
+++ b/public/preview.html
@@ -48,6 +48,16 @@
   .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; }
 
+  .vendor-picker { margin-top: 6px; margin-bottom: 14px; }
+  .vendor-picker h4 { margin: 0 0 8px; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
+  .vendor-picker .chips { display: flex; flex-wrap: wrap; gap: 6px; }
+  .vendor-picker label {
+    font-size: 12px; padding: 5px 10px; border: 1px solid var(--line); border-radius: 999px;
+    cursor: pointer; background: #fff; user-select: none; display: inline-flex; gap: 6px; align-items: center;
+  }
+  .vendor-picker label:has(input:checked) { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
+  .vendor-picker input { accent-color: #fff; }
+
   /* === Design Coordinate block === */
   .dc-section { margin-top: 32px; }
   .dc-section .dc-header {
@@ -152,10 +162,24 @@
       <p class="vendor" id="product-vendor">—</p>
       <p class="price">From $4.25 / sample</p>
       <button class="add">Add Sample to Cart</button>
+
+      <div class="vendor-picker" id="vendor-picker">
+        <h4>Paint brands</h4>
+        <div class="chips">
+          <label><input type="checkbox" value="sw"   checked> Sherwin-Williams</label>
+          <label><input type="checkbox" value="de"   checked> Dunn-Edwards</label>
+          <label><input type="checkbox" value="bm"   checked> Benjamin Moore</label>
+          <label><input type="checkbox" value="fb"   checked> Farrow &amp; Ball</label>
+          <label><input type="checkbox" value="behr"        > Behr</label>
+          <label><input type="checkbox" value="ppg"         > PPG</label>
+          <label><input type="checkbox" value="ral"         > RAL</label>
+        </div>
+      </div>
+
       <div class="src-palette" id="src-palette" hidden>
-        <h4>Color story — closest Sherwin-Williams &amp; Dunn-Edwards paints</h4>
+        <h4>Color story</h4>
         <table>
-          <thead><tr><th></th><th>Hex</th><th>Sherwin-Williams</th><th>Dunn-Edwards</th></tr></thead>
+          <thead><tr id="src-palette-head"></tr></thead>
           <tbody id="src-palette-body"></tbody>
         </table>
       </div>
@@ -244,20 +268,52 @@
   wireSlider(aiCols, aiColsVal, aiGrid);
 
   // ---- source palette render ----
+  // ===== paint vendor selection =====
+  var VENDOR_LABEL = { sw:'Sherwin-Williams', de:'Dunn-Edwards', bm:'Benjamin Moore', fb:'Farrow & Ball', behr:'Behr', ppg:'PPG', ral:'RAL' };
+  var VENDOR_DEFAULT = ['sw','de','bm','fb'];
+  var vendorChecks = document.querySelectorAll('#vendor-picker input[type=checkbox]');
+  function selectedVendors() {
+    var out = [];
+    vendorChecks.forEach(function (cb) { if (cb.checked) out.push(cb.value); });
+    return out.length ? out : VENDOR_DEFAULT.slice();
+  }
+  try {
+    var stored = JSON.parse(localStorage.getItem('dc.vendors') || 'null');
+    if (Array.isArray(stored) && stored.length) {
+      vendorChecks.forEach(function (cb) { cb.checked = stored.indexOf(cb.value) !== -1; });
+    }
+  } catch (_) {}
+  function persistVendors() {
+    try { localStorage.setItem('dc.vendors', JSON.stringify(selectedVendors())); } catch (_) {}
+  }
+  vendorChecks.forEach(function (cb) {
+    cb.addEventListener('change', function () {
+      persistVendors();
+      // Re-render with the new vendor set.
+      if (section._catalogPairs && !document.getElementById('catalog-grid').hidden) {
+        renderGrid(catalogGrid, section._catalogPairs);
+      }
+      if (section._sourcePalette) renderSourcePalette(section._sourcePalette);
+      // AI grid doesn't carry paint matches yet (placeholder posters)
+    });
+  });
+
   function renderSourcePalette(palette) {
     if (!palette || !palette.length) { srcPalette.hidden = true; return; }
+    section._sourcePalette = palette;
+    var vendors = selectedVendors();
+    document.getElementById('src-palette-head').innerHTML =
+      '<th></th><th>Hex</th>' + vendors.map(function (v) { return '<th>' + esc(VENDOR_LABEL[v]) + '</th>'; }).join('');
     srcPaletteBody.innerHTML = palette.map(function (c) {
-      var sw = c.paints && c.paints.sw, 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>';
+      var cells = '<td class="swatch"><span class="swatch-dot" style="background:' + esc(c.hex) + '"></span></td>'
+                + '<td><span class="paint-code">' + esc(c.hex) + '</span></td>';
+      vendors.forEach(function (v) {
+        var p = c.paints && c.paints[v];
+        cells += '<td>' + (p
+          ? '<span class="swatch-square" style="background:' + esc(p.hex) + '"></span>' + esc(p.name) + ' <span class="paint-code">' + esc(p.code) + '</span>'
+          : '—') + '</td>';
+      });
+      return '<tr>' + cells + '</tr>';
     }).join('');
     srcPalette.hidden = false;
   }
@@ -275,11 +331,16 @@
     var dom = (p.palette && 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>';
+      var vendors = selectedVendors();
+      var rows = vendors.map(function (v) {
+        var paint = dom.paints[v];
+        if (!paint) return '';
+        var lbl = ({ sw:'SW', de:'DE', bm:'BM', fb:'F&B', behr:'Behr', ppg:'PPG', ral:'RAL' })[v] || v.toUpperCase();
+        return '<span class="row"><span class="label">' + lbl + '</span>'
+          + '<span class="swatch-square" style="background:' + esc(paint.hex) + '"></span>'
+          + esc(paint.name) + ' <span class="paint-code">' + esc(paint.code) + '</span></span>';
+      }).filter(Boolean).join('');
+      if (rows) paintsBlock = '<div class="pairs-well__paints">' + rows + '</div>';
     }
     var href = p.url ? esc(p.url) : (p.handle ? '#' + esc(p.handle) : '#');
     return '<a class="pairs-well__card" href="' + href + '" title="' + esc((p.why || []).join(' · ')) + '">'
diff --git a/scripts/backfill-palette.js b/scripts/backfill-palette.js
new file mode 100755
index 0000000..1e27adc
--- /dev/null
+++ b/scripts/backfill-palette.js
@@ -0,0 +1,122 @@
+#!/usr/bin/env node
+/**
+ * Gemini palette backfill for shopify_products without enrichment.
+ *
+ * Pulls ACTIVE+image products missing shopify_color_enrichment rows,
+ * calls Gemini 2.0 Flash on the image, extracts dominant_hex + hex_codes,
+ * inserts into shopify_color_enrichment (status='backfill').
+ *
+ * Rate-limited to 20 rpm to stay well under Gemini free tier limits.
+ *
+ * Usage:
+ *   GEMINI_API_KEY=xxx DATABASE_URL=postgres://... node scripts/backfill-palette.js [--limit 100] [--dry-run]
+ */
+require('dotenv').config();
+const { Pool } = require('pg');
+
+const LIMIT = parseInt(process.argv.find(a => a.startsWith('--limit='))?.split('=')[1] || '5500', 10);
+const DRY = process.argv.includes('--dry-run');
+const SLEEP_MS = parseInt(process.env.SLEEP_MS || '3500', 10);   // ~17 rpm
+const GEMINI_KEY = process.env.GEMINI_API_KEY;
+const MODEL = process.env.GEMINI_MODEL || 'gemini-2.0-flash';
+
+if (!GEMINI_KEY && !DRY) {
+  console.error('FATAL: GEMINI_API_KEY missing. Set it or run --dry-run');
+  process.exit(1);
+}
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+
+const PROMPT = `Look at this wallcovering pattern image. Return ONLY a JSON object with two fields:
+{
+  "dominant_hex": "#RRGGBB",
+  "hex_codes": ["#RRGGBB", "#RRGGBB", "#RRGGBB", "#RRGGBB", "#RRGGBB"]
+}
+- dominant_hex: the single most prevalent color
+- hex_codes: 3-6 colors representing the full palette, ordered by visual prominence
+Return JSON ONLY, no markdown, no commentary.`;
+
+async function fetchTargets(limit) {
+  const r = await pool.query(`
+    SELECT sp.shopify_id, sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url
+    FROM shopify_products sp
+    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 sce.shopify_id IS NULL
+    ORDER BY sp.synced_at DESC NULLS LAST
+    LIMIT $1
+  `, [limit]);
+  return r.rows;
+}
+
+async function geminiPalette(imageUrl) {
+  // Download the image as base64
+  const imgRes = await fetch(imageUrl);
+  if (!imgRes.ok) throw new Error(`image fetch ${imgRes.status}`);
+  const buf = Buffer.from(await imgRes.arrayBuffer());
+  const mime = imgRes.headers.get('content-type') || 'image/jpeg';
+  const b64 = buf.toString('base64');
+
+  const body = {
+    contents: [{
+      parts: [
+        { text: PROMPT },
+        { inline_data: { mime_type: mime, data: b64 } }
+      ]
+    }],
+    generationConfig: { temperature: 0, response_mime_type: 'application/json' }
+  };
+  const url = `https://generativelanguage.googleapis.com/v1beta/models/${MODEL}:generateContent?key=${GEMINI_KEY}`;
+  const r = await fetch(url, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body)
+  });
+  if (!r.ok) throw new Error(`gemini ${r.status} ${await r.text()}`);
+  const j = await r.json();
+  const text = j.candidates?.[0]?.content?.parts?.[0]?.text || '';
+  const parsed = JSON.parse(text);
+  return parsed;
+}
+
+async function insert(row, palette) {
+  if (DRY) {
+    console.log(`  [DRY] ${row.dw_sku} → ${palette.dominant_hex} + ${(palette.hex_codes||[]).length} codes`);
+    return;
+  }
+  await pool.query(`
+    INSERT INTO shopify_color_enrichment
+      (shopify_id, handle, title, vendor, image_url, dominant_hex, hex_codes, status, gemini_model, enrichment_date)
+    VALUES ($1,$2,$3,$4,$5,$6,$7::jsonb,'backfill',$8,now())
+    ON CONFLICT (shopify_id) DO UPDATE SET
+      dominant_hex = EXCLUDED.dominant_hex,
+      hex_codes    = EXCLUDED.hex_codes,
+      status       = 'backfill',
+      enrichment_date = now()
+  `, [row.shopify_id, row.handle, row.title, row.vendor, row.image_url,
+      palette.dominant_hex, JSON.stringify(palette.hex_codes || []), MODEL]);
+}
+
+(async () => {
+  console.log(`backfill-palette: target ${LIMIT} products  model=${MODEL}  sleep=${SLEEP_MS}ms`);
+  const rows = await fetchTargets(LIMIT);
+  console.log(`fetched ${rows.length} candidates`);
+  let ok=0, err=0, skip=0;
+  for (let i=0; i<rows.length; i++) {
+    const row = rows[i];
+    if (!row.image_url) { skip++; continue; }
+    try {
+      const pal = await geminiPalette(row.image_url);
+      if (!pal?.dominant_hex) throw new Error('no dominant_hex');
+      await insert(row, pal);
+      ok++;
+      if (ok % 25 === 0) console.log(`  ${i+1}/${rows.length}  ok=${ok}  err=${err}  ${row.dw_sku} → ${pal.dominant_hex}`);
+    } catch (e) {
+      err++;
+      console.error(`  ${row.dw_sku} FAIL: ${e.message}`);
+    }
+    await new Promise(r => setTimeout(r, SLEEP_MS));
+  }
+  console.log(`DONE  ok=${ok}  err=${err}  skip=${skip}  total=${rows.length}`);
+  await pool.end();
+})().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/server.js b/server.js
index 721a3b4..4567f1b 100644
--- a/server.js
+++ b/server.js
@@ -309,7 +309,16 @@ app.get('/api/pairs', async (req, res) => {
     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 };
+      // Return all 7 brands; UI picks which to render. Short keys for transport size.
+      return {
+        sw:   m['sherwin-williams'] || null,
+        de:   m['dunn-edwards']     || null,
+        bm:   m['benjamin-moore']   || null,
+        behr: m['behr']             || null,
+        ppg:  m['ppg']              || null,
+        fb:   m['farrow-ball']      || null,
+        ral:  m['ral']              || null
+      };
     }
     function paletteWithPaints(hexList) {
       return hexList.map(h => ({ hex: h, paints: paintsFor(h) }));

← a3a416a Design Coordinate v5: graceful fallback for sources without  ·  back to Dw Pairs Well  ·  v6 paste-in snippet refreshed to match Liquid block 567375b →