← back to Dw Pairs Well
Add /api/similar (visually-similar designs) + similar-designs.liquid
5e32b37e93f94e86fb02f43c78a55f40ed6f1103 · 2026-06-25 23:29:11 -0700 · Steve
Forks the pairs-well scorer with inverted motif sign (reward sameness): new
scoreSimilar() in lib/classify.js (+5 motif, +3 material/era/color, hard-excludes
same pattern). New loadSimilarCandidates() anchors the pool on the source's own
motif/material tags; pickSimilar() dedupes by pattern (no colorway floods). New
GET /api/similar?dw_sku|handle endpoint. Frontend similar-designs.liquid mirrors
design-coordinate with its own namespace + density slider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
A deploy/shopify/similar-designs.liquidM lib/classify.jsM server.js
Diff
commit 5e32b37e93f94e86fb02f43c78a55f40ed6f1103
Author: Steve <steve@designerwallcoverings.com>
Date: Thu Jun 25 23:29:11 2026 -0700
Add /api/similar (visually-similar designs) + similar-designs.liquid
Forks the pairs-well scorer with inverted motif sign (reward sameness): new
scoreSimilar() in lib/classify.js (+5 motif, +3 material/era/color, hard-excludes
same pattern). New loadSimilarCandidates() anchors the pool on the source's own
motif/material tags; pickSimilar() dedupes by pattern (no colorway floods). New
GET /api/similar?dw_sku|handle endpoint. Frontend similar-designs.liquid mirrors
design-coordinate with its own namespace + density slider.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
deploy/shopify/similar-designs.liquid | 207 ++++++++++++++++++++++++++++++++++
lib/classify.js | 32 +++++-
server.js | 149 +++++++++++++++++++++++-
3 files changed, 386 insertions(+), 2 deletions(-)
diff --git a/deploy/shopify/similar-designs.liquid b/deploy/shopify/similar-designs.liquid
new file mode 100644
index 0000000..17ea9fe
--- /dev/null
+++ b/deploy/shopify/similar-designs.liquid
@@ -0,0 +1,207 @@
+{%- comment -%}
+ Similar Designs v1 — visual ALTERNATIVES to this product (NOT coordinates).
+ • Grid of look-alike designs: same motif / material / era / palette
+ • Density slider 3-12 cols (default 8), localStorage-persisted
+ • Each card shows palette dots + a "why it's similar" chip
+ Service: https://pairs.designerwallcoverings.com/api/similar (~/Projects/dw-pairs-well)
+ Self-contained .similar-designs__* namespace + IIFE — coexists with the
+ Design Coordinate section (#pairs-well-with) on the same product/pattern page.
+ {%- endcomment -%}
+ <section
+ id="similar-designs"
+ class="sd-section"
+ data-similar-endpoint="https://pairs.designerwallcoverings.com/api/similar"
+ 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="similar-designs__cta" aria-expanded="false">
+ <span class="similar-designs__cta-text">Similar Designs</span>
+ <span class="similar-designs__cta-icon" aria-hidden="true">+</span>
+ </button>
+
+ <div class="similar-designs__panel" id="similar-designs-panel" hidden>
+ <div class="similar-designs__panel-head">
+ <h3>Designs like this <span class="count" id="similar-designs-count"></span></h3>
+ <div class="similar-designs__controls">
+ <label for="similar-designs-cols">Columns</label>
+ <input id="similar-designs-cols" type="range" min="3" max="12" value="8" step="1">
+ <span class="similar-designs__cols-val" id="similar-designs-cols-val">8</span>
+ </div>
+ </div>
+ <div id="similar-designs-grid" class="similar-designs__grid"></div>
+ </div>
+ </section>
+
+ <style>
+ #similar-designs { font-family: inherit; --sd-cols: 8; }
+ .similar-designs__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;
+ }
+ .similar-designs__cta:hover { background: #2a2a2a; }
+ .similar-designs__cta:active { transform: scale(0.995); }
+ .similar-designs__cta[aria-expanded="true"] .similar-designs__cta-icon { transform: rotate(45deg); }
+ .similar-designs__cta-icon { display: inline-block; font-size: 22px; line-height: 1; transition: transform 200ms ease; }
+
+ .sd-dot { display: inline-block; width: 12px; height: 12px; border-radius: 50%; border: 1px solid rgba(0,0,0,0.12); vertical-align: middle; }
+
+ .similar-designs__panel-head {
+ display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap;
+ gap: 12px; margin: 24px 0 12px;
+ }
+ .similar-designs__panel-head h3 { margin: 0; font: 500 18px/1.2 ui-serif, Georgia, serif; }
+ .similar-designs__panel-head .count { color: #888; font-size: 12px; margin-left: 6px; font-family: -apple-system, sans-serif; font-weight: 400; }
+ .similar-designs__controls {
+ display: flex; align-items: center; gap: 10px;
+ font-size: 11px; color: #777; text-transform: uppercase; letter-spacing: 0.06em;
+ }
+ .similar-designs__controls input[type=range] { width: 160px; accent-color: #1a1a1a; }
+ .similar-designs__cols-val { font-variant-numeric: tabular-nums; color: #1a1a1a; min-width: 1.5em; text-align: right; font-weight: 600; }
+
+ .similar-designs__grid {
+ display: grid; gap: 14px; margin-top: 6px;
+ grid-template-columns: repeat(var(--sd-cols), minmax(0, 1fr));
+ }
+ @media (max-width: 1100px) { .similar-designs__grid { grid-template-columns: repeat(min(6, var(--sd-cols)), minmax(0, 1fr)); } }
+ @media (max-width: 900px) { .similar-designs__grid { grid-template-columns: repeat(min(4, var(--sd-cols)), minmax(0, 1fr)); } }
+ @media (max-width: 600px) { .similar-designs__grid { grid-template-columns: repeat(min(2, var(--sd-cols)), minmax(0, 1fr)); } }
+
+ .similar-designs__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;
+ }
+ .similar-designs__card:hover { box-shadow: 0 6px 20px rgba(0,0,0,0.08); transform: translateY(-2px); }
+ .similar-designs__img-wrap { aspect-ratio: 1 / 1; background: #f4f4f4; overflow: hidden; }
+ .similar-designs__img { width: 100%; height: 100%; object-fit: cover; display: block; }
+ .similar-designs__meta { padding: 10px 12px 12px; }
+ .similar-designs__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; }
+ .similar-designs__vendor { margin: 0 0 6px; font-size: 10px; color: #777; letter-spacing: 0.04em; text-transform: uppercase; }
+ .similar-designs__why { display: inline-block; padding: 2px 7px; background: #eef0f4; color: #34425a; border-radius: 999px; font-size: 10px; line-height: 1.4; margin-bottom: 8px; }
+ .similar-designs__palette { display: flex; gap: 3px; margin: 0; flex-wrap: wrap; }
+
+ .similar-designs__skeleton { background: linear-gradient(90deg, #eee 0%, #f6f6f6 50%, #eee 100%); background-size: 200% 100%; animation: sdShim 1.2s linear infinite; aspect-ratio: 1 / 1; border-radius: 6px; }
+ @keyframes sdShim { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
+ .similar-designs__empty { padding: 18px; color: #777; font-size: 14px; grid-column: 1 / -1; }
+ </style>
+
+ <script>
+ (function () {
+ var root = document.getElementById('similar-designs');
+ if (!root) return;
+
+ var endpoint = root.getAttribute('data-similar-endpoint');
+ var handle = root.getAttribute('data-product-handle');
+ var sku = root.getAttribute('data-product-sku');
+ // Pattern page (no Shopify product object) can set data-source-handle directly.
+ if (!handle) handle = root.getAttribute('data-source-handle') || '';
+ if (!sku) sku = root.getAttribute('data-source-sku') || '';
+ var hasIdentifier = (sku && sku.length) || (handle && handle.length);
+ if (!hasIdentifier) return;
+ root.hidden = false;
+
+ var cta = root.querySelector('.similar-designs__cta');
+ var panel = document.getElementById('similar-designs-panel');
+ var grid = document.getElementById('similar-designs-grid');
+ var cols = document.getElementById('similar-designs-cols');
+ var colsVal = document.getElementById('similar-designs-cols-val');
+ var countEl = document.getElementById('similar-designs-count');
+ var loaded = false;
+
+ function esc(s) {
+ return String(s == null ? '' : s)
+ .replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
+ .replace(/"/g, '"').replace(/'/g, ''');
+ }
+
+ // Density slider, localStorage-persisted (Steve's standing grid rule).
+ function wireSlider() {
+ function apply() {
+ var n = Math.max(3, Math.min(12, parseInt(cols.value, 10) || 8));
+ colsVal.textContent = n;
+ root.style.setProperty('--sd-cols', n);
+ try { localStorage.setItem('sd.cols', n); } catch (_) {}
+ }
+ try {
+ var s = parseInt(localStorage.getItem('sd.cols'), 10);
+ if (!isNaN(s) && s >= 3 && s <= 12) cols.value = s;
+ } catch (_) {}
+ cols.addEventListener('input', apply);
+ apply();
+ }
+ wireSlider();
+
+ function renderSkeletons(n) {
+ var html = '';
+ for (var i = 0; i < n; i++) html += '<div class="similar-designs__skeleton"></div>';
+ grid.innerHTML = html;
+ }
+
+ function renderCard(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 hex = typeof c === 'string' ? c : c.hex;
+ return '<span class="sd-dot" style="background:' + esc(hex) + '"></span>';
+ }).join('');
+ var href = p.url ? esc(p.url) : (p.handle ? '/products/' + esc(p.handle) : '#');
+ return '<a class="similar-designs__card" href="' + href + '" title="' + esc((p.why || []).join(' · ')) + '">'
+ + '<div class="similar-designs__img-wrap">' + (img ? '<img class="similar-designs__img" src="' + img + '" alt="' + esc(p.title) + '" loading="lazy">' : '') + '</div>'
+ + '<div class="similar-designs__meta">'
+ + '<p class="similar-designs__title">' + esc(p.title || '') + '</p>'
+ + '<p class="similar-designs__vendor">' + esc(p.vendor || '') + '</p>'
+ + (why ? '<span class="similar-designs__why">' + why + '</span>' : '')
+ + (palette ? '<div class="similar-designs__palette">' + palette + '</div>' : '')
+ + '</div>'
+ + '</a>';
+ }
+
+ function renderGrid(items) {
+ if (!items || !items.length) { grid.innerHTML = '<div class="similar-designs__empty">No similar designs found.</div>'; return; }
+ grid.innerHTML = items.map(renderCard).join('');
+ }
+
+ function buildUrl(limit) {
+ var qs = [];
+ if (sku) qs.push('dw_sku=' + encodeURIComponent(sku));
+ if (handle) qs.push('handle=' + encodeURIComponent(handle));
+ if (limit) qs.push('limit=' + limit);
+ return endpoint + '?' + qs.join('&');
+ }
+
+ function load() {
+ if (loaded) return;
+ loaded = true;
+ panel.hidden = false;
+ renderSkeletons(12);
+ fetch(buildUrl(12), { credentials: 'omit' })
+ .then(function (r) { return r.ok ? r.json() : Promise.reject(new Error('http ' + r.status)); })
+ .then(function (d) {
+ if (!d || d.ok === false) throw new Error((d && d.error) || 'bad payload');
+ var items = d.similar || [];
+ renderGrid(items);
+ countEl.textContent = '(' + items.length + ')';
+ if (!items.length) root.hidden = true; // nothing similar → hide the whole section
+ })
+ .catch(function () { grid.innerHTML = '<div class="similar-designs__empty">Could not load similar designs right now.</div>'; });
+ }
+
+ cta.addEventListener('click', function () {
+ var open = cta.getAttribute('aria-expanded') === 'true';
+ if (open) {
+ cta.setAttribute('aria-expanded', 'false');
+ panel.hidden = true;
+ return;
+ }
+ cta.setAttribute('aria-expanded', 'true');
+ load();
+ });
+ })();
+ </script>
diff --git a/lib/classify.js b/lib/classify.js
index c42c3c9..d80d8f6 100644
--- a/lib/classify.js
+++ b/lib/classify.js
@@ -84,4 +84,34 @@ function score(source, candidate, candidateVendor, sourceVendor, candidatePatter
return { score: s, why };
}
-module.exports = { parseTags, classify, score, COLORS, MOTIFS, ERAS, MATERIALS };
+// Inverted sibling of score() for the "Similar Designs" feature.
+// Where score() REWARDS contrast (coordinating a hero pattern), this REWARDS sameness
+// (finding visual alternatives that look like the source): same motif, finish, era, palette.
+// Same pattern_name is a colorway of the SAME design, not a similar one — hard-killed.
+function scoreSimilar(source, candidate, candidateVendor, sourceVendor, candidatePattern, sourcePattern) {
+ const sharedColors = intersect(source.colors, candidate.colors);
+ const sharedEras = intersect(source.eras, candidate.eras);
+ const sharedMotifs = intersect(source.motifs, candidate.motifs);
+ const sharedMaterials = intersect(source.materials, candidate.materials);
+
+ const samePattern = !!(sourcePattern && candidatePattern
+ && sourcePattern.toLowerCase() === candidatePattern.toLowerCase());
+
+ let s = 0;
+ s += sharedMotifs.length * 5; // same motif is the strongest "looks like it" signal
+ s += sharedMaterials.length * 3; // same finish/texture (grasscloth↔grasscloth)
+ s += sharedEras.length * 3; // same design era/style
+ s += sharedColors.length * 3; // shared palette
+ if (sourceVendor && candidateVendor && sourceVendor === candidateVendor) s += 1;
+ if (samePattern) s -= 100; // exclude this design's own colorways
+
+ const why = [];
+ if (sharedMotifs.length) why.push(`Same motif: ${sharedMotifs.slice(0, 2).join(', ')}`);
+ if (sharedMaterials.length) why.push(`Same material: ${sharedMaterials.slice(0, 2).join(', ')}`);
+ if (sharedEras.length) why.push(`Shared style: ${sharedEras.slice(0, 2).join(', ')}`);
+ if (sharedColors.length) why.push(`Shared color: ${sharedColors.slice(0, 2).join(', ')}`);
+
+ return { score: s, why, samePattern };
+}
+
+module.exports = { parseTags, classify, score, scoreSimilar, COLORS, MOTIFS, ERAS, MATERIALS };
diff --git a/server.js b/server.js
index 096c185..82ab90b 100644
--- a/server.js
+++ b/server.js
@@ -2,7 +2,7 @@ require('dotenv').config();
const express = require('express');
const { Pool } = require('pg');
const path = require('path');
-const { parseTags, classify, score } = require('./lib/classify');
+const { parseTags, classify, score, scoreSimilar } = require('./lib/classify');
const { hexToLab, deltaE2000, safeParseHexArray } = require('./lib/color');
const { nearestPaintsBatch } = require('./lib/paint');
const { generateCoordinates, renderCustom } = require('./lib/ai-stripes');
@@ -292,6 +292,153 @@ function pickPairs(source, candidates, k = 24) {
return out;
}
+// ============================================================
+// "Similar Designs" — visual ALTERNATIVES to the source (NOT coordinates).
+// Candidate pool is anchored on the SOURCE's own motif/material tags so a
+// grasscloth surfaces other grasscloths, a damask other damasks. Scoring
+// (scoreSimilar) rewards sameness; color overlap is a soft bonus, not a gate.
+// ============================================================
+async function loadSimilarCandidates(source, limit = 1200) {
+ const sCls = classify(parseTags(source.tags));
+ // Anchor on the source's recognized motif + material tags.
+ const anchors = [...sCls.motifs, ...sCls.materials];
+
+ const params = [source.dw_sku || ''];
+ let filterClause;
+ if (anchors.length) {
+ const likeClauses = anchors.map((_, i) => `lower(sp.tags) LIKE $${i + 2}`);
+ filterClause = `(${likeClauses.join(' OR ')})`;
+ anchors.forEach(a => params.push(`%${String(a).toLowerCase()}%`));
+ } else if (source.color_family) {
+ // No motif/material on source — fall back to same color family so the pool stays relevant.
+ filterClause = `lower(sp.tags) LIKE $2`;
+ params.push(`%${String(source.color_family).toLowerCase()}%`);
+ } else {
+ filterClause = 'TRUE';
+ }
+ params.push(limit);
+ const limitIdx = params.length;
+
+ const q = `
+ SELECT sp.dw_sku, sp.handle, sp.title, sp.vendor, sp.image_url, sp.tags, sp.pattern_name,
+ sce.dominant_hex, sce.background_hex, sce.hex_codes, sce.color_family
+ FROM shopify_products AS sp
+ LEFT JOIN shopify_color_enrichment sce ON sce.shopify_id = sp.shopify_id
+ WHERE sp.status = 'ACTIVE'
+ AND sp.image_url IS NOT NULL
+ AND sp.handle IS NOT NULL
+ AND sp.dw_sku IS NOT NULL
+ AND sp.dw_sku <> $1
+ AND ${filterClause}
+ ORDER BY (sce.dominant_hex IS NOT NULL) DESC,
+ (sce.hex_codes IS NOT NULL) DESC,
+ sp.synced_at DESC NULLS LAST
+ LIMIT $${limitIdx}
+ `;
+ const r = await pool.query(q, params);
+ return r.rows;
+}
+
+function pickSimilar(source, candidates, k = 12) {
+ const sCls = classify(parseTags(source.tags));
+ const sourcePalette = paletteOf(source);
+ const hasAnchors = (sCls.motifs.size + sCls.materials.size) > 0;
+
+ const scored = candidates.map(c => {
+ const cCls = classify(parseTags(c.tags));
+ const sim = scoreSimilar(sCls, cCls, c.vendor, source.vendor, c.pattern_name, source.pattern_name);
+ const candPalette = paletteOf(c);
+ const overlap = paletteOverlap(sourcePalette, candPalette);
+ // color overlap is a SOFT bonus for "similar" (capped), never a hard filter
+ const totalScore = sim.score + Math.min(overlap.score, 6);
+
+ const why = sim.why.slice(0, 2);
+ if (overlap.pickedUp.length) why.push(`Picks up: ${overlap.pickedUp.slice(0, 3).join(', ')}`);
+
+ return { ...c, _score: totalScore, _why: why, _palette: candPalette, _dE: overlap.avgDE, _samePattern: sim.samePattern };
+ });
+
+ // Drop this design's own colorways; require at least one real shared signal.
+ const eligible = scored.filter(x => !x._samePattern && x._score >= (hasAnchors ? 5 : 3));
+ eligible.sort((a, b) => b._score - a._score);
+
+ // Diversity: one card per SIMILAR pattern (no colorway floods), looser vendor cap than pairs.
+ const out = [];
+ const seenPatterns = new Set();
+ const vendorCount = new Map();
+ 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 >= 8) continue;
+ out.push(x);
+ if (pat) seenPatterns.add(pat);
+ vendorCount.set(x.vendor, vc + 1);
+ }
+ return out;
+}
+
+app.get('/api/similar', 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 source = await loadSource({ dw_sku, handle });
+ if (!source) return res.status(404).json({ ok: false, error: 'source not found' });
+
+ // "Similar" keys on motif/material/era tags — without tags we can't compute it.
+ if (!source.tags || parseTags(source.tags).length === 0) {
+ return res.json({
+ ok: true,
+ source: { dw_sku: source.dw_sku, handle: source.handle, title: source.title, image_url: source.image_url, vendor: source.vendor },
+ similar: [],
+ reason: 'no tags on source — cannot compute similar designs'
+ });
+ }
+
+ const requestedK = Math.max(1, Math.min(24, parseInt(req.query.limit, 10) || 12));
+ const candidates = await loadSimilarCandidates(source, 1200);
+ const top = pickSimilar(source, candidates, requestedK);
+
+ // Palette dots + paint chips, same shape as /api/pairs for UI parity.
+ const sourcePalette = paletteOf(source);
+ const allHexes = sourcePalette.slice();
+ for (const p of top) for (const h of p._palette) allHexes.push(h);
+ const paintMap = await nearestPaintsBatch(pool, allHexes);
+ function paintsFor(hex) {
+ const m = paintMap[String(hex || '').toLowerCase()] || null;
+ if (!m) return null;
+ return {
+ sw: m['sherwin-williams'] || null, de: m['dunn-edwards'] || null,
+ bm: m['benjamin-moore'] || null, behr: m['behr'] || null,
+ ppg: m['ppg'] || null, fb: m['farrow-ball'] || null, ral: m['ral'] || null
+ };
+ }
+ const paletteWithPaints = hexList => hexList.map(h => ({ hex: h, paints: paintsFor(h) }));
+
+ res.set('Cache-Control', 'public, max-age=300');
+ res.json({
+ ok: true,
+ source: {
+ dw_sku: source.dw_sku, handle: source.handle, title: source.title,
+ vendor: source.vendor, image_url: source.image_url, palette: paletteWithPaints(sourcePalette)
+ },
+ similar: top.map(p => ({
+ dw_sku: p.dw_sku, handle: p.handle, title: p.title, vendor: p.vendor,
+ image_url: p.image_url, url: `/products/${p.handle}`,
+ score: p._score, why: p._why, delta_e: p._dE, palette: paletteWithPaints(p._palette)
+ }))
+ });
+ } catch (e) {
+ console.error('similar error', e);
+ res.status(500).json({ ok: false, error: e.message });
+ }
+});
+
app.get('/api/pairs', async (req, res) => {
try {
const dw_sku = req.query.dw_sku && SAFE_KEY.test(req.query.dw_sku) ? req.query.dw_sku : null;
← ffc4b86 Add idempotent fleet-wide fix-all-stale dw_admin password re
·
back to Dw Pairs Well
·
auto-save: 2026-06-25T23:37:59 (1 files) — tools/ c76eef6 →