[object Object]

← back to Reidwitlin Landing

Grid UI upgrade: density slider to 20 cols, grid/list view toggle with sticky sortable column headers (10 fields, asc/desc, full-set sort), Safari user:pass@ fetch fix (location.origin)

19bbf56d983788e2b423109ff0b6156ee8eca8e3 · 2026-07-02 08:39:12 -0700 · Steve

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Files touched

Diff

commit 19bbf56d983788e2b423109ff0b6156ee8eca8e3
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 08:39:12 2026 -0700

    Grid UI upgrade: density slider to 20 cols, grid/list view toggle with sticky sortable column headers (10 fields, asc/desc, full-set sort), Safari user:pass@ fetch fix (location.origin)
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/index.html   | 77 +++++++++++++++++++++++++++++++++++++++++++++++------
 public/product.html |  7 ++---
 public/styles.css   | 34 +++++++++++++++++++++++
 3 files changed, 107 insertions(+), 11 deletions(-)

diff --git a/public/index.html b/public/index.html
index fff74e0..256db2e 100644
--- a/public/index.html
+++ b/public/index.html
@@ -58,7 +58,10 @@
     </div>
     <div class="ctl">
       <label for="density">Density</label>
-      <input type="range" id="density" min="2" max="6" step="1" value="4">
+      <input type="range" id="density" min="2" max="20" step="1" value="4">
+    </div>
+    <div class="ctl">
+      <button class="viewbtn" id="view" type="button">▤ List view</button>
     </div>
   </div>
 </div>
@@ -68,6 +71,7 @@
 
 <!-- GRID -->
 <main class="grid-wrap">
+  <div class="lhead" id="listHead" hidden></div>
   <div class="grid" id="grid"></div>
 </main>
 
@@ -119,7 +123,7 @@
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ALL=[], view=[], activeBook=null, activeColor=null, activeSeries=null, FACETS=null, LINE='';
-const LS={sort:'reidwitlin.sort', dens:'reidwitlin.density'};
+const LS={sort:'reidwitlin.sort', dens:'reidwitlin.density', view:'reidwitlin.view'};
 const BUCKET_HEX={white:'#efeae0',grey:'#9e978c',black:'#2a2722',red:'#a23b2e',orange:'#c8763a',brown:'#7b5a3c',gold:'#b8923f',green:'#5d7150',teal:'#3f8a86',blue:'#5a7494',purple:'#7a5f86',pink:'#bb8aa0'};
 
 // hero rotation from room shots
@@ -155,12 +159,47 @@ function sortItems(items,mode){
   return a;
 }
 
