[object Object]

← 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

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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[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 →