Files
Haven/public/js/theme-init.js
T

318 lines
17 KiB
JavaScript

// Apply saved theme immediately to prevent flash of unstyled content
(function() {
// Disable browser scroll restoration. With body { overflow: hidden } the
// app shouldn't ever be "scrolled" at the document level, but Android
// Chrome can scroll the html element when the visual viewport changes
// (URL bar / keyboard) and then persist that scroll across reloads.
// That's what made the entire UI appear shifted up after a refresh in
// issue #5285. Force-reset on every load.
try { if ('scrollRestoration' in history) history.scrollRestoration = 'manual'; } catch(e) { /* read-only in some embedded views: the reset below still runs */ }
function resetDocScroll() {
try {
if (window.scrollY || window.scrollX) window.scrollTo(0, 0);
if (document.documentElement) document.documentElement.scrollTop = 0;
if (document.body) document.body.scrollTop = 0;
} catch(e) { /* scroll reset is cosmetic */ }
}
resetDocScroll();
window.addEventListener('load', resetDocScroll);
window.addEventListener('pageshow', resetDocScroll);
window.addEventListener('resize', resetDocScroll);
if (window.visualViewport) {
window.visualViewport.addEventListener('resize', resetDocScroll);
window.visualViewport.addEventListener('scroll', resetDocScroll);
}
// Toggle style (sliders vs classic checkboxes). Applied before first paint
// so on/off settings don't render as one shape and then swap to the other.
// Sliders are the default, so only an explicit 'box' choice changes it.
try {
var _ts = localStorage.getItem('haven-toggle-style');
document.documentElement.setAttribute('data-toggle-style', _ts === 'box' ? 'box' : 'switch');
} catch (e) {
document.documentElement.setAttribute('data-toggle-style', 'switch');
}
// Public density state is available before first paint. Layout plugins and
// themes can react without reading Haven's private message geometry values.
try {
var _density = localStorage.getItem('haven-density');
if (['compact', 'cozy', 'spacious'].indexOf(_density) < 0) _density = 'cozy';
document.documentElement.setAttribute('data-density', _density);
document.documentElement.setAttribute('data-haven-density', _density);
} catch (e) {
document.documentElement.setAttribute('data-density', 'cozy');
document.documentElement.setAttribute('data-haven-density', 'cozy');
}
// Apply channel scroll behavior
try {
var _channelScroll = localStorage.getItem('haven-channel-scroll');
if (['separate', 'combined'].indexOf(_channelScroll) < 0) _channelScroll = 'separate';
document.documentElement.setAttribute('data-channel-scroll', _channelScroll);
} catch (e) {
document.documentElement.setAttribute('data-channel-scroll', 'separate');
}
// Apply the saved interface scale before first paint so the UI doesn't render
// at 100% and then jump. Mirrors the slider logic in app-media.js and
// migrates the retired 4-tier font-size setting.
try {
var _z = parseInt(localStorage.getItem('haven-zoom'), 10);
if (!_z) {
var _legacyZoom = { small: 85, normal: 100, large: 118, 'x-large': 138 };
_z = _legacyZoom[localStorage.getItem('haven-fontsize')] || 100;
}
_z = Math.min(150, Math.max(70, _z));
document.documentElement.style.setProperty('--ui-scale', _z + '%');
} catch (e) { /* storage blocked: keep the 100% scale */ }
// Accessibility options (Settings > Accessibility). Applied here, before
// first paint, for the same reason as the zoom above: bigger text or a
// calmer screen has to be there on the first frame, not arrive once
// app.js has loaded. The settings page reads and applies through the same
// two functions (window.HavenA11y), so there is one copy of the rules.
//
// Each option is a data- attribute on <html> that the CSS keys off. Reduce
// motion is the exception: its pick (system / on / off) is an attribute,
// and the result, after asking the OS when the pick is "system", is the
// one class every motion rule checks, html.reduce-motion.
var A11Y_CHOICES = {
lineSpacing: ['normal', 'relaxed', 'loose'],
letterSpacing: ['normal', 'wide'],
font: ['theme', 'system', 'hyperlegible', 'dyslexic'],
motion: ['system', 'on', 'off']
};
function _a11yPick(value, list) { return list.indexOf(value) >= 0 ? value : list[0]; }
function _readA11y(storage) {
var get = function(key) {
try { return storage ? storage.getItem(key) : null; } catch (e) { return null; }
};
var size = parseInt(get('haven-text-size'), 10);
return {
textSize: size ? Math.min(200, Math.max(80, size)) : 100,
lineSpacing: _a11yPick(get('haven-line-spacing'), A11Y_CHOICES.lineSpacing),
letterSpacing: _a11yPick(get('haven-letter-spacing'), A11Y_CHOICES.letterSpacing),
font: _a11yPick(get('haven-readable-font'), A11Y_CHOICES.font),
highContrast: get('haven-contrast') === 'high',
motion: _a11yPick(get('haven-reduce-motion'), A11Y_CHOICES.motion),
underlineLinks: get('haven-underline-links') === '1',
largeTargets: get('haven-large-targets') === '1'
};
}
var _motionQuery = null;
try { _motionQuery = window.matchMedia ? window.matchMedia('(prefers-reduced-motion: reduce)') : null; } catch (e) { /* no media queries: treat the OS as not asking */ }
var _a11yState = null;
function _applyA11y(prefs) {
var root = document.documentElement;
root.style.setProperty('--msg-text-scale', String(prefs.textSize / 100));
root.setAttribute('data-text-size', String(prefs.textSize));
root.setAttribute('data-line-spacing', prefs.lineSpacing);
root.setAttribute('data-letter-spacing', prefs.letterSpacing);
root.setAttribute('data-readable-font', prefs.font);
root.setAttribute('data-contrast', prefs.highContrast ? 'high' : 'normal');
root.setAttribute('data-reduce-motion', prefs.motion);
root.setAttribute('data-underline-links', prefs.underlineLinks ? 'on' : 'off');
root.setAttribute('data-large-targets', prefs.largeTargets ? 'on' : 'off');
var reduce = prefs.motion === 'on' || (prefs.motion === 'system' && !!(_motionQuery && _motionQuery.matches));
var was = _a11yState ? _a11yState.reduce : null;
if (reduce) root.classList.add('reduce-motion');
else if (root.classList.remove) root.classList.remove('reduce-motion');
_a11yState = { prefs: prefs, reduce: reduce };
// Theme effects, animated pictures and the like are JavaScript, so they
// hear about a change instead of reading the class on their own schedule.
if (was !== null && was !== reduce && typeof CustomEvent === 'function') {
document.dispatchEvent(new CustomEvent('haven:reduce-motion-change', { detail: { reduce: reduce } }));
}
return reduce;
}
window.HavenA11y = {
read: function() {
var storage = null;
try { storage = typeof localStorage !== 'undefined' ? localStorage : null; } catch (e) { /* blocked: every option at its default */ }
return _readA11y(storage);
},
apply: _applyA11y,
reducedMotion: function() { return !!(_a11yState && _a11yState.reduce); }
};
try { _applyA11y(window.HavenA11y.read()); } catch (e) { /* the page still renders with the theme's own settings */ }
// "Follow system" keeps following it while the page is open.
if (_motionQuery) {
var _onMotionQuery = function() { if (_a11yState) _applyA11y(_a11yState.prefs); };
if (_motionQuery.addEventListener) _motionQuery.addEventListener('change', _onMotionQuery);
else if (_motionQuery.addListener) _motionQuery.addListener(_onMotionQuery);
}
var _themeCompat = window.HavenThemeCompat;
var _safeMode = _themeCompat ? _themeCompat.isSafeMode(window.location) : false;
var _resetPending = _themeCompat ? _themeCompat.isResetPending() : false;
if (!_themeCompat) {
try {
var _safeRequest = new URLSearchParams(window.location.search).get('haven-safe-mode');
if (_safeRequest === '1') sessionStorage.setItem('haven_safe_mode', '1');
if (_safeRequest === '0') sessionStorage.removeItem('haven_safe_mode');
_safeMode = _safeRequest === '1'
|| (_safeRequest !== '0' && sessionStorage.getItem('haven_safe_mode') === '1');
_resetPending = sessionStorage.getItem('haven_customizations_reset_pending') === '1';
} catch {
_safeMode = new URLSearchParams(window.location.search).get('haven-safe-mode') === '1';
}
}
if (_safeMode) document.documentElement.setAttribute('data-haven-safe-mode', '1');
function _injectEarlyTheme(file, onSettled) {
if (document.getElementById('haven-theme-file-early')) {
if (onSettled) onSettled();
return;
}
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = '/themes/' + encodeURIComponent(file);
link.id = 'haven-theme-file-early';
if (onSettled) {
link.onload = onSettled;
link.onerror = onSettled;
}
document.head.appendChild(link);
}
// Safe mode and a pending recovery reset always start from Haven's base
// appearance. Preferences stay stored until the user explicitly resets them.
var t = (_safeMode || _resetPending) ? 'haven' : localStorage.getItem('haven_theme');
// Light or dark native controls (dropdown lists, scrollbars), as theme.js
// last measured them, so a light theme's first frame is light too. Haven's
// base (safe mode, no theme yet) is dark, the stylesheet default.
if (t && t !== 'haven') {
var _scheme = localStorage.getItem('haven_color_scheme');
if (_scheme === 'light' || _scheme === 'dark') document.documentElement.setAttribute('data-color-scheme', _scheme);
}
if (t) {
if (t.indexOf('file:') === 0) {
// File theme: inject the CSS link immediately so the theme applies on
// the login page (where plugin-loader doesn't run) and avoids a FOUC
// on the app page while waiting for plugin-loader's 500 ms startup
// delay. The data-theme is set to 'haven' as a stable layout base,
// matching what applyFileTheme() does when the plugin-loader runs. (#5359)
var _themeFile = t.slice(5);
var _cachedTheme = _themeCompat ? _themeCompat.getCachedTheme(_themeFile) : null;
if (_cachedTheme && _cachedTheme.compatible) {
_injectEarlyTheme(_themeFile);
} else {
// Validate an uncached theme immediately. Hide the base briefly so a
// first load does not flash Haven before the compatible CSS arrives.
document.documentElement.setAttribute('data-haven-theme-pending', _themeFile);
if (_themeCompat?.fetchThemes && typeof window.fetch === 'function') {
document.documentElement.style.setProperty('visibility', 'hidden');
var _finishThemeCheck = function() {
document.documentElement.removeAttribute('data-haven-theme-pending');
document.documentElement.style.removeProperty('visibility');
};
setTimeout(_finishThemeCheck, 3000);
_themeCompat.fetchThemes(window.fetch.bind(window)).then(function(themes) {
if (Array.isArray(themes)) {
_themeCompat.cacheThemes(themes);
var selected = themes.find(function(theme) { return theme?.file === _themeFile; });
if (selected && selected.compatible !== false
&& selected.compatibility !== 'invalid'
&& selected.compatibility !== 'unsupported') {
_injectEarlyTheme(_themeFile, _finishThemeCheck);
return;
}
}
_finishThemeCheck();
});
}
}
document.documentElement.setAttribute('data-theme', 'haven');
} else {
document.documentElement.setAttribute('data-theme', t);
}
}
// Defensive: if the saved theme is NOT custom/rgb, strip any inline CSS
// custom properties that may have been left on :root during a prior theme
// (e.g. switching custom → win95 in a previous session, then a server
// preferences event re-applying custom mid-session, then back). Without
// this, a leftover --bg-primary on :root would override the win95 theme's
// own --bg-primary and leave large surfaces rendering with a dark color
// while explicitly-styled chrome (sidebar, channel header) looks correct.
if (t && t !== 'custom' && t !== 'rgb') {
var leakedKeys = ['--accent','--accent-text','--accent-hover','--accent-dim','--accent-glow',
'--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active',
'--bg-input','--bg-card','--text-primary','--text-secondary','--text-muted',
'--text-link','--border','--border-light','--success','--danger','--warning',
'--led-on','--led-off','--led-glow'];
for (var i = 0; i < leakedKeys.length; i++) {
document.documentElement.style.removeProperty(leakedKeys[i]);
}
}
// Apply effect overlay system (stackable); always strip theme pseudo-element effects
document.documentElement.setAttribute('data-fx-custom', '');
var fxRaw = (_safeMode || _resetPending) ? '[]' : (localStorage.getItem('haven_effects') || 'auto');
var fxMode;
try { fxMode = JSON.parse(fxRaw); } catch(e) { fxMode = fxRaw; }
// Apply CRT class early for scanline var + font (prevents FOUC)
var fxList = [];
if (Array.isArray(fxMode)) { fxList = fxMode; }
else if (fxMode === 'auto' && t) {
var defaults = {matrix:1,fallout:1,ffx:1,ice:1,nord:1,darksouls:1,bloodborne:1,cyberpunk:1,lotr:1,abyss:1,scripture:1,chapel:1,gospel:1};
if (defaults[t]) fxList = [t];
}
// Reduced motion runs no theme effects (theme.js applyEffects), CRT included.
if (fxList.indexOf('crt') >= 0 && !(_a11yState && _a11yState.reduce)) document.documentElement.classList.add('fx-crt');
// Custom and RGB: paint the last palette theme.js showed, light or dark,
// so the first frame already has the right colors (#5726). Without one
// (the first load after updating), custom is worked out below.
var _paletteApplied = false;
if (t === 'custom' || t === 'rgb') {
try {
var _cached = JSON.parse(localStorage.getItem('haven_palette_cache') || 'null');
if (_cached && _cached.theme === t && _cached.vars && typeof _cached.vars === 'object') {
for (var _k in _cached.vars) {
if (/^--[a-z-]+$/.test(_k) && typeof _cached.vars[_k] === 'string') {
document.documentElement.style.setProperty(_k, _cached.vars[_k]);
}
}
_paletteApplied = true;
}
} catch (e) { /* unreadable copy: fall back to working the colors out */ }
}
// Apply custom theme variables if custom theme is active
if (t === 'custom' && !_paletteApplied) {
try {
var hsv = JSON.parse(localStorage.getItem('haven_custom_hsv'));
if (hsv && typeof hsv.h === 'number') {
var h = hsv.h, s = hsv.s, v = hsv.v;
function _hsvRgb(h,s,v) {
h=((h%360)+360)%360; var c=v*s,x=c*(1-Math.abs((h/60)%2-1)),m=v-c,r,g,b;
if(h<60){r=c;g=x;b=0}else if(h<120){r=x;g=c;b=0}else if(h<180){r=0;g=c;b=x}
else if(h<240){r=0;g=x;b=c}else if(h<300){r=x;g=0;b=c}else{r=c;g=0;b=x}
return[Math.round((r+m)*255),Math.round((g+m)*255),Math.round((b+m)*255)];
}
function _hex(h,s,v){var c=_hsvRgb(h,s,v);return'#'+c.map(function(x){return x.toString(16).padStart(2,'0')}).join('')}
var el = document.documentElement;
var vib = s; // vibrancy follows saturation for dramatic bg changes
var bgSat = 0.05 + vib * 0.30;
var bdrSat = 0.05 + vib * 0.25;
el.style.setProperty('--accent', _hex(h,s,v));
el.style.setProperty('--accent-hover', _hex(h,Math.max(s-.15,0),Math.min(v+.15,1)));
el.style.setProperty('--accent-dim', _hex(h,Math.min(s+.1,1),Math.max(v-.2,0)));
var rgb=_hsvRgb(h,s,v);
el.style.setProperty('--accent-glow', 'rgba('+rgb.join(',')+',0.25)');
el.style.setProperty('--bg-primary', _hex(h,bgSat,0.07+vib*0.03));
el.style.setProperty('--bg-secondary', _hex(h,bgSat*0.85,0.09+vib*0.04));
el.style.setProperty('--bg-tertiary', _hex(h,bgSat*0.7,0.12+vib*0.04));
el.style.setProperty('--bg-hover', _hex(h,bgSat*0.7,0.15+vib*0.05));
el.style.setProperty('--bg-active', _hex(h,bgSat*0.7,0.18+vib*0.06));
el.style.setProperty('--bg-input', _hex(h,bgSat,0.05+vib*0.03));
el.style.setProperty('--bg-card', _hex(h,bgSat*0.85,0.08+vib*0.04));
el.style.setProperty('--border', _hex(h,bdrSat,0.16+vib*0.06));
el.style.setProperty('--border-light', _hex(h,bdrSat,0.21+vib*0.06));
el.style.setProperty('--text-link', _hex((h+180)%360,.7,.95));
}
} catch(e) { /* corrupt saved colours: keep the base theme */ }
}
// RGB theme: set a neutral dark bg immediately; the cycle starts once theme.js loads
if (t === 'rgb') {
document.documentElement.setAttribute('data-theme', 'haven');
}
})();