[object Object]

← back to Costa Rica

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

b3cbf1b496b6e798ed8ffa9a206f8730526faff7 · 2026-08-01 20:19:18 -0700 · Steve

Files touched

Diff

commit b3cbf1b496b6e798ed8ffa9a206f8730526faff7
Author: Steve <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:19:18 2026 -0700

    costa-rica: adopt href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   | 36 +++++++++++++++++++++
 public/index.html | 94 ++++++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 118 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 c54e836..128dc5d 100644
--- a/public/index.html
+++ b/public/index.html
@@ -64,6 +64,10 @@
     footer{max-width:1320px;margin:0 auto;padding:30px 22px 60px;color:var(--muted);font-size:13px;border-top:1px solid var(--line);margin-top:30px}
     footer a{color:var(--accent)}
     .empty{padding:60px 0;text-align:center;color:var(--muted)}
+    /* ── drill atom: every data point filters to its deeper view (card is an <a>, atoms are <span>) ── */
+    .drill{color:inherit;cursor:pointer;border-bottom:1px dotted transparent}
+    .drill:hover{border-bottom-color:currentColor;opacity:.85}
+    .cat .drill:hover,.where .drill:hover{color:var(--accent)}
   </style>
 </head>
 <body>
@@ -126,6 +130,7 @@
     <div>© Costa Rica Directory. Aggregated public-record data; we do not host owner-operated content without permission. Owner of a listing? <a href="mailto:info@agentabrams.com">info@agentabrams.com</a>.</div>
   </footer>
 
+  <script src="/drill.js"></script>
   <script>
     const $ = (s,r=document)=>r.querySelector(s);
     const $$ = (s,r=document)=>[...r.querySelectorAll(s)];
@@ -135,10 +140,67 @@
       q: localStorage.getItem('cr-q') || '',
       cat: '',
       region: '',
+      vertical: '',
       sort: localStorage.getItem('cr-sort') || 'newest',
       cols: parseInt(localStorage.getItem('cr-cols') || '4', 10),
     };
 
+    // ── href-to-deeper-data adapter (client state model) ──────────────────────
+    // Filters live in a plain `state` object (q/category/region/vertical), not a
+    // FIELDS/FIL set — so override the drill primitives to map each facet field to
+    // state, keeping the <span class="drill" data-filter data-v> atom contract.
+    // The card is an <a>, so in-card atoms are <span> (delegated + stopPropagation),
+    // never nested <a>. Only EXISTING on-card values are linkified.
+    function esc(s){return String(s==null?'':s).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));}
+    const DFIELDS=['q','category','region','vertical'];   // drill field -> state key (cat maps to category)
+    function stateVal(k){ return k==='category' ? state.cat : state[k]; }
+    function drillState(over){
+      const s={q:state.q,category:state.cat,region:state.region,vertical:state.vertical};
+      if(over)Object.assign(s,over); return s;
+    }
+    function drillURL(s){
+      const u=new URLSearchParams();
+      for(const k of DFIELDS)if(s[k])u.set(k,s[k]);
+      if(state.sort&&state.sort!=='newest')u.set('sort',state.sort);
+      const q=u.toString(); return q?('?'+q):location.pathname;
+    }
+    // drill(field,value,label,cls) → in-card <span> whose data IS the filtered view.
+    // Not an <a> (card is already an anchor); delegated click applies it in place.
+    function drill(field,val,label,cls){
+      if(val==null||val==='')return esc(label!=null?label:val);
+      return `<span class="drill ${cls||''}" role="link" tabindex="0" data-filter="${field}" data-v="${esc(String(val))}" title="Filter to ${esc(String(label!=null?label:val))}">${esc(String(label!=null?label:val))}</span>`;
+    }
+    function setF(field,val){
+      if(field==='category'){ state.cat=String(val); syncCatNav(); }
+      else state[field]=String(val);
+      writeURL(true); load();
+    }
+    function syncCatNav(){
+      $$('.nav-links a').forEach(x=>x.classList.toggle('active',(x.dataset.cat||'')===state.cat));
+    }
+    function readURL(){
+      const u=new URLSearchParams(location.search);
+      state.q=u.get('q')||''; state.cat=u.get('category')||'';
+      state.region=u.get('region')||''; state.vertical=u.get('vertical')||'';
+      if(u.get('sort'))state.sort=u.get('sort');
+    }
+    function writeURL(push){ history[push?'pushState':'replaceState']({},'',drillURL(drillState())); }
+    // delegated: plain click / Enter on a drill atom filters in place; the card
+    // <a> navigation is suppressed via preventDefault + stopPropagation.
+    grid.addEventListener('click',e=>{
+      const d=e.target.closest('span.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);
+    });
+    grid.addEventListener('keydown',e=>{
+      if(e.key!=='Enter'&&e.key!==' ')return;
+      const d=e.target.closest('span.drill'); if(!d)return;
+      e.preventDefault(); e.stopPropagation();
+      setF(d.dataset.filter,d.dataset.v);
+    });
+    addEventListener('popstate',()=>{ readURL(); $('#q').value=state.q; $('#sort').value=state.sort; renderRegions(window.__regions__||[]); syncCatNav(); load(); });
+
     function applyTheme(){
       const cur = document.documentElement.getAttribute('data-theme') || 'light';
       $('#i-sun').style.display  = cur === 'dark' ? '' : 'none';
@@ -162,13 +224,13 @@
     });
 
     $('#sort').value = state.sort;
