[object Object]

← back to All Designerwallcoverings

amazon-style left filter rail + grid/list toggle + sortable columns (every field, asc/desc w/ null-aware desc) + density to 20 + URL param overrides

bb41e316e4586728fa435f86095f819dcd4b70ac · 2026-07-02 17:02:18 -0700 · Steve Abrams

Files touched

Diff

commit bb41e316e4586728fa435f86095f819dcd4b70ac
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Thu Jul 2 17:02:18 2026 -0700

    amazon-style left filter rail + grid/list toggle + sortable columns (every field, asc/desc w/ null-aware desc) + density to 20 + URL param overrides
---
 public/index.html | 360 +++++++++++++++++++++++++++++++++++-------------------
 server.js         |  25 +++-
 2 files changed, 258 insertions(+), 127 deletions(-)

diff --git a/public/index.html b/public/index.html
index c3a2523..5b37d37 100644
--- a/public/index.html
+++ b/public/index.html
@@ -5,39 +5,52 @@
 <meta name="viewport" content="width=device-width, initial-scale=1">
 <title>All Products — Designer Wallcoverings</title>
 <style>
-  :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; }
+  :root { --cols: 6; --bg:#0d0d0f; --card:#17171b; --line:#2a2a30; --txt:#e8e8ea; --mut:#9a9aa2; --accent:#c8a96a; --rail:260px; }
   * { box-sizing: border-box; }
   body { margin:0; background:var(--bg); color:var(--txt); font:14px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif; }
-  header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; max-height:78vh; overflow:auto; }
-  h1 { margin:0 0 10px; font-size:18px; font-weight:600; letter-spacing:.02em; }
-  h1 small { color:var(--mut); font-weight:400; font-size:13px; margin-left:8px; }
-  h1 a { color:var(--mut); font-size:12px; font-weight:400; margin-left:14px; text-decoration:none; border-bottom:1px dotted var(--mut); }
-  .controls { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
+
+  /* ── top bar ── */
+  header { position:sticky; top:0; z-index:10; background:rgba(13,13,15,.96); backdrop-filter:blur(8px);
+    border-bottom:1px solid var(--line); padding:12px 20px; display:flex; flex-wrap:wrap; gap:10px 16px; align-items:center; }
+  h1 { margin:0; font-size:17px; font-weight:600; letter-spacing:.02em; white-space:nowrap; }
+  h1 small { color:var(--mut); font-weight:400; font-size:12px; margin-left:8px; }
+  h1 a { color:var(--mut); font-size:11px; font-weight:400; margin-left:10px; text-decoration:none; border-bottom:1px dotted var(--mut); }
   select, input[type=search], input[type=text] { background:var(--card); color:var(--txt); border:1px solid var(--line); border-radius:8px; padding:7px 10px; font-size:13px; }
-  input[type=search] { min-width:260px; }
-  .dens { display:flex; align-items:center; gap:8px; color:var(--mut); }
-  details.facets { margin-top:12px; }
-  details.facets > summary { cursor:pointer; color:var(--mut); font-size:12px; letter-spacing:.04em; text-transform:uppercase; list-style:none; user-select:none; padding:2px 0; }
-  details.facets > summary::-webkit-details-marker { display:none; }
-  details.facets > summary::before { content:'▸ '; color:var(--accent); }
-  details.facets[open] > summary::before { content:'▾ '; }
-  summary b { color:var(--accent); }
-  .facetbody { display:flex; flex-direction:column; gap:9px; margin-top:9px; }
-  .frow { display:flex; flex-wrap:wrap; gap:6px; align-items:center; }
-  .flabel { color:var(--mut); font-size:11px; text-transform:uppercase; letter-spacing:.06em; min-width:62px; }
-  .chip { background:var(--card); border:1px solid var(--line); border-radius:20px; padding:4px 10px; cursor:pointer; font-size:11.5px; color:var(--mut); }
-  .chip.on { background:var(--accent); color:#1a1407; border-color:var(--accent); font-weight:600; }
-  .chip .ct { opacity:.65; margin-left:4px; font-size:10px; }
-  .chip.zero { opacity:.3; }
-  .chip.allchip { font-weight:600; border-style:dashed; }
-  .chip.allchip.on { border-style:solid; }
-  .chip.more { border-style:dotted; color:var(--accent); }
-  .ffind { padding:3px 9px !important; font-size:11px !important; border-radius:20px !important; min-width:110px; }
-  .active { display:flex; flex-wrap:wrap; gap:6px; align-items:center; padding-top:2px; }
+  input[type=search] { flex:1 1 200px; min-width:180px; max-width:420px; }
+  .bar { display:flex; align-items:center; gap:8px; color:var(--mut); font-size:12px; white-space:nowrap; }
+  .dirbtn { background:var(--card); color:var(--accent); border:1px solid var(--line); border-radius:8px; padding:7px 10px; cursor:pointer; font-size:13px; }
+  .viewseg { display:flex; border:1px solid var(--line); border-radius:8px; overflow:hidden; }
+  .viewseg button { background:var(--card); color:var(--mut); border:none; padding:7px 13px; font-size:12px; cursor:pointer; }
+  .viewseg button.on { background:var(--accent); color:#1a1407; font-weight:600; }
+  #densWrap input { accent-color:var(--accent); width:110px; }
+  #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:59px; max-height:calc(100vh - 59px);
+    overflow-y:auto; padding:14px 14px 60px; border-right:1px solid var(--line); }
+  aside h4 { margin:16px 0 7px; font-size:11px; letter-spacing:.08em; text-transform:uppercase; color:var(--mut);
+    border-top:1px solid var(--line); padding-top:12px; }
+  aside h4:first-of-type { border-top:none; margin-top:2px; padding-top:0; }
+  .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:var(--card); }
+  .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:#1a1407; 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; }
+  .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-radius:6px !important; margin:2px 0 5px; }
+  .active { display:flex; flex-wrap:wrap; gap:6px; margin-bottom:4px; }
   .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; }
