[object Object]

← back to Ffepurchasing

ffepurchasing: adopt href-to-deeper-data primitives (TK-10093)

23394bf3e33fa4ae992509dcd2ba839666a7055d · 2026-08-01 20:33:59 -0700 · Steve Abrams

Files touched

Diff

commit 23394bf3e33fa4ae992509dcd2ba839666a7055d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:33:59 2026 -0700

    ffepurchasing: adopt href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   | 36 ++++++++++++++++++++++++++++
 public/index.html | 72 +++++++++++++++++++++++++++++++++++++++++++++++--------
 2 files changed, 98 insertions(+), 10 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 845e5b4..b933aa4 100644
--- a/public/index.html
+++ b/public/index.html
@@ -611,6 +611,8 @@ textarea:focus-visible,
 })();
 </script>
 
+<style>a.drill.chip{text-decoration:none}.overlay .drill,.tblWrap .drill{cursor:pointer;text-decoration:none;border-bottom:1px dotted transparent}.overlay .drill:hover,.tblWrap .drill:hover{border-bottom-color:currentColor;opacity:.85}</style>
+<script src="/drill.js"></script>
 <script>
 const state = { q:'', facet:'all', page:1, pages:1, total:0, loading:false, exhausted:false, sort:'newest' };
 // Hydrate saved sort from localStorage BEFORE first /api/products call so the
@@ -620,6 +622,44 @@ try {
   const _savedSort = localStorage.getItem(_sortKey);
   if (_savedSort) state.sort = _savedSort;
 } catch(e){}
+// ── href-to-deeper-data (HARD rule) — storefront model: single `aesthetic` facet + q + sort ──
+// The whole filter state mirrors into the URL so any filtered view is a real, shareable,
+// deep-linkable href; aesthetic values become .drill atoms. drill.js is loaded for the fleet
+// atom contract + the enforcer canary; the server-paged logic below is storefront-specific.
+function sfQstr(){
+  const u=new URLSearchParams();
+  if(state.facet && state.facet!=='all') u.set('aesthetic',state.facet);
+  if(state.q) u.set('q',state.q);
+  if(state.sort && state.sort!=='newest') u.set('sort',state.sort);
+  const s=u.toString(); return s?('?'+s):location.pathname;
+}
+function sfWriteURL(push){ try{ history[push?'pushState':'replaceState']({},'',sfQstr()); }catch(_){} }
+function sfReadURL(){
+  const u=new URLSearchParams(location.search);
+  state.facet=u.get('aesthetic')||'all';
+  state.q=u.get('q')||'';
+  if(u.get('sort')) state.sort=u.get('sort');
+  const si=document.getElementById('searchInput'); if(si) si.value=state.q;
+  const ss=document.getElementById('sortSelect'); if(ss && u.get('sort')) ss.value=state.sort;
+}
+function sfSyncChip(){ document.querySelectorAll('#facets [data-facet]').forEach(b=>b.classList.toggle('active',(b.dataset.facet||'all')===state.facet)); }
+function sfSetAesthetic(v){ state.facet=v||'all'; sfSyncChip(); resetGrid(); sfWriteURL(true); const shop=document.getElementById('shop'); if(shop) shop.scrollIntoView({behavior:'smooth'}); }
+window.sfSetAesthetic=sfSetAesthetic;
+// a .drill atom whose href IS the filtered view for that aesthetic (tag='span' inside the card <a>)
+function sfDrill(value,label,tag){
+  if(value==null||value==='') return escAttr(label==null?'':label);
+  const t=tag||'a';
+  const u=new URLSearchParams(location.search); u.set('aesthetic',String(value));
+  const hattr=(t==='a')?(' href="?'+escAttr(u.toString())+'"'):'';
+  return '<'+t+' class="drill" data-filter="aesthetic" data-v="'+escAttr(value)+'"'+hattr+' title="See all '+escAttr(String(label))+'">'+escAttr(label)+'</'+t+'>';
+}
+// delegated: plain click on any aesthetic drill atom filters in place; modifier/middle keeps the href
+document.addEventListener('click',function(e){
+  const d=e.target.closest('.drill[data-filter="aesthetic"]'); if(!d) return;
+  if(e.metaKey||e.ctrlKey||e.shiftKey||e.altKey||e.button===1) return;
+  e.preventDefault(); e.stopPropagation(); sfSetAesthetic(d.dataset.v);
+});
+addEventListener('popstate',function(){ sfReadURL(); sfSyncChip(); resetGrid(); });
 const TOUCH = window.matchMedia('(hover: none)').matches;
 const LABELS = {"all":"All","textured":"Textured & Tactile","beige":"Warm Neutrals","contemporary":"Modern & Contemporary","gray":"Cool Grays","traditional":"Classic & Traditional","white":"Whites & Ivories"};
 
