← back to Astek Landing
astek-landing: catalog surface — every data point drills to deeper data (TK-10093)
cd9f7d4262031c02c6430df3ea01adb858e6f610 · 2026-07-31 10:53:24 -0700 · Steve
Reference impl of the HARD rule 'all data points must be href to deeper data':
- filter state is now URL-addressable (?book=X&color_bucket=blue…) so any facet
count is a real, shareable, new-tab-able href to its filtered view — not a JS button
- on-card + info-drawer atoms (book/series/color/color_bucket/material/width/length/
repeat/match) are .drill <a>s: plain click filters in place, cmd/middle-click opens
the filtered grid in a new tab
- back/forward (popstate) re-applies URL filter state; shared links land filtered
- reusable atoms: qstr() URL builder + drill()/kvd() helpers + data-filter delegation
Verified locally on :9944 (6167 products, PDP route 200, inline JS parses).
Files touched
Diff
commit cd9f7d4262031c02c6430df3ea01adb858e6f610
Author: Steve <steve@designerwallcoverings.com>
Date: Fri Jul 31 10:53:24 2026 -0700
astek-landing: catalog surface — every data point drills to deeper data (TK-10093)
Reference impl of the HARD rule 'all data points must be href to deeper data':
- filter state is now URL-addressable (?book=X&color_bucket=blue…) so any facet
count is a real, shareable, new-tab-able href to its filtered view — not a JS button
- on-card + info-drawer atoms (book/series/color/color_bucket/material/width/length/
repeat/match) are .drill <a>s: plain click filters in place, cmd/middle-click opens
the filtered grid in a new tab
- back/forward (popstate) re-applies URL filter state; shared links land filtered
- reusable atoms: qstr() URL builder + drill()/kvd() helpers + data-filter delegation
Verified locally on :9944 (6167 products, PDP route 200, inline JS parses).
---
public/index.html | 90 ++++++++++++++++++++++++++++++++++++++++++++-----------
1 file changed, 72 insertions(+), 18 deletions(-)
diff --git a/public/index.html b/public/index.html
index f225add..7831feb 100644
--- a/public/index.html
+++ b/public/index.html
@@ -92,6 +92,10 @@ body.imgonly .grid:not(.list) .card .b{display:none}
.kv .v{text-align:left;word-break:break-word;font-weight:500}
.lnk{color:var(--acc);text-decoration:none;border:1px solid var(--line);border-radius:6px;padding:2px 7px;font-size:11px}
.lnk:hover{background:var(--panel)}
+/* ── drill atom: every data point is an href to its filtered view (deeper data) ── */
+.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted transparent}
+.drill:hover{color:var(--acc);border-bottom-color:var(--acc)}
+aside a.frow{text-decoration:none} /* facet rows are real hrefs but keep the row look */
.acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
#toast{position:fixed;left:50%;bottom:18px;transform:translateX(-50%) translateY(20px);max-width:min(560px,92vw);background:#1b1e24;color:var(--ink,#e8e6e1);border:1px solid var(--line,#2a2d33);border-radius:8px;padding:10px 16px;font-size:13px;box-shadow:0 8px 30px #0008;opacity:0;pointer-events:none;transition:.25s;z-index:60}
#toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
@@ -191,6 +195,32 @@ const PAGE=120;
const BUCKET_HEX={white:'#f4f4f2',grey:'#9aa0aa',black:'#1c1e24',pink:'#f3a8c0',red:'#d0453e',orange:'#e08a3c',
brown:'#8a6242',gold:'#c9a75a',green:'#5f8f5c',blue:'#4f7fb5',purple:'#8a6fb5'};
function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));}
+// ── HARD RULE (2026-07-31): every data point is an href to its deeper (filtered) data ──
+// The filter state lives in the URL, so any count/atom becomes a real, shareable,
+// new-tab-able link — not just a JS button. qstr() builds the URL for a given filter set.
+function qstr(fil,q,pat){
+ const u=new URLSearchParams();
+ for(const [k] of FIELDS){ if(fil[k]) u.set(k,fil[k]); }
+ if(q) u.set('q',q); if(pat) u.set('pat',pat);
+ const s=u.toString(); return s?('?'+s):location.pathname;
+}
+function readURL(){
+ const u=new URLSearchParams(location.search);
+ for(const [k] of FIELDS) FIL[k]=u.get(k)||'';
+ $('#q').value=u.get('q')||''; $('#pattern').value=u.get('pat')||'';
+}
+function writeURL(push){
+ const url=qstr(FIL,$('#q').value.trim(),$('#pattern').value.trim());
+ history[push?'pushState':'replaceState']({},'',url);
+}
+// drill(field,value,label,cls) → an <a> whose href IS the filtered view for that value.
+// Real link: cmd/ctrl/middle-click opens the filtered grid in a new tab; plain click
+// filters in place (handled by the delegated listeners below). This is the reusable atom.
+function drill(field,val,label,cls){
+ if(val==null||val==='') return '';
+ const href=qstr({...FIL,[field]:String(val)},$('#q').value.trim(),$('#pattern').value.trim());
+ return `<a class="drill ${cls||''}" href="${href}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label!=null?esc(label):esc(val)}</a>`;
+}
// ── every data field is a left-panel table: collapsed on load, click value = filter ──
const FIELDS=[
['book','Books'],['style','Styles'],['color_bucket','Colors'],['series','Patterns'],['color','Colorways'],
@@ -241,27 +271,29 @@ function card(p){
(hasRoom?`<img loading="lazy" class="roomimg" src="${esc(p.room)}" alt=""><span class="roombadge">ROOM</span>`:'')+`</div>`
:`<div class="imgwrap noimg"></div>`;
const kv=(k,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';
+ // kvd = a kv row whose value drills to its filtered view (facet fields only)
+ const kvd=(k,field,v)=>v?`<div class="kv"><span class="k">${k}</span><span class="v">${drill(field,v,v)}</span></div>`:'';
const dot=BUCKET_HEX[p.color_bucket]||'#666';
return `<div class="card" data-href="/product/${encodeURIComponent(p.handle)}" data-handle="${esc(p.handle)}" data-sku="${esc(p.dw_sku||'')}" data-mfr="${esc(p.sku||'')}" data-title="${esc(p.display_name||p.series||p.dw_sku||'')}">
${img}<div class="b">
- <span class="vend">${esc(p.book||'Astek')}${p.series&&p.series!==p.display_name?(' · '+esc(p.series)):''}</span>
+ <span class="vend">${drill('book',p.book||'Astek',p.book||'Astek')}${p.series&&p.series!==p.display_name?(' · '+drill('series',p.series,p.series)):''}</span>
<div class="ttl">${title}</div>
- <span class="lc" title="Color">${esc(p.color||'')}</span>
+ <span class="lc" title="Color">${drill('color',p.color,p.color)}</span>
<div class="chips">
- ${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${esc(p.color_bucket)}</span>`:''}
+ ${p.color_bucket?`<span class="chip cl"><span class="dot" style="background:${dot}"></span>${drill('color_bucket',p.color_bucket,p.color_bucket)}</span>`:''}
<span class="chip toggle" onclick="event.stopPropagation();this.closest('.card').classList.toggle('info-open')">ⓘ details</span>
</div>
<span class="lsku" title="Vendor SKU">${esc(p.sku||'')}</span>
<span class="ldw" title="DW SKU">${esc(p.dw_sku||'')}</span>
- <span class="lc" title="Material">${esc(p.material||'')}</span>
- <span class="lc" title="Width">${esc(p.width||'')}</span>
- <span class="lc" title="Length">${esc(p.length||'')}</span>
- <span class="lc" title="Repeat">${esc(p.repeat||'')}</span>
- <span class="lc" title="Match">${esc(p.match||'')}</span>
+ <span class="lc" title="Material">${drill('material',p.material,p.material)}</span>
+ <span class="lc" title="Width">${drill('width',p.width,p.width)}</span>
+ <span class="lc" title="Length">${drill('length',p.length,p.length)}</span>
+ <span class="lc" title="Repeat">${drill('repeat',p.repeat,p.repeat)}</span>
+ <span class="lc" title="Match">${drill('match',p.match,p.match)}</span>
</div>
<div class="info" onclick="event.stopPropagation()">
- ${kv('DW SKU',p.dw_sku)}${kv('Vendor SKU',p.sku)}${kv('Series',p.series)}${kv('Book',p.book)}
- ${kv('Material',p.material)}${kv('Width',p.width)}${kv('Repeat',p.repeat)}${kv('Match',p.match)}
+ ${kv('DW SKU',p.dw_sku)}${kv('Vendor SKU',p.sku)}${kvd('Series','series',p.series)}${kvd('Book','book',p.book)}
+ ${kvd('Material','material',p.material)}${kvd('Width','width',p.width)}${kvd('Repeat','repeat',p.repeat)}${kvd('Match','match',p.match)}
<div class="acts">
<button class="act reqact" data-act="memo" title="Log a memo-sample request">Memo</button>
<button class="act reqact" data-act="stock" title="Email the vendor a stock check">Stock</button>
@@ -273,12 +305,21 @@ function card(p){
</div>
</div>`;
}
-// delegated card navigation — anything not interactive opens the PDP
+// delegated card navigation — anything not interactive opens the PDP.
+// (.drill atoms are <a>, so this handler's `a` skip lets them fall through to the drill handler.)
grid.addEventListener('click',e=>{
if(e.target.closest('.chip.toggle,.info,a,button'))return;
const c=e.target.closest('.card[data-href]');
if(c)location.href=c.dataset.href;
});
+// atom drill — plain click filters the grid to that value; cmd/ctrl/shift/middle-click
+// keeps the real href (opens the filtered view in a new tab). Every datum → deeper data.
+grid.addEventListener('click',e=>{
+ const d=e.target.closest('a.drill'); if(!d)return;
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1)return;
+ e.preventDefault(); e.stopPropagation();
+ setF(d.dataset.filter,d.dataset.v);
+});
function copyTxt(btn){
const t=btn.dataset.copy||'';
navigator.clipboard.writeText(t).then(()=>{
@@ -306,7 +347,7 @@ function toast(msg,ok){
t.textContent=msg; t.className='show '+(ok?'ok':'err');
clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
}
-function applyFilters(){
+function applyFilters(nav){
const q=$('#q').value.trim().toLowerCase(), pat=$('#pattern').value.trim().toLowerCase();
VIEW=ALL.filter(p=>{
for(const [k] of FIELDS){ if(FIL[k]&&String(p[k]??'')!==FIL[k])return false; }
@@ -335,6 +376,7 @@ function applyFilters(){
$('#total').textContent=VIEW.length.toLocaleString()+' designs';
$('#empty').style.display=VIEW.length?'none':'block';
renderSide();
+ if(nav!=='none')writeURL(nav==='push'); // reflect filter state into the URL (shareable/deep-linkable)
}
function renderMore(){
const slice=VIEW.slice(shown,shown+PAGE);
@@ -360,8 +402,10 @@ function renderSide(){
$('#facetbox').innerHTML=FIELDS.map(([k,label])=>{
const rows=(FCOUNTS[k]||[]).slice(0,FCAP).map(([v,n])=>{
const dot=k==='color_bucket'?`<span class="dot" style="background:${BUCKET_HEX[v]||'#666'}"></span>`:'';
- return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
- <span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></div>`;
+ // the count IS an href: its target is the grid filtered to this value (deeper data)
+ const href=qstr({...FIL,[k]:(FIL[k]===String(v)?'':String(v))},$('#q').value.trim(),$('#pattern').value.trim());
+ return `<a class="frow${FIL[k]===String(v)?' active':''}" href="${href}" data-f="${k}" data-v="${esc(v)}">
+ <span class="fl">${dot}${esc(v)}</span><span class="n">${n.toLocaleString()}</span></a>`;
}).join('');
const more=(FCOUNTS[k]||[]).length>FCAP?`<div class="fmore">+ ${(FCOUNTS[k].length-FCAP).toLocaleString()} more — use search</div>`:'';
const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(FIL[k])}</span>`:'';
@@ -369,9 +413,17 @@ function renderSide(){
}).join('');
$('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value)?'block':'none';
}
-function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters();}
-window.pickF=pickF;
-$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters('push');} // facet row: toggle
+function setF(k,v){if(FIL[k]===String(v))return;FIL[k]=String(v);applyFilters('push');} // atom: set (drill in)
+window.pickF=pickF;window.setF=setF;
+// facet counts are real <a> hrefs — plain click filters in place, modified click opens the
+// filtered view in a new tab. Delegated on the container so it survives renderSide() rebuilds.
+$('#facetbox').addEventListener('click',e=>{
+ const a=e.target.closest('a.frow'); if(!a)return;
+ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1)return;
+ e.preventDefault(); pickF(a.dataset.f,a.dataset.v);
+});
+$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters('push');};
$('#sort').onchange=()=>{localStorage.setItem('astek.sort',$('#sort').value);COLSORT.key=null;renderListHead();applyFilters();};
let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(applyFilters,300);};
let tp; $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(applyFilters,300);};
@@ -407,9 +459,11 @@ async function loadAll(){
fetch(location.origin+'/api/products').then(r=>r.json()),
fetch(location.origin+'/api/facets').then(r=>r.json())]);
ALL=pd.products; FACETS=fc;
- buildFacetCache(); applyFilters();
+ buildFacetCache(); readURL(); applyFilters(); // land in the filter state carried by the URL
}
loadAll();
+// back/forward re-applies the URL's filter state without re-pushing history
+addEventListener('popstate',()=>{readURL();applyFilters('none');});
/* ── Corner refresh countdown ──────────────────────────────────────────────
Data is rebuilt from dw_unified every 15 min by a cron. This pill counts down
← 9a51ba4 auto-save: 2026-07-31T10:26:53 (1 files) — data/products.jso
·
back to Astek Landing
·
astek-landing: PDP spec rows drill back to filtered catalog 72f14c1 →