const IDLE_DELAY = 7_000; const TRAIL_DURATION = 6_500; export function createIdlePaws(element, getArea) { const layer = document.createElement('div'); layer.className = 'terminal-paws'; layer.hidden = true; layer.setAttribute('aria-hidden', 'true'); for (let i = 0; i < 6; i++) layer.append(document.createElement('i')); element.append(layer); const reducedMotion = matchMedia('(prefers-reduced-motion: reduce)'); const listeners = new AbortController(); let enabled = false, visible = false, disposed = false; let timer = 0, trailTimer = 0, wakeAt = 0, nextAt = 0; function canRun() { return enabled && visible && !disposed && !document.hidden && document.hasFocus() && !reducedMotion.matches && !document.querySelector('dialog[open]'); } function hide() { clearTimeout(trailTimer); trailTimer = 0; if (!layer.hidden) layer.hidden = true; } function schedule() { if (!canRun()) { clearTimeout(timer); timer = 0; return; } // Reuse the pending timer when repeated input moves the deadline later. if (timer && wakeAt <= nextAt) return; clearTimeout(timer); wakeAt = nextAt; timer = setTimeout(tick, Math.max(0, nextAt - Date.now())); } function reset() { hide(); nextAt = Date.now() + IDLE_DELAY; schedule(); } function tick() { timer = 0; if (!canRun()) return; if (Date.now() < nextAt) { schedule(); return; } const area = getArea(); if (area && area.width >= 220 && area.height >= 180) { Object.assign(layer.style, { left: `${area.left}px`, top: `${area.top}px`, width: `${area.width}px`, height: `${area.height}px`, }); layer.style.setProperty('--trail-shift', `${Math.random() * 10}%`); layer.hidden = false; trailTimer = setTimeout(hide, TRAIL_DURATION); } nextAt = Date.now() + TRAIL_DURATION + 45_000 + Math.random() * 15_000; schedule(); } const options = { passive: true, capture: true, signal: listeners.signal }; for (const event of ['pointerdown', 'keydown', 'input', 'wheel', 'scroll', 'touchstart', 'touchmove', 'focusin']) { document.addEventListener(event, reset, options); } document.addEventListener('visibilitychange', reset, options); window.addEventListener('blur', reset, options); window.addEventListener('focus', reset, options); reducedMotion.addEventListener('change', reset, options); const dialogs = new MutationObserver(reset); for (const dialog of document.querySelectorAll('dialog')) dialogs.observe(dialog, { attributes: true, attributeFilter: ['open'] }); const visibility = new IntersectionObserver(([entry]) => { visible = entry.isIntersecting; reset(); }); visibility.observe(element); return { reset, setEnabled(value) { if (value !== enabled) { enabled = value; reset(); } }, dispose() { disposed = true; hide(); clearTimeout(timer); listeners.abort(); dialogs.disconnect(); visibility.disconnect(); layer.remove(); }, }; }