@@ -661,9 +701,12 @@ function cardHTML(p) {
   if (t.trade) trade += '<div class="row"><span class="lbl">NET30</span><span class="val">' + fmtTrade(t.trade) + '</span></div>';
   if (t.moq) trade += '<div class="row"><span class="lbl">MOQ</span><span class="val">' + fmtMoq(t.moq) + '</span></div>';
   else trade += '<div class="moq-soft">Spec sheet on request</div>';
+  const aesLabel = (p.aesthetic && p.aesthetic !== 'all') ? (LABELS[p.aesthetic] || String(p.aesthetic).replace(/-/g,' ')) : '';
+  const aesLine = aesLabel ? '<div class="pat-aes" style="font-size:10px;letter-spacing:.14em;text-transform:uppercase;opacity:.7;margin-top:2px">' + sfDrill(p.aesthetic, aesLabel, 'span') + '</div>' : '';
   return '<img loading="' + (eager ? 'eager' : 'lazy') + '"' + (eager ? ' fetchpriority="high"' : '') + ' src="' + escAttr(safeImg(p.image_url)) + '" alt="' + escAttr(p.title) + '">'
     + '<div class="overlay">'
     + '<div class="pat">' + escAttr(p.pattern_name || p.title) + '</div>'
+    + aesLine
     + '<div class="trade">' + trade + '</div>'
     + '<div class="actions">'
     + '<button class="sample-btn" onclick="event.stopPropagation();dwmOpen(\'Sample\',' + JSON.stringify({sku:p.sku||p.handle_display||p.handle, title:p.title, image_url:safeImg(p.image_url)}).replace(/"/g,'&quot;') + ')">Sample</button>'
@@ -676,7 +719,9 @@ function rowHTML(p) {
   return '<tr>'
     + '<td><img class="timg" src="' + escAttr(safeImg(p.image_url)) + '" alt="" loading="lazy"></td>'
     + '<td class="tsku">' + escAttr(t.sku || '—') + '</td>'
-    + '<td><div>' + escAttr(p.pattern_name || p.title) + '</div></td>'
+    + '<td><div>' + escAttr(p.pattern_name || p.title) + '</div>'
+      + ((p.aesthetic && p.aesthetic !== 'all') ? '<div style="font-size:10px;letter-spacing:.12em;text-transform:uppercase;opacity:.6;margin-top:2px">' + sfDrill(p.aesthetic, (LABELS[p.aesthetic] || String(p.aesthetic).replace(/-/g,' ')), 'span') + '</div>' : '')
+      + '</td>'
     + '<td class="twidth">' + escAttr(t.width || '—') + '</td>'
     + '<td class="trepeat">' + escAttr(t.repeat || '—') + '</td>'
     + '<td class="tprice">' + (t.trade ? fmtTrade(t.trade) : '—') + '</td>'
@@ -693,19 +738,24 @@ async function loadFacets() {
     f = await r.json();
   } catch (e) { console.error('loadFacets failed:', e); return; }
   const el = document.getElementById('facets');
+  // aesthetic chips are real drill <a href> — facet options OUTSIDE the card must be real links.
   for (const [k, v] of Object.entries(f.aesthetics).sort((a,b) => b[1] - a[1])) {
-    const b = document.createElement('button');
-    b.className = 'chip'; b.dataset.facet = k;
+    const b = document.createElement('a');
+    b.className = 'chip drill'; b.dataset.facet = k; b.dataset.filter = 'aesthetic'; b.dataset.v = k;
+    b.href = '?aesthetic=' + encodeURIComponent(k);
     b.innerHTML = (LABELS[k] || k) + ' <span style="opacity:.55;font-weight:500;margin-left:4px">' + v + '</span>';
     el.appendChild(b);
   }
+  // dynamic aesthetic chips are .drill anchors (handled by the delegated document click).
+  // this handler only covers the static "All" button (data-facet=all, not a .drill).
   el.addEventListener('click', e => {
-    if (e.target.tagName !== 'BUTTON') return;
-    document.querySelectorAll('#facets button').forEach(b => b.classList.remove('active'));
-    e.target.classList.add('active');
-    state.facet = e.target.dataset.facet;
-    resetGrid();
+    const t = e.target.closest('[data-facet]'); if (!t || t.classList.contains('drill')) return;
+    document.querySelectorAll('#facets [data-facet]').forEach(b => b.classList.remove('active'));
+    t.classList.add('active');
+    state.facet = t.dataset.facet || 'all';
+    resetGrid(); sfWriteURL(true);
   });
+  sfSyncChip();
   document.getElementById('totalCount').textContent = f.total;
   document.getElementById('footerStat').textContent = f.total + ' patterns · live archive';
   // Footer aesthetic links
@@ -713,7 +763,7 @@ async function loadFacets() {
   for (const [k] of Object.entries(f.aesthetics)) {
     const b = document.createElement('button');
     b.textContent = LABELS[k] || k;
-    b.onclick = () => { state.facet = k; resetGrid(); document.querySelectorAll('#facets button').forEach(x => x.classList.toggle('active', x.dataset.facet === k)); document.getElementById('shop').scrollIntoView({behavior:'smooth'}); };
+    b.onclick = () => sfSetAesthetic(k);
     footerEl.appendChild(b);
   }
 }
@@ -781,7 +831,7 @@ document.getElementById('searchInput').addEventListener('input', e => {
   state.q = e.target.value.trim();
   clearTimeout(window._t);
   window._t = setTimeout(() => {
-    resetGrid();
+    resetGrid(); sfWriteURL(false);
     // Mobile: results grid sits below the fold under the keyboard, so a search looks
     // like "nothing happened". Bring results into view when a query is entered.
     if (state.q) { const a = document.getElementById('statLine') || document.getElementById('grid'); if (a) a.scrollIntoView({ behavior: 'smooth', block: 'start' }); }
@@ -835,6 +885,7 @@ function setTheme(t){ document.documentElement.dataset.theme = t; try { localSto
 setTheme(document.documentElement.dataset.theme || 'light');
 tb.addEventListener('click', () => setTheme(document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark'));
 
+sfReadURL();          // deep-link: hydrate facet/q/sort from the URL before first load
 loadFacets();
 loadGridPage();
 </script>
@@ -1092,6 +1143,7 @@ loadGridPage();
     if (typeof state !== 'undefined') state.sort = v;
     if (typeof resetGrid === 'function') resetGrid();
     else location.reload();
+    if (typeof sfWriteURL === 'function') sfWriteURL(false);
   });
 })();
 

← ae481a9 search: scroll results into view on mobile (grid was below t  ·  back to Ffepurchasing  ·  fix: take infinite-scroll .loading indicator out of flow (ki 57ecf40 →