[object Object]

← back to Fromental Internal

Upgrade viewer rail to all.dw feature-parity: active-filter chips, collapsible facet sections with counts, color-family swatches, style/tag explosion, price bands, drill-down /api/facets (both jsonl+pg modes)

f0fa00c42a879aa89730c3b880bcfe57b1f77df9 · 2026-07-09 16:08:01 -0700 · Steve Abrams

Files touched

Diff

commit f0fa00c42a879aa89730c3b880bcfe57b1f77df9
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 9 16:08:01 2026 -0700

    Upgrade viewer rail to all.dw feature-parity: active-filter chips, collapsible facet sections with counts, color-family swatches, style/tag explosion, price bands, drill-down /api/facets (both jsonl+pg modes)
---
 public/index.html | 240 +++++++++++++++++++++++++++++++++++-----------
 server.js         | 280 +++++++++++++++++++++++++++++++++++++++++-------------
 2 files changed, 398 insertions(+), 122 deletions(-)

diff --git a/public/index.html b/public/index.html
index 527bb38..8ff5041 100644
--- a/public/index.html
+++ b/public/index.html
@@ -22,22 +22,39 @@
   input[type=range]{width:120px;accent-color:var(--acc)}
   .refresh{font-size:11px;color:var(--mut);border:1px solid var(--line);border-radius:20px;padding:3px 10px}
 
