[object Object]

← back to Zuber Internal

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

c76a882ccecbf394a8979d9f0302cef01ad2c384 · 2026-07-31 12:19:01 -0700 · Steve Abrams

Files touched

Diff

commit c76a882ccecbf394a8979d9f0302cef01ad2c384
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Fri Jul 31 12:19:01 2026 -0700

    zuber-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   |  36 +++++++++++++++
 public/index.html | 128 ++++++++++++++++++++++++++++++++++++++++++++++++------
 2 files changed, 150 insertions(+), 14 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 e7bc30f..b362708 100644
--- a/public/index.html
+++ b/public/index.html
@@ -59,6 +59,10 @@
   .card .meta { color: #777; font-size: calc(var(--fs) - 2px); }
   .card .when { color: #999; font-size: 10px; margin-top: 2px; }
   .card a { color: var(--teal); text-decoration: none; font-size: 11px; }
+  /* ── drill atom: every data point is an href to its filtered (deeper) view ── */
+  .drill { color: inherit; text-decoration: none; cursor: pointer; border-bottom: 1px dotted transparent; }
+  .drill:hover { color: var(--teal); border-bottom-color: var(--teal); }
+  .fitem .lbl .drill { display: block; overflow: hidden; text-overflow: ellipsis; }
   /* purchasing actions on each card: Memo / Stock / Price */
   .card .acts { display: flex; gap: 4px; padding: 0 9px 8px; }
   .card .acts .act { flex: 1; font: inherit; font-size: 10px; color: var(--mut); background: #fff; border: 1px solid var(--line); border-radius: 5px; padding: 4px 2px; cursor: pointer; }
@@ -105,6 +109,7 @@
 <div class="layout">
   <aside>
     <input type="search" id="q" class="search" placeholder="Search pattern / SKU / collection…" />
+    <input type="hidden" id="pattern" value="" /><!-- drill.js contract: pattern type-ahead input; zuber folds pattern search into #q, so this stays empty -->
     <div class="active" id="activeRow"></div>
     <div id="facets"></div>
   </aside>
@@ -113,9 +118,11 @@
     <div class="grid" id="grid"></div>
   </main>
 </div>
+<script src="/drill.js"></script>
 <script>
 const $ = (s, r=document) => r.querySelector(s);
 const API = location.origin;
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
 
 // facet dimensions: [stateKey, apiKey, label]  (single-vendor → NO vendor facet)
 const FACET_DEFS = [
@@ -134,6 +141,74 @@ const CHIP_LABEL = { types: 'Type', collection: 'Book', colors: 'Color', styles:
 const state = { q: '', sort: 'newest' };
 for (const [k] of FACET_DEFS) state[k] = new Set();
 
+// ── /drill.js bridge (href-to-deeper-data primitives, adapted to multi-select) ──
+// drill.js's drill()/qstr() are keyed by "field" = the URL param. Zuber's server reads
+// api-keys (product_type/collection/color/style/material/price/tag/image), so FIELDS keys
+// by api-key and we keep an api-key→state-key reverse map. drill(apiKey,val,label) emits an
+// <a href="?apiKey=val"> deep-link; a plain click ADDS val to that facet's Set (drill deeper),
+// modifier/middle click keeps the real shareable href. qstr/readURL/writeURL/setF are
+// overridden below to speak the '|'-joined multi-select URL the API already understands.
+const FIELDS = FACET_DEFS.map(([stateKey, apiKey]) => [apiKey, CHIP_LABEL[stateKey]]);
+const API_TO_STATE = Object.fromEntries(FACET_DEFS.map(([stateKey, apiKey]) => [apiKey, stateKey]));
+const API_FOR = Object.fromEntries(FACET_DEFS.map(([stateKey, apiKey]) => [stateKey, apiKey]));
+// URL a left-rail facet count links to = current multi-select state with `val` TOGGLED for that
+// facet (so the shareable link matches what a click produces). Built off the live Set state.
+function facetToggleHref(stateKey, apiKey, val) {
+  const p = new URLSearchParams();
+  for (const [sk, ak] of FACET_DEFS) {
+    const s = new Set(state[sk]);
+    if (sk === stateKey) { s.has(String(val)) ? s.delete(String(val)) : s.add(String(val)); }
+    if (s.size) p.set(ak, [...s].join('|'));
+  }
+  if (state.q) p.set('q', state.q);
+  if (state.sort && state.sort !== 'newest') p.set('sort', state.sort);
+  const qs = p.toString(); return qs ? ('?' + qs) : location.pathname;
+}
+// FIL is a live view of state, one representative value per api-key (drill.js reads FIL[field]
+// only to seed the href's other params — the multi-select qstr override rebuilds the full URL).
+const FIL = new Proxy({}, {
+  get(_t, apiKey) { const sk = API_TO_STATE[apiKey]; const s = sk && state[sk]; return s && s.size ? [...s].join('|') : ''; },
+  has(_t, apiKey) { return apiKey in API_TO_STATE; },
+});
+
+// Build the full multi-select URL from current Set state, plus any override from drill.js's
+// drill() (it passes {...FIL,[field]:val} so the atom's own value is UNIONED into that facet →
+// the rendered <a href> is a real "current filters + this value" deep-link).
+function qstr(override, q) {
+  const p = new URLSearchParams();
+  for (const [stateKey, apiKey] of FACET_DEFS) {
+    const s = new Set(state[stateKey]);
+    if (override && override[apiKey]) for (const v of String(override[apiKey]).split('|').filter(Boolean)) s.add(v);
+    if (s.size) p.set(apiKey, [...s].join('|'));
+  }
+  const qv = (q != null ? q : state.q);
+  if (qv) p.set('q', qv);
+  if (state.sort && state.sort !== 'newest') p.set('sort', state.sort);
+  const qs = p.toString(); return qs ? ('?' + qs) : location.pathname;
+}
+// Read multi-select filter state from the URL into the Sets + inputs (deep-link / back-button).
+function readURL() {
+  const u = new URLSearchParams(location.search);
+  for (const [stateKey, apiKey] of FACET_DEFS) {
+    const v = u.get(apiKey);
+    state[stateKey] = new Set(v ? v.split('|').filter(Boolean) : []);
+  }
+  state.q = u.get('q') || ''; $('#q').value = state.q; save('q', state.q);
+  state.sort = u.get('sort') || 'newest'; $('#sort').value = state.sort; save('sort', state.sort);
+  persistFacets();
+}
+// Reflect current filter state into the URL (push = new history entry, else replace).
+function writeURL(push) { history[push ? 'pushState' : 'replaceState']({}, '', qstr()); }
+// Atom drill target: ADD this value to the facet's Set (drill into deeper data), then re-filter.
+function setF(apiKey, v) {
+  const sk = API_TO_STATE[apiKey]; if (!sk) return;
+  if (state[sk].has(String(v))) return; // already drilled in — no-op
+  state[sk].add(String(v));
+  applyFilterChange();
+}
+// Republish onto window so drill.js's drill() closes over THESE overrides (drill.js set them first).
+window.qstr = qstr; window.readURL = readURL; window.writeURL = writeURL; window.setF = setF;
+
 function ls(k, d) { try { return JSON.parse(localStorage.getItem('zi_'+k)) ?? d; } catch { return d; } }
 function save(k, v) { localStorage.setItem('zi_'+k, JSON.stringify(v)); }
 
@@ -152,8 +227,8 @@ $('#q').value = ls('q', ''); state.q = $('#q').value;
 $('#cols').oninput = e => { document.documentElement.style.setProperty('--cols', e.target.value); save('cols', +e.target.value); };
 $('#fs').oninput = e => { document.documentElement.style.setProperty('--fs', e.target.value + 'px'); save('fs', +e.target.value); };
 $('#imgonly').onchange = e => { document.body.classList.toggle('imgonly', e.target.checked); save('imgonly', e.target.checked); };
-$('#sort').onchange = e => { state.sort = e.target.value; save('sort', e.target.value); load(); };
-$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(applyFilterChange, 220); };
+$('#sort').onchange = e => { state.sort = e.target.value; save('sort', e.target.value); writeURL(false); load(); };
+$('#q').oninput = e => { state.q = e.target.value; save('q', e.target.value); clearTimeout(window._t); window._t = setTimeout(() => applyFilterChange('push'), 220); };
 document.querySelectorAll('.ff').forEach(cb => cb.onchange = () => { applyField(cb.dataset.f, cb.checked); save('ff_'+cb.dataset.f, cb.checked); });
 function applyField(f, on) { document.querySelectorAll('.card .'+f).forEach(el => el.style.display = on ? '' : 'none'); }
 
@@ -175,7 +250,7 @@ function facetParams(extra = {}) {
 }
 // note: FACET_DEFS maps state key → api key; facetParams uses the api key as the query param
 function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
-function applyFilterChange() { persistFacets(); refreshFacets(); load(); }
+function applyFilterChange(nav) { persistFacets(); writeURL(nav === 'push' ? true : false); refreshFacets(); load(); }
 
 // per-section UI state: type-ahead text + expand
 const rowUI = {};
@@ -206,13 +281,22 @@ function facetList(host, stateKey, counts, opts = {}) {
   const shownVals = new Set(shown.map(([v]) => v));
   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.style.cssText = 'font-size:10px;color:#6a6a72;padding:2px'; s.textContent = 'none'; host.appendChild(s); return; }
+  const apiKey = API_FOR[stateKey];
   shown.forEach(([val, ct]) => {
-    const el = document.createElement('div');
-    el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    // Each facet count is a real, shareable href to its filtered (deeper) view. The anchor's
+    // href reflects the POST-click multi-select state (this value toggled into/out of the set);
+    // a plain click toggles in place, cmd/ctrl/middle-click opens the filtered URL in a new tab.
+    const el = document.createElement('a');
+    el.className = 'fitem drill' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    el.href = facetToggleHref(stateKey, apiKey, val);
+    el.dataset.filter = apiKey; el.dataset.v = val;
     let swatch = '';
-    if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${bg}"></span>`; }
-    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${val.replace(/"/g, '&quot;')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
-    el.onclick = () => toggleSet(set, val);
+    if (opts.swatch && opts.swatch[val]) { const bg = opts.swatch[val]; swatch = `<span class="swatch" style="background:${esc(bg)}"></span>`; }
+    el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${swatch}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.toLocaleString()}</span>`;
+    el.addEventListener('click', (e) => {
+      if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; // keep real href
+      e.preventDefault(); toggleSet(set, val);
+    });
     host.appendChild(el);
   });
   if (hidden > 0) {
@@ -296,14 +380,17 @@ async function load() {
     if (r.image) img.style.backgroundImage = `url("${r.image}")`;
     c.appendChild(img);
     const body = document.createElement('div'); body.className = 'body';
-    const meta = [r.collection, r.width].filter(Boolean).join(' · ');
-    const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${r.pattern_name_fr}</div>` : '';
+    // drill only the EXISTING facet-valued atom already shown on the card — the collection
+    // (Book/Series). No new text/chips introduced; other facets live in the left rail.
+    const collA = r.collection ? drill('collection', r.collection, r.collection) : '';
+    const metaParts = [collA, r.width ? esc(r.width) : ''].filter(Boolean).join(' · ');
+    const frLine = (r.pattern_name_fr && r.pattern_name_fr !== r.pattern_name) ? `<div class="fr">${esc(r.pattern_name_fr)}</div>` : '';
     body.innerHTML = `
-      <div class="pat">${r.pattern_name || r.mfr_sku}</div>
+      <div class="pat">${esc(r.pattern_name || r.mfr_sku)}</div>
       ${frLine}
-      <div class="meta">${r.mfr_sku}${meta ? ' — ' + meta : ''}</div>
-      <div class="when" title="${r.created_at || ''}">🕓 ${fmtDate(r.created_at)}</div>
-      ${r.product_url ? `<a href="${r.product_url}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
+      <div class="meta">${esc(r.mfr_sku)}${metaParts ? ' — ' + metaParts : ''}</div>
+      <div class="when" title="${esc(r.created_at || '')}">🕓 ${fmtDate(r.created_at)}</div>
+      ${r.product_url ? `<a href="${esc(r.product_url)}" target="_blank" rel="noopener noreferrer">Source ↗</a>` : ''}`;
     c.appendChild(body);
     const acts = document.createElement('div'); acts.className = 'acts';
     acts.innerHTML = `
@@ -315,8 +402,21 @@ async function load() {
   }
   ['pat','fr','meta','when'].forEach(f => applyField(f, document.querySelector('.ff[data-f="'+f+'"]').checked));
 }
+// Deep-link / back-button: seed filter state from the URL (falls back to persisted localStorage
+// when the URL carries no filters), then render. Every filter change rewrites the URL (writeURL).
+if (location.search) readURL();
 refreshFacets().then(load);
 setInterval(load, 60000);
+addEventListener('popstate', () => { readURL(); refreshFacets(); load(); });
+
+// ── Card facet atoms (drill) → drill INTO deeper data: a plain click adds that value to its facet
+// Set; cmd/ctrl/shift/middle-click keeps the real shareable href. Uses setF from /drill.js. ──
+$('#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; // keep real href
+  e.preventDefault(); e.stopPropagation();
+  setF(d.dataset.filter, d.dataset.v);
+});
 
 // ── Purchasing actions on each card: Memo / Stock / Price → /api/request ──
 $('#grid').addEventListener('click', async (e) => {

← 3f028dd zuber-internal: add Memo/Stock/Price chip actions (shared ve  ·  back to Zuber Internal  ·  viewer standard: add Grid/List toggle + per-field on/off men 661af65 →