-  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:12px; padding:18px 20px 60px; }
+
+  main { flex:1; min-width:0; }
+
+  /* ── grid view ── */
+  .grid { display:grid; grid-template-columns:repeat(var(--cols),1fr); gap:10px; padding:14px 16px 30px; }
   .card { background:var(--card); border:1px solid var(--line); border-radius:10px; overflow:hidden; display:flex; flex-direction:column; }
   .thumb { aspect-ratio:1/1; background:#000 center/cover no-repeat; position:relative; }
   .thumb.empty { display:flex; align-items:center; justify-content:center; color:#444; font-size:11px; }
@@ -49,90 +62,177 @@
   .ttl { color:var(--txt); font-size:11.5px; margin-top:3px; max-height:2.8em; overflow:hidden; }
   .vt { color:var(--mut); font-size:10.5px; margin-top:3px; }
   .fac { margin-top:4px; font-size:10.5px; color:var(--accent); }
-  .priceline { margin-top:4px; }
-  .price { color:#e8e8ea; font-size:12.5px; font-weight:600; }
+  .price { color:#e8e8ea; font-size:12.5px; font-weight:600; margin-top:4px; display:block; }
+  /* dense grids: shed card text progressively so 20-across stays a clean texture wall */
+  .grid.d1 .ttl, .grid.d1 .fac { display:none; }
+  .grid.d2 .meta { display:none; }
+
+  /* ── list view ── */
+  .listwrap { padding:0 16px 30px; overflow-x:auto; }
+  table { width:100%; border-collapse:collapse; font-size:12.5px; }
+  thead th { position:sticky; top:59px; background:#131317; z-index:5; text-align:left; padding:9px 10px; font-size:11px;
+    letter-spacing:.05em; text-transform:uppercase; color:var(--mut); border-bottom:1px solid var(--line);
+    cursor:pointer; user-select:none; white-space:nowrap; }
+  thead th:hover { color:var(--accent); }
+  thead th.sorted { color:var(--accent); }
+  tbody td { padding:7px 10px; border-bottom:1px solid #1d1d22; vertical-align:middle; }
+  tbody tr:hover { background:#141419; }
+  tbody tr.linked { cursor:pointer; }
+  td.timg { width:52px; } td.timg div { width:44px; height:44px; border-radius:6px; background:#000 center/cover no-repeat; }
+  td .sku { font-size:12px; }
+  td.num { text-align:right; font-variant-numeric:tabular-nums; }
+  .tfac { color:var(--accent); font-size:11px; }
+
   #sentinel { height:1px; }
   #status { text-align:center; color:var(--mut); padding:16px; font-size:13px; }
+
+  @media (max-width: 900px) {
+    #railToggle { display:inline-block; }
+    aside { position:fixed; left:0; top:59px; 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>All Products <small id="sub">Designer Wallcoverings — the whole catalog, one place</small><a href="/vendors.html">vendor directory →</a></h1>
-  <div class="controls">
-    <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
-    <label class="dens">Sort
-      <select id="sort">
-        <option value="newest">Newest</option>
-        <option value="oldest">Oldest</option>
-        <option value="updated">Recently updated</option>
-        <option value="title">Title A→Z</option>
-        <option value="vendor">Vendor A→Z</option>
-        <option value="type">Type A→Z</option>
-        <option value="sku">SKU A→Z</option>
-        <option value="pattern">Pattern A→Z</option>
-        <option value="color">Color (family)</option>
-        <option value="style">Style A→Z</option>
-        <option value="material">Material A→Z</option>
-        <option value="price_asc">Price ↑</option>
-        <option value="price_desc">Price ↓</option>
-        <option value="status">Status</option>
-      </select>
-    </label>
-    <label class="dens">Density
-      <input type="range" id="dens" min="3" max="10" value="6">
-    </label>
-  </div>
+  <h1>All Products <small id="sub">loading…</small><a href="/vendors.html">vendor directory →</a></h1>
+  <button class="dirbtn" id="railToggle">☰ Filters</button>
+  <input type="search" id="q" placeholder="Search anything — red, damask, DWKR, Thibaut…">
+  <span class="bar">Sort
+    <select id="sort">
+      <option value="newest">Newest</option>
+      <option value="oldest">Oldest</option>
+      <option value="updated">Recently updated</option>
+      <option value="title">Title</option>
+      <option value="vendor">Vendor</option>
+      <option value="type">Type</option>
+      <option value="sku">SKU</option>
+      <option value="pattern">Pattern</option>
+      <option value="color">Color (family)</option>
+      <option value="style">Style</option>
+      <option value="material">Material</option>
+      <option value="price">Price</option>
+      <option value="status">Status</option>
+    </select>
+    <button class="dirbtn" id="dir" title="Flip sort direction">▲</button>
+  </span>
+  <span class="viewseg"><button id="vGrid">Grid</button><button id="vList">List</button></span>
+  <span class="bar" id="densWrap">Density <input type="range" id="dens" min="2" max="20" value="6"> <span id="densN"></span></span>
+</header>
 
-  <details class="facets" id="facets">
-    <summary>Filters <b id="facetCount"></b></summary>
-    <div class="facetbody">
-      <div class="frow"><span class="flabel">Vendor</span><div class="frow" id="vendorRow"></div></div>
-      <div class="frow"><span class="flabel">Type</span><div class="frow" id="typeRow"></div></div>
-      <div class="frow"><span class="flabel">Color</span><div class="frow" id="colorRow"></div></div>
-      <div class="frow"><span class="flabel">Style</span><div class="frow" id="styleRow"></div></div>
-      <div class="frow"><span class="flabel">Material</span><div class="frow" id="matRow"></div></div>
-      <div class="frow"><span class="flabel">Price</span><div class="frow" id="priceRow"></div></div>
-      <div class="frow"><span class="flabel">Status</span><div class="frow" id="statusRow"></div></div>
-      <div class="frow"><span class="flabel">Image</span><div class="frow" id="imgRow"></div></div>
-      <div class="active" id="activeRow"></div>
+<div class="wrap">
+  <aside id="rail">
+    <div class="active" id="activeRow"></div>
+    <h4>Vendor</h4><div id="vendorRow"></div>
+    <h4>Type</h4><div id="typeRow"></div>
+    <h4>Color</h4><div id="colorRow"></div>
+    <h4>Style</h4><div id="styleRow"></div>
+    <h4>Material</h4><div id="matRow"></div>
+    <h4>Price</h4><div id="priceRow"></div>
+    <h4>Status</h4><div id="statusRow"></div>
+    <h4>Image</h4><div id="imgRow"></div>
+  </aside>
+
+  <main>
+    <div class="grid" id="grid"></div>
+    <div class="listwrap" id="listwrap" style="display:none">
+      <table>
+        <thead><tr id="cols"></tr></thead>
+        <tbody id="tbody"></tbody>
+      </table>
     </div>
-  </details>
-</header>
-<div class="grid" id="grid"></div>
-<div id="sentinel"></div>
-<div id="status">loading…</div>
+    <div id="sentinel"></div>
+    <div id="status">loading…</div>
+  </main>
+</div>
 
 <script>
-const grid = document.getElementById('grid');
-const statusEl = document.getElementById('status');
-const subEl = document.getElementById('sub');
+const grid = document.getElementById('grid'), tbody = document.getElementById('tbody');
+const listwrap = document.getElementById('listwrap');
+const statusEl = document.getElementById('status'), subEl = document.getElementById('sub');
 const LIMIT = 120;
 const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
 
+const urlq = new URLSearchParams(location.search);
 let state = {
-  sort: localStorage.getItem('all_sort') || 'newest',
-  q: '',
+  sort: urlq.get('sort') || localStorage.getItem('all_sort') || 'newest',
+  dir: urlq.get('dir') || localStorage.getItem('all_dir') || 'asc',
+  view: urlq.get('view') || localStorage.getItem('all_view') || 'grid',
+  q: urlq.get('q') || '',
   vendors: loadSet('all_vendors'), types: loadSet('all_types'), statuses: loadSet('all_statuses'),
   colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
   prices: loadSet('all_prices'), images: loadSet('all_images'),
   offset: 0, total: 0, loading: false, done: false,
 };
 // per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
-const rowUI = { vendorRow: { expand: false, find: '' }, typeRow: { expand: false, find: '' } };
+const rowUI = {
+  vendorRow: { expand: false, find: '', typeahead: true }, typeRow: { expand: false, find: '', typeahead: true },
+  colorRow: { expand: false, find: '' }, styleRow: { expand: false, find: '' }, matRow: { expand: false, find: '' },
+  priceRow: { expand: false, find: '' }, statusRow: { expand: false, find: '' }, imgRow: { expand: false, find: '' },
+};
 
-const dens = document.getElementById('dens');
+/* ── top-bar wiring ── */
+const dens = document.getElementById('dens'), densN = document.getElementById('densN');
 dens.value = localStorage.getItem('all_cols') || 6;
-document.documentElement.style.setProperty('--cols', dens.value);
-dens.oninput = () => { document.documentElement.style.setProperty('--cols', dens.value); localStorage.setItem('all_cols', dens.value); };
+const setCols = () => { document.documentElement.style.setProperty('--cols', dens.value); densN.textContent = dens.value;
+  grid.classList.toggle('d1', dens.value >= 9); grid.classList.toggle('d2', dens.value >= 13); };
+setCols();
+dens.oninput = () => { setCols(); localStorage.setItem('all_cols', dens.value); };
 
-const facetsEl = document.getElementById('facets');
-facetsEl.open = localStorage.getItem('all_facets_open') !== '0';
-facetsEl.ontoggle = () => localStorage.setItem('all_facets_open', facetsEl.open ? '1' : '0');
+const sortSel = document.getElementById('sort'), dirBtn = document.getElementById('dir');
+sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : state.sort;
+state.sort = sortSel.value;
+const drawDir = () => { dirBtn.textContent = state.dir === 'desc' ? '▼' : '▲'; };
+drawDir();
+sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); reset(); drawCols(); };
+dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); drawDir(); reset(); drawCols(); };
+function persistSort() { localStorage.setItem('all_sort', state.sort); localStorage.setItem('all_dir', state.dir); }
 
-document.getElementById('sort').value = state.sort;
-document.getElementById('sort').onchange = (e) => { state.sort = e.target.value; localStorage.setItem('all_sort', state.sort); reset(); };
-let qt; document.getElementById('q').oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+const vG = document.getElementById('vGrid'), vL = document.getElementById('vList');
+function drawView() {
+  vG.classList.toggle('on', state.view === 'grid'); vL.classList.toggle('on', state.view === 'list');
+  grid.style.display = state.view === 'grid' ? '' : 'none';
+  listwrap.style.display = state.view === 'list' ? '' : 'none';
+  document.getElementById('densWrap').style.opacity = state.view === 'grid' ? 1 : .35;
+}
+vG.onclick = () => { state.view = 'grid'; localStorage.setItem('all_view', 'grid'); drawView(); reset(); };
+vL.onclick = () => { state.view = 'list'; localStorage.setItem('all_view', 'list'); drawView(); reset(); };
+drawView();
+
+const rail = document.getElementById('rail');
+document.getElementById('railToggle').onclick = () => rail.classList.toggle('open');
+
+const qEl = document.getElementById('q'); qEl.value = state.q;
+let qt; qEl.oninput = (e) => { clearTimeout(qt); qt = setTimeout(() => { state.q = e.target.value.trim(); applyFilterChange(); }, 250); };
+
+/* ── list-view sortable columns: every column sorts, click toggles asc/desc ── */
+const COLS = [
+  { label: '', key: null }, // thumb
+  { label: 'SKU', key: 'sku' }, { label: 'Title', key: 'title' }, { label: 'Vendor', key: 'vendor' },
+  { label: 'Type', key: 'type' }, { label: 'Pattern', key: 'pattern' }, { label: 'Color', key: 'color' },
+  { label: 'Style', key: 'style' }, { label: 'Material', key: 'material' },
+  { label: 'Price', key: 'price', num: true }, { label: 'Status', key: 'status' },
+];
+function drawCols() {
+  const tr = document.getElementById('cols'); tr.innerHTML = '';
+  COLS.forEach((c) => {
+    const th = document.createElement('th');
+    if (c.key) {
+      const on = state.sort === c.key;
+      th.className = on ? 'sorted' : '';
+      th.textContent = c.label + (on ? (state.dir === 'desc' ? ' ▼' : ' ▲') : '');
+      th.onclick = () => {
+        if (state.sort === c.key) state.dir = state.dir === 'desc' ? 'asc' : 'desc';
+        else { state.sort = c.key; state.dir = 'asc'; }
+        sortSel.value = c.key; persistSort(); drawDir(); drawCols(); reset();
+      };
+    } else th.textContent = c.label;
+    tr.appendChild(th);
+  });
+}
+drawCols();
 
+/* ── filters (shared with facets API) ── */
 const SETS = {
   vendors: 'all_vendors', types: 'all_types', statuses: 'all_statuses', colors: 'all_colors',
   styles: 'all_styles', materials: 'all_materials', prices: 'all_prices', images: 'all_images',
@@ -140,7 +240,7 @@ const SETS = {
 function persistFacets() { for (const [k, key] of Object.entries(SETS)) localStorage.setItem(key, JSON.stringify([...state[k]])); }
 function facetParams(extra = {}) {
   return new URLSearchParams({
-    sort: state.sort, q: state.q,
+    sort: state.sort, dir: state.dir, q: state.q,
     vendors: [...state.vendors].join(','), types: [...state.types].join(','),
     statuses: [...state.statuses].join(','), colors: [...state.colors].join(','),
     styles: [...state.styles].join(','), materials: [...state.materials].join(','),
@@ -148,26 +248,16 @@ function facetParams(extra = {}) {
   });
 }
 
-function reset() { grid.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
+function reset() { grid.innerHTML = ''; tbody.innerHTML = ''; state.offset = 0; state.done = false; statusEl.textContent = 'loading…'; load(); }
 function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
 function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
 
-function allChip(set, label = 'All') {
-  const el = document.createElement('span');
-  el.className = 'chip allchip' + (set.size === 0 ? ' on' : '');
-  el.textContent = label;
-  el.title = set.size ? 'Clear this category' : 'Showing all';
-  el.onclick = () => { if (set.size) { set.clear(); applyFilterChange(); } };
-  return el;
-}
-
-// count-chip row. Big rows (vendor/type) get a type-ahead + top-N cap with "+N more" expander;
-// selected values ALWAYS render so an active filter can't hide off-screen.
-function chipRow(rowId, counts, set, opts = {}) {
+/* Amazon-style vertical facet list: checkbox rows + counts, type-ahead on big rows,
+   See-more expander, selected values always visible. */
+function facetList(rowId, counts, set, opts = {}) {
   const row = document.getElementById(rowId); row.innerHTML = '';
   const ui = rowUI[rowId];
-  row.appendChild(allChip(set, opts.allLabel));
-  if (ui) {
+  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); } };
@@ -177,27 +267,26 @@ function chipRow(rowId, counts, set, opts = {}) {
   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 && ui.find) entries = entries.filter(([v]) => v.toLowerCase().includes(ui.find.toLowerCase()));
-  const CAP = 30;
+  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 && !ui.expand && entries.length > CAP) { shown = entries.slice(0, CAP); hidden = entries.length - CAP; }
-  // active selections must always be visible
+  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('span'); s.style.cssText = 'font-size:10px;color:#6a6a72'; s.textContent = 'none'; row.appendChild(s); return; }
+  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]) => {
-    const el = document.createElement('span');
-    el.className = 'chip' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
-    el.innerHTML = `${val}<span class="ct">${ct.toLocaleString()}</span>`;
+    const el = document.createElement('div');
+    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span><span class="lbl" title="${val.replace(/"/g, '&quot;')}">${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 = 'chip more'; el.textContent = `+${hidden.toLocaleString()} more`;
+    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 && ui.expand && entries.length > CAP) {
-    const el = document.createElement('span'); el.className = 'chip more'; el.textContent = 'show less';
+  } 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);
   }
@@ -206,14 +295,14 @@ function chipRow(rowId, counts, set, opts = {}) {
 let LAST_FACETS = {};
 function renderFacets(f) {
   LAST_FACETS = f;
-  chipRow('vendorRow', f.vendor, state.vendors);
-  chipRow('typeRow', f.type, state.types);
-  chipRow('colorRow', f.color, state.colors, { order: f.family_order });
-  chipRow('styleRow', f.style, state.styles);
-  chipRow('matRow', f.material, state.materials);
-  chipRow('priceRow', f.price_band, state.prices, { order: f.price_order });
-  chipRow('statusRow', f.status, state.statuses, { allLabel: 'Active (default)' });
-  chipRow('imgRow', f.image_state, state.images);
+  facetList('vendorRow', f.vendor, state.vendors, { cap: 10 });
+  facetList('typeRow', f.type, state.types, { cap: 10 });
+  facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12 });
+  facetList('styleRow', f.style, state.styles, { cap: 10 });
+  facetList('matRow', f.material, state.materials, { cap: 10 });
+  facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8 });
+  facetList('statusRow', f.status, state.statuses, { cap: 6 });
+  facetList('imgRow', f.image_state, state.images, { cap: 4 });
   renderActive();
 }
 
