[object Object]

← back to Visual Factory

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

14fabff13e60154d399b0f84b917e8d22267a9b5 · 2026-08-01 20:38:06 -0700 · Steve Abrams

Files touched

Diff

commit 14fabff13e60154d399b0f84b917e8d22267a9b5
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 1 20:38:06 2026 -0700

    visual-factory: adopt href-to-deeper-data primitives (TK-10093)
---
 public/drill.js   |  36 +++++++++++++++++++
 public/index.html | 105 +++++++++++++++++++++++++++++++++++++++++++++++-------
 2 files changed, 128 insertions(+), 13 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 670c12f..2cd42e0 100644
--- a/public/index.html
+++ b/public/index.html
@@ -36,6 +36,12 @@
   .tab:hover { color: var(--text); }
   .tab.active { background: var(--accent); color: #1b1b1b; border-color: var(--accent); font-weight: 600; }
   .tab .count { opacity: 0.65; margin-left: 6px; font-size: 10px; }
+  a.tab { text-decoration: none; }
+  /* drill atom — every on-card data point is an href to its filtered view */
+  .drill { color: inherit; text-decoration: none; cursor: pointer; border-bottom: 1px dotted transparent; }
+  .drill:hover { color: var(--accent); border-bottom-color: var(--accent); }
+  .meta .status.drill:hover, .meta .drill:hover { color: var(--accent); }
+  .drill.dombadge { color: var(--accent); }
 
   .grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; }
   .card { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; overflow: hidden; cursor: pointer; transition: border-color 0.15s; }
@@ -127,13 +133,69 @@
   </div>
 </div>
 
+<!-- href-to-deeper-data primitives (shared atoms; app-specific adapter is inline below) -->
+<script src="/drill.js"></script>
 <script>
 // Same-origin: the orchestrator now serves this page, so paths are relative.
 const ORCH = '';
 const STAGE_NAMES = ['queued','intake','compose','render','vision_check','critic','iterate','catalog','activate'];
 let currentFilter = localStorage.getItem('vf:filter') || 'all';
+let domainFilter = '';        // '' = all domains
 let lastRuns = [];
 
