← 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
M public/preview.htmlM server.js
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 & 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 & 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, '"').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 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 →