-    $('#sort').addEventListener('change', e => { state.sort = e.target.value; localStorage.setItem('cr-sort', state.sort); load(); });
+    $('#sort').addEventListener('change', e => { state.sort = e.target.value; localStorage.setItem('cr-sort', state.sort); writeURL(true); load(); });
 
     $('#q').value = state.q;
     let qDebounce;
     $('#q').addEventListener('input', e => {
       clearTimeout(qDebounce);
-      qDebounce = setTimeout(() => { state.q = e.target.value.trim(); localStorage.setItem('cr-q', state.q); load(); }, 220);
+      qDebounce = setTimeout(() => { state.q = e.target.value.trim(); localStorage.setItem('cr-q', state.q); writeURL(false); load(); }, 220);
     });
 
     $$('.nav-links a').forEach(a => a.addEventListener('click', e => {
@@ -176,14 +238,14 @@
       $$('.nav-links a').forEach(x => x.classList.remove('active'));
       a.classList.add('active');
       state.cat = a.dataset.cat || '';
-      load();
+      writeURL(true); load();
     }));
 
     function regionPill(slug, name, n, active){
       const a = document.createElement('button');
       a.className = 'pill' + (active ? ' active' : '');
       a.innerHTML = `${name}<span class="n">${n||0}</span>`;
-      a.addEventListener('click', () => { state.region = active ? '' : slug; renderRegions(window.__regions__); load(); });
+      a.addEventListener('click', () => { state.region = active ? '' : slug; renderRegions(window.__regions__); writeURL(true); load(); });
       return a;
     }
 
@@ -212,13 +274,18 @@
       const img = heroSrc
         ? `<div class="img"><img loading="lazy" src="${heroSrc}" alt=""></div>`
         : `<div class="img">⛰</div>`;
-      const region = p.region_name ? `${p.region_name}${p.province?', '+p.province:''}` : (p.address || '');
+      // region drills on the region SLUG (server filters r.slug); province appended as plain text
+      const regionDrill = p.region_name && p.region_slug
+        ? drill('region', p.region_slug, p.region_name) + (p.province ? ', ' + esc(p.province) : '')
+        : esc(p.region_name ? `${p.region_name}${p.province?', '+p.province:''}` : (p.address || ''));
+      // vertical (the .cat label) drills on the raw p.vertical value; label is the spaced form
+      const catDrill = p.vertical ? drill('vertical', p.vertical, verticalLabel) : esc(verticalLabel);
       wrap.innerHTML = `
         ${img}
         <div class="body">
-          <div class="cat">${verticalLabel}</div>
-          <div class="name">${p.name}</div>
-          <div class="where">${region}</div>
+          <div class="cat">${catDrill}</div>
+          <div class="name">${esc(p.name)}</div>
+          <div class="where">${regionDrill}</div>
           <div class="desc">${p.description||''}</div>
           <div class="row">
             <span class="badge ${p.verified?'verified':''}">${p.verified?'verified':p.source||'pending'}</span>
@@ -232,10 +299,11 @@
 
     async function load(){
       const params = new URLSearchParams();
-      if (state.q)      params.set('q', state.q);
-      if (state.cat)    params.set('category', state.cat);
-      if (state.region) params.set('region', state.region);
-      if (state.sort)   params.set('sort', state.sort);
+      if (state.q)        params.set('q', state.q);
+      if (state.cat)      params.set('category', state.cat);
+      if (state.region)   params.set('region', state.region);
+      if (state.vertical) params.set('vertical', state.vertical);
+      if (state.sort)     params.set('sort', state.sort);
       params.set('limit','120');
       meta.textContent = 'Loading…';
       try {
@@ -253,6 +321,8 @@
       }
     }
 
+    // deep-link: hydrate state from the URL before first load (URL wins over localStorage)
+    readURL(); $('#q').value=state.q; $('#sort').value=state.sort; syncCatNav();
     loadRegions().then(load);
   </script>
 </body>

← a52fd3f Card badges for website/phone presence (site vs social disti  ·  back to Costa Rica  ·  costa-rica: escape p.description in card innerHTML (Cody ARG 8c8ebde →