MorphGuard / static /js /dynamic_theme.js
juanquy's picture
Initial clean commit of modular MorphGuard
2978bba
Raw
History Blame Contribute Delete
22.8 kB
/**
* MorphGuard Dynamic Theming System
*
* A context-aware theming system that adapts to user preferences,
* system settings, time of day, and other environmental factors.
*
* Features:
* - Multiple theme support (light, dark, high-contrast, custom)
* - Context-aware theme switching based on various signals
* - Theme persistence via localStorage
* - System preference detection (prefers-color-scheme)
* - Dynamic CSS variable management
* - Theme transition animations
*/
class DynamicTheme {
constructor(options = {}) {
// Default options
this.options = {
defaultTheme: 'system', // 'light', 'dark', 'system', or a custom theme name
themeStorageKey: 'mg-theme-preference',
transitionDuration: 300, // ms
autoDetectTime: true, // Use time of day to suggest theme
autoDetectLocation: false, // Use location for seasonal theming
contextSensors: true, // Enable additional context sensors (light sensors, etc.)
enableHighContrast: true, // Enable high contrast theme option
enableAnimations: true, // Enable transitions when changing themes
...options
};
// Theme definitions with CSS variables
this.themes = {
light: {
name: 'Light',
icon: 'sun',
variables: {
'--bg-primary': '#ffffff',
'--bg-secondary': '#f3f4f6',
'--bg-tertiary': '#e5e7eb',
'--text-primary': '#111827',
'--text-secondary': '#4b5563',
'--text-tertiary': '#9ca3af',
'--accent-primary': '#4f46e5',
'--accent-secondary': '#818cf8',
'--accent-tertiary': '#c7d2fe',
'--success': '#10b981',
'--warning': '#f59e0b',
'--error': '#ef4444',
'--info': '#3b82f6',
'--border-color': '#e5e7eb',
'--shadow-color': 'rgba(0, 0, 0, 0.1)',
'--shadow-intensity': '0.1',
'--card-bg': '#ffffff',
'--nav-bg': '#ffffff',
'--header-bg': '#ffffff',
'--footer-bg': '#f9fafb',
'--detection-safe': '#d1fae5',
'--detection-morph': '#fee2e2',
'--chart-color-1': '#4f46e5',
'--chart-color-2': '#10b981',
'--chart-color-3': '#f59e0b',
'--chart-color-4': '#ef4444',
'--chart-color-5': '#3b82f6',
'--chart-grid': '#e5e7eb'
}
},
dark: {
name: 'Dark',
icon: 'moon',
variables: {
'--bg-primary': '#111827',
'--bg-secondary': '#1f2937',
'--bg-tertiary': '#374151',
'--text-primary': '#f9fafb',
'--text-secondary': '#e5e7eb',
'--text-tertiary': '#9ca3af',
'--accent-primary': '#818cf8',
'--accent-secondary': '#6366f1',
'--accent-tertiary': '#4f46e5',
'--success': '#34d399',
'--warning': '#fbbf24',
'--error': '#f87171',
'--info': '#60a5fa',
'--border-color': '#374151',
'--shadow-color': 'rgba(0, 0, 0, 0.5)',
'--shadow-intensity': '0.25',
'--card-bg': '#1f2937',
'--nav-bg': '#111827',
'--header-bg': '#111827',
'--footer-bg': '#1f2937',
'--detection-safe': '#064e3b',
'--detection-morph': '#7f1d1d',
'--chart-color-1': '#818cf8',
'--chart-color-2': '#34d399',
'--chart-color-3': '#fbbf24',
'--chart-color-4': '#f87171',
'--chart-color-5': '#60a5fa',
'--chart-grid': '#374151'
}
},
highContrast: {
name: 'High Contrast',
icon: 'eye',
variables: {
'--bg-primary': '#000000',
'--bg-secondary': '#121212',
'--bg-tertiary': '#1e1e1e',
'--text-primary': '#ffffff',
'--text-secondary': '#eeeeee',
'--text-tertiary': '#dddddd',
'--accent-primary': '#ffff00',
'--accent-secondary': '#00ffff',
'--accent-tertiary': '#ff00ff',
'--success': '#00ff00',
'--warning': '#ffcc00',
'--error': '#ff0000',
'--info': '#00aaff',
'--border-color': '#ffffff',
'--shadow-color': 'rgba(255, 255, 255, 0.3)',
'--shadow-intensity': '0.3',
'--card-bg': '#121212',
'--nav-bg': '#000000',
'--header-bg': '#000000',
'--footer-bg': '#121212',
'--detection-safe': '#00aa00',
'--detection-morph': '#aa0000',
'--chart-color-1': '#ffff00',
'--chart-color-2': '#00ffff',
'--chart-color-3': '#ff00ff',
'--chart-color-4': '#ff0000',
'--chart-color-5': '#00aaff',
'--chart-grid': '#444444'
}
},
// Custom user-defined theme will be populated at runtime
custom: {
name: 'Custom',
icon: 'palette',
variables: {}
}
};
// Environmental context data
this.context = {
time: null,
location: null,
systemTheme: null,
lightLevel: null,
batteryLevel: null,
batteryCharging: null,
motion: null,
userActivity: null
};
// State tracking
this.currentTheme = null;
this.activeTheme = null;
this.isTransitioning = false;
this.observers = [];
this.contextSensors = {};
// Initialize
this.init();
}
/**
* Initialize the theming system
*/
init() {
// Create stylesheet for dynamic variables if not exists
this.createStylesheet();
// Load saved theme preference from localStorage
this.loadSavedTheme();
// Setup system theme detection using media query
this.setupSystemThemeDetection();
// Setup context sensors
if (this.options.contextSensors) {
this.setupContextSensors();
}
// Set initial theme
this.applyTheme(this.currentTheme);
// Add listener for system theme changes
this.addSystemThemeListener();
// Expose API on window for easy access
window.morphGuardTheme = this;
console.log(`MorphGuard Theme initialized with "${this.currentTheme}" theme`);
}
/**
* Create stylesheet for dynamic variables
*/
createStylesheet() {
if (!document.getElementById('mg-dynamic-theme')) {
const style = document.createElement('style');
style.id = 'mg-dynamic-theme';
document.head.appendChild(style);
this.stylesheet = style.sheet;
} else {
this.stylesheet = document.getElementById('mg-dynamic-theme').sheet;
}
}
/**
* Load saved theme preference from localStorage
*/
loadSavedTheme() {
const savedTheme = localStorage.getItem(this.options.themeStorageKey);
this.currentTheme = savedTheme || this.options.defaultTheme;
// If the theme is 'system', get the actual system theme
if (this.currentTheme === 'system') {
this.currentTheme = this.getSystemTheme();
}
}
/**
* Setup system theme detection using media query
*/
setupSystemThemeDetection() {
this.systemThemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
this.context.systemTheme = this.systemThemeMedia.matches ? 'dark' : 'light';
}
/**
* Add listener for system theme changes
*/
addSystemThemeListener() {
// Use newer event listener method if available
if (this.systemThemeMedia.addEventListener) {
this.systemThemeMedia.addEventListener('change', (e) => {
this.context.systemTheme = e.matches ? 'dark' : 'light';
// Only auto-update if using system theme
if (this.options.defaultTheme === 'system' &&
localStorage.getItem(this.options.themeStorageKey) === 'system') {
this.applyTheme(this.context.systemTheme);
}
// Notify observers
this.notifyObservers({
type: 'systemThemeChange',
theme: this.context.systemTheme
});
});
} else {
// Fallback for older browsers
this.systemThemeMedia.addListener((e) => {
this.context.systemTheme = e.matches ? 'dark' : 'light';
// Only auto-update if using system theme
if (this.options.defaultTheme === 'system' &&
localStorage.getItem(this.options.themeStorageKey) === 'system') {
this.applyTheme(this.context.systemTheme);
}
// Notify observers
this.notifyObservers({
type: 'systemThemeChange',
theme: this.context.systemTheme
});
});
}
}
/**
* Setup context sensors to gather environmental data
*/
setupContextSensors() {
// Time-based theming
if (this.options.autoDetectTime) {
this.setupTimeBasedTheme();
}
// Light level sensor if available
if ('AmbientLightSensor' in window) {
try {
const lightSensor = new window.AmbientLightSensor();
lightSensor.onreading = () => {
this.context.lightLevel = lightSensor.illuminance;
this.evaluateContextualTheme();
};
lightSensor.onerror = (event) => {
console.error(`Light sensor error: ${event.error.name}`);
};
lightSensor.start();
this.contextSensors.light = lightSensor;
} catch (error) {
console.error('Light sensor error:', error);
}
}
// Battery status
if ('getBattery' in navigator) {
navigator.getBattery().then(battery => {
// Update battery status
const updateBattery = () => {
this.context.batteryLevel = battery.level;
this.context.batteryCharging = battery.charging;
this.evaluateContextualTheme();
};
// Listen for battery changes
battery.addEventListener('levelchange', updateBattery);
battery.addEventListener('chargingchange', updateBattery);
// Initial update
updateBattery();
this.contextSensors.battery = battery;
});
}
// Location-based theming
if (this.options.autoDetectLocation && 'geolocation' in navigator) {
navigator.geolocation.getCurrentPosition(
(position) => {
this.context.location = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
this.evaluateContextualTheme();
},
(error) => {
console.error('Geolocation error:', error);
},
{ maximumAge: 3600000 } // Cache location for 1 hour
);
}
// Motion detection
if ('DeviceMotionEvent' in window) {
window.addEventListener('devicemotion', (event) => {
const acceleration = event.accelerationIncludingGravity;
if (acceleration) {
const magnitude = Math.sqrt(
acceleration.x * acceleration.x +
acceleration.y * acceleration.y +
acceleration.z * acceleration.z
);
this.context.motion = magnitude;
// Throttle evaluation to avoid excessive updates
if (!this._motionThrottleTimeout) {
this._motionThrottleTimeout = setTimeout(() => {
this.evaluateContextualTheme();
this._motionThrottleTimeout = null;
}, 1000);
}
}
});
}
// User activity tracking
this.lastInteractionTime = Date.now();
['click', 'touchstart', 'mousemove', 'keydown', 'scroll'].forEach(eventType => {
window.addEventListener(eventType, () => {
this.lastInteractionTime = Date.now();
this.context.userActivity = 'active';
// Throttle evaluation
if (!this._activityThrottleTimeout) {
this._activityThrottleTimeout = setTimeout(() => {
this.evaluateContextualTheme();
this._activityThrottleTimeout = null;
}, 1000);
}
}, { passive: true });
});
// Periodically check for inactivity
setInterval(() => {
const inactiveTime = Date.now() - this.lastInteractionTime;
if (inactiveTime > 5 * 60 * 1000) { // 5 minutes
this.context.userActivity = 'inactive';
this.evaluateContextualTheme();
}
}, 60 * 1000); // Check every minute
}
/**
* Setup time-based theme switching
*/
setupTimeBasedTheme() {
const updateTimeContext = () => {
const now = new Date();
this.context.time = {
hour: now.getHours(),
minute: now.getMinutes(),
isDaytime: now.getHours() >= 6 && now.getHours() < 18,
isMorning: now.getHours() >= 6 && now.getHours() < 12,
isAfternoon: now.getHours() >= 12 && now.getHours() < 18,
isEvening: now.getHours() >= 18 && now.getHours() < 22,
isNight: now.getHours() >= 22 || now.getHours() < 6
};
this.evaluateContextualTheme();
};
// Update time context immediately
updateTimeContext();
// Schedule updates
this.contextSensors.timeInterval = setInterval(updateTimeContext, 60 * 1000); // Every minute
}
/**
* Evaluate contextual factors to suggest optimal theme
*/
evaluateContextualTheme() {
// Skip if user has explicitly set a theme
const savedTheme = localStorage.getItem(this.options.themeStorageKey);
if (savedTheme && savedTheme !== 'system') return;
let suggestedTheme = null;
const ctx = this.context;
// Light level takes precedence if available
if (ctx.lightLevel !== null) {
if (ctx.lightLevel < 10) { // Very dark environment
suggestedTheme = 'dark';
} else if (ctx.lightLevel > 1000) { // Very bright environment
suggestedTheme = 'light';
}
}
// Time-based suggestion if no light-based suggestion
if (!suggestedTheme && ctx.time) {
if (ctx.time.isNight || ctx.time.isEvening) {
suggestedTheme = 'dark';
} else if (ctx.time.isDaytime) {
suggestedTheme = 'light';
}
}
// Battery level considerations
if (ctx.batteryLevel !== null && ctx.batteryLevel < 0.2 && !ctx.batteryCharging) {
suggestedTheme = 'dark'; // Save power with dark theme on low battery
}
// System preference as fallback
if (!suggestedTheme && ctx.systemTheme) {
suggestedTheme = ctx.systemTheme;
}
// Apply the suggested theme if different from current
if (suggestedTheme && suggestedTheme !== this.activeTheme) {
this.applyTheme(suggestedTheme);
// Notify observers about the context-based theme change
this.notifyObservers({
type: 'contextThemeChange',
theme: suggestedTheme,
reason: this.getThemeChangeReason()
});
}
}
/**
* Get the reason for theme change based on context
*/
getThemeChangeReason() {
const ctx = this.context;
if (ctx.lightLevel !== null) {
if (ctx.lightLevel < 10) return 'dark environment';
if (ctx.lightLevel > 1000) return 'bright environment';
}
if (ctx.time) {
if (ctx.time.isNight) return 'nighttime';
if (ctx.time.isEvening) return 'evening';
if (ctx.time.isMorning) return 'morning';
if (ctx.time.isAfternoon) return 'afternoon';
}
if (ctx.batteryLevel !== null && ctx.batteryLevel < 0.2 && !ctx.batteryCharging) {
return 'low battery';
}
if (ctx.systemTheme) {
return 'system preference';
}
return 'unknown';
}
/**
* Get the system theme preference
*/
getSystemTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
/**
* Apply the specified theme to the document
*/
applyTheme(themeName) {
// If 'system', use the detected system theme
if (themeName === 'system') {
themeName = this.getSystemTheme();
}
// Get theme configuration
const theme = this.themes[themeName] || this.themes.light;
// Set <html> data attribute for CSS selectors
document.documentElement.setAttribute('data-theme', themeName);
// Set the CSS variables
this.setThemeVariables(theme.variables);
// Update the active theme
this.activeTheme = themeName;
// Handle transitions
if (this.options.enableAnimations) {
this.handleThemeTransition();
}
// Notify observers
this.notifyObservers({
type: 'themeChange',
theme: themeName
});
console.log(`MorphGuard Theme: Applied "${themeName}" theme`);
}
/**
* Set theme CSS variables on the document
*/
setThemeVariables(variables) {
for (const [property, value] of Object.entries(variables)) {
document.documentElement.style.setProperty(property, value);
}
}
/**
* Handle smooth transition between themes
*/
handleThemeTransition() {
if (this.isTransitioning) return;
this.isTransitioning = true;
// Add transition class to body
document.body.classList.add('theme-transition');
// Remove transition class after transition completes
setTimeout(() => {
document.body.classList.remove('theme-transition');
this.isTransitioning = false;
}, this.options.transitionDuration);
}
/**
* Change the current theme
*/
setTheme(themeName) {
// Validate theme name
if (themeName !== 'system' && !this.themes[themeName]) {
console.error(`Theme "${themeName}" not found`);
return false;
}
// Save theme preference
localStorage.setItem(this.options.themeStorageKey, themeName);
// Update current theme
this.currentTheme = themeName;
// Apply the theme
this.applyTheme(themeName === 'system' ? this.getSystemTheme() : themeName);
return true;
}
/**
* Reset to default theme
*/
resetTheme() {
localStorage.removeItem(this.options.themeStorageKey);
this.currentTheme = this.options.defaultTheme;
this.applyTheme(this.currentTheme === 'system' ? this.getSystemTheme() : this.currentTheme);
}
/**
* Get all available themes
*/
getAvailableThemes() {
const themes = [
{ id: 'system', name: 'System Default', icon: 'device-desktop' },
...Object.entries(this.themes).map(([id, theme]) => ({
id,
name: theme.name,
icon: theme.icon
}))
];
// Filter out high contrast if disabled
if (!this.options.enableHighContrast) {
return themes.filter(theme => theme.id !== 'highContrast');
}
return themes;
}
/**
* Create a custom theme
*/
createCustomTheme(name, variables) {
this.themes.custom = {
name: name || 'Custom',
icon: 'palette',
variables: {
...this.themes.light.variables, // Start with light theme as base
...variables // Override with custom variables
}
};
return 'custom';
}
/**
* Add observer for theme changes
*/
addObserver(callback) {
if (typeof callback === 'function') {
this.observers.push(callback);
return true;
}
return false;
}
/**
* Remove observer
*/
removeObserver(callback) {
const index = this.observers.indexOf(callback);
if (index !== -1) {
this.observers.splice(index, 1);
return true;
}
return false;
}
/**
* Notify all observers of an event
*/
notifyObservers(event) {
this.observers.forEach(observer => {
try {
observer(event);
} catch (error) {
console.error('Error in theme observer:', error);
}
});
}
/**
* Get the current theme context
*/
getThemeContext() {
return {
currentTheme: this.currentTheme,
activeTheme: this.activeTheme,
context: { ...this.context },
defaultTheme: this.options.defaultTheme
};
}
/**
* Check if a theme is a dark theme
*/
isDarkTheme(themeName) {
if (themeName === 'system') {
return this.getSystemTheme() === 'dark';
}
return themeName === 'dark' || themeName === 'highContrast';
}
/**
* Clean up resources when the theme manager is no longer needed
*/
destroy() {
// Remove system theme listener
if (this.systemThemeMedia.removeEventListener) {
this.systemThemeMedia.removeEventListener('change', this.handleSystemThemeChange);
} else {
this.systemThemeMedia.removeListener(this.handleSystemThemeChange);
}
// Clear intervals
if (this.contextSensors.timeInterval) {
clearInterval(this.contextSensors.timeInterval);
}
// Stop sensors
if (this.contextSensors.light) {
this.contextSensors.light.stop();
}
// Remove theme stylesheet
const styleEl = document.getElementById('mg-dynamic-theme');
if (styleEl) {
styleEl.remove();
}
// Clear observers
this.observers = [];
// Remove window reference
delete window.morphGuardTheme;
}
}
// Initialize the theme when the DOM is loaded
document.addEventListener('DOMContentLoaded', () => {
// Create CSS for theme transitions
const transitionStyle = document.createElement('style');
transitionStyle.textContent = `
.theme-transition,
.theme-transition *,
.theme-transition *:before,
.theme-transition *:after {
transition: all 0.3s ease-in-out !important;
transition-delay: 0 !important;
}
`;
document.head.appendChild(transitionStyle);
// Initialize the theme system
window.morphGuardTheme = new DynamicTheme();
// Create theme toggle buttons if they exist
const themeToggles = document.querySelectorAll('[data-theme-toggle]');
themeToggles.forEach(toggle => {
toggle.addEventListener('click', (e) => {
e.preventDefault();
const targetTheme = toggle.getAttribute('data-theme-toggle');
window.morphGuardTheme.setTheme(targetTheme);
});
});
// Initialize theme selector if it exists
const themeSelector = document.querySelector('[data-theme-selector]');
if (themeSelector) {
// Populate the selector with available themes
const themes = window.morphGuardTheme.getAvailableThemes();
themes.forEach(theme => {
const option = document.createElement('option');
option.value = theme.id;
option.textContent = theme.name;
themeSelector.appendChild(option);
});
// Set current theme as selected
themeSelector.value = window.morphGuardTheme.currentTheme;
// Add change listener
themeSelector.addEventListener('change', () => {
window.morphGuardTheme.setTheme(themeSelector.value);
});
}
});
// Export the class for module use
if (typeof module !== 'undefined' && module.exports) {
module.exports = DynamicTheme;
}