[object Object]

← back to Artmura Internal

artmura-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)

16930afc51f8e92ce771ad6e938a923308be64c7 · 2026-07-31 11:49:31 -0700 · Steve Abrams

Files touched

Diff

commit 16930afc51f8e92ce771ad6e938a923308be64c7
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 11:49:31 2026 -0700

    artmura-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
 public/drill.js     | 36 +++++++++++++++++++++++++++++++++++
 public/index.html   | 54 +++++++++++++++++++++++++++++++++++++----------------
 public/product.html | 18 ++++++++++--------
 public/styles.css   |  3 +++
 4 files changed, 87 insertions(+), 24 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 a9fdeb4..47d2b91 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}
 .acts{display:flex;flex-wrap:wrap;gap:6px;margin-top:3px}
 .act{font-size:11px;font-weight:600;border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:7px;padding:4px 10px;cursor:pointer}
 .act:hover{border-color:var(--acc)}
@@ -179,6 +183,7 @@ body.listmode #listhead{display:grid}
   <div id="empty" style="display:none">No products match.</div>
 </main>
 </div>
+<script src="/drill.js"></script>
 <script>
 const $=s=>document.querySelector(s);
 const grid=$('#grid');
@@ -187,6 +192,7 @@ 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=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+// href-to-deeper-data primitives (qstr / readURL / writeURL / drill / setF) load from /drill.js
 // ── every data field the catalog carries is a left-panel table: collapsed on load,
 // click value = filter. Fields with zero values in the snapshot (Artmura today:
 // color/material/width/length/repeat/match are all null; no style field at all,
@@ -241,27 +247,28 @@ 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>`:'';
+  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)}">
     ${img}<div class="b">
-    <span class="vend">${esc(p.book||'Artmura')}${p.series&&p.series!==p.display_name?(' · '+esc(p.series)):''}</span>
+    <span class="vend">${drill('book',p.book||'Artmura',p.book||'Artmura')}${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">
         ${p.sku?`<button class="act" data-copy="${esc(p.sku)}" onclick="copyTxt(this)" title="copy vendor SKU">📋 SKU</button>`:''}
         ${p.dw_sku?`<button class="act" data-copy="${esc(p.dw_sku)}" onclick="copyTxt(this)" title="copy DW SKU">📋 DW</button>`:''}
@@ -276,6 +283,13 @@ grid.addEventListener('click',e=>{
   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
+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(()=>{
@@ -284,7 +298,7 @@ function copyTxt(btn){
   });
 }
 window.copyTxt=copyTxt;
-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; }
@@ -312,6 +326,7 @@ function applyFilters(){
   $('#total').textContent=VIEW.length.toLocaleString()+' designs';
   $('#empty').style.display=VIEW.length?'none':'block';
   renderSide();
+  if(nav!=='none')writeURL(nav==='push');
 }
 function renderMore(){
   const slice=VIEW.slice(shown,shown+PAGE);
@@ -337,8 +352,9 @@ function renderSide(){
   $('#facetbox').innerHTML=FIELDS.filter(([k])=>(FCOUNTS[k]||[]).length).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>`;
+      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>`:'';
@@ -346,9 +362,14 @@ 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();}
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters('push');}   // setF is in /drill.js
 window.pickF=pickF;
-$('#clearf').onclick=()=>{FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';applyFilters();};
+$('#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('artmura.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);};
@@ -384,9 +405,10 @@ 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();
 }
 loadAll();
+addEventListener('popstate',()=>{readURL();applyFilters('none');});
 
 /* ── Corner refresh countdown ──────────────────────────────────────────────
    Data is rebuilt from dw_unified every 15 min by a cron. This pill counts down
diff --git a/public/product.html b/public/product.html
index ba4ffa6..81efc28 100644
--- a/public/product.html
+++ b/public/product.html
@@ -46,6 +46,8 @@
 const handle=decodeURIComponent(location.pathname.split('/').pop());
 const esc=s=>(s||'').replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
 function specRow(k,v){return v?`<div class="row"><span class="k">${k}</span><span class="v">${esc(v)}</span></div>`:'';}
+// HARD RULE: a PDP spec value drills BACK to the catalog filtered by it (chain all the way down)
+function specDrill(k,field,v){return v?`<div class="row"><span class="k">${k}</span><span class="v"><a class="drill" href="/?${field}=${encodeURIComponent(v)}" title="See all with ${esc(k)} = ${esc(v)}">${esc(v)}</a></span></div>`:'';}
 
 let VM={}; // vendorMeta from /api/config — phone, account #, discount, pricing model
 const money=n=>n==null?null:'$'+Number(n).toFixed(2);
@@ -119,14 +121,14 @@ function priceBox(p){
           ${specRow('DW SKU',p.dw_sku)}
           ${specRow('Our Account #',VM.account_number)}
           ${specRow('Vendor Phone',VM.phone)}
-          ${specRow('Pattern',p.series)}
-          ${specRow('Colorway',p.color)}
-          ${specRow('Width',p.width)}
-          ${specRow('Length',p.length)}
-          ${specRow('Repeat',p.repeat)}
-          ${specRow('Material',p.material)}
-          ${specRow('Match',p.match)}
-          ${specRow('Format',p.book)}
+          ${specDrill('Pattern','series',p.series)}
+          ${specDrill('Colorway','color',p.color)}
+          ${specDrill('Width','width',p.width)}
+          ${specDrill('Length','length',p.length)}
+          ${specDrill('Repeat','repeat',p.repeat)}
+          ${specDrill('Material','material',p.material)}
+          ${specDrill('Match','match',p.match)}
+          ${specDrill('Format','book',p.book)}
         </div>
       </div>
     </div>
diff --git a/public/styles.css b/public/styles.css
index 76f90df..6abbe3d 100644
--- a/public/styles.css
+++ b/public/styles.css
@@ -166,6 +166,9 @@ footer{border-top:1px solid var(--line);background:var(--paper)}
 .spec .row{display:flex;justify-content:space-between;padding:11px 0;border-bottom:1px solid var(--line);font-size:13px}
 .spec .row .k{color:var(--ink-soft);letter-spacing:.04em}
 .spec .row .v{color:var(--ink);text-align:right}
+/* drill atom: a spec value is an href back to the catalog filtered by it (deeper data) */
+.spec .row .v a.drill{color:inherit;text-decoration:none;border-bottom:1px dotted var(--line);transition:color .12s,border-color .12s}
+.spec .row .v a.drill:hover{color:var(--accent,#5eead4);border-bottom-color:currentColor}
 .story{display:grid;grid-template-columns:repeat(3,1fr);gap:36px;margin-top:64px;
   padding-top:40px;border-top:1px solid var(--line)}
 .story h3{font-family:var(--serif);font-weight:500;font-size:22px;margin:0 0 12px}

← 7d1b251 deploy: fail-close .deploy.conf (was clobbering astek — loca  ·  back to Artmura Internal  ·  nav-agent: universal grid-controls drop-in on internal viewe fe03bd3 →