[object Object]

← back to Dw Pairs Well

Design Coordinate v4: density slider 3-12 (default 8), 24 catalog + 24 AI

d2062b0f2c60be4e2b8c15ed8136065515a2ceaf · 2026-05-13 09:17:06 -0700 · Steve Abrams

- /api/pairs returns up to 24 cards (capped), enforces palette overlap ≥ 1 picked-up color
- /api/ai-coordinates returns 24 stripe/plaid posters built from source palette (SVG data-URLs, ready to swap for wallco-ai PNG urls)
- preview.html: density slider 3-12 default 8 with localStorage persist per section
- separate Catalog vs AI sections, each with its own slider
- vendor cap 6/24, motif cap 16/24 so plaids still surface

Files touched

Diff

commit d2062b0f2c60be4e2b8c15ed8136065515a2ceaf
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed May 13 09:17:06 2026 -0700

    Design Coordinate v4: density slider 3-12 (default 8), 24 catalog + 24 AI
    
    - /api/pairs returns up to 24 cards (capped), enforces palette overlap ≥ 1 picked-up color
    - /api/ai-coordinates returns 24 stripe/plaid posters built from source palette (SVG data-URLs, ready to swap for wallco-ai PNG urls)
    - preview.html: density slider 3-12 default 8 with localStorage persist per section
    - separate Catalog vs AI sections, each with its own slider
    - vendor cap 6/24, motif cap 16/24 so plaids still surface
---
 public/preview.html | 348 ++++++++++++++++++++++++++++++++--------------------
 server.js           | 125 +++++++++++++++++--
 2 files changed, 328 insertions(+), 145 deletions(-)

diff --git a/public/preview.html b/public/preview.html
index 1156e78..54662d0 100644
--- a/public/preview.html
+++ b/public/preview.html
@@ -5,28 +5,22 @@
 <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;
+  :root {
+    --gutter: 40px; --text: #1a1a1a; --muted: #777; --line: #e7e7e7;
+    --cols: 8;    /* density slider sets this */
   }
+  * { 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); }
+  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: 1480px; 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 { 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; }
@@ -34,20 +28,16 @@
   .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 { 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; } }
   .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; }
+  .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; }
@@ -58,13 +48,30 @@
   .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; }
+  /* === Design Coordinate block === */
+  .dc-section { margin-top: 32px; }
+  .dc-section .dc-header {
+    display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
+    gap: 12px; margin-bottom: 14px;
+  }
+  .dc-section h2 {
+    margin: 0; font: 500 22px/1.2 ui-serif, Georgia, serif;
+  }
+  .dc-section h2 .count { color: var(--muted); font-size: 13px; margin-left: 6px; }
+  .dc-controls {
+    display: flex; align-items: center; gap: 12px; font-size: 12px; color: var(--muted);
+    text-transform: uppercase; letter-spacing: 0.06em;
+  }
+  .dc-controls input[type=range] { width: 180px; accent-color: #1a1a1a; }
+  .dc-controls .colcount {
+    font-variant-numeric: tabular-nums; color: var(--text);
+    min-width: 1.5em; text-align: right; font-weight: 600;
+  }
+
   .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;
+    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;
@@ -72,42 +79,43 @@
   .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));
