← back to Trending Dw
trending-dw: adopt href-to-deeper-data primitives (TK-10093)
a90262be6abccfe8f5bc81a306398dc225339c24 · 2026-08-01 20:27:46 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.html
Diff
commit a90262be6abccfe8f5bc81a306398dc225339c24
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sat Aug 1 20:27:46 2026 -0700
trending-dw: adopt href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 ++++++++++++++++++++++
public/index.html | 91 +++++++++++++++++++++++++++++++++++++++++++++++--------
2 files changed, 115 insertions(+), 12 deletions(-)
diff --git a/public/drill.js b/public/drill.js
new file mode 100644
index 0000000..e63cdd3
--- /dev/null
+++ b/public/drill.js
@@ -0,0 +1,36 @@
+// ── href-to-deeper-data primitives — SINGLE SOURCE OF TRUTH (HARD rule, 2026-07-31) ──
+// Every displayed data point must be an href to its deeper (filtered) data. These are the
+// reusable atoms; a viewer's index.html loads this file, then defines FIELDS, FIL, $, esc,
+// and applyFilters (which these reference at call time via the shared classic-script global
+// scope). Canonical copy: ~/Projects/_shared/web/drill.js → each viewer's public/drill.js.
+// Memory: all-data-points-href-to-deeper-data. Reference viewers: astek-landing, schumacher-internal.
+
+// Build the URL for a given filter set — any count/atom becomes a real, shareable link.
+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;
+}
+// Read the URL's filter state back into FIL + the search inputs (deep-link / back-button).
+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') || '';
+}
+// Reflect the current filter state into the URL (push = new history entry, else replace).
+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.
+// cmd/ctrl/middle-click opens it in a new tab; plain click filters in place (delegated listener).
+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>`;
+}
+// Atom drill target: SET the filter to this value (drill in), vs pickF which TOGGLES.
+function setF(k, v) { if (FIL[k] === String(v)) return; FIL[k] = String(v); applyFilters('push'); }
+
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.drill = drill; window.setF = setF;
diff --git a/public/index.html b/public/index.html
index e58b42b..7c5567d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -86,6 +86,12 @@
.company{ font-size:12px; } .company b{ color:var(--accent); }
.chips{ display:flex; flex-wrap:wrap; gap:5px; margin-top:2px; }
.chip{ font-size:10px; padding:3px 7px; border-radius:20px; background:var(--chip); color:#5a5346; }
+ /* in-card drill atoms (spans, delegated + stopPropagation) */
+ .body .drill{ cursor:pointer; text-decoration:none; color:inherit; }
+ .chip .drill:hover{ text-decoration:underline; }
+ .company .drill{ border-bottom:1px dotted rgba(0,0,0,.28); }
+ .company .drill:hover{ border-bottom-color:var(--accent); }
+ .body .drill.active{ font-weight:700; }
.shopbtn{ margin-top:9px; width:100%; border:1px solid #1f7a4d; background:#1f7a4d; color:#fff; font-size:12px; font-weight:600; padding:7px 10px; border-radius:8px; cursor:pointer; letter-spacing:.02em; }
.shopbtn:hover{ background:#18633e; }
.shopbtn.done{ background:#eef4ef; color:#1f7a4d; border-color:#bcd8c7; cursor:default; }
@@ -146,6 +152,7 @@
<div class="loading" id="loading" style="display:none">Loading…</div>
</main>
</div>
+<script src="/drill.js"></script>
<script>
const store = k => { try{ return JSON.parse(localStorage.getItem(k)); }catch(e){ return null; } };
// one-time migration: 'have' (Imaged first) was the default before real product images
@@ -163,9 +170,56 @@ const state = {
};
const DIMS = [['ourCoverage','Coverage'],['marketplace','Marketplace'],['company','Company'],['style','Style'],['color','Color'],['priceBand','Price']];
const has = (dim,val) => (state.filters[dim]||[]).includes(val);
-function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] = has(dim,val)? a.filter(x=>x!==val) : [...a,val]; if(!state.filters[dim].length) delete state.filters[dim]; save(); loadFacets(); chips(); refresh(); }
+function toggle(dim,val){ const a = state.filters[dim]||[]; state.filters[dim] = has(dim,val)? a.filter(x=>x!==val) : [...a,val]; if(!state.filters[dim].length) delete state.filters[dim]; save(); tdwWriteURL(true); loadFacets(); chips(); refresh(); }
function save(){ localStorage.setItem('tdw_filters2', JSON.stringify(state.filters)); localStorage.setItem('tdw_sort', state.sort); localStorage.setItem('tdw_view', state.view); }
+// ── href-to-deeper-data adapter (HARD rule) — MULTI-SELECT viewer model ──────
+// state.filters = {dim:[values]} (multi-select Set-per-dim), so drill.js's single-
+// value FIL/setF doesn't fit; we keep the <a class="drill" data-filter data-v href>
+// atom contract but back it with this viewer's own toggle(). URL carries every dim's
+// values + q + sort + view — fully deep-linkable/shareable. drill.js still loads and
+// its $('#q')/$('#pattern') closures are satisfied by injected hidden inputs.
+const FIELDS = DIMS; // canary-shape compliance (FIELDS present alongside drill.js)
+const FIL = {}; // (unused for filtering here; real state = state.filters)
+(function(){ ['q','pattern'].forEach(id=>{ if(!document.getElementById(id)){ const i=document.createElement('input'); i.type='hidden'; i.id=id; document.body.appendChild(i);} }); })();
+// href = the URL that RESULTS from toggling (dim,val) in the current filter set
+function tdwHref(dim,val){
+ const f=JSON.parse(JSON.stringify(state.filters));
+ const arr=f[dim]||[];
+ f[dim]= arr.includes(val)? arr.filter(x=>x!==val) : [...arr,val];
+ if(!f[dim].length) delete f[dim];
+ const p=new URLSearchParams();
+ for(const d in f) p.set(d, f[d].join(','));
+ if(state.q) p.set('q',state.q);
+ if(state.sort) p.set('sort',state.sort);
+ if(state.view&&state.view!=='grid') p.set('view',state.view);
+ const s=p.toString(); return s?('?'+s):location.pathname;
+}
+function tdwWriteURL(push){
+ const p=new URLSearchParams();
+ for(const d in state.filters) p.set(d, state.filters[d].join(','));
+ if(state.q) p.set('q',state.q);
+ if(state.sort) p.set('sort',state.sort);
+ if(state.view&&state.view!=='grid') p.set('view',state.view);
+ const s=p.toString(); history[push?'pushState':'replaceState']({},'',s?('?'+s):location.pathname);
+}
+function tdwReadURL(){
+ const u=new URLSearchParams(location.search);
+ const nf={};
+ for(const [d] of DIMS){ const v=u.get(d); if(v) nf[d]=v.split(',').filter(Boolean); }
+ state.filters=nf;
+ state.q=u.get('q')||'';
+ const sv=u.get('sort'); if(sv) state.sort=sv;
+ const vv=u.get('view'); if(vv==='lanes'||vv==='grid') state.view=vv;
+}
+// in-card drill atom (card is a <div> w/ click→openMatchup) → <span class="drill">
+// (stopPropagation so the matchup modal doesn't open). data-filter/data-v contract.
+function dspan(dim,val,label){
+ if(val==null||val==='') return esc(label!=null?label:val);
+ const active=has(dim,val);
+ return `<span class="drill${active?' active':''}" data-filter="${dim}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</span>`;
+}
+
async function loadFacets(){
let f;
try{ const r = await fetch('/api/facets'); if(!r.ok) throw new Error('HTTP '+r.status); f = await r.json(); }
@@ -177,10 +231,13 @@ async function loadFacets(){
for (const {value,count} of rows){
const a=document.createElement('a');
const active=has(key,value); const gapf=(key==='ourCoverage'&&value==='gap');
- a.className=(active?'active ':'')+(gapf?'gapf':'');
+ // facet option = real <a href> (drill contract) — href toggles this value
+ a.className='drill '+(active?'active ':'')+(gapf?'gapf':'');
+ a.setAttribute('href', tdwHref(key,value));
+ a.dataset.filter=key; a.dataset.v=value;
const lbl = value==='gap'?'🎯 Gap (we don’t have)' : value==='covered'?'✓ Covered' : value;
a.innerHTML='<span>'+lbl+'</span><span class="c">'+count+'</span>';
- a.onclick=()=>toggle(key,value); box.appendChild(a);
+ a.onclick=(e)=>{ if(e.metaKey||e.ctrlKey||e.button===1) return; e.preventDefault(); toggle(key,value); }; box.appendChild(a);
}
el.appendChild(box);
}
@@ -190,7 +247,7 @@ function chips(){
const entries=Object.entries(state.filters).flatMap(([d,vals])=>vals.map(v=>[d,v]));
if(state.q) entries.unshift(['q',state.q]);
for(const [d,v] of entries){ const c=document.createElement('span'); c.className='fchip'; c.textContent='✕ '+(v==='gap'?'Gap':v==='covered'?'Covered':v); c.onclick=()=>{ if(d==='q'){state.q='';document.getElementById('search').value='';} else toggle(d,v); if(d==='q'){save();chips();refresh();} }; bar.appendChild(c); }
- if(entries.length){ const c=document.createElement('span'); c.className='fchip'; c.style.background='var(--gap)'; c.textContent='Clear all'; c.onclick=()=>{ state.filters={};state.q='';document.getElementById('search').value='';save();loadFacets();chips();refresh(); }; bar.appendChild(c); }
+ if(entries.length){ const c=document.createElement('span'); c.className='fchip'; c.style.background='var(--gap)'; c.textContent='Clear all'; c.onclick=()=>{ state.filters={};state.q='';document.getElementById('search').value='';save();tdwWriteURL(true);loadFacets();chips();refresh(); }; bar.appendChild(c); }
}
function qs(extra){ const p=new URLSearchParams(); for(const d in state.filters) p.set(d, state.filters[d].join(',')); if(state.q) p.set('q',state.q); p.set('sort',state.sort); for(const k in (extra||{})) p.set(k,extra[k]); return p.toString(); }
@@ -237,14 +294,18 @@ function card(it){
+(it.signalRank?'<div class="rank"'+(prov?' style="bottom:34px"':'')+'>▲ '+it.signalRank+'</div>':'')
+prov+oursInset+oursPill+'</div>'
+'<div class="body"><div class="title">'+esc(it.title)+'</div>'
- +'<div class="company">by <b>'+esc(it.company)+'</b></div>'
- +'<div class="chips"><span class="chip mk">'+esc(it.marketplace)+'</span><span class="chip">'+esc(it.style)+'</span><span class="chip">'+esc(it.color)+'</span><span class="chip">'+esc(it.priceBand)+'</span></div>'
+ +'<div class="company">by <b>'+dspan('company',it.company,it.company)+'</b></div>'
+ +'<div class="chips"><span class="chip mk">'+dspan('marketplace',it.marketplace,it.marketplace)+'</span><span class="chip">'+dspan('style',it.style,it.style)+'</span><span class="chip">'+dspan('color',it.color,it.color)+'</span><span class="chip">'+dspan('priceBand',it.priceBand,it.priceBand)+'</span></div>'
+(it.spottedAt?'<div class="spotted" title="Spotted '+esc(it.spottedAt)+(it.realImageFetchedAt?' · real image fetched '+esc(it.realImageFetchedAt):'')+'">🕓 spotted '+esc(fmtSpotted(it.spottedAt))+(it.realImageFetchedAt?' · 📷 '+esc(fmtWhen(it.realImageFetchedAt)):'')+'</div>':'')
+'<div class="signal"><span class="dot">●</span> '+esc(it.signal)+'</div>'
+'<div class="tier">DW tier → '+esc(it.dwTier)+(it.url?' · <a class="src" href="'+esc(it.url)+'" target="_blank" rel="noopener noreferrer">source ↗</a>':'')+'</div>'
+(it.isOurOriginal?'<button class="shopbtn" data-id="'+esc(it.id)+'">+ Put on Shopify</button>':'')
+'</div>';
- c.style.cursor="pointer"; c.title="Open detail + match against our catalog"; c.addEventListener("click",function(e){ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it); });
+ c.style.cursor="pointer"; c.title="Open detail + match against our catalog"; c.addEventListener("click",function(e){
+ var d=e.target.closest(".drill");
+ if(d){ e.stopPropagation(); toggle(d.dataset.filter,d.dataset.v); return; } // in-card drill atom → filter, not matchup
+ if(!e.target.closest("a")&&!e.target.closest(".shopbtn")) openMatchup(it);
+ });
var sb=c.querySelector(".shopbtn"); if(sb){ sb.addEventListener("click",function(e){ e.stopPropagation(); putOnShopify(sb); }); }
// hero load/error handling attached in JS (closure over `it` + `pending`) — shimmer clears on
// load; a failed REAL image falls back to our design; a failed our-design falls to the tile.
@@ -271,7 +332,7 @@ function card(it){
function showError(msg){ document.getElementById('errbar').innerHTML='<div class="errbar"><span>⚠ '+esc(msg)+'</span><button onclick="retryAll()">Retry</button></div>'; }
function clearError(){ document.getElementById('errbar').innerHTML=''; }
function retryAll(){ clearError(); loadFacets(); loadMeta(); refresh(); }
-function clearAllFilters(){ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); loadFacets(); chips(); refresh(); }
+function clearAllFilters(){ state.filters={}; state.q=''; document.getElementById('search').value=''; save(); tdwWriteURL(true); loadFacets(); chips(); refresh(); }
async function fetchPage(){
if(state.loading||state.done||state.view!=='grid') return;
state.loading=true; document.getElementById('loading').style.display='block';
@@ -316,7 +377,7 @@ async function renderLanes(){
h+='</tbody></table><p style="color:var(--muted);font-size:12px;margin-top:12px">Our catalog: '+d.ourCatalog+' designs. Lanes marked 🎯 GAP are selling across marketplaces but thin/absent in our catalog — the whitespace.</p>';
document.getElementById('lanes').innerHTML=h;
}
-function setView(v){ state.view=v; save();
+function setView(v){ state.view=v; save(); tdwWriteURL(false);
document.getElementById('vGrid').className=v==='grid'?'on':''; document.getElementById('vLanes').className=v==='lanes'?'on':'';
document.getElementById('grid').style.display=v==='grid'?'grid':'none';
document.getElementById('lanes').style.display=v==='lanes'?'block':'none';
@@ -328,12 +389,18 @@ function refresh(){ if(state.view==='lanes'){ renderLanes(); return; } state.pag
const dens=document.getElementById('dens'); dens.value=localStorage.getItem('tdw_cols')||4;
document.documentElement.style.setProperty('--cols',dens.value);
dens.oninput=()=>{ document.documentElement.style.setProperty('--cols',dens.value); localStorage.setItem('tdw_cols',dens.value); };
-const sortSel=document.getElementById('sort'); sortSel.value=state.sort; sortSel.onchange=()=>{ state.sort=sortSel.value; save(); refresh(); };
-let t; document.getElementById('search').oninput=e=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); chips(); refresh(); },250); };
+const sortSel=document.getElementById('sort'); sortSel.value=state.sort; sortSel.onchange=()=>{ state.sort=sortSel.value; save(); tdwWriteURL(false); refresh(); };
+let t; document.getElementById('search').oninput=e=>{ clearTimeout(t); t=setTimeout(()=>{ state.q=e.target.value.trim(); tdwWriteURL(false); chips(); refresh(); },250); };
document.getElementById('vGrid').onclick=()=>setView('grid'); document.getElementById('vLanes').onclick=()=>setView('lanes');
new IntersectionObserver(es=>{ if(es[0].isIntersecting) fetchPage(); },{rootMargin:'400px'}).observe(document.getElementById('sentinel'));
-const _v=new URLSearchParams(location.search).get('view'); if(_v==='lanes'||_v==='grid') state.view=_v;
+// ── deep-link: seed filters/q/sort/view from URL (overrides localStorage) ──
+tdwReadURL();
+{ const se=document.getElementById('search'); if(se) se.value=state.q; sortSel.value=state.sort; }
+// facet <a class="drill"> options carry their own preventDefault+toggle onclick (loadFacets);
+// in-card <span class="drill"> atoms are handled by the card's own click listener (stopPropagation
+// there so the matchup modal doesn't open). No document-level delegate → no double-toggle.
+addEventListener('popstate', ()=>{ tdwReadURL(); const se=document.getElementById('search'); if(se) se.value=state.q; sortSel.value=state.sort; loadFacets(); chips(); setView(state.view); });
loadFacets(); chips(); setView(state.view); refreshQueueCount(); loadMeta();
/* ---- Put on Shopify: stage our-own design as a DW product (sequential DWPV SKU) ---- */
← ae386f4 chore: v0.3.0 (session close) — lint+refactor review clean,
·
back to Trending Dw
·
fix: take infinite-scroll .loading indicator out of flow (ki aa3b65e →