[object Object]

← back to Quadrille House Site

Grid controls upgrade: density slider to 20 cols, persisted grid/list view toggle, list view with 12 sortable sticky-header columns (asc/desc + arrow), Safari user:pass@ fetch fix (location.origin) — E2E-verified, 0 console errors

07616d9ab972dd3b4ac04bbae83aeebf0d226430 · 2026-07-02 08:43:37 -0700 · Steve

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

Files touched

Diff

commit 07616d9ab972dd3b4ac04bbae83aeebf0d226430
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Jul 2 08:43:37 2026 -0700

    Grid controls upgrade: density slider to 20 cols, persisted grid/list view toggle, list view with 12 sortable sticky-header columns (asc/desc + arrow), Safari user:pass@ fetch fix (location.origin) — E2E-verified, 0 console errors
    
    Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---
 public/index.html   | 104 ++++++++++++++++++++++++++++++++++++++++++++++++----
 public/product.html |   6 +--
 public/styles.css   |  22 +++++++++++
 3 files changed, 121 insertions(+), 11 deletions(-)

diff --git a/public/index.html b/public/index.html
index 63ff4a1..e00549c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -84,8 +84,9 @@
     </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>
+    <button class="chip view-btn" id="view" type="button">▤ List view</button>
   </div>
 </div>
 
@@ -94,6 +95,7 @@
 
 <!-- GRID -->
 <main class="grid-wrap">
+  <div class="list-head" id="listHead"></div>
   <div class="grid" id="grid"></div>
 </main>
 
@@ -136,14 +138,32 @@
 <script>
 const $=s=>document.querySelector(s), grid=$('#grid');
 let ALL=[], activeBrand='all', activeColor=null, activeSeries=null, FACETS=null, CFG=null;
