← 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
M public/index.htmlM public/product.htmlM public/styles.css
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,'&').replace(/</g,'<').replace(/"/g,'"');
+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,'"')}" 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 →