[object Object]

← back to Crezana Internal

Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-family/Style/Material/Price/Tags/Image), type-ahead, cap/expand, color-family bucketing + style/tag explosion identical in pg + jsonl modes; sort incl Color(family) + dir; card-fields image-only toggle; density/text/date chips preserved

1ebbbb69d395e4c77e9ad98430b3579d467263ff · 2026-07-09 16:08:28 -0700 · Steve

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 1ebbbb69d395e4c77e9ad98430b3579d467263ff
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 16:08:28 2026 -0700

    Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-family/Style/Material/Price/Tags/Image), type-ahead, cap/expand, color-family bucketing + style/tag explosion identical in pg + jsonl modes; sort incl Color(family) + dir; card-fields image-only toggle; density/text/date chips preserved
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 339 ++++++++++++++++++++++++++++++++++++++++--------------
 server.js         | 171 +++++++++++++++++++++++----
 2 files changed, 403 insertions(+), 107 deletions(-)

diff --git a/public/index.html b/public/index.html
index 684e1cd..ac82e67 100644
--- a/public/index.html
+++ b/public/index.html
@@ -2,64 +2,116 @@
 <meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
 <title>Crezana Design — Internal Line Viewer</title>
 <style>
-  :root{--bg:#0e0f11;--panel:#16181c;--stroke:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;--teal:#3fb8af;--scale:1;}
+  :root{--bg:#0e0f11;--card:#16181c;--panel:#16181c;--line:#2a2d33;--stroke:#2a2d33;--txt:#e8e6e1;--mut:#8b8f96;
+        --accent:#3fb8af;--teal:#3fb8af;--rail:290px;--cols:6;--fs:1;--scale:1;}
   *{box-sizing:border-box}
   body{margin:0;font:14px/1.5 -apple-system,Segoe UI,Roboto,sans-serif;background:var(--bg);color:var(--txt)}
-  header{padding:14px 18px;border-bottom:1px solid var(--stroke);display:flex;align-items:center;gap:14px;position:sticky;top:0;background:var(--bg);z-index:5}
-  header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase}
+  a{color:var(--accent)}
+  header{padding:12px 16px;border-bottom:1px solid var(--line);display:flex;align-items:center;gap:12px;flex-wrap:wrap;
+    position:sticky;top:0;background:var(--bg);z-index:10}
+  header h1{font-size:16px;margin:0;letter-spacing:.14em;font-weight:600;text-transform:uppercase;display:flex;align-items:center;gap:8px}
   header .meta{color:var(--mut);font-size:12px}
-  .quote{margin-left:auto;color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
-  .layout{display:flex;align-items:flex-start}
-  aside{width:290px;flex:0 0 290px;border-right:1px solid var(--stroke);height:calc(100vh - 51px);overflow:auto;position:sticky;top:51px;padding:12px}
-  aside input[type=search]{width:100%;padding:8px 10px;background:var(--panel);border:1px solid var(--stroke);color:var(--txt);border-radius:8px;margin-bottom:10px}
-  .controls{display:flex;flex-direction:column;gap:8px;margin-bottom:12px}
-  .controls label{font-size:11px;color:var(--mut);display:flex;align-items:center;gap:8px;justify-content:space-between}
-  .controls input[type=range]{flex:1}
-  details{border:1px solid var(--stroke);border-radius:8px;margin-bottom:8px;background:var(--panel)}
-  details>summary{cursor:pointer;padding:8px 10px;font-size:12px;letter-spacing:.06em;text-transform:uppercase;color:var(--mut);list-style:none}
-  details>summary::-webkit-details-marker{display:none}
-  details[open]>summary{color:var(--txt);border-bottom:1px solid var(--stroke)}
-  .facet{display:flex;justify-content:space-between;padding:5px 10px;cursor:pointer;font-size:13px}
-  .facet:hover{background:#1d2026}
-  .facet.active{color:var(--teal)}
-  .badge{background:#1d2026;color:var(--teal);border-radius:10px;padding:0 7px;font-size:11px;min-width:22px;text-align:center}
-  main{flex:1;padding:14px}
-  .grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(var(--card,190px),1fr));gap:12px}
-  .card{border:1px solid var(--stroke);border-radius:8px;overflow:hidden;background:var(--panel)}
+  header input[type=search]{flex:1;min-width:200px;padding:8px 10px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px}
+  .bar{display:flex;align-items:center;gap:6px;font-size:11px;color:var(--mut);letter-spacing:.04em}
+  .bar select{background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:7px;padding:5px 7px;font-size:12px}
+  .bar input[type=range]{width:120px}
+  .quote{color:var(--teal);border:1px solid var(--teal);padding:3px 10px;border-radius:12px;font-size:11px;letter-spacing:.1em}
+  .dirbtn{background:var(--panel);border:1px solid var(--line);color:var(--accent);border-radius:7px;padding:5px 9px;cursor:pointer;font-family:inherit}
+  .dirbtn:hover{border-color:var(--accent)}
+  #railToggle{display:none}
+
+  /* ── layout: Amazon-style left rail + content ── */
+  .wrap{display:flex;align-items:flex-start}
+  aside{width:var(--rail);flex:0 0 var(--rail);position:sticky;top:57px;max-height:calc(100vh - 57px);
+    overflow-y:auto;padding:10px 10px 60px;border-right:1px solid var(--line)}
+
+  /* active-filter chip row */
+  .active{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:6px}
+  .afilter{background:#23211a;border:1px solid var(--accent);color:var(--txt);border-radius:20px;padding:3px 9px;font-size:11px;cursor:pointer}
+  .afilter b{color:var(--accent);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}
+  .clearall:hover{border-color:var(--accent);color:var(--txt)}
+
+  /* collapsible facet sections */
+  details.sec{border:1px solid var(--line);border-radius:7px;margin-bottom:7px;background:var(--card)}
+  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(--accent);font-size:10px}
+  details.sec[open]>summary::before{content:'▾'}
+  details.sec>summary .n{margin-left:auto;background:var(--accent);color:#0e0f11;border-radius:20px;padding:0 7px;font-size:10px;font-weight:700}
+  .fbody{padding:2px 8px 9px}
+  .fitem{display:flex;align-items:center;gap:7px;padding:2.5px 2px;cursor:pointer;font-size:12.5px;color:var(--txt);border-radius:5px}
+  .fitem:hover{background:#1d2026}
+  .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(--accent);border-color:var(--accent);color:#0e0f11;font-weight:700}
+  .fitem.on{color:var(--accent)}
+  .fitem .swatch{width:12px;height:12px;flex:0 0 12px;border-radius:50%;border:1px solid #0006}
+  .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(--accent);font-size:11.5px;cursor:pointer;padding:3px 2px;display:inline-block}
+  .ffind{width:100%;padding:4px 8px;font-size:11.5px;border-radius:6px;margin:2px 0 5px;background:var(--panel);border:1px solid var(--line);color:var(--txt)}
+  /* Card-fields toggle chips (image-only wall = all off) */
+  .fieldchip{display:inline-flex;align-items:center;gap:5px;font-size:11px;color:var(--mut);border:1px solid var(--line);
+    border-radius:6px;padding:3px 8px;margin:2px 3px 2px 0;cursor:pointer;background:var(--panel)}
+  .fieldchip.on{color:var(--accent);border-color:var(--accent)}
+
+  main{flex:1;min-width:0;padding:14px}
+  .grid{display:grid;grid-template-columns:repeat(var(--cols),1fr);gap:8px}
+  .card{border:1px solid var(--line);border-radius:6px;overflow:hidden;background:var(--card);display:flex;flex-direction:column}
   .card .imgwrap{aspect-ratio:1;background:#000;overflow:hidden}
   .card img{width:100%;height:100%;object-fit:cover;display:block}
-  .card .body{padding:8px}
-  .card .nm{font-size:calc(13px*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px}
-  .card .sub{font-size:calc(11px*var(--scale));color:var(--mut)}
-  .card .when{font-size:calc(10px*var(--scale));color:var(--mut);margin-top:5px}
+  .card .body{padding:calc(8px*var(--fs))}
+  .card .nm{font-size:calc(13px*var(--fs)*var(--scale));font-weight:600;line-height:1.25;margin-bottom:3px;color:var(--txt)}
+  .card .sub{font-size:calc(11px*var(--fs)*var(--scale));color:var(--mut)}
+  .card .when{font-size:calc(10px*var(--fs)*var(--scale));color:var(--mut);margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
   .card .dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #0006;vertical-align:middle;margin-right:5px}
   .card .tags{margin-top:5px;display:flex;flex-wrap:wrap;gap:3px}
-  .card .tag{font-size:calc(9px*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
+  .card .tag{font-size:calc(9px*var(--fs)*var(--scale));background:#1d2026;color:var(--mut);padding:1px 6px;border-radius:8px}
   body.imgonly .card .body{display:none}
   .empty{color:var(--mut);padding:40px;text-align:center}
+
+  @media(max-width:900px){
+    #railToggle{display:inline-block}
+    aside{position:fixed;left:0;top:57px;bottom:0;background:var(--bg);z-index:20;transform:translateX(-100%);transition:transform .2s}
+    aside.open{transform:none;box-shadow:6px 0 30px rgba(0,0,0,.5)}
+  }
 </style></head><body>
 <header>
-  <h1>Crezana Design</h1>
+  <h1>Crezana Design <span class="quote">QUOTE-ONLY · FABRIC</span></h1>
+  <button class="dirbtn" id="railToggle">☰ Filters</button>
   <span class="meta" id="meta">loading…</span>
-  <span class="quote">QUOTE-ONLY · FABRIC</span>
+  <input type="search" id="q" placeholder="Search pattern / sku / collection / description…">
+  <span class="bar">Sort
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="title">Title A→Z</option>
+      <option value="sku">SKU A→Z</option>
+      <option value="collection">Book / Series</option>
+      <option value="color">Color (family)</option>
+      <option value="style">Style</option>
+    </select>
+    <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
+  </span>
+  <span class="bar">Density <input type="range" id="dens" min="2" max="14" value="6"> <span id="densN"></span></span>
+  <span class="bar">Text <input type="range" id="tscale" min="80" max="150" value="100"></span>
+  <label class="bar"><input type="checkbox" id="imgonly"> Image-only wall</label>
 </header>
-<div class="layout">
-  <aside>
-    <input type="search" id="q" placeholder="Search pattern / sku / collection…">
-    <div class="controls">
-      <label>Sort
-        <select id="sort">
-          <option value="newest">Newest</option>
-          <option value="title">Title A→Z</option>
-          <option value="color">Color</option>
-          <option value="style">Style</option>
-        </select>
-      </label>
-      <label>Density <input type="range" id="density" min="120" max="340" value="190"></label>
-      <label>Text size <input type="range" id="tscale" min="80" max="150" value="100"></label>
-      <label><input type="checkbox" id="imgonly"> Image-only wall</label>
-    </div>
-    <div id="facets"></div>
+
+<div class="wrap">
+  <aside id="rail">
+    <input type="search" id="qRail" placeholder="Search…" style="width:100%;padding:7px 9px;background:var(--panel);border:1px solid var(--line);color:var(--txt);border-radius:8px;margin-bottom:8px;display:none">
+    <div class="active" id="activeRow"></div>
+    <details class="sec" data-k="fields"><summary>Card fields<span class="n" id="nFields"></span></summary><div class="fbody" id="fieldsRow"></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" open><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="tags"><summary>Tags<span class="n" id="nTags"></span></summary><div class="fbody" id="tagsRow"></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>
     <div class="grid" id="grid"></div>
@@ -68,77 +120,190 @@
 </div>
 <script>
 const API = location.origin;
-let ALL=[], activeFacets={};
-let SORT = localStorage.getItem('crz_sort') || 'newest';
-const fmtDate = d => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
-function hue(hex){ if(!hex) return 999; const m=/^#?([0-9a-f]{6})$/i.exec(hex); if(!m) return 999; const n=parseInt(m[1],16), r=(n>>16&255)/255, g=(n>>8&255)/255, b=(n&255)/255; const mx=Math.max(r,g,b), mn=Math.min(r,g,b), d=mx-mn; if(!d) return -1; 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; return (h*60+360)%360; }
+let ALL = [], LAST_FACETS = {};
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
+const fmtDate = (d) => d ? new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}) : '';
+
+/* ── persisted multi-select facet state (Sets, mirrored to localStorage) ── */
+const SETS = { types:'crz_types', collections:'crz_collections', colors:'crz_colors', styles:'crz_styles', materials:'crz_materials', prices:'crz_prices', tags:'crz_tags', images:'crz_images' };
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k)||'[]')); } catch { return new Set(); } };
+const state = {
+  q: '', sort: localStorage.getItem('crz_sort')||'newest', dir: localStorage.getItem('crz_dir')||'asc',
+  types:loadSet(SETS.types), collections:loadSet(SETS.collections), colors:loadSet(SETS.colors),
+  styles:loadSet(SETS.styles), materials:loadSet(SETS.materials), prices:loadSet(SETS.prices),
+  tags:loadSet(SETS.tags), images:loadSet(SETS.images),
+};
+function persistFacets(){ state.types=state.types; for(const [k,key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+// per-row UI state: expanded? + type-ahead needle
+const rowUI = {
+  collectionRow:{expand:false,find:''}, colorRow:{expand:false,find:''}, styleRow:{expand:false,find:'',typeahead:true},
+  matRow:{expand:false,find:''}, priceRow:{expand:false,find:''}, tagsRow:{expand:false,find:'',typeahead:true},
+  typeRow:{expand:false,find:''}, imgRow:{expand:false,find:''},
+};
+
+/* ── card-field visibility toggles (all off = image-only texture wall) ── */
+const FIELDS = [['nm','Pattern'],['sub','Collection · SKU'],['dot','Color dot'],['tags','Tags'],['when','Date']];
+let fieldOn = (()=>{ try{ return JSON.parse(localStorage.getItem('crz_fields')); }catch{} return null; })() || {nm:1,sub:1,dot:1,tags:1,when:1};
+function renderFieldChips(){
+  const box=document.getElementById('fieldsRow'); box.innerHTML='';
+  for(const [k,label] of FIELDS){
+    const el=document.createElement('span'); el.className='fieldchip'+(fieldOn[k]?' on':'');
+    el.innerHTML=`<span class="box" style="width:11px;height:11px;border:1px solid var(--mut);border-radius:3px;display:inline-flex">${fieldOn[k]?'✓':''}</span>${label}`;
+    el.onclick=()=>{ fieldOn[k]=fieldOn[k]?0:1; localStorage.setItem('crz_fields',JSON.stringify(fieldOn)); renderFieldChips(); render(); };
+    box.appendChild(el);
+  }
+  document.getElementById('nFields').textContent = Object.values(fieldOn).filter(Boolean).length;
+}
+
+/* ── sort ── */
+function hue(hex){ if(!hex) return 999; const m=/^#?([0-9a-f]{6})$/i.exec(hex); if(!m) return 999; const n=parseInt(m[1],16),r=(n>>16&255)/255,g=(n>>8&255)/255,b=(n&255)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; if(!d) return -1; 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; return (h*60+360)%360; }
+const FAMILY_ORDER = ['Red','Orange','Brown','Yellow','Gold','Green','Teal','Blue','Purple','Pink','Beige','Cream','Black','Gray','White','Multi'];
 function sortRows(rows){
+  const str=(x)=>String(x||'');
   const by={
     newest:(a,b)=> new Date(b.crawled_at||0)-new Date(a.crawled_at||0),
-    title:(a,b)=> (a.pattern_name||a.mfr_sku||'').localeCompare(b.pattern_name||b.mfr_sku||''),
-    color:(a,b)=> hue(a.dominant_color_hex||a.color_hex)-hue(b.dominant_color_hex||b.color_hex),
-    style:(a,b)=> ((a.ai_styles&&a.ai_styles[0])||'zz').localeCompare((b.ai_styles&&b.ai_styles[0])||'zz'),
+    title:(a,b)=> str(a.pattern_name||a.mfr_sku).localeCompare(str(b.pattern_name||b.mfr_sku)),
+    sku:(a,b)=> str(a.mfr_sku).localeCompare(str(b.mfr_sku)),
+    collection:(a,b)=> str(a.collection).localeCompare(str(b.collection)),
+    color:(a,b)=>{ const i=(x)=>{ const c=(x.colors||[])[0]; const k=c?FAMILY_ORDER.indexOf(c):-1; return k<0?99:k; }; return i(a)-i(b) || str(a.mfr_sku).localeCompare(str(b.mfr_sku)); },
+    style:(a,b)=> str((a.styles||[])[0]||'zz').localeCompare(str((b.styles||[])[0]||'zz')),
   };
-  return rows.slice().sort(by[SORT]||by.newest);
+  const out = rows.slice().sort(by[state.sort]||by.newest);
+  return state.dir==='desc' ? out.reverse() : out;
 }
 
+/* ── data load ── */
 async function load(){
   const [p,f] = await Promise.all([
     fetch(API+'/api/products').then(r=>r.json()),
-    fetch(API+'/api/facets').then(r=>r.json())
+    fetch(API+'/api/facets').then(r=>r.json()),
   ]);
   ALL = p.products;
   document.getElementById('meta').textContent = p.count+' images · '+new Set(ALL.map(x=>x.collection)).size+' collections';
+  LAST_FACETS = f;
+  renderFieldChips();
   renderFacets(f);
   render();
 }
+
+/* ── facet list rows: checkbox + count + type-ahead + cap/expand ── (ported from all.dw) */
+function toggleSet(set,val){ set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+function applyFilterChange(){ persistFacets(); renderFacets(LAST_FACETS); render(); }
+function facetList(rowId, items, set, opts={}){
+  const row=document.getElementById(rowId); row.innerHTML='';
+  const ui=rowUI[rowId]||{expand:false,find:''};
+  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=document.querySelector(`#${rowId} .ffind`); if(f2){ f2.focus(); f2.setSelectionRange(f2.value.length,f2.value.length); } };
+    row.appendChild(find);
+  }
+  let entries=(items||[]).map(it=>[it.v,it.n,it.dot]);
+  if(opts.order) entries.sort((a,b)=>opts.order.indexOf(a[0])-opts.order.indexOf(b[0]));
+  if(ui.find) entries=entries.filter(([v])=>String(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'; row.appendChild(s); return; }
+  shown.forEach(([val,ct,dot])=>{
+    const el=document.createElement('div'); el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+    const sw = dot ? `<span class="swatch" style="background:${dot}"></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(set,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; renderFacets(LAST_FACETS); }; 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; renderFacets(LAST_FACETS); }; row.appendChild(el); }
+}
+
 function renderFacets(f){
-  const box=document.getElementById('facets'); box.innerHTML='';
-  const groups=[['collection','Collection',f.collection],['styles','Style',f.styles],['tags','Tag',f.tags],['product_type','Type',f.product_type]];
-  for(const [key,label,items] of groups){
-    if(!items||!items.length) continue;
-    const d=document.createElement('details'); if(key==='collection') d.open=true;
-    d.innerHTML=`<summary>${label} (${items.length})</summary>`;
-    for(const it of items){
-      const row=document.createElement('div'); row.className='facet';
-      row.innerHTML=`<span>${it.v}</span><span class="badge">${it.n}</span>`;
-      row.onclick=()=>{ activeFacets[key]=activeFacets[key]===it.v?null:it.v; renderFacets(f); render(); };
-      if(activeFacets[key]===it.v) row.classList.add('active');
-      d.appendChild(row);
-    }
-    box.appendChild(d);
+  LAST_FACETS=f;
+  facetList('typeRow', f.product_type, state.types, {cap:6});
+  facetList('collectionRow', f.collection, state.collections, {cap:12});
+  facetList('colorRow', f.color, state.colors, {order:f.family_order||FAMILY_ORDER, cap:16});
+  facetList('styleRow', f.style, state.styles, {cap:12});
+  facetList('matRow', f.material, state.materials, {cap:12});
+  facetList('priceRow', f.price_band, state.prices, {cap:4});
+  facetList('tagsRow', f.tags, state.tags, {cap:14});
+  facetList('imgRow', f.image_state, state.images, {cap:4});
+  renderActive();
+}
+
+/* ── active-filter chip row + per-section count badges ── */
+const DIMS = [['Type','types','nType'],['Book','collections','nCollection'],['Color','colors','nColor'],['Style','styles','nStyle'],['Material','materials','nMaterial'],['Price','prices','nPrice'],['Tag','tags','nTags'],['Image','images','nImage']];
+function renderActive(){
+  const row=document.getElementById('activeRow'); row.innerHTML='';
+  for(const [,k,badgeId] of DIMS){ const b=document.getElementById(badgeId); if(b){ b.textContent=state[k].size||''; b.style.display=state[k].size?'':'none'; } }
+  let n=0;
+  for(const [label,k] of DIMS){
+    state[k].forEach((v)=>{
+      n++;
+      const el=document.createElement('span'); el.className='afilter';
+      el.innerHTML=`<b>${label}:</b> ${esc(v)} ✕`;
+      el.onclick=()=>{ state[k].delete(v); applyFilterChange(); };
+      row.appendChild(el);
+    });
   }
+  if(n){ const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all'; c.onclick=()=>{ DIMS.forEach(([,k])=>state[k].clear()); applyFilterChange(); }; row.appendChild(c); }
 }
+
+/* ── filter predicate (client-side, all rows in RAM) ── */
 function matches(p){
-  const q=document.getElementById('q').value.trim().toLowerCase();
+  const q=state.q;
   if(q){ const hay=[p.pattern_name,p.mfr_sku,p.collection,p.description].filter(Boolean).join(' ').toLowerCase(); if(!hay.includes(q)) return false; }
-  if(activeFacets.collection && p.collection!==activeFacets.collection) return false;
-  if(activeFacets.product_type && p.product_type!==activeFacets.product_type) return false;
-  if(activeFacets.styles && !(p.ai_styles||[]).includes(activeFacets.styles)) return false;
-  if(activeFacets.tags && !(p.ai_tags||[]).includes(activeFacets.tags)) return false;
+  if(state.types.size && !state.types.has(p.product_type)) return false;
+  if(state.collections.size && !state.collections.has(p.collection)) return false;
+  if(state.colors.size && !(p.colors||[]).some(c=>state.colors.has(c))) return false;
+  if(state.styles.size && !(p.styles||[]).some(s=>state.styles.has(s))) return false;
+  if(state.materials.size && !(p.materials||[]).some(m=>state.materials.has(m))) return false;
+  if(state.prices.size && !state.prices.has(p.price_band)) return false;
+  if(state.tags.size && !(p.tags||[]).some(t=>state.tags.has(t))) return false;
+  if(state.images.size && !state.images.has(p.image_state)) return false;
   return true;
 }
+
 function render(){
   const grid=document.getElementById('grid'); const rows=sortRows(ALL.filter(matches));
   document.getElementById('empty').style.display=rows.length?'none':'block';
   grid.innerHTML = rows.map(p=>{
     const hex=p.dominant_color_hex||p.color_hex;
-    const tags=(p.ai_styles||[]).concat(p.ai_tags||[]).slice(0,4);
+    const tags=(p.styles||[]).concat(p.tags||[]).slice(0,4);
+    const body = fieldOn.nm||fieldOn.sub||fieldOn.tags||fieldOn.when ? `<div class="body">
+        ${fieldOn.nm?`<div class="nm">${esc(p.pattern_name||p.mfr_sku)}</div>`:''}
+        ${fieldOn.sub?`<div class="sub">${fieldOn.dot&&hex?`<span class="dot" style="background:${esc(hex)}"></span>`:''}${esc(p.collection||'')} · ${esc(p.mfr_sku)}</div>`:''}
+        ${fieldOn.tags&&tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${esc(t)}</span>`).join('')}</div>`:''}
+        ${fieldOn.when?`<div class="when" title="${esc(p.crawled_at||'')}">🕓 ${fmtDate(p.crawled_at)}</div>`:''}
+      </div>`:'';
     return `<div class="card">
-      <div class="imgwrap"><img loading="lazy" src="${p.image_url}" alt="${p.pattern_name||''}"></div>
-      <div class="body">
-        <div class="nm">${p.pattern_name||p.mfr_sku}</div>
-        <div class="sub">${hex?`<span class="dot" style="background:${hex}"></span>`:''}${p.collection||''} · ${p.mfr_sku}</div>
-        ${tags.length?`<div class="tags">${tags.map(t=>`<span class="tag">${t}</span>`).join('')}</div>`:''}
-        <div class="when" title="${p.crawled_at||''}">🕓 ${fmtDate(p.crawled_at)}</div>
-      </div></div>`;
+      <div class="imgwrap"><img loading="lazy" src="${esc(p.image_url)}" alt="${esc(p.pattern_name||'')}"></div>
+      ${body}</div>`;
   }).join('');
 }
-document.getElementById('q').oninput=render;
-const sortEl=document.getElementById('sort'); sortEl.value=SORT;
-sortEl.onchange=e=>{ SORT=e.target.value; localStorage.setItem('crz_sort',SORT); render(); };
-document.getElementById('density').oninput=e=>document.documentElement.style.setProperty('--card',e.target.value+'px');
-document.getElementById('tscale').oninput=e=>document.documentElement.style.setProperty('--scale',(e.target.value/100));
-document.getElementById('imgonly').onchange=e=>document.body.classList.toggle('imgonly',e.target.checked);
+
+/* ── top-bar wiring ── */
+const qEl=document.getElementById('q');
+qEl.oninput=()=>{ state.q=qEl.value.trim().toLowerCase(); render(); };
+const sortEl=document.getElementById('sort'); sortEl.value=state.sort;
+sortEl.onchange=e=>{ state.sort=e.target.value; localStorage.setItem('crz_sort',state.sort); render(); };
+const dirBtn=document.getElementById('dir');
+const drawDir=()=>{ dirBtn.textContent = state.dir==='desc'?'▼':'▲'; };
+drawDir();
+dirBtn.onclick=()=>{ state.dir=state.dir==='desc'?'asc':'desc'; localStorage.setItem('crz_dir',state.dir); drawDir(); render(); };
+const dens=document.getElementById('dens'), densN=document.getElementById('densN');
+dens.value=localStorage.getItem('crz_cols')||6;
+const setCols=()=>{ document.documentElement.style.setProperty('--cols',dens.value); densN.textContent=dens.value; document.documentElement.style.setProperty('--fs', Math.max(.62,Math.min(1.15, 6/dens.value)).toFixed(2)); };
+setCols();
+dens.oninput=()=>{ setCols(); localStorage.setItem('crz_cols',dens.value); };
+const tscale=document.getElementById('tscale');
+tscale.value=localStorage.getItem('crz_tscale')||100;
+document.documentElement.style.setProperty('--scale',(tscale.value/100));
+tscale.oninput=e=>{ document.documentElement.style.setProperty('--scale',(e.target.value/100)); localStorage.setItem('crz_tscale',e.target.value); };
+const imgonlyEl=document.getElementById('imgonly');
+imgonlyEl.checked = localStorage.getItem('crz_imgonly')==='1';
+document.body.classList.toggle('imgonly',imgonlyEl.checked);
+imgonlyEl.onchange=e=>{ document.body.classList.toggle('imgonly',e.target.checked); localStorage.setItem('crz_imgonly',e.target.checked?'1':'0'); };
+document.getElementById('railToggle').onclick=()=>document.getElementById('rail').classList.toggle('open');
+
 load();
 </script></body></html>
diff --git a/server.js b/server.js
index c401757..3777dbf 100644
--- a/server.js
+++ b/server.js
@@ -1,11 +1,15 @@
-// Crezana internal line viewer — basic-auth, reads crezana_catalog. Port 10072.
+// Crezana internal line viewer — basic-auth, reads crezana_catalog. Port 10072 (local) / 10106 (Kamatera).
 // Internal curation surface per internal-line-viewer spec. QUOTE-ONLY, product_type Fabric.
+// Rail upgraded to full feature-parity with all.designerwallcoverings.com's rail
+// (active-filter chip row + collapsible facet sections w/ counts, type-ahead, cap/expand,
+//  color-FAMILY bucketing, style/tag explosion). Single-vendor line → NO Vendor facet.
 //
 // AUTO-DETECT data source: a self-contained jsonl bundle (no DB — for Kamatera) or
-// live Postgres (local). In BOTH modes the catalog is loaded ONCE into memory and
-// every endpoint is served from that array — the client does its own sort/filter,
-// so /api/products returns raw rows and /api/facets returns {v,n} lists identical
-// to the previous SQL output. jsonl mode when DATA_SOURCE=jsonl OR the bundle exists.
+// live Postgres (local). In BOTH modes the catalog is loaded ONCE into memory, each row is
+// ENRICHED once (color families from hex/ai_colors, style/tag/material explosion, image_state),
+// and every endpoint is served from that array. Color-family bucketing + explosion are
+// IDENTICAL in both modes (they run on the in-memory rows, never in SQL). jsonl mode when
+// DATA_SOURCE=jsonl OR the bundle exists — and in jsonl mode the pg pool is NEVER required.
 const express = require('express');
 const basicAuth = require('basic-auth');
 const path = require('path');
@@ -19,6 +23,7 @@ const app = express();
 
 // ---- Basic auth (admin / DW2024!) ----
 app.use((req, res, next) => {
+  if (req.path === '/api/health') return next();
   const cred = basicAuth(req);
   if (!cred || cred.name !== 'admin' || cred.pass !== 'DW2024!') {
     res.set('WWW-Authenticate', 'Basic realm="Crezana Internal"');
@@ -29,11 +34,133 @@ app.use((req, res, next) => {
 
 app.use(express.static(__dirname + '/public'));
 
+// ─── shared enrichment (identical in jsonl + pg modes — runs on in-memory rows) ──────────
+// Color FAMILIES: map every hex in color_hex/dominant_color_hex/ai_colors into a hue bucket,
+// family-ordered. Also fold in ai_colors *names* that are already family words.
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Multi'];
+// swatch color for each family dot (front-end reads facet.dot)
+const FAMILY_HEX = {
+  Red: '#c0392b', Orange: '#e67e22', Brown: '#8b5a2b', Yellow: '#f1c40f', Gold: '#c9a227',
+  Green: '#27ae60', Teal: '#1abc9c', Blue: '#2980b9', Purple: '#8e44ad', Pink: '#e84393',
+  Beige: '#d2b48c', Cream: '#f5f0e1', Black: '#1a1a1a', Gray: '#8b8f96', White: '#f5f5f5', Multi: 'linear-gradient(90deg,#c0392b,#27ae60,#2980b9)',
+};
+// map a family word (from ai_colors names) → canonical family bucket
+const NAME_TO_FAMILY = {
+  red: 'Red', crimson: 'Red', maroon: 'Red', burgundy: 'Red', rust: 'Red', coral: 'Red', terracotta: 'Orange',
+  orange: 'Orange', peach: 'Orange', apricot: 'Orange', amber: 'Orange',
+  brown: 'Brown', tan: 'Brown', taupe: 'Brown', chocolate: 'Brown', khaki: 'Brown', 'light brown': 'Brown', 'dark brown': 'Brown', mocha: 'Brown', umber: 'Brown', sienna: 'Brown', chestnut: 'Brown',
+  yellow: 'Yellow', mustard: 'Yellow', lemon: 'Yellow',
+  gold: 'Gold', golden: 'Gold', brass: 'Gold', bronze: 'Gold',
+  green: 'Green', olive: 'Green', sage: 'Green', mint: 'Green', emerald: 'Green', forest: 'Green', lime: 'Green', 'light green': 'Green', 'dark green': 'Green',
+  teal: 'Teal', turquoise: 'Teal', aqua: 'Teal', cyan: 'Teal',
+  blue: 'Blue', navy: 'Blue', 'light blue': 'Blue', 'dark blue': 'Blue', 'sky blue': 'Blue', indigo: 'Blue', cobalt: 'Blue', denim: 'Blue',
+  purple: 'Purple', violet: 'Purple', lavender: 'Purple', plum: 'Purple', mauve: 'Purple', lilac: 'Purple',
+  pink: 'Pink', rose: 'Pink', blush: 'Pink', magenta: 'Pink', fuchsia: 'Pink',
+  beige: 'Beige', sand: 'Beige', ecru: 'Beige', 'neutral beige': 'Beige', 'light beige': 'Beige', 'warm brown': 'Brown', neutral: 'Beige', natural: 'Beige', wheat: 'Beige',
+  cream: 'Cream', ivory: 'Cream', 'off-white': 'Cream', 'off white': 'Cream', vanilla: 'Cream', linen: 'Cream',
+  black: 'Black', charcoal: 'Black', ebony: 'Black', onyx: 'Black',
+  gray: 'Gray', grey: 'Gray', silver: 'Gray', 'light gray': 'Gray', 'dark gray': 'Gray', 'light grey': 'Gray', 'dark grey': 'Gray', slate: 'Gray', graphite: 'Gray', pewter: 'Gray',
+  white: 'White', 'pure white': 'White', snow: 'White',
+};
+function hexFamily(hex) {
+  if (!hex) return null;
+  const m = /^#?([0-9a-f]{6})$/i.exec(String(hex).trim());
+  if (!m) return null;
+  const n = parseInt(m[1], 16);
+  const r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b), d = mx - mn;
+  const light = (mx + mn) / 2 / 255; // 0..1
+  // near-neutral (low saturation) → white / cream / beige / gray / black by lightness
+  if (d < 26) {
+    if (light > 0.90) return 'White';
+    if (light > 0.80) return 'Cream';
+    if (light > 0.16) return 'Gray';
+    return 'Black';
+  }
+  // hue in degrees
+  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 = (h * 60 + 360) % 360;
+  // low-light warm hues read as brown; very light warm read as beige/cream
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 45) { // orange band → brown when dark, orange when bright
+    if (light < 0.42) return 'Brown';
+    if (light > 0.82 && d < 90) return 'Beige';
+    return 'Orange';
+  }
+  if (h < 65) { if (light < 0.42) return 'Brown'; return light > 0.78 ? 'Gold' : 'Yellow'; }
+  if (h < 80) return 'Gold';
+  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 rowColorFamilies(r) {
+  const fams = new Set();
+  // from ai_colors: prefer the name if it's a known family word, else bucket its hex
+  let ac = r.ai_colors;
+  if (typeof ac === 'string') { try { ac = JSON.parse(ac); } catch { ac = []; } }
+  if (Array.isArray(ac)) {
+    for (const c of ac) {
+      const nm = (c && c.name ? String(c.name) : '').toLowerCase().trim();
+      const byName = NAME_TO_FAMILY[nm];
+      if (byName) { fams.add(byName); continue; }
+      const byHex = hexFamily(c && c.hex);
+      if (byHex) fams.add(byHex);
+    }
+  }
+  // fold in the primary hex(es) too
+  for (const h of [r.dominant_color_hex, r.color_hex]) { const f = hexFamily(h); if (f) fams.add(f); }
+  return [...fams].sort((a, b) => FAMILY_ORDER.indexOf(a) - FAMILY_ORDER.indexOf(b));
+}
+// Material facet — Crezana data carries NO material/composition column, so derive fabric-content
+// terms out of ai_tags + ai_styles (Woven, Linen, Cotton, Silk, Velvet, Embroidered, …).
+const MATERIAL_TERMS = {
+  woven: 'Woven', linen: 'Linen', cotton: 'Cotton', silk: 'Silk', velvet: 'Velvet', wool: 'Wool',
+  embroidered: 'Embroidered', embroidery: 'Embroidered', jute: 'Jute', burlap: 'Burlap', leather: 'Leather',
+  suede: 'Suede', chenille: 'Chenille', jacquard: 'Jacquard', boucle: 'Bouclé', 'bouclé': 'Bouclé',
+  grasscloth: 'Grasscloth', sisal: 'Sisal', canvas: 'Canvas', tweed: 'Tweed', damask: 'Damask',
+  metallic: 'Metallic', beaded: 'Beaded', 'faux leather': 'Faux Leather',
+};
+function rowMaterials(r) {
+  const out = new Set();
+  const feed = [].concat(explode(r.ai_tags), explode(r.ai_styles));
+  for (const t of feed) { const m = MATERIAL_TERMS[String(t).toLowerCase().trim()]; if (m) out.add(m); }
+  return [...out];
+}
+function explode(v) {
+  if (typeof v === 'string') { try { v = JSON.parse(v); } catch { return []; } }
+  if (!Array.isArray(v)) return [];
+  // title-case-fold so "elegant"/"Elegant" collapse to one value
+  return v.filter((x) => x != null && x !== '').map((x) => String(x));
+}
+const titleCase = (s) => String(s).replace(/\w\S*/g, (t) => t.charAt(0).toUpperCase() + t.slice(1).toLowerCase());
+function foldList(v) {
+  const seen = new Map();
+  for (const raw of explode(v)) { const k = titleCase(raw); if (!seen.has(k)) seen.set(k, k); }
+  return [...seen.values()];
+}
+function enrich(r) {
+  r.colors = rowColorFamilies(r);
+  r.styles = foldList(r.ai_styles);
+  r.tags = foldList(r.ai_tags);
+  r.materials = rowMaterials(r);
+  r.image_state = (r.image_url && String(r.image_url).trim()) ? 'Has image' : 'No image';
+  // QUOTE-ONLY line → single price band
+  r.price_band = 'Quote-only';
+  return r;
+}
+
 // ---- in-memory catalog (jsonl bundle OR live Postgres) --------------------
 let ROWS = [];
 
 async function load() {
   if (USE_JSONL) {
+    // HARD RAIL: jsonl mode NEVER touches a DB — no pool is even required here.
     ROWS = fs.readFileSync(JSONL, 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
   } else {
     const { Pool } = require('pg');
@@ -47,36 +174,40 @@ async function load() {
     await pool.end();
     ROWS = rows;
   }
+  ROWS.forEach(enrich);
   console.log(`[crezana] loaded ${ROWS.length} products (source=${USE_JSONL ? 'jsonl' : 'pg'})`);
 }
 
-// Facet counts for the collapsible field tabs — {v,n} lists, count desc.
-// Reproduces the previous SQL facets exactly (scalar col facet + jsonb-array facet).
+// Facet counts for the collapsible field sections — {v,n[,dot]} lists, count desc.
+// Single-vendor line → NO vendor facet. Color is family-bucketed & family-ordered w/ swatch.
 function facets() {
   const scalar = (col) => {
     const m = new Map();
     for (const r of ROWS) { const v = r[col]; if (v == null || v === '') continue; m.set(v, (m.get(v) || 0) + 1); }
     return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
   };
-  const jsonArr = (col) => {
+  const listCol = (col, opts = {}) => { // col already holds an array on each enriched row
     const m = new Map();
-    for (const r of ROWS) {
-      let arr = r[col];
-      if (typeof arr === 'string') { try { arr = JSON.parse(arr); } catch { arr = []; } }
-      if (!Array.isArray(arr)) continue;
-      for (const val of arr) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); }
-    }
-    return [...m.entries()].sort((a, b) => b[1] - a[1]).map(([v, n]) => ({ v, n }));
+    for (const r of ROWS) { for (const val of (r[col] || [])) { if (val == null || val === '') continue; m.set(val, (m.get(val) || 0) + 1); } }
+    let e = [...m.entries()];
+    if (opts.order) e.sort((a, b) => opts.order.indexOf(a[0]) - opts.order.indexOf(b[0]));
+    else e.sort((a, b) => b[1] - a[1]);
+    return e.map(([v, n]) => ({ v, n, ...(opts.dot ? { dot: FAMILY_HEX[v] || '' } : {}) }));
   };
   return {
-    collection: scalar('collection'),
     product_type: scalar('product_type'),
-    styles: jsonArr('ai_styles'),
-    tags: jsonArr('ai_tags'),
+    collection: scalar('collection'),
+    color: listCol('colors', { order: FAMILY_ORDER, dot: true }),
+    style: listCol('styles'),
+    material: listCol('materials'),
+    price_band: [{ v: 'Quote-only', n: ROWS.length }],
+    tags: listCol('tags'),
+    image_state: scalar('image_state'),
+    family_order: FAMILY_ORDER,
   };
 }
 
-// Full products list (raw rows — the client sorts/filters/renders).
+// Full products list (raw enriched rows — the client sorts/filters/renders).
 app.get('/api/products', (req, res) => {
   res.json({ count: ROWS.length, products: ROWS });
 });
@@ -86,7 +217,7 @@ app.get('/api/facets', (req, res) => {
   catch (e) { res.status(500).json({ error: e.message }); }
 });
 
-app.get('/api/health', (req, res) => res.json({ ok: true, service: 'crezana-internal' }));
+app.get('/api/health', (req, res) => res.json({ ok: true, service: 'crezana-internal', count: ROWS.length, source: USE_JSONL ? 'jsonl' : 'pg' }));
 
 load().then(() => {
   app.listen(PORT, () => console.log(`crezana-internal on :${PORT} (source=${USE_JSONL ? 'jsonl' : 'pg'})`));

← d05fb36 jsonl-backed data layer: self-contained bundle for Kamatera  ·  back to Crezana Internal  ·  Show DWCZ- DW series SKU instead of raw CREZ- mfr hash on ca ff668cb →