/* ============================================================ CODEX OBSCURA — main.js Dust motes drifting in candlelight. ============================================================ */ (function () { 'use strict'; document.documentElement.classList.remove('no-js'); document.documentElement.classList.add('js'); /* ── Uptime Counter ──────────────────────────────────────── */ const uptimeEl = document.getElementById('js-uptime'); if (uptimeEl) { const startTime = Date.now(); function updateUptime() { const elapsed = Math.floor((Date.now() - startTime) / 1000); const h = String(Math.floor(elapsed / 3600)).padStart(2, '0'); const m = String(Math.floor((elapsed % 3600) / 60)).padStart(2, '0'); const s = String(elapsed % 60).padStart(2, '0'); uptimeEl.textContent = h + ':' + m + ':' + s; } setInterval(updateUptime, 1000); updateUptime(); } /* ── Keyboard shortcuts ──────────────────────────────────── */ document.addEventListener('keydown', function (e) { if (e.key === '/' && e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { const search = document.getElementById('search-input'); if (search) { e.preventDefault(); search.focus(); } } if (e.key === 'Escape' && document.activeElement) { document.activeElement.blur(); } }); /* ── Dust Motes ──────────────────────────────────────────── */ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return; const canvas = document.createElement('canvas'); canvas.style.cssText = [ 'position:absolute', 'top:0', 'left:0', 'width:100%', 'height:100%', 'pointer-events:none', 'z-index:2' ].join(';'); document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); let W = document.body.scrollWidth; let H = document.body.scrollHeight; canvas.width = W; canvas.height = H; window.addEventListener('resize', function () { W = canvas.width = document.body.scrollWidth; H = canvas.height = document.body.scrollHeight; }); // Warm amber/parchment dust colours const COLORS = [ '212, 162, 40', // amber '201, 176, 122', // parchment warm '196, 96, 28', // orange '138, 96, 48', // umber '232, 213, 163', // parchment light ' 78, 120, 48', // forest ]; const MOTE_COUNT = 500; function rand(min, max) { return min + Math.random() * (max - min); } function makeMote(scatterY) { // Each mote has its own independent fade cycle driven by a sine wave // so they breathe in and out at different rates rather than all // appearing and disappearing together. const peakAlpha = rand(0.06, 0.22); return { x: rand(0, W), y: scatterY !== undefined ? scatterY : rand(0, H), // very slow drift — tiny random velocity vx: rand(-0.12, 0.12), vy: rand(-0.08, 0.06), // slight upward bias like warm air // size: 1–2.5px radius, soft and small r: rand(1.2, 2.4), color: COLORS[Math.floor(Math.random() * COLORS.length)], peakAlpha, // sine-wave phase and speed for opacity breathing phase: rand(0, Math.PI * 2), phaseSpeed: rand(0.003, 0.009), // full breath every ~12–35 seconds // gentle Brownian wobble wobbleX: rand(0, Math.PI * 2), wobbleY: rand(0, Math.PI * 2), wobbleSpeedX: rand(0.002, 0.007), wobbleSpeedY: rand(0.002, 0.006), wobbleAmp: rand(0.04, 0.18), // pixels of wobble per frame }; } // Seed motes scattered across the whole viewport let motes = Array.from({ length: MOTE_COUNT }, function () { return makeMote(rand(0, H)); }); function animate() { ctx.clearRect(0, 0, W, H); const now = performance.now() * 0.001; // seconds, for wobble for (let i = 0; i < motes.length; i++) { const m = motes[i]; // Advance fade phase m.phase += m.phaseSpeed; // Opacity: sine wave between 0 and peakAlpha, always non-negative const alpha = m.peakAlpha * (0.5 + 0.5 * Math.sin(m.phase)); // Drift position m.x += m.vx + Math.sin(m.wobbleX) * m.wobbleAmp; m.y += m.vy + Math.cos(m.wobbleY) * m.wobbleAmp * 0.6; // Advance wobble phases independently of main phase m.wobbleX += m.wobbleSpeedX; m.wobbleY += m.wobbleSpeedY; // Wrap around edges with a small buffer so motes never pop in visibly const buf = 4; if (m.x < -buf) m.x = W + buf; if (m.x > W + buf) m.x = -buf; if (m.y < -buf) m.y = H + buf; if (m.y > H + buf) m.y = -buf; // Draw as a soft radial-gradient circle (blurred disc, not a hard pixel) const grad = ctx.createRadialGradient(m.x, m.y, 0, m.x, m.y, m.r * 2.5); grad.addColorStop(0, 'rgba(' + m.color + ', ' + alpha + ')'); grad.addColorStop(0.5, 'rgba(' + m.color + ', ' + (alpha * 0.4) + ')'); grad.addColorStop(1, 'rgba(' + m.color + ', 0)'); ctx.beginPath(); ctx.arc(m.x, m.y, m.r * 2.5, 0, Math.PI * 2); ctx.fillStyle = grad; ctx.fill(); } requestAnimationFrame(animate); } animate(); })();