@@ -231,7 +320,6 @@ function renderActive() {
       row.appendChild(el);
     });
   }
-  document.getElementById('facetCount').textContent = n ? `· ${n} active` : '';
   if (n) {
     const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
     c.onclick = () => { dims.forEach(([, s]) => s.clear()); applyFilterChange(); };
@@ -246,6 +334,7 @@ async function refreshFacets() {
   } catch (e) { /* facets degrade silently — grid still works */ }
 }
 
+/* ── renderers ── */
 function card(r) {
   const el = document.createElement('div'); el.className = 'card';
   const stBadge = (r.status && r.status !== 'ACTIVE') ? `<span class="badge st-${r.status}">${r.status}</span>` : '';
@@ -254,12 +343,31 @@ function card(r) {
     : `<div class="thumb empty">${stBadge}no image</div>`;
   const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
   const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
-  const price = r.price ? `<div class="priceline"><span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span></div>` : '';
+  const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
   el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${fac ? `<div class="fac">${fac}</div>` : ''}${price}</div>`;
   if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
   return el;
 }
 
+function listRow(r) {
+  const tr = document.createElement('tr');
+  const cell = (html, cls) => { const td = document.createElement('td'); if (cls) td.className = cls; td.innerHTML = html; return td; };
+  tr.appendChild((() => { const td = document.createElement('td'); td.className = 'timg';
+    td.innerHTML = r.image ? `<div style="background-image:url('${r.image}')"></div>` : '<div></div>'; return td; })());
+  tr.appendChild(cell(`<span class="sku">${r.sku || '—'}</span>`));
+  tr.appendChild(cell((r.title || '').replace(/</g, '&lt;')));
+  tr.appendChild(cell(r.vendor || ''));
+  tr.appendChild(cell(r.type || ''));
+  tr.appendChild(cell(r.pattern || ''));
+  tr.appendChild(cell(`<span class="tfac">${(r.colors || []).join(', ')}</span>`));
+  tr.appendChild(cell(`<span class="tfac">${(r.styles || []).join(', ')}</span>`));
+  tr.appendChild(cell(`<span class="tfac">${(r.materials || []).join(', ')}</span>`));
+  tr.appendChild(cell(r.price ? '$' + r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '', 'num'));
+  tr.appendChild(cell(r.status || ''));
+  if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
+  return tr;
+}
+
 async function load() {
   if (state.loading || state.done) return;
   state.loading = true;
@@ -268,11 +376,11 @@ async function load() {
     const data = await res.json();
     state.total = data.total;
     const frag = document.createDocumentFragment();
-    data.rows.forEach((r) => frag.appendChild(card(r)));
-    grid.appendChild(frag);
+    data.rows.forEach((r) => frag.appendChild(state.view === 'list' ? listRow(r) : card(r)));
+    (state.view === 'list' ? tbody : grid).appendChild(frag);
     state.offset += data.rows.length;
     state.done = state.offset >= data.total || data.rows.length === 0;
-    subEl.textContent = `${data.catalog.toLocaleString()} products in catalog · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
+    subEl.textContent = `${data.catalog.toLocaleString()} products · ${data.total.toLocaleString()} matching · showing ${state.offset.toLocaleString()}`;
     statusEl.textContent = data.total === 0 ? '— no matching products —'
       : (state.done ? `— end · ${state.total.toLocaleString()} matching —` : 'scroll for more…');
   } catch (e) {
diff --git a/server.js b/server.js
index 9563791..8eda2af 100644
--- a/server.js
+++ b/server.js
@@ -169,6 +169,15 @@ const sorters = {
   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.sku).localeCompare(str(b.sku)); },
   style: (a, b) => str(a.styles[0]).localeCompare(str(b.styles[0])) || str(a.sku).localeCompare(str(b.sku)),
   material: (a, b) => str(a.materials[0]).localeCompare(str(b.materials[0])) || str(a.sku).localeCompare(str(b.sku)),
+  price: (a, b) => (a.price ?? 1e12) - (b.price ?? 1e12), // generic; pair with dir=desc for high→low
+};
+
+// per-sort "row actually has this value" — used to keep empties last on dir=desc
+const HAS_VALUE = {
+  sku: (r) => r.sku != null && r.sku !== '', title: (r) => !!r.title, vendor: (r) => !!r.vendor,
+  type: (r) => !!r.type, pattern: (r) => !!r.pattern, status: (r) => !!r.status,
+  price: (r) => r.price != null, price_asc: (r) => r.price != null, price_desc: (r) => r.price != null,
+  color: (r) => r.colors.length > 0, style: (r) => r.styles.length > 0, material: (r) => r.materials.length > 0,
 };
 
 const tally = (rows, key) => { const m = {}; for (const r of rows) { const v = r[key]; if (v) m[v] = (m[v] || 0) + 1; } return m; };
