← back to Fabricut Landing
fix(fabricut): group grid by pattern — one card per pattern with colorway swatch strip (1325 cards -> 189)
c7514d0b3c2d26895768d87c8955895d9260a3a2 · 2026-08-24 14:01:09 -0700 · Steve
Root cause of perceived 'duplicates': the grid rendered one card per colorway/SKU,
so a 189-pattern catalog exploded into 1325 near-identical cards (94% of patterns
have >1 colorway). Data/API had 0 true duplicate SKUs. Fix is front-end only:
groupByPattern() collapses colorways of a series into one pattern card (swatch strip
+ N-colors), applied after filtering so facets/search re-group correctly. Verified
headless: 189 pattern cards, color filter 0-leak, infinite-scroll no dup.
Files touched
M public/index.htmlM public/styles.css
Diff
commit c7514d0b3c2d26895768d87c8955895d9260a3a2
Author: Steve <steve@designerwallcoverings.com>
Date: Mon Aug 24 14:01:09 2026 -0700
fix(fabricut): group grid by pattern — one card per pattern with colorway swatch strip (1325 cards -> 189)
Root cause of perceived 'duplicates': the grid rendered one card per colorway/SKU,
so a 189-pattern catalog exploded into 1325 near-identical cards (94% of patterns
have >1 colorway). Data/API had 0 true duplicate SKUs. Fix is front-end only:
groupByPattern() collapses colorways of a series into one pattern card (swatch strip
+ N-colors), applied after filtering so facets/search re-group correctly. Verified
headless: 189 pattern cards, color filter 0-leak, infinite-scroll no dup.
---
public/index.html | 85 +++++++++++++++++++++++++++++++++++++++++++++++++++++--
public/styles.css | 18 ++++++++++++
2 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/public/index.html b/public/index.html
index c6589d1..9d1e5fe 100644
--- a/public/index.html
+++ b/public/index.html
@@ -246,8 +246,84 @@ document.querySelectorAll('#fieldsbox input[data-fv]').forEach(cb=>{
cb.onchange=()=>{FVIS[cb.dataset.fv]=cb.checked;applyFieldVis();};
});
applyFieldVis();
+// ── group colorways into one card per PATTERN (series) ──
+// Each group mirrors a representative colorway's sortable fields (hue/series/sku/book…)
+// so the existing sort + column-sort machinery keeps working on grouped rows.
+function groupByPattern(list){
+ const m=new Map();
+ for(const p of list){
+ const key=String(p.series||p.display_name||'').trim().toLowerCase()||('__'+p.handle);
+ let g=m.get(key);
+ if(!g){ g={items:[],books:new Set(),colors:new Map(),rep:p}; m.set(key,g); }
+ g.items.push(p);
+ if(p.book)g.books.add(p.book);
+ const ck=String(p.color||p.display_name||'').trim().toLowerCase();
+ if(ck&&!g.colors.has(ck))g.colors.set(ck,p); // one rep product per distinct colour (swatch strip)
+ }
+ const out=[];
+ for(const g of m.values()){
+ const rep=g.rep, colorways=[...g.colors.values()];
+ const hues=g.items.map(p=>p.hue).filter(h=>h!=null);
+ out.push({
+ __group:true,
+ series:rep.series||rep.display_name||'', colorways, nColors:g.colors.size, nItems:g.items.length,
+ books:[...g.books],
+ // representative / sortable fields (mirror a product so cmp + colsort work unchanged)
+ book:[...g.books][0]||rep.book||'Fabricut', display_name:rep.series||rep.display_name||'',
+ color:g.colors.size+' color'+(g.colors.size===1?'':'s'), color_bucket:rep.color_bucket,
+ hue:hues.length?Math.min(...hues):999, val:rep.val, style:rep.style,
+ sku:rep.sku, dw_sku:rep.dw_sku, handle:rep.handle, swatch:rep.swatch, room:rep.room,
+ material:rep.material, width:rep.width, length:rep.length, repeat:rep.repeat, match:rep.match,
+ });
+ }
+ return out;
+}
+// one card per pattern: hero swatch + book(s) + pattern name + a colorway swatch strip.
+// Main click opens the representative colourway's PDP; each swatch/detail row opens its own PDP.
+function patternCard(g){
+ const title=esc(g.series||g.display_name||'(untitled)');
+ const hasRoom=g.room&&g.room!==g.swatch;
+ const img=g.swatch
+ ?`<div class="imgwrap"><img loading="lazy" src="${esc(g.swatch)}" alt="${title}" onerror="this.style.display='none';this.parentNode.classList.add('noimg')">`+
+ (hasRoom?`<img loading="lazy" class="roomimg" src="${esc(g.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
+ :`<div class="imgwrap noimg"></div>`;
+ const dot=BUCKET_HEX[g.color_bucket]||'#666';
+ const books=g.books||[];
+ const bookLabel=drill('book',books[0]||'Fabricut',books[0]||'Fabricut')+(books.length>1?` <span class="bmore" title="${esc(books.join(', '))}">+${books.length-1}</span>`:'');
+ const SWMAX=16;
+ const sw=(g.colorways||[]).slice(0,SWMAX).map(c=>
+ `<a class="sw${c.swatch?'':' noimg'}" href="/product/${encodeURIComponent(c.handle)}" title="${esc(c.color||c.display_name||'')}">`+
+ (c.swatch?`<img loading="lazy" src="${esc(c.swatch)}" alt="${esc(c.color||'')}" onerror="this.parentNode.classList.add('noimg');this.remove()">`:'')+`</a>`
+ ).join('');
+ const swMore=(g.colorways||[]).length>SWMAX?`<span class="swmore">+${g.colorways.length-SWMAX}</span>`:'';
+ const nColors=g.nColors||(g.colorways||[]).length;
+ return `<div class="card patcard" data-href="/product/${encodeURIComponent(g.handle)}" data-handle="${esc(g.handle)}" data-sku="${esc(g.dw_sku||'')}" data-mfr="${esc(g.sku||'')}" data-title="${esc(g.series||'')}">
+ ${img}<div class="b">
+ <span class="vend">${bookLabel}</span>
+ <div class="ttl">${title}</div>
+ <span class="lc" title="Colorways">${nColors} color${nColors===1?'':'s'}</span>
+ <div class="swatches">${sw}${swMore}</div>
+ <div class="chips">
+ ${g.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${drill('color_bucket',g.color_bucket,g.color_bucket)}</span>`:''}
+ <span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ ${nColors} color${nColors===1?'':'s'}</span>
+ </div>
+ <span class="lsku" title="Vendor SKU">${esc(g.sku||'')}</span>
+ <span class="ldw" title="DW SKU">${esc(g.dw_sku||'')}</span>
+ <span class="lc" title="Material">${drill('material',g.material,g.material)}</span>
+ <span class="lc" title="Width">${drill('width',g.width,g.width)}</span>
+ <span class="lc" title="Length">${drill('length',g.length,g.length)}</span>
+ <span class="lc" title="Repeat">${drill('repeat',g.repeat,g.repeat)}</span>
+ <span class="lc" title="Match">${drill('match',g.match,g.match)}</span>
+ </div>
+ <div class="info" onclick="event.stopPropagation()">
+ ${(g.colorways||[]).map(c=>`<div class="kv"><span class="k">${esc(c.color||c.display_name||'(colour)')}</span><span class="v"><a class="cwlink" href="/product/${encodeURIComponent(c.handle)}">${esc(c.dw_sku||c.sku||'open')} ↗</a></span></div>`).join('')}
+ </div>
+ </div>`;
+}
// NOTE: no <a> wrapper around the card — nesting anchors splits the DOM (adoption
// agency) and litters the grid with empty <a> cells. Navigation is delegated click.
+// card(p) renders a single colourway — retained for reference; the grid now renders
+// patternCard(g). Kept intentionally so a per-colourway view can be re-enabled cheaply.
function card(p){
const title=esc(p.display_name||p.series||p.sku||'(untitled)')+(p.color&&p.color!==p.display_name?(', '+esc(p.color)):'');
const hasRoom=p.room&&p.room!==p.swatch;
@@ -337,12 +413,15 @@ function toast(msg,ok){
}
function applyFilters(nav){
const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
- VIEW=ALL.filter(p=>{
+ const matched=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
if(pat&&!String(p.series||p.display_name||'').toLowerCase().includes(pat))return false;
if(q&&![p.display_name,p.series,p.sku,p.dw_sku,p.color,p.book,p.japan_sku,p.japan_vendor].some(v=>v&&String(v).toLowerCase().includes(q)))return false;
return true;
});
+ // Group colorways into one card per PATTERN (series). Filtering happens first (above),
+ // so pattern cards only carry colorways that match the active filters/search.
+ VIEW=groupByPattern(matched);
const s=$('#sort').value, cmp={
newest:null,
color:(a,b)=>(a.hue??999)-(b.hue??999)||(a.val??0)-(b.val??0),
@@ -361,14 +440,14 @@ function applyFilters(nav){
});
}else if(cmp)VIEW=[...VIEW].sort(cmp);
shown=0; grid.innerHTML=''; renderMore();
- $('#total').textContent=VIEW.length.toLocaleString()+' designs';
+ $('#total').textContent=VIEW.length.toLocaleString()+' patterns';
$('#empty').style.display=VIEW.length?'none':'block';
renderSide();
if(nav!=='none')writeURL(nav==='push'); // reflect filter state into the URL (shareable/deep-linkable)
}
function renderMore(){
const slice=VIEW.slice(shown,shown+PAGE);
- grid.insertAdjacentHTML('beforeend',slice.map(card).join(''));
+ grid.insertAdjacentHTML('beforeend',slice.map(patternCard).join(''));
shown+=slice.length;
$('#meta').textContent=`${shown.toLocaleString()} of ${VIEW.length.toLocaleString()} shown`;
$('#more').style.display=shown<VIEW.length?'block':'none';
diff --git a/public/styles.css b/public/styles.css
index 6abbe3d..1c91c59 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -287,3 +287,21 @@ body[data-line="fallingstar"] .books h2::after{content:"";display:block;width:72
.preview .pv-body{margin-top:10px;border-top:1px solid var(--line);padding-top:8px;color:var(--ink)}
.preview .pv-body p{margin:0 0 8px}
.preview .pv-warn{color:#8a5a00}
+
+/* ── pattern-grouped cards: colourway swatch strip (added 2026-08-24: dedupe by pattern) ── */
+.card.patcard .swatches{display:flex;flex-wrap:wrap;gap:3px;margin:5px 0 3px}
+.card.patcard .sw{width:22px;height:22px;border-radius:3px;overflow:hidden;display:block;
+ border:1px solid rgba(0,0,0,.14);background:#eee;flex:0 0 auto}
+.card.patcard .sw img{width:100%;height:100%;object-fit:cover;display:block}
+.card.patcard .sw.noimg{background:repeating-linear-gradient(45deg,#eee,#eee 4px,#e2e2e2 4px,#e2e2e2 8px)}
+.card.patcard .sw:hover{border-color:rgba(0,0,0,.4)}
+.card.patcard .swmore{font-size:.72em;opacity:.6;align-self:center;padding-left:3px;white-space:nowrap}
+.bmore{font-size:.72em;opacity:.55;font-weight:600}
+/* colourway list inside the ⓘ details drawer */
+.card.patcard .info .cwlink{text-decoration:none;opacity:.85}
+.card.patcard .info .cwlink:hover{text-decoration:underline;opacity:1}
+/* when card-field chips are toggled off, hide the swatch strip too (it's a colour aid) */
+body.f-off-chips .card.patcard .swatches{display:none}
+body.imgonly .card.patcard .swatches{display:none}
+/* list view: keep the swatch strip compact on one line */
+#grid.list .card.patcard .swatches{max-width:220px;overflow:hidden;flex-wrap:nowrap}
← aa2887a chore: v1.0.0 — go-live (session close)
·
back to Fabricut Landing
·
chore(fabricut): rebase onto live — add GA snippet + pin pro bdb71d3 →