init-capture
/* init-capture — sovereign interaction capture. * * ONE delegated listener at the document (capture phase) catches every * interaction anywhere on the page — click / submit / meaningful keydown — * and beacons a compact event to /api/capture, which writes it as an * `interaction` Thing attributed to the being (incl. anonymous Traveler, * resolved/created server-side). Queryable via /api/things?sapphire=interaction. * * NOT captured: scroll, mousemove, pointermove (passive noise). This records * intent, not eyeballs. */ (function () { 'use strict'; // Cluster interactions into ~30s windows: one flush = one subu-sized cluster // Thing (not one Thing per event). MAX is just a payload safety cap. var buf = [], timer = null, MAX = 100, FLUSH_MS = 30000;
function descriptor(el) { if (!el || !el.closest) return {}; // Walk up to the nearest meaningful target. var t = el.closest('[data-thing],[data-id],[data-chip],button,a,.rb2-key,.bp-item,[role="button"]') || el; var cls = t.className; cls = (cls && cls.baseVal !== undefined) ? cls.baseVal : (cls || ''); return { thing: (t.getAttribute && (t.getAttribute('data-thing') || t.getAttribute('data-id'))) || null, chip: (t.getAttribute && t.getAttribute('data-chip')) || null, id: t.id || null, tag: (t.tagName || '').toLowerCase(), cls: String(cls).trim().slice(0, 64), text: (t.textContent || '').trim().replace(/\s+/g, ' ').slice(0, 48) }; }
function push(kind, el) { buf.push({ kind: kind, t: Date.now(), path: location.pathname, target: descriptor(el) }); if (buf.length >= MAX) flush(); else if (!timer) timer = setTimeout(flush, FLUSH_MS); }
function flush() { if (timer) { clearTimeout(timer); timer = null; } if (!buf.length) return; var batch = buf.splice(0, buf.length); try { var blob = new Blob([JSON.stringify({ events: batch })], { type: 'application/json' }); navigator.sendBeacon('/api/capture', blob); } catch (e) { /* fire-and-forget */ } }
document.addEventListener('click', function (e) { push('click', e.target); }, true); document.addEventListener('submit', function (e) { push('submit', e.target); }, true); document.addEventListener('keydown', function (e) { var el = e.target; if (el && /^(input|textarea|select)$/i.test(el.tagName)) return; // don't log typing if (e.metaKey || e.ctrlKey || e.altKey || e.key.length === 1 || e.key.indexOf('Arrow') === 0 || e.key === 'Enter' || e.key === 'Escape' || e.key === ' ') { push('key:' + e.key, el); } }, true);
// Scroll — throttled to scroll-stop and recorded as depth %, not every tick. // Only logs when depth moves ≥10% so it's reading progress, not a firehose. var lastDepth = -1, scrollTimer = null; window.addEventListener('scroll', function () { if (scrollTimer) return; scrollTimer = setTimeout(function () { scrollTimer = null; var max = (document.documentElement.scrollHeight || 0) - window.innerHeight; var depth = max > 0 ? Math.round((window.scrollY / max) * 100) : 0; if (Math.abs(depth - lastDepth) >= 5) { lastDepth = depth; push('scroll', document.scrollingElement || document.documentElement); var ev = buf[buf.length - 1]; if (ev) ev.depth = depth; } }, 400); }, { passive: true });
// Media interaction — video/audio events don't bubble, so listen in the // CAPTURE phase at document. Records the moment (currentTime) + duration. ['play', 'pause', 'ended', 'seeked'].forEach(function (type) { document.addEventListener(type, function (e) { var v = e.target; if (!v || (v.tagName !== 'VIDEO' && v.tagName !== 'AUDIO')) return; push('media:' + type, v); var ev = buf[buf.length - 1]; if (ev) { ev.at = Math.round(v.currentTime || 0); ev.dur = Math.round(v.duration || 0); } }, true); });
// Flush on the way out so nothing is lost. window.addEventListener('pagehide', flush); document.addEventListener('visibilitychange', function () { if (document.visibilityState === 'hidden') flush(); }); })();