[object Object]

← back to All Designerwallcoverings

all-designerwallcoverings: adopt href-to-deeper-data primitives — LOCAL only, deploy Steve-gated (TK-10093)

ba032a8206bc029437d40f8334ccc355419951ab · 2026-08-01 21:06:39 -0700 · Steve Abrams

Files touched

Diff

commit ba032a8206bc029437d40f8334ccc355419951ab
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 21:06:39 2026 -0700

    all-designerwallcoverings: adopt href-to-deeper-data primitives — LOCAL only, deploy Steve-gated (TK-10093)
---
 public/drill.js        |  36 ++++++++++
 public/index.html      | 191 +++++++++++++++++++++++++++++++++++++++++--------
 public/microsites.html |  24 ++++++-
 3 files changed, 220 insertions(+), 31 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 6608fab..13f0c7c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -60,7 +60,12 @@
   details.sec > summary .n { margin-left:auto; background:var(--accent); color:#1a1407; border-radius:20px;
     padding:0 7px; font-size:10px; font-weight:700; }
   .fbody { padding:2px 8px 9px; }
-  .fitem { display:flex; align-items:center; gap:7px; padding:2.5px 2px; cursor:pointer; font-size:12.5px; color:var(--txt); border-radius:5px; }
+  .fitem { display:flex; align-items:center; gap:7px; padding:2.5px 2px; cursor:pointer; font-size:12.5px; color:var(--txt); border-radius:5px; text-decoration:none; }
+  a.fitem:hover .lbl { color:var(--accent); }
+  /* in-card drill atoms — a data point that hrefs to its filtered view (no underline chrome;
+     the value stays legible, gains an accent tint + underline only on hover) */
+  .drill { color:inherit; text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; }
+  .drill:hover { color:var(--accent); border-bottom-color:var(--accent); }
   .fitem:hover { background:var(--card); }
   .fitem .box { width:13px; height:13px; flex:0 0 13px; border:1px solid var(--mut); border-radius:3px; display:inline-flex; align-items:center; justify-content:center; font-size:10px; }
   .fitem.on .box { background:var(--accent); border-color:var(--accent); color:#1a1407; font-weight:700; }
@@ -318,6 +323,10 @@
 
 <div id="fmModal"><div class="box"><span class="close" id="fmModalClose">✕ close</span><h3>FileMaker → Shopify — dry-run preview</h3><div id="fmModalBody">…</div></div></div>
 
+<!-- href-to-deeper-data primitives (shared atom contract + enforcer canary). This aggregator's
+     filter model is CSV multi-select Sets, so the inline script below defines a Set-aware
+     drill()/qstr adapter that keeps the <a class="drill" data-filter data-v href> atom contract. -->
+<script src="/drill.js"></script>
 <script>
 const grid = document.getElementById('grid'), tbody = document.getElementById('tbody');
 const listwrap = document.getElementById('listwrap');
@@ -326,19 +335,99 @@ const LIMIT = 120;
 const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
 
 const urlq = new URLSearchParams(location.search);
+// ── href-to-deeper-data contract (drill.js adapter, HARD rule 2026-07-31) ──────────────
+// Every displayed data point is an <a href="?<param>=<value>"> to its filtered view. This
+// aggregator's filter model is CSV MULTI-SELECT Sets (state.vendors, state.types, …) — NOT
+// drill.js's single-value FIL — so we keep drill.js's `<a class="drill" data-filter data-v href>`
+// ATOM CONTRACT (loaded from /drill.js for the enforcer canary) but override drill()/qstr with
+// this Set-aware adapter below. DRILL_FIELDS maps a card-atom/facet field → its URL CSV param
+// AND the state.* Set that holds it, so one map drives href-building, click-filtering, and
+// URL read/write. Deep-link + back-button work because the URL is the source of truth.
+const DRILL_FIELDS = {
+  vendor:    { param: 'vendors',    set: 'vendors' },
+  type:      { param: 'types',      set: 'types' },
+  series:    { param: 'series',     set: 'series' },
+  color:     { param: 'colors',     set: 'colors' },
+  style:     { param: 'styles',     set: 'styles' },
+  material:  { param: 'materials',  set: 'materials' },
+  price:     { param: 'prices',     set: 'prices' },
+  lifecycle: { param: 'lifecycles', set: 'lifecycles' },
+  image:     { param: 'images',     set: 'images' },
+};
+// Read a CSV facet param off the URL; if ANY facet param is present the URL wins (deep-link),
+// else fall back to the localStorage-remembered Set (the pre-retrofit behaviour).
+const urlHasFacets = Object.values(DRILL_FIELDS).some((d) => urlq.get(d.param) != null) || urlq.get('q') != null;
+function initSet(param, lsKey) {
+  if (urlHasFacets) return new Set((urlq.get(param) || '').split(',').map((s) => s.trim()).filter(Boolean));
+  return loadSet(lsKey);
+}
 let state = {
   sort: urlq.get('sort') || localStorage.getItem('all_sort') || 'newest',
   dir: urlq.get('dir') || localStorage.getItem('all_dir') || 'asc',
   view: urlq.get('view') || localStorage.getItem('all_view') || 'grid',
   q: urlq.get('q') || '',
-  vendors: loadSet('all_vendors'), types: loadSet('all_types'), series: loadSet('all_series'), lifecycles: loadSet('all_lifecycles'),
-  colors: loadSet('all_colors'), styles: loadSet('all_styles'), materials: loadSet('all_materials'),
-  prices: loadSet('all_prices'), images: loadSet('all_images'),
+  vendors: initSet('vendors', 'all_vendors'), types: initSet('types', 'all_types'), series: initSet('series', 'all_series'), lifecycles: initSet('lifecycles', 'all_lifecycles'),
+  colors: initSet('colors', 'all_colors'), styles: initSet('styles', 'all_styles'), materials: initSet('materials', 'all_materials'),
+  prices: initSet('prices', 'all_prices'), images: initSet('images', 'all_images'),
   // FileMaker verification views: mismatch = only rows whose FM mfr# ≠ Shopify's; fmDisco =
   // include the rows FileMaker Pro marks discontinued (hidden by default).
   mismatch: urlq.get('mismatch') === '1', fmDisco: urlq.get('fm_disco') === '1',
   offset: 0, total: 0, loading: false, done: false,
 };
+
+// ── drill.js adapter (Set-aware) — the href-to-deeper-data primitives, adapted ─────────
+// Build the FULL current filter URL (?vendors=…&types=…&q=…&sort=…) — every count/atom becomes
+// a real, shareable link. `overrides` = { setKey: Set } to swap one dimension in for the href.
+function drillQstr(overrides) {
+  const u = new URLSearchParams();
+  for (const { param, set } of Object.values(DRILL_FIELDS)) {
+    const s = (overrides && overrides[set]) || state[set];
+    if (s && s.size) u.set(param, [...s].join(','));
+  }
+  if (state.q) u.set('q', state.q);
+  if (state.sort && state.sort !== 'newest') u.set('sort', state.sort);
+  if (state.dir && state.dir !== 'asc') u.set('dir', state.dir);
+  if (state.view && state.view !== 'grid') u.set('view', state.view);
+  if (state.mismatch) u.set('mismatch', '1');
+  if (state.fmDisco) u.set('fm_disco', '1');
+  const q = u.toString();
+  return q ? ('?' + q) : location.pathname;
+}
+// Reflect current filter state into the URL. push=new history entry (a drill), else replace.
+function writeURL(push) {
+  const url = drillQstr();
+  history[push ? 'pushState' : 'replaceState']({}, '', url);
+}
+// The drill ATOM: an <a> whose href IS the filtered view with `val` ADDED to `field`'s Set.
+// Contract preserved for the enforcer canary: <a class="drill" data-filter data-v href>.
+// asSpan=true → render as <span class="drill"> (used INSIDE the card <a>-like element so we
+// never nest a real <a>; the delegated click handler filters + stopPropagation).
+function drill(field, val, label, asSpan) {
+  if (val == null || val === '') return '';
+  const d = DRILL_FIELDS[field]; if (!d) return esc(label != null ? label : val);
+  const preview = new Set(state[d.set]); preview.add(String(val));
+  const href = drillQstr({ [d.set]: preview });
+  const txt = label != null ? esc(label) : esc(val);
+  const attrs = `class="drill" data-filter="${field}" data-v="${esc(val)}" href="${href}" title="Filter to ${esc(val)}"`;
+  return asSpan
+    ? `<span ${attrs} role="link" tabindex="0">${txt}</span>`
+    : `<a ${attrs}>${txt}</a>`;
+}
+// Drill in: SET this value into its dimension's Set + re-filter (matches toggleSet's add path).
+function drillTo(field, val, push) {
+  const d = DRILL_FIELDS[field]; if (!d) return;
+  const set = state[d.set]; if (set.has(String(val))) return;
+  set.add(String(val)); applyFilterChange(push);
+}
+// Delegated click handler for in-card / in-row drill atoms (spans). stopPropagation keeps the
+// card/row's own open-website click from firing; cmd/ctrl/middle-click lets the href open a tab.
+function handleDrillClick(e) {
+  const a = e.target.closest('.drill'); if (!a) return;
+  if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) { e.stopPropagation(); return; } // let the new-tab open proceed
+  // stopImmediatePropagation also blocks a same-element onclick (list <tr> open-website handler).
+  e.stopImmediatePropagation(); e.preventDefault();
+  drillTo(a.dataset.filter, a.dataset.v, true);
+}
 // per-row UI state: expanded? + type-ahead needle (vendor/type rows have hundreds of values)
 const rowUI = {
   vendorRow: { expand: false, find: '', typeahead: true }, typeRow: { expand: false, find: '', typeahead: true },
@@ -364,8 +453,8 @@ const fmDiscoBtn = document.getElementById('fmDisco');
 const fmBulkBtn = document.getElementById('fmBulk');
 const syncToggles = () => { fmMismatchBtn.classList.toggle('on', state.mismatch); fmDiscoBtn.classList.toggle('on', state.fmDisco); };
 syncToggles();
-fmMismatchBtn.onclick = () => { state.mismatch = !state.mismatch; syncToggles(); refreshFacets(); reset(); };
-fmDiscoBtn.onclick = () => { state.fmDisco = !state.fmDisco; syncToggles(); refreshFacets(); reset(); };
+fmMismatchBtn.onclick = () => { state.mismatch = !state.mismatch; syncToggles(); writeURL(); refreshFacets(); reset(); };
+fmDiscoBtn.onclick = () => { state.fmDisco = !state.fmDisco; syncToggles(); writeURL(); refreshFacets(); reset(); };
 fmBulkBtn.onclick = () => fmBulkPreview();
 
 // Bulk dry-run: send the loaded FileMaker-joined SKUs (≤100 per call) to the push worker and
@@ -432,8 +521,8 @@ sortSel.value = ['price_asc','price_desc'].includes(state.sort) ? 'price' : stat
 state.sort = sortSel.value;
 const drawDir = () => { dirBtn.textContent = state.dir === 'desc' ? '▼' : '▲'; };
 drawDir();
-sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); reset(); drawCols(); };
-dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); drawDir(); reset(); drawCols(); };
+sortSel.onchange = () => { state.sort = sortSel.value; persistSort(); writeURL(); reset(); drawCols(); };
+dirBtn.onclick = () => { state.dir = state.dir === 'desc' ? 'asc' : 'desc'; persistSort(); writeURL(); drawDir(); reset(); drawCols(); };
 function persistSort() { localStorage.setItem('all_sort', state.sort); localStorage.setItem('all_dir', state.dir); }
 
 const vG = document.getElementById('vGrid'), vL = document.getElementById('vList');
