← 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
M public/index.htmlM public/product.htmlM public/styles.css
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=>({'&':'&','<':'<','>':'>','"
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 →