← 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
A public/drill.jsM public/index.htmlM public/microsites.html
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, '"')}">${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, '"')}">${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, '<')));
- 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,'"')}" 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,'"')}" 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 →