@@ -583,8 +672,10 @@ async function loadSimilar(sku) {
     box.innerHTML = `<div class="dbnone">Similarity search unavailable — try again.</div>`;
   }
 }
-function applyFilterChange() { persistFacets(); refreshFacets(); reset(); }
-function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(); }
+// push=true adds a history entry (a drill-in / atom click is deep-linkable + back-navigable);
+// omitted/false replaces (a facet toggle / search keystroke) so the URL stays the source of truth.
+function applyFilterChange(push) { persistFacets(); writeURL(push); refreshFacets(); reset(); }
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); applyFilterChange(true); }
 
 /* Amazon-style vertical facet list: checkbox rows + counts, type-ahead on big rows,
    See-more expander, selected values always visible. */
@@ -611,10 +702,21 @@ function facetList(rowId, counts, set, opts = {}) {
   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'; row.appendChild(s); return; }
   shown.forEach(([val, ct]) => {
-    const el = document.createElement('div');
+    // href-to-deeper-data: each facet value+count is a REAL <a href="?<param>=<value>"> to its
+    // filtered view (deep-linkable, middle-click-openable). Plain click still toggles in place.
+    const field = opts.field;
+    const d = field && DRILL_FIELDS[field];
+    const href = d ? drillQstr({ [d.set]: new Set([...set, String(val)]) }) : '#';
+    const el = document.createElement('a');
     el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+    el.href = href;
+    if (field) { el.dataset.filter = field; el.dataset.v = val; }
     el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span><span class="lbl" title="${val.replace(/"/g, '&quot;')}">${val}</span><span class="ct">${ct.toLocaleString()}</span>`;
-    el.onclick = () => toggleSet(set, val);
+    el.onclick = (e) => {
+      // cmd/ctrl/middle-click → let the browser open the filtered view in a new tab.
+      if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
+      e.preventDefault(); toggleSet(set, val);
+    };
     row.appendChild(el);
   });
   if (hidden > 0) {
@@ -631,15 +733,15 @@ function facetList(rowId, counts, set, opts = {}) {
 let LAST_FACETS = {};
 function renderFacets(f) {
   LAST_FACETS = f;
-  facetList('vendorRow', f.vendor, state.vendors, { cap: 10, byName: true });
-  facetList('typeRow', f.type, state.types, { cap: 10 });
-  facetList('seriesRow', f.series, state.series, { cap: 12 });
-  facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12 });
-  facetList('styleRow', f.style, state.styles, { cap: 10 });
-  facetList('matRow', f.material, state.materials, { cap: 10 });
-  facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8 });
-  facetList('lifecycleRow', f.lifecycle, state.lifecycles, { order: f.lifecycle_order, cap: 6 });
-  facetList('imgRow', f.image_state, state.images, { cap: 4 });
+  facetList('vendorRow', f.vendor, state.vendors, { cap: 10, byName: true, field: 'vendor' });
+  facetList('typeRow', f.type, state.types, { cap: 10, field: 'type' });
+  facetList('seriesRow', f.series, state.series, { cap: 12, field: 'series' });
+  facetList('colorRow', f.color, state.colors, { order: f.family_order, cap: 12, field: 'color' });
+  facetList('styleRow', f.style, state.styles, { cap: 10, field: 'style' });
+  facetList('matRow', f.material, state.materials, { cap: 10, field: 'material' });
+  facetList('priceRow', f.price_band, state.prices, { order: f.price_order, cap: 8, field: 'price' });
+  facetList('lifecycleRow', f.lifecycle, state.lifecycles, { order: f.lifecycle_order, cap: 6, field: 'lifecycle' });
+  facetList('imgRow', f.image_state, state.images, { cap: 4, field: 'image' });
   renderActive();
 }
 
