// ==UserScript==
// @name DarkModer
// @namespace https://github.com/SysAdminDoc/DarkModer
// @version 3.0.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.0.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 (NEW in v3.0.0)
// ============================================================================
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
};
// ============================================================================
// 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
},
time: {
activation: '18:00',
deactivation: '09:00'
},
location: {
latitude: null,
longitude: null
},
shortcuts: {
toggle: 'Alt+Shift+D',
toggleSite: 'Alt+Shift+S',
openSettings: 'Alt+Shift+A'
},
previewNewDesign: true,
enableForPDF: true,
enableForProtectedPages: false,
enableContextMenus: false,
detectDarkTheme: true
};
// ============================================================================
// 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',
'discord.com',
'github.com',
'netflix.com',
'twitch.tv',
'youtube.com',
'music.youtube.com',
'reddit.com',
'twitter.com',
'x.com',
'spotify.com',
'slack.com',
'notion.so',
'figma.com',
'linear.app',
'vercel.com',
'vitejs.dev',
'hulu.com',
'disneyplus.com',
'primevideo.com',
'hbomax.com'
];
// ============================================================================
// BUILT-IN DYNAMIC THEME FIXES
// ============================================================================
const BUILT_IN_DYNAMIC_FIXES = {
// Example fixes structure
// 'example.com': 'INVERT\n.icon\n\nCSS\n.element { color: white !important; }'
};
// ============================================================================
// BUILT-IN INVERSION FIXES
// ============================================================================
const BUILT_IN_INVERSION_FIXES = {
// Example fixes structure
// 'example.com': 'INVERT\nimg\n\nNO INVERT\n.logo'
};
// ============================================================================
// 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 if URL matches any pattern in the list
* Supports exact match, wildcard (*), and regex (/pattern/)
* @param {string} url - URL to check
* @param {Array} patterns - Array of patterns to match against
* @returns {boolean} True if matched
*/
function isURLMatched(url, patterns) {
if (!patterns || !Array.isArray(patterns)) {
return false;
}
const hostname = getURLHostname(url) || url;
return patterns.some(pattern => {
if (!pattern || typeof pattern !== 'string') {
return false;
}
// Regex pattern: /pattern/
if (pattern.startsWith('/') && pattern.endsWith('/')) {
try {
const regex = new RegExp(pattern.slice(1, -1));
return regex.test(url);
} catch (e) {
return false;
}
}
// Wildcard pattern: *.example.com
if (pattern.includes('*')) {
const escapedPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escapedPattern + '$', 'i');
return regex.test(hostname) || regex.test(url);
}
// Exact match or subdomain match
return hostname === pattern || hostname.endsWith('.' + pattern);
});
}
/**
* Generate a unique identifier
* @returns {string} Unique ID
*/
function generateUID() {
return Math.random().toString(36).substring(2, 11);
}
/**
* Parse a keyboard shortcut string
* @param {string} shortcut - Shortcut string (e.g., "Alt+Shift+D")
* @returns {Object|null} Parsed shortcut object
*/
function parseShortcut(shortcut) {
if (!shortcut || typeof shortcut !== 'string') {
return null;
}
const parts = shortcut.toLowerCase().split('+').map(p => p.trim());
return {
ctrl: parts.includes('ctrl') || parts.includes('control'),
alt: parts.includes('alt'),
shift: parts.includes('shift'),
meta: parts.includes('meta') || parts.includes('cmd') || parts.includes('command'),
key: parts.find(p => !['ctrl', 'control', 'alt', 'shift', 'meta', 'cmd', 'command'].includes(p)) || ''
};
}
/**
* Check if a keyboard event matches a shortcut
* @param {KeyboardEvent} event - Keyboard event
* @param {string} shortcut - Shortcut string to match
* @returns {boolean} True if matched
*/
function matchShortcut(event, shortcut) {
const parsed = parseShortcut(shortcut);
if (!parsed) {
return false;
}
return event.ctrlKey === parsed.ctrl &&
event.altKey === parsed.alt &&
event.shiftKey === parsed.shift &&
event.metaKey === parsed.meta &&
event.key.toLowerCase() === parsed.key;
}
/**
* 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) {
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 = {
/**
* 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 !== '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) {
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;
}
};
// ============================================================================
// 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) {
const brightness = theme.brightness / 100;
const contrast = theme.contrast / 100;
const sepia = theme.sepia / 100;
const grayscale = theme.grayscale / 100;
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;
}
`;
},
/**
* Apply filter engine
* @param {Object} theme - Theme settings
*/
apply(theme) {
this.remove();
this.style = document.createElement('style');
this.style.id = 'darkmoder-filter';
this.style.textContent = this.create(theme);
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) {
const brightness = theme.brightness / 100;
const contrast = theme.contrast / 100;
const sepia = theme.sepia / 100;
const grayscale = theme.grayscale / 100;
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;
}
`;
},
/**
* 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) {
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);
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 WeakSet(),
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;
}
/* 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);
}
}
},
/**
* Process page stylesheets and inline styles
* @param {Object} theme - Theme settings
*/
processPage(theme) {
const sheets = Array.from(document.styleSheets);
let index = 0;
const BATCH_SIZE = CONFIG.stylesheetBatchSize;
/**
* Process stylesheets in batches to avoid blocking
*/
const processBatch = () => {
const endIndex = Math.min(index + BATCH_SIZE, sheets.length);
for (; index < endIndex; index++) {
this.processStyleSheet(sheets[index], theme);
}
if (index < sheets.length) {
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(processBatch, { timeout: 50 });
} else {
setTimeout(processBatch, 0);
}
}
};
// Start batch processing
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(processBatch, { timeout: 100 });
} else {
setTimeout(processBatch, 0);
}
// Process inline styles
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(() => this.processInlineStyles(theme), { timeout: 200 });
} else {
setTimeout(() => this.processInlineStyles(theme), 50);
}
// Start observing for changes
this.observe(theme);
},
/**
* Process a single stylesheet
* @param {CSSStyleSheet} sheet - Stylesheet to process
* @param {Object} theme - Theme settings
*/
processStyleSheet(sheet, theme) {
// Skip already processed sheets
if (this.processedSheets.has(sheet)) {
return;
}
this.processedSheets.add(sheet);
try {
const modified = CSSModifier.processStyleSheet(sheet, theme);
if (modified) {
const style = document.createElement('style');
style.className = 'darkmoder-dynamic-sheet';
style.textContent = modified;
document.documentElement.appendChild(style);
this.styles.push(style);
}
} catch (e) {
// CORS error or other issue - skip this sheet
}
},
/**
* Process inline styles on elements (throttled)
* @param {Object} theme - Theme settings
*/
processInlineStyles: throttle(function(theme) {
const elements = document.querySelectorAll('[style]');
const maxElements = Math.min(elements.length, CONFIG.maxInlineElements);
for (let i = 0; i < maxElements; i++) {
const el = elements[i];
// Skip our own UI
if (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;
}
this.processElementStyle(el, theme);
el.setAttribute('data-darkmoder-processed', '1');
}
}, 200),
/**
* 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
}
},
/**
* 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.processedSheets.has(sheet)) {
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);
}
};
// Create mutation observer
this.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) {
// 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);
}
}
}
}
} 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();
}
});
// Start observing
this.observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['style']
});
},
/**
* Remove dynamic engine and clean up
*/
remove() {
// Disconnect observer
if (this.observer) {
this.observer.disconnect();
this.observer = null;
}
// Remove all injected styles
for (const style of this.styles) {
style.remove();
}
this.styles = [];
// Reset tracking
this.processedSheets = new WeakSet();
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 = () => {
document.querySelectorAll('[data-darkmoder-processed]').forEach(el => {
el.removeAttribute('data-darkmoder-processed');
});
};
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) {
this.remove();
this.style = document.createElement('style');
this.style.id = 'darkmoder-static';
this.style.textContent = this.create(theme);
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: {},
/**
* Load a configuration file
* @param {string} name - Config name (darkSites, dynamicFixes, etc.)
* @returns {Promise<*>} Configuration data
*/
async loadConfig(name) {
// 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);
if (response) {
const data = JSON.parse(response);
// Extract the relevant array/object from the response
let result;
if (name === 'darkSites' && data.sites) {
result = data.sites;
} else if (name === 'dynamicFixes' && data.fixes) {
result = data.fixes;
} else if (name === 'inversionFixes' && data.fixes) {
result = data.fixes;
} else if (name === 'staticThemes' && data.themes) {
result = data.themes;
} else {
result = data;
}
// Update cache
this.cache[name] = result;
this.cacheTimestamps[name] = Date.now();
return result;
}
} catch (e) {
console.warn(`DarkModer: Failed to load ${name}, using built-in`);
}
return this.getBuiltIn(name);
},
/**
* Fetch a URL with timeout
* @param {string} url - URL to fetch
* @param {number} timeout - Timeout in ms
* @returns {Promise} Response text
*/
fetch(url, timeout = 3000) {
return new Promise((resolve, reject) => {
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
reject(new Error('Timeout'));
}, timeout);
// Use GM_xmlhttpRequest if available (bypasses CORS)
if (typeof GM_xmlhttpRequest !== 'undefined') {
GM_xmlhttpRequest({
method: 'GET',
url: url,
timeout: timeout,
onload: (response) => {
if (timedOut) return;
clearTimeout(timer);
if (response.status >= 200 && response.status < 300) {
resolve(response.responseText);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: () => {
if (timedOut) return;
clearTimeout(timer);
reject(new Error('Network error'));
},
ontimeout: () => {
if (timedOut) return;
clearTimeout(timer);
reject(new Error('Timeout'));
}
});
} else {
// Fallback to fetch API
fetch(url, { signal: AbortSignal.timeout(timeout) })
.then(r => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.text();
})
.then(text => {
if (timedOut) return;
clearTimeout(timer);
resolve(text);
})
.catch(e => {
if (timedOut) return;
clearTimeout(timer);
reject(e);
});
}
});
},
/**
* Get built-in configuration
* @param {string} name - Config name
* @returns {*} Built-in config data
*/
getBuiltIn(name) {
switch (name) {
case 'darkSites':
return BUILT_IN_DARK_SITES;
case 'dynamicFixes':
return BUILT_IN_DYNAMIC_FIXES;
case 'inversionFixes':
return BUILT_IN_INVERSION_FIXES;
default:
return null;
}
},
/**
* Get site-specific fix from fixes object
* @param {string} hostname - Site hostname
* @param {Object} fixes - Fixes object
* @returns {string|null} Fix text or null
*/
getSiteFix(hostname, fixes) {
if (!fixes || typeof fixes !== 'object') {
return null;
}
// Direct match
if (fixes[hostname]) {
return fixes[hostname];
}
// Check parent domains
const parts = hostname.split('.');
for (let i = 1; i < parts.length - 1; i++) {
const domain = parts.slice(i).join('.');
if (fixes[domain]) {
return fixes[domain];
}
}
// Check wildcard patterns
for (const [pattern, fix] of Object.entries(fixes)) {
if (pattern.includes('*')) {
const escapedPattern = pattern
.replace(/[.+?^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escapedPattern + '$', 'i');
if (regex.test(hostname)) {
return fix;
}
}
}
return null;
}
};
// ============================================================================
// STORAGE
// Persistent storage with GM_* API fallback to localStorage
// ============================================================================
const Storage = {
/**
* Get value from storage
* @param {string} key - Storage key
* @param {*} defaultValue - Default value if not found
* @returns {*} Stored value or default
*/
get(key, defaultValue) {
try {
if (typeof GM_getValue === 'function') {
const val = GM_getValue(key, undefined);
if (val === undefined) {
return defaultValue;
}
// GM_getValue might return string or object depending on userscript manager
return typeof val === 'string' ? JSON.parse(val) : val;
}
// Fallback to localStorage
const stored = localStorage.getItem(key);
return stored ? JSON.parse(stored) : defaultValue;
} catch (e) {
console.warn('DarkModer: Failed to read settings', e);
return defaultValue;
}
},
/**
* Set value in storage
* @param {string} key - Storage key
* @param {*} value - Value to store
*/
set(key, value) {
try {
const json = JSON.stringify(value);
if (typeof GM_setValue === 'function') {
GM_setValue(key, json);
} else {
localStorage.setItem(key, json);
}
} catch (e) {
console.warn('DarkModer: Failed to save settings', e);
}
},
/**
* Remove value from storage
* @param {string} key - Storage key
*/
remove(key) {
try {
if (typeof GM_deleteValue === 'function') {
GM_deleteValue(key);
} else {
localStorage.removeItem(key);
}
} catch (e) {
// Ignore removal errors
}
}
};
// ============================================================================
// SETTINGS UI
// ============================================================================
const UI = {
container: null,
isOpen: false,
currentTab: 'theme',
/**
* CSS styles for the settings panel
*/
styles: `
#darkmoder-ui {
position: fixed;
top: 10px;
right: 10px;
z-index: 2147483647;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
font-size: 12px;
line-height: 1.4;
color: #e8e6e3;
}
#darkmoder-ui * {
box-sizing: border-box;
margin: 0;
padding: 0;
}
#darkmoder-ui .dm-popup {
width: 360px;
background: #1e2021;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.6);
overflow: hidden;
}
#darkmoder-ui .dm-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: linear-gradient(to bottom, #2d2f30, #1e2021);
border-bottom: 1px solid #3a3d3e;
}
#darkmoder-ui .dm-header-title {
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 600;
}
#darkmoder-ui .dm-logo {
width: 26px;
height: 26px;
}
#darkmoder-ui .dm-close {
background: none;
border: none;
color: #7a7a7a;
font-size: 22px;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
border-radius: 4px;
transition: all 0.2s;
}
#darkmoder-ui .dm-close:hover {
color: #e8e6e3;
background: rgba(255, 255, 255, 0.1);
}
#darkmoder-ui .dm-tabs {
display: flex;
background: #181a1b;
border-bottom: 1px solid #3a3d3e;
}
#darkmoder-ui .dm-tab {
flex: 1;
padding: 10px;
text-align: center;
cursor: pointer;
border: none;
background: none;
color: #7a7a7a;
font-size: 11px;
transition: all 0.2s;
}
#darkmoder-ui .dm-tab:hover {
color: #e8e6e3;
background: rgba(255, 255, 255, 0.05);
}
#darkmoder-ui .dm-tab.active {
color: #3391ff;
border-bottom: 2px solid #3391ff;
}
#darkmoder-ui .dm-body {
padding: 16px;
max-height: 65vh;
overflow-y: auto;
}
#darkmoder-ui .dm-panel {
display: none;
}
#darkmoder-ui .dm-panel.active {
display: block;
}
#darkmoder-ui .dm-main-toggle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px;
margin-bottom: 16px;
background: #181a1b;
border-radius: 8px;
}
#darkmoder-ui .dm-toggle {
position: relative;
width: 40px;
height: 22px;
cursor: pointer;
}
#darkmoder-ui .dm-toggle input {
opacity: 0;
width: 0;
height: 0;
}
#darkmoder-ui .dm-toggle .dm-slider {
position: absolute;
inset: 0;
background: #3a3d3e;
border-radius: 22px;
transition: 0.3s;
}
#darkmoder-ui .dm-toggle .dm-slider:before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
bottom: 3px;
background: #7a7a7a;
border-radius: 50%;
transition: 0.3s;
}
#darkmoder-ui .dm-toggle input:checked + .dm-slider {
background: #3391ff;
}
#darkmoder-ui .dm-toggle input:checked + .dm-slider:before {
transform: translateX(18px);
background: #fff;
}
#darkmoder-ui .dm-section {
margin-bottom: 16px;
}
#darkmoder-ui .dm-section-title {
font-size: 11px;
font-weight: 600;
color: #7a7a7a;
text-transform: uppercase;
margin-bottom: 8px;
}
#darkmoder-ui .dm-mode-btns {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
#darkmoder-ui .dm-mode-btn {
flex: 1;
min-width: 70px;
padding: 8px;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 6px;
color: #e8e6e3;
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
}
#darkmoder-ui .dm-mode-btn:hover {
background: #3a3d3e;
}
#darkmoder-ui .dm-mode-btn.active {
background: #3391ff;
border-color: #3391ff;
}
#darkmoder-ui .dm-slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
#darkmoder-ui .dm-slider-label {
width: 70px;
font-size: 11px;
}
#darkmoder-ui .dm-slider-input {
flex: 1;
height: 4px;
-webkit-appearance: none;
background: #3a3d3e;
border-radius: 2px;
outline: none;
}
#darkmoder-ui .dm-slider-input::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #3391ff;
border-radius: 50%;
cursor: pointer;
}
#darkmoder-ui .dm-slider-input::-moz-range-thumb {
width: 14px;
height: 14px;
background: #3391ff;
border-radius: 50%;
cursor: pointer;
border: none;
}
#darkmoder-ui .dm-slider-value {
width: 35px;
text-align: right;
font-size: 11px;
}
#darkmoder-ui .dm-color-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
#darkmoder-ui .dm-color-label {
width: 100px;
font-size: 11px;
}
#darkmoder-ui .dm-color-picker {
width: 30px;
height: 24px;
border: 1px solid #3a3d3e;
border-radius: 4px;
cursor: pointer;
padding: 0;
}
#darkmoder-ui .dm-color-text {
flex: 1;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 4px;
color: #e8e6e3;
padding: 4px 8px;
font-size: 11px;
}
#darkmoder-ui .dm-select {
width: 100%;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 6px;
color: #e8e6e3;
padding: 8px;
font-size: 11px;
}
#darkmoder-ui .dm-textarea {
width: 100%;
min-height: 100px;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 6px;
color: #e8e6e3;
padding: 8px;
font-size: 11px;
font-family: monospace;
resize: vertical;
}
#darkmoder-ui .dm-input {
width: 100%;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 6px;
color: #e8e6e3;
padding: 8px;
font-size: 11px;
}
#darkmoder-ui .dm-btn {
padding: 8px 16px;
background: #3391ff;
border: none;
border-radius: 6px;
color: #fff;
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
}
#darkmoder-ui .dm-btn:hover {
background: #2778e6;
}
#darkmoder-ui .dm-btn-secondary {
background: #2d2f30;
border: 1px solid #3a3d3e;
color: #e8e6e3;
}
#darkmoder-ui .dm-btn-secondary:hover {
background: #3a3d3e;
}
#darkmoder-ui .dm-btn-row {
display: flex;
gap: 8px;
margin-top: 12px;
}
#darkmoder-ui .dm-checkbox-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
#darkmoder-ui .dm-checkbox {
width: 16px;
height: 16px;
accent-color: #3391ff;
}
#darkmoder-ui .dm-preset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px;
background: #2d2f30;
border-radius: 6px;
margin-bottom: 8px;
}
#darkmoder-ui .dm-preset-name {
font-size: 12px;
}
#darkmoder-ui .dm-preset-btns {
display: flex;
gap: 4px;
}
#darkmoder-ui .dm-preset-btn {
padding: 4px 8px;
font-size: 10px;
background: #3a3d3e;
border: none;
border-radius: 4px;
color: #e8e6e3;
cursor: pointer;
}
#darkmoder-ui .dm-preset-btn:hover {
background: #4a4d4e;
}
#darkmoder-ui .dm-preset-btn.delete {
background: #aa3333;
}
#darkmoder-ui .dm-preset-btn.delete:hover {
background: #cc4444;
}
#darkmoder-ui .dm-shortcut-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
#darkmoder-ui .dm-shortcut-input {
width: 150px;
background: #2d2f30;
border: 1px solid #3a3d3e;
border-radius: 4px;
color: #e8e6e3;
padding: 6px 8px;
font-size: 11px;
}
#darkmoder-ui .dm-info {
font-size: 10px;
color: #7a7a7a;
margin-top: 4px;
}
`,
/**
* SVG logo for the header
*/
logo: ``,
/**
* Create the UI container and inject into page
*/
create() {
if (this.container) {
return;
}
this.container = document.createElement('div');
this.container.id = 'darkmoder-ui';
this.container.innerHTML = this.getHTML();
// Add styles
const styleEl = document.createElement('style');
styleEl.textContent = this.styles;
this.container.appendChild(styleEl);
document.body.appendChild(this.container);
this.bindEvents();
this.updateUI();
},
/**
* Get the effective theme for the current site
* @returns {Object} Theme settings
*/
getSiteTheme() {
const hostname = window.location.hostname;
const settings = DarkModer.settings;
if (settings.siteSettings && settings.siteSettings[hostname]) {
return { ...settings.theme, ...settings.siteSettings[hostname] };
}
return settings.theme;
},
/**
* Generate HTML for the settings panel
* @returns {string} HTML string
*/
getHTML() {
const hostname = window.location.hostname;
const settings = DarkModer.settings;
const theme = this.getSiteTheme();
// Generate color scheme options
const schemeOptions = Object.keys(COLOR_SCHEMES).map(name =>
``
).join('');
// Generate presets list
const presetsList = (settings.presets || []).map((preset, i) =>
`
${escapeHtml(preset.name)}
`
).join('') || 'No presets saved yet
';
// Site fix text
const siteFixText = settings.siteFixesUser && settings.siteFixesUser[hostname]
? escapeHtml(settings.siteFixesUser[hostname])
: '';
// Location info
const locationInfo = settings.location.latitude
? `Lat: ${settings.location.latitude.toFixed(2)}, Lng: ${settings.location.longitude.toFixed(2)}`
: 'No location set';
return `
`;
},
/**
* Bind event handlers for UI elements
*/
bindEvents() {
const $ = sel => this.container.querySelector(sel);
const $$ = sel => this.container.querySelectorAll(sel);
// Close button
$('#dm-close').addEventListener('click', () => this.close());
// Global enable toggle
$('#dm-enabled').addEventListener('change', (e) => {
DarkModer.settings.enabled = e.target.checked;
DarkModer.saveSettings();
if (e.target.checked) {
DarkModer.apply();
} else {
DarkModer.remove();
}
});
// Site enable toggle
$('#dm-site-enabled').addEventListener('change', (e) => {
const hostname = window.location.hostname;
if (e.target.checked) {
DarkModer.settings.disabledFor = DarkModer.settings.disabledFor.filter(s => s !== hostname);
} else {
if (!DarkModer.settings.disabledFor.includes(hostname)) {
DarkModer.settings.disabledFor.push(hostname);
}
}
DarkModer.saveSettings();
if (DarkModer.settings.enabled && e.target.checked) {
DarkModer.apply();
} else {
DarkModer.remove();
}
});
// Tab switching
$$('.dm-tab').forEach(tab => {
tab.addEventListener('click', () => {
$$('.dm-tab').forEach(t => t.classList.remove('active'));
$$('.dm-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
$(`[data-panel="${tab.dataset.tab}"]`).classList.add('active');
this.currentTab = tab.dataset.tab;
});
});
// Engine mode buttons
$$('.dm-mode-btn').forEach(btn => {
btn.addEventListener('click', () => {
$$('.dm-mode-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
this.updateTheme({ engine: btn.dataset.engine });
});
});
// Adjustment sliders
['brightness', 'contrast', 'sepia', 'grayscale'].forEach(name => {
const slider = $(`#dm-${name}`);
slider.addEventListener('input', () => {
$(`#dm-${name}-val`).textContent = slider.value;
this.updateTheme({ [name]: parseInt(slider.value) });
});
});
// Text stroke slider
$('#dm-text-stroke').addEventListener('input', (e) => {
$('#dm-text-stroke-val').textContent = e.target.value;
this.updateTheme({ textStroke: parseInt(e.target.value) });
});
// Color scheme selector
$('#dm-color-scheme').addEventListener('change', (e) => {
const scheme = COLOR_SCHEMES[e.target.value];
if (scheme) {
this.updateTheme({
colorScheme: e.target.value,
darkSchemeBackgroundColor: scheme.background,
darkSchemeTextColor: scheme.text,
selectionBgColor: scheme.selectionBg,
selectionTextColor: scheme.selectionText,
linkColor: scheme.link,
borderColor: scheme.border
});
this.updateUI();
}
});
// Color pickers
const colorInputs = [
{ picker: '#dm-bg-color', text: '#dm-bg-color-text', key: 'darkSchemeBackgroundColor' },
{ picker: '#dm-text-color', text: '#dm-text-color-text', key: 'darkSchemeTextColor' },
{ picker: '#dm-selection-bg', text: '#dm-selection-bg-text', key: 'selectionBgColor' },
{ picker: '#dm-selection-text', text: '#dm-selection-text-text', key: 'selectionTextColor' }
];
colorInputs.forEach(({ picker, text, key }) => {
$(picker).addEventListener('input', (e) => {
$(text).value = e.target.value;
this.updateTheme({ [key]: e.target.value });
});
$(text).addEventListener('change', (e) => {
$(picker).value = e.target.value;
this.updateTheme({ [key]: e.target.value });
});
});
// Font settings
$('#dm-use-font').addEventListener('change', (e) => {
this.updateTheme({ useFont: e.target.checked });
});
$('#dm-font-family').addEventListener('change', (e) => {
this.updateTheme({ fontFamily: e.target.value });
});
// Site-only checkbox
$('#dm-site-only').addEventListener('change', (e) => {
if (e.target.checked) {
const hostname = window.location.hostname;
const theme = this.getSiteTheme();
if (!DarkModer.settings.siteSettings) {
DarkModer.settings.siteSettings = {};
}
DarkModer.settings.siteSettings[hostname] = { ...theme };
DarkModer.saveSettings();
}
});
// Disabled sites textarea
$('#dm-disabled-sites').addEventListener('change', (e) => {
DarkModer.settings.disabledFor = e.target.value
.split('\n')
.map(s => s.trim())
.filter(Boolean);
DarkModer.saveSettings();
});
// Detect dark theme checkbox
$('#dm-detect-dark').addEventListener('change', (e) => {
DarkModer.settings.detectDarkTheme = e.target.checked;
DarkModer.saveSettings();
});
// Site fixes textarea
$('#dm-site-fixes').addEventListener('change', (e) => {
const hostname = window.location.hostname;
if (!DarkModer.settings.siteFixesUser) {
DarkModer.settings.siteFixesUser = {};
}
DarkModer.settings.siteFixesUser[hostname] = e.target.value;
DarkModer.saveSettings();
DarkModer.apply();
});
// Automation selector
$('#dm-automation').addEventListener('change', (e) => {
DarkModer.settings.automation.mode = e.target.value;
DarkModer.settings.automation.enabled = !!e.target.value;
DarkModer.saveSettings();
$('#dm-time-settings').style.display = e.target.value === 'time' ? 'block' : 'none';
$('#dm-location-settings').style.display = e.target.value === 'location' ? 'block' : 'none';
DarkModer.checkAutomation();
});
// Time settings
$('#dm-time-on').addEventListener('change', (e) => {
DarkModer.settings.time.activation = e.target.value;
DarkModer.saveSettings();
DarkModer.checkAutomation();
});
$('#dm-time-off').addEventListener('change', (e) => {
DarkModer.settings.time.deactivation = e.target.value;
DarkModer.saveSettings();
DarkModer.checkAutomation();
});
// Get location button
$('#dm-get-location').addEventListener('click', () => {
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
DarkModer.settings.location.latitude = pos.coords.latitude;
DarkModer.settings.location.longitude = pos.coords.longitude;
DarkModer.saveSettings();
$('#dm-location-info').textContent =
`Lat: ${pos.coords.latitude.toFixed(2)}, Lng: ${pos.coords.longitude.toFixed(2)}`;
DarkModer.checkAutomation();
},
(err) => {
$('#dm-location-info').textContent = 'Failed to get location: ' + err.message;
}
);
} else {
$('#dm-location-info').textContent = 'Geolocation not supported';
}
});
// Keyboard shortcut inputs
$('#dm-shortcut-toggle').addEventListener('change', (e) => {
if (!DarkModer.settings.shortcuts) {
DarkModer.settings.shortcuts = {};
}
DarkModer.settings.shortcuts.toggle = e.target.value;
DarkModer.saveSettings();
});
$('#dm-shortcut-site').addEventListener('change', (e) => {
if (!DarkModer.settings.shortcuts) {
DarkModer.settings.shortcuts = {};
}
DarkModer.settings.shortcuts.toggleSite = e.target.value;
DarkModer.saveSettings();
});
$('#dm-shortcut-settings').addEventListener('change', (e) => {
if (!DarkModer.settings.shortcuts) {
DarkModer.settings.shortcuts = {};
}
DarkModer.settings.shortcuts.openSettings = e.target.value;
DarkModer.saveSettings();
});
// Save preset button
$('#dm-save-preset').addEventListener('click', () => {
const name = $('#dm-preset-name').value.trim();
if (!name) {
alert('Please enter a preset name');
return;
}
if (!DarkModer.settings.presets) {
DarkModer.settings.presets = [];
}
DarkModer.settings.presets.push({
name: name,
theme: { ...DarkModer.settings.theme },
created: Date.now()
});
DarkModer.saveSettings();
this.updateUI();
$('#dm-preset-name').value = '';
});
// Preset apply/delete buttons (delegated)
this.container.addEventListener('click', (e) => {
// Apply preset
if (e.target.dataset.presetApply !== undefined) {
const idx = parseInt(e.target.dataset.presetApply);
const preset = DarkModer.settings.presets[idx];
if (preset) {
DarkModer.settings.theme = { ...preset.theme };
DarkModer.saveSettings();
DarkModer.apply();
this.updateUI();
}
}
// Delete preset
if (e.target.dataset.presetDelete !== undefined) {
const idx = parseInt(e.target.dataset.presetDelete);
if (confirm('Delete this preset?')) {
DarkModer.settings.presets.splice(idx, 1);
DarkModer.saveSettings();
this.updateUI();
}
}
});
// Export settings
$('#dm-export').addEventListener('click', () => {
const data = JSON.stringify(DarkModer.settings, null, 2);
const blob = new Blob([data], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'darkmoder-settings.json';
a.click();
URL.revokeObjectURL(url);
});
// Import settings
$('#dm-import').addEventListener('click', () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = (ev) => {
try {
const imported = JSON.parse(ev.target.result);
DarkModer.settings = { ...DEFAULT_SETTINGS, ...imported };
DarkModer.settings.theme = { ...DEFAULT_THEME, ...imported.theme };
DarkModer.saveSettings();
DarkModer.apply();
this.updateUI();
alert('Settings imported successfully!');
} catch (err) {
alert('Invalid settings file: ' + err.message);
}
};
reader.readAsText(file);
}
};
input.click();
});
// Export generated CSS
$('#dm-export-css').addEventListener('click', () => {
const css = DynamicEngine.exportCSS();
if (!css || css.trim().length < 100) {
alert('No CSS generated. Make sure Dynamic mode is active.');
return;
}
const blob = new Blob([css], { type: 'text/css' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `darkmoder-${window.location.hostname}.css`;
a.click();
URL.revokeObjectURL(url);
});
// Reset all settings
$('#dm-reset').addEventListener('click', () => {
if (confirm('Reset all settings to default? This cannot be undone.')) {
DarkModer.settings = { ...DEFAULT_SETTINGS };
DarkModer.settings.theme = { ...DEFAULT_THEME };
DarkModer.saveSettings();
DarkModer.apply();
this.updateUI();
}
});
// Close on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isOpen) {
this.close();
}
});
// Close when clicking outside
this.container.addEventListener('click', (e) => {
if (e.target === this.container) {
this.close();
}
});
},
/**
* Update theme from UI state
*/
updateTheme() {
const $ = (sel) => this.panel.querySelector(sel);
// Get current values from sliders
const brightness = parseInt($('#dm-brightness').value, 10);
const contrast = parseInt($('#dm-contrast').value, 10);
const sepia = parseInt($('#dm-sepia').value, 10);
const grayscale = parseInt($('#dm-grayscale').value, 10);
const textStroke = parseInt($('#dm-text-stroke').value, 10);
// Get colors
const bgColor = $('#dm-bg-color').value;
const textColor = $('#dm-text-color').value;
const selectionBg = $('#dm-selection-bg').value;
const selectionText = $('#dm-selection-text').value;
// Get mode
let mode = 1; // Dynamic default
if ($('#dm-mode-filter').classList.contains('active')) {
mode = 0;
} else if ($('#dm-mode-filter-plus').classList.contains('active')) {
mode = 2;
} else if ($('#dm-mode-static').classList.contains('active')) {
mode = 3;
}
// Get font settings
const useFont = $('#dm-use-font').checked;
const fontFamily = $('#dm-font-family').value.trim();
// Update theme object
DarkModer.settings.theme = {
...DarkModer.settings.theme,
mode,
brightness,
contrast,
sepia,
grayscale,
textStroke,
backgroundColor: bgColor,
textColor,
selectionColor: selectionBg,
selectionTextColor: selectionText,
useFont,
fontFamily: fontFamily || DEFAULT_THEME.fontFamily
};
// Save and apply
DarkModer.saveSettings();
DarkModer.apply();
},
/**
* Update UI controls from current settings
*/
updateUI() {
if (!this.panel) {
return;
}
const $ = (sel) => this.panel.querySelector(sel);
const settings = DarkModer.settings;
const theme = settings.theme;
// Update enabled state
const toggleBtn = $('#dm-toggle');
if (toggleBtn) {
toggleBtn.classList.toggle('active', settings.enabled);
toggleBtn.textContent = settings.enabled ? 'ON' : 'OFF';
}
// Update site toggle
const siteToggle = $('#dm-site-toggle');
if (siteToggle) {
const hostname = getURLHostname(location.href);
const disabledSites = settings.disabledSites || [];
const disabledFor = settings.disabledFor || [];
const isDisabled = disabledSites.includes(hostname) || disabledFor.includes(hostname);
siteToggle.classList.toggle('active', !isDisabled);
siteToggle.textContent = isDisabled ? 'DISABLED' : 'ENABLED';
}
// Update sliders
const sliderIds = [
'dm-brightness',
'dm-contrast',
'dm-sepia',
'dm-grayscale',
'dm-text-stroke'
];
const sliderProps = [
'brightness',
'contrast',
'sepia',
'grayscale',
'textStroke'
];
for (let i = 0; i < sliderIds.length; i++) {
const slider = $('#' + sliderIds[i]);
if (slider) {
slider.value = theme[sliderProps[i]];
const valueSpan = slider.parentElement.querySelector('.dm-slider-value');
if (valueSpan) {
valueSpan.textContent = theme[sliderProps[i]];
}
}
}
// Update color pickers
const bgColorInput = $('#dm-bg-color');
if (bgColorInput) {
bgColorInput.value = theme.backgroundColor;
}
const textColorInput = $('#dm-text-color');
if (textColorInput) {
textColorInput.value = theme.textColor;
}
const selBgInput = $('#dm-selection-bg');
if (selBgInput) {
selBgInput.value = theme.selectionColor;
}
const selTextInput = $('#dm-selection-text');
if (selTextInput) {
selTextInput.value = theme.selectionTextColor;
}
// Update mode buttons
const modeButtons = [
{ id: 'dm-mode-filter', mode: 0 },
{ id: 'dm-mode-dynamic', mode: 1 },
{ id: 'dm-mode-filter-plus', mode: 2 },
{ id: 'dm-mode-static', mode: 3 }
];
for (const btn of modeButtons) {
const el = $('#' + btn.id);
if (el) {
el.classList.toggle('active', theme.mode === btn.mode);
}
}
// Update font settings
const useFontCheck = $('#dm-use-font');
if (useFontCheck) {
useFontCheck.checked = theme.useFont;
}
const fontFamilyInput = $('#dm-font-family');
if (fontFamilyInput) {
fontFamilyInput.value = theme.fontFamily;
}
// Update automation settings
const autoMode = $('#dm-auto-mode');
if (autoMode) {
autoMode.value = settings.automation.mode || 'disabled';
}
const autoStart = $('#dm-auto-start');
if (autoStart) {
autoStart.value = settings.automation.startTime || settings.time?.activation || '18:00';
}
const autoEnd = $('#dm-auto-end');
if (autoEnd) {
autoEnd.value = settings.automation.endTime || settings.time?.deactivation || '09:00';
}
// Also update time inputs if they exist (alternative IDs)
const timeOn = $('#dm-time-on');
if (timeOn) {
timeOn.value = settings.time?.activation || settings.automation.startTime || '18:00';
}
const timeOff = $('#dm-time-off');
if (timeOff) {
timeOff.value = settings.time?.deactivation || settings.automation.endTime || '09:00';
}
// Update keyboard shortcuts
const shortcutToggle = $('#dm-shortcut-toggle');
if (shortcutToggle) {
shortcutToggle.value = settings.shortcuts.toggle;
}
const shortcutSite = $('#dm-shortcut-site');
if (shortcutSite) {
shortcutSite.value = settings.shortcuts.toggleSite;
}
const shortcutSettings = $('#dm-shortcut-settings');
if (shortcutSettings) {
shortcutSettings.value = settings.shortcuts.openSettings;
}
// Update color scheme selector
const schemeSelect = $('#dm-color-scheme');
if (schemeSelect) {
schemeSelect.value = settings.colorScheme || 'Default';
}
// Update theme presets list
this.updatePresetsList();
// Update disabled sites list
this.updateDisabledSitesList();
},
/**
* Update the presets dropdown
*/
updatePresetsList() {
const select = this.panel?.querySelector('#dm-preset-list');
if (!select) {
return;
}
// Clear existing options except placeholder
select.innerHTML = '';
// Add saved presets
const presets = DarkModer.settings.themePresets || {};
for (const name of Object.keys(presets)) {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
select.appendChild(option);
}
},
/**
* Update the disabled sites list
*/
updateDisabledSitesList() {
const container = this.panel?.querySelector('#dm-disabled-sites-list');
if (!container) {
return;
}
container.innerHTML = '';
// Combine both arrays for display
const disabledSites = DarkModer.settings.disabledSites || [];
const disabledFor = DarkModer.settings.disabledFor || [];
const sites = [...new Set([...disabledSites, ...disabledFor])];
if (sites.length === 0) {
container.innerHTML = 'No disabled sites
';
return;
}
for (const site of sites) {
const item = document.createElement('div');
item.className = 'dm-site-item';
item.innerHTML = `
${escapeHtml(site)}
`;
container.appendChild(item);
// Add remove handler
item.querySelector('.dm-site-remove').addEventListener('click', () => {
// Remove from both arrays
let idx = DarkModer.settings.disabledSites.indexOf(site);
if (idx !== -1) {
DarkModer.settings.disabledSites.splice(idx, 1);
}
idx = DarkModer.settings.disabledFor.indexOf(site);
if (idx !== -1) {
DarkModer.settings.disabledFor.splice(idx, 1);
}
DarkModer.saveSettings();
DarkModer.apply();
this.updateDisabledSitesList();
this.updateUI();
});
}
},
/**
* Open the settings panel
*/
open() {
if (!this.container) {
this.create();
}
this.updateUI();
this.container.classList.add('visible');
this.isOpen = true;
// Focus first input
requestAnimationFrame(() => {
const firstSlider = this.panel?.querySelector('input[type="range"]');
if (firstSlider) {
firstSlider.focus();
}
});
},
/**
* Close the settings panel
*/
close() {
if (this.container) {
this.container.classList.remove('visible');
}
this.isOpen = false;
},
/**
* Toggle the settings panel
*/
toggle() {
if (this.isOpen) {
this.close();
} else {
this.open();
}
},
/**
* Destroy the UI
*/
destroy() {
if (this.container) {
this.container.remove();
this.container = null;
this.panel = null;
}
this.isOpen = false;
}
};
// ==========================================================================
// DARKMODER MAIN CONTROLLER
// ==========================================================================
/**
* Main DarkModer controller
* Manages state, settings, and coordinates all subsystems
*/
const DarkModer = {
/** Current settings */
settings: null,
/** Is theme currently applied */
isApplied: false,
/** Site has native dark theme detected */
siteHasDarkTheme: false,
/** Current active engine */
activeEngine: null,
/** Provisional background style element */
provisionalStyle: null,
/** Automation interval ID */
automationInterval: null,
/** Remote config cache */
remoteConfig: null,
/** Site-specific settings cache */
currentSiteTheme: null,
/**
* Initialize DarkModer
*/
async init() {
// Load saved settings
await this.loadSettings();
// Apply provisional dark background immediately to prevent white flash
if (this.settings.enabled && this.shouldApply()) {
this.applyProvisional();
}
// Wait for DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', () => {
this.onDOMReady();
});
} else {
this.onDOMReady();
}
},
/**
* Called when DOM is ready
*/
async onDOMReady() {
// Setup dark theme monitoring
this.setupThemeMonitoring();
// Load remote configs
await this.loadRemoteConfigs();
// Apply theme if enabled
if (this.settings.enabled) {
this.apply();
}
// Setup keyboard shortcuts
this.setupKeyboardShortcuts();
// Setup automation
this.setupAutomationInterval();
// Register menu command
this.registerMenuCommand();
},
/**
* Load settings from storage
*/
async loadSettings() {
const saved = await Storage.get('darkmoder_settings');
if (saved) {
// Merge with defaults to ensure all keys exist
this.settings = deepMerge({ ...DEFAULT_SETTINGS }, saved);
this.settings.theme = deepMerge({ ...DEFAULT_THEME }, saved.theme || {});
} else {
// Use defaults
this.settings = { ...DEFAULT_SETTINGS };
this.settings.theme = { ...DEFAULT_THEME };
}
// Ensure arrays are always arrays (fix for older saved settings)
if (!Array.isArray(this.settings.disabledSites)) {
this.settings.disabledSites = [];
}
if (!Array.isArray(this.settings.disabledFor)) {
this.settings.disabledFor = [];
}
// Ensure objects are always objects
if (!this.settings.themePresets || typeof this.settings.themePresets !== 'object') {
this.settings.themePresets = {};
}
if (!this.settings.siteSettings || typeof this.settings.siteSettings !== 'object') {
this.settings.siteSettings = {};
}
if (!this.settings.automation || typeof this.settings.automation !== 'object') {
this.settings.automation = { ...DEFAULT_SETTINGS.automation };
}
if (!this.settings.shortcuts || typeof this.settings.shortcuts !== 'object') {
this.settings.shortcuts = { ...DEFAULT_SETTINGS.shortcuts };
}
// Load site-specific theme if exists
const hostname = getURLHostname(location.href);
if (this.settings.siteSettings && this.settings.siteSettings[hostname]) {
this.currentSiteTheme = this.settings.siteSettings[hostname];
}
},
/**
* Save settings to storage
*/
async saveSettings() {
await Storage.set('darkmoder_settings', this.settings);
},
/**
* Setup keyboard shortcut listener
*/
setupKeyboardShortcuts() {
document.addEventListener('keydown', (e) => {
// Don't trigger in input fields
const target = e.target;
const tagName = target.tagName.toLowerCase();
if (tagName === 'input' || tagName === 'textarea' || target.isContentEditable) {
return;
}
const shortcuts = this.settings.shortcuts;
// Check toggle shortcut
if (shortcuts.toggle && matchShortcut(e, parseShortcut(shortcuts.toggle))) {
e.preventDefault();
this.toggle();
return;
}
// Check toggle site shortcut
if (shortcuts.toggleSite && matchShortcut(e, parseShortcut(shortcuts.toggleSite))) {
e.preventDefault();
this.toggleSite();
return;
}
// Check open settings shortcut
if (shortcuts.openSettings && matchShortcut(e, parseShortcut(shortcuts.openSettings))) {
e.preventDefault();
UI.toggle();
return;
}
});
},
/**
* Setup automation check interval
*/
setupAutomationInterval() {
// Clear existing interval
if (this.automationInterval) {
clearInterval(this.automationInterval);
this.automationInterval = null;
}
// Only setup if automation is enabled
const mode = this.settings.automation?.mode || 'disabled';
if (mode === 'disabled' || mode === '') {
return;
}
// Check every minute
this.automationInterval = setInterval(() => {
this.checkAutomation();
}, 60000);
// Also check immediately
this.checkAutomation();
},
/**
* Check automation rules and apply/remove theme accordingly
*/
checkAutomation() {
const mode = this.settings.automation?.mode || 'disabled';
if (mode === 'disabled' || mode === '') {
return;
}
let shouldEnable = false;
if (mode === 'system') {
// Follow system dark mode preference
shouldEnable = window.matchMedia('(prefers-color-scheme: dark)').matches;
} else if (mode === 'time') {
// Time-based automation
const now = new Date();
const currentMinutes = now.getHours() * 60 + now.getMinutes();
// Get times from either automation or time settings
const startTime = this.settings.automation?.startTime || this.settings.time?.activation || '18:00';
const endTime = this.settings.automation?.endTime || this.settings.time?.deactivation || '09:00';
const startParts = startTime.split(':');
const startMinutes = parseInt(startParts[0], 10) * 60 + parseInt(startParts[1], 10);
const endParts = endTime.split(':');
const endMinutes = parseInt(endParts[0], 10) * 60 + parseInt(endParts[1], 10);
if (startMinutes < endMinutes) {
// Normal range (e.g., 9:00 to 17:00)
shouldEnable = currentMinutes >= startMinutes && currentMinutes < endMinutes;
} else {
// Overnight range (e.g., 20:00 to 06:00)
shouldEnable = currentMinutes >= startMinutes || currentMinutes < endMinutes;
}
} else if (mode === 'location') {
// Sunrise/sunset based on location
const lat = this.settings.automation?.latitude || this.settings.location?.latitude;
const lon = this.settings.automation?.longitude || this.settings.location?.longitude;
if (lat !== null && lon !== null) {
const times = SunCalc.getTimes(new Date(), lat, lon);
const now = new Date();
const sunrise = times.sunrise;
const sunset = times.sunset;
// Enable dark mode between sunset and sunrise
shouldEnable = now < sunrise || now >= sunset;
}
}
// Apply or remove based on automation result
if (shouldEnable && !this.settings.enabled) {
this.settings.enabled = true;
this.saveSettings();
this.apply();
} else if (!shouldEnable && this.settings.enabled) {
this.settings.enabled = false;
this.saveSettings();
this.remove();
}
},
/**
* Apply provisional dark background before full processing
* Prevents white flash during page load
*/
applyProvisional() {
if (this.provisionalStyle) {
return; // Already applied
}
const bgColor = this.getEffectiveTheme().backgroundColor;
this.provisionalStyle = document.createElement('style');
this.provisionalStyle.id = 'darkmoder-provisional';
this.provisionalStyle.textContent = `
html, body {
background-color: ${bgColor} !important;
}
`;
// Insert as early as possible
const target = document.head || document.documentElement;
if (target) {
target.insertBefore(this.provisionalStyle, target.firstChild);
}
},
/**
* Remove provisional background
*/
removeProvisional() {
if (this.provisionalStyle) {
this.provisionalStyle.remove();
this.provisionalStyle = null;
}
},
/**
* Setup dark theme monitoring for early detection
* Uses DarkThemeDetector's two-phase approach
*/
setupThemeMonitoring() {
// Skip if detection is disabled
if (!this.settings.detectDarkTheme) {
this.siteHasDarkTheme = false;
return;
}
// Phase 1: Early detection (immediate)
const earlyResult = DarkThemeDetector.detectEarly();
if (earlyResult.isDark) {
// Site already has dark theme, skip our processing
this.siteHasDarkTheme = true;
if (CONFIG.debugMode) {
console.log('[DarkModer] Early dark theme detected, skipping');
}
this.removeProvisional();
return;
}
// Phase 2: Full detection (after page loads)
requestIdleCallback(() => {
const fullResult = DarkThemeDetector.detect();
if (fullResult.isDark) {
this.siteHasDarkTheme = true;
if (CONFIG.debugMode) {
console.log('[DarkModer] Dark theme detected in full scan, removing');
}
this.remove();
return;
}
this.siteHasDarkTheme = false;
// Setup observer for dynamic theme changes
DarkThemeDetector.observe((isDark) => {
if (isDark && this.isApplied) {
this.siteHasDarkTheme = true;
if (CONFIG.debugMode) {
console.log('[DarkModer] Dynamic dark theme detected, removing');
}
this.remove();
} else if (!isDark && !this.isApplied && this.settings.enabled) {
this.siteHasDarkTheme = false;
this.apply();
}
});
}, { timeout: 1000 });
},
/**
* Load remote configuration files
*/
async loadRemoteConfigs() {
try {
// Load site fixes
const siteFixes = await ConfigLoader.fetch(
'site-fixes',
'https://raw.githubusercontent.com/nickshanks/darkmoder/main/site-fixes.txt'
);
if (siteFixes) {
const parsed = SiteFixesProcessor.parseFixes(siteFixes);
this.remoteConfig = {
siteFixes: parsed
};
}
} catch (e) {
// Silent fail - use built-in configs
if (CONFIG.debugMode) {
console.log('[DarkModer] Failed to load remote configs:', e);
}
}
},
/**
* Register Tampermonkey/Greasemonkey menu command
*/
registerMenuCommand() {
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('DarkModer Settings', () => {
UI.toggle();
});
}
},
/**
* Get effective theme (considering site-specific overrides)
*/
getEffectiveTheme() {
const baseTheme = this.settings.theme;
// Check for site-specific settings
if (this.currentSiteTheme) {
return {
...baseTheme,
...this.currentSiteTheme
};
}
return baseTheme;
},
/**
* Check if theme should be applied to current site
*/
shouldApply() {
// Skip if site has native dark theme
if (this.siteHasDarkTheme && this.settings.detectDarkTheme) {
return false;
}
const hostname = getURLHostname(location.href);
// Check if site is in disabled list (check both arrays for compatibility)
const disabledSites = this.settings.disabledSites || [];
const disabledFor = this.settings.disabledFor || [];
const allDisabled = [...disabledSites, ...disabledFor];
if (allDisabled.includes(hostname)) {
return false;
}
// Check for pattern matches in disabled list
for (const pattern of allDisabled) {
if (isURLMatched(location.href, pattern)) {
return false;
}
}
// Check if site has built-in dark mode
if (BUILT_IN_DARK_SITES.some(pattern => isURLMatched(location.href, pattern))) {
return false;
}
return true;
},
/**
* Apply the dark theme
*/
apply() {
// Check if we should apply
if (!this.settings.enabled) {
this.remove();
return;
}
if (!this.shouldApply()) {
if (CONFIG.debugMode) {
if (this.siteHasDarkTheme) {
console.log('[DarkModer] Skipping - site has native dark theme');
} else {
console.log('[DarkModer] Skipping - site in disabled list or built-in dark');
}
}
this.remove();
return;
}
// Get effective theme with site-specific overrides
const theme = this.getEffectiveTheme();
// Remove any existing engine
if (this.activeEngine) {
this.activeEngine.remove();
this.activeEngine = null;
}
// Remove provisional background
this.removeProvisional();
// Get site fix if available
const hostname = getURLHostname(location.href);
let siteFix = null;
// Check remote configs
if (this.remoteConfig?.siteFixes?.[hostname]) {
siteFix = this.remoteConfig.siteFixes[hostname];
}
// Check built-in fixes
const builtInFix = ConfigLoader.getSiteFix(hostname);
if (builtInFix) {
siteFix = siteFix ? { ...siteFix, ...builtInFix } : builtInFix;
}
// Select and apply engine based on mode
switch (theme.mode) {
case 0:
// Filter mode
this.activeEngine = FilterEngine;
break;
case 1:
// Dynamic mode (default)
this.activeEngine = DynamicEngine;
break;
case 2:
// Filter+ mode
this.activeEngine = FilterPlusEngine;
break;
case 3:
// Static mode
this.activeEngine = StaticEngine;
break;
default:
this.activeEngine = DynamicEngine;
}
// Apply the engine
this.activeEngine.apply(theme, siteFix);
this.isApplied = true;
if (CONFIG.debugMode) {
console.log('[DarkModer] Theme applied with mode:', theme.mode);
}
},
/**
* Remove the dark theme
*/
remove() {
// Remove active engine
if (this.activeEngine) {
this.activeEngine.remove();
this.activeEngine = null;
}
// Remove provisional background
this.removeProvisional();
this.isApplied = false;
if (CONFIG.debugMode) {
console.log('[DarkModer] Theme removed');
}
},
/**
* Toggle dark mode on/off
*/
toggle() {
this.settings.enabled = !this.settings.enabled;
this.saveSettings();
if (this.settings.enabled) {
this.apply();
} else {
this.remove();
}
// Update UI if open
if (UI.isOpen) {
UI.updateUI();
}
},
/**
* Toggle dark mode for current site
*/
toggleSite() {
const hostname = getURLHostname(location.href);
// Check both arrays
const inDisabledSites = (this.settings.disabledSites || []).indexOf(hostname);
const inDisabledFor = (this.settings.disabledFor || []).indexOf(hostname);
const isCurrentlyDisabled = inDisabledSites !== -1 || inDisabledFor !== -1;
if (!isCurrentlyDisabled) {
// Add to disabledFor (primary array for UI additions)
if (!this.settings.disabledFor) {
this.settings.disabledFor = [];
}
this.settings.disabledFor.push(hostname);
this.remove();
} else {
// Remove from both arrays
if (inDisabledSites !== -1) {
this.settings.disabledSites.splice(inDisabledSites, 1);
}
if (inDisabledFor !== -1) {
this.settings.disabledFor.splice(inDisabledFor, 1);
}
if (this.settings.enabled) {
this.apply();
}
}
this.saveSettings();
// Update UI if open
if (UI.isOpen) {
UI.updateUI();
}
},
/**
* Reset all settings to defaults
*/
resetSettings() {
this.settings = { ...DEFAULT_SETTINGS };
this.settings.theme = { ...DEFAULT_THEME };
this.saveSettings();
// Re-apply with default settings
if (this.settings.enabled) {
this.apply();
}
// Update UI if open
if (UI.isOpen) {
UI.updateUI();
}
},
/**
* Export settings as JSON
*/
exportSettings() {
return JSON.stringify(this.settings, null, 2);
},
/**
* Import settings from JSON
*/
importSettings(json) {
try {
const imported = JSON.parse(json);
// Validate basic structure
if (typeof imported !== 'object' || imported === null) {
throw new Error('Invalid settings format');
}
// Merge with defaults
this.settings = deepMerge({ ...DEFAULT_SETTINGS }, imported);
this.settings.theme = deepMerge({ ...DEFAULT_THEME }, imported.theme || {});
this.saveSettings();
// Re-apply
if (this.settings.enabled) {
this.apply();
} else {
this.remove();
}
// Update UI if open
if (UI.isOpen) {
UI.updateUI();
}
return true;
} catch (e) {
console.error('[DarkModer] Failed to import settings:', e);
return false;
}
},
/**
* Get the generated CSS (for Dynamic mode)
*/
getGeneratedCSS() {
if (this.activeEngine === DynamicEngine) {
return DynamicEngine.exportCSS();
}
return '';
},
/**
* Apply a color scheme preset
*/
applyColorScheme(schemeName) {
const scheme = COLOR_SCHEMES[schemeName];
if (!scheme) {
return false;
}
// Apply scheme colors to theme
this.settings.theme.backgroundColor = scheme.background;
this.settings.theme.textColor = scheme.text;
this.settings.theme.selectionColor = scheme.selection;
this.settings.theme.selectionTextColor = scheme.selectionText;
this.settings.colorScheme = schemeName;
this.saveSettings();
this.apply();
return true;
},
/**
* Save current theme as preset
*/
saveThemePreset(name) {
if (!name || name.trim().length === 0) {
return false;
}
if (!this.settings.themePresets) {
this.settings.themePresets = {};
}
this.settings.themePresets[name] = { ...this.settings.theme };
this.saveSettings();
return true;
},
/**
* Apply a saved theme preset
*/
applyThemePreset(name) {
const preset = this.settings.themePresets?.[name];
if (!preset) {
return false;
}
this.settings.theme = deepMerge({ ...DEFAULT_THEME }, preset);
this.saveSettings();
this.apply();
return true;
},
/**
* Delete a theme preset
*/
deleteThemePreset(name) {
if (this.settings.themePresets?.[name]) {
delete this.settings.themePresets[name];
this.saveSettings();
return true;
}
return false;
},
/**
* Save site-specific settings
*/
saveSiteSettings(hostname, siteTheme) {
if (!this.settings.siteSettings) {
this.settings.siteSettings = {};
}
this.settings.siteSettings[hostname] = siteTheme;
this.currentSiteTheme = siteTheme;
this.saveSettings();
this.apply();
},
/**
* Clear site-specific settings
*/
clearSiteSettings(hostname) {
if (this.settings.siteSettings?.[hostname]) {
delete this.settings.siteSettings[hostname];
if (hostname === getURLHostname(location.href)) {
this.currentSiteTheme = null;
}
this.saveSettings();
this.apply();
}
}
};
// ==========================================================================
// INITIALIZATION
// ==========================================================================
/**
* requestIdleCallback polyfill for browsers that don't support it
*/
if (typeof window.requestIdleCallback !== 'function') {
window.requestIdleCallback = function(callback, options) {
const start = Date.now();
return setTimeout(function() {
callback({
didTimeout: false,
timeRemaining: function() {
return Math.max(0, 50 - (Date.now() - start));
}
});
}, options?.timeout || 1);
};
}
if (typeof window.cancelIdleCallback !== 'function') {
window.cancelIdleCallback = function(id) {
clearTimeout(id);
};
}
/**
* Start DarkModer
* Uses immediate execution to prevent any flash of unstyled content
*/
(function startDarkModer() {
// Ensure we're running in a valid context
if (typeof window === 'undefined' || typeof document === 'undefined') {
return;
}
// Don't run in iframes by default (can be changed via CONFIG)
if (window !== window.top && !CONFIG.applyToIframes) {
return;
}
// Don't run on about:blank or similar
if (location.protocol === 'about:' || location.protocol === 'data:') {
return;
}
// Initialize DarkModer
DarkModer.init().catch((err) => {
console.error('[DarkModer] Initialization error:', err);
});
// Expose for debugging if debug mode is enabled
if (CONFIG.debugMode) {
unsafeWindow.DarkModer = DarkModer;
unsafeWindow.DarkModerUI = UI;
unsafeWindow.DarkModerConfig = CONFIG;
}
})();
})();