← back to Fromental Internal
fromental-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
a329063edc6bda985e9373ef5df48d0dd17f9c94 · 2026-07-31 12:17:38 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.html
Diff
commit a329063edc6bda985e9373ef5df48d0dd17f9c94
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 31 12:17:38 2026 -0700
fromental-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 +++++++++++++++++
public/index.html | 119 +++++++++++++++++++++++++++++++++++++++++++++++-------
2 files changed, 140 insertions(+), 15 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 2c03320..ae5c5ed 100644
--- a/public/index.html
+++ b/public/index.html
@@ -103,6 +103,10 @@
.drawer .gal{display:grid;grid-template-columns:repeat(4,1fr);gap:6px;margin-top:10px}
.drawer .gal img{width:100%;aspect-ratio:1/1;object-fit:cover;border:1px solid var(--line);border-radius:2px}
.close{position:absolute;top:10px;right:14px;font-size:22px;color:#fff;cursor:pointer;text-shadow:0 1px 3px rgba(0,0,0,.6)}
+
+ /* ── drill atom: every displayed data point is an href to its filtered (deeper) view ── */
+ a.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted transparent}
+ a.drill:hover{color:var(--acc);border-bottom-color:var(--acc)}
</style>
</head>
<body>
@@ -161,8 +165,10 @@
<div class="drawer" id="drawer"><div class="panel" id="panel"></div></div>
+<script src="/drill.js"></script>
<script>
const ORIGIN = location.origin;
+const $ = (s)=> document.querySelector(s);
const esc = (s)=> String(s==null?'':s).replace(/[&<>"]/g,c=>({'&':'&','<':'<','>':'>','"':'"'}[c]));
// ── filter state: one Set per facet field (multi-select), persisted to localStorage ──
@@ -190,6 +196,59 @@ FACETS.forEach(f=> rowUI[f.row] = { expand:false, find:'', typeahead:(f.api==='t
function persistFacets(){ FACETS.forEach(f=> localStorage.setItem('fr_'+f.k, JSON.stringify([...state[f.k]]))); }
+// ── href-to-deeper-data bridge (ADAPTED to this viewer's Set-based, server-facet model) ──
+// The shared /drill.js atom `drill(field,value,label)` builds an <a href> by calling qstr/$/esc
+// from global scope. This viewer stores each facet as a multi-select Set (not a single FIL value)
+// and computes facets server-side, so we OVERRIDE qstr/readURL/writeURL/setF here to speak Sets.
+// DRILL_MAP: URL param → facet state key. A drill link SETS that one facet to exactly {value}.
+const DRILL_MAP = { type:'types', collection:'collections', color:'colors', style:'styles', material:'materials', price:'prices', tag:'tags', image:'images' };
+// Build the shareable URL for the current filter state (Sets → comma-joined params + q).
+function qstr(overrideParam, overrideVal){
+ const u = new URLSearchParams();
+ for (const [param, key] of Object.entries(DRILL_MAP)){
+ let vals = [...state[key]];
+ if (param === overrideParam) vals = (overrideVal==null||overrideVal==='') ? [] : [String(overrideVal)];
+ if (vals.length) u.set(param, vals.join(','));
+ }
+ if (state.q) u.set('q', state.q);
+ const s = u.toString(); return s ? ('?'+s) : location.pathname;
+}
+// qstr variant for the multi-select rail: replace ONE facet's values with an explicit list.
+function qstrToggle(overrideParam, overrideVals){
+ const u = new URLSearchParams();
+ for (const [param, key] of Object.entries(DRILL_MAP)){
+ const vals = (param===overrideParam) ? (overrideVals||[]) : [...state[key]];
+ if (vals.length) u.set(param, vals.join(','));
+ }
+ if (state.q) u.set('q', state.q);
+ const s = u.toString(); return s ? ('?'+s) : location.pathname;
+}
+// Read URL filter state back into the Sets + search box (deep-link / back-button).
+function readURL(){
+ const u = new URLSearchParams(location.search);
+ for (const [param, key] of Object.entries(DRILL_MAP)){
+ const raw = u.get(param);
+ state[key] = new Set(raw ? raw.split(',').filter(Boolean) : []);
+ }
+ state.q = u.get('q') || '';
+ const sb = $('#search'); if (sb) sb.value = state.q;
+}
+// Reflect current filter state into the URL (push = new history entry, else replace).
+function writeURL(push){ history[push?'pushState':'replaceState']({}, '', qstr()); }
+// Atom drill target: SET this facet to exactly this one value (drill in), then apply + push URL.
+function setF(param, val){
+ const key = DRILL_MAP[param]; if (!key) return;
+ state[key] = new Set([String(val)]);
+ const dr=document.getElementById('drawer'); if(dr) dr.classList.remove('open'); // drilling from the PDP returns to the filtered grid
+ applyFilterChange('push');
+}
+// Overridden drill() so the href reflects "set THIS facet to this value" against the Set model.
+function drill(param, val, label, cls){
+ if (val == null || val === '') return '';
+ const href = qstr(param, val);
+ return `<a class="drill ${cls||''}" href="${href}" data-filter="${esc(param)}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${label!=null?esc(label):esc(val)}</a>`;
+}
+
function lum(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return null; const r=parseInt(hex.slice(1,3),16),g=parseInt(hex.slice(3,5),16),b=parseInt(hex.slice(5,7),16); return .2126*r+.7152*g+.0722*b; }
function hue(hex){ if(!hex||hex[0]!=='#'||hex.length!==7) return 999; let r=parseInt(hex.slice(1,3),16)/255,g=parseInt(hex.slice(3,5),16)/255,b=parseInt(hex.slice(5,7),16)/255; const mx=Math.max(r,g,b),mn=Math.min(r,g,b),d=mx-mn; if(d===0)return 999; let h; if(mx===r)h=((g-b)/d)%6; else if(mx===g)h=(b-r)/d+2; else h=(r-g)/d+4; h*=60; if(h<0)h+=360; return h; }
function fmtDate(d){ if(!d) return ''; try{ return new Date(d).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }catch{ return ''; } }
@@ -230,7 +289,13 @@ function render(){
const g=document.getElementById('grid');
g.innerHTML=rows.map(p=>{
const chips=[];
- if(p.type) chips.push(`<span class="chip">${esc(p.type)}</span>`);
+ // type + material are single-value facets → drill straight to the filtered view
+ if(p.type) chips.push(`<span class="chip">${drill('type',p.type)}</span>`);
+ if(p.material) chips.push(`<span class="chip">${drill('material',p.material)}</span>`);
+ // color-family + style are the drillable facets (arrays) — surface the first of each as a drill chip
+ const fam0=(p.families||[])[0]; if(fam0) chips.push(`<span class="chip">${drill('color',fam0)}</span>`);
+ const sty0=(p.styles||[])[0]; if(sty0) chips.push(`<span class="chip">${drill('style',sty0)}</span>`);
+ if(p.priceBand) chips.push(`<span class="chip">${drill('price',p.priceBand)}</span>`);
chips.push(p.quote?`<span class="chip q">Quote</span>`:(p.price!=null?`<span class="chip p">$${p.price.toLocaleString()}</span>`:''));
const dot=p.hex?`<span class="dot" style="background:${esc(p.hex)}"></span>`:'';
return `<div class="card" data-sku="${esc(p.sku)}" data-mfr="${esc(p.sku||'')}" data-handle="${esc(p.handle||'')}" data-title="${esc(p.pattern||p.sku||'')}">
@@ -276,12 +341,17 @@ function facetList(f, counts){
for(const v of set){ if(!shownVals.has(v)){ const e=entries.find(([x])=>x===v); shown.push(e||[v,0]); } }
if(!shown.length){ const s=document.createElement('div'); s.className='fnone'; s.textContent='no data'; row.appendChild(s); return; }
const swatchMap = f.swatch ? (state.facets.family_swatch||{}) : null;
+ // param name for this facet in the URL (reverse of DRILL_MAP), so the rail row is a real href
+ const paramFor = Object.keys(DRILL_MAP).find(pp=> DRILL_MAP[pp]===f.k);
shown.forEach(([val,ct])=>{
- const el=document.createElement('div');
- el.className='fitem'+(set.has(val)?' on':'')+(ct?'':' zero');
+ const el=document.createElement('a');
+ el.className='fitem drill'+(set.has(val)?' on':'')+(ct?'':' zero');
+ // href reflects the state AFTER this row is toggled — plain click filters in place, cmd/middle opens in a new tab
+ if(paramFor){ const nextVals = set.has(val) ? [...set].filter(x=>x!==val) : [...set,val];
+ el.setAttribute('href', qstrToggle(paramFor, nextVals)); el.dataset.filter=paramFor; el.dataset.v=val; }
const sw = swatchMap ? `<span class="sw" style="background:${esc(swatchMap[val]||'#ccc')}"></span>` : '';
el.innerHTML=`<span class="box">${set.has(val)?'✓':''}</span>${sw}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${(ct||0).toLocaleString()}</span>`;
- el.onclick=()=> toggleSet(f.k, val);
+ el.onclick=(e)=>{ if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1) return; e.preventDefault(); toggleSet(f.k, val); };
row.appendChild(el);
});
if(hidden>0){
@@ -312,19 +382,19 @@ function renderActive(){
const el=document.createElement('span'); el.className='afilter';
const sw = f.swatch ? `<span class="sw" style="background:${esc(swatchMap[v]||'#ccc')}"></span>` : '';
el.innerHTML=`${sw}<b>${f.label}:</b> ${esc(v)} ✕`;
- el.onclick=()=>{ state[f.k].delete(v); applyFilterChange(); };
+ el.onclick=()=>{ state[f.k].delete(v); applyFilterChange('push'); };
row.appendChild(el);
});
});
if(n){
const c=document.createElement('button'); c.className='clearall'; c.textContent='Clear all';
- c.onclick=()=>{ FACETS.forEach(f=> state[f.k].clear()); applyFilterChange(); };
+ c.onclick=()=>{ FACETS.forEach(f=> state[f.k].clear()); state.q=''; const sb=$('#search'); if(sb) sb.value=''; applyFilterChange('push'); };
row.appendChild(c);
}
}
-function applyFilterChange(){ persistFacets(); refreshFacets(); render(); }
-function toggleSet(key, val){ const set=state[key]; set.has(val)?set.delete(val):set.add(val); applyFilterChange(); }
+function applyFilterChange(nav){ persistFacets(); if(nav!=='none') writeURL(nav==='push'); refreshFacets(); render(); }
+function toggleSet(key, val){ const set=state[key]; set.has(val)?set.delete(val):set.add(val); applyFilterChange('push'); }
function facetParams(){
return new URLSearchParams({
@@ -341,8 +411,16 @@ async function refreshFacets(){
}
// events
+// atom drill — plain click filters the grid in place; cmd/ctrl/shift/alt/middle-click keeps the real href (new tab)
+document.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);
+});
document.addEventListener('click',(e)=>{
if(e.target.closest('.act')) return; // purchasing buttons handled below — don't open the drawer
+ if(e.target.closest('a.drill')) return; // drill link handled above — don't open the drawer
const card=e.target.closest('.card'); if(card){ openDrawer(card.dataset.sku); }
});
@@ -366,7 +444,7 @@ function toast(msg, ok){
t.textContent=msg; t.className='show '+(ok?'ok':'err');
clearTimeout(toastT); toastT=setTimeout(()=>t.className='',6000);
}
-document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); render(); refreshFacets(); });
+document.getElementById('search').addEventListener('input',(e)=>{ state.q=e.target.value.trim(); writeURL(false); render(); refreshFacets(); });
document.getElementById('sort').addEventListener('change',(e)=>{ state.sort=e.target.value; localStorage.setItem('fr_sort',state.sort); render(); });
document.getElementById('density').addEventListener('input',(e)=>setDensity(+e.target.value));
function setDensity(cols){
@@ -402,14 +480,21 @@ async function openDrawer(sku){
const p=await fetch(ORIGIN+'/api/product/'+encodeURIComponent(sku)).then(r=>r.json()).catch(()=>null);
if(!p){ panel.innerHTML='<div class="pad">Not found.</div>'; return; }
const gal=(p.gallery||[]).slice(0,8).map(g=>`<img src="${esc(g)}">`).join('');
+ // drawer spec values drill BACK to the filtered grid; drillFacet joins each value as a drill link
+ const drillJoin=(param,arr)=> (arr||[]).filter(Boolean).map(v=>drill(param,v)).join(', ');
const rowsSpec=[
- ['SKU',p.sku],['Color',p.color],['Type',p.type],['Collection',p.collection],
- ['Material',p.material],['Width',p.width],['Vendor',p.vendor],
- ['Color families',(p.families||[]).join(', ')],
+ ['SKU', esc(p.sku)],
+ ['Color', esc(p.color)],
+ ['Type', p.type?drill('type',p.type):''],
+ ['Collection', p.collection?drill('collection',p.collection):''],
+ ['Material', p.material?drill('material',p.material):''],
+ ['Width', esc(p.width)],
+ ['Vendor', esc(p.vendor)],
+ ['Color families', drillJoin('color', p.families)],
['Price', p.quote?'Quote-only (made to order)':(p.price!=null?'$'+p.price.toLocaleString():'—')],
- ['Styles',(p.styles||[]).join(', ')],
- ['Tags',(p.aiTags||[]).slice(0,10).join(', ')],
- ].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${esc(r[1])}</span></div>`).join('');
+ ['Styles', drillJoin('style', p.styles)],
+ ['Tags', drillJoin('tag', (p.aiTags||[]).slice(0,10))],
+ ].filter(r=>r[1]).map(r=>`<div class="row2"><span class="k">${r[0]}</span><span>${r[1]}</span></div>`).join('');
panel.innerHTML=`
<div style="position:relative"><span class="close" onclick="document.getElementById('drawer').classList.remove('open')">×</span>
${p.image?`<img class="hero" src="${esc(p.image)}">`:''}</div>
@@ -438,6 +523,8 @@ async function boot(){
setDensity(+(localStorage.getItem('fr_density')||6));
document.getElementById('sort').value=state.sort;
const cf=localStorage.getItem('fr_cf'); if(cf){ try{ const o=JSON.parse(cf); document.querySelectorAll('#cardfields input').forEach(cb=>{ if(o[cb.dataset.f]===false) cb.checked=false; }); }catch{} }
+ // URL filter state wins over localStorage when present (deep-link / shared drill link)
+ if(location.search) readURL();
const [prod,fac]=await Promise.all([
fetch(ORIGIN+'/api/products').then(r=>r.json()),
@@ -447,6 +534,8 @@ async function boot(){
document.getElementById('patterns').innerHTML=[...new Set(state.all.map(p=>p.pattern).filter(Boolean))].slice(0,400).map(v=>`<option value="${esc(v)}">`).join('');
renderFacets(fac); render(); tickMeta(); setInterval(tickMeta,60000);
}
+// back / forward buttons re-apply the URL's filter state (no new history entry)
+addEventListener('popstate',()=>{ readURL(); persistFacets(); refreshFacets(); render(); });
boot();
</script>
</body>
← 1fcdfed fromental-internal: add Memo/Stock/Price chip actions (share
·
back to Fromental Internal
·
nav-agent: universal grid-controls drop-in on internal viewe 8d26e44 →