+// ── list view: sortable columns over every non-null data field ──
+const COLS=[
+  ['book','Book'],['series','Pattern'],['display_name','Name'],['color','Color'],
+  ['color_bucket','Bucket'],['sku','SKU'],['dw_sku','DW SKU'],['width','Width'],
+  ['repeat','Repeat'],['published_at','Added']
+];
+let colSort=null; // {f:field, dir:1|-1}
+function colCompare(f,dir){
+  return (x,y)=>{
+    let a=x[f], b=y[f];
+    const ea=(a==null||a===''), eb=(b==null||b==='');
+    if(ea&&eb) return 0; if(ea) return 1; if(eb) return -1; // empties last either way
+    if(f==='published_at') return (new Date(a)-new Date(b))*dir;
+    return String(a).localeCompare(String(b),undefined,{numeric:true,sensitivity:'base'})*dir;
+  };
+}
+function renderListHead(){
+  const h=$('#listHead');
+  h.innerHTML='<span></span>'+COLS.map(([f,l])=>{
+    const on=colSort&&colSort.f===f;
+    return `<button class="lh ${on?'on':''}" data-f="${f}" type="button">${l}${on?(colSort.dir>0?' ▲':' ▼'):''}</button>`;
+  }).join('');
+  h.querySelectorAll('.lh').forEach(b=>b.onclick=()=>{
+    const f=b.dataset.f;
+    colSort = (colSort&&colSort.f===f) ? {f,dir:-colSort.dir} : {f,dir:1};
+    renderListHead(); render();
+  });
+}
+function fmtAdded(d){
+  if(!d) return '';
+  const t=new Date(d); if(isNaN(t)) return '';
+  return t.toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'});
+}
+
 function render(){
   let items = ALL;
   if(activeBook) items = items.filter(p=>p.book===activeBook);
   if(activeColor) items = items.filter(p=>p.color_bucket===activeColor);
   if(activeSeries) items = items.filter(p=>p.series===activeSeries);
   items = sortItems(items, $('#sort').value);
+  if(colSort) items = [...items].sort(colCompare(colSort.f,colSort.dir)); // full filtered set, not just rendered rows
   view=items;
   $('#cnt').textContent=items.length;
   // cap DOM to first 600 for perf; controls still filter the full set
@@ -174,6 +213,16 @@ function render(){
           <span class="col">${p.color_bucket?`<span class="dot" style="background:${BUCKET_HEX[p.color_bucket]||'#ccc'}"></span>`:''}${p.book||LINE}</span>
           <span class="pr view">View →</span>
         </div>
+        <span class="lc">${p.book||''}</span>
+        <span class="lc">${p.series||''}</span>
+        <span class="lc lc-nm">${p.display_name||p.title||''}</span>
+        <span class="lc">${p.color||''}</span>
+        <span class="lc">${p.color_bucket?`<span class="dot" style="background:${BUCKET_HEX[p.color_bucket]||'#ccc'}"></span>`:''}${p.color_bucket||''}</span>
+        <span class="lc mono">${p.sku||''}</span>
+        <span class="lc mono">${p.dw_sku||''}</span>
+        <span class="lc">${p.width||''}</span>
+        <span class="lc">${p.repeat||''}</span>
+        <span class="lc">${fmtAdded(p.published_at)}</span>
       </div>
     </div>`).join('');
 }
@@ -195,7 +244,18 @@ function colorbar(facets){
 
 function applyDensity(v){document.documentElement.style.setProperty('--cols',v);}
 $('#density').addEventListener('input',e=>{applyDensity(e.target.value);localStorage.setItem(LS.dens,e.target.value);});
-$('#sort').addEventListener('change',()=>{localStorage.setItem(LS.sort,$('#sort').value);render();});
+$('#sort').addEventListener('change',()=>{localStorage.setItem(LS.sort,$('#sort').value);colSort=null;renderListHead();render();});
+
+// grid / list view toggle (persisted)
+function applyView(){
+  const list=localStorage.getItem(LS.view)==='list';
+  grid.classList.toggle('list',list);
+  $('#listHead').hidden=!list;
+  $('#view').textContent=list?'▦ Grid view':'▤ List view';
+  const c=document.querySelector('.controls');
+  if(c) document.documentElement.style.setProperty('--ctlh',c.offsetHeight+'px');
+}
+$('#view').onclick=()=>{localStorage.setItem(LS.view,localStorage.getItem(LS.view)==='list'?'grid':'list');applyView();};
 $('#pattern').addEventListener('change',()=>{activeSeries=$('#pattern').value||null;render();$('#catalog').scrollIntoView({behavior:'smooth'});});
 function fillPatterns(facets){
   const sel=$('#pattern');
@@ -275,7 +335,7 @@ $('#inqSend').onclick=async()=>{
   if(!/.+@.+\..+/.test(email)){m.textContent='Please enter a valid email.';m.className='msg err';return;}
   $('#inqSend').disabled=true;
   try{
-    const r=await fetch('/api/inquiry',{method:'POST',headers:{'Content-Type':'application/json'},
+    const r=await fetch(location.origin+'/api/inquiry',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({sku:$('#inqSku').value||'reidwitlin-collection',name:$('#inqName').value.trim(),email,note:$('#inqNote').value.trim()})});
     const j=await r.json();
     if(j.ok){m.textContent=j.message;m.className='msg ok';setTimeout(()=>$('#inqBack').classList.remove('on'),1800);}
@@ -289,14 +349,15 @@ $('#footGate')&&$('#footGate').addEventListener('click',()=>openInquiry(null));
 (async()=>{
   const savedSort=localStorage.getItem(LS.sort); if(savedSort)$('#sort').value=savedSort;
   const savedDens=localStorage.getItem(LS.dens)||'4'; $('#density').value=savedDens; applyDensity(savedDens);
+  // location.origin prefix: pages opened via user:pass@ URLs break relative fetches in WebKit
   const [cfg,pd,fc]=await Promise.all([
-    fetch('/api/config').then(r=>r.json()),
-    fetch('/api/products').then(r=>r.json()),
-    fetch('/api/facets').then(r=>r.json())]);
+    fetch(location.origin+'/api/config').then(r=>r.json()),
+    fetch(location.origin+'/api/products').then(r=>r.json()),
+    fetch(location.origin+'/api/facets').then(r=>r.json())]);
   applyConfig(cfg);
   ALL=pd.products; FACETS=fc;
   const sb=$('#shopbar'); if(sb){ sb.innerHTML=`Explore all ${ALL.length} ${cfg.line||''} designs at <b>Designer Wallcoverings</b> →`; }
-  startHero(ALL); chips(fc); colorbar(fc); fillPatterns(fc); render();
+  startHero(ALL); chips(fc); colorbar(fc); fillPatterns(fc); renderListHead(); applyView(); render();
 })();
 </script>
 </body>
diff --git a/public/product.html b/public/product.html
index c475a69..1d2bc3b 100644
--- a/public/product.html
+++ b/public/product.html
@@ -47,7 +47,8 @@ const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"
 function specRow(k,v){return v?`<div class="row"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';}
 
 (async()=>{
-  const [cfg,r]=await Promise.all([fetch('/api/config').then(x=>x.json()).catch(()=>({})), fetch('/api/product/'+encodeURIComponent(handle))]);
+  // location.origin prefix: pages opened via user:pass@ URLs break relative fetches in WebKit
+  const [cfg,r]=await Promise.all([fetch(location.origin+'/api/config').then(x=>x.json()).catch(()=>({})), fetch(location.origin+'/api/product/'+encodeURIComponent(handle))]);
   const LINE = cfg.line || cfg.vendor || 'Reid Witlin';
   if(cfg.slug){ document.body.dataset.line = cfg.slug; }
   document.querySelector('.corner.ul').setAttribute('href', cfg.houseUrl || 'https://www.designerwallcoverings.com');
@@ -108,7 +109,7 @@ function specRow(k,v){return v?`<div class="row"><span class="k">${k}</span><spa
 
   // pairs well with
   try{
-    const pr=await (await fetch('/api/pairs/'+encodeURIComponent(handle))).json();
+    const pr=await (await fetch(location.origin+'/api/pairs/'+encodeURIComponent(handle))).json();
     if(pr.pairs&&pr.pairs.length){
       document.getElementById('pairGrid').innerHTML=pr.pairs.map(q=>`
         <a class="pcard" href="/product/${encodeURIComponent(q.handle)}">
@@ -144,7 +145,7 @@ document.getElementById('inqSend').onclick=async()=>{
   if(!/.+@.+\..+/.test(email)){m.textContent='Please enter a valid email.';m.className='msg err';return;}
   document.getElementById('inqSend').disabled=true;
   try{
-    const r=await fetch('/api/inquiry',{method:'POST',headers:{'Content-Type':'application/json'},
+    const r=await fetch(location.origin+'/api/inquiry',{method:'POST',headers:{'Content-Type':'application/json'},
       body:JSON.stringify({sku:document.getElementById('inqSku').value||'reidwitlin',name:document.getElementById('inqName').value.trim(),email,note:document.getElementById('inqNote').value.trim()})});
     const j=await r.json();
     if(j.ok){m.textContent=j.message;m.className='msg ok';setTimeout(()=>document.getElementById('inqBack').classList.remove('on'),1800);}
diff --git a/public/styles.css b/public/styles.css
index 3fd0969..090cec8 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -121,6 +121,40 @@ body.scrolled .corner{opacity:0;pointer-events:none}
 .card .pr{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:var(--gold);transition:.2s}
 .card:hover .pr{color:var(--accent)}
 
+/* ---------- view toggle ---------- */
+.viewbtn{font-family:var(--sans);font-size:12px;letter-spacing:.04em;border:1px solid var(--line);
+  background:var(--paper);color:var(--ink);border-radius:8px;padding:7px 13px;cursor:pointer;transition:.18s}
+.viewbtn:hover{border-color:var(--taupe)}
+
+/* ---------- list view ---------- */
+/* aligned CSS-grid rows: card .body uses display:contents so its .lc children
+   become columns of the card grid. Columns:
+   48px thumb · Book · Pattern · Name · Color · Bucket · SKU · DW SKU · Width · Repeat · Added */
+.lc{display:none}
+.lhead{position:sticky;top:var(--ctlh,62px);z-index:40;display:grid;align-items:center;
+  grid-template-columns:48px 1fr 1.1fr 1.3fr 1fr .9fr 1.1fr 1.2fr .7fr 1.1fr .9fr;column-gap:12px;
+  background:rgba(246,242,236,.97);backdrop-filter:blur(8px);border:1px solid var(--line);
+  border-radius:4px;padding:0 12px 0 8px;margin-bottom:6px}
+.lhead .lh{font-family:var(--sans);font-size:10.5px;letter-spacing:.1em;text-transform:uppercase;
+  color:var(--ink-soft);background:none;border:0;text-align:left;padding:10px 0;cursor:pointer;
+  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.lhead .lh:hover{color:var(--ink)}
+.lhead .lh.on{color:var(--accent);font-weight:600}
+.grid.list{grid-template-columns:1fr;gap:5px}
+.grid.list .card{display:grid;align-items:center;min-height:52px;padding:0 12px 0 8px;border-radius:4px;
+  grid-template-columns:48px 1fr 1.1fr 1.3fr 1fr .9fr 1.1fr 1.2fr .7fr 1.1fr .9fr;column-gap:12px}
+.grid.list .card:hover{transform:none;box-shadow:0 8px 22px -16px rgba(33,29,24,.45)}
+.grid.list .card .ph{width:48px;min-width:48px;height:48px;aspect-ratio:auto;border-radius:3px;margin:4px 0}
+.grid.list .card:hover .ph{transform:none}
+.grid.list .card .body{display:contents;padding:0}
+.grid.list .card .ser,.grid.list .card .nm,.grid.list .card .meta{display:none}
+.grid.list .lc{display:block;font-size:12.5px;color:var(--ink-soft);min-width:0;
+  white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.grid.list .lc.lc-nm{font-family:var(--serif);font-size:15px;color:var(--ink)}
+.grid.list .lc.mono{font-family:ui-monospace,Menlo,monospace;font-size:11.5px}
+.grid.list .lc .dot{display:inline-block;width:9px;height:9px;border-radius:50%;
+  border:1px solid rgba(0,0,0,.12);margin-right:6px;vertical-align:baseline}
+
 /* ---------- footer ---------- */
 footer{border-top:1px solid var(--line);background:var(--paper)}
 .foot-in{max-width:var(--maxw);margin:0 auto;padding:46px 26px;display:flex;

← f181757 chore: v0.1.1 (session close — lint clean, refactor auto-sav  ·  back to Reidwitlin Landing  ·  internal PDP port from astek template: pricing tiers, Reques ab34197 →