// ==UserScript==
// @name DarkModer
// @namespace https://github.com/SysAdminDoc/DarkModer
// @version 3.2.0
// @description Dark mode for every website. Complete Dark Reader recreation as userscript with all features.
// @author SysAdminDoc (Based on Dark Reader by Alexander Shutau)
// @license MIT
// @match *://*/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @connect raw.githubusercontent.com
// @connect *
// @run-at document-start
// @noframes
// @updateURL https://github.com/SysAdminDoc/DarkModer/raw/refs/heads/main/DarkModer.user.js
// @downloadURL https://github.com/SysAdminDoc/DarkModer/raw/refs/heads/main/DarkModer.user.js
// @homepageURL https://github.com/SysAdminDoc/DarkModer
// @supportURL https://github.com/SysAdminDoc/DarkModer/issues
// ==/UserScript==
(function() {
'use strict';
// ============================================================================
// CONFIGURATION
// ============================================================================
const CONFIG = {
version: '3.2.0',
storageKey: 'darkModer',
configBaseURL: 'https://raw.githubusercontent.com/SysAdminDoc/DarkModer/refs/heads/main/',
configFiles: {
darkSites: 'dark-sites.json',
dynamicFixes: 'dynamic-theme-fixes.json',
inversionFixes: 'inversion-fixes.json',
staticThemes: 'static-themes.json'
},
cacheDuration: 60 * 60 * 1000, // 1 hour
maxInlineElements: 200,
inlineElementBatchSize: 50,
stylesheetBatchSize: 5,
observerBatchSize: 3,
maxCSSRules: 1000,
colorSamplingThreshold: 0.3,
maxColorSamples: 30
};
// ============================================================================
// COLOR SCHEME PRESETS
// ============================================================================
const COLOR_SCHEMES = {
'Default': {
background: '#181a1b',
text: '#e8e6e3',
selectionBg: '#004daa',
selectionText: '#ffffff',
link: '#3391ff',
border: '#3a3d3e'
},
'Dracula': {
background: '#282a36',
text: '#f8f8f2',
selectionBg: '#44475a',
selectionText: '#f8f8f2',
link: '#8be9fd',
border: '#44475a'
},
'Nord': {
background: '#2e3440',
text: '#eceff4',
selectionBg: '#4c566a',
selectionText: '#eceff4',
link: '#88c0d0',
border: '#3b4252'
},
'Solarized Dark': {
background: '#002b36',
text: '#839496',
selectionBg: '#073642',
selectionText: '#93a1a1',
link: '#268bd2',
border: '#073642'
},
'Monokai': {
background: '#272822',
text: '#f8f8f2',
selectionBg: '#49483e',
selectionText: '#f8f8f2',
link: '#66d9ef',
border: '#3e3d32'
},
'One Dark': {
background: '#282c34',
text: '#abb2bf',
selectionBg: '#3e4451',
selectionText: '#abb2bf',
link: '#61afef',
border: '#3b4048'
},
'Gruvbox': {
background: '#282828',
text: '#ebdbb2',
selectionBg: '#504945',
selectionText: '#ebdbb2',
link: '#83a598',
border: '#3c3836'
},
'Tokyo Night': {
background: '#1a1b26',
text: '#a9b1d6',
selectionBg: '#33467c',
selectionText: '#c0caf5',
link: '#7aa2f7',
border: '#292e42'
},
'Catppuccin Mocha': {
background: '#1e1e2e',
text: '#cdd6f4',
selectionBg: '#45475a',
selectionText: '#cdd6f4',
link: '#89b4fa',
border: '#313244'
},
'GitHub Dark': {
background: '#0d1117',
text: '#c9d1d9',
selectionBg: '#388bfd',
selectionText: '#ffffff',
link: '#58a6ff',
border: '#30363d'
},
'Amoled': {
background: '#000000',
text: '#ffffff',
selectionBg: '#1a1a1a',
selectionText: '#ffffff',
link: '#4da6ff',
border: '#1a1a1a'
},
'Sepia': {
background: '#232018',
text: '#c8b89a',
selectionBg: '#3d3426',
selectionText: '#c8b89a',
link: '#d4a574',
border: '#3d3426'
}
};
// ============================================================================
// DEFAULT THEME SETTINGS
// ============================================================================
const DEFAULT_THEME = {
mode: 1, // 0: light, 1: dark
brightness: 100,
contrast: 100,
grayscale: 0,
sepia: 0,
useFont: false,
fontFamily: '',
textStroke: 0,
engine: 'dynamic', // 'filter', 'filterPlus', 'dynamic', 'static'
stylesheet: '',
darkSchemeBackgroundColor: '#181a1b',
darkSchemeTextColor: '#e8e6e3',
lightSchemeBackgroundColor: '#dcdad7',
lightSchemeTextColor: '#181a1b',
scrollbarColor: 'auto',
selectionColor: 'auto',
selectionBgColor: '#004daa',
selectionTextColor: '#ffffff',
linkColor: '#3391ff',
visitedLinkColor: '#9e6eff',
borderColor: '#3a3d3e',
styleSystemControls: true,
colorScheme: 'Default',
immediateModify: true
};
// Scheduled themes used by the optional day/night automation mode.
const SCHEDULED_THEME_PRESETS = {
matte: {
brightness: 92,
contrast: 94,
sepia: 8,
grayscale: 0,
darkSchemeBackgroundColor: '#292a2b',
darkSchemeTextColor: '#d2d0cc',
borderColor: '#4a4b4d',
linkColor: '#72a7d8'
},
deep: {
brightness: 100,
contrast: 104,
sepia: 0,
grayscale: 0,
darkSchemeBackgroundColor: '#0b0d10',
darkSchemeTextColor: '#e8e6e3',
borderColor: '#2c3035',
linkColor: '#6ea8fe'
}
};
// ============================================================================
// DEFAULT SETTINGS
// ============================================================================
const DEFAULT_SETTINGS = {
enabled: true,
fetchNews: false,
theme: { ...DEFAULT_THEME },
presets: [],
siteSettings: {},
customThemes: [],
themePresets: {},
enabledByDefault: true,
enabledFor: [],
disabledFor: [],
disabledSites: [],
siteFixesUser: {},
changeBrowserTheme: false,
syncSettings: false,
syncSitesFixes: false,
colorScheme: 'Default',
automation: {
enabled: false,
mode: 'disabled', // 'disabled', 'time', 'system', 'location'
behavior: 'OnOff',
startTime: '18:00',
endTime: '09:00',
latitude: null,
longitude: null,
themeSchedule: {
enabled: false,
day: 'matte',
night: 'deep'
}
},
time: {
activation: '18:00',
deactivation: '09:00'
},
location: {
latitude: null,
longitude: null
},
previewNewDesign: true,
enableForPDF: true,
enableForProtectedPages: false,
enableContextMenus: false,
detectDarkTheme: true,
hdrToneMapping: false
};
// ============================================================================
// NAMED CSS COLORS (Pre-computed table - created once, not per-call)
// ============================================================================
const NAMED_COLORS = {
// Basic colors
white: [255, 255, 255],
black: [0, 0, 0],
red: [255, 0, 0],
green: [0, 128, 0],
blue: [0, 0, 255],
yellow: [255, 255, 0],
cyan: [0, 255, 255],
magenta: [255, 0, 255],
// Gray scale
silver: [192, 192, 192],
gray: [128, 128, 128],
grey: [128, 128, 128],
darkgray: [169, 169, 169],
darkgrey: [169, 169, 169],
dimgray: [105, 105, 105],
dimgrey: [105, 105, 105],
lightgray: [211, 211, 211],
lightgrey: [211, 211, 211],
gainsboro: [220, 220, 220],
whitesmoke: [245, 245, 245],
// Reds
maroon: [128, 0, 0],
darkred: [139, 0, 0],
crimson: [220, 20, 60],
firebrick: [178, 34, 34],
indianred: [205, 92, 92],
lightcoral: [240, 128, 128],
salmon: [250, 128, 114],
darksalmon: [233, 150, 122],
lightsalmon: [255, 160, 122],
coral: [255, 127, 80],
tomato: [255, 99, 71],
orangered: [255, 69, 0],
// Pinks
pink: [255, 192, 203],
lightpink: [255, 182, 193],
hotpink: [255, 105, 180],
deeppink: [255, 20, 147],
mediumvioletred: [199, 21, 133],
palevioletred: [219, 112, 147],
// Oranges
orange: [255, 165, 0],
darkorange: [255, 140, 0],
// Yellows
gold: [255, 215, 0],
lightyellow: [255, 255, 224],
lemonchiffon: [255, 250, 205],
lightgoldenrodyellow: [250, 250, 210],
papayawhip: [255, 239, 213],
moccasin: [255, 228, 181],
peachpuff: [255, 218, 185],
palegoldenrod: [238, 232, 170],
khaki: [240, 230, 140],
darkkhaki: [189, 183, 107],
// Greens
lime: [0, 255, 0],
limegreen: [50, 205, 50],
forestgreen: [34, 139, 34],
darkgreen: [0, 100, 0],
seagreen: [46, 139, 87],
mediumseagreen: [60, 179, 113],
springgreen: [0, 255, 127],
mediumspringgreen: [0, 250, 154],
lightgreen: [144, 238, 144],
palegreen: [152, 251, 152],
darkseagreen: [143, 188, 143],
mediumaquamarine: [102, 205, 170],
yellowgreen: [154, 205, 50],
olivedrab: [107, 142, 35],
olive: [128, 128, 0],
darkolivegreen: [85, 107, 47],
greenyellow: [173, 255, 47],
chartreuse: [127, 255, 0],
lawngreen: [124, 252, 0],
// Cyans
aqua: [0, 255, 255],
teal: [0, 128, 128],
darkcyan: [0, 139, 139],
lightcyan: [224, 255, 255],
paleturquoise: [175, 238, 238],
aquamarine: [127, 255, 212],
turquoise: [64, 224, 208],
mediumturquoise: [72, 209, 204],
darkturquoise: [0, 206, 209],
cadetblue: [95, 158, 160],
steelblue: [70, 130, 180],
lightsteelblue: [176, 196, 222],
// Blues
navy: [0, 0, 128],
darkblue: [0, 0, 139],
mediumblue: [0, 0, 205],
royalblue: [65, 105, 225],
cornflowerblue: [100, 149, 237],
dodgerblue: [30, 144, 255],
deepskyblue: [0, 191, 255],
lightskyblue: [135, 206, 250],
skyblue: [135, 206, 235],
lightblue: [173, 216, 230],
powderblue: [176, 224, 230],
aliceblue: [240, 248, 255],
midnightblue: [25, 25, 112],
// Purples
fuchsia: [255, 0, 255],
purple: [128, 0, 128],
indigo: [75, 0, 130],
darkmagenta: [139, 0, 139],
darkviolet: [148, 0, 211],
darkorchid: [153, 50, 204],
mediumorchid: [186, 85, 211],
orchid: [218, 112, 214],
violet: [238, 130, 238],
plum: [221, 160, 221],
thistle: [216, 191, 216],
lavender: [230, 230, 250],
rebeccapurple: [102, 51, 153],
blueviolet: [138, 43, 226],
mediumpurple: [147, 112, 219],
slateblue: [106, 90, 205],
darkslateblue: [72, 61, 139],
mediumslateblue: [123, 104, 238],
// Browns
brown: [165, 42, 42],
saddlebrown: [139, 69, 19],
sienna: [160, 82, 45],
chocolate: [210, 105, 30],
peru: [205, 133, 63],
sandybrown: [244, 164, 96],
burlywood: [222, 184, 135],
tan: [210, 180, 140],
rosybrown: [188, 143, 143],
goldenrod: [218, 165, 32],
darkgoldenrod: [184, 134, 11],
// Whites
snow: [255, 250, 250],
honeydew: [240, 255, 240],
mintcream: [245, 255, 250],
azure: [240, 255, 255],
ghostwhite: [248, 248, 255],
floralwhite: [255, 250, 240],
ivory: [255, 255, 240],
beige: [245, 245, 220],
linen: [250, 240, 230],
oldlace: [253, 245, 230],
antiquewhite: [250, 235, 215],
bisque: [255, 228, 196],
blanchedalmond: [255, 235, 205],
wheat: [245, 222, 179],
cornsilk: [255, 248, 220],
navajowhite: [255, 222, 173],
seashell: [255, 245, 238],
mistyrose: [255, 228, 225],
lavenderblush: [255, 240, 245],
// Slate colors
slategray: [112, 128, 144],
slategrey: [112, 128, 144],
lightslategray: [119, 136, 153],
lightslategrey: [119, 136, 153],
darkslategray: [47, 79, 79],
darkslategrey: [47, 79, 79],
// Transparent
transparent: [0, 0, 0, 0]
};
// ============================================================================
// BUILT-IN DARK SITES LIST
// ============================================================================
const BUILT_IN_DARK_SITES = [
'darkreader.org', '*.darkreader.org',
'discord.com', '*.discord.com',
'github.com', '*.github.com', 'gist.github.com',
'netflix.com', '*.netflix.com',
'twitch.tv', '*.twitch.tv',
'youtube.com', '*.youtube.com', 'music.youtube.com',
'reddit.com', '*.reddit.com', 'old.reddit.com',
'twitter.com', '*.twitter.com',
'x.com', '*.x.com',
'spotify.com', '*.spotify.com', 'open.spotify.com',
'slack.com', '*.slack.com',
'notion.so', '*.notion.so',
'figma.com', '*.figma.com',
'linear.app', '*.linear.app',
'vercel.com', '*.vercel.com',
'vitejs.dev', '*.vitejs.dev',
'vuejs.org', '*.vuejs.org',
'nuxt.com', '*.nuxt.com',
'astro.build', '*.astro.build',
'hulu.com', '*.hulu.com',
'disneyplus.com', '*.disneyplus.com',
'primevideo.com', '*.primevideo.com',
'hbomax.com', '*.hbomax.com',
'max.com', '*.max.com',
'crunchyroll.com', '*.crunchyroll.com',
'vscode.dev', '*.vscode.dev',
'codepen.io', '*.codepen.io',
'codesandbox.io', '*.codesandbox.io',
'replit.com', '*.replit.com',
'stackblitz.com', '*.stackblitz.com',
'jsfiddle.net',
'visualstudio.com', 'dev.azure.com',
'gitlab.com', '*.gitlab.com',
'bitbucket.org', '*.bitbucket.org',
'soundcloud.com', '*.soundcloud.com',
'bandcamp.com', '*.bandcamp.com',
'deezer.com', '*.deezer.com',
'tidal.com', '*.tidal.com',
'proton.me', '*.proton.me',
'protonmail.com', 'mail.protonmail.com',
'bitwarden.com', 'vault.bitwarden.com',
'1password.com', '*.1password.com',
'brave.com', '*.brave.com',
'duckduckgo.com', '*.duckduckgo.com',
'monkeytype.com', 'keybr.com',
'chess.com', '*.chess.com',
'lichess.org', '*.lichess.org',
'store.steampowered.com', 'steamcommunity.com'
];
// ============================================================================
// BUILT-IN DYNAMIC THEME FIXES
// ============================================================================
const BUILT_IN_DYNAMIC_FIXES = {
'google.com': {
invert: [],
css: '.gb_Td { background-color: var(--darkreader-neutral-background) !important; }\n.RNNXgb { background-color: #303134 !important; }\n.aajZCb { background-color: #303134 !important; }\n.sfbg { background-color: #202124 !important; }',
ignoreInlineStyle: ['.ytp-gradient-bottom'],
ignoreImageAnalysis: []
},
'youtube.com': {
invert: [],
css: 'html[dark] { --yt-spec-base-background: #0f0f0f !important; }\nytd-masthead { background-color: #0f0f0f !important; }',
ignoreInlineStyle: ['.ytp-gradient-bottom', '.ytp-gradient-top'],
ignoreImageAnalysis: []
},
'facebook.com': {
invert: [],
css: '.__fb-light-mode { --primary-background: #18191a !important; --secondary-background: #242526 !important; }',
ignoreInlineStyle: [],
ignoreImageAnalysis: []
},
'instagram.com': {
invert: [],
css: '',
ignoreInlineStyle: ['._ab1y'],
ignoreImageAnalysis: []
},
'amazon.com': {
invert: ['.a-button-text img', '.nav-logo-link img', '.s-image', '#landingImage'],
css: '#nav-belt { background-color: #131921 !important; }\n#nav-main { background-color: #232f3e !important; }\n.nav-sprite { filter: none !important; }',
ignoreInlineStyle: [],
ignoreImageAnalysis: ['.s-image', '#landingImage']
},
'ebay.com': {
invert: ['.s-item__image img'],
css: '',
ignoreInlineStyle: [],
ignoreImageAnalysis: ['.s-item__image img']
},
'wikipedia.org': {
invert: [],
css: '.mw-body { background-color: var(--darkreader-neutral-background) !important; }\n.vector-menu-portal .vector-menu-content { background-color: var(--darkreader-neutral-background) !important; }',
ignoreInlineStyle: [],
ignoreImageAnalysis: ['.mw-file-element', '.thumbimage']
},
'stackoverflow.com': {
invert: [],
css: '.s-prose code { background-color: #2d2d2d !important; border-color: #3e4446 !important; }\n.s-prose pre { background-color: #1e1e1e !important; }',
ignoreInlineStyle: [],
ignoreImageAnalysis: []
},
'linkedin.com': {
invert: [],
css: '',
ignoreInlineStyle: [],
ignoreImageAnalysis: ['.presence-entity__image', '.feed-shared-actor__avatar-image']
},
'docs.microsoft.com': {
invert: [],
css: '.theme-light { --theme-body-background: #1e1e1e !important; }',
ignoreInlineStyle: [],
ignoreImageAnalysis: []
},
'docs.google.com': {
invert: [],
css: '.docs-sheet-container { background-color: var(--darkreader-neutral-background) !important; }',
ignoreInlineStyle: ['.kix-page', '.grid-container'],
ignoreImageAnalysis: []
},
'maps.google.com': {
invert: [],
css: '',
ignoreInlineStyle: ['#scene'],
ignoreImageAnalysis: []
},
'notion.so': {
invert: [],
css: '',
ignoreInlineStyle: ['.notion-page-content'],
ignoreImageAnalysis: []
},
'trello.com': {
invert: [],
css: '',
ignoreInlineStyle: ['.board-canvas'],
ignoreImageAnalysis: []
}
};
// ============================================================================
// BUILT-IN INVERSION FIXES
// ============================================================================
const BUILT_IN_INVERSION_FIXES = {
'default': {
invert: ['img', 'video', 'picture', 'canvas', 'iframe', 'embed', 'object', 'svg image', '[style*="background-image"]:not([style*="gradient"])', '.emoji', '[class*="emoji"]'],
noinvert: ['img img', 'picture img', 'video video', 'svg[class*="icon"]', '[class*="icon"] svg', '.octicon', '.lucide', '[data-icon] svg', '[aria-hidden="true"][focusable="false"]'],
removebg: [],
css: ''
},
'google.com': {
invert: ['img:not(.gb_Pc)', '.lnXdpd'],
noinvert: ['.gb_Pc'],
removebg: [],
css: ''
},
'amazon.com': {
invert: ['.s-image', '#landingImage', '.a-declarative img'],
noinvert: ['#nav-logo-sprites'],
removebg: [],
css: '.nav-sprite { filter: none !important; }'
},
'wikipedia.org': {
invert: ['.mw-file-element', '.thumbimage'],
noinvert: [],
removebg: [],
css: ''
},
'stackoverflow.com': {
invert: ['.user-gravatar32 img', '.s-avatar img'],
noinvert: [],
removebg: [],
css: ''
},
'github.com': {
invert: ['.avatar', '.avatar-user'],
noinvert: ['.octicon'],
removebg: [],
css: ''
},
'linkedin.com': {
invert: ['.presence-entity__image', '.feed-shared-actor__avatar-image', '.EntityPhoto-circle-4'],
noinvert: [],
removebg: [],
css: ''
},
'medium.com': {
invert: ['figure img', '.progressiveMedia-image'],
noinvert: [],
removebg: [],
css: ''
},
'maps.google.com': {
invert: [],
noinvert: ['#scene', '.widget-scene'],
removebg: [],
css: ''
},
'docs.google.com': {
invert: [],
noinvert: ['.kix-page', '.docs-texteventtarget-iframe'],
removebg: [],
css: ''
}
};
// ============================================================================
// UTILITY FUNCTIONS
// ============================================================================
/**
* Throttle function execution to prevent excessive calls
* @param {Function} fn - Function to throttle
* @param {number} delay - Minimum delay between calls in ms
* @returns {Function} Throttled function
*/
function throttle(fn, delay) {
let lastCall = 0;
let lastResult;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
lastResult = fn.apply(this, args);
}
return lastResult;
};
}
/**
* Debounce function execution to prevent rapid successive calls
* @param {Function} fn - Function to debounce
* @param {number} delay - Delay in ms after last call
* @returns {Function} Debounced function
*/
function debounce(fn, delay) {
let timeout;
return function(...args) {
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(this, args), delay);
};
}
/**
* Clamp a value between min and max
* @param {number} value - Value to clamp
* @param {number} min - Minimum value
* @param {number} max - Maximum value
* @returns {number} Clamped value
*/
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
/**
* Extract hostname from URL
* @param {string} url - URL to parse
* @returns {string} Hostname or empty string
*/
function getURLHostname(url) {
try {
return new URL(url).hostname;
} catch (e) {
return '';
}
}
/**
* Check one URL pattern.
* Supports exact hosts, wildcards, /regex/ and /regex/flags.
* A leading ! marks an exclusion when used in a pattern list.
* @param {string} url - URL to check
* @param {string} pattern - Pattern to match
* @returns {boolean} True when the pattern matches
*/
function isURLPatternMatched(url, pattern) {
if (!pattern || typeof pattern !== 'string') {
return false;
}
const candidate = pattern.trim().replace(/^!\s*/, '');
if (!candidate) {
return false;
}
const hostname = (getURLHostname(url) || url).toLowerCase();
// Regex pattern: /pattern/ or /pattern/flags
const regexMatch = candidate.match(/^\/(.*)\/([dgimsuvy]*)$/i);
if (regexMatch) {
try {
return new RegExp(regexMatch[1], regexMatch[2]).test(url);
} catch (e) {
return false;
}
}
// Wildcard pattern: *.example.com
if (candidate.includes('*')) {
const escapedPattern = candidate
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escapedPattern + '$', 'i');
return regex.test(hostname) || regex.test(url);
}
// Exact match or subdomain match.
const normalized = candidate.toLowerCase();
return hostname === normalized || hostname.endsWith('.' + normalized);
}
/**
* Check if URL matches any pattern in the list.
* Positive matches enable the rule; a matching !pattern always excludes
* the URL, regardless of its position in the list. This makes rules such
* as ["*.gov.uk", "!legacy.*"] deterministic and easy to edit.
* @param {string} url - URL to check
* @param {Array} patterns - Array of patterns to match against
* @returns {boolean} True if matched and not excluded
*/
function isURLMatched(url, patterns) {
if (!patterns || !Array.isArray(patterns)) {
return false;
}
let matched = false;
for (const rawPattern of patterns) {
if (typeof rawPattern !== 'string') {
continue;
}
const pattern = rawPattern.trim();
if (!pattern || !isURLPatternMatched(url, pattern)) {
continue;
}
if (pattern.startsWith('!')) {
return false;
}
matched = true;
}
return matched;
}
/**
* Generate a unique identifier
* @returns {string} Unique ID
*/
function generateUID() {
return Math.random().toString(36).substring(2, 11);
}
/**
* Escape HTML special characters
* @param {string} str - String to escape
* @returns {string} Escaped string
*/
function escapeHtml(str) {
const htmlEntities = {
'&': '&',
'<': '<',
'>': '>',
'"': '"',
"'": '''
};
return String(str).replace(/[&<>"']/g, char => htmlEntities[char]);
}
/**
* Deep merge two objects
* @param {Object} target - Target object
* @param {Object} source - Source object
* @returns {Object} Merged object
*/
function deepMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key] || {}, source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
// ============================================================================
// SUN CALCULATOR (for sunrise/sunset automation)
// Based on NOAA Solar Calculator algorithms
// ============================================================================
const SunCalc = {
/**
* Calculate sunrise and sunset times for a given date and location
* @param {Date} date - Date to calculate for
* @param {number} lat - Latitude in degrees
* @param {number} lng - Longitude in degrees
* @returns {Object} Object with sunrise and sunset Date objects
*/
calculate(date, lat, lng) {
const rad = Math.PI / 180;
const dayMs = 1000 * 60 * 60 * 24;
const J1970 = 2440588;
const J2000 = 2451545;
// Convert date to Julian
const toJulian = d => d.valueOf() / dayMs - 0.5 + J1970;
const fromJulian = j => new Date((j + 0.5 - J1970) * dayMs);
const toDays = d => toJulian(d) - J2000;
// Obliquity of the ecliptic
const e = rad * 23.4397;
/**
* Calculate right ascension
*/
const rightAscension = (l, b) => {
return Math.atan2(
Math.sin(l) * Math.cos(e) - Math.tan(b) * Math.sin(e),
Math.cos(l)
);
};
/**
* Calculate declination
*/
const declination = (l, b) => {
return Math.asin(
Math.sin(b) * Math.cos(e) +
Math.cos(b) * Math.sin(e) * Math.sin(l)
);
};
/**
* Calculate solar mean anomaly
*/
const solarMeanAnomaly = d => {
return rad * (357.5291 + 0.98560028 * d);
};
/**
* Calculate ecliptic longitude
*/
const eclipticLongitude = M => {
const C = rad * (
1.9148 * Math.sin(M) +
0.02 * Math.sin(2 * M) +
0.0003 * Math.sin(3 * M)
);
const P = rad * 102.9372;
return M + C + P + Math.PI;
};
/**
* Calculate Julian cycle
*/
const julianCycle = (d, lw) => {
return Math.round(d - 0.0009 - lw / (2 * Math.PI));
};
/**
* Calculate approximate transit
*/
const approxTransit = (Ht, lw, n) => {
return 0.0009 + (Ht + lw) / (2 * Math.PI) + n;
};
/**
* Calculate solar transit in Julian days
*/
const solarTransitJ = (ds, M, L) => {
return J2000 + ds + 0.0053 * Math.sin(M) - 0.0069 * Math.sin(2 * L);
};
/**
* Calculate hour angle
*/
const hourAngle = (h, phi, d) => {
const cosH = (Math.sin(h) - Math.sin(phi) * Math.sin(d)) /
(Math.cos(phi) * Math.cos(d));
// Clamp to valid range
if (cosH < -1) return Math.PI;
if (cosH > 1) return 0;
return Math.acos(cosH);
};
/**
* Calculate sunset time in Julian days
*/
const getSetJ = (h, lw, phi, dec, n, M, L) => {
const w = hourAngle(h, phi, dec);
const a = approxTransit(w, lw, n);
return solarTransitJ(a, M, L);
};
try {
const lw = rad * -lng;
const phi = rad * lat;
const d = toDays(date);
const n = julianCycle(d, lw);
const ds = approxTransit(0, lw, n);
const M = solarMeanAnomaly(ds);
const L = eclipticLongitude(M);
const dec = declination(L, 0);
const Jnoon = solarTransitJ(ds, M, L);
// Sun altitude at sunrise/sunset
const h0 = rad * -0.833;
const Jset = getSetJ(h0, lw, phi, dec, n, M, L);
const Jrise = Jnoon - (Jset - Jnoon);
return {
sunrise: fromJulian(Jrise),
sunset: fromJulian(Jset),
noon: fromJulian(Jnoon)
};
} catch (e) {
console.warn('SunCalc: Calculation error', e);
return {
sunrise: null,
sunset: null,
noon: null
};
}
},
/**
* Check if it's currently dark (between sunset and sunrise)
* @param {Date} now - Current time
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @returns {boolean} True if dark
*/
isDark(now, lat, lng) {
const { sunrise, sunset } = this.calculate(now, lat, lng);
if (!sunrise || !sunset) {
return true; // Default to dark if calculation fails
}
return now < sunrise || now > sunset;
}
};
// ============================================================================
// DARK THEME DETECTOR
// Two-phase detection system to prevent white flash
// ============================================================================
const DarkThemeDetector = {
/**
* CSS selectors that indicate dark mode on html element (Phase 1 - early)
*/
earlyDarkSelectors: [
'html[data-theme="dark"]',
'html[data-color-mode="dark"]',
'html[data-dark-mode="true"]',
'html[data-bs-theme="dark"]',
'html[data-color-scheme="dark"]',
'html[data-mode="dark"]',
'html.dark',
'html.dark-mode',
'html.dark-theme',
'html.theme-dark',
':root[data-theme="dark"]',
':root[data-color-mode="dark"]',
':root.dark'
],
/**
* CSS selectors that indicate dark mode on body element (Phase 2)
*/
bodyDarkSelectors: [
'body[data-theme="dark"]',
'body[data-color-mode="dark"]',
'body[data-dark-mode="true"]',
'body[data-bs-theme="dark"]',
'body.dark',
'body.dark-mode',
'body.dark-theme',
'body.theme-dark',
'body[dark]'
],
/**
* Phase 1: Early detection before body is available
* Runs at document-start to prevent white flash
* @returns {Object} Detection result {isDark: boolean, reason: string|null}
*/
detectEarly() {
// Check for meta darkreader-lock tag
if (document.querySelector('meta[name="darkreader-lock"]')) {
return {
isDark: true,
reason: 'meta-lock'
};
}
// Check color-scheme meta tag
const colorSchemeMeta = document.querySelector('meta[name="color-scheme"]');
if (colorSchemeMeta) {
const content = colorSchemeMeta.getAttribute('content') || '';
if (content.includes('dark') && !content.includes('light')) {
return {
isDark: true,
reason: 'meta-color-scheme'
};
}
}
// Check early dark selectors on html element
if (this.hasEarlyDarkSelector()) {
return {
isDark: true,
reason: 'html-selector'
};
}
return {
isDark: false,
reason: null
};
},
/**
* Phase 2: Full detection after DOM is ready
* Can optionally use color sampling (disabled by default for performance)
* @param {boolean} useColorSampling - Whether to sample page colors
* @returns {Object} Detection result
*/
detect(useColorSampling = false) {
// Check for meta darkreader-lock tag
if (document.querySelector('meta[name="darkreader-lock"]')) {
return {
isDark: true,
reason: 'meta-lock'
};
}
// Temporarily disable our provisional style if present
const provisional = document.getElementById('darkmoder-provisional');
if (provisional) {
provisional.disabled = true;
}
try {
// Check color-scheme meta tag
const colorSchemeMeta = document.querySelector('meta[name="color-scheme"]');
if (colorSchemeMeta) {
const content = colorSchemeMeta.getAttribute('content') || '';
if (content.includes('dark') && !content.includes('light')) {
return {
isDark: true,
reason: 'meta-color-scheme'
};
}
}
// Check computed color-scheme property
if (!provisional && document.documentElement) {
try {
const computedStyle = getComputedStyle(document.documentElement);
const colorScheme = computedStyle.colorScheme ||
computedStyle.getPropertyValue('color-scheme');
if (colorScheme && colorScheme.includes('dark') && !colorScheme.includes('light')) {
return {
isDark: true,
reason: 'color-scheme-property'
};
}
} catch (e) {
// Ignore getComputedStyle errors
}
}
// Check CSS class/attribute selectors
if (this.hasDarkModeSelector()) {
return {
isDark: true,
reason: 'css-selector'
};
}
// Optional: Sample page colors
if (useColorSampling && document.body) {
const avgLuminance = this.samplePageColors();
if (avgLuminance < CONFIG.colorSamplingThreshold) {
return {
isDark: true,
reason: 'color-sampling'
};
}
}
return {
isDark: false,
reason: null
};
} finally {
// Re-enable provisional style
if (provisional) {
provisional.disabled = false;
}
}
},
/**
* Check if any early dark selector matches
* @returns {boolean} True if matched
*/
hasEarlyDarkSelector() {
for (const selector of this.earlyDarkSelectors) {
try {
if (document.querySelector(selector)) {
return true;
}
} catch (e) {
// Invalid selector, skip
}
}
return false;
},
/**
* Check if any dark mode selector matches (html or body)
* @returns {boolean} True if matched
*/
hasDarkModeSelector() {
// Check html selectors first
if (this.hasEarlyDarkSelector()) {
return true;
}
// Check body selectors
if (document.body) {
for (const selector of this.bodyDarkSelectors) {
try {
if (document.querySelector(selector)) {
return true;
}
} catch (e) {
// Invalid selector, skip
}
}
}
return false;
},
/**
* Calculate relative luminance from RGB values
* @param {number} r - Red (0-255)
* @param {number} g - Green (0-255)
* @param {number} b - Blue (0-255)
* @returns {number} Luminance (0-1)
*/
getLuminance(r, g, b) {
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
},
/**
* Parse a CSS color string to RGB
* @param {string} color - CSS color value
* @returns {Object|null} {r, g, b} or null
*/
parseColor(color) {
if (!color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)') {
return null;
}
const rgbMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (rgbMatch) {
return {
r: parseInt(rgbMatch[1], 10),
g: parseInt(rgbMatch[2], 10),
b: parseInt(rgbMatch[3], 10)
};
}
return null;
},
/**
* Sample colors from key page elements to estimate theme
* @returns {number} Average luminance (0-1)
*/
samplePageColors() {
const samples = [];
const MAX_SAMPLES = CONFIG.maxColorSamples;
// Sample key structural elements
const keyElements = [
document.documentElement,
document.body,
document.querySelector('main'),
document.querySelector('article'),
document.querySelector('#content'),
document.querySelector('.content'),
document.querySelector('#main'),
document.querySelector('.main'),
document.querySelector('header'),
document.querySelector('nav')
].filter(Boolean);
for (const el of keyElements) {
if (samples.length >= MAX_SAMPLES) break;
try {
const style = getComputedStyle(el);
const bgColor = style.backgroundColor;
const parsed = this.parseColor(bgColor);
if (parsed) {
const luminance = this.getLuminance(parsed.r, parsed.g, parsed.b);
// Weight key elements more heavily
samples.push(luminance, luminance, luminance);
}
} catch (e) {
// Skip elements that can't be computed
}
}
// Sample elements at grid points
const step = 200;
const maxWidth = Math.min(window.innerWidth, 800);
const maxHeight = Math.min(window.innerHeight, 600);
for (let x = step; x < maxWidth && samples.length < MAX_SAMPLES; x += step) {
for (let y = step; y < maxHeight && samples.length < MAX_SAMPLES; y += step) {
try {
const el = document.elementFromPoint(x, y);
if (!el) continue;
if (el.id && el.id.startsWith('darkmoder')) continue;
if (el.className && typeof el.className === 'string' &&
el.className.includes('darkmoder')) continue;
const style = getComputedStyle(el);
const bgColor = style.backgroundColor;
const parsed = this.parseColor(bgColor);
if (parsed) {
const luminance = this.getLuminance(parsed.r, parsed.g, parsed.b);
samples.push(luminance);
}
} catch (e) {
// Skip on error
}
}
}
if (samples.length === 0) {
return 1; // Default to light if no samples
}
return samples.reduce((a, b) => a + b, 0) / samples.length;
},
/**
* Observe theme changes and call callback when detected
* @param {Function} callback - Called with detection result
* @returns {Function} Cleanup function
*/
observe(callback) {
let lastState = null;
const check = () => {
const result = this.detect(false);
if (lastState === null || lastState !== result.isDark) {
lastState = result.isDark;
callback(result);
}
};
// Debounced check
const debouncedCheck = debounce(check, 100);
// Observe html element
const htmlObserver = new MutationObserver(debouncedCheck);
htmlObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-color-mode', 'data-dark-mode', 'data-bs-theme', 'style']
});
// Observe body element when available
let bodyObserver = null;
if (document.body) {
bodyObserver = new MutationObserver(debouncedCheck);
bodyObserver.observe(document.body, {
attributes: true,
attributeFilter: ['class', 'data-theme', 'data-color-mode', 'data-dark-mode', 'style']
});
}
// Listen for system color scheme changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const mediaHandler = () => debouncedCheck();
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', mediaHandler);
} else if (mediaQuery.addListener) {
mediaQuery.addListener(mediaHandler);
}
// Initial check
check();
// Return cleanup function
return () => {
htmlObserver.disconnect();
if (bodyObserver) {
bodyObserver.disconnect();
}
if (mediaQuery.removeEventListener) {
mediaQuery.removeEventListener('change', mediaHandler);
} else if (mediaQuery.removeListener) {
mediaQuery.removeListener(mediaHandler);
}
};
}
};
// ============================================================================
// COLOR MANIPULATION
// ============================================================================
const Color = {
/**
* Parse a CSS color string to RGBA object
* @param {string} input - CSS color value
* @returns {Object|null} {r, g, b, a} or null if invalid
*/
parse(input) {
if (!input ||
input === 'transparent' ||
input === 'inherit' ||
input === 'initial' ||
input === 'currentColor' ||
input === 'none' ||
input === 'unset') {
return null;
}
input = input.trim().toLowerCase();
// Hex color: #RGB, #RGBA, #RRGGBB, #RRGGBBAA
let match = input.match(/^#([0-9a-f]{3,8})$/i);
if (match) {
const hex = match[1];
if (hex.length === 3 || hex.length === 4) {
const [r, g, b, a = 'f'] = hex;
return {
r: parseInt(r + r, 16),
g: parseInt(g + g, 16),
b: parseInt(b + b, 16),
a: parseInt(a + a, 16) / 255
};
}
if (hex.length === 6 || hex.length === 8) {
return {
r: parseInt(hex.slice(0, 2), 16),
g: parseInt(hex.slice(2, 4), 16),
b: parseInt(hex.slice(4, 6), 16),
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1
};
}
}
// RGB/RGBA with commas: rgb(R, G, B) or rgba(R, G, B, A)
match = input.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
if (match) {
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: match[4] !== undefined ? parseFloat(match[4]) : 1
};
}
// RGB/RGBA with spaces: rgb(R G B) or rgb(R G B / A)
match = input.match(/^rgba?\(\s*(\d+)\s+(\d+)\s+(\d+)\s*(?:\/\s*([\d.]+%?))?\s*\)$/);
if (match) {
let alpha = 1;
if (match[4]) {
alpha = match[4].endsWith('%')
? parseFloat(match[4]) / 100
: parseFloat(match[4]);
}
return {
r: parseInt(match[1], 10),
g: parseInt(match[2], 10),
b: parseInt(match[3], 10),
a: alpha
};
}
// HSL/HSLA: hsl(H, S%, L%) or hsla(H, S%, L%, A)
match = input.match(/^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+))?\s*\)$/);
if (match) {
return this.hslToRgb(
parseFloat(match[1]),
parseFloat(match[2]),
parseFloat(match[3]),
match[4] !== undefined ? parseFloat(match[4]) : 1
);
}
// Named color
const named = this.getNamedColor(input);
if (named) {
return named;
}
return null;
},
/**
* Get named color RGB values
* @param {string} name - Color name
* @returns {Object|null} {r, g, b, a} or null
*/
getNamedColor(name) {
const color = NAMED_COLORS[name];
if (color) {
if (color.length === 4) {
return { r: color[0], g: color[1], b: color[2], a: color[3] };
}
return { r: color[0], g: color[1], b: color[2], a: 1 };
}
return null;
},
/**
* Convert RGB to HSL
* @param {number} r - Red (0-255)
* @param {number} g - Green (0-255)
* @param {number} b - Blue (0-255)
* @returns {Object} {h, s, l} with h in degrees, s and l as percentages
*/
rgbToHsl(r, g, b) {
r /= 255;
g /= 255;
b /= 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s;
const l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r:
h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
break;
case g:
h = ((b - r) / d + 2) / 6;
break;
case b:
h = ((r - g) / d + 4) / 6;
break;
}
}
return {
h: h * 360,
s: s * 100,
l: l * 100
};
},
/**
* Convert HSL to RGB
* @param {number} h - Hue (0-360)
* @param {number} s - Saturation (0-100)
* @param {number} l - Lightness (0-100)
* @param {number} a - Alpha (0-1)
* @returns {Object} {r, g, b, a}
*/
hslToRgb(h, s, l, a = 1) {
h /= 360;
s /= 100;
l /= 100;
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
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;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
return {
r: Math.round(r * 255),
g: Math.round(g * 255),
b: Math.round(b * 255),
a: a
};
},
/**
* Convert color object to rgba() string
* @param {Object} color - {r, g, b, a}
* @returns {string} CSS rgba() value
*/
toRgba(color) {
return `rgba(${Math.round(color.r)}, ${Math.round(color.g)}, ${Math.round(color.b)}, ${color.a})`;
},
/**
* Convert color object to hex string
* @param {Object} color - {r, g, b}
* @returns {string} CSS hex value
*/
toHex(color) {
const r = Math.round(color.r).toString(16).padStart(2, '0');
const g = Math.round(color.g).toString(16).padStart(2, '0');
const b = Math.round(color.b).toString(16).padStart(2, '0');
return `#${r}${g}${b}`;
},
/**
* Calculate relative luminance
* @param {Object} color - {r, g, b}
* @returns {number} Luminance (0-1)
*/
getLuminance(color) {
const rsrgb = color.r / 255;
const gsrgb = color.g / 255;
const bsrgb = color.b / 255;
const rlin = rsrgb <= 0.03928
? rsrgb / 12.92
: Math.pow((rsrgb + 0.055) / 1.055, 2.4);
const glin = gsrgb <= 0.03928
? gsrgb / 12.92
: Math.pow((gsrgb + 0.055) / 1.055, 2.4);
const blin = bsrgb <= 0.03928
? bsrgb / 12.92
: Math.pow((bsrgb + 0.055) / 1.055, 2.4);
return 0.2126 * rlin + 0.7152 * glin + 0.0722 * blin;
},
/**
* Check if color is light
* @param {Object} color - {r, g, b}
* @returns {boolean} True if light
*/
isLight(color) {
return this.getLuminance(color) > 0.179;
},
/**
* Check if color is dark
* @param {Object} color - {r, g, b}
* @returns {boolean} True if dark
*/
isDark(color) {
return !this.isLight(color);
},
/**
* Modify background color for dark mode
* @param {Object} color - Original color
* @param {Object} theme - Theme settings
* @returns {string|null} Modified CSS color or null
*/
modifyBackgroundColor(color, theme) {
if (!color || color.a < 0.1) {
return null;
}
const hsl = this.rgbToHsl(color.r, color.g, color.b);
const luminance = this.getLuminance(color);
// Dark mode: invert lightness for light backgrounds
if (theme.mode === 1) {
if (luminance > 0.5) {
// Light background -> make dark
hsl.l = Math.max(5, Math.min(15, 100 - hsl.l));
} else if (luminance > 0.2) {
// Medium background -> make darker
hsl.l = Math.max(5, hsl.l * 0.3);
}
// Clamp to dark range
hsl.l = clamp(hsl.l, 0, 20);
}
// Apply brightness adjustment
hsl.l = hsl.l * (theme.brightness / 100);
// Apply contrast adjustment
hsl.l = ((hsl.l - 50) * (theme.contrast / 100)) + 50;
hsl.l = clamp(hsl.l, 0, 100);
// Apply sepia effect
if (theme.sepia > 0) {
hsl.h = hsl.h * (1 - theme.sepia / 100) + 40 * (theme.sepia / 100);
hsl.s = Math.max(hsl.s, theme.sepia * 0.1);
}
// Apply grayscale
hsl.s = hsl.s * (1 - theme.grayscale / 100);
const result = this.hslToRgb(hsl.h, hsl.s, hsl.l, color.a);
return this.toRgba(result);
},
/**
* Modify text color for dark mode
* @param {Object} color - Original color
* @param {Object} theme - Theme settings
* @returns {string|null} Modified CSS color or null
*/
modifyTextColor(color, theme) {
if (!color) {
return null;
}
const hsl = this.rgbToHsl(color.r, color.g, color.b);
const luminance = this.getLuminance(color);
// Dark mode: make dark text light
if (theme.mode === 1) {
if (luminance < 0.5) {
// Dark text -> make light
hsl.l = Math.max(75, Math.min(95, 100 - hsl.l));
} else if (luminance < 0.8) {
// Medium text -> make lighter
hsl.l = Math.min(95, hsl.l + 40);
}
// Clamp to light range
hsl.l = clamp(hsl.l, 70, 95);
}
// Apply brightness adjustment
hsl.l = hsl.l * (theme.brightness / 100);
// Apply contrast adjustment
hsl.l = ((hsl.l - 50) * (theme.contrast / 100)) + 50;
hsl.l = clamp(hsl.l, 0, 100);
// Apply sepia effect
if (theme.sepia > 0) {
hsl.h = hsl.h * (1 - theme.sepia / 100) + 40 * (theme.sepia / 100);
}
// Apply grayscale
hsl.s = hsl.s * (1 - theme.grayscale / 100);
const result = this.hslToRgb(hsl.h, hsl.s, hsl.l, color.a);
return this.toRgba(result);
},
/**
* Modify border color for dark mode
* @param {Object} color - Original color
* @param {Object} theme - Theme settings
* @returns {string|null} Modified CSS color or null
*/
modifyBorderColor(color, theme) {
if (!color || color.a < 0.1) {
return null;
}
const hsl = this.rgbToHsl(color.r, color.g, color.b);
// Dark mode: set border to medium gray
if (theme.mode === 1) {
hsl.l = clamp(30, 20, 50);
}
// Apply grayscale
hsl.s = hsl.s * (1 - theme.grayscale / 100);
const result = this.hslToRgb(hsl.h, hsl.s, hsl.l, color.a);
return this.toRgba(result);
}
};
// ============================================================================
// CSS MODIFIER
// ============================================================================
const CSSModifier = {
/**
* Process a single CSS declaration
* @param {string} property - CSS property name
* @param {string} value - CSS property value
* @param {Object} theme - Theme settings
* @returns {string|null} Modified value or null
*/
processDeclaration(property, value, theme) {
const prop = property.toLowerCase();
// Background color
if (prop === 'background-color' ||
(prop === 'background' && !value.includes('url') && !value.includes('gradient'))) {
const color = Color.parse(value);
if (color && color.a > 0.1) {
return Color.modifyBackgroundColor(color, theme);
}
}
// Text color
if (prop === 'color') {
const color = Color.parse(value);
if (color) {
return Color.modifyTextColor(color, theme);
}
}
// Border colors
if (prop.includes('border') && prop.includes('color')) {
const color = Color.parse(value);
if (color && color.a > 0.1) {
return Color.modifyBorderColor(color, theme);
}
}
// Outline color
if (prop === 'outline-color') {
const color = Color.parse(value);
if (color && color.a > 0.1) {
return Color.modifyBorderColor(color, theme);
}
}
// Box shadow - simplify in dark mode
if (prop === 'box-shadow' && value !== 'none') {
return 'none';
}
// Text shadow - simplify in dark mode
if (prop === 'text-shadow' && value !== 'none') {
return 'none';
}
return null;
},
/**
* Process a CSS rule
* @param {CSSStyleRule} rule - CSS rule
* @param {Object} theme - Theme settings
* @param {string} selectorPrefix - Optional selector prefix
* @returns {string|null} Modified CSS rule string or null
*/
processRule(rule, theme, selectorPrefix = '') {
if (!rule.style) {
return null;
}
const modifications = [];
const selector = selectorPrefix
? `${selectorPrefix} ${rule.selectorText}`
: rule.selectorText;
for (let i = 0; i < rule.style.length; i++) {
const property = rule.style[i];
const value = rule.style.getPropertyValue(property);
const priority = rule.style.getPropertyPriority(property);
const modified = this.processDeclaration(property, value, theme);
if (modified) {
const important = priority ? ' !important' : '';
modifications.push(`${property}: ${modified}${important}`);
}
}
if (modifications.length > 0) {
return `${selector} { ${modifications.join('; ')}; }`;
}
return null;
},
/**
* Process an entire stylesheet
* @param {CSSStyleSheet} sheet - Stylesheet to process
* @param {Object} theme - Theme settings
* @returns {string} Modified CSS string
*/
processStyleSheet(sheet, theme, root = document) {
const rules = [];
const MAX_RULES = CONFIG.maxCSSRules;
let ruleCount = 0;
try {
const cssRules = sheet.cssRules || sheet.rules;
if (!cssRules) {
return '';
}
for (const rule of cssRules) {
if (ruleCount++ > MAX_RULES) {
break;
}
// Style rule
if (rule.type === CSSRule.STYLE_RULE) {
const modified = this.processRule(rule, theme);
if (modified) {
rules.push(modified);
}
}
// Media rule
else if (rule.type === CSSRule.MEDIA_RULE) {
const mediaRules = [];
for (const innerRule of rule.cssRules) {
if (ruleCount++ > MAX_RULES) {
break;
}
if (innerRule.type === CSSRule.STYLE_RULE) {
const modified = this.processRule(innerRule, theme);
if (modified) {
mediaRules.push(modified);
}
}
}
if (mediaRules.length > 0) {
rules.push(`@media ${rule.conditionText} { ${mediaRules.join(' ')} }`);
}
}
// Supports rule
else if (rule.type === CSSRule.SUPPORTS_RULE) {
const supportsRules = [];
for (const innerRule of rule.cssRules) {
if (ruleCount++ > MAX_RULES) {
break;
}
if (innerRule.type === CSSRule.STYLE_RULE) {
const modified = this.processRule(innerRule, theme);
if (modified) {
supportsRules.push(modified);
}
}
}
if (supportsRules.length > 0) {
rules.push(`@supports ${rule.conditionText} { ${supportsRules.join(' ')} }`);
}
}
}
} catch (e) {
// CORS error - expected for external stylesheets
}
return rules.join('\n');
}
};
// ============================================================================
// SITE FIXES PROCESSOR
// Processes Dark Reader style fix syntax
// ============================================================================
const SiteFixesProcessor = {
/**
* Normalize text or object fixes into one internal shape.
* @param {string|Object|null} fix - Fix source
* @returns {Object|null} Normalized fix
*/
normalizeFix(fix) {
if (!fix) {
return null;
}
if (typeof fix === 'string') {
return this.parseFixes(fix);
}
if (typeof fix !== 'object') {
return null;
}
return {
invert: Array.isArray(fix.invert) ? [...fix.invert] : [],
css: typeof fix.css === 'string' ? fix.css : '',
ignoreInlineStyle: Array.isArray(fix.ignoreInlineStyle) ? [...fix.ignoreInlineStyle] : [],
ignoreImageAnalysis: Array.isArray(fix.ignoreImageAnalysis) ? [...fix.ignoreImageAnalysis] : [],
noInvert: Array.isArray(fix.noInvert) ? [...fix.noInvert] : (Array.isArray(fix.noinvert) ? [...fix.noinvert] : []),
removeBg: Array.isArray(fix.removeBg) ? [...fix.removeBg] : (Array.isArray(fix.removebg) ? [...fix.removebg] : [])
};
},
/**
* Merge fixes in order; later fixes win by appending CSS last.
* @param {...(string|Object|null)} fixes - Fixes to merge
* @returns {Object|null} Merged fix
*/
mergeFixes(...fixes) {
const merged = {
invert: [],
css: '',
ignoreInlineStyle: [],
ignoreImageAnalysis: [],
noInvert: [],
removeBg: []
};
let hasFix = false;
for (const fix of fixes) {
const normalized = this.normalizeFix(fix);
if (!normalized) {
continue;
}
hasFix = true;
for (const key of ['invert', 'ignoreInlineStyle', 'ignoreImageAnalysis', 'noInvert', 'removeBg']) {
merged[key].push(...(normalized[key] || []));
merged[key] = [...new Set(merged[key])];
}
if (normalized.css) {
merged.css += `${merged.css ? '\n' : ''}${normalized.css}`;
}
}
return hasFix ? merged : null;
},
/**
* Parse site fixes text into structured object
* Supports: INVERT, CSS, IGNORE INLINE STYLE, IGNORE IMAGE ANALYSIS, NO INVERT, REMOVE BG
* @param {string} fixText - Fix text in Dark Reader format
* @returns {Object|null} Parsed fixes object
*/
parseFixes(fixText) {
if (fixText && typeof fixText === 'object') {
return this.normalizeFix(fixText);
}
if (!fixText || typeof fixText !== 'string') {
return null;
}
const result = {
invert: [],
css: '',
ignoreInlineStyle: [],
ignoreImageAnalysis: [],
noInvert: [],
removeBg: []
};
const lines = fixText.split('\n');
let currentSection = null;
let cssLines = [];
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
// Section headers
if (trimmed === 'INVERT') {
currentSection = 'invert';
} else if (trimmed === 'CSS') {
currentSection = 'css';
} else if (trimmed === 'IGNORE INLINE STYLE') {
currentSection = 'ignoreInlineStyle';
} else if (trimmed === 'IGNORE IMAGE ANALYSIS') {
currentSection = 'ignoreImageAnalysis';
} else if (trimmed === 'NO INVERT') {
currentSection = 'noInvert';
} else if (trimmed === 'REMOVE BG') {
currentSection = 'removeBg';
} else if (currentSection) {
// Content lines
if (currentSection === 'css') {
cssLines.push(line); // Preserve original indentation
} else if (Array.isArray(result[currentSection])) {
result[currentSection].push(trimmed);
}
}
}
result.css = cssLines.join('\n');
return result;
},
/**
* Apply color template variables to CSS
* Replaces ${white} and ${black} with theme colors
* @param {string} css - CSS with template variables
* @param {Object} theme - Theme settings
* @returns {string} CSS with variables replaced
*/
applyColorTemplate(css, theme) {
const bg = theme.darkSchemeBackgroundColor;
const text = theme.darkSchemeTextColor;
return css
.replace(/\$\{white\}/g, bg)
.replace(/\$\{black\}/g, text)
.replace(/var\(--darkreader-neutral-background\)/g, bg)
.replace(/var\(--darkreader-neutral-text\)/g, text);
},
/**
* Generate CSS from parsed fixes
* @param {Object} fixes - Parsed fixes object
* @param {Object} theme - Theme settings
* @returns {string} Generated CSS
*/
generateFixCSS(fixes, theme) {
fixes = this.normalizeFix(fixes);
if (!fixes) {
return '';
}
let css = '';
// INVERT selectors
if (fixes.invert && fixes.invert.length > 0) {
const selector = fixes.invert.join(', ');
css += `${selector} {\n`;
css += ` filter: invert(1) hue-rotate(180deg) !important;\n`;
css += `}\n\n`;
}
// NO INVERT selectors (counter inversion for Filter mode)
if (fixes.noInvert && fixes.noInvert.length > 0) {
const selector = fixes.noInvert.join(', ');
css += `${selector} {\n`;
css += ` filter: none !important;\n`;
css += `}\n\n`;
}
// REMOVE BG selectors
if (fixes.removeBg && fixes.removeBg.length > 0) {
const selector = fixes.removeBg.join(', ');
css += `${selector} {\n`;
css += ` background-image: none !important;\n`;
css += ` background-color: #000 !important;\n`;
css += `}\n\n`;
}
// Custom CSS with color template replacement
if (fixes.css) {
css += this.applyColorTemplate(fixes.css, theme);
css += '\n';
}
return css;
}
};
// ============================================================================
// HDR TONE MAPPING
// ============================================================================
const HDRToneMapper = {
/**
* Check whether the display advertises a high dynamic range.
* @returns {boolean} True when the browser reports HDR output
*/
isDisplayHDR() {
try {
return Boolean(window.matchMedia?.('(dynamic-range: high)')?.matches);
} catch (e) {
return false;
}
},
/**
* Compress extreme highlights while retaining dark-theme detail.
* @param {string} value - CSS color value
* @param {number} amount - Highlight compression from 0 to 1
* @returns {string} Tone-mapped color or the original value
*/
toneMapColor(value, amount = 0.14) {
const color = Color.parse(value);
if (!color) {
return value;
}
const compression = clamp(Number(amount), 0, 1);
const mapChannel = (channel) => {
const normalized = clamp(channel / 255, 0, 1);
// Leave shadows intact and gently compress HDR highlights.
const mapped = normalized < 0.5
? normalized * (1 + compression * 0.08)
: 0.5 + (normalized - 0.5) * (1 - compression);
return clamp(mapped * 255, 0, 255);
};
const mapped = {
r: mapChannel(color.r),
g: mapChannel(color.g),
b: mapChannel(color.b),
a: color.a
};
return color.a < 1 ? Color.toRgba(mapped) : Color.toHex(mapped);
},
/**
* Apply HDR-aware adjustments to a resolved theme.
* @param {Object} theme - Resolved theme settings
* @param {boolean} displayHDR - Optional capability override for tests
* @returns {Object} Theme safe for HDR output
*/
apply(theme, displayHDR = this.isDisplayHDR()) {
if (!theme || !displayHDR) {
return theme;
}
const mapped = { ...theme };
for (const key of [
'darkSchemeBackgroundColor',
'darkSchemeTextColor',
'lightSchemeBackgroundColor',
'lightSchemeTextColor',
'selectionBgColor',
'selectionTextColor',
'linkColor',
'visitedLinkColor',
'borderColor'
]) {
if (mapped[key]) {
mapped[key] = this.toneMapColor(mapped[key]);
}
}
mapped.contrast = clamp(Number(mapped.contrast || 100) * 0.97, 50, 150);
return mapped;
}
};
// ============================================================================
// LIGHT ELEMENT PINS
// Session-only right-click overrides for elements that should stay light.
// ============================================================================
const LightPinManager = {
pinnedElements: new Set(),
style: null,
menu: null,
outsideHandler: null,
contextHandler: null,
engineName: null,
theme: null,
/**
* Install the opt-in context menu used to pin page elements.
*/
install() {
if (this.contextHandler || typeof document === 'undefined') {
return;
}
this.contextHandler = (event) => {
if (event.defaultPrevented || event.button !== 2) {
return;
}
const target = event.target?.nodeType === Node.ELEMENT_NODE
? event.target
: event.target?.parentElement;
if (!target || target === document.documentElement || target === document.body ||
target.closest?.('#darkmoder-ui, darkmoder-ui, #darkmoder-pin-menu')) {
return;
}
event.preventDefault();
this.showMenu(target, event.clientX, event.clientY);
};
document.addEventListener('contextmenu', this.contextHandler, true);
},
/**
* Toggle a light pin on an element.
* @param {Element} element - Element to pin or unpin
* @returns {boolean} True when the element is now pinned
*/
toggle(element) {
if (!element || element.nodeType !== Node.ELEMENT_NODE) {
return false;
}
if (this.pinnedElements.has(element)) {
this.pinnedElements.delete(element);
element.removeAttribute('data-darkmoder-light-pin');
this.update();
return false;
}
this.pinnedElements.add(element);
element.setAttribute('data-darkmoder-light-pin', '1');
this.update();
return true;
},
/**
* Render the pin CSS for the active engine.
* @param {string|null} engineName - Active engine name
* @param {Object|null} theme - Active theme
*/
update(engineName = undefined, theme = undefined) {
if (engineName !== undefined) {
this.engineName = engineName;
}
if (theme !== undefined) {
this.theme = theme;
}
for (const element of this.pinnedElements) {
if (!element.isConnected) {
this.pinnedElements.delete(element);
element.removeAttribute?.('data-darkmoder-light-pin');
}
}
if (!this.style && typeof document !== 'undefined') {
this.style = document.createElement('style');
this.style.id = 'darkmoder-light-pins';
(document.head || document.documentElement)?.appendChild(this.style);
}
if (!this.style || !this.engineName || this.pinnedElements.size === 0) {
if (this.style) {
this.style.textContent = '';
}
return;
}
const selector = '[data-darkmoder-light-pin="1"]';
const isFilterEngine = this.engineName === 'filter' || this.engineName === 'filterPlus';
const brightness = Math.max(0.01, Number(this.theme?.brightness || 100) / 100);
const contrast = Math.max(0.01, Number(this.theme?.contrast || 100) / 100);
const correction = isFilterEngine
? `invert(1) hue-rotate(180deg) brightness(${1 / brightness}) contrast(${1 / contrast})`
: 'none';
this.style.textContent = `
${selector} {
filter: ${correction} !important;
background-color: #ffffff !important;
color: #111111 !important;
border-color: #c8c8c8 !important;
color-scheme: light !important;
}
${selector} * {
color-scheme: light !important;
}
`;
},
/**
* Show the lightweight element action menu.
* @param {Element} element - Target element
* @param {number} x - Pointer X coordinate
* @param {number} y - Pointer Y coordinate
*/
showMenu(element, x, y) {
this.closeMenu();
const menu = document.createElement('div');
menu.id = 'darkmoder-pin-menu';
menu.style.cssText = [
'position:fixed',
'z-index:2147483647',
`left:${Math.min(x, Math.max(8, window.innerWidth - 220))}px`,
`top:${Math.min(y, Math.max(8, window.innerHeight - 92))}px`,
'min-width:200px',
'padding:6px',
'border:1px solid #45475a',
'border-radius:8px',
'background:#1e1e2e',
'box-shadow:0 6px 24px rgba(0,0,0,.45)',
'font:12px -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif'
].join(';');
const action = document.createElement('button');
action.type = 'button';
action.textContent = this.pinnedElements.has(element) ? 'Remove light pin' : 'Keep element light';
action.style.cssText = 'display:block;width:100%;padding:8px;border:0;border-radius:5px;background:#89b4fa;color:#1e1e2e;cursor:pointer;text-align:left;';
action.addEventListener('click', () => {
this.toggle(element);
this.closeMenu();
});
const cancel = document.createElement('button');
cancel.type = 'button';
cancel.textContent = 'Cancel';
cancel.style.cssText = 'display:block;width:100%;margin-top:4px;padding:6px;border:0;background:transparent;color:#cdd6f4;cursor:pointer;text-align:left;';
cancel.addEventListener('click', () => this.closeMenu());
menu.append(action, cancel);
(document.body || document.documentElement).appendChild(menu);
this.menu = menu;
this.outsideHandler = (event) => {
if (!menu.contains(event.target)) {
this.closeMenu();
}
};
document.addEventListener('mousedown', this.outsideHandler, true);
},
closeMenu() {
if (this.menu) {
this.menu.remove();
this.menu = null;
}
if (this.outsideHandler) {
document.removeEventListener('mousedown', this.outsideHandler, true);
this.outsideHandler = null;
}
}
};
// ============================================================================
// THEME ENGINES
// ============================================================================
// --------------------------------------------------------------------------
// Filter Engine - Simple CSS filter inversion
// --------------------------------------------------------------------------
const FilterEngine = {
style: null,
/**
* Generate filter CSS
* @param {Object} theme - Theme settings
* @returns {string} CSS string
*/
create(theme, fixes = null) {
const brightness = theme.brightness / 100;
const contrast = theme.contrast / 100;
const sepia = theme.sepia / 100;
const grayscale = theme.grayscale / 100;
const fixCSS = SiteFixesProcessor.generateFixCSS(fixes, theme);
return `
html {
filter: invert(1) hue-rotate(180deg)
brightness(${brightness})
contrast(${contrast})
sepia(${sepia})
grayscale(${grayscale}) !important;
background-color: white !important;
}
html img,
html video,
html picture,
html canvas,
html [style*="background-image"]:not([style*="gradient"]),
html iframe,
html embed,
html object,
html svg image {
filter: invert(1) hue-rotate(180deg) !important;
}
html [data-darkmoder-inline-bgcolor] {
background-color: inherit !important;
}
${fixCSS}
`;
},
/**
* Apply filter engine
* @param {Object} theme - Theme settings
*/
apply(theme, fixes = null) {
this.remove();
this.style = document.createElement('style');
this.style.id = 'darkmoder-filter';
this.style.textContent = this.create(theme, fixes);
const target = document.head || document.documentElement;
target.appendChild(this.style);
},
/**
* Remove filter engine styles
*/
remove() {
if (this.style) {
this.style.remove();
this.style = null;
}
// Also remove any lingering filter style
const existing = document.getElementById('darkmoder-filter');
if (existing) {
existing.remove();
}
}
};
// --------------------------------------------------------------------------
// Filter+ Engine - SVG filter based inversion (better colors)
// --------------------------------------------------------------------------
const FilterPlusEngine = {
style: null,
svgContainer: null,
/**
* Get SVG filter definition
* @returns {string} SVG markup
*/
getSVGFilter() {
return `
`;
},
/**
* Generate filter+ CSS
* @param {Object} theme - Theme settings
* @returns {string} CSS string
*/
create(theme, fixes = null) {
const brightness = theme.brightness / 100;
const contrast = theme.contrast / 100;
const sepia = theme.sepia / 100;
const grayscale = theme.grayscale / 100;
const fixCSS = SiteFixesProcessor.generateFixCSS(fixes, theme);
return `
html {
filter: url(#darkmoder-filter)
brightness(${brightness})
contrast(${contrast})
sepia(${sepia})
grayscale(${grayscale}) !important;
}
html img,
html video,
html picture,
html canvas,
html [style*="background-image"]:not([style*="gradient"]),
html iframe,
html embed,
html object {
filter: url(#darkmoder-filter) !important;
}
${fixCSS}
`;
},
/**
* Insert SVG filter into document
*/
insertSVG() {
if (this.svgContainer) {
return;
}
this.svgContainer = document.createElement('div');
this.svgContainer.id = 'darkmoder-svg-filters';
this.svgContainer.innerHTML = this.getSVGFilter();
const target = document.body || document.documentElement;
target.appendChild(this.svgContainer);
},
/**
* Apply filter+ engine
* @param {Object} theme - Theme settings
*/
apply(theme, fixes = null) {
this.remove();
// Insert SVG when body is available
if (document.body) {
this.insertSVG();
} else {
document.addEventListener('DOMContentLoaded', () => this.insertSVG(), { once: true });
}
this.style = document.createElement('style');
this.style.id = 'darkmoder-filterplus';
this.style.textContent = this.create(theme, fixes);
const target = document.head || document.documentElement;
target.appendChild(this.style);
},
/**
* Remove filter+ engine styles
*/
remove() {
if (this.style) {
this.style.remove();
this.style = null;
}
if (this.svgContainer) {
this.svgContainer.remove();
this.svgContainer = null;
}
// Also remove any lingering elements
const existingStyle = document.getElementById('darkmoder-filterplus');
if (existingStyle) {
existingStyle.remove();
}
const existingSvg = document.getElementById('darkmoder-svg-filters');
if (existingSvg) {
existingSvg.remove();
}
}
};
// --------------------------------------------------------------------------
// Dynamic Engine - Intelligent color analysis and modification
// This is the most sophisticated engine with performance optimizations
// --------------------------------------------------------------------------
const DynamicEngine = {
styles: [],
observer: null,
processedSheets: new WeakMap(),
shadowRoots: new Set(),
shadowObservers: [],
shadowRootStyles: new WeakMap(),
originalAttachShadow: null,
theme: null,
fixes: null,
ignoreInlineStyle: new Set(),
ignoreImageAnalysis: new Set(),
/**
* Generate root CSS with base dark theme styles
* @param {Object} theme - Theme settings
* @returns {string} CSS string
*/
createRootCSS(theme) {
const bg = theme.darkSchemeBackgroundColor;
const text = theme.darkSchemeTextColor;
const border = theme.borderColor || '#3a3d3e';
const link = theme.linkColor || '#3391ff';
const visitedLink = theme.visitedLinkColor || '#9e6eff';
// Selection colors
const selectionBg = theme.selectionColor === 'auto'
? (theme.selectionBgColor || '#004daa')
: theme.selectionBgColor;
const selectionText = theme.selectionColor === 'auto'
? (theme.selectionTextColor || '#ffffff')
: theme.selectionTextColor;
// Optional font customization
let fontCSS = '';
if (theme.useFont && theme.fontFamily) {
fontCSS = `
html, body, input, textarea, select, button {
font-family: ${theme.fontFamily} !important;
}
`;
}
// Optional text stroke
let textStrokeCSS = '';
if (theme.textStroke > 0) {
const strokeWidth = theme.textStroke * 0.1;
textStrokeCSS = `
body * {
-webkit-text-stroke: ${strokeWidth}px !important;
}
`;
}
return `
/* Root element styles */
html {
background-color: ${bg} !important;
color-scheme: dark !important;
}
html, body {
background-color: ${bg} !important;
color: ${text} !important;
}
${fontCSS}
${textStrokeCSS}
/* Links */
a {
color: ${link} !important;
}
a:visited {
color: ${visitedLink} !important;
}
/* Selection */
::selection {
background-color: ${selectionBg} !important;
color: ${selectionText} !important;
}
::-moz-selection {
background-color: ${selectionBg} !important;
color: ${selectionText} !important;
}
/* Form elements */
input, textarea, select, button {
background-color: #1c1e1f !important;
color: ${text} !important;
border-color: ${border} !important;
}
input::placeholder, textarea::placeholder {
color: #7a7a7a !important;
}
/* Tables */
table, td, th {
border-color: ${border} !important;
}
th {
background-color: #1f2123 !important;
}
/* Misc elements */
hr {
border-color: ${border} !important;
background-color: ${border} !important;
}
pre, code {
background-color: #1a1c1e !important;
border-color: ${border} !important;
}
blockquote {
border-color: ${border} !important;
background-color: rgba(0, 0, 0, 0.2) !important;
}
/* Preserve media elements */
img, video, picture, canvas, iframe, embed, object {
filter: none !important;
}
/* Monochrome inline SVG icons follow themed text color */
svg[data-darkmoder-icon="1"] {
color: ${text} !important;
}
svg[data-darkmoder-icon="1"] *:not([fill="none"]) {
fill: currentColor !important;
}
svg[data-darkmoder-icon="1"] *:not([stroke="none"]) {
stroke: currentColor !important;
}
/* Scrollbar styling */
* {
scrollbar-color: #454a4d ${bg};
scrollbar-width: thin;
}
::-webkit-scrollbar {
width: 12px;
height: 12px;
background-color: ${bg};
}
::-webkit-scrollbar-thumb {
background-color: #454a4d;
border-radius: 6px;
border: 3px solid ${bg};
}
::-webkit-scrollbar-thumb:hover {
background-color: #5a5f62;
}
::-webkit-scrollbar-track {
background-color: ${bg};
}
::-webkit-scrollbar-corner {
background-color: ${bg};
}
`;
},
/**
* Apply dynamic engine
* @param {Object} theme - Theme settings
* @param {string|null} fixes - Site fixes text
*/
apply(theme, fixes = null) {
this.remove();
this.theme = theme;
this.fixes = fixes;
// Parse site fixes
if (fixes) {
const parsed = SiteFixesProcessor.parseFixes(fixes);
if (parsed) {
this.ignoreInlineStyle = new Set(parsed.ignoreInlineStyle || []);
this.ignoreImageAnalysis = new Set(parsed.ignoreImageAnalysis || []);
}
}
// Wait for documentElement
if (!document.documentElement) {
document.addEventListener('DOMContentLoaded', () => this.apply(theme, fixes), { once: true });
return;
}
// Apply root styles
const rootStyle = document.createElement('style');
rootStyle.id = 'darkmoder-dynamic-root';
rootStyle.textContent = this.createRootCSS(theme);
const target = document.head || document.documentElement;
target.appendChild(rootStyle);
this.styles.push(rootStyle);
// Apply site-specific fixes
if (fixes) {
const parsed = SiteFixesProcessor.parseFixes(fixes);
if (parsed) {
const fixCSS = SiteFixesProcessor.generateFixCSS(parsed, theme);
if (fixCSS) {
const fixStyle = document.createElement('style');
fixStyle.id = 'darkmoder-dynamic-fixes';
fixStyle.textContent = fixCSS;
target.appendChild(fixStyle);
this.styles.push(fixStyle);
}
}
}
// Process page content
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => this.processPage(theme), { once: true });
} else {
// Use requestIdleCallback for non-blocking processing
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => this.processPage(theme), { timeout: 100 });
} else {
setTimeout(() => this.processPage(theme), 10);
}
}
},
/**
* Patch attachShadow while Dynamic mode is active so future open roots are themed.
* @param {Object} theme - Theme settings
*/
setupShadowDOMSupport(theme) {
this.discoverShadowRoots(document, theme);
if (this.originalAttachShadow || !Element.prototype.attachShadow) {
return;
}
const engine = this;
this.originalAttachShadow = Element.prototype.attachShadow;
Element.prototype.attachShadow = function(init) {
const root = engine.originalAttachShadow.call(this, init);
if (root?.mode === 'open' || init?.mode === 'open') {
requestAnimationFrame(() => engine.registerShadowRoot(root, theme));
}
return root;
};
},
/**
* Discover existing open shadow roots beneath a root.
* @param {Document|ShadowRoot|Element} root - Root to scan
* @param {Object} theme - Theme settings
*/
discoverShadowRoots(root, theme) {
const nodes = root?.querySelectorAll ? root.querySelectorAll('*') : [];
for (const node of nodes) {
if (node.shadowRoot) {
this.registerShadowRoot(node.shadowRoot, theme);
this.discoverShadowRoots(node.shadowRoot, theme);
}
}
},
/**
* Register and process an open shadow root.
* @param {ShadowRoot} root - Shadow root
* @param {Object} theme - Theme settings
*/
registerShadowRoot(root, theme) {
if (!root || this.shadowRoots.has(root)) {
return;
}
this.shadowRoots.add(root);
this.injectShadowRootStyles(root, theme);
this.processRoot(root, theme);
this.observeShadowRoot(root, theme);
},
/**
* Inject the engine's base styles and site fixes into a shadow root.
* Document styles cannot cross a shadow boundary, so each open root
* needs its own copy of the rules that apply to its descendants.
* @param {ShadowRoot} root - Shadow root
* @param {Object} theme - Theme settings
*/
injectShadowRootStyles(root, theme) {
if (!root || this.shadowRootStyles.has(root)) {
return;
}
const ownerDocument = root.ownerDocument || document;
const rootStyle = ownerDocument.createElement('style');
rootStyle.className = 'darkmoder-dynamic-shadow-root';
rootStyle.textContent = this.createRootCSS(theme)
.replace(/html\s*,\s*body/g, ':host')
.replace(/\bhtml\b/g, ':host')
.replace(/\bbody\s+\*/g, ':host *');
root.appendChild(rootStyle);
this.styles.push(rootStyle);
let fixesStyle = null;
const parsedFixes = SiteFixesProcessor.parseFixes(this.fixes);
const fixCSS = SiteFixesProcessor.generateFixCSS(parsedFixes, theme);
if (fixCSS) {
fixesStyle = ownerDocument.createElement('style');
fixesStyle.className = 'darkmoder-dynamic-shadow-fixes';
fixesStyle.textContent = fixCSS;
root.appendChild(fixesStyle);
this.styles.push(fixesStyle);
}
this.shadowRootStyles.set(root, { rootStyle, fixesStyle });
},
/**
* Observe one shadow root for new inline styles and stylesheets.
* @param {ShadowRoot} root - Shadow root
* @param {Object} theme - Theme settings
*/
observeShadowRoot(root, theme) {
const pendingElements = new Set();
const pendingSheets = new Set();
let frameRequested = false;
const processPending = () => {
frameRequested = false;
for (const sheet of pendingSheets) {
this.processStyleSheet(sheet, theme, root);
}
pendingSheets.clear();
for (const el of pendingElements) {
if (el.isConnected && !el.hasAttribute('data-darkmoder-processed')) {
el.setAttribute('data-darkmoder-processed', '1');
this.processElementStyle(el, theme);
}
}
pendingElements.clear();
this.processInlineSvgIcons(theme, root);
};
const schedule = () => {
if (!frameRequested) {
frameRequested = true;
requestAnimationFrame(processPending);
}
};
const observer = new MutationObserver((mutations) => {
for (const mutation of mutations) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType !== Node.ELEMENT_NODE) {
continue;
}
if ((node.tagName === 'STYLE' || node.tagName === 'LINK') && node.sheet) {
pendingSheets.add(node.sheet);
}
if (node.hasAttribute?.('style')) {
pendingElements.add(node);
}
node.querySelectorAll?.('[style]').forEach(el => pendingElements.add(el));
if (node.shadowRoot) {
this.registerShadowRoot(node.shadowRoot, theme);
}
this.discoverShadowRoots(node, theme);
}
} else if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
pendingElements.add(mutation.target);
}
}
if (pendingSheets.size > 0 || pendingElements.size > 0) {
schedule();
}
});
observer.observe(root, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style']
});
this.shadowObservers.push(observer);
},
/**
* Process stylesheets, inline styles, and icons inside a document or shadow root.
* @param {Document|ShadowRoot} root - Root to process
* @param {Object} theme - Theme settings
*/
processRoot(root, theme) {
const sheets = [...new Set([
...Array.from(root.styleSheets || []),
...Array.from(root.adoptedStyleSheets || []),
...Array.from(root.querySelectorAll?.('style') || [])
.map(style => style.sheet)
.filter(Boolean)
])];
let index = 0;
const BATCH_SIZE = CONFIG.stylesheetBatchSize;
const processBatch = () => {
const endIndex = Math.min(index + BATCH_SIZE, sheets.length);
for (; index < endIndex; index++) {
this.processStyleSheet(sheets[index], theme, root);
}
if (index < sheets.length) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(processBatch, { timeout: 50 });
} else {
setTimeout(processBatch, 0);
}
}
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(processBatch, { timeout: 100 });
requestIdleCallback(() => this.processInlineStyles(theme, root), { timeout: 200 });
} else {
setTimeout(processBatch, 0);
setTimeout(() => this.processInlineStyles(theme, root), 50);
}
this.processInlineSvgIcons(theme, root);
},
/**
* Process page stylesheets and inline styles
* @param {Object} theme - Theme settings
*/
processPage(theme) {
this.setupShadowDOMSupport(theme);
this.processRoot(document, theme);
this.observe(theme);
},
/**
* Check whether a stylesheet has been processed for one root.
* A shared CSSStyleSheet may be adopted by several shadow roots.
* @param {CSSStyleSheet} sheet - Stylesheet to check
* @param {Document|ShadowRoot} root - Root that owns the stylesheet
* @returns {boolean} True when already processed for this root
*/
isStyleSheetProcessed(sheet, root = document) {
return Boolean(this.processedSheets.get(root)?.has(sheet));
},
/**
* Process a single stylesheet
* @param {CSSStyleSheet} sheet - Stylesheet to process
* @param {Object} theme - Theme settings
* @param {Document|ShadowRoot} root - Root that owns the stylesheet
*/
processStyleSheet(sheet, theme, root = document) {
if (!sheet || !root) {
return;
}
const ownerNode = sheet.ownerNode;
if (ownerNode?.id?.startsWith('darkmoder-') ||
ownerNode?.className?.toString().includes('darkmoder-')) {
return;
}
let processedForRoot = this.processedSheets.get(root);
if (!processedForRoot) {
processedForRoot = new WeakSet();
this.processedSheets.set(root, processedForRoot);
}
if (processedForRoot.has(sheet)) {
return;
}
try {
const modified = CSSModifier.processStyleSheet(sheet, theme);
if (modified) {
const ownerDocument = root.ownerDocument || document;
const style = ownerDocument.createElement('style');
style.className = 'darkmoder-dynamic-sheet';
style.textContent = modified;
const target = root.head || root.documentElement || root;
target.appendChild(style);
this.styles.push(style);
}
processedForRoot.add(sheet);
} catch (e) {
// CORS error or other issue - skip this sheet
}
},
/** IntersectionObserver for deferred viewport theming */
viewportObserver: null,
/**
* Setup IntersectionObserver to lazily theme elements entering the viewport
* @param {Object} theme - Theme settings
*/
setupViewportObserver(theme) {
if (this.viewportObserver || typeof IntersectionObserver === 'undefined') {
return;
}
this.viewportObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const el = entry.target;
this.viewportObserver.unobserve(el);
if (!el.hasAttribute('data-darkmoder-processed')) {
el.setAttribute('data-darkmoder-processed', '1');
this.processElementStyle(el, theme);
}
}
}
}, { rootMargin: '200px' }); // 200px margin to start processing before visible
},
/**
* Process inline styles on elements (throttled)
* Visible elements are processed immediately; off-screen elements
* are deferred until they enter the viewport via IntersectionObserver.
* @param {Object} theme - Theme settings
*/
processInlineStyles: throttle(function(theme, root = document) {
const elements = root.querySelectorAll ? root.querySelectorAll('[style]') : [];
const maxElements = Math.min(elements.length, CONFIG.maxInlineElements);
// Ensure viewport observer is ready
this.setupViewportObserver(theme);
for (let i = 0; i < maxElements; i++) {
const el = elements[i];
// Skip our own UI
if (el.closest('#darkmoder-ui') || el.closest('darkmoder-ui')) {
continue;
}
// Skip already processed
if (el.hasAttribute('data-darkmoder-processed')) {
continue;
}
// Check IGNORE INLINE STYLE rules
if (this.shouldIgnoreInlineStyle(el)) {
continue;
}
// Defer off-screen elements to IntersectionObserver
if (this.viewportObserver) {
const rect = el.getBoundingClientRect();
const inViewport = (
rect.bottom >= -200 &&
rect.top <= window.innerHeight + 200 &&
rect.right >= -200 &&
rect.left <= window.innerWidth + 200
);
if (!inViewport) {
this.viewportObserver.observe(el);
continue;
}
}
this.processElementStyle(el, theme);
el.setAttribute('data-darkmoder-processed', '1');
}
}, 0),
/**
* Check if element should be ignored based on site fixes
* @param {Element} el - Element to check
* @returns {boolean} True if should ignore
*/
shouldIgnoreInlineStyle(el) {
for (const selector of this.ignoreInlineStyle) {
try {
if (el.matches(selector)) {
return true;
}
} catch (e) {
// Invalid selector
}
}
return false;
},
/**
* Process inline style on a single element
* @param {Element} el - Element to process
* @param {Object} theme - Theme settings
*/
processElementStyle(el, theme) {
if (!el || !el.isConnected || !el.style) {
return;
}
// Skip script-related elements
const tagName = el.tagName;
if (tagName === 'SCRIPT' || tagName === 'NOSCRIPT' || tagName === 'HEAD' || tagName === 'META') {
return;
}
try {
const style = el.style;
const bgColor = style.backgroundColor || style.background;
const textColor = style.color;
// Process background color
if (bgColor && bgColor !== 'transparent' && bgColor !== 'inherit') {
const color = Color.parse(bgColor);
if (color && Color.isLight(color) && color.a > 0.1) {
const modified = Color.modifyBackgroundColor(color, theme);
if (modified) {
style.setProperty('background-color', modified, 'important');
}
}
}
// Process text color
if (textColor && textColor !== 'inherit') {
const color = Color.parse(textColor);
if (color && Color.isDark(color)) {
const modified = Color.modifyTextColor(color, theme);
if (modified) {
style.setProperty('color', modified, 'important');
}
}
}
} catch (e) {
// Skip elements that cause errors
}
},
/**
* Check whether an inline SVG is a small monochrome UI icon.
* @param {SVGElement} svg - SVG element
* @returns {boolean} True if this is likely a monochrome icon
*/
isMonochromeInlineIcon(svg) {
if (!svg || svg.tagName?.toLowerCase() !== 'svg') {
return false;
}
if (svg.querySelector('image, foreignObject, feImage, linearGradient, radialGradient, pattern')) {
return false;
}
const text = `${svg.getAttribute('class') || ''} ${svg.getAttribute('aria-label') || ''} ${svg.getAttribute('role') || ''} ${svg.getAttribute('data-icon') || ''}`.toLowerCase();
const hasIconHint = /icon|glyph|symbol|logo|octicon|lucide|material|heroicon/.test(text) ||
svg.getAttribute('aria-hidden') === 'true' ||
Boolean(svg.closest?.('button, [role="button"], a'));
const viewBox = (svg.getAttribute('viewBox') || '').trim().split(/\s+/).map(Number);
const width = parseFloat(svg.getAttribute('width')) || (viewBox.length === 4 ? viewBox[2] : 0);
const height = parseFloat(svg.getAttribute('height')) || (viewBox.length === 4 ? viewBox[3] : 0);
const looksSmall = width > 0 && height > 0 && width <= 96 && height <= 96;
const colors = new Set();
const nodes = [svg, ...Array.from(svg.querySelectorAll('*'))];
for (const node of nodes) {
for (const attr of ['fill', 'stroke']) {
const value = (node.getAttribute(attr) || '').trim().toLowerCase();
if (value && value !== 'none' && value !== 'currentcolor' && !value.startsWith('url(')) {
colors.add(value);
}
}
const inlineStyle = node.getAttribute('style') || '';
for (const declaration of inlineStyle.split(';')) {
const match = declaration.match(/^\s*(?:fill|stroke)\s*:\s*([^;]+)$/i);
const value = match?.[1]?.trim().toLowerCase();
if (value && value !== 'none' && value !== 'currentcolor' && !value.startsWith('url(')) {
colors.add(value);
}
}
}
const isSimpleShape = nodes.length <= 32;
return (hasIconHint || (looksSmall && isSimpleShape)) && colors.size <= 2;
},
/**
* Mark safe inline SVG icons for themed recoloring.
* @param {Object} theme - Theme settings
* @param {Document|ShadowRoot} root - Root to scan
*/
processInlineSvgIcons(theme, root = document) {
const svgs = [];
if (root?.matches?.('svg:not([data-darkmoder-icon-checked])')) {
svgs.push(root);
}
if (root?.querySelectorAll) {
svgs.push(...root.querySelectorAll('svg:not([data-darkmoder-icon-checked])'));
}
for (const svg of svgs) {
svg.setAttribute('data-darkmoder-icon-checked', '1');
if (this.isMonochromeInlineIcon(svg)) {
svg.setAttribute('data-darkmoder-icon', '1');
}
}
},
/**
* Observe DOM changes and process new content
* @param {Object} theme - Theme settings
*/
observe(theme) {
if (this.observer) {
return;
}
let pendingElements = new Set();
let pendingSheets = new Set();
let frameRequested = false;
/**
* Process pending items in batches
*/
const processBatch = () => {
frameRequested = false;
// Process pending stylesheets
const SHEET_BATCH_SIZE = CONFIG.observerBatchSize;
let sheetCount = 0;
const sheetsToRemove = [];
for (const sheet of pendingSheets) {
if (sheetCount >= SHEET_BATCH_SIZE) {
break;
}
if (!this.isStyleSheetProcessed(sheet, document)) {
this.processStyleSheet(sheet, theme);
}
sheetsToRemove.push(sheet);
sheetCount++;
}
for (const sheet of sheetsToRemove) {
pendingSheets.delete(sheet);
}
// Process pending elements
const ELEMENT_BATCH_SIZE = CONFIG.inlineElementBatchSize;
let processed = 0;
for (const el of pendingElements) {
if (processed >= ELEMENT_BATCH_SIZE) {
break;
}
pendingElements.delete(el);
if (el.isConnected && !el.hasAttribute('data-darkmoder-processed')) {
el.setAttribute('data-darkmoder-processed', '1');
this.processElementStyle(el, theme);
}
processed++;
}
// Schedule another batch if needed
if (pendingSheets.size > 0 || pendingElements.size > 0) {
scheduleProcess();
}
};
/**
* Schedule batch processing
*/
const scheduleProcess = () => {
if (!frameRequested) {
frameRequested = true;
requestAnimationFrame(processBatch);
}
};
// Debounced mutation collector — coalesces rapid-fire mutations
let mutationDebounceTimer = null;
let pendingMutations = [];
const MUTATION_DEBOUNCE_MS = 50;
const drainMutations = () => {
mutationDebounceTimer = null;
const batched = pendingMutations;
pendingMutations = [];
for (const mutation of batched) {
if (mutation.type === 'childList') {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
// Check for new stylesheets
if (node.tagName === 'STYLE' || node.tagName === 'LINK') {
// Skip our own styles
if (node.className && node.className.includes('darkmoder')) {
continue;
}
if (node.id && node.id.startsWith('darkmoder')) {
continue;
}
if (node.sheet) {
pendingSheets.add(node.sheet);
}
}
// Check for inline styles
if (node.hasAttribute && node.hasAttribute('style')) {
if (!node.hasAttribute('data-darkmoder-processed')) {
pendingElements.add(node);
}
}
// Check children for inline styles
const styledChildren = node.querySelectorAll ?
node.querySelectorAll('[style]') : [];
for (const child of styledChildren) {
if (!child.hasAttribute('data-darkmoder-processed')) {
pendingElements.add(child);
}
}
if (node.matches?.('svg')) {
this.processInlineSvgIcons(theme, node.parentNode || document);
}
this.processInlineSvgIcons(theme, node);
if (node.shadowRoot) {
this.registerShadowRoot(node.shadowRoot, theme);
}
this.discoverShadowRoots(node, theme);
}
}
} else if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
const target = mutation.target;
if (!target.hasAttribute('data-darkmoder-processed')) {
pendingElements.add(target);
}
}
}
if (pendingSheets.size > 0 || pendingElements.size > 0) {
scheduleProcess();
}
};
// Create mutation observer with debounced callback
this.observer = new MutationObserver((mutations) => {
pendingMutations.push(...mutations);
if (!mutationDebounceTimer) {
mutationDebounceTimer = setTimeout(drainMutations, MUTATION_DEBOUNCE_MS);
}
});
// Start observing
this.observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style']
});
},
/**
* Remove dynamic engine and clean up
*/
remove() {
// Disconnect mutation observer
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Disconnect viewport observer
if (this.viewportObserver) {
this.viewportObserver.disconnect();
this.viewportObserver = null;
}
const rootsToClean = [document, ...this.shadowRoots];
for (const observer of this.shadowObservers) {
observer.disconnect();
}
this.shadowObservers = [];
this.shadowRoots.clear();
if (this.originalAttachShadow) {
Element.prototype.attachShadow = this.originalAttachShadow;
this.originalAttachShadow = null;
}
// Remove all injected styles
for (const style of this.styles) {
style.remove();
}
this.styles = [];
// Reset tracking
this.processedSheets = new WeakMap();
this.shadowRootStyles = new WeakMap();
this.ignoreInlineStyle = new Set();
this.ignoreImageAnalysis = new Set();
// Remove any lingering elements
document.querySelectorAll('[class*="darkmoder-dynamic"]').forEach(el => el.remove());
document.querySelectorAll('#darkmoder-dynamic-root, #darkmoder-dynamic-fixes').forEach(el => el.remove());
// Clean up processed attributes (deferred)
const cleanup = () => {
for (const root of rootsToClean) {
root.querySelectorAll?.('[data-darkmoder-processed]').forEach(el => {
el.removeAttribute('data-darkmoder-processed');
});
root.querySelectorAll?.('[data-darkmoder-icon], [data-darkmoder-icon-checked]').forEach(el => {
el.removeAttribute('data-darkmoder-icon');
el.removeAttribute('data-darkmoder-icon-checked');
});
}
};
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(cleanup, { timeout: 1000 });
} else {
setTimeout(cleanup, 100);
}
},
/**
* Export generated CSS
* @returns {string} Combined CSS from all styles
*/
exportCSS() {
let css = '/* DarkModer Generated CSS */\n\n';
for (const style of this.styles) {
css += style.textContent + '\n\n';
}
return css;
}
};
// --------------------------------------------------------------------------
// Static Engine - Simple static CSS replacement
// --------------------------------------------------------------------------
const StaticEngine = {
style: null,
/**
* Generate static CSS
* @param {Object} theme - Theme settings
* @returns {string} CSS string
*/
create(theme) {
const bg = theme.darkSchemeBackgroundColor;
const text = theme.darkSchemeTextColor;
const border = theme.borderColor || '#3a3d3e';
const link = theme.linkColor || '#3391ff';
const visitedLink = theme.visitedLinkColor || '#9e6eff';
return `
/* Static dark mode - applies to all major elements */
html, body, article, section, nav, aside, header, footer, main,
div, span, p, h1, h2, h3, h4, h5, h6, ul, ol, li, dl, dt, dd,
form, fieldset, legend, table, caption, tbody, tfoot, thead, tr, th, td,
address, blockquote, pre, figure, figcaption, details, summary {
background-color: ${bg} !important;
color: ${text} !important;
border-color: ${border} !important;
}
/* Links */
a {
color: ${link} !important;
}
a:visited {
color: ${visitedLink} !important;
}
/* Form elements */
input, textarea, select, button, optgroup, option {
background-color: #1c1e1f !important;
color: ${text} !important;
border-color: ${border} !important;
}
input::placeholder, textarea::placeholder {
color: #7a7a7a !important;
}
/* Code elements */
code, pre, kbd, samp {
background-color: #1a1c1e !important;
color: ${text} !important;
}
/* Images and media - don't modify */
img, video, picture, canvas, svg, iframe {
/* Preserve original colors */
}
`;
},
/**
* Apply static engine
* @param {Object} theme - Theme settings
*/
apply(theme, fixes = null) {
this.remove();
this.style = document.createElement('style');
this.style.id = 'darkmoder-static';
const fixCSS = SiteFixesProcessor.generateFixCSS(fixes, theme);
this.style.textContent = `${this.create(theme)}\n${fixCSS}`;
const target = document.head || document.documentElement;
target.appendChild(this.style);
},
/**
* Remove static engine
*/
remove() {
if (this.style) {
this.style.remove();
this.style = null;
}
const existing = document.getElementById('darkmoder-static');
if (existing) {
existing.remove();
}
}
};
// ============================================================================
// CONFIG LOADER
// Loads remote configuration files with caching
// ============================================================================
const ConfigLoader = {
cache: {},
cacheTimestamps: {},
cacheMeta: {},
cacheLoaded: false,
getCacheStorageKey() {
return `${CONFIG.storageKey}_remote_config_cache`;
},
/**
* Restore the persistent remote-config cache.
*/
async loadStoredCache() {
if (this.cacheLoaded) {
return;
}
this.cacheLoaded = true;
const stored = await Storage.get(this.getCacheStorageKey(), null);
if (!stored || stored.version !== CONFIG.version) {
return;
}
this.cache = stored.cache || {};
this.cacheTimestamps = stored.timestamps || {};
this.cacheMeta = stored.meta || {};
},
/**
* Persist the remote-config cache.
*/
async saveStoredCache() {
await Storage.set(this.getCacheStorageKey(), {
version: CONFIG.version,
cache: this.cache,
timestamps: this.cacheTimestamps,
meta: this.cacheMeta
});
},
/**
* Extract the config payload from a remote JSON document.
* @param {string} name - Config name
* @param {Object} data - Parsed JSON
* @returns {*} Extracted config
*/
extractConfig(name, data) {
if (name === 'darkSites' && data.sites) {
return data.sites;
}
if ((name === 'dynamicFixes' || name === 'inversionFixes') && data.fixes) {
return data.fixes;
}
if (name === 'staticThemes' && data.themes) {
return data.themes;
}
return data;
},
/**
* Load a configuration file
* @param {string} name - Config name (darkSites, dynamicFixes, etc.)
* @returns {Promise<*>} Configuration data
*/
async loadConfig(name) {
await this.loadStoredCache();
// Check cache
if (this.cache[name] && this.cacheTimestamps[name]) {
const age = Date.now() - this.cacheTimestamps[name];
if (age < CONFIG.cacheDuration) {
return this.cache[name];
}
}
// If no base URL, use built-in
if (!CONFIG.configBaseURL) {
return this.getBuiltIn(name);
}
try {
const url = CONFIG.configBaseURL + CONFIG.configFiles[name];
const response = await this.fetch(url, 3000, this.cacheMeta[name]?.etag);
if (response) {
if (response.notModified && this.cache[name]) {
this.cacheTimestamps[name] = Date.now();
await this.saveStoredCache();
return this.cache[name];
}
const data = JSON.parse(response.text);
const result = this.extractConfig(name, data);
// Update cache
this.cache[name] = result;
this.cacheTimestamps[name] = Date.now();
this.cacheMeta[name] = {
etag: response.etag || this.cacheMeta[name]?.etag || null
};
await this.saveStoredCache();
return result;
}
} catch (e) {
console.warn(`DarkModer: Failed to load ${name}, using built-in`);
}
return this.getBuiltIn(name);
},
/**
* Fetch a URL with timeout and optional ETag validation.
* @param {string} url - URL to fetch
* @param {number} timeout - Timeout in ms
* @param {string|null} etag - Previous ETag
* @returns {Promise