@@ -190,7 +199,21 @@ const server = http.createServer((req, res) => {
     const offset = parseInt(u.searchParams.get('offset') || '0', 10) || 0;
     const limit = Math.min(parseInt(u.searchParams.get('limit') || '120', 10) || 120, 500);
     let rows = applyFilters(ROWS, f);
-    if (sorters[sort]) rows = [...rows].sort(sorters[sort]);
+    if (sorters[sort]) {
+      rows = [...rows].sort(sorters[sort]);
+      // dir=desc flips ANY sort key so every list-view column sorts both ways.
+      // Reversing would surface the null-sentinel rows first, so after the flip
+      // we stable-partition rows lacking the sort value back to the end.
+      if (u.searchParams.get('dir') === 'desc') {
+        rows.reverse();
+        const has = HAS_VALUE[sort];
+        if (has) {
+          const withV = [], without = [];
+          for (const r of rows) (has(r) ? withV : without).push(r);
+          rows = withV.concat(without);
+        }
+      }
+    }
     res.writeHead(200, { 'Content-Type': 'application/json' });
     res.end(JSON.stringify({
       total: rows.length, catalog: ROWS.length, loaded_at: LOADED_AT, offset, limit,

← 86857a5 portable snapshot SQL: probe optional mirror-only columns (m  ·  back to All Designerwallcoverings  ·  Astek-viewer alignment: accordion filter sections + CARD FIE 96f3c15 →