[object Object]

← back to Gracie Internal

Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-families/Style/Material/Price-quote-only/Tags/Image), color-family bucketing + style/tag explosion computed identically in pg + jsonl modes, type-ahead + cap/expand, swatch dots, card-field toggles

68969165b35e004ed45464476c2e800960118bb0 · 2026-07-09 16:08:50 -0700 · Steve

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

Files touched

Diff

commit 68969165b35e004ed45464476c2e800960118bb0
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 9 16:08:50 2026 -0700

    Upgrade rail to all.dw feature-parity: active-chip row + collapsible facet sections (Type/Book/Color-families/Style/Material/Price-quote-only/Tags/Image), color-family bucketing + style/tag explosion computed identically in pg + jsonl modes, type-ahead + cap/expand, swatch dots, card-field toggles
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 public/index.html | 349 +++++++++++++++++++++++++++++++++++++++---------------
 server.js         | 109 +++++++++++++----
 2 files changed, 343 insertions(+), 115 deletions(-)

diff --git a/public/index.html b/public/index.html
index a3eaef1..0a96f7b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,53 +5,80 @@
 <meta name="viewport" content="width=device-width, initial-scale=1" />
 <title>Gracie — Internal Line Viewer</title>
 <style>
-  :root { --cardmin: 230px; --stroke:#e4e0d8; --ink:#1c1a17; --muted:#8a857c; --teal:#0f766e; --bg:#faf8f4; }
+  :root { --cardmin: 230px; --stroke:#e4e0d8; --ink:#1c1a17; --mut:#8a857c; --accent:#0f766e; --bg:#faf8f4; --card:#fbf9f5; }
   * { box-sizing: border-box; }
   body { margin:0; font-family: ui-sans-serif, -apple-system, "Segoe UI", Roboto, sans-serif; color:var(--ink); background:var(--bg); }
   header { display:flex; align-items:center; gap:16px; padding:14px 20px; border-bottom:1px solid var(--stroke); background:#fff; position:sticky; top:0; z-index:20; }
   header .brand { font-family: Georgia, "Times New Roman", serif; font-size:20px; letter-spacing:.02em; }
-  header .brand small { color:var(--muted); font-size:12px; letter-spacing:.14em; text-transform:uppercase; display:block; }
-  header .count { color:var(--muted); font-size:13px; }
+  header .brand small { color:var(--mut); font-size:12px; letter-spacing:.14em; text-transform:uppercase; display:block; }
+  header .count { color:var(--mut); font-size:13px; }
   .controls { margin-left:auto; display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
-  .controls label { font-size:12px; color:var(--muted); display:flex; align-items:center; gap:6px; }
+  .controls label { font-size:12px; color:var(--mut); display:flex; align-items:center; gap:6px; }
   select, input[type=search] { font:inherit; font-size:13px; padding:6px 8px; border:1px solid var(--stroke); border-radius:6px; background:#fff; }
-  .wrap { display:grid; grid-template-columns: 280px 1fr; gap:0; align-items:start; }
-  aside { border-right:1px solid var(--stroke); padding:14px; position:sticky; top:57px; max-height:calc(100vh - 57px); overflow:auto; background:#fff; }
-  aside input[type=search] { width:100%; margin-bottom:12px; }
-  .tab { border:1px solid var(--stroke); border-radius:8px; margin-bottom:8px; overflow:hidden; }
-  .tab > summary { cursor:pointer; padding:9px 11px; font-size:12.5px; font-weight:600; letter-spacing:.02em; display:flex; justify-content:space-between; align-items:center; list-style:none; }
-  .tab > summary::-webkit-details-marker { display:none; }
-  .tab > summary .n { color:var(--muted); font-weight:400; }
-  .tab .vals { max-height:230px; overflow:auto; padding:4px 6px 8px; }
-  .val { display:flex; justify-content:space-between; gap:8px; padding:4px 6px; font-size:12.5px; border-radius:5px; cursor:pointer; }
-  .val:hover { background:#f2efe9; }
-  .val.on { background:var(--teal); color:#fff; }
-  .val .badge { background:#eee; color:#555; border-radius:10px; padding:0 7px; font-size:11px; }
-  .val.on .badge { background:rgba(255,255,255,.25); color:#fff; }
-  .active-filters { display:flex; flex-wrap:wrap; gap:6px; padding:0 20px 10px; }
-  .chip { font-size:11.5px; background:#efe9df; border:1px solid var(--stroke); border-radius:14px; padding:3px 10px; cursor:pointer; }
-  .chip:hover { background:#e3dccf; }
-  main { padding:16px 20px 60px; }
+
+  /* ── layout: Amazon-style left rail + content (ported from all.designerwallcoverings) ── */
+  .wrap { display:flex; align-items:flex-start; }
+  aside#rail { width:280px; flex:0 0 280px; position:sticky; top:57px; max-height:calc(100vh - 57px);
+    overflow-y:auto; padding:12px 12px 60px; border-right:1px solid var(--stroke); background:#fff; }
+  aside#rail input[type=search] { width:100%; margin-bottom:10px; }
+
+  /* active-filter chip row above the sections */
+  .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:8px; }
+  .afilter { background:#eef4f3; border:1px solid var(--accent); color:var(--ink); 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(--stroke); color:var(--mut); border-radius:20px; padding:3px 11px; font-size:11px; cursor:pointer; }
+
+  /* Astek-style collapsed accordion sections per field */
+  details.sec { border:1px solid var(--stroke); 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:#fff; 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(--ink); 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; }
+  .fitem.on .box { background:var(--accent); border-color:var(--accent); color:#fff; font-weight:700; }
+  .fitem.on { color:var(--accent); }
+  .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; }
+  .fitem .dot { width:12px; height:12px; flex:0 0 12px; border-radius:3px; border:1px solid rgba(0,0,0,.18); }
+  .more { color:var(--accent); 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(--stroke); border-radius:6px !important; margin:2px 0 5px; background:#fff; }
+  /* Card-fields toggles reuse .fitem chrome */
+  #fieldsRow .fitem .box { border-radius:3px; }
+
+  main { flex:1; min-width:0; padding:16px 20px 60px; }
   .grid { display:grid; grid-template-columns: repeat(auto-fill, minmax(var(--cardmin), 1fr)); gap:14px; }
   .card { border:1px solid var(--stroke); border-radius:10px; overflow:hidden; background:#fff; display:flex; flex-direction:column; }
   .card .imgwrap { aspect-ratio: 3/4; background:#f0ece4; overflow:hidden; }
   .card img { width:100%; height:100%; object-fit:cover; display:block; }
   .card .body { padding:9px 10px 11px; display:flex; flex-direction:column; gap:4px; }
   .card .ptn { font-size:13.5px; font-weight:600; line-height:1.2; }
-  .card .meta { font-size:11.5px; color:var(--muted); }
+  .card .meta { font-size:11.5px; color:var(--mut); }
   .card .tags { display:flex; flex-wrap:wrap; gap:4px; margin-top:3px; }
   .card .tag { font-size:10px; background:#f2efe9; border-radius:8px; padding:1px 7px; color:#5a554c; }
-  .card .when { font-size:10.5px; color:var(--muted); display:flex; align-items:center; gap:4px; margin-top:2px; }
+  .card .when { font-size:10.5px; color:var(--mut); display:flex; align-items:center; gap:4px; margin-top:2px; }
   .card .flags { display:flex; gap:5px; flex-wrap:wrap; }
   .flag { font-size:9.5px; text-transform:uppercase; letter-spacing:.04em; border-radius:6px; padding:1px 6px; }
   .flag.gate { background:#fde2e2; color:#a12; }
-  .flag.enr { background:#e2f0ec; color:var(--teal); }
+  .flag.enr { background:#e2f0ec; color:var(--accent); }
   .swatch { width:11px; height:11px; border-radius:3px; border:1px solid rgba(0,0,0,.15); display:inline-block; vertical-align:-1px; margin-right:4px; }
-  /* image-only mode */
+  /* card-field visibility toggles (hidden classes set on body) */
+  body.hide-meta .card .meta { display:none; }
+  body.hide-flags .card .flags { display:none; }
+  body.hide-tags .card .tags { display:none; }
+  body.hide-when .card .when { display:none; }
+  body.hide-ptn .card .ptn { display:none; }
+  /* image-only mode = collapse the entire card body → pure texture wall */
   body.imgonly .card .body { display:none; }
   body.imgonly .grid { gap:6px; }
-  /* density → type scale */
   .grid { font-size: calc(11px + (var(--cardmin) - 160px) * 0.012); }
+  @media (max-width:760px){ aside#rail { position:static; width:100%; flex-basis:auto; max-height:none; border-right:none; border-bottom:1px solid var(--stroke); } .wrap { flex-direction:column; } }
 </style>
 </head>
 <body>
@@ -62,7 +89,7 @@
     <label>Sort
       <select id="sort">
         <option value="newest">Newest</option>
-        <option value="collection">Collection</option>
+        <option value="collection">Book / Series</option>
         <option value="pattern">Pattern A→Z</option>
         <option value="sku">SKU A→Z</option>
         <option value="color">Color</option>
@@ -74,11 +101,19 @@
     <label><input type="checkbox" id="imgonly" /> Image only</label>
   </div>
 </header>
-<div class="active-filters" id="activeFilters"></div>
 <div class="wrap">
-  <aside>
+  <aside id="rail">
     <input type="search" id="search" placeholder="Search pattern / SKU / tag…" autocomplete="off" />
-    <div id="facets"></div>
+    <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="series"><summary>Book / Series<span class="n" id="nSeries"></span></summary><div class="fbody" id="seriesRow"></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>
     <div class="grid" id="grid"></div>
@@ -86,87 +121,206 @@
 </div>
 <script>
 const ORIGIN = location.origin;
-let ALL = [], FACETS = {}, FILTERS = {}, SEARCH = '';
+let ALL = [], FACETS = {}, SEARCH = '';
 const grid = document.getElementById('grid');
 const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso; } };
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;' }[c]));
+
+/* ── filter state — one Set per facet dimension, persisted to localStorage ── */
+const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
+const state = {
+  types: loadSet('gr_types'), series: loadSet('gr_series'), colors: loadSet('gr_colors'),
+  styles: loadSet('gr_styles'), materials: loadSet('gr_materials'), tags: loadSet('gr_tags'),
+  prices: loadSet('gr_prices'), images: loadSet('gr_images'),
+};
+const SETS = { types:'gr_types', series:'gr_series', colors:'gr_colors', styles:'gr_styles', materials:'gr_materials', tags:'gr_tags', prices:'gr_prices', images:'gr_images' };
+function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
+
+/* per-row UI state: expanded? + type-ahead needle (big rows get a find box) */
+const rowUI = {
+  typeRow: { expand:false, find:'' }, seriesRow: { expand:false, find:'' },
+  colorRow: { expand:false, find:'' }, styleRow: { expand:false, find:'' },
+  matRow: { expand:false, find:'' }, tagRow: { expand:false, find:'', typeahead:true },
+  priceRow: { expand:false, find:'' }, imgRow: { expand:false, find:'' },
+};
+
+/* card-field visibility toggles (image-only spectrum) */
+const CARD_FIELDS = [
+  { key:'ptn', label:'Pattern name', cls:'hide-ptn' },
+  { key:'meta', label:'SKU · book', cls:'hide-meta' },
+  { key:'flags', label:'Flags', cls:'hide-flags' },
+  { key:'tags', label:'Tags', cls:'hide-tags' },
+  { key:'when', label:'Created date', cls:'hide-when' },
+];
+function renderCardFields() {
+  const row = document.getElementById('fieldsRow'); row.innerHTML = '';
+  for (const f of CARD_FIELDS) {
+    const on = localStorage.getItem('gr_cf_' + f.key) !== '0'; // default shown
+    document.body.classList.toggle(f.cls, !on);
+    const el = document.createElement('div');
+    el.className = 'fitem' + (on ? ' on' : '');
+    el.innerHTML = `<span class="box">${on ? '✓' : ''}</span><span class="lbl">${f.label}</span>`;
+    el.onclick = () => { const nv = localStorage.getItem('gr_cf_' + f.key) === '0'; localStorage.setItem('gr_cf_' + f.key, nv ? '1' : '0'); renderCardFields(); };
+    row.appendChild(el);
+  }
+  document.getElementById('nFields').textContent = CARD_FIELDS.filter(f => localStorage.getItem('gr_cf_' + f.key) === '0').length || '';
+  document.getElementById('nFields').style.display = CARD_FIELDS.some(f => localStorage.getItem('gr_cf_' + f.key) === '0') ? '' : 'none';
+}
 
 async function boot() {
   const [p, f] = await Promise.all([
-    fetch(ORIGIN+'/api/products').then(r=>r.json()),
-    fetch(ORIGIN+'/api/facets').then(r=>r.json()),
+    fetch(ORIGIN + '/api/products').then(r => r.json()),
+    fetch(ORIGIN + '/api/facets').then(r => r.json()),
   ]);
-  ALL = p.products; FACETS = f.facets;
-  renderFacets(); render();
+  ALL = p.products; FACETS = f;
+  renderCardFields(); renderFacets(); render();
 }
 
+/* does product p match value val on facet dimension key? */
 function facetMatch(p, key, val) {
-  switch(key) {
-    case 'Collection': return p.collection === val;
-    case 'Type': return p.product_type === val;
-    case 'Material': return p.material === val;
-    case 'Style': return (p.styles||[]).includes(val);
-    case 'Tag': return (p.tags||[]).includes(val);
-    case 'Settlement': return (p.settlement_flag||'ungated') === val;
-    case 'Enriched': return (p.enriched?'yes':'no') === val;
+  switch (key) {
+    case 'types': return p.product_type === val;
+    case 'series': return p.collection === val;
+    case 'colors': return productFamilies(p).includes(val);
+    case 'styles': return (p.styles || []).includes(val);
+    case 'materials': return (p.material || 'Unspecified') === val;
+    case 'tags': return (p.tags || []).includes(val);
+    case 'prices': return val === 'Quote-only'; // Gracie is quote-only across the board
+    case 'images': return (p.image_url ? 'Has image' : 'No image') === val;
     default: return true;
   }
 }
 
-function renderFacets() {
-  const host = document.getElementById('facets');
-  host.innerHTML = '';
-  for (const key of Object.keys(FACETS)) {
-    const d = document.createElement('details'); d.className='tab';
-    const sum = document.createElement('summary');
-    sum.innerHTML = `<span>${key}</span><span class="n">${FACETS[key].length}</span>`;
-    d.appendChild(sum);
-    const vals = document.createElement('div'); vals.className='vals';
-    for (const {value,count} of FACETS[key]) {
-      const row = document.createElement('div'); row.className='val';
-      if ((FILTERS[key]||new Set()).has(value)) row.classList.add('on');
-      row.innerHTML = `<span>${value}</span><span class="badge">${count}</span>`;
-      row.onclick = () => toggleFilter(key, value);
-      vals.appendChild(row);
-    }
-    d.appendChild(vals);
-    host.appendChild(d);
+/* client-side color-family bucketing — MUST match the server's familyOf() exactly */
+function hexToRgb(hex){ const m=String(hex||'').trim().replace(/^#/,''); if(!/^[0-9a-fA-F]{6}$/.test(m))return null; return [parseInt(m.slice(0,2),16),parseInt(m.slice(2,4),16),parseInt(m.slice(4,6),16)]; }
+function rgbToHsl(r,g,b){ r/=255;g/=255;b/=255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b); let h=0,s=0; const l=(mx+mn)/2; if(mx!==mn){const d=mx-mn;s=l>0.5?d/(2-mx-mn):d/(mx+mn);switch(mx){case r:h=(g-b)/d+(g<b?6:0);break;case g:h=(b-r)/d+2;break;case b:h=(r-g)/d+4;break;}h/=6;} return [h*360,s*100,l*100]; }
+function familyOf(hex){
+  const rgb=hexToRgb(hex); if(!rgb) return null;
+  const [h,s,l]=rgbToHsl(...rgb);
+  if(l<=10) return 'Black';
+  if(l>=94 && s<=8) return 'White';
+  if(s<=10){ if(l>=86) return 'White'; return 'Gray'; }
+  if(s<=25 && l>=84) return 'Cream';
+  if((h<52||h>=345) && l<=32 && !(h>300&&h<345)) return 'Brown';
+  if(h>=18 && h<52){ if(l<=42)return 'Brown'; if(s<=38&&l>=82)return 'Cream'; if(s<=55&&l>=60)return 'Beige'; if(s>=45&&l>=42&&l<=72)return 'Gold'; if(l<=60)return 'Brown'; return 'Beige'; }
+  if(h>=52 && h<66) return 'Yellow';
+  if((h<15||h>=340) && (l>=72||(s<=60&&l>=60))) return 'Pink';
+  if(h<15||h>=345) return 'Red';
+  if(h<18) return 'Orange';
+  if(h<160) return 'Green';
+  if(h<200) return 'Teal';
+  if(h<255) return 'Blue';
+  if(h<290) return 'Purple';
+  if(h<340) return 'Pink';
+  return 'Red';
+}
+function productFamilies(p){
+  const fams=new Set();
+  for(const c of (p.colors||[])){ const f=familyOf(c&&c.hex); if(f)fams.add(f); }
+  if(!fams.size && p.color_hex){ const f=familyOf(p.color_hex); if(f)fams.add(f); }
+  return [...fams];
+}
+
+/* ── the ported facet list: checkbox rows + counts, type-ahead on big rows, cap/expand ── */
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); persistFacets(); renderFacets(); render(); }
+
+function facetList(rowId, counts, set, opts = {}) {
+  const row = document.getElementById(rowId); row.innerHTML = '';
+  const ui = rowUI[rowId];
+  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(); const f2 = document.querySelector(`#${rowId} .ffind`); if (f2) { f2.focus(); f2.setSelectionRange(f2.value.length, f2.value.length); } };
+    row.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:var(--mut);padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
+  shown.forEach(([val, ct]) => {
+    const el = document.createElement('div');
+    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    const dot = opts.swatch && opts.swatch[val] ? `<span class="dot" style="background:${esc(opts.swatch[val])}"></span>` : '';
+    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${dot}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.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(); };
+    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(); };
+    row.appendChild(el);
   }
 }
 
-function toggleFilter(key, value) {
-  FILTERS[key] = FILTERS[key] || new Set();
-  FILTERS[key].has(value) ? FILTERS[key].delete(value) : FILTERS[key].add(value);
-  if (!FILTERS[key].size) delete FILTERS[key];
-  renderFacets(); render();
+function renderFacets() {
+  facetList('typeRow', FACETS.type, state.types, { cap: 10 });
+  facetList('seriesRow', FACETS.series, state.series, { cap: 12 });
+  facetList('colorRow', FACETS.color, state.colors, { order: FACETS.family_order, swatch: FACETS.color_swatch, cap: 12 });
+  facetList('styleRow', FACETS.style, state.styles, { cap: 10 });
+  facetList('matRow', FACETS.material, state.materials, { cap: 10 });
+  facetList('priceRow', FACETS.price_band, state.prices, { order: FACETS.price_order, cap: 4 });
+  facetList('tagRow', FACETS.tag, state.tags, { cap: 12 });
+  facetList('imgRow', FACETS.image_state, state.images, { cap: 4 });
+  renderActive();
 }
 
+const SECN = { Type:'nType', Book:'nSeries', Color:'nColor', Style:'nStyle', Material:'nMaterial', Price:'nPrice', Tag:'nTag', Image:'nImage' };
 function renderActive() {
-  const host = document.getElementById('activeFilters'); host.innerHTML='';
-  for (const key of Object.keys(FILTERS)) for (const v of FILTERS[key]) {
-    const c = document.createElement('span'); c.className='chip'; c.textContent = `${key}: ${v} ✕`;
-    c.onclick = () => toggleFilter(key, v); host.appendChild(c);
+  const row = document.getElementById('activeRow'); row.innerHTML = '';
+  const dims = [['Type', state.types], ['Book', state.series], ['Color', state.colors], ['Style', state.styles],
+    ['Material', state.materials], ['Price', state.prices], ['Tag', state.tags], ['Image', state.images]];
+  for (const [label, set] of dims) { // selection-count badge on each collapsed section header
+    const b = document.getElementById(SECN[label]);
+    if (b) { b.textContent = set.size || ''; b.style.display = set.size ? '' : 'none'; }
+  }
+  let n = 0;
+  for (const [label, set] of dims) {
+    set.forEach((v) => {
+      n++;
+      const el = document.createElement('span'); el.className = 'afilter';
+      el.innerHTML = `<b>${label}:</b> ${esc(v)} ✕`;
+      el.onclick = () => { set.delete(v); persistFacets(); renderFacets(); render(); };
+      row.appendChild(el);
+    });
+  }
+  if (n) {
+    const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
+    c.onclick = () => { dims.forEach(([, s]) => s.clear()); persistFacets(); renderFacets(); render(); };
+    row.appendChild(c);
   }
 }
 
 function filtered() {
-  let rows = ALL.filter(p => {
-    for (const key of Object.keys(FILTERS)) {
-      const any = [...FILTERS[key]].some(v => facetMatch(p, key, v));
-      if (!any) return false;
+  let rows = ALL.filter((p) => {
+    for (const key of Object.keys(SETS)) {
+      const set = state[key];
+      if (set.size && ![...set].some((v) => facetMatch(p, key, v))) return false;
     }
     if (SEARCH) {
-      const hay = (p.pattern_name+' '+p.mfr_sku+' '+p.collection+' '+(p.tags||[]).join(' ')+' '+(p.styles||[]).join(' ')).toLowerCase();
+      const hay = (p.pattern_name + ' ' + p.mfr_sku + ' ' + p.collection + ' ' + (p.tags || []).join(' ') + ' ' + (p.styles || []).join(' ')).toLowerCase();
       if (!hay.includes(SEARCH)) return false;
     }
     return true;
   });
   const s = document.getElementById('sort').value;
+  const fam = (p) => { const f = productFamilies(p)[0]; const i = f ? (FACETS.family_order || []).indexOf(f) : -1; return i < 0 ? 99 : i; };
   const cmp = {
-    newest: (a,b)=> (b.created_at||'').localeCompare(a.created_at||''),
-    collection: (a,b)=> (a.collection||'').localeCompare(b.collection||'') || (a.pattern_name||'').localeCompare(b.pattern_name||''),
-    pattern: (a,b)=> (a.pattern_name||'').localeCompare(b.pattern_name||''),
-    sku: (a,b)=> (a.mfr_sku||'').localeCompare(b.mfr_sku||''),
-    color: (a,b)=> (a.color_hex||'zzz').localeCompare(b.color_hex||'zzz'),
+    newest: (a, b) => (b.created_at || '').localeCompare(a.created_at || ''),
+    collection: (a, b) => (a.collection || '').localeCompare(b.collection || '') || (a.pattern_name || '').localeCompare(b.pattern_name || ''),
+    pattern: (a, b) => (a.pattern_name || '').localeCompare(b.pattern_name || ''),
+    sku: (a, b) => (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
+    color: (a, b) => fam(a) - fam(b) || (a.mfr_sku || '').localeCompare(b.mfr_sku || ''),
   }[s];
   return rows.sort(cmp);
 }
@@ -178,36 +332,41 @@ function render() {
   grid.innerHTML = '';
   const frag = document.createDocumentFragment();
   for (const p of rows) {
-    const card = document.createElement('div'); card.className='card';
-    const img = p.image_url ? `<img loading="lazy" src="${p.image_url}?w=500&q=70" alt="${p.pattern_name}">` : '';
+    const card = document.createElement('div'); card.className = 'card';
+    const img = p.image_url ? `<img loading="lazy" src="${esc(p.image_url)}?w=500&q=70" alt="${esc(p.pattern_name)}">` : '';
     const flags = [];
-    if (p.settlement_flag && p.settlement_flag!=='OK') flags.push(`<span class="flag gate">gate: ${p.settlement_flag}</span>`);
+    if (p.settlement_flag && p.settlement_flag !== 'OK') flags.push(`<span class="flag gate">gate: ${esc(p.settlement_flag)}</span>`);
     if (p.enriched) flags.push(`<span class="flag enr">enriched</span>`);
-    const sw = p.color_hex ? `<span class="swatch" style="background:${p.color_hex}"></span>` : '';
-    const tags = (p.tags||[]).slice(0,5).map(t=>`<span class="tag">${t}</span>`).join('');
+    const sw = p.color_hex ? `<span class="swatch" style="background:${esc(p.color_hex)}"></span>` : '';
+    const tags = (p.tags || []).slice(0, 5).map((t) => `<span class="tag">${esc(t)}</span>`).join('');
     card.innerHTML = `
       <div class="imgwrap">${img}</div>
       <div class="body">
-        <div class="ptn">${p.pattern_name}</div>
-        <div class="meta">${sw}${p.mfr_sku} · ${p.collection}</div>
+        <div class="ptn">${esc(p.pattern_name)}</div>
+        <div class="meta">${sw}${esc(p.mfr_sku)} · ${esc(p.collection)}</div>
         <div class="flags">${flags.join('')}</div>
         <div class="tags">${tags}</div>
-        <div class="when" title="${p.created_at}">🕓 ${fmtDate(p.created_at)}</div>
+        <div class="when" title="${esc(p.created_at)}">🕓 ${fmtDate(p.created_at)}</div>
       </div>`;
     frag.appendChild(card);
   }
   grid.appendChild(frag);
 }
 
-// controls
-document.getElementById('sort').onchange = (e)=> { localStorage.gracieSort = e.target.value; render(); };
-document.getElementById('density').oninput = (e)=> { document.documentElement.style.setProperty('--cardmin', e.target.value+'px'); localStorage.gracieDensity = e.target.value; };
-document.getElementById('imgonly').onchange = (e)=> { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked?'1':''; };
-document.getElementById('search').oninput = (e)=> { SEARCH = e.target.value.trim().toLowerCase(); render(); };
-// restore prefs
-if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity+'px'); }
+/* ── top-bar controls (search + sort + density + image-only, all persisted) ── */
+document.getElementById('sort').onchange = (e) => { localStorage.gracieSort = e.target.value; render(); };
+document.getElementById('density').oninput = (e) => { document.documentElement.style.setProperty('--cardmin', e.target.value + 'px'); localStorage.gracieDensity = e.target.value; };
+document.getElementById('imgonly').onchange = (e) => { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked ? '1' : ''; };
+document.getElementById('search').oninput = (e) => { SEARCH = e.target.value.trim().toLowerCase(); render(); };
+/* restore prefs */
+if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity + 'px'); }
 if (localStorage.gracieImgOnly) { document.getElementById('imgonly').checked = true; document.body.classList.add('imgonly'); }
 if (localStorage.gracieSort) { document.getElementById('sort').value = localStorage.gracieSort; }
+/* accordion sections: persist open/closed per section (collapsed by default, Astek-style) */
+document.querySelectorAll('details.sec').forEach((d) => {
+  d.open = localStorage.getItem('gr_sec_' + d.dataset.k) === '1';
+  d.ontoggle = () => localStorage.setItem('gr_sec_' + d.dataset.k, d.open ? '1' : '0');
+});
 boot();
 </script>
 </body>
diff --git a/server.js b/server.js
index 504ce45..9ea6cd8 100644
--- a/server.js
+++ b/server.js
@@ -93,32 +93,101 @@ async function load() {
   console.log(`[gracie-internal] loaded ${SNAP.length} products`);
 }
 
-// Build field-tab facets (value + count) for the left panel.
+// ── color-family bucketing (all.dw rail parity) ──────────────────────────────────
+// Gracie is single-vendor and has no free-text color TAGS like all.dw — instead each
+// product carries a real HEX palette (color_hex / dominant_color_hex + ai_colors[{hex,name}]).
+// We bucket every hex into an interior-designer hue FAMILY (hex → HSL → family), so the
+// Color facet shows family rows with a representative swatch + count. Bucketing is derived
+// on SNAP, so it is identical in both data modes (pg + jsonl) by construction.
+const FAMILY_ORDER = ['Red', 'Orange', 'Brown', 'Yellow', 'Gold', 'Green', 'Teal', 'Blue', 'Purple', 'Pink', 'Beige', 'Cream', 'Black', 'Gray', 'White', 'Metallic', 'Multi'];
+function hexToRgb(hex) {
+  const m = String(hex || '').trim().replace(/^#/, '');
+  if (!/^[0-9a-fA-F]{6}$/.test(m)) return null;
+  return [parseInt(m.slice(0, 2), 16), parseInt(m.slice(2, 4), 16), parseInt(m.slice(4, 6), 16)];
+}
+function rgbToHsl(r, g, b) {
+  r /= 255; g /= 255; b /= 255;
+  const mx = Math.max(r, g, b), mn = Math.min(r, g, b); let h = 0, s = 0; const l = (mx + mn) / 2;
+  if (mx !== mn) {
+    const d = mx - mn; s = l > 0.5 ? d / (2 - mx - mn) : d / (mx + mn);
+    switch (mx) { case r: h = (g - b) / d + (g < b ? 6 : 0); break; case g: h = (b - r) / d + 2; break; case b: h = (r - g) / d + 4; break; }
+    h /= 6;
+  }
+  return [h * 360, s * 100, l * 100];
+}
+// hex → interior-designer color family; null when unparseable.
+function familyOf(hex) {
+  const rgb = hexToRgb(hex); if (!rgb) return null;
+  const [h, s, l] = rgbToHsl(...rgb);
+  if (l <= 10) return 'Black';
+  if (l >= 94 && s <= 8) return 'White';
+  if (s <= 10) { if (l >= 86) return 'White'; return 'Gray'; }
+  if (s <= 25 && l >= 84) return 'Cream';                                    // parchment/ivory grounds
+  if ((h < 52 || h >= 345) && l <= 32 && !(h > 300 && h < 345)) return 'Brown'; // espresso/chocolate
+  if (h >= 18 && h < 52) {                                                   // warm band → Brown/Beige/Gold/Cream
+    if (l <= 42) return 'Brown';
+    if (s <= 38 && l >= 82) return 'Cream';
+    if (s <= 55 && l >= 60) return 'Beige';
+    if (s >= 45 && l >= 42 && l <= 72) return 'Gold';
+    if (l <= 60) return 'Brown';
+    return 'Beige';
+  }
+  if (h >= 52 && h < 66) return 'Yellow';
+  if ((h < 15 || h >= 340) && (l >= 72 || (s <= 60 && l >= 60))) return 'Pink';
+  if (h < 15 || h >= 345) return 'Red';
+  if (h < 18) return 'Orange';
+  if (h < 160) return 'Green';
+  if (h < 200) return 'Teal';
+  if (h < 255) return 'Blue';
+  if (h < 290) return 'Purple';
+  if (h < 340) return 'Pink';
+  return 'Red';
+}
+// The set of color families present on a product (deduped), from its ai_colors palette,
+// falling back to color_hex when the palette is empty.
+function familiesOf(p) {
+  const fams = new Set();
+  for (const c of (p.colors || [])) { const f = familyOf(c && c.hex); if (f) fams.add(f); }
+  if (!fams.size && p.color_hex) { const f = familyOf(p.color_hex); if (f) fams.add(f); }
+  return [...fams];
+}
+
+// ── facet aggregation (all.dw rail shape) ────────────────────────────────────────
+// Returns {counts:{value:count}} per dimension + swatch map for color + orderings.
+// Computed on SNAP, so pg and jsonl modes yield identical facets. Style + Tag are
+// EXPLODED from their JSONB arrays; Color is family-bucketed; Price is quote-only.
 function facets() {
-  const f = {};
-  const bump = (key, val) => {
-    if (val == null || val === '') return;
-    f[key] = f[key] || {};
-    f[key][val] = (f[key][val] || 0) + 1;
-  };
+  const type = {}, series = {}, color = {}, style = {}, material = {}, tag = {}, price_band = {}, image_state = {};
+  const colorSwatch = {};
+  const bump = (o, v) => { if (v == null || v === '') return; o[v] = (o[v] || 0) + 1; };
   for (const p of SNAP) {
-    bump('Collection', p.collection);
-    bump('Type', p.product_type);
-    bump('Material', p.material);
-    (p.styles || []).forEach((s) => bump('Style', s));
-    (p.tags || []).forEach((t) => bump('Tag', t));
-    bump('Settlement', p.settlement_flag || 'ungated');
-    bump('Enriched', p.enriched ? 'yes' : 'no');
+    bump(type, p.product_type);
+    bump(series, p.collection);
+    bump(material, p.material || 'Unspecified');
+    (p.styles || []).forEach((s) => bump(style, s));                        // explode ai_styles JSONB
+    (p.tags || []).forEach((t) => bump(tag, t));                            // explode ai_tags JSONB
+    for (const fam of familiesOf(p)) {
+      bump(color, fam);
+      // a representative swatch per family — prefer a hex that truly buckets to this family
+      const hit = (p.colors || []).find((c) => c && familyOf(c.hex) === fam) || (familyOf(p.color_hex) === fam ? { hex: p.color_hex } : null);
+      if (hit && (!colorSwatch[fam] || familyOf(colorSwatch[fam]) !== fam)) colorSwatch[fam] = hit.hex;
+    }
+    // Gracie is QUOTE-ONLY — a single price band reflects that (parity with all.dw's price facet shape).
+    bump(price_band, 'Quote-only');
+    bump(image_state, p.image_url ? 'Has image' : 'No image');
   }
-  const out = {};
-  for (const k of Object.keys(f)) {
-    out[k] = Object.entries(f[k]).sort((a, b) => b[1] - a[1]).map(([value, count]) => ({ value, count }));
-  }
-  return out;
+  return {
+    type, series, color, style, material, tag, price_band, image_state,
+    color_swatch: colorSwatch,
+    family_order: FAMILY_ORDER,
+    price_order: ['Quote-only'],
+    total: SNAP.length,
+    refreshed: LAST_REFRESH,
+  };
 }
 
 app.get('/api/products', (_req, res) => res.json({ count: SNAP.length, products: SNAP }));
-app.get('/api/facets', (_req, res) => res.json({ facets: facets(), total: SNAP.length, refreshed: LAST_REFRESH }));
+app.get('/api/facets', (_req, res) => res.json(facets()));
 
 app.use(express.static(path.join(__dirname, 'public')));
 

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