-  .wrap{display:grid;grid-template-columns:260px 1fr;gap:0;align-items:start}
-  aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:14px;background:#fff}
-  aside .search{width:100%;margin-bottom:10px}
-  .clearbtn{width:100%;text-align:left;font-size:12px;color:var(--acc);background:none;border:none;cursor:pointer;padding:6px 2px;margin-bottom:6px}
-  details.facet{border-bottom:1px solid var(--line)}
-  details.facet>summary{cursor:pointer;list-style:none;padding:8px 2px;font-size:12px;font-weight:600;letter-spacing:.03em;text-transform:uppercase;color:#4a453f;display:flex;justify-content:space-between;align-items:center}
-  details.facet>summary::-webkit-details-marker{display:none}
-  details.facet>summary .badge{background:var(--acc);color:#fff;border-radius:10px;font-size:10px;padding:1px 7px;font-weight:600;text-transform:none;letter-spacing:0}
-  details.facet[data-active="1"]>summary{color:var(--acc)}
-  .facet .rows{padding:2px 0 8px}
-  .facet .row{display:flex;justify-content:space-between;gap:8px;font-size:12.5px;padding:3px 6px;cursor:pointer;border-radius:3px;color:#3a352f}
-  .facet .row:hover{background:#f2efe9}
-  .facet .row.sel{background:var(--acc);color:#fff}
-  .facet .row .n{color:var(--mut);font-variant-numeric:tabular-nums}
-  .facet .row.sel .n{color:#dff3f0}
-  .cardfields{padding:8px 2px 2px}
+  .wrap{display:grid;grid-template-columns:266px 1fr;gap:0;align-items:start}
+  aside{position:sticky;top:57px;max-height:calc(100vh - 57px);overflow:auto;border-right:1px solid var(--line);padding:12px 10px 60px;background:#fff}
+  aside .search{width:100%;margin-bottom:8px}
+
+  /* ── active-filter chip row (ported from all.dw #activeRow) ── */
+  .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px}
+  .afilter{background:#f2efe9;border:1px solid var(--acc);color:#3a352f;border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer;display:inline-flex;align-items:center;gap:5px}
+  .afilter b{color:var(--acc);font-weight:600}
+  .afilter .sw{width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}
+  .clearall{background:none;border:1px solid var(--line);color:var(--mut);border-radius:20px;padding:3px 11px;font-size:11px;cursor:pointer}
+
+  /* ── Astek-style collapsed accordion sections per field (ported from all.dw details.sec) ── */
+  details.sec{border:1px solid var(--line);border-radius:7px;margin-bottom:7px;background:#fff}
+  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}
+  details.sec>summary::-webkit-details-marker{display:none}
+  details.sec>summary::before{content:'▸';color:var(--acc);font-size:10px}
+  details.sec[open]>summary::before{content:'▾'}
+  details.sec>summary .n{margin-left:auto;background:var(--acc);color:#fff;border-radius:20px;padding:0 7px;font-size:10px;font-weight:700}
+  details.sec>summary .n:empty{display:none}
+  .fbody{padding:2px 8px 9px}
+  .fitem{display:flex;align-items:center;gap:7px;padding:2.5px 2px;cursor:pointer;font-size:12.5px;color:#3a352f;border-radius:5px}
+  .fitem:hover{background:#f2efe9}
+  .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;color:#fff}
+  .fitem.on .box{background:var(--acc);border-color:var(--acc)}
+  .fitem.on{color:var(--acc)}
+  .fitem .sw{width:12px;height:12px;flex:0 0 12px;border-radius:3px;border:1px solid rgba(0,0,0,.15)}
+  .fitem .lbl{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+  .fitem .ct{color:var(--mut);font-size:10.5px;font-variant-numeric:tabular-nums}
+  .fitem.zero{opacity:.4}
+  .more{color:var(--acc);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-radius:6px !important;margin:2px 0 5px;border:1px solid var(--line)}
+  .fnone{font-size:10.5px;color:var(--mut);padding:3px 2px}
+  .cardfields{padding:6px 8px 4px}
   .cardfields .cf{display:flex;align-items:center;gap:7px;font-size:12.5px;padding:3px 4px;color:#3a352f}
 
   main{padding:14px}
@@ -102,13 +119,13 @@
 </header>
 
 <div class="wrap">
-  <aside>
+  <aside id="rail">
     <input class="search" type="search" id="search" placeholder="Search pattern, color, SKU…" list="patterns" />
     <datalist id="patterns"></datalist>
-    <button class="clearbtn" id="clear">✕ Clear filters</button>
-    <div id="facets"></div>
-    <details class="facet" open>
-      <summary>Card fields</summary>
+    <div class="active" id="activeRow"></div>
+
+    <details class="sec" data-k="fields">
+      <summary>Card fields<span class="n"></span></summary>
       <div class="cardfields" id="cardfields">
         <label class="cf"><input type="checkbox" data-f="ttl" checked> Title</label>
         <label class="cf"><input type="checkbox" data-f="col" checked> Color</label>
@@ -116,6 +133,14 @@
         <label class="cf"><input type="checkbox" data-f="when" checked> Created date</label>
       </div>
     </details>
+    <details class="sec" data-k="type"><summary>Type<span class="n" id="nType"></span></summary><div class="fbody" id="typeRow"></div></details>
+    <details class="sec" data-k="collection"><summary>Book / Series<span class="n" id="nCollection"></span></summary><div class="fbody" id="collectionRow"></div></details>
+    <details class="sec" data-k="color"><summary>Color<span class="n" id="nColor"></span></summary><div class="fbody" id="colorRow"></div></details>
+    <details class="sec" data-k="style"><summary>Style<span class="n" id="nStyle"></span></summary><div class="fbody" id="styleRow"></div></details>
+    <details class="sec" data-k="material"><summary>Material<span class="n" id="nMaterial"></span></summary><div class="fbody" id="matRow"></div></details>
+    <details class="sec" data-k="price"><summary>Price<span class="n" id="nPrice"></span></summary><div class="fbody" id="priceRow"></div></details>
+    <details class="sec" data-k="tag"><summary>Tags<span class="n" id="nTag"></span></summary><div class="fbody" id="tagRow"></div></details>
+    <details class="sec" data-k="image"><summary>Image<span class="n" id="nImage"></span></summary><div class="fbody" id="imgRow"></div></details>
   </aside>
 
   <main>
@@ -127,23 +152,48 @@
 
 <script>
 const ORIGIN = location.origin;
-const state = { all: [], facets: {}, filters: {}, q: '', sort: 'newest' };
-const FACET_LABELS = { type:'Type', collection:'Collection', color:'Color', material:'Material', width:'Width', style:'Style', tag:'Tag', pricing:'Pricing' };
+const esc = (s)=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
+
+// ── filter state: one Set per facet field (multi-select), persisted to localStorage ──
+const loadSet = (k)=>{ try{ return new Set(JSON.parse(localStorage.getItem(k)||'[]')); }catch{ return new Set(); } };
+const state = {
+  all: [], facets: {}, sort: localStorage.getItem('fr_sort')||'newest', q: '',
+  types: loadSet('fr_types'), collections: loadSet('fr_collections'), colors: loadSet('fr_colors'),
+  styles: loadSet('fr_styles'), materials: loadSet('fr_materials'), prices: loadSet('fr_prices'),
+  tags: loadSet('fr_tags'), images: loadSet('fr_images'),
+};
+// field → {stateKey, rowId, summaryId, label, apiKey, array?, swatch?, orderKey?}
+const FACETS = [
+  { k:'types',       row:'typeRow',       n:'nType',       label:'Type',        api:'type',        arr:false },
+  { k:'collections', row:'collectionRow', n:'nCollection', label:'Book',        api:'collection',  arr:false },
+  { k:'colors',      row:'colorRow',      n:'nColor',      label:'Color',       api:'color',       arr:true,  swatch:true, orderKey:'family_order' },
+  { k:'styles',      row:'styleRow',      n:'nStyle',      label:'Style',       api:'style',       arr:true  },
+  { k:'materials',   row:'matRow',        n:'nMaterial',   label:'Material',    api:'material',    arr:false },
+  { k:'prices',      row:'priceRow',      n:'nPrice',      label:'Price',       api:'price_band',  arr:false, orderKey:'price_order' },
+  { k:'tags',        row:'tagRow',        n:'nTag',        label:'Tag',         api:'tag',         arr:true  },
+  { k:'images',      row:'imgRow',        n:'nImage',      label:'Image',       api:'image_state', arr:false, orderKey:'image_order' },
+];
+// per-row UI state: expanded? + type-ahead needle
+const rowUI = {};
+FACETS.forEach(f=> rowUI[f.row] = { expand:false, find:'', typeahead:(f.api==='tag'||f.api==='color'||f.api==='style') });
+
+function persistFacets(){ FACETS.forEach(f=> localStorage.setItem('fr_'+f.k, JSON.stringify([...state[f.k]]))); }
 
 function lum(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return null; const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return .2126*r+.7152*g+.0722*b; }
 function hue(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return 999; let 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; if(d===0)return 999; 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; return h; }
 function fmtDate(d){ if(!d) return ''; try{ return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return ''; } }
 
+// ── client-side filter (mirrors server applyFilters for the grid render) ──
 function matches(p){
   if(state.q){ const q=state.q.toLowerCase(); const hay=[p.pattern,p.color,p.sku,p.type,p.collection].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
-  for(const [field,val] of Object.entries(state.filters)){
-    if(val==null) continue;
-    if(field==='pricing'){ const isQuote=p.quote; if((val==='Quote-only')!==isQuote) return false; continue; }
-    if(field==='style'){ if(!(p.styles||[]).includes(val)) return false; continue; }
-    if(field==='tag'){ if(!(p.aiTags||[]).includes(val)) return false; continue; }
-    const map={type:'type',collection:'collection',color:'color',material:'material',width:'width'};
-    if(p[map[field]]!==val) return false;
-  }
+  if(state.types.size && !state.types.has(p.type)) return false;
+  if(state.collections.size && !state.collections.has(p.collection)) return false;
+  if(state.colors.size && !(p.families||[]).some(x=>state.colors.has(x))) return false;
+  if(state.styles.size && !(p.styles||[]).some(x=>state.styles.has(x))) return false;
+  if(state.materials.size && !state.materials.has(p.material)) return false;
+  if(state.prices.size && !state.prices.has(p.priceBand)) return false;
+  if(state.tags.size && !(p.aiTags||[]).some(x=>state.tags.has(x))) return false;
+  if(state.images.size && !state.images.has(p.imageState)) return false;
   return true;
 }
 function sortRows(rows){
@@ -171,7 +221,7 @@ function render(){
     const chips=[];
     if(p.type) chips.push(`<span class="chip">${esc(p.type)}</span>`);
     chips.push(p.quote?`<span class="chip q">Quote</span>`:(p.price!=null?`<span class="chip p">$${p.price.toLocaleString()}</span>`:''));
-    const dot=p.hex?`<span class="dot" style="background:${p.hex}"></span>`:'';
+    const dot=p.hex?`<span class="dot" style="background:${esc(p.hex)}"></span>`:'';
     return `<div class="card" data-sku="${esc(p.sku)}">
       <div class="imgwrap">${p.image?`<img loading="lazy" src="${esc(p.image)}" alt="${esc(p.pattern||'')}">`:''}</div>
       <div class="b">
@@ -184,30 +234,101 @@ function render(){
   }).join('');
   applyCardFields();
 }
-function esc(s){ return String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
 
-// facets
-function renderFacets(){
-  const host=document.getElementById('facets');
-  host.innerHTML=Object.entries(state.facets).map(([field,rows])=>{
-    if(!rows||!rows.length) return '';
-    const active=state.filters[field];
-    const rowsHtml=rows.map(([v,n])=>`<div class="row ${active===v?'sel':''}" data-field="${field}" data-val="${esc(v)}"><span>${esc(v)}</span><span class="n">${n}</span></div>`).join('');
-    return `<details class="facet" data-active="${active?1:0}">
-      <summary>${FACET_LABELS[field]||field} ${active?`<span class="badge">${esc(active)}</span>`:''}</summary>
-      <div class="rows">${rowsHtml}</div>
-    </details>`;
-  }).join('');
+// ── Amazon-style vertical facet list (ported from all.dw facetList): checkbox rows + counts,
+//    type-ahead on big rows, See-more expander, selected values always visible, optional swatch. ──
+function facetList(f, counts){
+  const row=document.getElementById(f.row); if(!row) return;
+  row.innerHTML='';
+  const set=state[f.k], ui=rowUI[f.row];
+  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; facetList(f, LAST_FACETS[f.api]); const f2=row.querySelector('.ffind'); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
+    row.appendChild(find);
+  }
+  let entries=Object.entries(counts||{});
+  const order = f.orderKey ? (state.facets[f.orderKey]||[]) : null;
+  entries = order
+    ? entries.sort((a,b)=> (order.indexOf(a[0])<0?999:order.indexOf(a[0])) - (order.indexOf(b[0])<0?999: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 = f.api==='color'||f.api==='tag' ? 12 : (f.api==='style'?10: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.className='fnone'; s.textContent='no data'; row.appendChild(s); return; }
+  const swatchMap = f.swatch ? (state.facets.family_swatch||{}) : null;
+  shown.forEach(([val,ct])=>{
+    const el=document.createElement('div');
+    el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+    const sw = swatchMap ? `<span class="sw" style="background:${esc(swatchMap[val]||'#ccc')}"></span>` : '';
+    el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
+    el.onclick=()=> toggleSet(f.k, val);
+    row.appendChild(el);
+  });
+  if(hidden>0){
+    const el=document.createElement('span'); el.className='more'; el.textContent=`See ${hidden.toLocaleString()} more ▾`;
+    el.onclick=()=>{ ui.expand=true; facetList(f, LAST_FACETS[f.api]); }; row.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; facetList(f, LAST_FACETS[f.api]); }; row.appendChild(el);
+  }
+}
+
+let LAST_FACETS={};
+function renderFacets(f){
+  LAST_FACETS=f; state.facets=f;
+  FACETS.forEach(def=> facetList(def, f[def.api]));
+  renderActive();
+}
+
+// ── active-filter chip row + per-section count badges (ported from all.dw renderActive) ──
+function renderActive(){
+  const row=document.getElementById('activeRow'); row.innerHTML='';
+  const swatchMap=state.facets.family_swatch||{};
+  FACETS.forEach(f=>{ const b=document.getElementById(f.n); if(b){ const n=state[f.k].size; b.textContent=n||''; } });
+  let n=0;
+  FACETS.forEach(f=>{
+    state[f.k].forEach(v=>{
+      n++;
+      const el=document.createElement('span'); el.className='afilter';
+      const sw = f.swatch ? `<span class="sw" style="background:${esc(swatchMap[v]||'#ccc')}"></span>` : '';
+      el.innerHTML=`${sw}<b>${f.label}:</b> ${esc(v)} ✕`;
+      el.onclick=()=>{ state[f.k].delete(v); applyFilterChange(); };
+      row.appendChild(el);
+    });
+  });
+  if(n){
+    const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all';
+    c.onclick=()=>{ FACETS.forEach(f=> state[f.k].clear()); applyFilterChange(); };
+    row.appendChild(c);
+  }
+}
+
+function applyFilterChange(){ persistFacets(); refreshFacets(); render(); }
+function toggleSet(key, val){ const set=state[key]; set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+
+function facetParams(){
+  return new URLSearchParams({
+    q: state.q,
+    types:[...state.types].join(','), collections:[...state.collections].join(','),
+    colors:[...state.colors].join(','), styles:[...state.styles].join(','),
+    materials:[...state.materials].join(','), prices:[...state.prices].join(','),
+    tags:[...state.tags].join(','), images:[...state.images].join(','),
+  });
+}
+async function refreshFacets(){
+  try{ const f=await fetch(ORIGIN+'/api/facets?'+facetParams()).then(r=>r.json()); renderFacets(f); }
+  catch(e){ /* facets degrade silently — grid still works */ }
 }
 
 // events
 document.addEventListener('click',(e)=>{
-  const row=e.target.closest('.facet .row');
-  if(row){ const f=row.dataset.field,v=row.dataset.val; state.filters[f]=(state.filters[f]===v)?null:v; renderFacets(); render(); return; }
   const card=e.target.closest('.card'); if(card){ openDrawer(card.dataset.sku); }
 });
-document.getElementById('clear').addEventListener('click',()=>{ state.filters={}; state.q=''; document.getElementById('search').value=''; renderFacets(); render(); });
-document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); });
+document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); refreshFacets(); });
 document.getElementById('sort').addEventListener('change',(e)=>{ state.sort=e.target.value; localStorage.setItem('fr_sort',state.sort); render(); });
 document.getElementById('density').addEventListener('input',(e)=>setDensity(+e.target.value));
 function setDensity(cols){
@@ -216,6 +337,14 @@ function setDensity(cols){
   document.documentElement.style.setProperty('--fs',fs);
   localStorage.setItem('fr_density',cols); document.getElementById('density').value=cols;
 }
+
+// accordion sections: persist open/closed per section (collapsed by default)
+document.querySelectorAll('details.sec').forEach((d)=>{
+  d.open = localStorage.getItem('fr_sec_'+d.dataset.k)==='1';
+  d.ontoggle = ()=> localStorage.setItem('fr_sec_'+d.dataset.k, d.open?'1':'0');
+});
+
+// card fields
 document.querySelectorAll('#cardfields input').forEach(cb=>cb.addEventListener('change',()=>{ saveCardFields(); applyCardFields(); }));
 function saveCardFields(){ const o={}; document.querySelectorAll('#cardfields input').forEach(cb=>o[cb.dataset.f]=cb.checked); localStorage.setItem('fr_cf',JSON.stringify(o)); }
 function applyCardFields(){
@@ -238,8 +367,10 @@ async function openDrawer(sku){
   const rowsSpec=[
     ['SKU',p.sku],['Color',p.color],['Type',p.type],['Collection',p.collection],
     ['Material',p.material],['Width',p.width],['Vendor',p.vendor],
+    ['Color families',(p.families||[]).join(', ')],
     ['Price', p.quote?'Quote-only (made to order)':(p.price!=null?'$'+p.price.toLocaleString():'—')],
     ['Styles',(p.styles||[]).join(', ')],
+    ['Tags',(p.aiTags||[]).slice(0,10).join(', ')],
   ].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${esc(r[1])}</span></div>`).join('');
   panel.innerHTML=`
     <div style="position:relative"><span class="close" onclick="document.getElementById('drawer').classList.remove('open')">×</span>
@@ -261,23 +392,22 @@ document.getElementById('drawer').addEventListener('click',(e)=>{ if(e.target.id
 // meta refresh pill
 async function tickMeta(){
   try{ const m=await fetch(ORIGIN+'/api/meta').then(r=>r.json());
-    document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched`;
+    document.getElementById('refresh').textContent=`↻ ${m.count} · ${m.enriched} enriched · ${m.source}`;
   }catch{}
 }
 
 async function boot(){
-  // restore prefs
   setDensity(+(localStorage.getItem('fr_density')||6));
-  state.sort=localStorage.getItem('fr_sort')||'newest'; document.getElementById('sort').value=state.sort;
+  document.getElementById('sort').value=state.sort;
   const cf=localStorage.getItem('fr_cf'); if(cf){ try{ const o=JSON.parse(cf); document.querySelectorAll('#cardfields input').forEach(cb=>{ if(o[cb.dataset.f]===false) cb.checked=false; }); }catch{} }
 
   const [prod,fac]=await Promise.all([
     fetch(ORIGIN+'/api/products').then(r=>r.json()),
-    fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+    fetch(ORIGIN+'/api/facets?'+facetParams()).then(r=>r.json()),
   ]);
-  state.all=prod.products; state.facets=fac;
+  state.all=prod.products;
   document.getElementById('patterns').innerHTML=[...new Set(state.all.map(p=>p.pattern).filter(Boolean))].slice(0,400).map(v=>`<option value="${esc(v)}">`).join('');
-  renderFacets(); render(); tickMeta(); setInterval(tickMeta,60000);
+  renderFacets(fac); render(); tickMeta(); setInterval(tickMeta,60000);
 }
 boot();
 </script>
diff --git a/server.js b/server.js
index 85a01e2..dc2f3f0 100644
--- a/server.js
+++ b/server.js
@@ -1,12 +1,16 @@
-/* Fromental — INTERNAL line viewer (basic-auth, read-only over dw_unified.fromental_catalog).
-   Built to Steve's internal-line-viewer standing spec (astek-landing :9944 is the reference):
-   left panel with search + per-field collapsible tab tables (values+counts, click-to-filter),
-   card-field visibility toggles down to image-only, type scale tied to the density slider,
-   created-date chips, location.origin fetches, sort + density persisted in localStorage.
-
-   Reads fromental_catalog LIVE (refreshes an in-memory snapshot every REFRESH_INTERVAL_SEC).
-   Fromental is CUSTOMER-FACING (not private-label) but this viewer is an internal curation
-   surface — quote-only, sample-order only, no Shopify links. */
+/* Fromental — INTERNAL line viewer (basic-auth, read-only).
+   Built to Steve's internal-line-viewer standing spec (astek-landing :9944) AND upgraded to
+   full feature-parity with all.designerwallcoverings.com's Amazon-style left rail:
+     - active-filter chip row (#activeRow) with removable pills + Clear all
+     - collapsible <details class="sec"> facet sections, each with a live count badge (.n)
+     - Amazon-style checkbox rows + counts + type-ahead (.ffind) + cap/expand "show more/less"
+     - server-computed /api/facets that recomputes each facet's counts from the CURRENT filtered
+       set (drill-down), the SAME way in BOTH data modes.
+
+   DATA PATHS (a HARD rail): jsonl mode NEVER touches a DB.
+     - LOCAL  : live Postgres over dw_unified.fromental_catalog (in-memory snapshot, periodic reload)
+     - KAMATERA: self-contained data/fromental.jsonl loaded into memory (static; no DB, no reload)
+   The JSONL rows are the exact pg row shape, so the mapping + facet logic below is mode-agnostic. */
 const express = require('express');
 const path = require('path');
 const fs = require('fs');
@@ -24,15 +28,96 @@ const USE_JSONL = process.env.DATA_SOURCE === 'jsonl' || (process.env.DATA_SOURC
 const app = express();
 app.use(require('compression')());
 
-let SNAP = { products: [], facets: {}, count: 0 };
+let SNAP = { products: [], count: 0 };
 let LAST_REFRESH = new Date().toISOString();
 
+// ── color families (hue buckets) ─────────────────────────────────────────────
+// A single hex → a coarse hue/lightness family. Ordered light→warm→cool→dark so the
+// Color facet reads like a paint deck. Each family carries a representative swatch hex
+// so the rail can render a color dot next to the family name.
+const FAMILY_ORDER = ['White', 'Cream', 'Beige', 'Grey', 'Silver', 'Warm', 'Red', 'Orange', 'Yellow', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Brown', 'Black', 'Neutral'];
+const FAMILY_SWATCH = {
+  White: '#f5f3ee', Cream: '#efe7d0', Beige: '#d8c7a8', Grey: '#9a9a9a', Silver: '#c4c7cc',
+  Warm: '#c8a26a', Red: '#c0392b', Orange: '#e07b39', Yellow: '#e4c14a', Green: '#4c8b52',
+  Teal: '#2f9c94', Blue: '#3f6fb0', Purple: '#7a5aa0', Pink: '#d98aa8', Brown: '#6b4a30',
+  Black: '#1c1c1c', Neutral: '#b9b2a6',
+};
+function hexToRgb(hex) {
+  if (!hex || hex[0] !== '#' || (hex.length !== 7 && hex.length !== 4)) return null;
+  let h = hex;
+  if (h.length === 4) h = '#' + h[1] + h[1] + h[2] + h[2] + h[3] + h[3];
+  const r = parseInt(h.slice(1, 3), 16), g = parseInt(h.slice(3, 5), 16), b = parseInt(h.slice(5, 7), 16);
+  if ([r, g, b].some(Number.isNaN)) return null;
+  return [r, g, b];
+}
+function rgbToHsl(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  let h = 0; const l = (mx + mn) / 2; const s = d === 0 ? 0 : d / (1 - Math.abs(2 * l - 1));
+  if (d !== 0) {
+    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;
+  }
+  return [h, s, l];
+}
+// Map a hex to one of FAMILY_ORDER. Lightness/saturation first (achromatics), then hue.
+function hexToFamily(hex) {
+  const rgb = hexToRgb(hex);
+  if (!rgb) return null;
+  const [h, s, l] = rgbToHsl(rgb[0], rgb[1], rgb[2]);
+  if (l >= 0.92) return 'White';
+  if (l <= 0.09) return 'Black';
+  if (s < 0.12) {           // near-grey axis
+    if (l >= 0.75) return 'Silver';
+    if (l >= 0.55) return 'Grey';
+    return 'Grey';
+  }
+  if (s < 0.28 && l >= 0.62) {  // muted, light → soft neutrals
+    if (h >= 30 && h <= 70) return 'Cream';
+    return 'Beige';
+  }
+  // low-sat mid/dark warm → Brown / Warm
+  if (s < 0.45 && l < 0.5 && (h < 45 || h >= 330)) return 'Brown';
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 40) return (l < 0.45 ? 'Brown' : (s < 0.5 ? 'Warm' : 'Orange'));
+  if (h < 50) return 'Orange';
+  if (h < 70) return 'Yellow';
+  if (h < 160) return 'Green';
+  if (h < 195) return 'Teal';
+  if (h < 255) return 'Blue';
+  if (h < 300) return 'Purple';
+  if (h < 345) return 'Pink';
+  return 'Neutral';
+}
+
+// ── price bands (tuned to the Fromental range: $20 – $13,745, median ~$1,015) ──
+const PRICE_BANDS = [
+  { key: 'Quote-only', test: null },              // special: matched by p.quote, handled below
+  { key: '≤$100', test: (p) => p <= 100 },
+  { key: '$100–500', test: (p) => p > 100 && p <= 500 },
+  { key: '$500–1k', test: (p) => p > 500 && p <= 1000 },
+  { key: '$1k–2.5k', test: (p) => p > 1000 && p <= 2500 },
+  { key: '$2.5k–5k', test: (p) => p > 2500 && p <= 5000 },
+  { key: '$5k+', test: (p) => p > 5000 },
+];
+const PRICE_ORDER = PRICE_BANDS.map((b) => b.key);
+function priceBand(p) {
+  if (p.quote || p.price == null) return 'Quote-only';
+  const b = PRICE_BANDS.find((x) => x.test && x.test(p.price));
+  return b ? b.key : 'Quote-only';
+}
+
+const IMAGE_ORDER = ['Has image', 'No image'];
+
 function titleCase(s) {
-  return String(s || '').replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+  return String(s || '').replace(/\w[\w'-]*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
 }
+// Normalize an ai_styles / ai_tags value so "Chinoiserie" and "chinoiserie" collapse to one.
+const normLabel = (s) => titleCase(String(s || '').trim());
 
 // Raw-row acquisition — jsonl bundle (self-contained) or live Postgres.
-// The JSONL rows are the exact pg row shape, so downstream mapping is unchanged.
 async function fetchRows() {
   if (USE_JSONL) {
     return fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
@@ -55,63 +140,123 @@ async function fetchRows() {
 async function load() {
   const rows = await fetchRows();
 
-  const products = rows.map((r) => ({
-    id: r.id,
-    sku: r.mfr_sku,
-    handle: r.handle,
-    pattern: r.pattern_name,
-    color: r.color_name,
-    collection: r.collection,
-    type: r.product_type,
-    material: r.material,
-    composition: r.composition,
-    width: r.width,
-    length: r.roll_length,
-    image: r.image_url,
-    gallery: r.gallery_images || [],
-    productUrl: r.product_url,
-    description: r.description || r.ai_description || null,
-    tags: r.tags || [],
-    vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
-    price: r.price != null ? Number(r.price) : null,
-    quote: !!r.quote_only && r.price == null,
-    hex: r.dominant_color_hex || r.color_hex || null,
-    styles: r.ai_styles || [],
-    aiTags: r.ai_tags || [],
-    enriched: !!r.ai_accepted_at,
-    createdAt: r.vendor_created_at || r.crawled_at,
-  }));
-
-  // Facets — one entry per filterable field: [value, count], sorted by count desc.
-  const facet = (keyFn) => {
-    const m = new Map();
-    for (const p of products) {
-      const vals = keyFn(p);
-      for (const v of (Array.isArray(vals) ? vals : [vals])) {
-        if (v == null || v === '') continue;
-        m.set(v, (m.get(v) || 0) + 1);
-      }
+  const products = rows.map((r) => {
+    const hex = r.dominant_color_hex || r.color_hex || null;
+    // color families: primary hex family + every ai_colors hex family (so a pattern with a
+    // green ground and blue motif shows under both Green and Blue).
+    const famSet = new Set();
+    const pf = hexToFamily(hex); if (pf) famSet.add(pf);
+    for (const c of (Array.isArray(r.ai_colors) ? r.ai_colors : [])) {
+      const f = hexToFamily(c && c.hex); if (f) famSet.add(f);
     }
-    return [...m.entries()].sort((a, b) => b[1] - a[1]);
-  };
-  const facets = {
-    type: facet((p) => p.type),
-    collection: facet((p) => p.collection),
-    color: facet((p) => p.color),
-    material: facet((p) => p.material),
-    width: facet((p) => p.width),
-    style: facet((p) => p.styles),
-    tag: facet((p) => p.aiTags),
-    pricing: facet((p) => (p.quote ? 'Quote-only' : 'Priced')),
-  };
+    const price = r.price != null && r.price !== '' ? Number(r.price) : null;
+    const quote = (!!r.quote_only && price == null) || price == null;
+    const styles = [...new Set((Array.isArray(r.ai_styles) ? r.ai_styles : []).map(normLabel).filter(Boolean))];
+    const aiTags = [...new Set((Array.isArray(r.ai_tags) ? r.ai_tags : []).map(normLabel).filter(Boolean))];
+    const material = r.material || r.composition || null;   // fall back composition → material
+    return {
+      id: r.id,
+      sku: r.mfr_sku,
+      handle: r.handle,
+      pattern: r.pattern_name,
+      color: r.color_name,
+      collection: r.collection,
+      type: r.product_type,
+      material,
+      composition: r.composition,
+      width: r.width,
+      length: r.roll_length,
+      image: r.image_url,
+      gallery: r.gallery_images || [],
+      productUrl: r.product_url,
+      description: r.description || r.ai_description || null,
+      tags: r.tags || [],
+      vendor: (r.vendor || 'Fromental').replace(/\s*Design$/i, ''),
+      price,
+      quote,
+      hex,
+      families: [...famSet],
+      styles,
+      aiTags,
+      imageState: r.image_url ? 'Has image' : 'No image',
+      enriched: !!r.ai_accepted_at,
+      createdAt: r.vendor_created_at || r.crawled_at,
+    };
+  });
+  // precompute priceBand once per product
+  for (const p of products) p.priceBand = priceBand(p);
 
-  SNAP = { products, facets, count: products.length };
+  SNAP = { products, count: products.length };
   LAST_REFRESH = new Date().toISOString();
-  console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched)`);
+  console.log(`[fromental] loaded ${products.length} products (${products.filter((p) => p.enriched).length} enriched, source=${USE_JSONL ? 'jsonl' : 'pg'})`);
+}
+
+// ── filtered-set + facet aggregation (mode-agnostic; runs over the in-memory SNAP) ──
+// Each facet field is a multi-select OR within the field, AND across fields — exactly like
+// all.dw. When tallying a given field, that field's own filter is skipped (drill-down counts).
+function parseSet(v) { return new Set(String(v || '').split(',').map((s) => s.trim()).filter(Boolean)); }
+
+function applyFilters(products, f, skip) {
+  return products.filter((p) => {
+    if (f.q) {
+      const hay = [p.pattern, p.color, p.sku, p.type, p.collection].filter(Boolean).join(' ').toLowerCase();
+      if (!hay.includes(f.q)) return false;
+    }
+    if (skip !== 'type' && f.types.size && !f.types.has(p.type)) return false;
+    if (skip !== 'collection' && f.collections.size && !f.collections.has(p.collection)) return false;
+    if (skip !== 'color' && f.colors.size && !p.families.some((x) => f.colors.has(x))) return false;
+    if (skip !== 'style' && f.styles.size && !p.styles.some((x) => f.styles.has(x))) return false;
+    if (skip !== 'material' && f.materials.size && !f.materials.has(p.material)) return false;
+    if (skip !== 'price' && f.prices.size && !f.prices.has(p.priceBand)) return false;
+    if (skip !== 'tag' && f.tags.size && !p.aiTags.some((x) => f.tags.has(x))) return false;
+    if (skip !== 'image' && f.images.size && !f.images.has(p.imageState)) return false;
+    return true;
+  });
+}
+// tally: {value: count} over a set of products for a single-valued or array-valued key.
+function tally(products, keyFn) {
+  const m = new Map();
+  for (const p of products) {
+    const vals = keyFn(p);
+    for (const v of (Array.isArray(vals) ? vals : [vals])) {
+      if (v == null || v === '') continue;
+      m.set(v, (m.get(v) || 0) + 1);
+    }
+  }
+  // return a plain object (client sorts by count / order)
+  const out = {};
+  for (const [k, n] of [...m.entries()].sort((a, b) => b[1] - a[1])) out[k] = n;
+  return out;
+}
+
+function computeFacets(query) {
+  const f = {
+    q: String(query.q || '').trim().toLowerCase(),
+    types: parseSet(query.types), collections: parseSet(query.collections),
+    colors: parseSet(query.colors), styles: parseSet(query.styles),
+    materials: parseSet(query.materials), prices: parseSet(query.prices),
+    tags: parseSet(query.tags), images: parseSet(query.images),
+  };
+  const P = SNAP.products;
+  return {
+    type: tally(applyFilters(P, f, 'type'), (p) => p.type),
+    collection: tally(applyFilters(P, f, 'collection'), (p) => p.collection),
+    color: tally(applyFilters(P, f, 'color'), (p) => p.families),
+    style: tally(applyFilters(P, f, 'style'), (p) => p.styles),
+    material: tally(applyFilters(P, f, 'material'), (p) => p.material),
+    price_band: tally(applyFilters(P, f, 'price'), (p) => p.priceBand),
+    tag: tally(applyFilters(P, f, 'tag'), (p) => p.aiTags),
+    image_state: tally(applyFilters(P, f, 'image'), (p) => p.imageState),
+    family_order: FAMILY_ORDER,
+    family_swatch: FAMILY_SWATCH,
+    price_order: PRICE_ORDER,
+    image_order: IMAGE_ORDER,
+    matched: applyFilters(P, f).length,
+    total: P.length,
+  };
 }
 
-// health probe BEFORE auth (deploy smoke + uptime monitors) — bare liveness only,
-// no catalog size/detail leaked to the unauthenticated probe (count stays behind auth via /api/meta)
+// health probe BEFORE auth (deploy smoke + uptime monitors) — bare liveness only.
 app.get('/healthz', (_req, res) => res.json({ ok: true }));
 
 app.use((req, res, next) => {
@@ -129,14 +274,15 @@ app.use(express.json({ limit: '2mb' }));
 app.get('/api/meta', (_req, res) => res.json({
   lastRefresh: LAST_REFRESH, intervalSec: REFRESH_INTERVAL_SEC,
   count: SNAP.count, enriched: SNAP.products.filter((p) => p.enriched).length,
-  now: new Date().toISOString(),
+  source: USE_JSONL ? 'jsonl' : 'pg', now: new Date().toISOString(),
 }));
 // light list — drop heavy fields the grid never renders
 app.get('/api/products', (_req, res) => res.json({
   count: SNAP.count,
-  products: SNAP.products.map(({ gallery, description, ...p }) => p),
+  products: SNAP.products.map(({ gallery, description, composition, ...p }) => p),
 }));
-app.get('/api/facets', (_req, res) => res.json(SNAP.facets));
+// facets recomputed from the current filtered set (query-param driven, like all.dw)
+app.get('/api/facets', (req, res) => res.json(computeFacets(req.query)));
 app.get('/api/product/:sku', (req, res) => {
   const p = SNAP.products.find((x) => x.sku === req.params.sku);
   if (!p) return res.status(404).json({ error: 'not found' });

← d3c9132 jsonl-backed data layer: self-contained bundle for Kamatera  ·  back to Fromental Internal  ·  Populate collection + material facets from Fromental collect ee94c52 →