[object Object]

← back to New Import Viewer

new-import-viewer: adopt href-to-deeper-data primitives (TK-10093)

c4b93a3448400d3c4267a9602de1a879e5731117 · 2026-08-01 20:20:43 -0700 · Steve Abrams

Files touched

Diff

commit c4b93a3448400d3c4267a9602de1a879e5731117
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:20:43 2026 -0700

    new-import-viewer: adopt href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   | 36 +++++++++++++++++++++++++
 public/index.html | 79 ++++++++++++++++++++++++++++++++++++++++++++++---------
 server.js         |  5 ++++
 3 files changed, 108 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 7722ce7..2240962 100644
--- a/public/index.html
+++ b/public/index.html
@@ -42,8 +42,12 @@ aside summary .cur{margin-left:auto;color:var(--acc);text-transform:none;letter-
  max-width:104px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
 .fbody{padding:2px 0 8px}
 .frow{display:flex;justify-content:space-between;align-items:center;gap:8px;padding:3px 8px;border-radius:6px;
- cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none}
+ cursor:pointer;color:var(--mut);font-size:12.5px;user-select:none;text-decoration:none}
+a.frow{color:var(--mut)}
 .frow:hover{background:var(--panel);color:var(--ink)}
+/* in-card drill atoms — dotted-underline affordance, inherit surrounding type */
+.b a.drill{color:inherit;text-decoration:none;cursor:pointer;border-bottom:1px dotted rgba(120,150,220,.5)}
+.b a.drill:hover{color:var(--acc);border-bottom-color:var(--acc)}
 .frow.active{background:var(--panel);color:var(--ink);outline:1px solid var(--acc);outline-offset:-1px}
 .frow .fl{display:inline-flex;align-items:center;gap:7px;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
 .frow .n{font-variant-numeric:tabular-nums;font-size:11px;color:var(--mut);flex:none}
@@ -254,6 +258,7 @@ body.listview #listhead{display:grid}
   <button id="bulkgo">Apply ▸</button>
   <span class="muted" id="bulkmsg"></span>
 </div>
+<script src="/drill.js"></script>
 <script>
 const $=s=>document.querySelector(s);
 const grid=$('#grid'), state={offset:0,limit:120,count:0,loading:false,live:false,liveActions:[],store:''};
@@ -432,10 +437,10 @@ function card(p){
   return `<div class="card${checked?' sel':''}" data-id="${p.id}" data-st="${st}" data-img="${p.image?1:0}">
     <label class="pick"><input type="checkbox" ${checked} onchange="togglePick(${p.id},this)"></label>
     ${img}<div class="b">
-    <span class="vend">${esc(fmtVendor(p.vendor))}</span>
+    <span class="vend">${p.vendor?dc('vendor',p.vendor,fmtVendor(p.vendor)):'—'}</span>
     <div class="ttl">${title}</div>
     <span class="sub when" title="last crawl: ${esc(p.crawled||'unknown')}${p.firstSeen?(' · first seen: '+esc(p.firstSeen)):''}">🕓 ${esc(p.crawled||p.firstSeen||'—')}</span>
-    <span class="lcell lcolor" title="Color">${esc(p.color||'—')}</span>
+    <span class="lcell lcolor" title="Color">${p.color?dc('color',p.color,p.color):'—'}</span>
     <div class="chips">
       <span class="chip st-${st}">${STATUS_LABEL[st]||st}</span>
       <span class="chip toggle" onclick="togglePane(this,'info')">ⓘ details</span>
@@ -443,9 +448,9 @@ function card(p){
     </div>
     <span class="lcell lsku" title="Vendor SKU">${esc(p.sku||'—')}</span>
     <span class="lcell ldw" title="DW SKU">${esc(p.dwSku||'—')}</span>
-    <span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${esc(p.collection||'—')}</span>
-    <span class="lcell ltype" title="Type">${esc(p.type||'—')}</span>
-    <span class="lcell lsync" title="Sync status">${esc(p.syncStatus||'—')}</span>
+    <span class="lcell lcoll" title="Collection: ${esc(p.collection||'')}">${p.collection?dc('collection',p.collection,p.collection):'—'}</span>
+    <span class="lcell ltype" title="Type">${p.type?dc('ptype',p.type,p.type):'—'}</span>
+    <span class="lcell lsync" title="Sync status">${p.syncStatus?dc('sync',p.syncStatus,p.syncStatus):'—'}</span>
     <span class="lcell lshopid" title="Shopify product id">${esc(p.shopifyId||'—')}</span>
     <span class="lcell lfirst" title="First seen: ${esc(p.firstSeen||'')}">${esc(p.firstSeen||'—')}</span>
     <span class="lcell lcrawl" title="Last crawl: ${esc(p.crawled||'')}">🕓 ${esc(p.crawled||'—')}</span>
@@ -508,6 +513,44 @@ function filsParams(p){
   const pat=$('#pattern').value.trim(); if(pat)p.set('pattern',pat);
   return p;
 }
+// ── href-to-deeper-data URL addressing (HARD rule) ───────────────────────────
+// drill.js supplies drill()/setF()/qstr; setF() calls applyFilters (defined here),
+// and the anchor href is built by drill.js qstr from FIL+FIELDS. This viewer also
+// carries STATUS + q + pattern, so we own the full URL read/write below (naming
+// them so they don't collide with drill.js's #pattern-vs-`pat` closure).
+function nipURL(){
+  const p=filsParams(new URLSearchParams());
+  if(STATUS&&STATUS!=='netnew') p.set('status',STATUS);
+  const qv=$('#q').value.trim(); if(qv) p.set('q',qv);
+  const s=p.toString(); return s?('?'+s):location.pathname;
+}
+// in-card drill atom (card is NOT an <a> per DOM-split note → real <a class="drill">).
+// href carries FULL state incl. STATUS/q; the delegated click routes through setF.
+function dc(field,val,label){
+  if(val==null||val==='') return '';
+  return `<a class="drill" href="${nipHrefFor(field,val)}" data-filter="${field}" data-v="${esc(val)}" title="Filter to ${esc(val)}">${esc(label!=null?label:val)}</a>`;
+}
+function nipWriteURL(push){ history[push?'pushState':'replaceState']({}, '', nipURL()); }
+// href for a facet OR in-card atom: current URL with field k toggled to v
+function nipHrefFor(k,v){
+  const p=new URLSearchParams();
+  for(const [f] of FIELDS){ const nv=(f===k)?(FIL[f]===String(v)?'':String(v)):FIL[f]; if(nv)p.set(f,nv); }
+  if(STATUS&&STATUS!=='netnew') p.set('status',STATUS);
+  const qv=$('#q').value.trim(); if(qv) p.set('q',qv);
+  const pat=$('#pattern').value.trim(); if(pat) p.set('pattern',pat);
+  const s=p.toString(); return s?('?'+s):location.pathname;
+}
+function nipReadURL(){
+  const u=new URLSearchParams(location.search);
+  for(const [k] of FIELDS) FIL[k]=u.get(k)||'';
+  const st=u.get('status'); if(st&&STATUS_DEFS.some(([k])=>k===st)) STATUS=st;
+  $('#q').value=u.get('q')||''; $('#pattern').value=u.get('pattern')||'';
+}
+// applyFilters — the single place that reflects state → URL → re-query (drill.js setF calls this)
+function applyFilters(nav){
+  if(nav!=='none') nipWriteURL(nav==='push');
+  renderStatusBox(); loadFacets(); load(true); refreshPatternList();
+}
 async function load(reset){
   if(state.loading) return; state.loading=true;
   if(reset){ state.offset=0; grid.innerHTML=''; }
@@ -545,7 +588,7 @@ function renderStatusBox(){
 }
 function pickStatus(k){
   STATUS=k; localStorage.setItem('nip.status',k);
-  renderStatusBox(); loadFacets(); load(true); refreshPatternList();
+  applyFilters('push');
 }
 function renderSide(){
   renderStatusBox();
@@ -555,8 +598,10 @@ function renderSide(){
     const rows=all.slice(0,FCAP).map(([v,n,wi])=>{
       const pct=(k==='vendor'&&n)?Math.round((wi||0)/n*100):null;
       const img=pct!=null?`<span class="imgpct${pct<100?' warn':''}" title="${wi||0} of ${n} rows have an image">${pct}%🖼</span>`:'';
-      return `<div class="frow${FIL[k]===String(v)?' active':''}" onclick="pickF('${k}',this.dataset.v)" data-v="${esc(v)}">
-        <span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></div>`;
+      // facet option row is a real <a href> (drill-atom contract) — href toggles this value
+      const href=nipHrefFor(k,v);
+      return `<a class="frow drill${FIL[k]===String(v)?' active':''}" href="${href}" data-filter="${k}" data-v="${esc(v)}">
+        <span class="fl">${esc(k==='vendor'?fmtVendor(v):v)}</span>${img}<span class="n">${n.toLocaleString()}</span></a>`;
     }).join('');
     const more=all.length>FCAP?`<div class="fmore">+ ${(all.length-FCAP).toLocaleString()} more — use search</div>`:'';
     const cur=FIL[k]?`<span class="cur" title="${esc(FIL[k])}">${esc(k==='vendor'?fmtVendor(FIL[k]):FIL[k])}</span>`:'';
@@ -564,11 +609,11 @@ function renderSide(){
   }).join('');
   $('#clearf').style.display=(Object.values(FIL).some(Boolean)||$('#q').value||$('#pattern').value||STATUS!=='netnew')?'block':'none';
 }
-function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);renderSide();load(true);refreshPatternList();}
+function pickF(k,v){FIL[k]=(FIL[k]===v?'':v);applyFilters('push');}
 $('#clearf').onclick=()=>{
   FIELDS.forEach(([k])=>FIL[k]='');$('#q').value='';$('#pattern').value='';
   STATUS='netnew';localStorage.setItem('nip.status',STATUS);
-  renderStatusBox();loadFacets();load(true);
+  applyFilters('push');
 };
 // server-backed pattern type-ahead (126k distinct patterns — datalist refreshes
 // as you type, scoped to the current status + filters)
@@ -584,7 +629,7 @@ async function refreshPatternList(){
 }
 $('#pattern').oninput=()=>{clearTimeout(tp);tp=setTimeout(()=>{refreshPatternList();renderSide();load(true);},300);};
 $('#sort').onchange=()=>{state.sort=$('#sort').value||'newest';localStorage.setItem('nip.sort',state.sort);renderListHead();load(true);};
-let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{renderSide();load(true);},300);};
+let t; $('#q').oninput=()=>{clearTimeout(t);t=setTimeout(()=>{nipWriteURL(false);renderSide();load(true);},300);};
 $('#more').onclick=()=>load(false);
 function applyView(){const list=localStorage.getItem('nip.view')==='list';grid.classList.toggle('list',list);document.body.classList.toggle('listview',list);$('#view').textContent=list?'▦ Grid view':'▤ List view';renderListHead();pinListHead();}
 $('#view').onclick=()=>{localStorage.setItem('nip.view',localStorage.getItem('nip.view')==='list'?'grid':'list');applyView();};
@@ -674,6 +719,16 @@ $('#bulkgo').onclick=async()=>{
   $('#bulkgo').disabled=false;
 };
 addEventListener('scroll',()=>{if(innerHeight+scrollY>=document.body.offsetHeight-600)load(false);});
+// ── drill wiring: delegated <a class="drill"> (facet rows + in-card atoms) ──
+document.addEventListener('click',e=>{
+  const a=e.target.closest('a.drill'); if(!a)return;
+  if(e.metaKey||e.ctrlKey||e.button===1)return;   // cmd/ctrl/middle → let the href open a new tab
+  e.preventDefault();
+  // toggle semantics match the href built by nipHrefFor (drill in / drill out)
+  pickF(a.dataset.filter, a.dataset.v);
+});
+addEventListener('popstate',()=>{ nipReadURL(); renderStatusBox(); loadFacets(); load(true); refreshPatternList(); });
+nipReadURL();   // deep-link: seed FIL/STATUS/q/pattern from the URL before boot
 syncSel();
 boot();
 </script>
diff --git a/server.js b/server.js
index b46000f..39ae758 100644
--- a/server.js
+++ b/server.js
@@ -489,6 +489,11 @@ const server = http.createServer((req, res) => {
     if (u.pathname === '/' || u.pathname === '/index.html') {
       return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'index.html')), 'text/html; charset=utf-8');
     }
+    if (u.pathname === '/drill.js') {
+      // href-to-deeper-data primitives (shared module) — served static for index.html
+      try { return send(res, 200, fs.readFileSync(path.join(__dirname, 'public', 'drill.js')), 'text/javascript; charset=utf-8'); }
+      catch { return send(res, 404, '// not found', 'text/javascript'); }
+    }
     if (u.pathname === '/img') { serveImg(u.searchParams.get('u') || '', res); return; }
     if (u.pathname === '/api/similar') {
       const sku = u.searchParams.get('sku');

← fae9d94 TK-10039: phase8-fix/shopify-push.js migrates width to metaf  ·  back to New Import Viewer  ·  nav-agent: universal grid-controls drop-in on internal viewe a665308 →