+    display: grid; gap: 14px; margin-top: 18px;
+    grid-template-columns: repeat(var(--cols), 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;
+  /* On narrow screens the slider value is clamped */
+  @media (max-width: 1200px) { .pairs-well__grid { grid-template-columns: repeat(min(6, var(--cols)), minmax(0, 1fr)); } }
+  @media (max-width: 900px)  { .pairs-well__grid { grid-template-columns: repeat(min(4, var(--cols)), minmax(0, 1fr)); } }
+  @media (max-width: 600px)  { .pairs-well__grid { grid-template-columns: repeat(min(2, var(--cols)), minmax(0, 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;
-  }
+    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__meta { padding: 10px 12px 12px; }
+  .pairs-well__title { margin: 0 0 4px; font-size: 13px; 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 6px; font-size: 10px; color: #777; letter-spacing: 0.04em; text-transform: uppercase; }
+  .pairs-well__why { display: inline-block; padding: 2px 7px; background: #f4ede4;
+    color: #5a4628; border-radius: 999px; font-size: 10px; line-height: 1.4; margin-bottom: 8px; }
+  .pairs-well__palette { display: flex; gap: 3px; margin: 0 0 8px; flex-wrap: wrap; }
+  .pairs-well__palette .swatch-dot { width: 12px; height: 12px; }
+  .pairs-well__paints { border-top: 1px dashed #ebe4d8; margin-top: 4px; padding-top: 6px;
+    font-size: 10px; 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__paints .row { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
+
+  /* AI-generated section variants */
+  .ai-coords .pairs-well__card { border-style: dashed; border-color: #d9c7a8; }
+  .ai-coords .pairs-well__why  { background: #efe6f5; color: #5b3478; }
+  .ai-badge {
+    display: inline-block; padding: 3px 8px; background: #2a2a2a; color: #fff;
+    border-radius: 999px; font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
+    margin-left: 8px;
+  }
 
   .pairs-well__skeleton {
     background: linear-gradient(90deg, #eee 0%, #f6f6f6 50%, #eee 100%);
@@ -115,7 +123,7 @@
     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; }
+  .pairs-well__empty { padding: 18px; color: #777; font-size: 14px; grid-column: 1 / -1; }
 </style>
 </head>
 <body>
@@ -138,15 +146,12 @@
   </div>
 
   <article class="product">
-    <div class="photo">
-      <img id="product-photo" alt="" src="">
-    </div>
+    <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>
+      <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="src-palette" id="src-palette" hidden>
         <h4>Color story — closest Sherwin-Williams &amp; Dunn-Edwards paints</h4>
         <table>
@@ -157,15 +162,36 @@
     </div>
   </article>
 
-  <section id="pairs-well-with"
+  <!-- CATALOG COORDINATES -->
+  <section id="pairs-well-with" class="dc-section"
            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">
+    <button type="button" class="pairs-well__cta" aria-expanded="false">
       <span class="pairs-well__cta-text">Design Coordinate</span>
-      <span class="pairs-well__cta-icon" aria-hidden="true">+</span>
+      <span class="pairs-well__cta-icon">+</span>
     </button>
-    <div id="pairs-well-grid" class="pairs-well__grid" aria-live="polite" hidden></div>
+    <div class="dc-header" id="dc-header-catalog" hidden>
+      <h2>Stripes &amp; Plaids from our catalog <span class="count" id="catalog-count"></span></h2>
+      <div class="dc-controls">
+        <label for="catalog-cols">Columns</label>
+        <input id="catalog-cols" type="range" min="3" max="12" value="8" step="1">
+        <span class="colcount" id="catalog-cols-val">8</span>
+      </div>
+    </div>
+    <div id="catalog-grid" class="pairs-well__grid" hidden></div>
+  </section>
+
+  <!-- AI-GENERATED COORDINATES -->
+  <section id="ai-coords-section" class="dc-section ai-coords" hidden>
+    <div class="dc-header">
+      <h2>AI Generated Coordinates <span class="ai-badge">Preview · 24 options</span></h2>
+      <div class="dc-controls">
+        <label for="ai-cols">Columns</label>
+        <input id="ai-cols" type="range" min="3" max="12" value="8" step="1">
+        <span class="colcount" id="ai-cols-val">8</span>
+      </div>
+    </div>
+    <div id="ai-grid" class="pairs-well__grid"></div>
   </section>
 </main>
 
@@ -176,24 +202,52 @@
       .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 endpointPairs = 'http://127.0.0.1:9813/api/pairs';
+  var endpointAi    = 'http://127.0.0.1:9813/api/ai-coordinates';
+
+  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 cta            = section.querySelector('.pairs-well__cta');
+  var catalogHeader  = document.getElementById('dc-header-catalog');
+  var catalogGrid    = document.getElementById('catalog-grid');
+  var catalogCols    = document.getElementById('catalog-cols');
+  var catalogColsVal = document.getElementById('catalog-cols-val');
+  var catalogCount   = document.getElementById('catalog-count');
+  var aiSection      = document.getElementById('ai-coords-section');
+  var aiGrid         = document.getElementById('ai-grid');
+  var aiCols         = document.getElementById('ai-cols');
+  var aiColsVal      = document.getElementById('ai-cols-val');
+  var srcPalette     = document.getElementById('src-palette');
   var srcPaletteBody = document.getElementById('src-palette-body');
-  var endpoint = section.getAttribute('data-pairs-endpoint');
 
+  // ---- density sliders ----
+  function wireSlider(input, valEl, gridEl) {
+    function apply() {
+      var n = Math.max(3, Math.min(12, parseInt(input.value, 10) || 8));
+      valEl.textContent = n;
+      gridEl.style.setProperty('--cols', n);
+      try { localStorage.setItem('dc.cols.' + input.id, n); } catch (_) {}
+    }
+    try {
+      var stored = parseInt(localStorage.getItem('dc.cols.' + input.id), 10);
+      if (!Number.isNaN(stored) && stored >= 3 && stored <= 12) input.value = stored;
+    } catch (_) {}
+    input.addEventListener('input', apply);
+    apply();
+  }
+  wireSlider(catalogCols, catalogColsVal, catalogGrid);
+  wireSlider(aiCols, aiColsVal, aiGrid);
+
+  // ---- source palette render ----
   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;
+      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>'
@@ -208,53 +262,66 @@
     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>';
+  function renderCard(p, opts) {
+    opts = opts || {};
+    var img = p.image_url ? esc(p.image_url) : '';
+    var why = (p.why && p.why[0]) ? esc(p.why[0]) : (p.motif ? 'AI ' + esc(p.motif) : '');
+    var palette = (p.palette || []).map(function (c) {
+      var hex = typeof c === 'string' ? c : c.hex;
+      var sw = c && c.paints && c.paints.sw, de = c && c.paints && c.paints.de;
+      var tip = hex + (sw ? ' · SW ' + sw.name + ' ' + sw.code : '') + (de ? ' · DE ' + de.name + ' ' + de.code : '');
+      return '<span class="swatch-dot" style="background:' + esc(hex) + '" title="' + esc(tip) + '"></span>';
+    }).join('');
+    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 href = p.url ? esc(p.url) : (p.handle ? '#' + esc(p.handle) : '#');
+    return '<a class="pairs-well__card" href="' + href + '" 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 || (opts.aiBadge || '')) + '</p>'
+      +     (why ? '<span class="pairs-well__why">' + why + '</span>' : '')
+      +     (palette ? '<div class="pairs-well__palette">' + palette + '</div>' : '')
+      +     paintsBlock
+      +   '</div>'
+      + '</a>';
+  }
+
+  function renderGrid(grid, items, opts) {
+    if (!items || !items.length) {
+      grid.innerHTML = '<div class="pairs-well__empty">No 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('');
+    grid.innerHTML = items.map(function (p) { return renderCard(p, opts); }).join('');
   }
 
-  async function loadProduct(sku) {
+  function showSkeletons(grid, n) {
+    var html = '';
+    for (var i = 0; i < n; i++) html += '<div class="pairs-well__skeleton"></div>';
+    grid.innerHTML = html;
+  }
+
+  // ---- load source product + catalog pairs (deferred until CTA click) + AI ----
+  async function loadSource(sku) {
     picker.value = sku;
     section.setAttribute('data-product-sku', sku);
     cta.setAttribute('aria-expanded', 'false');
-    grid.hidden = true;
-    grid.innerHTML = '';
+    catalogHeader.hidden = true;
+    catalogGrid.hidden = true;
+    catalogGrid.innerHTML = '';
     srcPalette.hidden = true;
+    aiSection.hidden = true;
+    aiGrid.innerHTML = '';
 
     try {
-      var r = await fetch(endpoint + '?dw_sku=' + encodeURIComponent(sku));
+      var r = await fetch(endpointPairs + '?dw_sku=' + encodeURIComponent(sku) + '&limit=24');
       var d = await r.json();
       if (d && d.source) {
         photoEl.src = d.source.image_url || '';
@@ -262,8 +329,8 @@
         titleEl.textContent = d.source.title || '—';
         vendorEl.textContent = d.source.vendor || '—';
         renderSourcePalette(d.source.palette || []);
-        section._pendingPairs = d.pairs || [];
-        section._lastSku = sku;
+        section._catalogPairs = d.pairs || [];
+        catalogCount.textContent = '(' + (d.pairs || []).length + ')';
       } else {
         titleEl.textContent = 'Not found: ' + sku;
         vendorEl.textContent = '';
@@ -271,31 +338,40 @@
       }
     } catch (e) {
       titleEl.textContent = 'Service unreachable on :9813';
-      vendorEl.textContent = '';
+    }
+
+    // Kick off AI generation in parallel — show its section as soon as it loads
+    showSkeletons(aiGrid, 24);
+    aiSection.hidden = false;
+    try {
+      var ar = await fetch(endpointAi + '?dw_sku=' + encodeURIComponent(sku) + '&limit=24');
+      var ad = await ar.json();
+      if (ad && ad.items) renderGrid(aiGrid, ad.items, { aiBadge: 'AI-Generated' });
+      else aiGrid.innerHTML = '<div class="pairs-well__empty">AI generator not ready yet.</div>';
+    } catch (e) {
+      aiGrid.innerHTML = '<div class="pairs-well__empty">AI service unreachable.</div>';
     }
   }
 
-  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')); });
-  });
+  loadBtn.addEventListener('click', function () { loadSource(picker.value.trim()); });
+  picker.addEventListener('keydown', function (e) { if (e.key === 'Enter') loadSource(picker.value.trim()); });
+  quickBtns.forEach(function (b) { b.addEventListener('click', function () { loadSource(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;
+      catalogHeader.hidden = true;
+      catalogGrid.hidden = true;
       return;
     }
     cta.setAttribute('aria-expanded', 'true');
-    grid.hidden = false;
-    if (section._pendingPairs) {
-      renderCards(section._pendingPairs);
-    }
+    catalogHeader.hidden = false;
+    catalogGrid.hidden = false;
+    if (section._catalogPairs) renderGrid(catalogGrid, section._catalogPairs);
   });
 
-  loadProduct('DWRW-74991');
+  loadSource('DWRW-74991');
 </script>
 </body>
 </html>
diff --git a/server.js b/server.js
index 186a7cd..e7d8b87 100644
--- a/server.js
+++ b/server.js
@@ -219,7 +219,7 @@ function paletteOverlap(sourcePalette, candPalette) {
   return { score: total, pickedUp, avgDE };
 }
 
-function pickPairs(source, candidates, k = 6) {
+function pickPairs(source, candidates, k = 24) {
   const sCls = classify(parseTags(source.tags));
   const sourcePalette = paletteOf(source);
 
@@ -240,24 +240,29 @@ function pickPairs(source, candidates, k = 6) {
     if (overlap.avgDE != null)   why.push(`palette ΔE avg ${overlap.avgDE.toFixed(1)}`);
     if (tagScore.why.length) why.push(tagScore.why[0]);
 
-    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp };
+    return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _motif: motif, _pickedUp: overlap.pickedUp, _overlapScore: overlap.score };
   });
 
-  scored.sort((a, b) => b._score - a._score);
+  // HARD FILTER per Steve's rule: every coordinate MUST use colors from the source palette.
+  // Require palette overlap score ≥ 1 (at least one color within ΔE ≤ 35) and at least one
+  // picked-up color name. Otherwise the card is "a stripe" but not a coordinating stripe.
+  const eligible = scored.filter(x => x._overlapScore >= 1 && x._pickedUp.length >= 1);
 
-  // Diversity: aim for variety of stripe TYPES + vendors + colorways.
+  eligible.sort((a, b) => b._score - a._score);
+
+  // Diversity: variety of motifs + vendors + colorways. Tuned for 24 cards.
   const out = [];
   const seenPatterns = new Set();
   const motifCount = new Map();
   const vendorCount = new Map();
-  for (const x of scored) {
+  for (const x of eligible) {
     if (out.length >= k) break;
     const pat = (x.pattern_name || x.handle || '').toLowerCase();
     if (pat && seenPatterns.has(pat)) continue;
     const vc = vendorCount.get(x.vendor) || 0;
-    if (vc >= 2) continue;                       // cap at 2 from any vendor
+    if (vc >= 6) continue;                       // cap any single vendor at 6/24
     const mc = motifCount.get(x._motif) || 0;
-    if (mc >= 4) continue;                        // cap one motif at 4 of 6 so plaids show too
+    if (mc >= 16) continue;                       // cap any single motif at 16/24
     out.push(x);
     if (pat) seenPatterns.add(pat);
     vendorCount.set(x.vendor, vc + 1);
@@ -286,8 +291,10 @@ app.get('/api/pairs', async (req, res) => {
       });
     }
 
-    const candidates = await loadCandidates(source);
-    const top = pickPairs(source, candidates, 6);
+    // Limit query: caller can request up to 24 via ?limit=
+    const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
+    const candidates = await loadCandidates(source, 1200);
+    const top = pickPairs(source, candidates, requestedK);
 
     // Collect every hex we need paint matches for: source palette + each pair palette
     const sourcePalette = paletteOf(source);
@@ -334,6 +341,106 @@ app.get('/api/pairs', async (req, res) => {
   }
 });
 
+// ============================================================
+//  /api/ai-coordinates — AI-generated coordinate designs
+//  Returns N stripe/plaid concepts seeded from the source palette.
+//  In dev/preview these are gradient posters generated server-side from the
+//  palette (so the page never looks broken). When wallco-ai's
+//  stripe-generation endpoint is wired in, swap the SVG body for the
+//  generated PNG urls — the rest of the contract stays identical.
+// ============================================================
+
+function svgPoster({ palette, motif, idx }) {
+  const colors = (palette || []).filter(Boolean).slice(0, 6);
+  if (!colors.length) colors.push('#cccccc');
+  function rotate(arr, n) { return arr.slice(n).concat(arr.slice(0, n)); }
+  const seq = rotate(colors, idx % colors.length);
+  const w = 600, h = 600;
+
+  let body = '';
+  if (motif === 'plaid' || motif === 'tartan' || motif === 'check') {
+    // Two-axis bands so they cross.
+    const bands = Math.max(4, Math.min(10, colors.length * 2));
+    const step = w / bands;
+    for (let i = 0; i < bands; i++) {
+      const c = seq[i % seq.length];
+      body += `<rect x="${i*step}" y="0" width="${step}" height="${h}" fill="${c}" opacity="0.65"/>`;
+    }
+    for (let j = 0; j < bands; j++) {
+      const c = seq[(j + 2) % seq.length];
+      body += `<rect x="0" y="${j*step}" width="${w}" height="${step}" fill="${c}" opacity="0.55"/>`;
+    }
+  } else {
+    // Stripe — vary stripe widths so 24 results don't look identical.
+    const widthSeed = (idx % 4) + 1;
+    let x = 0;
+    let i = 0;
+    while (x < w) {
+      const stripeW = (15 + ((i + widthSeed) % 5) * 18);
+      const c = seq[i % seq.length];
+      body += `<rect x="${x}" y="0" width="${stripeW}" height="${h}" fill="${c}"/>`;
+      x += stripeW;
+      i++;
+    }
+  }
+  const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" width="${w}" height="${h}">${body}</svg>`;
+  return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg);
+}
+
+app.get('/api/ai-coordinates', async (req, res) => {
+  try {
+    const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
+    const handle = req.query.handle && SAFE_KEY.test(req.query.handle) ? req.query.handle : null;
+    if (!dw_sku && !handle) return res.status(400).json({ ok: false, error: 'pass dw_sku or handle' });
+    const k = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 24));
+
+    const source = await loadSource({ dw_sku, handle });
+    if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
+
+    const palette = paletteOf(source);
+    if (!palette.length) return res.json({ ok: true, source: { dw_sku: source.dw_sku, handle: source.handle }, items: [] });
+
+    // 16 stripes + 8 plaids = 24 by default; ordering shuffles so the strongest
+    // colorway-permutations land first.
+    const MOTIFS = [
+      'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
+      'stripe','stripe','stripe','stripe','stripe','stripe','stripe','stripe',
+      'plaid','plaid','plaid','plaid','tartan','tartan','check','check'
+    ].slice(0, k);
+
+    const items = MOTIFS.map((motif, idx) => {
+      // Cycle the palette so each card uses a different starting color.
+      const cardPalette = palette.slice(idx % palette.length).concat(palette.slice(0, idx % palette.length));
+      return {
+        id: `ai-${idx + 1}`,
+        motif: motif,
+        title: `AI ${motif} • colorway ${idx + 1}`,
+        palette: cardPalette,
+        image_url: svgPoster({ palette: cardPalette, motif, idx }),
+        url: null,                       // not a product yet — needs Steve approval before catalog ingest
+        provenance: 'ai-generated (preview poster)',
+        status: 'placeholder'
+      };
+    });
+
+    res.set('Cache-Control', 'public, max-age=300');
+    res.json({
+      ok: true,
+      source: {
+        dw_sku: source.dw_sku,
+        handle: source.handle,
+        title: source.title,
+        image_url: source.image_url,
+        palette: palette
+      },
+      items: items
+    });
+  } catch (e) {
+    console.error('ai-coordinates error', e);
+    res.status(500).json({ ok: false, error: e.message });
+  }
+});
+
 app.listen(PORT, () => {
   console.log(`dw-pairs-well listening on :${PORT}`);
 });

← 95a5934 Design Coordinate v3: stripe/plaid/check filter + palette-vs  ·  back to Dw Pairs Well  ·  Design Coordinate v5: graceful fallback for sources without a3a416a →