@@ -1049,14 +1151,24 @@ function card(r) {
   const thumb = r.image
     ? `<div class="thumb" style="background-image:url('${r.image}')">${stBadge}${discoBadge}${galBadge}</div>`
     : `<div class="thumb empty">${stBadge}${discoBadge}no image</div>`;
-  const vt = [r.vendor, r.type].filter(Boolean).join(' · ');
+  // Card data atoms → drill spans (href-to-deeper-data). The card itself is a click-to-open
+  // element, so in-card atoms are <span class="drill"> (asSpan=true), NEVER nested <a>; a
+  // delegated .meta handler filters + stopPropagation so the card's window.open never fires.
+  const vt = [r.vendor && drill('vendor', r.vendor, r.vendor, true), r.type && drill('type', r.type, r.type, true)]
+    .filter(Boolean).join(' · ');
   const mfrn = mfrHTML(r);
   const mfrname = r.mfr_name ? `<div class="mfrname"><b>Mfr</b> ${esc(r.mfr_name)}</div>` : '';
   const fmspec = fmSpecsHTML(r);
-  const fac = [...(r.colors || []), ...(r.styles || []), ...(r.materials || [])].slice(0, 4).join(' · ');
+  const facAtoms = [
+    ...(r.colors || []).map((c) => drill('color', c, c, true)),
+    ...(r.styles || []).map((c) => drill('style', c, c, true)),
+    ...(r.materials || []).map((c) => drill('material', c, c, true)),
+  ].filter(Boolean).slice(0, 4);
+  const fac = facAtoms.join(' · ');
   const price = r.price ? `<span class="price">$${r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</span>` : '';
   el.innerHTML = `${thumb}<div class="meta"><div class="sku">${r.sku || '—'}</div><div class="ttl" title="${(r.title || '').replace(/"/g, '&quot;')}">${r.title || ''}</div>${vt ? `<div class="vt">${vt}</div>` : ''}${mfrn}${mfrname}${fmspec}${fac ? `<div class="fac">${fac}</div>` : ''}${price}${whenChip(r).html}</div><div class="acts">${actButtonsHTML(r)}</div>`;
   const acts = el.querySelector('.acts'); if (acts) acts.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
+  const meta = el.querySelector('.meta'); if (meta) meta.addEventListener('click', handleDrillClick);
   if (r.url) { el.style.cursor = 'pointer'; el.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
   // hover-cycle the full Shopify gallery on the thumb (no extra requests — urls already in the row)
   if (gcount > 1 && r.images && r.images.length > 1) {
@@ -1075,19 +1187,22 @@ function listRow(r) {
   tr.appendChild(cell(`<span class="sku">${r.sku || '—'}</span>`));
   tr.appendChild(cell(mfrCell(r), 'tmfr'));
   tr.appendChild(cell((r.title || '').replace(/</g, '&lt;')));
-  tr.appendChild(cell(r.vendor || ''));
-  tr.appendChild(cell(r.type || ''));
+  // List-view data cells are drillable too (href-to-deeper-data) — spans, delegated + stopProp.
+  const drillJoin = (field, vals, sep) => (vals || []).map((v) => drill(field, v, v, true)).filter(Boolean).join(sep || ', ');
+  tr.appendChild(cell(r.vendor ? drill('vendor', r.vendor, r.vendor, true) : ''));
+  tr.appendChild(cell(r.type ? drill('type', r.type, r.type, true) : ''));
   tr.appendChild(cell(r.pattern || ''));
-  tr.appendChild(cell(`<span class="tfac">${(r.colors || []).join(', ')}</span>`));
-  tr.appendChild(cell(`<span class="tfac">${(r.styles || []).join(', ')}</span>`));
-  tr.appendChild(cell(`<span class="tfac">${(r.materials || []).join(', ')}</span>`));
+  tr.appendChild(cell(`<span class="tfac">${drillJoin('color', r.colors)}</span>`));
+  tr.appendChild(cell(`<span class="tfac">${drillJoin('style', r.styles)}</span>`));
+  tr.appendChild(cell(`<span class="tfac">${drillJoin('material', r.materials)}</span>`));
   tr.appendChild(cell(r.price ? '$' + r.price.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : '', 'num'));
-  tr.appendChild(cell(r.lifecycle ? `<span class="when ${lifeClass(r)}"><b>${r.lifecycle}</b></span>` : ''));
+  tr.appendChild(cell(r.lifecycle ? `<span class="when ${lifeClass(r)}">${drill('lifecycle', r.lifecycle, r.lifecycle, true)}</span>` : ''));
   tr.appendChild(cell(r.status || ''));
   tr.appendChild(cell(r.created ? `<span title="${new Date(r.created).toISOString()}">${fmtDate(r.created)}</span>` : ''));
   const actTd = cell(actButtonsHTML(r), 'acts-td');
   actTd.addEventListener('click', (e) => { e.stopPropagation(); handleActsClick(e); });
   tr.appendChild(actTd);
+  tr.addEventListener('click', handleDrillClick);   // in-row drill atoms filter without opening the product
   if (r.url) { tr.className = 'linked'; tr.onclick = () => window.open(r.url, '_blank', 'noopener,noreferrer'); }
   return tr;
 }
@@ -1146,6 +1261,24 @@ new IntersectionObserver((es) => { sentVisible = es[0].isIntersecting; if (sentV
   .observe(sentEl);
 // Re-pump on window resize too (a wider viewport can reveal the sentinel with no scroll event).
 window.addEventListener('resize', () => { if (!state.done) pump(); });
+
+// ── back / forward button: the URL is the source of truth — re-read the CSV facet params +
+// q/sort/dir/view into state, resync the controls, and re-render (deep-link + popstate). ──
+window.addEventListener('popstate', () => {
+  const u = new URLSearchParams(location.search);
+  for (const { param, set } of Object.values(DRILL_FIELDS)) {
+    state[set] = new Set((u.get(param) || '').split(',').map((s) => s.trim()).filter(Boolean));
+  }
+  state.q = u.get('q') || '';
+  state.sort = u.get('sort') || 'newest';
+  state.dir = u.get('dir') || 'asc';
+  state.mismatch = u.get('mismatch') === '1'; state.fmDisco = u.get('fm_disco') === '1';
+  qEl.value = state.q;
+  sortSel.value = ['price_asc', 'price_desc'].includes(state.sort) ? 'price' : state.sort;
+  drawDir(); drawCols(); syncToggles(); persistFacets();
+  refreshFacets(); reset();
+});
+
 refreshFacets(); load().then(() => pump());
 </script>
 </body>
diff --git a/public/microsites.html b/public/microsites.html
index 084726c..fd6ac26 100644
--- a/public/microsites.html
+++ b/public/microsites.html
@@ -60,6 +60,10 @@
   .card h3 { font-family: Georgia, serif; font-weight:400; font-size:17px; letter-spacing:.03em; }
   .card .sub { margin-top:6px; font-size:11px; letter-spacing:.18em; text-transform:uppercase; color:#9a927f; }
   .card .cta { margin-top:12px; font-size:11px; letter-spacing:.22em; text-transform:uppercase; color:var(--gold); }
+  /* per-microsite product count → drills into the catalog aggregator filtered to this vendor */
+  .countdrill { cursor:pointer; border-bottom:1px dotted transparent; }
+  .badge.countdrill:hover { background:var(--gold); color:#fff; }
+  .sub .countdrill:hover, .card .sub .countdrill:hover { color:var(--gold); border-bottom-color:var(--gold); }
 
   /* per-card vendor inquiry button + page-level compose modal (Gmail DRAFT only, never auto-sends) */
   .card .mailv { margin-top:12px; align-self:flex-start; background:none; border:1px solid var(--line); border-radius:2px;
@@ -248,10 +252,22 @@ function card(s){
   const cells = pool.length
     ? pool.map(u=>`<img loading="lazy" src="${String(u).replace(/"/g,'')}" alt="${name} design">`).join('')
     : `<div class="ph">${name.charAt(0)}</div>`;
+  // href-to-deeper-data: the per-microsite product COUNT links to the aggregator filtered to
+  // this vendor's products (/?vendors=<vendor>). The tile itself is an <a> to the live microsite,
+  // so the count is a <span class="countdrill"> with a delegated navigate (can't nest an <a>).
+  // Only when we have a real vendor name that maps to the product grid's vendor facet.
+  const aggUrl = s.vendor ? '/?vendors=' + encodeURIComponent(s.vendor) : '';
+  const countTxt = s.productCount.toLocaleString() + ' designs';
   const badge = gated ? `<span class="badge gated">Trade Access</span>`
-    : (s.productCount>0 ? `<span class="badge">${s.productCount.toLocaleString()} designs</span>` : '');
+    : (s.productCount>0
+        ? (aggUrl ? `<span class="badge countdrill" data-agg="${aggUrl.replace(/"/g,'&quot;')}" title="See all ${countTxt} in the catalog aggregator">${countTxt}</span>`
+                  : `<span class="badge">${countTxt}</span>`)
+        : '');
   const sub = gated ? 'By trade inquiry'
-    : (s.productCount>0 ? s.productCount.toLocaleString()+' designs' : 'Editorial lookbook');
+    : (s.productCount>0
+        ? (aggUrl ? `<span class="countdrill" data-agg="${aggUrl.replace(/"/g,'&quot;')}" title="See all ${countTxt} in the catalog aggregator">${countTxt} →</span>`
+                  : countTxt)
+        : 'Editorial lookbook');
   el.innerHTML = `
     ${badge}
     <div class="collage${pool.length<=1?' single':''}">${cells}</div>
@@ -264,6 +280,10 @@ function card(s){
   el.querySelector('h3').textContent = name;
   // The card itself is an <a> — the inquiry button must not navigate.
   el.querySelector('.mailv').addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); openMail(name); });
+  // The count drill navigates to the aggregator (not the microsite) — intercept before the tile <a>.
+  el.querySelectorAll('.countdrill').forEach((c) => c.addEventListener('click', (e) => {
+    e.preventDefault(); e.stopPropagation(); window.location.href = c.dataset.agg;
+  }));
   return el;
 }
 

← 09077cc auto-save: 2026-07-27T15:21:37 (1 files) — package-lock.json  ·  back to All Designerwallcoverings  ·  all.: repoint Phillipe Romano line-viewer off dead philliper e98a86b →