-const LS={sort:'qhouse.sort', dens:'qhouse.density', brand:'qhouse.brand'};
+const LS={sort:'qhouse.sort', dens:'qhouse.density', brand:'qhouse.brand', view:'qhouse.view'};
+// LIST-VIEW columns — every non-always-null data field a product carries
+// (match_type / finish / application / coverage are always null in house.json → skipped;
+//  brand === book for all 3,717 products → one "Line" column).
+const COLS=[
+  {k:'brand',        l:'Line'},
+  {k:'series',       l:'Pattern'},
+  {k:'display_name', l:'Name'},
+  {k:'color',        l:'Color'},
+  {k:'color_bucket', l:'Color Family'},
+  {k:'sku',          l:'DW SKU', mono:true},
+  {k:'product_type', l:'Type'},
+  {k:'composition',  l:'Material'},
+  {k:'width',        l:'Width'},
+  {k:'repeat',       l:'Repeat'},
+  {k:'sample_price', l:'Sample', num:true},
+  {k:'published_at', l:'Published', date:true},
+];
+let listSort=null; // {k, dir:1|-1}
 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 REAL interior room-setting photos (lookbook rooms), not
 // fabric-group shots. Falls back to product room shots if the lookbook is empty.
 async function startHero(items){
   let pool=[];
-  try{ const lb=await (await fetch('/api/lookbook')).json(); pool=(lb.rooms||[]).map(r=>r.src).filter(Boolean); }catch{}
+  try{ const lb=await (await fetch(location.origin+'/api/lookbook')).json(); pool=(lb.rooms||[]).map(r=>r.src).filter(Boolean); }catch{}
   if(pool.length<3){ pool=pool.concat(items.filter(p=>p.room&&p.images&&p.images.length>1).map(p=>p.room)); }
   pool=pool.slice(0,8);
   const h=$('#hero');
@@ -176,11 +196,72 @@ function sortItems(items,mode){
   return a;
 }
 
+// ── LIST VIEW helpers ──
+const isList=()=>localStorage.getItem(LS.view)==='list';
+const LC_FMT={
+  sample_price:v=>'$'+Number(v).toFixed(2),
+  published_at:v=>new Date(v).toLocaleDateString(undefined,{year:'numeric',month:'short',day:'numeric'}),
+};
+const esc=s=>String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/"/g,'&quot;');
+function lcCell(p,c){
+  const v=p[c.k];
+  if(v==null||v==='') return `<span class="lc${c.mono?' mono':''}">—</span>`;
+  const txt=esc(LC_FMT[c.k]?LC_FMT[c.k](v):v);
+  const dot=c.k==='color_bucket'&&p.hex?`<span class="dot" style="background:${p.hex}"></span>`:'';
+  return `<span class="lc${c.mono?' mono':''}" title="${txt}">${dot}${txt}</span>`;
+}
+function sortVal(p,c){
+  const v=p[c.k];
+  if(v==null||v==='') return null;
+  if(c.num) return Number(v);
+  if(c.date) return new Date(v).getTime();
+  if(c.k==='width'||c.k==='repeat'){const n=parseFloat(String(v)); if(!isNaN(n)) return n;}
+  return String(v).toLowerCase();
+}
+function listCompare(a,b){
+  const c=COLS.find(x=>x.k===listSort.k);
+  const va=sortVal(a,c), vb=sortVal(b,c);
+  if(va==null&&vb==null) return 0;
+  if(va==null) return 1;              // nulls always last
+  if(vb==null) return -1;
+  const r=(typeof va==='number'&&typeof vb==='number')?va-vb:String(va).localeCompare(String(vb),undefined,{numeric:true});
+  return r*listSort.dir;
+}
+function renderListHead(){
+  const lh=$('#listHead');
+  lh.innerHTML='<span class="hc" style="cursor:default" aria-hidden="true"></span>'+COLS.map(c=>{
+    const on=listSort&&listSort.k===c.k;
+    return `<button class="hc ${on?'active':''}" data-k="${c.k}" type="button" title="Sort by ${c.l}">${c.l}${on?(listSort.dir===1?' ▲':' ▼'):''}</button>`;
+  }).join('');
+  lh.querySelectorAll('.hc[data-k]').forEach(b=>b.onclick=()=>{
+    const k=b.dataset.k;
+    listSort=(listSort&&listSort.k===k)?{k,dir:-listSort.dir}:{k,dir:1};
+    renderListHead(); render();
+  });
+}
+function placeListHead(){ // sticky offset = live height of the sticky controls bar
+  const c=document.querySelector('.controls');
+  $('#listHead').style.top=((c?c.offsetHeight:60)+2)+'px';
+}
+addEventListener('resize',placeListHead);
+function applyView(){
+  const list=isList();
+  grid.classList.toggle('list',list);
+  $('#listHead').classList.toggle('on',list);
+  $('#view').textContent=list?'▦ Grid view':'▤ List view';
+  if(list){ renderListHead(); placeListHead(); }
+}
+$('#view').addEventListener('click',()=>{
+  localStorage.setItem(LS.view,isList()?'grid':'list');
+  applyView(); render();
+});
+
 function render(){
   let items = ALL;
   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(isList()&&listSort) items.sort(listCompare); // column sort over the FULL filtered set
   $('#cnt').textContent=items.length;
   grid.innerHTML = items.map(p=>`
     <div class="card" data-h="${p.handle}">
@@ -192,6 +273,7 @@ function render(){
           <span class="col">${p.hex?`<span class="dot" style="background:${p.hex}"></span>`:''}${p.brand}</span>
           <span class="pr view">${p.cta_mode==='live'?'View →':p.cta_mode==='sample'?('Sample $'+Number(p.sample_price||4.25).toFixed(2)+' →'):'Request →'}</span>
         </div>
+        ${COLS.map(c=>lcCell(p,c)).join('')}
       </div>
     </div>`).join('');
 }
@@ -228,12 +310,17 @@ $('#density').addEventListener('input',e=>{applyDensity(e.target.value);localSto
 $('#sort').addEventListener('change',()=>{localStorage.setItem(LS.sort,$('#sort').value);render();});
 $('#pattern').addEventListener('change',()=>{activeSeries=$('#pattern').value||null;render();$('#catalog').scrollIntoView({behavior:'smooth'});});
 
-addEventListener('scroll',()=>{document.body.classList.toggle('scrolled',scrollY>innerHeight*0.82);});
+addEventListener('scroll',()=>{
+  const was=document.body.classList.contains('scrolled');
+  const now=scrollY>innerHeight*0.82;
+  document.body.classList.toggle('scrolled',now);
+  if(was!==now&&isList()) placeListHead(); // controls bar height changes when the wordmark row appears
+});
 
 // LOOKBOOK
 async function loadLookbook(){
   try{
-    const lb=await (await fetch('/api/lookbook')).json();
+    const lb=await (await fetch(location.origin+'/api/lookbook')).json();
     $('#lbRooms').innerHTML=(lb.rooms||[]).slice(0,40).map(r=>`
       <figure class="lb-room"><img loading="lazy" decoding="async" alt="${(r.caption||'').replace(/"/g,'&quot;')}" src="${r.src}"></figure>`).join('');
     $('#lbPdfs').innerHTML=(lb.lookbooks||[]).map(p=>`
@@ -305,8 +392,8 @@ function renderShare(url, title){
 async function loadBrand(){
   const q = activeBrand==='all' ? '' : '?brand='+encodeURIComponent(activeBrand);
   const [pd,fc]=await Promise.all([
-    fetch('/api/products'+q).then(r=>r.json()),
-    fetch('/api/facets'+q).then(r=>r.json())]);
+    fetch(location.origin+'/api/products'+q).then(r=>r.json()),
+    fetch(location.origin+'/api/facets'+q).then(r=>r.json())]);
   ALL=pd.products; FACETS=fc;
   // collection-aware shopbar
   const sb=$('#shopbar'); if(sb) sb.innerHTML=`Shop ${activeBrand==='all'?'the Quadrille house':activeBrand} at <b>Designer Wallcoverings</b> →`;
@@ -317,7 +404,8 @@ async function loadBrand(){
   const savedSort=localStorage.getItem(LS.sort); if(savedSort)$('#sort').value=savedSort;
   const savedDens=localStorage.getItem(LS.dens)||'4'; $('#density').value=savedDens; applyDensity(savedDens);
   const savedBrand=localStorage.getItem(LS.brand); if(savedBrand)activeBrand=savedBrand;
-  const cfg=await fetch('/api/config').then(r=>r.json());
+  applyView();
+  const cfg=await fetch(location.origin+'/api/config').then(r=>r.json());
   applyConfig(cfg);
   await loadBrand();
   startHero(ALL.length?ALL:[]);
diff --git a/public/product.html b/public/product.html
index 697399b..a1e2271 100644
--- a/public/product.html
+++ b/public/product.html
@@ -48,7 +48,7 @@ function specRow(k,v){return v?`<div class="row"><span class="k">${k}</span><spa
 let PROD=null, CFG=null;
 
 (async()=>{
-  const [cfg,r]=await Promise.all([fetch('/api/config').then(x=>x.json()).catch(()=>({})), fetch('/api/product/'+handle)]);
+  const [cfg,r]=await Promise.all([fetch(location.origin+'/api/config').then(x=>x.json()).catch(()=>({})), fetch(location.origin+'/api/product/'+handle)]);
   CFG=cfg;
   if(cfg.slug) document.body.dataset.line=cfg.slug;
   if(cfg.palette){const rs=document.documentElement.style;['bg','paper','ink','accent','gold','line','taupe'].forEach(k=>{if(cfg.palette[k])rs.setProperty('--'+k,cfg.palette[k]);});}
@@ -131,7 +131,7 @@ let PROD=null, CFG=null;
 
   // pairs
   try{
-    const pr=await (await fetch('/api/pairs/'+handle)).json();
+    const pr=await (await fetch(location.origin+'/api/pairs/'+handle)).json();
     if(pr.pairs&&pr.pairs.length){
       document.getElementById('pairGrid').innerHTML=pr.pairs.map(q=>`
         <a class="pcard" href="/product/${q.handle}">
@@ -169,7 +169,7 @@ document.getElementById('qform').addEventListener('submit',async e=>{
   const f=e.target, fd=Object.fromEntries(new FormData(f).entries());
   const body={sku:PROD.sku,brand:PROD.brand,pattern:PROD.series,color:PROD.color,...fd};
   try{
-    const r=await (await fetch('/api/quote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
+    const r=await (await fetch(location.origin+'/api/quote',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)})).json();
     f.hidden=true;
     const d=document.getElementById('qdone'); d.hidden=false;
     d.innerHTML=`<p>${esc(r.message||'Request received.')}</p><button class="cta ghost" type="button" onclick="closeQuote()">Close</button>`;
diff --git a/public/styles.css b/public/styles.css
index 6172fab..9d9353e 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -277,3 +277,25 @@ body[data-line="fallingstar"] .books h2::after{content:"";display:block;width:72
 /* $4.25 sample price display (net-new held lines, sold as samples) */
 .price-sample{font-family:Georgia,serif;font-size:1.35rem;color:var(--gold);margin:.2rem 0 .6rem;letter-spacing:.01em}
 .price-sample::before{content:"";display:inline-block;width:18px;height:1px;background:var(--gold);vertical-align:middle;margin-right:.5rem;opacity:.6}
+
+/* ── LIST VIEW ── single-column CSS-grid rows; card body uses display:contents
+   so its children align as columns (pattern from astek-landing .grid.list). */
+:root{--listcols:46px minmax(0,.9fr) minmax(0,1fr) minmax(0,1.7fr) minmax(0,1.5fr) minmax(0,1fr) minmax(0,1.05fr) minmax(0,.8fr) minmax(0,1.4fr) minmax(0,.55fr) minmax(0,.6fr) minmax(0,.7fr) minmax(0,.9fr)}
+.lc{display:none}
+.chip.view-btn{font-family:var(--sans);cursor:pointer;font-size:13px}
+.list-head{display:none}
+.grid.list{grid-template-columns:1fr;gap:4px}
+.grid.list .card,.list-head.on{display:grid;grid-template-columns:var(--listcols);column-gap:12px;align-items:center}
+.grid.list .card{min-height:52px;padding:0 12px 0 8px;border-radius:6px}
+.grid.list .card:hover{transform:none;box-shadow:0 8px 22px -16px rgba(33,29,24,.4)}
+.grid.list .card .ph{width:42px;height:42px;min-width:42px;aspect-ratio:auto;border-radius:5px;margin:5px 0}
+.grid.list .card:hover .ph{transform:none}
+.grid.list .card .body{display:contents}
+.grid.list .ser,.grid.list .nm,.grid.list .meta{display:none}
+.grid.list .lc{display:block;font-size:11.5px;color:var(--ink-soft);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
+.grid.list .lc.mono{font-family:ui-monospace,Menlo,monospace;font-size:11px}
+.grid.list .lc .dot{display:inline-block;width:9px;height:9px;border-radius:50%;border:1px solid rgba(0,0,0,.15);margin-right:5px;vertical-align:-1px}
+.list-head.on{position:sticky;top:62px;z-index:40;background:var(--paper);border:1px solid var(--line);border-radius:6px;padding:8px 12px 8px 8px;margin-bottom:6px}
+.list-head .hc{font-family:var(--sans);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--ink-soft);cursor:pointer;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;background:none;border:0;text-align:left;padding:0;min-width:0}
+.list-head .hc:hover{color:var(--ink)}
+.list-head .hc.active{color:var(--accent);font-weight:600}

← 631156d Quadrille landing: controls-bar 3D Showroom chip → no-hyphen  ·  back to Quadrille House Site  ·  chore: macstudio3 migration — reconcile from mac2 + repoint 2845a90 →