[object Object]

← back to Zuber Internal

Rail parity with all.dw: cross-filtered facets, color families+swatches, active-chip row, needs-curation preserved

6db0b612f40358cec2f3f92ecb44b42fbc6113bc · 2026-07-09 16:08:13 -0700 · Steve

Files touched

Diff

commit 6db0b612f40358cec2f3f92ecb44b42fbc6113bc
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 16:08:13 2026 -0700

    Rail parity with all.dw: cross-filtered facets, color families+swatches, active-chip row, needs-curation preserved
---
 public/index.html | 227 +++++++++++++++++++++++++++++++++++++++++++-----------
 server.js         | 188 ++++++++++++++++++++++++++++++++++----------
 2 files changed, 327 insertions(+), 88 deletions(-)

diff --git a/public/index.html b/public/index.html
index 7f0746e..9afd948 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,7 +5,7 @@
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>Zuber — Designer Wallcoverings (internal)</title>
 <style>
-  :root { --cols: 5; --fs: 13px; --teal: #0f766e; --ink: #1a1a1a; --line: #e2e2e0; --bg: #fafaf8; }
+  :root { --cols: 5; --fs: 13px; --teal: #0f766e; --ink: #1a1a1a; --mut:#6a6a72; --line: #e2e2e0; --bg: #fafaf8; --card:#f6f6f4; }
   * { box-sizing: border-box; }
   body { margin: 0; font: var(--fs)/1.45 -apple-system, "Helvetica Neue", Arial, sans-serif; color: var(--ink); background: var(--bg); }
   header { position: sticky; top: 0; z-index: 20; background: #fff; border-bottom: 1px solid var(--line); padding: 10px 16px; display: flex; align-items: center; gap: 16px; flex-wrap: wrap; }
@@ -15,18 +15,38 @@
   header label { font-size: 11px; color: #666; display: inline-flex; align-items: center; gap: 6px; }
   select, input[type=search] { font: inherit; padding: 5px 8px; border: 1px solid var(--line); border-radius: 4px; background: #fff; }
   input[type=range] { vertical-align: middle; }
-  .layout { display: grid; grid-template-columns: 260px 1fr; gap: 0; }
-  aside { border-right: 1px solid var(--line); background: #fff; height: calc(100vh - 52px); overflow-y: auto; position: sticky; top: 52px; padding: 12px; }
-  aside .search { width: 100%; margin-bottom: 12px; }
-  .facet { border: 1px solid var(--line); border-radius: 5px; margin-bottom: 8px; overflow: hidden; }
-  .facet > summary { cursor: pointer; padding: 7px 10px; font-weight: 600; font-size: 12px; list-style: none; display: flex; justify-content: space-between; align-items: center; background: #f6f6f4; }
-  .facet > summary::-webkit-details-marker { display: none; }
-  .facet .rows { max-height: 260px; overflow-y: auto; }
-  .facet .row { display: flex; justify-content: space-between; align-items: center; padding: 4px 10px; cursor: pointer; font-size: 12px; }
-  .facet .row:hover { background: #f0f0ee; }
-  .facet .row.on { background: #e6f4f2; }
-  .facet .row .badge { background: var(--teal); color: #fff; border-radius: 10px; padding: 0 7px; font-size: 10px; min-width: 20px; text-align: center; }
-  .facet .row.on .badge { background: #0b544e; }
+  .layout { display: grid; grid-template-columns: 262px 1fr; gap: 0; }
+
+  /* ── left rail (all.dw parity) ── */
+  aside { border-right: 1px solid var(--line); background: #fff; height: calc(100vh - 52px); overflow-y: auto; position: sticky; top: 52px; padding: 10px 10px 60px; }
+  aside .search { width: 100%; margin-bottom: 10px; }
+
+  /* active-filter chip row */
+  .active { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
+  .afilter { background: #e6f4f2; border: 1px solid var(--teal); color: var(--ink); border-radius: 20px; padding: 3px 9px; font-size: 11px; cursor: pointer; }
+  .afilter b { color: var(--teal); font-weight: 600; }
+  .clearall { background: none; border: 1px solid var(--line); color: var(--mut); border-radius: 20px; padding: 3px 11px; font-size: 11px; cursor: pointer; }
+
+  /* collapsible facet sections */
+  details.sec { border: 1px solid var(--line); border-radius: 7px; margin-bottom: 7px; background: var(--card); overflow: hidden; }
+  details.sec > summary { list-style: none; cursor: pointer; user-select: none; padding: 8px 10px; font-size: 10.5px; letter-spacing: .09em; text-transform: uppercase; color: var(--mut); display: flex; align-items: center; gap: 7px; font-weight: 600; }
+  details.sec > summary::-webkit-details-marker { display: none; }
+  details.sec > summary::before { content: '▸'; color: var(--teal); font-size: 10px; }
+  details.sec[open] > summary::before { content: '▾'; }
+  details.sec > summary .n { margin-left: auto; background: var(--teal); color: #fff; border-radius: 20px; padding: 0 7px; font-size: 10px; font-weight: 700; }
+  .fbody { padding: 2px 8px 9px; max-height: 300px; overflow-y: auto; }
+  .fitem { display: flex; align-items: center; gap: 7px; padding: 2.5px 2px; cursor: pointer; font-size: 12.5px; color: var(--ink); border-radius: 5px; }
+  .fitem:hover { background: #eaeae7; }
+  .fitem .box { width: 13px; height: 13px; flex: 0 0 13px; border: 1px solid var(--mut); border-radius: 3px; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; }
+  .fitem.on .box { background: var(--teal); border-color: var(--teal); color: #fff; font-weight: 700; }
+  .fitem.on { color: var(--teal); }
+  .fitem .swatch { width: 12px; height: 12px; flex: 0 0 12px; border-radius: 3px; border: 1px solid rgba(0,0,0,.18); }
+  .fitem .lbl { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+  .fitem .ct { color: var(--mut); font-size: 10.5px; }
+  .fitem.zero { opacity: .35; }
+  .more { color: var(--teal); font-size: 11.5px; cursor: pointer; padding: 3px 2px; display: inline-block; }
+  .ffind { width: 100%; padding: 4px 8px !important; font-size: 11.5px !important; border: 1px solid var(--line); border-radius: 6px !important; margin: 2px 0 5px; }
+
   main { padding: 14px 16px 60px; }
   .toolbar { display: flex; align-items: center; gap: 14px; margin-bottom: 12px; flex-wrap: wrap; }
   .count { color: #888; font-size: 12px; }
@@ -34,7 +54,8 @@
   .card { border: 1px solid var(--line); border-radius: 6px; background: #fff; overflow: hidden; display: flex; flex-direction: column; }
   .card .img { aspect-ratio: 4/3; background: #eee center/cover no-repeat; }
   .card .body { padding: 8px 9px; display: flex; flex-direction: column; gap: 3px; }
-  .card .pat { font-weight: 600; font-size: calc(var(--fs) - 0px); line-height: 1.2; }
+  .card .pat { font-weight: 600; font-size: var(--fs); line-height: 1.2; }
+  .card .fr { color: #999; font-size: calc(var(--fs) - 2px); font-style: italic; }
   .card .meta { color: #777; font-size: calc(var(--fs) - 2px); }
   .card .when { color: #999; font-size: 10px; margin-top: 2px; }
   .card a { color: var(--teal); text-decoration: none; font-size: 11px; }
@@ -65,6 +86,7 @@
   <label><input type="checkbox" id="imgonly" /> Image-only</label>
   <span class="fields">
     <label><input type="checkbox" class="ff" data-f="pat" checked> Name</label>
+    <label><input type="checkbox" class="ff" data-f="fr" checked> FR</label>
     <label><input type="checkbox" class="ff" data-f="meta" checked> Meta</label>
     <label><input type="checkbox" class="ff" data-f="when" checked> Date</label>
   </span>
@@ -72,6 +94,7 @@
 <div class="layout">
   <aside>
     <input type="search" id="q" class="search" placeholder="Search pattern / SKU / collection…" />
+    <div class="active" id="activeRow"></div>
     <div id="facets"></div>
   </aside>
   <main>
@@ -82,24 +105,44 @@
 <script>
 const $ = (s, r=document) => r.querySelector(s);
 const API = location.origin;
-const state = { q: '', sort: 'newest', sel: {} };
-const FACET_DEFS = [['collection','Collection'],['product_type','Type'],['material','Material'],['style','Style'],['tag','Tag']];
+
+// facet dimensions: [stateKey, apiKey, label]  (single-vendor → NO vendor facet)
+const FACET_DEFS = [
+  ['types', 'product_type', 'Type'],
+  ['collection', 'collection', 'Book / Series'],
+  ['colors', 'color', 'Color'],
+  ['styles', 'style', 'Style'],
+  ['materials', 'material', 'Material'],
+  ['prices', 'price', 'Price'],
+  ['tags', 'tag', 'Tag'],
+  ['images', 'image', 'Image'],
+];
+// active-chip labels + which apiKey emits family_order/family_swatch
+const CHIP_LABEL = { types: 'Type', collection: 'Book', colors: 'Color', styles: 'Style', materials: 'Material', prices: 'Price', tags: 'Tag', images: 'Image' };
+
+const state = { q: '', sort: 'newest' };
+for (const [k] of FACET_DEFS) state[k] = new Set();
 
 function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
 function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
 
-// restore prefs
+// restore facet selections from localStorage
+for (const [k] of FACET_DEFS) { const arr = ls('sel_'+k, []); if (Array.isArray(arr)) state[k] = new Set(arr); }
+function persistFacets() { for (const [k] of FACET_DEFS) save('sel_'+k, [...state[k]]); }
+
+// restore header prefs
 $('#cols').value = ls('cols', 5); document.documentElement.style.setProperty('--cols', $('#cols').value);
 $('#fs').value = ls('fs', 13); document.documentElement.style.setProperty('--fs', $('#fs').value + 'px');
 $('#imgonly').checked = ls('imgonly', false); document.body.classList.toggle('imgonly', $('#imgonly').checked);
 $('#sort').value = ls('sort', 'newest'); state.sort = $('#sort').value;
-['pat','meta','when'].forEach(f => { const on = ls('ff_'+f, true); document.querySelector('.ff[data-f="'+f+'"]').checked = on; applyField(f, on); });
+$('#q').value = ls('q', ''); state.q = $('#q').value;
+['pat','fr','meta','when'].forEach(f => { const on = ls('ff_'+f, true); document.querySelector('.ff[data-f="'+f+'"]').checked = on; });
 
 $('#cols').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); save('cols', +e.target.value); };
 $('#fs').oninput = e => { document.documentElement.style.setProperty('--fs', e.target.value + 'px'); save('fs', +e.target.value); };
 $('#imgonly').onchange = e => { document.body.classList.toggle('imgonly', e.target.checked); save('imgonly', e.target.checked); };
 $('#sort').onchange = e => { state.sort = e.target.value; save('sort', e.target.value); load(); };
-$('#q').oninput = e => { state.q = e.target.value; clearTimeout(window._t); window._t = setTimeout(load, 200); };
+$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(applyFilterChange, 220); };
 document.querySelectorAll('.ff').forEach(cb => cb.onchange = () => { applyField(cb.dataset.f, cb.checked); save('ff_'+cb.dataset.f, cb.checked); });
 function applyField(f, on) { document.querySelectorAll('.card .'+f).forEach(el => el.style.display = on ? '' : 'none'); }
 
@@ -110,36 +153,126 @@ function fmtDate(iso) {
   return d.toLocaleString(undefined, { year:'numeric', month:'short', day:'numeric', hour:'numeric', minute:'2-digit' });
 }
 
-async function loadFacets() {
-  const { facets } = await (await fetch(API + '/api/facets')).json();
-  const host = $('#facets'); host.innerHTML = '';
-  for (const [key, label] of FACET_DEFS) {
-    const items = facets[key] || [];
-    if (!items.length) continue;
-    const det = document.createElement('details'); det.className = 'facet';
-    det.innerHTML = `<summary>${label}<span>${items.length}</span></summary><div class="rows"></div>`;
-    const rows = det.querySelector('.rows');
-    for (const it of items) {
-      const r = document.createElement('div'); r.className = 'row';
-      r.innerHTML = `<span>${it.value}</span><span class="badge">${it.count}</span>`;
-      r.onclick = () => {
-        state.sel[key] = state.sel[key] || new Set();
-        if (state.sel[key].has(it.value)) { state.sel[key].delete(it.value); r.classList.remove('on'); }
-        else { state.sel[key].add(it.value); r.classList.add('on'); }
-        load();
-      };
-      rows.appendChild(r);
-    }
+// build the query params shared by /api/facets + /api/products
+function facetParams(extra = {}) {
+  const p = new URLSearchParams();
+  if (state.q) p.set('q', state.q);
+  p.set('sort', state.sort);
+  for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) { const set = state[k]; if (set && set.size) p.set(apiKey, [...set].join('|')); }
+  for (const [k, v] of Object.entries(extra)) p.set(k, v);
+  return p;
+}
+// note: FACET_DEFS maps state key → api key; facetParams uses the api key as the query param
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
+function applyFilterChange() { persistFacets(); refreshFacets(); load(); }
+
+// per-section UI state: type-ahead text + expand
+const rowUI = {};
+for (const [k] of FACET_DEFS) rowUI[k] = { find: '', expand: false, typeahead: false };
+rowUI.styles.typeahead = true; rowUI.tags.typeahead = true; rowUI.collection.typeahead = false;
+
+let LAST_FACETS = {};
+
+// Amazon-style facet list: checkbox rows + counts, optional swatch, type-ahead, cap/expand.
+function facetList(host, stateKey, counts, opts = {}) {
+  host.innerHTML = '';
+  const ui = rowUI[stateKey];
+  const set = state[stateKey];
+  if (ui.typeahead) {
+    const find = document.createElement('input');
+    find.type = 'text'; find.className = 'ffind'; find.placeholder = 'find…'; find.value = ui.find;
+    find.oninput = () => { ui.find = find.value; renderFacets(LAST_FACETS); const f2 = host.querySelector('.ffind'); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+    host.appendChild(find);
+  }
+  let entries = Object.entries(counts || {});
+  entries = opts.order
+    ? entries.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]))
+    : entries.sort((a, b) => b[1] - a[1]);
+  if (ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
+  const CAP = opts.cap || 8;
+  let shown = entries, hidden = 0;
+  if (!ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
+  const shownVals = new Set(shown.map(([v]) => v));
+  for (const v of set) if (!shownVals.has(v)) { const e = entries.find(([x]) => x === v); shown.push(e || [v, 0]); }
+  if (!shown.length) { const s = document.createElement('div'); s.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; host.appendChild(s); return; }
+  shown.forEach(([val, ct]) => {
+    const el = document.createElement('div');
+    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    let swatch = '';
+    if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${bg}"></span>`; }
+    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${val.replace(/"/g, '&quot;')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
+    el.onclick = () => toggleSet(set, val);
+    host.appendChild(el);
+  });
+  if (hidden > 0) {
+    const el = document.createElement('span'); el.className = 'more'; el.textContent = `See ${hidden.toLocaleString()} more ▾`;
+    el.onclick = () => { ui.expand = true; renderFacets(LAST_FACETS); };
+    host.appendChild(el);
+  } else if (ui.expand && entries.length > CAP) {
+    const el = document.createElement('span'); el.className = 'more'; el.textContent = 'See less ▴';
+    el.onclick = () => { ui.expand = false; renderFacets(LAST_FACETS); };
+    host.appendChild(el);
+  }
+}
+
+// Build/refresh the section shells once, fill bodies on every render.
+function ensureSections() {
+  if ($('#facets').dataset.built) return;
+  const host = $('#facets');
+  for (const [k, apiKey, label] of FACET_DEFS) {
+    const det = document.createElement('details'); det.className = 'sec'; det.dataset.k = k;
+    if (ls('open_'+k, k === 'collection' || k === 'colors')) det.open = true;
+    det.innerHTML = `<summary>${label}<span class="n" id="n_${k}"></span></summary><div class="fbody" id="body_${k}"></div>`;
+    det.addEventListener('toggle', () => save('open_'+k, det.open));
     host.appendChild(det);
   }
+  $('#facets').dataset.built = '1';
+}
+
+function renderFacets(f) {
+  LAST_FACETS = f;
+  ensureSections();
+  const capMap = { types: 6, collection: 12, colors: 17, styles: 12, materials: 10, prices: 4, tags: 14, images: 3 };
+  for (const [k, apiKey] of FACET_DEFS.map(([k,a]) => [k,a])) {
+    const body = $('#body_'+k); if (!body) continue;
+    const opts = { cap: capMap[k] || 8 };
+    if (k === 'colors') { opts.order = f.family_order; opts.swatch = f.family_swatch; }
+    facetList(body, k, f[apiKey] || {}, opts);
+  }
+  renderActive();
+}
+
+function renderActive() {
+  const row = $('#activeRow'); row.innerHTML = '';
+  let n = 0;
+  for (const [k] of FACET_DEFS) {
+    const set = state[k];
+    const badge = $('#n_'+k);
+    if (badge) { badge.textContent = set.size || ''; badge.style.display = set.size ? '' : 'none'; }
+    set.forEach((v) => {
+      n++;
+      const el = document.createElement('span'); el.className = 'afilter';
+      el.innerHTML = `<b>${CHIP_LABEL[k]}:</b> ${v} ✕`;
+      el.onclick = () => { set.delete(v); applyFilterChange(); };
+      row.appendChild(el);
+    });
+  }
+  if (n) {
+    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+    c.onclick = () => { for (const [k] of FACET_DEFS) state[k].clear(); applyFilterChange(); };
+    row.appendChild(c);
+  }
+}
+
+async function refreshFacets() {
+  try {
+    const f = await (await fetch(API + '/api/facets?' + facetParams())).json();
+    renderFacets(f);
+  } catch (e) { /* facets degrade silently — grid still works */ }
 }
 
 async function load() {
-  const p = new URLSearchParams();
-  if (state.q) p.set('q', state.q);
-  p.set('sort', state.sort);
-  for (const [k, set] of Object.entries(state.sel)) if (set.size) p.set(k, [...set].join('|'));
-  const data = await (await fetch(API + '/api/products?' + p)).json();
+  const data = await (await fetch(API + '/api/products?' + facetParams())).json();
   $('#count').textContent = `${data.total} of ${data.all} products`;
   const grid = $('#grid'); grid.innerHTML = '';
   for (const r of data.products) {
@@ -149,17 +282,19 @@ async function load() {
     c.appendChild(img);
     const body = document.createElement('div'); body.className = 'body';
     const meta = [r.collection, r.width].filter(Boolean).join(' · ');
+    const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${r.pattern_name_fr}</div>` : '';
     body.innerHTML = `
       <div class="pat">${r.pattern_name || r.mfr_sku}</div>
+      ${frLine}
       <div class="meta">${r.mfr_sku}${meta ? ' — ' + meta : ''}</div>
       <div class="when" title="${r.created_at || ''}">🕓 ${fmtDate(r.created_at)}</div>
       ${r.product_url ? `<a href="${r.product_url}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
     c.appendChild(body);
     grid.appendChild(c);
   }
-  ['pat','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
+  ['pat','fr','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
 }
-loadFacets().then(load);
+refreshFacets().then(load);
 setInterval(load, 60000);
 </script>
 </body>
diff --git a/server.js b/server.js
index 2ae35f6..2eb265b 100644
--- a/server.js
+++ b/server.js
@@ -1,8 +1,15 @@
 /* Zuber internal line viewer — Designer Wallcoverings (INTERNAL, basic-auth).
-   Reads dw_unified.zuber_catalog LIVE (via psql), read-only browse + curate.
-   Internal-line-viewer spec (astek-landing :9944 reference): left search + collapsible
-   field-tab tables w/ counts, density slider, image-only toggle, created-date chips.
-   NOT on Shopify. Quote-only, made-to-order heritage line. */
+   Reads dw_unified.zuber_catalog LIVE (via psql, local) OR a self-contained
+   data/zuber.jsonl bundle (Kamatera — NEVER touches a DB).
+   Left rail is ported to full all.designerwallcoverings.com parity:
+     - active-filter chip row (removable pills + clear-all, persisted)
+     - collapsible <details.sec> facet sections w/ per-value counts
+     - CROSS-FILTERED facets (each dim's counts skip its own selection)
+     - Color families (hue buckets, swatch dot + count, family-ordered)
+     - Style / Tag explosion (JSONB arrays), Type, Book/Series (collection),
+       Material (fallback composition), Price (Quote-only), Image (present/missing)
+   Single-vendor line → NO Vendor facet. Quote-only, made-to-order heritage line.
+   NOT on Shopify. Color sort + density + created-date chips preserved. */
 const express = require('express');
 const { execFile } = require('child_process');
 const path = require('path');
@@ -22,7 +29,6 @@ const app = express();
 app.use(require('compression')());
 
 // unauthenticated health probe BEFORE the gate — keep it a bare liveness signal
-// (no catalog size/detail; the count is only exposed behind auth via /api/config)
 app.get('/healthz', (_req, res) => res.json({ ok: true }));
 
 app.use((req, res, next) => {
@@ -35,20 +41,86 @@ app.use((req, res, next) => {
   return res.status(401).send('Authentication required.');
 });
 
+// ── color families (hue buckets) ─────────────────────────────────────────────
+// Interior-designer wheel order; each family carries a representative swatch hex
+// for the rail dot. A product's color set is derived from its ai_colors[].name +
+// color_hex (name keyword first, hue fallback). IDENTICAL in psql + jsonl paths.
+const FAMILY_ORDER = ['Red', 'Orange', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Beige', 'Black', 'Gray', 'White', 'Silver', 'Metallic', 'Multi'];
+const FAMILY_SWATCH = {
+  Red: '#c0392b', Orange: '#e67e22', Yellow: '#f1c40f', Gold: '#c9a227', Green: '#27ae60',
+  Teal: '#17a2a2', Blue: '#2e6fd6', Purple: '#8e44ad', Pink: '#e28ac0', Brown: '#8b5a2b',
+  Beige: '#d8c9a8', Black: '#222222', Gray: '#8a8a8a', White: '#f2f2ee', Silver: '#c0c4c8',
+  Metallic: '#b8a06a', Multi: 'linear-gradient(90deg,#e74c3c,#f1c40f,#27ae60,#2e6fd6,#8e44ad)',
+};
+// Name-keyword → family. Longer/more-specific keys first via ordered array.
+const NAME_FAMILY = [
+  [/gold|golden|ambre|amber|aurore/i, 'Gold'],
+  [/silver/i, 'Silver'],
+  [/metallic|bronze|copper|brass/i, 'Metallic'],
+  [/terracotta|rust|burnt|orange/i, 'Orange'],
+  [/teal|turquoise|aqua|cyan/i, 'Teal'],
+  [/sky\s*blue|light\s*blue|navy|indigo|cobalt|blue/i, 'Blue'],
+  [/green|olive|sage|emerald|foliage/i, 'Green'],
+  [/purple|violet|lavender|mauve|plum/i, 'Purple'],
+  [/pink|rose|blush|magenta|fuchsia/i, 'Pink'],
+  [/red|crimson|scarlet|burgundy|maroon/i, 'Red'],
+  [/yellow|lemon|mustard|ochre|ocher/i, 'Yellow'],
+  [/brown|earth|sepia|chocolate|tan|umber|khaki/i, 'Brown'],
+  [/beige|cream|ivory|ecru|taupe|sand|oatmeal|linen|wheat/i, 'Beige'],
+  [/black|ebony|onyx|charcoal/i, 'Black'],
+  [/gray|grey|slate|ash/i, 'Gray'],
+  [/white|alabaster|snow/i, 'White'],
+];
+function hueOf(hex) {
+  if (!hex || hex[0] !== '#' || hex.length !== 7) return null;
+  const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  const l = (mx + mn) / 2;
+  if (d < 0.06) { // near-gray: split by lightness
+    if (l > 0.85) return 'White'; if (l < 0.18) return 'Black'; return 'Gray';
+  }
+  let h; if (mx === r) h = ((g - b) / d) % 6; else if (mx === g) h = (b - r) / d + 2; else h = (r - g) / d + 4;
+  h *= 60; if (h < 0) h += 360;
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 40) return 'Orange';
+  if (h < 65) return l < 0.45 ? 'Brown' : 'Yellow';
+  if (h < 160) return 'Green';
+  if (h < 195) return 'Teal';
+  if (h < 255) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Multi';
+}
+function nameFamily(name) {
+  if (!name) return null;
+  for (const [re, fam] of NAME_FAMILY) if (re.test(name)) return fam;
+  return null;
+}
+// Derive the de-duped, family-ordered set of color families for one row.
+function colorFamilies(aiColors, colorHex) {
+  const fams = new Set();
+  for (const c of (aiColors || [])) {
+    const f = nameFamily(c && c.name) || hueOf(c && c.hex);
+    if (f) fams.add(f);
+  }
+  if (!fams.size && colorHex) { const f = hueOf(colorHex); if (f) fams.add(f); }
+  return FAMILY_ORDER.filter((f) => fams.has(f));
+}
+
 // ---- data load (psql -> JSON) --------------------------------------------
 let ROWS = [];
 let LAST_REFRESH = new Date().toISOString();
 const Q = `SELECT json_agg(row_to_json(t)) FROM (
   SELECT mfr_sku, pattern_name, pattern_name_fr, color_name, collection, product_type,
-    material, width, image_url, product_url, description,
+    material, composition, width, image_url, product_url, description,
     coalesce(all_images,'') AS all_images,
     ai_colors, ai_styles, ai_tags, color_hex,
     to_char(coalesce(crawled_at, last_scraped, now()),'YYYY-MM-DD"T"HH24:MI:SS') AS created_at
   FROM zuber_catalog ORDER BY id
 ) t;`;
 
-// Map raw catalog rows (from psql row_to_json OR the jsonl bundle — identical
-// shape) into the in-memory ROWS the API serves. Unchanged mapping across sources.
+// Map raw catalog rows (psql row_to_json OR jsonl bundle — identical shape) into
+// the in-memory ROWS the API serves. Color families + style/tag explosion baked here.
 function mapRows(raw) {
   ROWS = raw.map((r, i) => {
     let imgs = [];
@@ -56,28 +128,34 @@ function mapRows(raw) {
     if (!imgs.length && r.image_url) imgs = [r.image_url];
     const parseArr = (v) => Array.isArray(v) ? v : (typeof v === 'string' && v.trim() ? (() => { try { return JSON.parse(v); } catch { return []; } })() : []);
     const aiColors = parseArr(r.ai_colors);
+    const aiStyles = parseArr(r.ai_styles);
+    const aiTags = parseArr(r.ai_tags);
+    const material = r.material || r.composition || null; // Material facet fallback → composition
+    const image = r.image_url || imgs[0] || null;
+    const colorHex = r.color_hex || (aiColors[0] && aiColors[0].hex) || null;
     return {
       idx: i,
       sku: r.mfr_sku,
       mfr_sku: r.mfr_sku,
       pattern_name: r.pattern_name || r.mfr_sku,
       pattern_name_fr: r.pattern_name_fr || null,
-      // zuber_catalog.color_name is null for all rows; fall back to the enriched
-      // ai_colors[0].name (the same pattern Gracie uses) so cards + Color sort have context.
       color_name: r.color_name || (aiColors[0] && aiColors[0].name) || null,
       collection: r.collection || null,
       product_type: r.product_type || null,
-      material: r.material || null,
+      material,
       width: r.width || null,
-      image: r.image_url || imgs[0] || null,
+      image,
       images: imgs,
       image_count: imgs.length,
+      image_state: image ? 'Has image' : 'No image',
       product_url: r.product_url || null,
       description: r.description || null,
       ai_colors: aiColors,
-      ai_styles: parseArr(r.ai_styles),
-      ai_tags: parseArr(r.ai_tags),
-      color_hex: r.color_hex || (aiColors[0] && aiColors[0].hex) || null,
+      ai_styles: aiStyles,
+      ai_tags: aiTags,
+      color_hex: colorHex,
+      colors: colorFamilies(aiColors, colorHex), // family-ordered color set for the facet + chips
+      price_band: 'Quote-only',                  // whole heritage line is quote-only, made-to-order
       created_at: r.created_at || null,
     };
   });
@@ -105,26 +183,42 @@ load();
 // Only poll psql against a live DB; the jsonl bundle is static.
 if (!USE_JSONL) setInterval(load, REFRESH_SEC * 1000);
 
-// ---- facets (field-tab tables) -------------------------------------------
-function facets() {
-  const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
-  const collection = {}, product_type = {}, material = {}, style = {}, tag = {};
-  for (const r of ROWS) {
-    bump(collection, r.collection);
-    bump(product_type, r.product_type);
-    bump(material, r.material);
-    for (const s of r.ai_styles) bump(style, s);
-    for (const t of r.ai_tags) bump(tag, t);
-  }
-  const byCount = (o) => Object.entries(o).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
-  const byAlpha = (o) => Object.entries(o).sort((a, b) => a[0].localeCompare(b[0])).map(([value, count]) => ({ value, count }));
-  return { collection: byAlpha(collection), product_type: byCount(product_type), material: byCount(material), style: byCount(style), tag: byCount(tag) };
+// ---- filtering + cross-filtered facets -----------------------------------
+// Parse the multi-select filter state from the query string. Values are '|'-joined.
+function parseFilters(q) {
+  const sel = (k) => { const v = q[k]; return new Set(v ? v.toString().split('|').filter(Boolean) : []); };
+  return {
+    q: (q.q || '').toString().trim().toLowerCase(),
+    collection: sel('collection'), types: sel('product_type'), materials: sel('material'),
+    colors: sel('color'), styles: sel('style'), tags: sel('tag'), prices: sel('price'), images: sel('image'),
+  };
+}
+// Apply every active filter EXCEPT the one named in opts.skip — that's what makes the
+// facet counts cross-filtered (all.dw parity): a dimension's tally reflects the OTHER
+// selections but not its own, so its own options never collapse to the picked value.
+function applyFilters(rows, f, opts = {}) {
+  const hasAny = (arr, set) => set.size === 0 || arr.some((v) => set.has(v));
+  const inSet = (val, set) => set.size === 0 || set.has(val ?? '');
+  let out = rows;
+  if (opts.skip !== 'collection' && f.collection.size) out = out.filter((r) => inSet(r.collection, f.collection));
+  if (opts.skip !== 'type' && f.types.size) out = out.filter((r) => inSet(r.product_type, f.types));
+  if (opts.skip !== 'material' && f.materials.size) out = out.filter((r) => inSet(r.material, f.materials));
+  if (opts.skip !== 'color' && f.colors.size) out = out.filter((r) => hasAny(r.colors, f.colors));
+  if (opts.skip !== 'style' && f.styles.size) out = out.filter((r) => hasAny(r.ai_styles, f.styles));
+  if (opts.skip !== 'tag' && f.tags.size) out = out.filter((r) => hasAny(r.ai_tags, f.tags));
+  if (opts.skip !== 'price' && f.prices.size) out = out.filter((r) => inSet(r.price_band, f.prices));
+  if (opts.skip !== 'image' && f.images.size) out = out.filter((r) => inSet(r.image_state, f.images));
+  if (opts.skip !== 'q' && f.q) out = out.filter((r) => hay(r).includes(f.q));
+  return out;
 }
+const hay = (r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase();
+
+// tally a scalar field into { value: count }; tallyArr for an array field (styles/tags/colors).
+function tally(rows, key) { const o = {}; for (const r of rows) { const v = r[key]; if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
+function tallyArr(rows, key) { const o = {}; for (const r of rows) for (const v of (r[key] || [])) { if (v != null && v !== '') o[v] = (o[v] || 0) + 1; } return o; }
 
 // ---- api ------------------------------------------------------------------
 const cmp = (a, b) => String(a ?? '').localeCompare(String(b ?? ''), undefined, { sensitivity: 'base' });
-// hue() maps a #rrggbb hex to a 0-360 color-wheel bucket (grays/unknown → 999 so they
-// sort last). Identical helper the sibling viewers (fromental/crezana) use for Color sort.
 function hue(hex) {
   if (!hex || hex[0] !== '#' || hex.length !== 7) return 999;
   const r = parseInt(hex.slice(1, 3), 16) / 255, g = parseInt(hex.slice(3, 5), 16) / 255, b = parseInt(hex.slice(5, 7), 16) / 255;
@@ -147,23 +241,33 @@ app.get('/api/config', (_req, res) => res.json({
   title: 'Zuber — Designer Wallcoverings (internal)', total: ROWS.length,
   lastRefresh: LAST_REFRESH, samples_only: true,
 }));
-app.get('/api/facets', (_req, res) => res.json({ total: ROWS.length, facets: facets() }));
+
+// Cross-filtered facets — each dimension's counts skip its own selection.
+app.get('/api/facets', (req, res) => {
+  const f = parseFilters(req.query);
+  res.json({
+    total: applyFilters(ROWS, f).length,
+    all: ROWS.length,
+    product_type: tally(applyFilters(ROWS, f, { skip: 'type' }), 'product_type'),
+    collection: tally(applyFilters(ROWS, f, { skip: 'collection' }), 'collection'),
+    color: tallyArr(applyFilters(ROWS, f, { skip: 'color' }), 'colors'),
+    style: tallyArr(applyFilters(ROWS, f, { skip: 'style' }), 'ai_styles'),
+    material: tally(applyFilters(ROWS, f, { skip: 'material' }), 'material'),
+    price: tally(applyFilters(ROWS, f, { skip: 'price' }), 'price_band'),
+    tag: tallyArr(applyFilters(ROWS, f, { skip: 'tag' }), 'ai_tags'),
+    image_state: tally(applyFilters(ROWS, f, { skip: 'image' }), 'image_state'),
+    family_order: FAMILY_ORDER, family_swatch: FAMILY_SWATCH,
+  });
+});
 
 app.get('/api/products', (req, res) => {
-  const q = (req.query.q || '').toString().trim().toLowerCase();
+  const f = parseFilters(req.query);
   const sort = (req.query.sort || 'newest').toString();
-  const sel = (k) => { const v = req.query[k]; return v ? v.toString().split('|').filter(Boolean) : []; };
-  const fColl = sel('collection'), fType = sel('product_type'), fMat = sel('material'), fStyle = sel('style'), fTag = sel('tag');
-  const inAny = (arr, val) => arr.length === 0 || arr.includes(val ?? '');
-  let rows = ROWS.filter((r) =>
-    inAny(fColl, r.collection) && inAny(fType, r.product_type) && inAny(fMat, r.material) &&
-    (fStyle.length === 0 || r.ai_styles.some((s) => fStyle.includes(s))) &&
-    (fTag.length === 0 || r.ai_tags.some((t) => fTag.includes(t))));
-  if (q) rows = rows.filter((r) => ((r.pattern_name || '') + ' ' + (r.pattern_name_fr || '') + ' ' + (r.mfr_sku || '') + ' ' + (r.collection || '') + ' ' + (r.description || '')).toLowerCase().includes(q));
+  let rows = applyFilters(ROWS, f);
   if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
   res.json({ total: rows.length, all: ROWS.length, products: rows });
 });
 
 app.use(express.static(path.join(__dirname, 'public')));
 
-app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT}  (basic-auth ${AUTH_USER})`));
+app.listen(PORT, () => console.log(`Zuber internal viewer -> http://127.0.0.1:${PORT}  (basic-auth ${AUTH_USER}, source=${USE_JSONL ? 'jsonl' : 'psql'})`));

← ada4b82 jsonl-backed data layer: self-contained bundle for Kamatera  ·  back to Zuber Internal  ·  Add Activation Calendar tab (go-live date+time per staged SK 8879146 →