// INITIALIZATION // =============================== let navigationEpoch = 0; let _optimisticNavPageId = null; // Schedule heavy per-page init during browser idle time so navigation paints and // the page becomes scrollable first. timeout caps the delay so content still loads // promptly. Falls back to a macrotask in browsers without requestIdleCallback. function _scheduleHeavyInit(fn) { if (typeof requestIdleCallback === 'function') { requestIdleCallback(fn, { timeout: 200 }); } else { setTimeout(fn, 0); } } function notifyPageWillChange(nextPageId) { const fromPageId = typeof currentPage === 'string' ? currentPage : null; if (fromPageId === nextPageId) return; window.dispatchEvent( new CustomEvent(PAGE_WILL_CHANGE_EVENT, { detail: { fromPageId, toPageId: nextPageId, }, }), ); } // ---- Accent Color System ---- function getAccentFallbackColors() { let accent = localStorage.getItem('soulsync-accent') || '#1db954'; if (!/^#[0-9a-fA-F]{6}$/.test(accent)) accent = '#1db954'; // Compute a lighter variant for the second color const r = parseInt(accent.slice(1, 3), 16), g = parseInt(accent.slice(3, 5), 16), b = parseInt(accent.slice(5, 7), 16); const lighter = '#' + [Math.min(r + 20, 255), Math.min(g + 30, 255), Math.min(b + 12, 255)] .map(v => v.toString(16).padStart(2, '0')).join(''); return [accent, lighter]; } function applyAccentColor(hex) { // Validate hex format — reject corrupt values if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) { hex = '#1db954'; // fallback to default } // Convert hex to RGB const r = parseInt(hex.slice(1, 3), 16); const g = parseInt(hex.slice(3, 5), 16); const b = parseInt(hex.slice(5, 7), 16); // Convert RGB to HSL const rn = r / 255, gn = g / 255, bn = b / 255; const max = Math.max(rn, gn, bn), min = Math.min(rn, gn, bn); const l = (max + min) / 2; let h = 0, s = 0; if (max !== min) { const d = max - min; s = l > 0.5 ? d / (2 - max - min) : d / (max + min); if (max === rn) h = ((gn - bn) / d + (gn < bn ? 6 : 0)) / 6; else if (max === gn) h = ((bn - rn) / d + 2) / 6; else h = ((rn - gn) / d + 4) / 6; } // Compute light variant: +16% lightness const lightL = Math.min(l + 0.16, 0.95); // Compute neon variant: high lightness + boosted saturation const neonL = Math.min(l + 0.30, 0.95); const neonS = Math.min(s + 0.1, 1.0); function hslToRgb(h, s, l) { if (s === 0) { const v = Math.round(l * 255); return [v, v, v]; } const hue2rgb = (p, q, t) => { if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1 / 6) return p + (q - p) * 6 * t; if (t < 1 / 2) return q; if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6; return p; }; const q = l < 0.5 ? l * (1 + s) : l + s - l * s; const p = 2 * l - q; return [Math.round(hue2rgb(p, q, h + 1 / 3) * 255), Math.round(hue2rgb(p, q, h) * 255), Math.round(hue2rgb(p, q, h - 1 / 3) * 255)]; } const light = hslToRgb(h, s, lightL); const neon = hslToRgb(h, neonS, neonL); const root = document.documentElement.style; root.setProperty('--accent-rgb', `${r}, ${g}, ${b}`); root.setProperty('--accent-light-rgb', `${light[0]}, ${light[1]}, ${light[2]}`); root.setProperty('--accent-neon-rgb', `${neon[0]}, ${neon[1]}, ${neon[2]}`); // Store for instant restore on next page load localStorage.setItem('soulsync-accent', hex); // Update preview swatch if it exists const swatch = document.getElementById('accent-preview-swatch'); if (swatch) swatch.style.background = hex; } function applyParticlesSetting(enabled) { const canvas = document.getElementById('page-particles-canvas'); if (canvas) canvas.style.display = enabled ? '' : 'none'; if (window.pageParticles) { if (enabled) { const activePage = document.querySelector('.page.active'); if (activePage) { window.pageParticles.setPage(activePage.id.replace('-page', '')); } } else { window.pageParticles.stop(); } } window._particlesEnabled = enabled; localStorage.setItem('soulsync-particles', String(enabled)); } function applyWorkerOrbsSetting(enabled) { window._workerOrbsEnabled = enabled; localStorage.setItem('soulsync-worker-orbs', String(enabled)); if (window.workerOrbs) { if (enabled) { const activePage = document.querySelector('.page.active'); if (activePage && activePage.id === 'dashboard-page') { window.workerOrbs.setPage('dashboard'); } } else { window.workerOrbs.setPage('_disabled'); } } } function initAccentColorListeners() { const presetSelect = document.getElementById('accent-preset'); const customGroup = document.getElementById('custom-color-group'); const customPicker = document.getElementById('accent-custom-color'); if (!presetSelect) return; presetSelect.addEventListener('change', () => { const val = presetSelect.value; if (val === 'custom') { if (customGroup) customGroup.style.display = ''; if (customPicker) applyAccentColor(customPicker.value); } else { if (customGroup) customGroup.style.display = 'none'; applyAccentColor(val); } }); if (customPicker) { customPicker.addEventListener('input', () => { applyAccentColor(customPicker.value); }); } // Particles toggle — apply immediately on change const particlesCheckbox = document.getElementById('particles-enabled'); if (particlesCheckbox) { particlesCheckbox.addEventListener('change', () => { applyParticlesSetting(particlesCheckbox.checked); }); } // Worker orbs toggle — apply immediately on change const workerOrbsCheckbox = document.getElementById('worker-orbs-enabled'); if (workerOrbsCheckbox) { workerOrbsCheckbox.addEventListener('change', () => { applyWorkerOrbsSetting(workerOrbsCheckbox.checked); }); } // Reduce effects toggle — apply immediately on change const reduceEffectsCheckbox = document.getElementById('reduce-effects-enabled'); if (reduceEffectsCheckbox) { reduceEffectsCheckbox.addEventListener('change', () => { applyReduceEffects(reduceEffectsCheckbox.checked); }); } // Max Performance toggle — apply immediately on change const maxPerfCheckbox = document.getElementById('max-performance-enabled'); if (maxPerfCheckbox) { maxPerfCheckbox.addEventListener('change', () => { applyMaxPerformance(maxPerfCheckbox.checked); }); } } function applyReduceEffects(enabled) { if (enabled) { document.body.classList.add('reduce-effects'); } else { document.body.classList.remove('reduce-effects'); } window._reduceEffectsActive = enabled; localStorage.setItem('soulsync-reduce-effects', enabled ? '1' : '0'); // Reduce Visual Effects is a full performance switch: also halt the canvas // animation loops (particles + worker orbs), not just CSS effects. const pcanvas = document.getElementById('page-particles-canvas'); if (enabled) { if (window.pageParticles) window.pageParticles.stop(); if (pcanvas) pcanvas.style.display = 'none'; if (window.workerOrbs) window.workerOrbs.setPage('_disabled'); } else { // Restore only what the user's own toggles still allow. const activePage = document.querySelector('.page.active'); const activeId = activePage ? activePage.id.replace('-page', '') : null; if (window._particlesEnabled !== false) { if (pcanvas) pcanvas.style.display = ''; if (window.pageParticles && activeId) window.pageParticles.setPage(activeId); } if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) { window.workerOrbs.setPage(activeId); } } } // Max Performance overrides Worker Orbs / Particles / Reduce Effects, so while it's // on we lock those checkboxes (greyed + visually off) and restore them when it's // off. We never fire their change handlers, so the user's real saved prefs // (window._workerOrbsEnabled / _particlesEnabled / the reduce-effects localStorage) // stay intact — saving reads those, not these forced-off boxes. function _syncMaxPerfDependentToggles(maxPerfOn) { const ids = ['worker-orbs-enabled', 'particles-enabled', 'reduce-effects-enabled']; ids.forEach(id => { const cb = document.getElementById(id); if (!cb) return; const group = cb.closest('.form-group'); if (maxPerfOn) { cb.disabled = true; cb.checked = false; if (group) group.classList.add('setting-overridden'); } else { cb.disabled = false; if (group) group.classList.remove('setting-overridden'); // Restore each box to the user's real per-device preference. if (id === 'worker-orbs-enabled') cb.checked = window._workerOrbsEnabled !== false; else if (id === 'particles-enabled') cb.checked = window._particlesEnabled === true; else if (id === 'reduce-effects-enabled') cb.checked = localStorage.getItem('soulsync-reduce-effects') === '1'; } }); } // Max Performance — the nuclear low-power switch for software-rendered / no-GPU // setups (e.g. Docker). Superset of Reduce Visual Effects: body.max-performance CSS // kills the expensive GPU properties AND all animation/transitions, while here we // halt every JS canvas loop (particles + worker orbs; cursor-glow + API sparks gate // on window._maxPerfActive themselves). function applyMaxPerformance(enabled) { if (enabled) { document.body.classList.add('max-performance'); } else { document.body.classList.remove('max-performance'); } window._maxPerfActive = enabled; localStorage.setItem('soulsync-max-performance', enabled ? '1' : '0'); const pcanvas = document.getElementById('page-particles-canvas'); if (enabled) { if (window.pageParticles) window.pageParticles.stop(); if (pcanvas) pcanvas.style.display = 'none'; if (window.workerOrbs) window.workerOrbs.setPage('_disabled'); } else { // Restore whatever the user's own toggles (and reduce-effects) still allow. const reduce = window._reduceEffectsActive === true; const activePage = document.querySelector('.page.active'); const activeId = activePage ? activePage.id.replace('-page', '') : null; if (!reduce && window._particlesEnabled !== false) { if (pcanvas) pcanvas.style.display = ''; if (window.pageParticles && activeId) window.pageParticles.setPage(activeId); } if (window._workerOrbsEnabled !== false && window.workerOrbs && activeId) { window.workerOrbs.setPage(activeId); } } _syncMaxPerfDependentToggles(enabled); } // Bootstrap accent and reduce-effects from localStorage instantly (prevents flash) (function () { // Auto performance mode on likely-weak hardware. Only acts when this device has // NO stored preference yet (null) — so it runs at most once and never overrides // a choice the user (or a prior auto-run) made. Device-scoped via localStorage on // purpose: a weak laptop shouldn't flip the server setting for the user's other // machines. Mobile already disables these effects elsewhere, so skip it here. if (localStorage.getItem('soulsync-reduce-effects') === null) { const ua = navigator.userAgent || ''; const isMobile = window.innerWidth <= 768 || /Mobi|Android|iPhone|iPad|iPod/i.test(ua); const cores = navigator.hardwareConcurrency || 0; // widely supported const mem = navigator.deviceMemory || 0; // Chromium only; 0 elsewhere // Conservative — avoid flagging capable machines: <=2 cores, or <=2GB, or a // low-mid box that's low on BOTH (<=4 cores AND <=4GB). A 4-core/8GB laptop // (mem>4) is NOT flagged; Firefox/Safari (mem unknown) only trip on <=2 cores. const weak = !isMobile && ( (cores > 0 && cores <= 2) || (mem > 0 && mem <= 2) || (cores > 0 && cores <= 4 && mem > 0 && mem <= 4) ); if (weak) { localStorage.setItem('soulsync-reduce-effects', '1'); window._autoPerfModeApplied = true; // show the explainer toast once the UI is up } } if (window._autoPerfModeApplied) { // Toast lives in downloads.js (loaded separately) — retry until it's defined. const fireToast = (tries) => { if (typeof showToast === 'function') { showToast('Performance mode is on — this looks like a lower-power device. ' + 'Turn effects back on in Settings → Appearance.', 'info'); } else if (tries < 40) { setTimeout(() => fireToast(tries + 1), 250); } }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', () => fireToast(0)); } else { fireToast(0); } } const reduceEffectsSaved = localStorage.getItem('soulsync-reduce-effects'); if (reduceEffectsSaved === '1') { document.body.classList.add('reduce-effects'); window._reduceEffectsActive = true; } else if (reduceEffectsSaved === '0') { document.body.classList.remove('reduce-effects'); window._reduceEffectsActive = false; } else if (window._reduceEffectsActive) { document.body.classList.add('reduce-effects'); } // Max Performance — device-scoped (localStorage wins over the server default, // same as reduce-effects). The window flag is seeded server-side in index.html // for a flash-free first paint; localStorage reconciles it here. const maxPerfSaved = localStorage.getItem('soulsync-max-performance'); if (maxPerfSaved === '1') { document.body.classList.add('max-performance'); window._maxPerfActive = true; } else if (maxPerfSaved === '0') { document.body.classList.remove('max-performance'); window._maxPerfActive = false; } else if (window._maxPerfActive) { document.body.classList.add('max-performance'); } const saved = localStorage.getItem('soulsync-accent'); if (saved) applyAccentColor(saved); // Bootstrap particles setting from localStorage — OFF by default (continuous // full-page canvas = real GPU cost); only on when the user explicitly enabled it. const particlesSaved = localStorage.getItem('soulsync-particles'); if (particlesSaved === 'true') { window._particlesEnabled = true; } else if (particlesSaved === 'false') { window._particlesEnabled = false; } else if (typeof window._particlesEnabled !== 'boolean') { window._particlesEnabled = false; } if (!window._particlesEnabled) { const canvas = document.getElementById('page-particles-canvas'); if (canvas) canvas.style.display = 'none'; } // Bootstrap worker orbs setting from localStorage const workerOrbsSaved = localStorage.getItem('soulsync-worker-orbs'); if (workerOrbsSaved === 'false') { window._workerOrbsEnabled = false; } else if (workerOrbsSaved === 'true') { window._workerOrbsEnabled = true; } else if (typeof window._workerOrbsEnabled !== 'boolean') { window._workerOrbsEnabled = true; } })(); async function bootstrapServerAppearanceSettings() { try { const response = await fetch('/api/settings', { credentials: 'same-origin' }); const settings = await response.json(); if (!response.ok || !settings || typeof settings !== 'object' || settings.error) return; const appearance = settings.ui_appearance || {}; const preset = appearance.accent_preset || '#1db954'; const custom = appearance.accent_color || '#1db954'; const accent = preset === 'custom' ? custom : preset; applyAccentColor(accent); if (Object.prototype.hasOwnProperty.call(appearance, 'particles_enabled')) { applyParticlesSetting(appearance.particles_enabled !== false); } if (Object.prototype.hasOwnProperty.call(appearance, 'worker_orbs_enabled')) { applyWorkerOrbsSetting(appearance.worker_orbs_enabled !== false); } if (localStorage.getItem('soulsync-reduce-effects') === null) { applyReduceEffects(appearance.reduce_effects === true); } } catch (error) { console.warn('Could not bootstrap appearance settings:', error); } } bootstrapServerAppearanceSettings(); // ── Password-manager autofill suppression ────────────────────────────── // Bitwarden / 1Password / LastPass etc. attach an inline autofill overlay to // every /