+// ── href-to-deeper-data (TK-10093) ───────────────────────────────────────────
+// This is a run/pipeline board: the FILTER MODEL is the status-bucket tabs, and
+// each card carries two drillable data points — its `domain` and its `status`.
+// The shared /drill.js atoms want classic-script globals (FIELDS/FIL/$/esc/
+// applyFilters + qstr/readURL/writeURL); we provide a THIN adapter mapping them
+// onto this board's currentFilter (tab bucket, url param `f`) + domainFilter
+// (url param `domain`), so tabs/status/domain are real, shareable <a href>
+// deep-links and the URL always reflects the active filter (deep-link + back
+// button restore it).
+const $ = s => document.querySelector(s);
+const esc = escapeHtml;                                   // defined later; hoisted fn
+const FIELDS = [['f','Status'],['domain','Domain']];
+const FIL = { f: '', domain: '' };
+function qstr(fil) {
+  const u = new URLSearchParams();
+  if (fil.f && fil.f !== 'all') u.set('f', fil.f);
+  if (fil.domain) u.set('domain', fil.domain);
+  const s = u.toString();
+  return s ? ('?' + s) : location.pathname;
+}
+// drill(field,val,label,cls) → an <a> whose href IS the filtered view for that value.
+function drill(field, val, label, cls) {
+  if (val == null || val === '') return '';
+  const href = qstr({ ...FIL, [field]: String(val) });
+  return `<a class="drill ${cls || ''}" href="${href}" data-filter="${field}" data-v="${escapeHtml(String(val))}" title="Filter to ${escapeHtml(String(val))}">${label != null ? escapeHtml(String(label)) : escapeHtml(String(val))}</a>`;
+}
+function readURL() {
+  const u = new URLSearchParams(location.search);
+  currentFilter = u.get('f') || 'all';
+  domainFilter = u.get('domain') || '';
+  FIL.f = currentFilter; FIL.domain = domainFilter;
+}
+function writeURL(push) {
+  FIL.f = currentFilter; FIL.domain = domainFilter;
+  history[push ? 'pushState' : 'replaceState']({}, '', qstr(FIL));
+}
+// applyFilters(nav) — reflect state → URL + repaint tabs/grid.
+function applyFilters(nav) {
+  FIL.f = currentFilter; FIL.domain = domainFilter;
+  if (nav !== 'none') writeURL(nav === 'push');
+  renderTabs(lastRuns);
+  renderGrid(lastRuns);
+}
+// setF drill target used by delegated atom clicks.
+function setF(k, v) {
+  if (k === 'f') { currentFilter = String(v); localStorage.setItem('vf:filter', currentFilter); }
+  else if (k === 'domain') { domainFilter = domainFilter === String(v) ? '' : String(v); }
+  applyFilters('push');
+}
+window.qstr = qstr; window.drill = drill; window.readURL = readURL; window.writeURL = writeURL;
+window.setF = setF; window.esc = esc; window.applyFilters = applyFilters;
+window.FIELDS = FIELDS; window.FIL = FIL;
+
 // Bucket a run into a UI tab. Server statuses are messier than the user
 // thinks about them — collapse here so the tabs stay simple.
 function bucket(status) {
@@ -160,31 +222,32 @@ function renderTabs(runs) {
     const b = bucket(r.status);
     if (b in counts) counts[b]++;
   }
+  // Each tab is a real <a href> to its filtered view (drill atom on field `f`).
   document.getElementById('tabs').innerHTML = TABS.map(t =>
-    `<span class="tab ${currentFilter===t.id?'active':''}" data-filter="${t.id}">${t.label}<span class="count">${counts[t.id]||0}</span></span>`
+    `<a class="tab drill ${currentFilter===t.id?'active':''}" data-filter="f" data-v="${t.id}" href="${qstr({ ...FIL, f: t.id })}">${t.label}<span class="count">${counts[t.id]||0}</span></a>`
   ).join('');
-  document.querySelectorAll('.tab').forEach(el => {
-    el.addEventListener('click', () => {
-      currentFilter = el.dataset.filter;
-      localStorage.setItem('vf:filter', currentFilter);
-      renderTabs(lastRuns);
-      renderGrid(lastRuns);
-    });
-  });
+  // domain-filter indicator: a clearable pill when a domain drill is active.
+  if (domainFilter) {
+    document.getElementById('tabs').insertAdjacentHTML('beforeend',
+      `<a class="tab drill active" data-filter="domain" data-v="${escapeHtml(domainFilter)}" href="${qstr({ ...FIL, domain: '' })}" title="Clear domain filter">domain: ${escapeHtml(domainFilter)} ✕</a>`);
+  }
 }
 
 function renderGrid(runs) {
   const grid = document.getElementById('grid');
-  const filtered = currentFilter === 'all' ? runs : runs.filter(r => bucket(r.status) === currentFilter);
-  if (!filtered.length) { grid.innerHTML = `<div class="empty">No runs in &ldquo;${currentFilter}&rdquo;.</div>`; return; }
+  const filtered = runs.filter(r =>
+    (currentFilter === 'all' || bucket(r.status) === currentFilter) &&
+    (!domainFilter || r.domain === domainFilter));
+  if (!filtered.length) { grid.innerHTML = `<div class="empty">No runs in &ldquo;${escapeHtml(currentFilter)}&rdquo;${domainFilter ? ' · ' + escapeHtml(domainFilter) : ''}.</div>`; return; }
   grid.innerHTML = filtered.map(r => {
     const status = String(r.status || '');
     const canActivate = status.startsWith('awaiting_activation');
     const canRetry = /^(failed|completed_unapproved|unapproved)/.test(status);
     const isRunning = status === 'running' || status === 'pending';
     const dimText = `${r.width||'?'}×${r.height||'?'}`;
+    // domain badge is a drill atom — click filters the board to that domain.
     const domainBadge = r.domain
-      ? `<span style="color:var(--accent);font-size:10px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase">${escapeHtml(r.domain)}</span>`
+      ? `<span style="font-size:10px;font-weight:600;letter-spacing:0.06em;text-transform:uppercase">${drill('domain', r.domain, r.domain, 'dombadge')}</span>`
       : '';
     const subline = r.purpose ? escapeHtml(r.purpose) : escapeHtml(r.brief || '');
     const vMatch = status.match(/^awaiting_activation_v(\d+)$/);
@@ -215,7 +278,7 @@ function renderGrid(runs) {
           <div class="brief">${subline}</div>
           ${badges.length ? `<div class="badges">${badges.join('')}</div>` : ''}
           ${errsnip}
-          <div class="row"><span class="status ${status}">${escapeHtml(status)}</span></div>
+          <div class="row"><span class="status ${status}">${drill('f', bucket(status), status)}</span></div>
           ${actions.length ? `<div class="actions-row">${actions.join('')}</div>` : ''}
         </div>
       </div>`;
@@ -276,6 +339,21 @@ function renderGrid(runs) {
   });
 }
 
+// Delegated drill atom clicks (bound ONCE). Capture phase + stopPropagation so a
+// drill on `domain`/`status` inside a card filters the board WITHOUT also opening
+// the card's modal. Modifier / middle-click keeps the real href (open in new tab).
+function wireDrill(container) {
+  container.addEventListener('click', e => {
+    const a = e.target.closest('a.drill'); if (!a || !container.contains(a)) return;
+    if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button === 1) return;
+    e.preventDefault(); e.stopPropagation();
+    setF(a.dataset.filter, a.dataset.v);
+  }, true);
+}
+wireDrill(document.getElementById('grid'));
+wireDrill(document.getElementById('tabs'));
+addEventListener('popstate', () => { readURL(); applyFilters('none'); });
+
 // Codex P2 #14 fix: cancel an in-flight loadRuns / openModal before issuing a
 // new one, so an older slow response can't overwrite newer state when the user
 // switches filters fast or the 2s modal poll laps the previous request.
@@ -415,6 +493,7 @@ async function refreshModal() {
 }
 setInterval(refreshModal, 2000);
 
+readURL();          // deep-link / back-button: hydrate currentFilter + domainFilter before first paint
 loadRuns();
 loadHealth();
 setInterval(loadHealth, 30_000);

← 26fb67d refactor: add .bak/.pre-/.orig 404 guard + broaden .gitignor  ·  back to Visual Factory  ·  auto-data-snapshot: 2026-09-23T14:48:42 (2 data files) — .en b533446 →