← back to Gracie Internal
gracie-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
167e70979aa2664b424cce5e7018cfbdb81dfe83 · 2026-07-31 12:20:26 -0700 · Steve Abrams
Files touched
A public/drill.jsM public/index.html
Diff
commit 167e70979aa2664b424cce5e7018cfbdb81dfe83
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Fri Jul 31 12:20:26 2026 -0700
gracie-internal: adopt shared /drill.js href-to-deeper-data primitives (TK-10093)
---
public/drill.js | 36 +++++++++++++++++
public/index.html | 115 +++++++++++++++++++++++++++++++++++++++++++++++++-----
2 files changed, 142 insertions(+), 9 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 567d1b9..b133ab7 100644
--- a/public/index.html
+++ b/public/index.html
@@ -68,6 +68,15 @@
.flag.gate { background:#fde2e2; color:#a12; }
.flag.enr { background:#e2f0ec; color:var(--accent); }
.swatch { width:11px; height:11px; border-radius:3px; border:1px solid rgba(0,0,0,.15); display:inline-block; vertical-align:-1px; margin-right:4px; }
+ /* ── drill atom: every data point is an href to its filtered (deeper) view ── */
+ a.drill { color:inherit; text-decoration:none; cursor:pointer; border-bottom:1px dotted transparent; transition:color .12s, border-color .12s; }
+ a.drill:hover { color:var(--accent); border-bottom-color:var(--accent); }
+ .card .famrow { font-size:10.5px; color:var(--mut); }
+ .card .famrow a.drill.fam { font-size:10.5px; }
+ .card .tag a.drill { color:#5a554c; }
+ .card .tag a.drill:hover { color:var(--accent); }
+ a.fitem { text-decoration:none; color:inherit; } /* rail rows are real <a> now */
+ body.hide-meta .card .famrow { display:none; } /* family drill rides with meta visibility */
/* card-field visibility toggles (hidden classes set on body) */
body.hide-meta .card .meta { display:none; }
body.hide-flags .card .flags { display:none; }
@@ -131,6 +140,7 @@
<div class="grid" id="grid"></div>
</main>
</div>
+<script src="/drill.js"></script>
<script>
const ORIGIN = location.origin;
let ALL = [], FACETS = {}, SEARCH = '';
@@ -138,6 +148,67 @@ const grid = document.getElementById('grid');
const fmtDate = (iso) => { try { return new Date(iso).toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); } catch { return iso; } };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&':'&','<':'<','>':'>','"':'"' }[c]));
+/* ── href-to-deeper-data shim (adapts /drill.js to Gracie's Set-per-facet model) ──
+ drill.js (drill/setF) speaks a single-value FIL[k] model. Gracie filters are
+ multi-select Sets persisted to localStorage. This shim exposes the globals
+ drill.js needs (FIELDS, FIL, $, applyFilters) mapped onto that Set model, and
+ OVERRIDES qstr/readURL/writeURL so each facet Set round-trips through the URL as
+ a comma-joined param. drill(field,val) → an <a href="?field=val">; a plain click
+ drills IN (sets that facet's Set to exactly {val}); URL is address-shareable. */
+const $ = (s) => document.querySelector(s);
+// URL param -> state Set key + value<->product accessor (mirrors facetMatch).
+const DRILL = {
+ type: { set: 'types', values: (p) => [p.product_type] },
+ series: { set: 'series', values: (p) => [p.collection] },
+ color: { set: 'colors', values: (p) => productFamilies(p) },
+ style: { set: 'styles', values: (p) => (p.styles || []) },
+ material: { set: 'materials', values: (p) => [p.material || 'Unspecified'] },
+ tag: { set: 'tags', values: (p) => (p.tags || []) },
+};
+// rail rowId -> drill param (only the drillable, product-facet rows; price/image are derived)
+const ROW_FIELD = { typeRow: 'type', seriesRow: 'series', colorRow: 'color', styleRow: 'style', matRow: 'material', tagRow: 'tag' };
+const FIELDS = Object.keys(DRILL).map((k) => [k]); // drill.js iterates [[k],…]
+const FIL = {}; // scalar view drill.js reads
+// keep FIL in sync FROM the Sets (first selected value is the scalar drill.js sees)
+function syncFIL() { for (const k of Object.keys(DRILL)) { const s = state[DRILL[k].set]; FIL[k] = s.size ? [...s][0] : ''; } }
+// OVERRIDE drill.js URL helpers to serialize every facet Set (comma-joined) + search.
+window.qstr = function (fil, q) {
+ const u = new URLSearchParams();
+ for (const k of Object.keys(DRILL)) {
+ // when this call passes a scalar override for k (a drill click), honor it; else the Set
+ const ov = (fil && Object.prototype.hasOwnProperty.call(fil, k) && fil[k] !== FIL[k]) ? fil[k] : null;
+ const vals = ov != null && ov !== '' ? [ov] : [...state[DRILL[k].set]];
+ if (vals.length) u.set(k, vals.join(','));
+ }
+ if (q) u.set('q', q);
+ const s = u.toString(); return s ? ('?' + s) : location.pathname;
+};
+window.readURL = function () {
+ const u = new URLSearchParams(location.search);
+ for (const k of Object.keys(DRILL)) {
+ const set = state[DRILL[k].set]; set.clear();
+ const raw = u.get(k); if (raw) raw.split(',').filter(Boolean).forEach((v) => set.add(v));
+ }
+ SEARCH = (u.get('q') || '').toLowerCase();
+ $('#search').value = u.get('q') || '';
+ persistFacets(); syncFIL();
+};
+window.writeURL = function (push) {
+ const url = window.qstr(FIL, $('#search').value.trim());
+ history[push ? 'pushState' : 'replaceState']({}, '', url);
+};
+// drill.js's drill()/setF() call these globals; applyFilters is Gracie's render+URL step.
+function applyFilters(nav) { syncFIL(); renderFacets(); render(); if (nav !== 'none') window.writeURL(nav === 'push'); }
+// setF from drill.js sets FIL[k]=v then applyFilters('push'); we intercept to drill INTO the Set.
+window.setF = function (k, v) { const cfg = DRILL[k]; if (!cfg) return; const set = state[cfg.set]; set.clear(); set.add(String(v)); persistFacets(); applyFilters('push'); };
+// OVERRIDE drill.js's drill(): its built-in reads $('#q')/$('#pattern') (absent here) —
+// this variant uses window.qstr (Set-aware) + Gracie's #search, same <a class="drill"> output.
+window.drill = function (field, val, label, cls) {
+ if (val == null || val === '') return '';
+ const href = window.qstr({ ...FIL, [field]: String(val) }, $('#search').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>`;
+};
+
/* ── filter state — one Set per facet dimension, persisted to localStorage ── */
const loadSet = (k) => { try { return new Set(JSON.parse(localStorage.getItem(k) || '[]')); } catch { return new Set(); } };
const state = {
@@ -185,8 +256,11 @@ async function boot() {
fetch(ORIGIN + '/api/facets').then(r => r.json()),
]);
ALL = p.products; FACETS = f;
+ readURL(); // deep-link / shared filter state → Sets + search box
renderCardFields(); renderFacets(); render();
}
+// back/forward re-reads the URL into the filter state (no new history entry).
+addEventListener('popstate', () => { readURL(); renderFacets(); render(); });
/* does product p match value val on facet dimension key? */
function facetMatch(p, key, val) {
@@ -234,7 +308,7 @@ function productFamilies(p){
}
/* ── the ported facet list: checkbox rows + counts, type-ahead on big rows, cap/expand ── */
-function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); persistFacets(); renderFacets(); render(); }
+function toggleSet(set, val) { set.has(val) ? set.delete(val) : set.add(val); persistFacets(); syncFIL(); renderFacets(); render(); writeURL(true); }
function facetList(rowId, counts, set, opts = {}) {
const row = document.getElementById(rowId); row.innerHTML = '';
@@ -256,12 +330,17 @@ function facetList(rowId, counts, set, 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:var(--mut);padding:2px'; s.textContent = 'none'; row.appendChild(s); return; }
+ // drill.js param name for this rail row (a facet row's count/value → a real href).
+ const field = ROW_FIELD[rowId] || null;
shown.forEach(([val, ct]) => {
- const el = document.createElement('div');
+ // real ?field=value link when this dimension is drillable; else a plain div (unchanged).
+ const el = document.createElement(field ? 'a' : 'div');
el.className = 'fitem' + (set.has(val) ? ' on' : '') + (ct ? '' : ' zero');
+ if (field) { el.href = qstr({ ...FIL, [field]: String(val) }, $('#search').value.trim()); el.dataset.filter = field; el.dataset.v = val; }
const dot = opts.swatch && opts.swatch[val] ? `<span class="dot" style="background:${esc(opts.swatch[val])}"></span>` : '';
el.innerHTML = `<span class="box">${set.has(val) ? '✓' : ''}</span>${dot}<span class="lbl" title="${esc(val)}">${esc(val)}</span><span class="ct">${ct.toLocaleString()}</span>`;
- el.onclick = () => toggleSet(set, val);
+ // plain click keeps Gracie's multi-select TOGGLE; a modified click follows the real href.
+ el.onclick = (e) => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return; e.preventDefault(); toggleSet(set, val); };
row.appendChild(el);
});
if (hidden > 0) {
@@ -302,13 +381,13 @@ function renderActive() {
n++;
const el = document.createElement('span'); el.className = 'afilter';
el.innerHTML = `<b>${label}:</b> ${esc(v)} ✕`;
- el.onclick = () => { set.delete(v); persistFacets(); renderFacets(); render(); };
+ el.onclick = () => { set.delete(v); persistFacets(); syncFIL(); renderFacets(); render(); writeURL(true); };
row.appendChild(el);
});
}
if (n) {
const c = document.createElement('button'); c.className = 'clearall'; c.textContent = 'Clear all';
- c.onclick = () => { dims.forEach(([, s]) => s.clear()); persistFacets(); renderFacets(); render(); };
+ c.onclick = () => { dims.forEach(([, s]) => s.clear()); persistFacets(); syncFIL(); renderFacets(); render(); writeURL(true); };
row.appendChild(c);
}
}
@@ -352,13 +431,21 @@ function render() {
const flags = [];
if (p.settlement_flag && p.settlement_flag !== 'OK') flags.push(`<span class="flag gate">gate: ${esc(p.settlement_flag)}</span>`);
if (p.enriched) flags.push(`<span class="flag enr">enriched</span>`);
- const sw = p.color_hex ? `<span class="swatch" style="background:${esc(p.color_hex)}"></span>` : '';
- const tags = (p.tags || []).slice(0, 5).map((t) => `<span class="tag">${esc(t)}</span>`).join('');
+ // color-family drill: the swatch dot stays OUTSIDE the anchor (drill() escapes its
+ // label), and the family NAME becomes the drill link to all patterns in that hue.
+ const fam = productFamilies(p)[0];
+ const swDot = p.color_hex ? `<span class="swatch" style="background:${esc(p.color_hex)}"></span>` : '';
+ const famLnk = fam ? drill('color', fam, fam, 'fam') : '';
+ // Book/Series (p.collection) is the `series` facet → drill to that collection
+ const coll = p.collection ? drill('series', p.collection, p.collection) : '';
+ // Tags are the `tag` facet → each chip drills to that tag
+ const tags = (p.tags || []).slice(0, 5).map((t) => `<span class="tag">${drill('tag', t, t)}</span>`).join('');
card.innerHTML = `
<div class="imgwrap">${img}</div>
<div class="body">
<div class="ptn">${esc(p.pattern_name)}</div>
- <div class="meta">${sw}${esc(p.mfr_sku)} · ${esc(p.collection)}</div>
+ <div class="meta">${swDot}${esc(p.mfr_sku)}${coll ? ' · ' + coll : ''}</div>
+ <div class="famrow">${famLnk}</div>
<div class="flags">${flags.join('')}</div>
<div class="tags">${tags}</div>
<div class="when" title="${esc(p.created_at)}">🕓 ${fmtDate(p.created_at)}</div>
@@ -373,6 +460,15 @@ function render() {
grid.appendChild(frag);
}
+/* ── drill atom click: plain click filters in place; cmd/ctrl/shift/middle-click
+ keeps the real href (open the filtered view in a new tab / share). ── */
+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;
+ e.preventDefault(); e.stopPropagation();
+ setF(d.dataset.filter, d.dataset.v); // drill IN to this exact value (from /drill.js)
+});
+
/* ── Purchasing actions on each card: Memo / Stock / Price → /api/request ── */
grid.addEventListener('click', async (e) => {
const btn = e.target.closest('.act'); if (!btn) return;
@@ -397,7 +493,8 @@ function toast(msg, ok) {
document.getElementById('sort').onchange = (e) => { localStorage.gracieSort = e.target.value; render(); };
document.getElementById('density').oninput = (e) => { document.documentElement.style.setProperty('--cardmin', e.target.value + 'px'); localStorage.gracieDensity = e.target.value; };
document.getElementById('imgonly').onchange = (e) => { document.body.classList.toggle('imgonly', e.target.checked); localStorage.gracieImgOnly = e.target.checked ? '1' : ''; };
-document.getElementById('search').oninput = (e) => { SEARCH = e.target.value.trim().toLowerCase(); render(); };
+let searchT = null;
+document.getElementById('search').oninput = (e) => { SEARCH = e.target.value.trim().toLowerCase(); render(); clearTimeout(searchT); searchT = setTimeout(() => writeURL(false), 300); };
/* restore prefs */
if (localStorage.gracieDensity) { document.getElementById('density').value = localStorage.gracieDensity; document.documentElement.style.setProperty('--cardmin', localStorage.gracieDensity + 'px'); }
if (localStorage.gracieImgOnly) { document.getElementById('imgonly').checked = true; document.body.classList.add('imgonly'); }
← 69f55eb gracie-internal: add Memo/Stock/Price chip actions (shared v
·
back to Gracie Internal
·
nav-agent: universal grid-controls drop-in on internal viewe eb41ebc →